diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-collectors.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-collectors.test.ts index b8bf1e35c..59dbb1c78 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-collectors.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-collectors.test.ts @@ -18,10 +18,15 @@ import { collectNotionReconciliation, buildPersonIdentityLookup, buildPersonIdentityPage, + buildRipplingWorkerPage, + buildRipplingWorkersRequest, + buildUnavailableRipplingWorkerPage, buildSlackDirectoryPersonPage, groupSlackMessagesIntoDayPages, isSlackDirectoryRefreshDue, isSlackHumanProfile, + parseRipplingSnapshotCursor, + parseRipplingWorkersResponse, runBrainCollectors, selectPersonIdentityBatch, slackDirectoryPageUserIds, @@ -1387,6 +1392,151 @@ describe('person identity pages', () => { }); }); +describe('Rippling worker pages', () => { + const worker = { + id: 'worker-ada', + status: 'ACTIVE', + work_email: 'ada@example.com', + title: 'Principal Engineer', + start_date: '2024-01-15', + user: { + name: { display_name: 'Ada Lovelace' }, + number: 'E-1001', + timezone: 'America/Los_Angeles', + }, + manager_id: 'worker-grace', + manager: { + id: 'worker-grace', + work_email: 'grace@example.com', + user: { name: { display_name: 'Grace Hopper' } }, + }, + department_id: 'department-engineering', + department: { id: 'department-engineering', name: 'Engineering' }, + teams: [{ id: 'team-platform', name: 'Platform' }], + employment_type: { type: 'EMPLOYEE', label: 'Full time' }, + location: { type: 'REMOTE' }, + }; + + it('projects authoritative employment, reporting, membership, and freshness data', () => { + const page = buildRipplingWorkerPage({ + worker, + observedAt: new Date('2026-08-17T12:30:00Z'), + snapshotStartedAt: new Date('2026-08-17T12:00:00Z'), + }); + + expect(page?.slug).toMatch(/^people\/rippling-worker-[a-f0-9]{16}$/); + expect(page?.content).toContain('type: person'); + expect(page?.content).toContain('source_authority: authoritative-hris'); + expect(page?.content).toContain('work_email: "ada@example.com"'); + expect(page?.content).toContain('employee_number: "E-1001"'); + expect(page?.content).toContain('rippling_manager_id: "worker-grace"'); + expect(page?.content).toContain('job_title: "Principal Engineer"'); + expect(page?.content).toContain('employment_type: "Full time"'); + expect(page?.content).toContain('timezone: "America/Los_Angeles"'); + expect(page?.content).toContain('start_date: "2024-01-15"'); + expect(page?.content).toContain('reports_to: "people/rippling-worker-'); + expect(page?.content).toContain('"type":"department"'); + expect(page?.content).toContain('"type":"team"'); + expect(page?.content).toContain( + 'Reporting and membership fields above come directly from Rippling HRIS', + ); + }); + + it('links matching work emails to the canonical Roomote person', () => { + const page = buildRipplingWorkerPage({ + worker, + observedAt: new Date('2026-08-17T12:30:00Z'), + snapshotStartedAt: new Date('2026-08-17T12:00:00Z'), + identities: new Map([ + [ + 'ada@example.com', + { slug: 'people/roomote-member-ada', title: 'Ada Lovelace' }, + ], + [ + 'grace@example.com', + { slug: 'people/roomote-member-grace', title: 'Grace Hopper' }, + ], + ]), + }); + + expect(page?.content).toContain('type: person-alias'); + expect(page?.content).toContain('canonical: "people/roomote-member-ada"'); + expect(page?.content).toContain( + 'reports_to: "people/roomote-member-grace"', + ); + }); + + it('preserves Rippling termination state while removing active aliases', () => { + const page = buildRipplingWorkerPage({ + worker: { ...worker, status: 'TERMINATED', end_date: '2026-08-01' }, + observedAt: new Date('2026-08-17T12:30:00Z'), + snapshotStartedAt: new Date('2026-08-17T12:00:00Z'), + }); + + expect(page?.content).toContain('status: inactive'); + expect(page?.content).toContain('source_status: "TERMINATED"'); + expect(page?.content).toContain('end_date: "2026-08-01"'); + expect(page?.content).toContain('aliases: []'); + }); + + it('preserves the opaque next page link for a resumable snapshot', () => { + expect( + parseRipplingWorkersResponse({ + results: [worker], + next_link: + 'https://rest.ripplingapis.com/workers/?cursor=opaque-next-page', + }), + ).toEqual({ + workers: [worker], + nextLink: + 'https://rest.ripplingapis.com/workers/?cursor=opaque-next-page', + }); + expect( + parseRipplingSnapshotCursor( + JSON.stringify({ + mode: 'scan', + startedAt: '2026-08-17T12:00:00.000Z', + nextLink: + 'https://rest.ripplingapis.com/workers/?cursor=opaque-next-page', + }), + ), + ).toMatchObject({ mode: 'scan', startedAt: '2026-08-17T12:00:00.000Z' }); + }); + + it('reapplies relationship expansions to cursor-only continuation links', () => { + expect( + buildRipplingWorkersRequest( + 'https://rest.ripplingapis.com/workers/?cursor=opaque-next-page', + 100, + ), + ).toEqual({ + pathOrUrl: + 'https://rest.ripplingapis.com/workers/?cursor=opaque-next-page', + query: { + expand: 'user,manager,manager.user,department,employment_type,teams', + }, + }); + }); + + it('rejects malformed roster pages before reconciliation can advance', () => { + expect(() => + parseRipplingWorkersResponse({ results: [{ status: 'ACTIVE' }] }), + ).toThrow('invalid worker at index 0'); + }); + + it('turns workers missing from a complete snapshot into unavailable tombstones', () => { + const page = buildUnavailableRipplingWorkerPage({ + itemId: 'worker-former', + slug: 'people/rippling-worker-former', + }); + + expect(page.slug).toBe('people/rippling-worker-former'); + expect(page.content).toContain('status: unavailable'); + expect(page.content).toContain('aliases: []'); + expect(page.content).toContain('rippling_worker_id: "worker-former"'); + }); +}); + describe('backfill reaches channels joined later', () => { const cursorOf = (state: { completed: string[]; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts index 12c3d05f9..f1e1ac077 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts @@ -33,12 +33,15 @@ import { NotionApiError, notionApiRequestJson, } from '@roomote/sdk/server/notion-api'; +import { ripplingApiRequestJson } from '@roomote/sdk/server/rippling-api'; import { createSlackWebClient } from '@roomote/slack'; import { isMcpConnectionGranolaConfig, isMcpConnectionNotionConfig, + isMcpConnectionRipplingConfig, type McpConnectionGranolaConfig, type McpConnectionNotionConfig, + type McpConnectionRipplingConfig, } from '@roomote/types'; import { @@ -2158,6 +2161,592 @@ function slugifySegment(value: string): string { .replace(/-+$/g, ''); } +/** + * Rippling: authoritative employee directory + * ------------------------------------------- + * + * Rippling's V2 workers endpoint is cursor paginated but Worker Changes is a + * separately entitled API product. Use complete, resumable snapshots here so + * every installation gets correct lifecycle handling without assuming that + * optional entitlement. Reconciliation starts only after the final page. + */ + +const RIPPLING_WORKERS_COLLECTOR_ID = 'rippling-workers'; +const RIPPLING_SNAPSHOT_STATE_ID = `${RIPPLING_WORKERS_COLLECTOR_ID}:snapshot`; +const RIPPLING_WORKER_EXPANSIONS = + 'user,manager,manager.user,department,employment_type,teams'; + +type RipplingObject = Record; + +type RipplingWorker = RipplingObject & { + id?: unknown; + status?: unknown; + work_email?: unknown; + user?: unknown; + manager_id?: unknown; + manager?: unknown; + title?: unknown; + department_id?: unknown; + department?: unknown; + teams?: unknown; + employment_type_id?: unknown; + employment_type?: unknown; + location?: unknown; + start_date?: unknown; + end_date?: unknown; +}; + +type RipplingWorkersResponse = { + results?: unknown; + next_link?: unknown; +}; + +type RipplingSnapshotCursor = + | { mode: 'idle'; lastCompletedAt: string | null } + | { mode: 'scan'; startedAt: string; nextLink: string | null } + | { mode: 'reconcile'; startedAt: string }; + +function asObject(value: unknown): RipplingObject | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as RipplingObject) + : null; +} + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + const resolved = asString(value); + if (resolved) return resolved; + } + return null; +} + +function ripplingDisplayName(worker: RipplingWorker): string { + const user = asObject(worker.user); + const name = asObject(user?.name); + const displayName = firstString( + name?.display_name, + name?.preferred_name, + user?.display_name, + ); + if (displayName) return brainSafeIdentityValue(displayName); + + const joined = [ + firstString(name?.given_name, user?.given_name), + firstString(name?.family_name, user?.family_name), + ] + .filter(Boolean) + .join(' '); + return brainSafeIdentityValue(joined) || 'Rippling worker'; +} + +function ripplingWorkEmail(worker: RipplingWorker): string | null { + return firstString(worker.work_email, asObject(worker.user)?.work_email); +} + +function ripplingWorkerId(worker: RipplingWorker): string | null { + return firstString(worker.id); +} + +function ripplingWorkerSlug(workerId: string): string { + const digest = createHash('sha256') + .update(workerId) + .digest('hex') + .slice(0, 16); + return `people/rippling-worker-${digest}`; +} + +type RipplingMembership = { + type: 'department' | 'team'; + id: string | null; + name: string; +}; + +function ripplingNamedObject( + value: unknown, + fallbackId: unknown, +): { id: string | null; name: string } | null { + const object = asObject(value); + const id = firstString(object?.id, fallbackId); + const name = firstString(object?.name, object?.label, object?.display_name); + if (!name && !id) return null; + return { id, name: brainSafeIdentityValue(name ?? id ?? '') }; +} + +function ripplingMemberships(worker: RipplingWorker): RipplingMembership[] { + const memberships: RipplingMembership[] = []; + const department = ripplingNamedObject( + worker.department, + worker.department_id, + ); + if (department?.name) { + memberships.push({ type: 'department', ...department }); + } + + if (Array.isArray(worker.teams)) { + for (const teamValue of worker.teams) { + const team = ripplingNamedObject(teamValue, null); + if (team?.name) memberships.push({ type: 'team', ...team }); + } + } + + return memberships.sort( + (a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name), + ); +} + +function ripplingEmploymentType(worker: RipplingWorker): string | null { + const employmentType = asObject(worker.employment_type); + return firstString( + employmentType?.label, + employmentType?.name, + employmentType?.type, + worker.employment_type_id, + ); +} + +function ripplingLocation(worker: RipplingWorker): string | null { + const location = asObject(worker.location); + if (!location) return firstString(worker.location); + + const type = firstString(location.type); + const name = firstString( + location.name, + location.label, + location.work_location_name, + location.work_location_id, + ); + return ( + [type, name] + .filter(Boolean) + .map((value) => brainSafeIdentityValue(value!)) + .join(' — ') || null + ); +} + +export function parseRipplingWorkersResponse(payload: unknown): { + workers: RipplingWorker[]; + nextLink: string | null; +} { + const response = asObject(payload) as RipplingWorkersResponse | null; + if (!response || !Array.isArray(response.results)) { + throw new Error( + 'Rippling workers response did not contain a results array', + ); + } + + const workers = response.results.map((worker, index) => { + const object = asObject(worker) as RipplingWorker | null; + if (!object || !ripplingWorkerId(object)) { + throw new Error( + `Rippling workers response contained an invalid worker at index ${index}`, + ); + } + return object; + }); + + return { + workers, + nextLink: firstString(response.next_link), + }; +} + +export function parseRipplingSnapshotCursor( + value: string | null, +): RipplingSnapshotCursor { + if (!value) return { mode: 'idle', lastCompletedAt: null }; + try { + const parsed = JSON.parse(value) as Partial; + if ( + parsed.mode === 'scan' && + firstString(parsed.startedAt) && + (parsed.nextLink === null || typeof parsed.nextLink === 'string') + ) { + return { + mode: 'scan', + startedAt: parsed.startedAt!, + nextLink: parsed.nextLink ?? null, + }; + } + if (parsed.mode === 'reconcile' && firstString(parsed.startedAt)) { + return { mode: 'reconcile', startedAt: parsed.startedAt! }; + } + if ( + parsed.mode === 'idle' && + (parsed.lastCompletedAt === null || + typeof parsed.lastCompletedAt === 'string') + ) { + return { + mode: 'idle', + lastCompletedAt: parsed.lastCompletedAt ?? null, + }; + } + } catch { + // A malformed cursor safely restarts a complete snapshot. + } + return { mode: 'idle', lastCompletedAt: null }; +} + +function serializeRipplingSnapshotCursor( + cursor: RipplingSnapshotCursor, +): string { + return JSON.stringify(cursor); +} + +type RipplingPersonReference = { + slug: string; + title: string; +}; + +function ripplingManagerReference( + worker: RipplingWorker, + identities: Map, +): RipplingPersonReference | null { + const manager = asObject(worker.manager) as RipplingWorker | null; + const managerId = firstString(worker.manager_id, manager?.id); + if (!managerId) return null; + + const managerEmail = manager ? ripplingWorkEmail(manager) : null; + const canonical = managerEmail + ? identities.get(normalizeIdentityAlias(managerEmail)) + : null; + return canonical + ? { slug: canonical.slug, title: canonical.title } + : { + slug: ripplingWorkerSlug(managerId), + title: manager ? ripplingDisplayName(manager) : 'Manager', + }; +} + +export function buildRipplingWorkerPage(input: { + worker: RipplingWorker; + observedAt: Date; + snapshotStartedAt: Date; + identities?: Map; +}): CollectorPage | null { + const workerId = ripplingWorkerId(input.worker); + if (!workerId) return null; + + const identities = input.identities ?? new Map(); + const name = ripplingDisplayName(input.worker); + const workEmail = ripplingWorkEmail(input.worker); + const canonical = workEmail + ? identities.get(normalizeIdentityAlias(workEmail)) + : null; + const manager = ripplingManagerReference(input.worker, identities); + const managerId = firstString( + input.worker.manager_id, + asObject(input.worker.manager)?.id, + ); + const memberships = ripplingMemberships(input.worker); + const exactStatus = firstString(input.worker.status) ?? 'UNKNOWN'; + const active = exactStatus === 'ACTIVE'; + const title = firstString(input.worker.title); + const employmentType = ripplingEmploymentType(input.worker); + const location = ripplingLocation(input.worker); + const timezone = firstString(asObject(input.worker.user)?.timezone); + const startDate = firstString(input.worker.start_date); + const endDate = firstString(input.worker.end_date); + const employeeNumber = firstString(asObject(input.worker.user)?.number); + const aliases = [name, workEmail, workerId].filter(Boolean); + + return { + slug: ripplingWorkerSlug(workerId), + title: name, + content: [ + '---', + `type: ${canonical ? 'person-alias' : 'person'}`, + `aliases: ${JSON.stringify(active ? aliases : [])}`, + `status: ${active ? 'active' : 'inactive'}`, + `source_status: ${JSON.stringify(exactStatus)}`, + `rippling_worker_id: ${JSON.stringify(workerId)}`, + ...(employeeNumber + ? [`employee_number: ${JSON.stringify(employeeNumber)}`] + : []), + ...(managerId + ? [`rippling_manager_id: ${JSON.stringify(managerId)}`] + : []), + `source_authority: authoritative-hris`, + `provenance: rippling-hris`, + `observed_at: ${input.observedAt.toISOString()}`, + `snapshot_started_at: ${input.snapshotStartedAt.toISOString()}`, + ...(canonical ? [`canonical: ${JSON.stringify(canonical.slug)}`] : []), + ...(workEmail ? [`work_email: ${JSON.stringify(workEmail)}`] : []), + ...(title ? [`job_title: ${JSON.stringify(title)}`] : []), + ...(employmentType + ? [`employment_type: ${JSON.stringify(employmentType)}`] + : []), + ...(location ? [`location: ${JSON.stringify(location)}`] : []), + ...(timezone ? [`timezone: ${JSON.stringify(timezone)}`] : []), + ...(startDate ? [`start_date: ${JSON.stringify(startDate)}`] : []), + ...(endDate ? [`end_date: ${JSON.stringify(endDate)}`] : []), + ...(manager ? [`reports_to: ${JSON.stringify(manager.slug)}`] : []), + `authoritative_memberships: ${JSON.stringify(memberships)}`, + '---', + '', + `# ${name}`, + '', + ...(canonical + ? [`Rippling identity for [${canonical.title}](${canonical.slug}).`, ''] + : []), + '## Employment', + '', + `- Rippling employee ID: ${workerId}`, + ...(employeeNumber ? [`- Employee number: ${employeeNumber}`] : []), + `- Status: ${exactStatus}`, + ...(workEmail ? [`- Work email: ${workEmail}`] : []), + ...(title ? [`- Title: ${brainSafeIdentityValue(title)}`] : []), + ...(employmentType + ? [`- Employment type: ${brainSafeIdentityValue(employmentType)}`] + : []), + ...(location ? [`- Location: ${brainSafeIdentityValue(location)}`] : []), + ...(timezone ? [`- Time zone: ${brainSafeIdentityValue(timezone)}`] : []), + ...(startDate ? [`- Start date: ${startDate}`] : []), + ...(endDate ? [`- End date: ${endDate}`] : []), + '', + '## Authoritative organization data', + '', + ...(manager + ? [`- Reports to: [${manager.title}](${manager.slug})`] + : ['- Reports to: not provided']), + ...(memberships.length > 0 + ? memberships.map( + (membership) => + `- ${membership.type === 'department' ? 'Department' : 'Team'}: ${membership.name}`, + ) + : ['- Memberships: none provided']), + '', + '_Reporting and membership fields above come directly from Rippling HRIS. Collaboration-derived relationships elsewhere in Brain are inferred signals, not replacements for this source._', + '', + ].join('\n'), + }; +} + +export function buildUnavailableRipplingWorkerPage(item: { + itemId: string; + slug: string; +}): CollectorPage { + return { + slug: item.slug, + title: 'Unavailable Rippling worker', + content: [ + '---', + 'type: person', + 'aliases: []', + 'status: unavailable', + `rippling_worker_id: ${JSON.stringify(item.itemId)}`, + 'source_authority: authoritative-hris', + 'provenance: rippling-hris', + '---', + '', + '# Unavailable Rippling worker', + '', + 'This worker was absent from the latest complete Rippling roster snapshot or the integration was disconnected.', + '', + ].join('\n'), + }; +} + +async function findRipplingConnectionConfig(): Promise { + const [connection, enablement] = await Promise.all([ + db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'rippling'), + isNull(mcpConnections.userId), + eq(mcpConnections.enabled, true), + eq(mcpConnections.authStatus, 'authenticated'), + ), + }), + db.query.deploymentMcpEnablements.findFirst({ + where: and( + eq(deploymentMcpEnablements.mcpId, 'rippling'), + eq(deploymentMcpEnablements.enabled, true), + ), + columns: { mcpId: true }, + }), + ]); + + return enablement && isMcpConnectionRipplingConfig(connection?.authConfig) + ? connection.authConfig + : null; +} + +async function collectRipplingReconciliation( + startedAt: Date, + limit: number, +): Promise { + const stale = await listBrainCollectorItemsBefore( + db, + RIPPLING_WORKERS_COLLECTOR_ID, + startedAt, + limit + 1, + ); + const batch = stale.slice(0, limit); + const complete = stale.length <= limit; + + return { + pages: batch.map(buildUnavailableRipplingWorkerPage), + nextSince: complete ? startedAt : null, + itemDeletes: [ + { + collectorId: RIPPLING_WORKERS_COLLECTOR_ID, + itemIds: batch.map((item) => item.itemId), + }, + ], + stateUpdates: [ + { + collectorId: RIPPLING_SNAPSHOT_STATE_ID, + cursor: serializeRipplingSnapshotCursor( + complete + ? { mode: 'idle', lastCompletedAt: startedAt.toISOString() } + : { mode: 'reconcile', startedAt: startedAt.toISOString() }, + ), + }, + ], + }; +} + +export function buildRipplingWorkersRequest( + nextLink: string | null, + limit: number, +): { + pathOrUrl: string; + query: { expand: string; limit?: number }; +} { + return { + pathOrUrl: nextLink ?? 'workers/', + query: { + expand: RIPPLING_WORKER_EXPANSIONS, + ...(nextLink ? {} : { limit: Math.min(100, Math.max(1, limit)) }), + }, + }; +} + +async function collectRipplingWorkers(input: { + config: McpConnectionRipplingConfig; + now: Date; + limit: number; +}): Promise { + const state = await getBrainSyncState(db, RIPPLING_SNAPSHOT_STATE_ID); + const saved = parseRipplingSnapshotCursor(state?.backfillCursor ?? null); + if (saved.mode === 'reconcile') { + return collectRipplingReconciliation( + parseDate(saved.startedAt) ?? input.now, + input.limit, + ); + } + + const startedAt = + saved.mode === 'scan' + ? (parseDate(saved.startedAt) ?? input.now) + : input.now; + const request = buildRipplingWorkersRequest( + saved.mode === 'scan' ? saved.nextLink : null, + input.limit, + ); + const response = await ripplingApiRequestJson({ + config: input.config, + ...request, + }); + const batch = parseRipplingWorkersResponse(response); + const identities = buildPersonIdentityLookup( + await loadPersonIdentityRecords(), + ); + const pages: CollectorPage[] = []; + const itemUpdates: CollectorItemUpdate[] = []; + + for (const worker of batch.workers) { + const page = buildRipplingWorkerPage({ + worker, + identities, + observedAt: input.now, + snapshotStartedAt: startedAt, + }); + const workerId = ripplingWorkerId(worker); + if (!page || !workerId) { + throw new Error('Rippling worker could not be projected safely'); + } + pages.push(page); + itemUpdates.push({ + collectorId: RIPPLING_WORKERS_COLLECTOR_ID, + itemId: workerId, + slug: page.slug, + lastSeenAt: startedAt, + }); + } + + return { + pages, + nextSince: null, + itemUpdates, + stateUpdates: [ + { + collectorId: RIPPLING_SNAPSHOT_STATE_ID, + cursor: serializeRipplingSnapshotCursor( + batch.nextLink + ? { + mode: 'scan', + startedAt: startedAt.toISOString(), + nextLink: batch.nextLink, + } + : { mode: 'reconcile', startedAt: startedAt.toISOString() }, + ), + }, + ], + }; +} + +async function collectDisabledRipplingWorkers( + limit: number, +): Promise { + const tracked = await listBrainCollectorItems( + db, + RIPPLING_WORKERS_COLLECTOR_ID, + limit + 1, + ); + const batch = tracked.slice(0, limit); + return { + pages: batch.map(buildUnavailableRipplingWorkerPage), + nextSince: null, + itemDeletes: [ + { + collectorId: RIPPLING_WORKERS_COLLECTOR_ID, + itemIds: batch.map((item) => item.itemId), + }, + ], + ...(tracked.length <= limit + ? { + stateUpdates: [ + { + collectorId: RIPPLING_SNAPSHOT_STATE_ID, + cursor: serializeRipplingSnapshotCursor({ + mode: 'idle', + lastCompletedAt: null, + }), + }, + ], + } + : {}), + }; +} + +const ripplingWorkersCollector: BrainCollector = { + id: RIPPLING_WORKERS_COLLECTOR_ID, + displayName: 'Rippling employee directory', + async isEnabled() { + const [config, tracked] = await Promise.all([ + findRipplingConnectionConfig(), + listBrainCollectorItems(db, RIPPLING_WORKERS_COLLECTOR_ID, 1), + ]); + return Boolean(config || tracked.length > 0); + }, + async collect({ now, limit }) { + const config = await findRipplingConnectionConfig(); + return config + ? collectRipplingWorkers({ config, now, limit }) + : collectDisabledRipplingWorkers(limit); + }, +}; + /** * Notion: pages shared with the deployment integration * ----------------------------------------------------- @@ -3223,6 +3812,7 @@ const githubIssuesCollector: BrainCollector = { const BRAIN_COLLECTORS: BrainCollector[] = [ slackPersonDirectoryCollector, personIdentitiesCollector, + ripplingWorkersCollector, slackPublicChannelsCollector, notionPagesCollector, granolaMeetingsCollector, diff --git a/apps/docs/brain.mdx b/apps/docs/brain.mdx index c9342c2b5..360bca54f 100644 --- a/apps/docs/brain.mdx +++ b/apps/docs/brain.mdx @@ -24,6 +24,10 @@ Roomote fills the Brain from what it can already see: - **GitHub issues** in connected repositories - **Notion pages** explicitly shared with the deployment's Notion integration - **meeting notes** from Granola, when that integration is connected +- **employee directory and reporting structure** from Rippling, when that + integration is connected; HRIS reporting and membership fields remain + explicitly authoritative rather than being mixed with inferred collaboration + signals - **people identities**, projected from Roomote accounts, linked provider handles, and the human members in connected Slack workspace directories; Slack display names, real names, and job titles help agents connect people diff --git a/apps/docs/docs.json b/apps/docs/docs.json index dbf57acbf..3a470b300 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -158,6 +158,7 @@ "integrations/pylon", "integrations/railway", "integrations/resend", + "integrations/rippling", "integrations/sentry", "integrations/snowflake", "integrations/supabase", diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index cbdd738f3..3690aac96 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -65,6 +65,7 @@ from [Personal Settings](/personal-settings). | | Customer issue and account context | Admin connection once | | | Project and service context from Railway | Admin connection once | | | Email delivery and infrastructure management | Admin connection once | +| | Authoritative employee and reporting context | Admin connection once | | | Error and performance investigation | Admin connection once | | | Data warehouse exploration | Admin connection once | | | Read-only database access in Supabase | Enable first, then teammates link accounts | diff --git a/apps/docs/integrations/rippling.mdx b/apps/docs/integrations/rippling.mdx new file mode 100644 index 000000000..83154b03d --- /dev/null +++ b/apps/docs/integrations/rippling.mdx @@ -0,0 +1,82 @@ +--- +title: Rippling +description: Give Brain an authoritative employee directory and reporting structure from Rippling HRIS. +icon: users +--- + +Connect Rippling when Brain should use your HR system as the source of truth for +who works at the company, where they sit in the organization, and who they +report to. + +## Prerequisites + +Rippling's V2 REST API requires the API product for your company. The admin who +creates the token needs access to the **API Tokens** app and a company-wide +permission profile. A token limited to the creator's direct or indirect reports +cannot produce an authoritative company roster. + +Create a token with the scopes needed to read the roster and its expanded +references: + +- `workers.read` +- `users.read` +- `departments.read` +- `teams.read` +- `work-locations.read` + +Rippling applies both the token scopes and the token owner's permission profile. +Review both if employees or fields are missing. Rippling also revokes a token +when its owner is terminated or the token is unused for more than 30 days. + +See Rippling's [API token and permission +guide](https://developer.rippling.com/documentation/rest-api/essentials/api-tokens) +and [HRIS getting-started +guide](https://developer.rippling.com/documentation/rest-api/guides/hris-getting-started) +for the current account requirements. + +## Connect Rippling + +1. Create a company-wide API token in Rippling with the required read scopes. +2. In Roomote, open **Settings > Integrations** and choose **Configure + Rippling**. +3. Paste the API token and save. + +Roomote verifies the token against Rippling's V2 workers endpoint before it is +stored. The token is encrypted on the control plane and is never sent to task +sandboxes or exposed as an agent tool. + +## What Brain collects + +Roomote collects the Rippling worker resource ID, permanent employee number when +Rippling returns one, manager ID, work email, name, title, department and team +memberships, employment type, available location and time-zone data, start and +end dates, and Rippling's worker status. A work email that exactly matches a +Roomote account links the Rippling identity to that existing person card. + +Person pages preserve: + +- `rippling-hris` provenance and the snapshot observation time +- exact Rippling status alongside Brain's active or inactive status +- explicit `reports_to` and membership fields marked as authoritative HRIS data +- a clear distinction between those source-of-truth relationships and + collaboration relationships inferred from messages, meetings, or code review + +The Brain is deployment-wide. Work emails and HR attributes collected through +this integration are available to teammates who can use the deployment's +Brain. + +## Sync and lifecycle behavior + +Roomote follows Rippling's cursor pagination and builds resumable full roster +snapshots. It retries temporary failures and `429` rate limits with bounded +backoff. Inventory reconciliation happens only after the final page succeeds, +so a failed or interrupted snapshot cannot deactivate employees accidentally. + +Terminated workers remain represented with Rippling's exact status and end date. +Workers absent from a later complete snapshot, or left behind after the +integration is disconnected, become unavailable tombstones so stale active +profiles and relationships do not remain searchable. + +Rippling's Worker Changes API is a separately entitled product. Roomote does not +assume it is available; the baseline integration uses safe snapshots for every +customer instead. diff --git a/apps/docs/logo/integrations/rippling.svg b/apps/docs/logo/integrations/rippling.svg new file mode 100644 index 000000000..2bd8fe034 --- /dev/null +++ b/apps/docs/logo/integrations/rippling.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/snippets/integration-name.jsx b/apps/docs/snippets/integration-name.jsx index 6b17bf76d..74ffe7204 100644 --- a/apps/docs/snippets/integration-name.jsx +++ b/apps/docs/snippets/integration-name.jsx @@ -7,6 +7,7 @@ export function IntegrationName({ href, icon, name }) { azure: '/logo/integrations/azure.svg', granola: '/logo/integrations/granola.svg', monday: '/logo/integrations/monday.svg', + rippling: '/logo/integrations/rippling.svg', }; const iconSrc = manualIcons[icon] ?? diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 625444844..7565e3db4 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -38,6 +38,9 @@ const state = vi.hoisted(() => ({ notionConnection: null as null | { authStatus?: string | null; }, + ripplingConnection: null as null | { + authStatus?: string | null; + }, granolaConnection: null as null | { authStatus?: string | null; }, @@ -105,6 +108,7 @@ const { mutations, selectMock } = vi.hoisted(() => ({ setDisabledTools: vi.fn(), saveAsanaConnection: vi.fn(), saveNotionConnection: vi.fn(), + saveRipplingConnection: vi.fn(), saveGranolaConnection: vi.fn(), saveElevenLabsConnection: vi.fn(), saveGrafanaConnection: vi.fn(), @@ -267,6 +271,14 @@ vi.mock('@/hooks/mcp-connections', () => ({ data: state.notionConnection, isPending: false, }), + useSaveRipplingConnection: () => ({ + isPending: false, + mutate: mutations.saveRipplingConnection, + }), + useRipplingConnection: () => ({ + data: state.ripplingConnection, + isPending: false, + }), useSaveGranolaConnection: () => ({ isPending: false, mutate: mutations.saveGranolaConnection, @@ -506,6 +518,7 @@ describe('Integrations settings', () => { state.linearRedirectPath = ''; state.asanaConnection = null; state.notionConnection = null; + state.ripplingConnection = null; state.granolaConnection = null; state.grafanaConnection = null; state.vercelConnection = null; @@ -886,6 +899,7 @@ describe('Integrations settings', () => { 'Pylon', 'Railway', 'Resend', + 'Rippling', 'Sentry', 'Snowflake', 'Supabase', @@ -1496,6 +1510,24 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('opens the Rippling HRIS dialog with secure roster guidance', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Rippling' })); + + expect( + screen.getByRole('heading', { name: 'Connect Rippling' }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('API token')).toHaveAttribute( + 'type', + 'password', + ); + expect( + screen.getByText(/workers.read and the user, department, team/i), + ).toBeInTheDocument(); + expect(screen.getByText(/never sent to agents/i)).toBeInTheDocument(); + }); + it('lets admins replace a legacy Notion OAuth connection in place', () => { state.deploymentEnablements = [{ mcpId: 'notion', enabled: true }]; state.userConnections = [ diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index c500c574a..f24b52882 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -29,8 +29,10 @@ import { useDeploymentMcpEnablements, useMcpOauthReadiness, useNotionConnection, + useRipplingConnection, useSaveAsanaConnection, useSaveNotionConnection, + useSaveRipplingConnection, useSaveGrafanaConnection, useSaveGranolaConnection, useSaveElevenLabsConnection, @@ -53,6 +55,7 @@ import { useCustomMcpServers } from './CustomMcpServers'; import { saveAsanaConnectionSchema, saveNotionConnectionSchema, + saveRipplingConnectionSchema, saveGrafanaConnectionSchema, saveGranolaConnectionSchema, saveElevenLabsConnectionSchema, @@ -110,6 +113,8 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { neon: 'Roomote will get database access to inspect schemas and query data.', notion: 'Roomote will use one deployment-wide Notion internal integration. Notion controls its capabilities and which pages and data sources it can access.', + rippling: + "Roomote will keep Brain's employee directory and reporting structure current from one deployment-wide Rippling connection.", pylon: 'Roomote will be able to inspect customer issues, message history, and account context.', posthog: @@ -185,6 +190,10 @@ type NotionFormState = { internalIntegrationSecret: string; }; +type RipplingFormState = { + apiToken: string; +}; + type GranolaFormState = { apiKey: string; }; @@ -243,6 +252,10 @@ function buildEmptyNotionForm(): NotionFormState { return { internalIntegrationSecret: '' }; } +function buildEmptyRipplingForm(): RipplingFormState { + return { apiToken: '' }; +} + function buildEmptyGranolaForm(): GranolaFormState { return { apiKey: '', @@ -364,6 +377,13 @@ function getNotionFieldErrors( }; } +function getRipplingFieldErrors( + result: ReturnType, +): Partial> { + if (result.success) return {}; + return { apiToken: result.error.flatten().fieldErrors.apiToken }; +} + function getGranolaFieldErrors( result: ReturnType, ): Partial> { @@ -935,6 +955,60 @@ function NotionConnectionFields({ ); } +function RipplingConnectionFields({ + form, + fieldErrors, + formError, + allowBlankToken, + onFieldChange, +}: { + form: RipplingFormState; + fieldErrors: Partial>; + formError: string | null; + allowBlankToken: boolean; + onFieldChange: (field: keyof RipplingFormState, value: string) => void; +}) { + const fieldClassName = + 'mt-2 w-full border-border/70 bg-background data-[invalid=true]:border-destructive'; + + return ( + <> +
+ + onFieldChange('apiToken', event.target.value)} + data-invalid={fieldErrors.apiToken ? 'true' : undefined} + className={fieldClassName} + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + data-1p-ignore + /> +

+ Create a company-wide token in Rippling's API Tokens app with + workers.read and the user, department, team, employment type, and work + location read scopes needed for the roster. Roomote validates the + token before storing it. +

+ {allowBlankToken ? ( +

+ Leave blank to keep and revalidate the existing token. +

+ ) : null} + {fieldErrors.apiToken ? ( +

{fieldErrors.apiToken[0]}

+ ) : null} +
+ {formError ? ( +

{formError}

+ ) : null} + + ); +} + function XConnectionFields({ form, fieldErrors, @@ -1338,6 +1412,16 @@ export function Integrations() { Partial> >({}); const [notionFormError, setNotionFormError] = useState(null); + const [isRipplingDialogOpen, setIsRipplingDialogOpen] = useState(false); + const [ripplingForm, setRipplingForm] = useState( + buildEmptyRipplingForm(), + ); + const [ripplingFieldErrors, setRipplingFieldErrors] = useState< + Partial> + >({}); + const [ripplingFormError, setRipplingFormError] = useState( + null, + ); const [isGranolaDialogOpen, setIsGranolaDialogOpen] = useState(false); const [granolaForm, setGranolaForm] = useState( buildEmptyGranolaForm(), @@ -1415,6 +1499,7 @@ export function Integrations() { const disconnectMcp = useDisconnectMcp(); const saveAsanaConnection = useSaveAsanaConnection(); const saveNotionConnection = useSaveNotionConnection(); + const saveRipplingConnection = useSaveRipplingConnection(); const saveGrafanaConnection = useSaveGrafanaConnection(); const saveGranolaConnection = useSaveGranolaConnection(); const saveElevenLabsConnection = useSaveElevenLabsConnection(); @@ -1446,6 +1531,21 @@ export function Integrations() { const isNotionConnected = notionConnectionSummary?.authStatus === 'authenticated' && notionConnection.data?.authStatus === 'authenticated'; + const ripplingConnectionSummary = useMemo( + () => + (userMcpConnections.data ?? []).find( + (entry) => entry.mcpId === 'rippling', + ), + [userMcpConnections.data], + ); + const ripplingConnection = useRipplingConnection( + isAdmin && + (ripplingConnectionSummary?.authStatus === 'authenticated' || + isRipplingDialogOpen), + ); + const isRipplingConnected = + ripplingConnectionSummary?.authStatus === 'authenticated' && + ripplingConnection.data?.authStatus === 'authenticated'; const granolaConnectionSummary = useMemo(() => { const connection = (userMcpConnections.data ?? []).find( (entry) => entry.mcpId === 'granola', @@ -1548,6 +1648,14 @@ export function Integrations() { setNotionForm(buildEmptyNotionForm()); }, [isNotionConnected, isNotionDialogOpen, notionConnection.isPending]); + useEffect(() => { + if (!isRipplingDialogOpen) return; + if (ripplingConnection.isPending && isRipplingConnected) return; + setRipplingFieldErrors({}); + setRipplingFormError(null); + setRipplingForm(buildEmptyRipplingForm()); + }, [isRipplingConnected, isRipplingDialogOpen, ripplingConnection.isPending]); + useEffect(() => { if (!isGranolaDialogOpen) { return; @@ -1848,6 +1956,26 @@ export function Integrations() { }); } + if (integration.id === 'rippling') { + return buildAdminConfiguredIntegrationItem({ + integration, + connection: ripplingConnectionSummary, + orgEnabled: orgEnablementMap.get(integration.id) ?? false, + highlightedIntegrationId, + savePending: saveRipplingConnection.isPending, + disconnectPending: disconnectMcp.isPending, + disconnectingMcpId: disconnectMcp.variables?.mcpId, + dialogOpen: isRipplingDialogOpen, + connectionPending: ripplingConnection.isPending, + canConfigure: isAdmin, + canManageTools: false, + openDialog: () => setIsRipplingDialogOpen(true), + openToolDialog: () => openMcpToolDialog(integration), + disconnectIntegration: () => + disconnectAdminConfiguredIntegration(integration), + }); + } + if (integration.id === 'granola') { return buildAdminConfiguredIntegrationItem({ integration, @@ -2124,6 +2252,7 @@ export function Integrations() { isLinearOauthSetupOpen, saveAsanaConnection.isPending, saveNotionConnection.isPending, + saveRipplingConnection.isPending, saveGrafanaConnection.isPending, saveGranolaConnection.isPending, saveElevenLabsConnection.isPending, @@ -2137,6 +2266,9 @@ export function Integrations() { isNotionDialogOpen, notionConnectionSummary, notionConnection.isPending, + isRipplingDialogOpen, + ripplingConnection.isPending, + ripplingConnectionSummary, snowflakeConnection.isPending, isSnowflakeDialogOpen, vercelConnection.isPending, @@ -2249,6 +2381,17 @@ export function Integrations() { setNotionFormError(null); }; + const handleRipplingFieldChange = ( + field: keyof RipplingFormState, + value: string, + ) => { + setRipplingForm((current) => ({ ...current, [field]: value })); + setRipplingFieldErrors((current) => + current[field] ? { ...current, [field]: undefined } : current, + ); + setRipplingFormError(null); + }; + const handleGranolaFieldChange = ( field: keyof GranolaFormState, value: string, @@ -2393,6 +2536,13 @@ export function Integrations() { } }; + const handleRipplingDialogOpenChange = (open: boolean) => { + setIsRipplingDialogOpen(open); + setRipplingFieldErrors({}); + setRipplingFormError(null); + if (open) setRipplingForm(buildEmptyRipplingForm()); + }; + const handleGranolaDialogOpenChange = (open: boolean) => { setIsGranolaDialogOpen(open); @@ -2534,6 +2684,33 @@ export function Integrations() { }); }; + const handleRipplingSubmit = (event: FormEvent) => { + event.preventDefault(); + const parsed = saveRipplingConnectionSchema.safeParse(ripplingForm); + if (!parsed.success) { + setRipplingFieldErrors(getRipplingFieldErrors(parsed)); + return; + } + if (!isRipplingConnected && parsed.data.apiToken.length === 0) { + setRipplingFieldErrors({ apiToken: ['API token is required'] }); + return; + } + + setRipplingFieldErrors({}); + setRipplingFormError(null); + saveRipplingConnection.mutate(parsed.data, { + onSuccess: () => { + toast.success( + isRipplingConnected + ? 'Rippling connection updated for this deployment.' + : 'Rippling connected for this deployment.', + ); + handleRipplingDialogOpenChange(false); + }, + onError: (error) => setRipplingFormError(error.message), + }); + }; + const handleGranolaSubmit = (event: FormEvent) => { event.preventDefault(); @@ -2821,6 +2998,30 @@ export function Integrations() { onFieldChange={handleNotionFieldChange} /> + + Connect Rippling's read-only HRIS API so Brain can maintain the + employee roster and authoritative reporting structure. The token + stays encrypted on the control plane and is never sent to agents. + + } + onSubmit={handleRipplingSubmit} + > + + + + + ); +} + function MondayIcon({ name, className, @@ -545,6 +569,16 @@ export function BrandIcon({ icon, name, className }: BrandIconProps) { ); } + if (icon === 'rippling') { + return ( + + ); + } + if (icon === 'monday') { return ( { + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.userConnections.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.ripplingConnection.queryKey(), + }); + }, + }), + ); +} diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 62173bdc1..1fc5f6535 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -18,6 +18,7 @@ import { type McpConnectionRole, isMcpConnectionAsanaConfig, isMcpConnectionNotionConfig, + isMcpConnectionRipplingConfig, isMcpConnectionGranolaConfig, isMcpConnectionElevenLabsConfig, isMcpConnectionGrafanaConfig, @@ -35,6 +36,7 @@ import { } from '@roomote/types'; import { decrypt, encrypt } from '@roomote/db/encryption'; import { getValidAccessToken } from '@roomote/sdk/server'; +import { validateRipplingConnection } from '@roomote/sdk/server/rippling-api'; import type { UserAuthSuccess } from '@/types'; import type { StaticOauthReadiness } from '@/lib/server/mcp-static-oauth'; @@ -45,6 +47,7 @@ import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-er import type { SaveAsanaConnectionInput, SaveNotionConnectionInput, + SaveRipplingConnectionInput, SaveGranolaConnectionInput, SaveElevenLabsConnectionInput, SaveGrafanaConnectionInput, @@ -828,6 +831,24 @@ export async function getNotionConnectionCommand(auth: UserAuthSuccess) { return { authStatus: connection.authStatus }; } +export async function getRipplingConnectionCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const connection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'rippling'), + isNull(mcpConnections.userId), + ), + columns: { authConfig: true, authStatus: true }, + }); + + if (!connection || !isMcpConnectionRipplingConfig(connection.authConfig)) { + return null; + } + + return { authStatus: connection.authStatus }; +} + export async function getGranolaConnectionCommand(auth: UserAuthSuccess) { assertAdmin(auth); @@ -1265,6 +1286,92 @@ export async function saveNotionConnectionCommand( return { authStatus: 'authenticated' as const }; } +export async function saveRipplingConnectionCommand( + auth: UserAuthSuccess, + input: SaveRipplingConnectionInput, +) { + assertAdmin(auth); + assertCuratedIntegrationsEnabled(); + + const existingConnection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'rippling'), + isNull(mcpConnections.userId), + ), + columns: { authConfig: true }, + }); + const existingConfig = isMcpConnectionRipplingConfig( + existingConnection?.authConfig, + ) + ? existingConnection.authConfig + : null; + const nextEncryptedApiToken = + input.apiToken.length > 0 + ? encrypt(input.apiToken) + : existingConfig?.encryptedApiToken; + + if (!nextEncryptedApiToken) { + throw new Error( + 'A Rippling API token is required when no token is already stored.', + ); + } + + const authConfig = { + type: 'rippling' as const, + encryptedApiToken: nextEncryptedApiToken, + }; + + await validateRipplingConnection(authConfig); + + await db + .insert(mcpConnections) + .values({ + userId: null, + mcpId: 'rippling', + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + }) + .onConflictDoUpdate({ + target: [ + mcpConnections.userId, + mcpConnections.mcpId, + mcpConnections.connectionRole, + ], + set: { + connectionRole: 'default', + authConfig, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + scopes: null, + enabled: true, + authStatus: 'authenticated', + updatedAt: new Date(), + }, + }); + + await db + .insert(deploymentMcpEnablements) + .values({ + mcpId: 'rippling', + enabled: true, + enabledByUserId: auth.userId, + }) + .onConflictDoUpdate({ + target: [deploymentMcpEnablements.mcpId], + set: { + enabled: true, + enabledByUserId: auth.userId, + disabledTools: null, + updatedAt: new Date(), + }, + }); + + return { authStatus: 'authenticated' as const }; +} + export async function saveGranolaConnectionCommand( auth: UserAuthSuccess, input: SaveGranolaConnectionInput, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index ecd12d954..829133141 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -41,6 +41,7 @@ import { filterSchema, saveAsanaConnectionSchema, saveNotionConnectionSchema, + saveRipplingConnectionSchema, saveGranolaConnectionSchema, saveElevenLabsConnectionSchema, saveGrafanaConnectionSchema, @@ -209,6 +210,7 @@ import { getUserMcpConnectionsCommand, getAsanaConnectionCommand, getNotionConnectionCommand, + getRipplingConnectionCommand, getGranolaConnectionCommand, getElevenLabsConnectionCommand, getGrafanaConnectionCommand, @@ -218,6 +220,7 @@ import { listDeploymentMcpIntegrationToolsCommand, saveAsanaConnectionCommand, saveNotionConnectionCommand, + saveRipplingConnectionCommand, saveGranolaConnectionCommand, saveElevenLabsConnectionCommand, saveGrafanaConnectionCommand, @@ -1769,6 +1772,10 @@ export const appRouter = createRouter({ getNotionConnectionCommand(auth), ), + ripplingConnection: protectedProcedure.query(({ ctx: { auth } }) => + getRipplingConnectionCommand(auth), + ), + granolaConnection: protectedProcedure.query(({ ctx: { auth } }) => getGranolaConnectionCommand(auth), ), @@ -1849,6 +1856,12 @@ export const appRouter = createRouter({ saveNotionConnectionCommand(auth, input), ), + saveRipplingConnection: protectedProcedure + .input(saveRipplingConnectionSchema) + .mutation(({ ctx: { auth }, input }) => + saveRipplingConnectionCommand(auth, input), + ), + saveGranolaConnection: protectedProcedure .input(saveGranolaConnectionSchema) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/web/src/types/mcp-connections.ts b/apps/web/src/types/mcp-connections.ts index 9e010e0fa..2cd98bd4a 100644 --- a/apps/web/src/types/mcp-connections.ts +++ b/apps/web/src/types/mcp-connections.ts @@ -44,6 +44,14 @@ export type SaveNotionConnectionInput = z.infer< typeof saveNotionConnectionSchema >; +export const saveRipplingConnectionSchema = z.object({ + apiToken: z.string().transform((value) => value.trim()), +}); + +export type SaveRipplingConnectionInput = z.infer< + typeof saveRipplingConnectionSchema +>; + export const saveGranolaConnectionSchema = z.object({ apiKey: z.string().transform((value) => value.trim()), }); diff --git a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts index 845063dc8..a7fb19353 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -73,6 +73,13 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< 'Create and update shared content when the Notion integration capabilities permit it', ], }, + rippling: { + capabilities: [ + 'Keep Brain employee profiles current from an authoritative HRIS roster', + 'Use explicit reporting lines and department or team memberships in task context', + 'Keep the Rippling API token on the control plane rather than exposing it to agents', + ], + }, jira: { capabilities: [ 'Read Jira issues and linked metadata', diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7055c91b5..cfe60132c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -63,6 +63,10 @@ "./server/notion-api": { "import": "./src/server/lib/notion-api.ts", "require": "./src/server/lib/notion-api.ts" + }, + "./server/rippling-api": { + "import": "./src/server/lib/rippling-api.ts", + "require": "./src/server/lib/rippling-api.ts" } }, "scripts": { diff --git a/packages/sdk/src/server/lib/rippling-api.test.ts b/packages/sdk/src/server/lib/rippling-api.test.ts new file mode 100644 index 000000000..098e92d44 --- /dev/null +++ b/packages/sdk/src/server/lib/rippling-api.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@roomote/db/encryption', () => ({ + decrypt: vi.fn((value: string) => value.replace(/^enc:/, '')), +})); + +import { resolveRipplingPageUrl, ripplingApiRequestJson } from './rippling-api'; + +const config = { + type: 'rippling' as const, + encryptedApiToken: 'enc:rippling-secret', +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('ripplingApiRequestJson', () => { + it('authenticates with the encrypted bearer token', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ results: [], next_link: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + await ripplingApiRequestJson({ + config, + pathOrUrl: 'workers/', + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + new URL('https://rest.ripplingapis.com/workers/'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer rippling-secret', + }), + }), + ); + }); + + it('retries rate limits using Retry-After', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ detail: 'Slow down' }), { + status: 429, + headers: { 'retry-after': '2' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ results: [], next_link: null }), { + status: 200, + }), + ); + const wait = vi.fn().mockResolvedValue(undefined); + + await ripplingApiRequestJson({ + config, + pathOrUrl: 'workers/', + fetchImpl, + wait, + }); + + expect(wait).toHaveBeenCalledWith(10_000); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('retries transient network failures with bounded backoff', async () => { + const fetchImpl = vi + .fn() + .mockRejectedValueOnce(new TypeError('network unavailable')) + .mockResolvedValueOnce( + new Response(JSON.stringify({ results: [], next_link: null }), { + status: 200, + }), + ); + const wait = vi.fn().mockResolvedValue(undefined); + + await ripplingApiRequestJson({ + config, + pathOrUrl: 'workers/', + fetchImpl, + wait, + }); + + expect(wait).toHaveBeenCalledWith(500); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('rejects pagination URLs outside Rippling', () => { + expect(() => + resolveRipplingPageUrl('https://example.com/workers/'), + ).toThrow('unexpected API origin'); + }); +}); diff --git a/packages/sdk/src/server/lib/rippling-api.ts b/packages/sdk/src/server/lib/rippling-api.ts new file mode 100644 index 000000000..9f1f5e4c9 --- /dev/null +++ b/packages/sdk/src/server/lib/rippling-api.ts @@ -0,0 +1,134 @@ +import { decrypt } from '@roomote/db/encryption'; +import type { McpConnectionRipplingConfig } from '@roomote/types'; + +export const RIPPLING_API_BASE_URL = 'https://rest.ripplingapis.com/'; +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); + +type RipplingErrorResponse = { + detail?: string; + message?: string; +}; + +export class RipplingApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly retryAfterSeconds: number | null, + ) { + super(message); + this.name = 'RipplingApiError'; + } +} + +export function resolveRipplingApiToken( + config: McpConnectionRipplingConfig, +): string { + const token = decrypt(config.encryptedApiToken).trim(); + if (!token) { + throw new Error('Rippling connection is missing a stored API token'); + } + return token; +} + +export function resolveRipplingPageUrl(pathOrUrl: string): URL { + const url = new URL(pathOrUrl, RIPPLING_API_BASE_URL); + const base = new URL(RIPPLING_API_BASE_URL); + if (url.protocol !== base.protocol || url.host !== base.host) { + throw new Error('Rippling pagination returned an unexpected API origin'); + } + return url; +} + +function retryAfterSeconds(response: Response): number | null { + const raw = response.headers.get('retry-after'); + if (raw === null) return null; + + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds >= 0) return seconds; + + const date = Date.parse(raw); + if (Number.isNaN(date)) return null; + return Math.max(0, (date - Date.now()) / 1000); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function ripplingApiRequestJson(params: { + config: McpConnectionRipplingConfig; + pathOrUrl: string; + query?: Record; + attempts?: number; + fetchImpl?: typeof fetch; + wait?: (ms: number) => Promise; +}): Promise { + const url = resolveRipplingPageUrl(params.pathOrUrl); + for (const [key, value] of Object.entries(params.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + + const attempts = Math.max(1, params.attempts ?? 3); + const fetchImpl = params.fetchImpl ?? fetch; + const wait = params.wait ?? sleep; + + for (let attempt = 0; attempt < attempts; attempt++) { + let response: Response; + try { + response = await fetchImpl(url, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${resolveRipplingApiToken(params.config)}`, + }, + signal: AbortSignal.timeout(15_000), + }); + } catch (error) { + if (attempt + 1 >= attempts) throw error; + await wait(500 * 2 ** attempt); + continue; + } + + if (response.ok) return (await response.json()) as T; + + const payload = (await response + .json() + .catch(() => null)) as RipplingErrorResponse | null; + const retryAfter = retryAfterSeconds(response); + const error = new RipplingApiError( + payload?.detail?.trim() || + payload?.message?.trim() || + `Rippling API request failed with status ${response.status}`, + response.status, + retryAfter, + ); + + if ( + !RETRYABLE_STATUS_CODES.has(response.status) || + attempt + 1 >= attempts + ) { + throw error; + } + + await wait( + Math.max( + retryAfter === null ? 0 : retryAfter * 1000, + response.status === 429 ? 10_000 : 500 * 2 ** attempt, + ), + ); + } + + throw new Error('Rippling API request exhausted retries'); +} + +export async function validateRipplingConnection( + config: McpConnectionRipplingConfig, +): Promise { + await ripplingApiRequestJson({ + config, + pathOrUrl: 'workers/', + query: { + limit: 1, + expand: 'user,manager,manager.user,department,employment_type,teams', + }, + }); +} diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index b4018e471..3514cb22d 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -6,6 +6,7 @@ import { getMcpIntegrationOauthScopeMode, getMcpIntegrationOauthScopes, isMcpConnectionNotionConfig, + isMcpConnectionRipplingConfig, isMcpConnectionElevenLabsConfig, isMcpConnectionGbrainConfig, LINEAR_APP_OAUTH_SCOPES, @@ -80,6 +81,30 @@ describe('Notion internal integration', () => { }); }); +describe('Rippling HRIS connection', () => { + it('keeps the deployment credential on the control plane', () => { + expect(getMcpIntegration('rippling')).toMatchObject({ + name: 'Rippling', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'credential_only', + }); + expect(getMcpIntegration('rippling')?.url).toBeUndefined(); + }); + + it('recognizes only encrypted Rippling token configs', () => { + expect( + isMcpConnectionRipplingConfig({ + type: 'rippling', + encryptedApiToken: 'encrypted', + }), + ).toBe(true); + expect(isMcpConnectionRipplingConfig({ type: 'rippling' } as never)).toBe( + false, + ); + }); +}); + describe('Better Stack OAuth', () => { it('uses the hosted MCP with deployment-scoped read-only access', () => { expect(getMcpIntegration('betterstack')).toMatchObject({ diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 94cf55dde..07141c950 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -135,6 +135,17 @@ export interface McpConnectionNotionConfig { encryptedToken: string; } +/** + * Deployment-scoped Rippling HRIS configuration. + * + * The API token is used only by the server-side Brain collector and is + * expected to be encrypted before persistence. + */ +export interface McpConnectionRipplingConfig { + type: 'rippling'; + encryptedApiToken: string; +} + /** * Deployment-scoped Granola connection config stored in mcpConnections.authConfig. * @@ -238,6 +249,7 @@ export type McpConnectionAuthConfig = | McpConnectionSnowflakeConfig | McpConnectionAsanaConfig | McpConnectionNotionConfig + | McpConnectionRipplingConfig | McpConnectionGranolaConfig | McpConnectionElevenLabsConfig | McpConnectionVercelConfig @@ -413,6 +425,15 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ instructions: 'Use Notion for pages and data sources explicitly shared with the deployment integration. Content outside that connection boundary, including unshared private pages, is unavailable. Notion controls whether the connection may read, update, insert, or comment.', }, + { + id: 'rippling', + name: 'Rippling', + description: `Connect Rippling so Brain can keep an authoritative employee directory and reporting structure current for ${PRODUCT_NAME} tasks`, + icon: 'rippling', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'credential_only', + }, { id: 'jira', name: 'Jira', @@ -954,6 +975,19 @@ export function isMcpConnectionNotionConfig( ); } +export function isMcpConnectionRipplingConfig( + authConfig: McpConnectionAuthConfig | null | undefined, +): authConfig is McpConnectionRipplingConfig { + return Boolean( + authConfig && + typeof authConfig === 'object' && + 'type' in authConfig && + authConfig.type === 'rippling' && + 'encryptedApiToken' in authConfig && + typeof authConfig.encryptedApiToken === 'string', + ); +} + export function isMcpConnectionGranolaConfig( authConfig: McpConnectionAuthConfig | null | undefined, ): authConfig is McpConnectionGranolaConfig {