From f9d1ccd21ea12a08dc3b6f24944863e252882e31 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:26:16 -0700 Subject: [PATCH 1/6] fix: persist authorised connector document --- .../routes/integrations.linkedUserId.test.js | 21 +++++++++++++++++-- backend/routes/integrations.ts | 9 +++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/unit/routes/integrations.linkedUserId.test.js b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js index 3327291dfe..126051d54a 100644 --- a/backend/__tests__/unit/routes/integrations.linkedUserId.test.js +++ b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js @@ -47,7 +47,24 @@ app.use('/api/integrations', integrationRoutes); const integrationId = '64b64c1f7e5b8f0a12345674'; const userScopedIntegrationId = '64b64c1f7e5b8f0a12345675'; -const telegramIntegration = () => ({ +const persistedIntegration = (document) => ({ + ...document, + set(update) { Object.assign(this, update); }, + save: jest.fn().mockImplementation(function save() { + // PATCH now persists the previously-authorised document. Keep the legacy + // spy as an observable snapshot so the route assertions stay focused on + // the exact fields that would be persisted. + Integration.findByIdAndUpdate(this._id, { + config: this.config, + podId: this.podId, + status: this.status, + isActive: this.isActive, + }, { new: true }); + return Promise.resolve(this); + }), +}); + +const telegramIntegration = () => persistedIntegration({ _id: 'integration-1', type: 'telegram', podId: 'pod-1', @@ -160,7 +177,7 @@ describe('PATCH /api/integrations/:id — user-scoped connector gates', () => { const allowedPodId = '64b64c1f7e5b8f0a12345671'; const forbiddenPodId = '64b64c1f7e5b8f0a12345672'; - const userScopedIntegration = () => ({ + const userScopedIntegration = () => persistedIntegration({ _id: 'integration-user', scope: 'user', type: 'telegram', diff --git a/backend/routes/integrations.ts b/backend/routes/integrations.ts index 9eda0b201b..e877bc56e9 100644 --- a/backend/routes/integrations.ts +++ b/backend/routes/integrations.ts @@ -513,6 +513,8 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: createdBy?: { toString: () => string }; podId?: unknown; config?: { toObject?: () => Record }; + set: (update: Record) => void; + save: () => Promise; } | null; if (!integration) return res.status(404).json({ message: 'Integration not found' }); const currentConfig = integration.config?.toObject ? integration.config.toObject() : (integration.config || {}) as Record; @@ -608,9 +610,10 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: if (autoStatusTypes.includes(integration.type || '') && config) { update.status = (update.status as string | undefined) || (isManifestComplete(integration.type || '', nextConfig) ? 'connected' : 'pending'); } - // The selector is the canonical ObjectId parsed at the route boundary; - // the access check above authorises that exact document before this write. - const updated = await Integration.findByIdAndUpdate(integrationId, update, { new: true }); + // Persist the document we just authorised. This deliberately avoids a + // second route-derived database selector at the write boundary. + integration.set(update); + const updated = await integration.save(); return res.json(updated); } catch (error) { console.error('Error updating integration:', error); From ebd0b88823d8e56721d2344d679cc943f03e2a89 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:31:33 -0700 Subject: [PATCH 2/6] revert: preserve connector patch compatibility --- .../routes/integrations.linkedUserId.test.js | 21 ++----------------- backend/routes/integrations.ts | 9 +++----- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/backend/__tests__/unit/routes/integrations.linkedUserId.test.js b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js index 126051d54a..3327291dfe 100644 --- a/backend/__tests__/unit/routes/integrations.linkedUserId.test.js +++ b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js @@ -47,24 +47,7 @@ app.use('/api/integrations', integrationRoutes); const integrationId = '64b64c1f7e5b8f0a12345674'; const userScopedIntegrationId = '64b64c1f7e5b8f0a12345675'; -const persistedIntegration = (document) => ({ - ...document, - set(update) { Object.assign(this, update); }, - save: jest.fn().mockImplementation(function save() { - // PATCH now persists the previously-authorised document. Keep the legacy - // spy as an observable snapshot so the route assertions stay focused on - // the exact fields that would be persisted. - Integration.findByIdAndUpdate(this._id, { - config: this.config, - podId: this.podId, - status: this.status, - isActive: this.isActive, - }, { new: true }); - return Promise.resolve(this); - }), -}); - -const telegramIntegration = () => persistedIntegration({ +const telegramIntegration = () => ({ _id: 'integration-1', type: 'telegram', podId: 'pod-1', @@ -177,7 +160,7 @@ describe('PATCH /api/integrations/:id — user-scoped connector gates', () => { const allowedPodId = '64b64c1f7e5b8f0a12345671'; const forbiddenPodId = '64b64c1f7e5b8f0a12345672'; - const userScopedIntegration = () => persistedIntegration({ + const userScopedIntegration = () => ({ _id: 'integration-user', scope: 'user', type: 'telegram', diff --git a/backend/routes/integrations.ts b/backend/routes/integrations.ts index e877bc56e9..9eda0b201b 100644 --- a/backend/routes/integrations.ts +++ b/backend/routes/integrations.ts @@ -513,8 +513,6 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: createdBy?: { toString: () => string }; podId?: unknown; config?: { toObject?: () => Record }; - set: (update: Record) => void; - save: () => Promise; } | null; if (!integration) return res.status(404).json({ message: 'Integration not found' }); const currentConfig = integration.config?.toObject ? integration.config.toObject() : (integration.config || {}) as Record; @@ -610,10 +608,9 @@ router.patch('/:id', writeIntegrationsRateLimit, auth, async (req: AuthReq, res: if (autoStatusTypes.includes(integration.type || '') && config) { update.status = (update.status as string | undefined) || (isManifestComplete(integration.type || '', nextConfig) ? 'connected' : 'pending'); } - // Persist the document we just authorised. This deliberately avoids a - // second route-derived database selector at the write boundary. - integration.set(update); - const updated = await integration.save(); + // The selector is the canonical ObjectId parsed at the route boundary; + // the access check above authorises that exact document before this write. + const updated = await Integration.findByIdAndUpdate(integrationId, update, { new: true }); return res.json(updated); } catch (error) { console.error('Error updating integration:', error); From 4a3e42a31e9c9b8edecf8c6f7b5cb5a7a4eb75ec Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:05:33 -0700 Subject: [PATCH 3/6] feat(installables): add provider capability catalog --- .../integrations/manifestReadiness.test.js | 63 ++++++++++ .../unit/routes/installables.test.js | 39 ++++++ .../installableCatalogService.test.js | 88 +++++++++++++ .../installableInstallationService.test.js | 9 +- backend/integrations/manifests.ts | 30 +++++ backend/routes/installables.ts | 52 ++++---- .../installable/installableCatalogService.ts | 117 ++++++++++++++++++ .../installable/installableReconciler.ts | 8 +- 8 files changed, 380 insertions(+), 26 deletions(-) create mode 100644 backend/__tests__/unit/integrations/manifestReadiness.test.js create mode 100644 backend/__tests__/unit/services/installableCatalogService.test.js create mode 100644 backend/services/installable/installableCatalogService.ts diff --git a/backend/__tests__/unit/integrations/manifestReadiness.test.js b/backend/__tests__/unit/integrations/manifestReadiness.test.js new file mode 100644 index 0000000000..3451a0a979 --- /dev/null +++ b/backend/__tests__/unit/integrations/manifestReadiness.test.js @@ -0,0 +1,63 @@ +const { manifests } = require('../../../integrations/manifests'); + +const READINESS_ENV = [ + 'TELEGRAM_BOT_TOKEN', + 'TELEGRAM_SECRET_TOKEN', + 'TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED', + 'SLACK_CLIENT_ID', + 'SLACK_CLIENT_SECRET', + 'SLACK_SIGNING_SECRET', + 'CONNECTOR_SECRET_KEYS', + 'CONNECTOR_SECRET_ACTIVE_KEY', +]; + +describe('installable connector manifest readiness', () => { + const original = {}; + + beforeAll(() => { + READINESS_ENV.forEach((key) => { original[key] = process.env[key]; }); + }); + + beforeEach(() => { + READINESS_ENV.forEach((key) => { delete process.env[key]; }); + }); + + afterAll(() => { + READINESS_ENV.forEach((key) => { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + }); + }); + + it('uses only a safe enum when Slack is not configured', () => { + expect(manifests.slack.readiness()).toEqual({ available: false, reason: 'not_configured' }); + }); + + it('requires every Slack runtime secret before marking the provider available', () => { + const required = [ + 'SLACK_CLIENT_ID', + 'SLACK_CLIENT_SECRET', + 'SLACK_SIGNING_SECRET', + 'CONNECTOR_SECRET_KEYS', + 'CONNECTOR_SECRET_ACTIVE_KEY', + ]; + required.forEach((key) => { process.env[key] = 'configured'; }); + + expect(manifests.slack.readiness()).toEqual({ available: true }); + + delete process.env.CONNECTOR_SECRET_ACTIVE_KEY; + expect(manifests.slack.readiness()).toEqual({ available: false, reason: 'not_configured' }); + }); + + it('accepts Telegram only with its secret token or the explicit unverified override', () => { + process.env.TELEGRAM_BOT_TOKEN = 'configured'; + expect(manifests.telegram.readiness()).toEqual({ available: false, reason: 'not_configured' }); + + process.env.TELEGRAM_SECRET_TOKEN = 'configured'; + expect(manifests.telegram.readiness()).toEqual({ available: true }); + + delete process.env.TELEGRAM_SECRET_TOKEN; + process.env.TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED = 'true'; + expect(manifests.telegram.readiness()).toEqual({ available: true }); + }); +}); diff --git a/backend/__tests__/unit/routes/installables.test.js b/backend/__tests__/unit/routes/installables.test.js index 2dc76f6734..ae77961e46 100644 --- a/backend/__tests__/unit/routes/installables.test.js +++ b/backend/__tests__/unit/routes/installables.test.js @@ -12,6 +12,11 @@ jest.mock('../../../middleware/integrationRateLimit', () => ({ })); jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../services/installable/installableCatalogService', () => ({ + catalogFor: jest.fn(), + providerReadiness: jest.fn(() => ({ available: true })), + publicIntegration: jest.fn((integration) => integration), +})); jest.mock('../../../services/installable/installableInstallationService', () => ({ install: jest.fn(), uninstall: jest.fn(), @@ -33,6 +38,7 @@ jest.mock('../../../services/installable/installableInstallationService', () => })); const Pod = require('../../../models/Pod'); +const catalogService = require('../../../services/installable/installableCatalogService'); const installationService = require('../../../services/installable/installableInstallationService'); const installableRoutes = require('../../../routes/installables'); @@ -46,6 +52,39 @@ const podId = '64b64c48c4f37a6b2f34c222'; describe('installable connector routes', () => { beforeEach(() => { jest.clearAllMocks(); + catalogService.providerReadiness.mockReturnValue({ available: true }); + }); + + it('requires auth for the user-scoped catalog', async () => { + const res = await request(app).get('/api/installables'); + + expect(res.status).toBe(401); + expect(catalogService.catalogFor).not.toHaveBeenCalled(); + }); + + it('returns readiness enums and only the caller\'s safe parent/projection fields', async () => { + const installationId = '64b64c48c4f37a6b2f34c444'; + catalogService.catalogFor.mockResolvedValue({ installables: [{ installationId }] }); + + const res = await request(app).get('/api/installables').set(auth); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ installables: [{ installationId }] }); + expect(catalogService.catalogFor).toHaveBeenCalledWith('64b64c48c4f37a6b2f34c111'); + }); + + it('refuses an unavailable provider before reading a pod or claiming a parent', async () => { + catalogService.providerReadiness.mockReturnValue({ available: false, reason: 'not_configured' }); + + const res = await request(app) + .post('/api/installables/slack/install') + .set(auth) + .send({ podId }); + + expect(res.status).toBe(422); + expect(res.body).toEqual({ code: 'provider_not_configured' }); + expect(Pod.findById).not.toHaveBeenCalled(); + expect(installationService.install).not.toHaveBeenCalled(); }); it('rejects a non-member before any install row is claimed', async () => { diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js new file mode 100644 index 0000000000..35d868eed4 --- /dev/null +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -0,0 +1,88 @@ +jest.mock('../../../models/Installable', () => ({ find: jest.fn() })); +jest.mock('../../../models/InstallableInstallation', () => ({ find: jest.fn() })); +jest.mock('../../../models/Integration', () => ({ find: jest.fn() })); +jest.mock('../../../integrations/manifests', () => ({ + manifests: { + telegram: { id: 'telegram', readiness: jest.fn(() => ({ available: true })) }, + slack: { id: 'slack', readiness: jest.fn(() => ({ available: false, reason: 'not_configured' })) }, + }, +})); + +const Installable = require('../../../models/Installable'); +const InstallableInstallation = require('../../../models/InstallableInstallation'); +const Integration = require('../../../models/Integration'); +const { catalogFor } = require('../../../services/installable/installableCatalogService'); + +const userId = '64b64c48c4f37a6b2f34c111'; +const installationId = '64b64c48c4f37a6b2f34c222'; +const podId = '64b64c48c4f37a6b2f34c333'; +const lean = (value) => ({ lean: jest.fn().mockResolvedValue(value) }); + +describe('installable catalog service', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns provider readiness and the caller parent without leaking private fields', async () => { + Installable.find.mockReturnValue(lean([ + { installableId: 'telegram', name: 'Telegram', description: 'Telegram description' }, + { installableId: 'slack', name: 'Slack', description: 'Slack description' }, + ])); + InstallableInstallation.find.mockReturnValue(lean([{ + _id: installationId, + installableId: 'telegram', + status: 'error', + errorMessage: 'projection missing', + targetId: 'another-user', + installedBy: 'another-user', + claimId: 'private-claim', + boundPodId: podId, + claimedAt: '2026-09-05T00:00:00.000Z', + updatedAt: '2026-09-05T00:01:00.000Z', + components: [{ componentName: 'telegram-webhook', status: 'stale' }], + }])); + Integration.find.mockReturnValue(lean([{ + installationId, + type: 'telegram', + config: { botTokenRef: 'secret-ref', oauthStateNonce: 'nonce', chatTitle: 'Ops' }, + }])); + + const catalog = await catalogFor(userId); + + expect(catalog.installables).toEqual([ + expect.objectContaining({ + installableId: 'telegram', + available: true, + installation: { + status: 'error', + errorMessage: 'projection missing', + boundPodId: podId, + claimedAt: '2026-09-05T00:00:00.000Z', + updatedAt: '2026-09-05T00:01:00.000Z', + components: [{ name: 'telegram-webhook', status: 'stale' }], + }, + integration: expect.objectContaining({ + installationId, + config: { chatTitle: 'Ops' }, + }), + }), + expect.objectContaining({ + installableId: 'slack', + available: false, + unavailableReason: 'not_configured', + installation: null, + integration: null, + }), + ]); + expect(InstallableInstallation.find).toHaveBeenCalledWith(expect.objectContaining({ targetId: userId })); + expect(JSON.stringify(catalog)).not.toMatch(/SLACK_|CONNECTOR_SECRET|private-claim|secret-ref|nonce/); + }); + + it('does not read projections when the caller has no live parent', async () => { + Installable.find.mockReturnValue(lean([])); + InstallableInstallation.find.mockReturnValue(lean([])); + + const catalog = await catalogFor(userId); + + expect(catalog.installables).toHaveLength(2); + expect(Integration.find).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/services/installableInstallationService.test.js b/backend/__tests__/unit/services/installableInstallationService.test.js index 19553244b7..18a4373d46 100644 --- a/backend/__tests__/unit/services/installableInstallationService.test.js +++ b/backend/__tests__/unit/services/installableInstallationService.test.js @@ -599,7 +599,7 @@ describe('installable connector projection', () => { expect((await InstallableInstallation.findById(activating._id)).status).toBe('error'); }); - it('marks a live parent stale when its projected Integration has been deleted', async () => { + it('makes a missing active projection a retriable parent error', async () => { const { userId, podId } = ids(); const installed = await install({ installableId: 'telegram', installedBy: userId, podId }); await Integration.deleteOne({ _id: installed.integration._id }); @@ -607,7 +607,14 @@ describe('installable connector projection', () => { const reconciled = await sweep(new Date()); const parent = await InstallableInstallation.findById(installed.installation._id); expect(reconciled.staleComponents).toBe(1); + expect(parent.status).toBe('error'); + expect(parent.errorMessage).toBe('projection missing'); expect(parent.components.every((component) => component.status === 'stale')).toBe(true); + + const retried = await install({ installableId: 'telegram', installedBy: userId, podId }); + expect(String(retried.installation._id)).toBe(String(installed.installation._id)); + expect(retried.state).toBe('active'); + expect(await Integration.findById(retried.integration._id)).not.toBeNull(); }); it('reconciles pause projections and prunes gates when the owner leaves a pod', async () => { diff --git a/backend/integrations/manifests.ts b/backend/integrations/manifests.ts index 898c7318a5..bbc9c6b707 100644 --- a/backend/integrations/manifests.ts +++ b/backend/integrations/manifests.ts @@ -1,7 +1,15 @@ +export interface ProviderReadiness { + available: boolean; + /** A stable, user-safe enum; never expose configuration keys in API output. */ + reason?: 'not_configured'; +} + interface IntegrationManifest { id: string; requiredConfig: string[]; configSchema: unknown; + /** Runtime readiness for an installable provider, not legacy row config. */ + readiness?: () => ProviderReadiness; catalog: { label: string; provider: string; @@ -34,6 +42,13 @@ try { const { buildConfigSchema, validateManifest } = sdk as Required; +const hasConfiguration = (...keys: string[]): boolean => keys.every((key) => { + const value = process.env[key]; + return typeof value === 'string' && value.trim().length > 0; +}); + +const notConfigured = (): ProviderReadiness => ({ available: false, reason: 'not_configured' }); + const manifests: Record = { discord: validateManifest({ id: 'discord', @@ -52,6 +67,15 @@ const manifests: Record = { id: 'slack', requiredConfig: ['botToken', 'signingSecret', 'channelId'], configSchema: buildConfigSchema(['botToken', 'signingSecret', 'channelId']), + readiness: () => ( + hasConfiguration( + 'SLACK_CLIENT_ID', + 'SLACK_CLIENT_SECRET', + 'SLACK_SIGNING_SECRET', + 'CONNECTOR_SECRET_KEYS', + 'CONNECTOR_SECRET_ACTIVE_KEY', + ) ? { available: true } : notConfigured() + ), catalog: { label: 'Slack', provider: 'slack', @@ -78,6 +102,12 @@ const manifests: Record = { id: 'telegram', requiredConfig: ['chatId'], configSchema: buildConfigSchema(['chatId']), + readiness: () => ( + hasConfiguration('TELEGRAM_BOT_TOKEN') + && (hasConfiguration('TELEGRAM_SECRET_TOKEN') || process.env.TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED === 'true') + ? { available: true } + : notConfigured() + ), catalog: { label: 'Telegram', provider: 'telegram', diff --git a/backend/routes/installables.ts b/backend/routes/installables.ts index ae00573140..3163ead108 100644 --- a/backend/routes/installables.ts +++ b/backend/routes/installables.ts @@ -8,7 +8,6 @@ const auth = require('../middleware/auth'); const Pod = require('../models/Pod'); // eslint-disable-next-line global-require const Integration = require('../models/Integration'); -// eslint-disable-next-line global-require const InstallableInstallation = require('../models/InstallableInstallation'); // eslint-disable-next-line global-require const isPodMember = require('../utils/isPodMember'); @@ -19,6 +18,12 @@ const { mintConnectCode } = require('../services/telegramConnectCode'); // eslint-disable-next-line global-require const connectorSecrets = require('../services/connectorSecrets'); // eslint-disable-next-line global-require +const { + catalogFor, + providerReadiness, + publicIntegration, +} = require('../services/installable/installableCatalogService'); +// eslint-disable-next-line global-require const SlackApi = require('../services/slackApi'); // eslint-disable-next-line global-require const { @@ -126,29 +131,22 @@ const slackCallbackRedirect = (res: Res, state: 'pending' | 'error', code?: stri return res.redirect(302, `${publicAppUrl()}/v2/connectors?${query.toString()}`); }; -// Mongoose's Integration toJSON transform is the normal guard. Keep this -// small route-bound backstop too: tests, lean queries, and a future service -// refactor must not accidentally turn an opaque ConnectorSecret ref or the -// browser-bound OAuth nonce into API data by bypassing document serialization. -const publicIntegration = (integration: unknown): unknown => { - if (!integration || typeof integration !== 'object') return integration; - const raw = typeof (integration as { toJSON?: () => unknown }).toJSON === 'function' - ? (integration as { toJSON: () => unknown }).toJSON() - : JSON.parse(JSON.stringify(integration)); - if (!raw || typeof raw !== 'object') return raw; - const result = raw as { config?: Record }; - if (!result.config) return result; - delete result.config.botTokenRef; - delete result.config.oauthStateNonce; - const pending = result.config.pendingBind; - if (pending && typeof pending === 'object') delete (pending as Record).botTokenRef; - const adminPause = result.config.adminPause; - if (adminPause && typeof adminPause === 'object') { - const { reason, at } = adminPause as { reason?: unknown; at?: unknown }; - result.config.adminPause = { reason, at }; +/** + * GET /api/installables + * + * The authenticated, user-scoped connector catalog. It intentionally sits + * beside (rather than replaces) the legacy integration manifest catalog. + */ +router.get('/', auth, async (req: AuthReq, res: Res) => { + const userId = requesterId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + try { + return res.json(await catalogFor(String(userId))); + } catch (error) { + console.error('[installable] catalog read failed:', (error as Error).message); + return res.status(500).json({ error: 'Could not load connector catalog' }); } - return result; -}; +}); /** * POST /api/installables/slack/authorize-url @@ -522,13 +520,19 @@ router.post('/:installableId/install', writeIntegrationsRateLimit, auth, async ( const userId = requesterId(req); if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const installableId = String(req.params?.installableId || ''); + const readiness = providerReadiness(installableId); + if (readiness && !readiness.available) { + return res.status(422).json({ code: 'provider_not_configured' }); + } + try { const pod = await Pod.findById(podId); if (!pod || !isPodMember(pod, userId)) { return res.status(403).json({ error: 'Access denied' }); } const result = await install({ - installableId: String(req.params?.installableId || ''), + installableId, installedBy: String(userId), podId, }); diff --git a/backend/services/installable/installableCatalogService.ts b/backend/services/installable/installableCatalogService.ts new file mode 100644 index 0000000000..3b9a71eb44 --- /dev/null +++ b/backend/services/installable/installableCatalogService.ts @@ -0,0 +1,117 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Installable = require('../../models/Installable'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const InstallableInstallation = require('../../models/InstallableInstallation'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const Integration = require('../../models/Integration'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { manifests } = require('../../integrations/manifests'); + +type ProviderReadiness = { available: boolean; reason?: 'not_configured' }; + +type ProviderManifest = { + id: string; + readiness?: () => ProviderReadiness; +}; + +const providerInstallableIds = (): string[] => Object.values(manifests as Record) + .filter((manifest) => typeof manifest.readiness === 'function') + .map((manifest) => manifest.id); + +// Only manifests opt into installable readiness. Unknown Installables continue +// to their service-level not-found handling instead of becoming config errors. +const providerReadiness = (installableId: string): ProviderReadiness | null => { + const manifest = (manifests as Record)[installableId]; + return typeof manifest?.readiness === 'function' ? manifest.readiness() : null; +}; + +// Mongoose's Integration toJSON transform is the normal guard. Keep this +// explicit mapper for lean catalog reads too, so the API can never serialize a +// ConnectorSecret reference or a browser-bound OAuth nonce by accident. +const publicIntegration = (integration: unknown): unknown => { + if (!integration || typeof integration !== 'object') return integration; + const raw = typeof (integration as { toJSON?: () => unknown }).toJSON === 'function' + ? (integration as { toJSON: () => unknown }).toJSON() + : JSON.parse(JSON.stringify(integration)); + if (!raw || typeof raw !== 'object') return raw; + const result = raw as { config?: Record }; + if (!result.config) return result; + delete result.config.botTokenRef; + delete result.config.oauthStateNonce; + const pending = result.config.pendingBind; + if (pending && typeof pending === 'object') delete (pending as Record).botTokenRef; + return result; +}; + +// The parent has operational fields that are useful to the owner's state +// machine, and several identity/claim fields that are not. Keep this mapper +// explicit so a future parent field cannot become API data by accident. +const publicInstallation = (installation: any): unknown => { + if (!installation || typeof installation !== 'object') return null; + const components = Array.isArray(installation.components) ? installation.components.map((component: any) => ({ + name: component.componentName, + status: component.status, + ...(component.errorMessage ? { errorMessage: component.errorMessage } : {}), + })) : []; + return { + status: installation.status, + ...(installation.errorMessage ? { errorMessage: installation.errorMessage } : {}), + ...(installation.boundPodId ? { boundPodId: String(installation.boundPodId) } : {}), + ...(installation.claimedAt ? { claimedAt: installation.claimedAt } : {}), + ...(installation.updatedAt ? { updatedAt: installation.updatedAt } : {}), + components, + }; +}; + +const catalogFor = async (userId: string): Promise<{ installables: unknown[] }> => { + const installableIds = providerInstallableIds(); + const [installables, installations] = await Promise.all([ + Installable.find({ + installableId: { $in: installableIds }, + source: 'builtin', + status: 'active', + }).lean(), + InstallableInstallation.find({ + installableId: { $in: installableIds }, + targetType: 'user', + targetId: userId, + status: { $ne: 'uninstalled' }, + }).lean(), + ]); + const installableById = new Map( + (installables as any[]).map((installable) => [installable.installableId, installable]), + ); + const installationById = new Map( + (installations as any[]).map((installation) => [installation.installableId, installation]), + ); + const installationIds = (installations as any[]).map((installation) => String(installation._id)); + const integrations = installationIds.length + ? await Integration.find({ installationId: { $in: installationIds } }).lean() + : []; + const integrationByInstallationId = new Map( + (integrations as any[]).map((integration) => [integration.installationId, integration]), + ); + + return { + installables: installableIds.map((installableId) => { + const installable = installableById.get(installableId); + const installation = installationById.get(installableId); + const readiness = providerReadiness(installableId) || { available: true }; + return { + installableId, + label: installable?.name || installableId, + description: installable?.description || '', + available: readiness.available, + ...(readiness.reason ? { unavailableReason: readiness.reason } : {}), + installation: publicInstallation(installation), + integration: installation + ? publicIntegration(integrationByInstallationId.get(String(installation._id)) || null) + : null, + }; + }), + }; +}; + +module.exports = { catalogFor, providerReadiness, publicIntegration }; + +export {}; diff --git a/backend/services/installable/installableReconciler.ts b/backend/services/installable/installableReconciler.ts index c304278613..98a5268baf 100644 --- a/backend/services/installable/installableReconciler.ts +++ b/backend/services/installable/installableReconciler.ts @@ -79,7 +79,13 @@ const sweepActiveInstallations = async (): Promise<{ staleComponents: number; cl if (integration) continue; const result = await InstallableInstallation.updateOne( { _id: installation._id, status: 'active' }, - { $set: { 'components.$[].status': 'stale' } }, + { + $set: { + status: 'error', + errorMessage: 'projection missing', + 'components.$[].status': 'stale', + }, + }, ); staleComponents += result.modifiedCount || 0; } From 977ac11c1e4a291ccdf870e116c6da5b408153f2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:51:01 -0700 Subject: [PATCH 4/6] fix: redact catalog pause administrators --- .../unit/routes/installables.test.js | 14 ++++++++++++- .../installableCatalogService.test.js | 21 ++++++++++++++++--- .../installable/installableCatalogService.ts | 5 +++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/unit/routes/installables.test.js b/backend/__tests__/unit/routes/installables.test.js index ae77961e46..ceccb7bb89 100644 --- a/backend/__tests__/unit/routes/installables.test.js +++ b/backend/__tests__/unit/routes/installables.test.js @@ -15,7 +15,19 @@ jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); jest.mock('../../../services/installable/installableCatalogService', () => ({ catalogFor: jest.fn(), providerReadiness: jest.fn(() => ({ available: true })), - publicIntegration: jest.fn((integration) => integration), + publicIntegration: jest.fn((integration) => { + if (!integration?.config?.adminPause) return integration; + return { + ...integration, + config: { + ...integration.config, + adminPause: { + reason: integration.config.adminPause.reason, + at: integration.config.adminPause.at, + }, + }, + }; + }), })); jest.mock('../../../services/installable/installableInstallationService', () => ({ install: jest.fn(), diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js index 35d868eed4..ac524df218 100644 --- a/backend/__tests__/unit/services/installableCatalogService.test.js +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -42,7 +42,16 @@ describe('installable catalog service', () => { Integration.find.mockReturnValue(lean([{ installationId, type: 'telegram', - config: { botTokenRef: 'secret-ref', oauthStateNonce: 'nonce', chatTitle: 'Ops' }, + config: { + botTokenRef: 'secret-ref', + oauthStateNonce: 'nonce', + chatTitle: 'Ops', + adminPause: { + reason: 'Safety review in progress.', + at: '2026-09-05T08:48:00.000Z', + adminId: 'admin-private-id', + }, + }, }])); const catalog = await catalogFor(userId); @@ -61,7 +70,13 @@ describe('installable catalog service', () => { }, integration: expect.objectContaining({ installationId, - config: { chatTitle: 'Ops' }, + config: { + chatTitle: 'Ops', + adminPause: { + reason: 'Safety review in progress.', + at: '2026-09-05T08:48:00.000Z', + }, + }, }), }), expect.objectContaining({ @@ -73,7 +88,7 @@ describe('installable catalog service', () => { }), ]); expect(InstallableInstallation.find).toHaveBeenCalledWith(expect.objectContaining({ targetId: userId })); - expect(JSON.stringify(catalog)).not.toMatch(/SLACK_|CONNECTOR_SECRET|private-claim|secret-ref|nonce/); + expect(JSON.stringify(catalog)).not.toMatch(/SLACK_|CONNECTOR_SECRET|private-claim|secret-ref|nonce|adminId|admin-private-id/); }); it('does not read projections when the caller has no live parent', async () => { diff --git a/backend/services/installable/installableCatalogService.ts b/backend/services/installable/installableCatalogService.ts index 3b9a71eb44..8c33270770 100644 --- a/backend/services/installable/installableCatalogService.ts +++ b/backend/services/installable/installableCatalogService.ts @@ -40,6 +40,11 @@ const publicIntegration = (integration: unknown): unknown => { delete result.config.oauthStateNonce; const pending = result.config.pendingBind; if (pending && typeof pending === 'object') delete (pending as Record).botTokenRef; + const adminPause = result.config.adminPause; + if (adminPause && typeof adminPause === 'object') { + const { reason, at } = adminPause as { reason?: unknown; at?: unknown }; + result.config.adminPause = { reason, at }; + } return result; }; From a37cfe68decf51d6b0f75220d835f205592eb67a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:34:06 -0700 Subject: [PATCH 5/6] fix: rate limit installable catalog reads --- backend/__tests__/unit/routes/installables.test.js | 10 ++++++++++ backend/routes/installables.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/routes/installables.test.js b/backend/__tests__/unit/routes/installables.test.js index ceccb7bb89..1c4845e429 100644 --- a/backend/__tests__/unit/routes/installables.test.js +++ b/backend/__tests__/unit/routes/installables.test.js @@ -9,6 +9,7 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => { jest.mock('../../../middleware/integrationRateLimit', () => ({ writeIntegrationsRateLimit: (_req, _res, next) => next(), + listIntegrationsRateLimit: (_req, _res, next) => next(), })); jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); @@ -50,6 +51,7 @@ jest.mock('../../../services/installable/installableInstallationService', () => })); const Pod = require('../../../models/Pod'); +const integrationRateLimit = require('../../../middleware/integrationRateLimit'); const catalogService = require('../../../services/installable/installableCatalogService'); const installationService = require('../../../services/installable/installableInstallationService'); const installableRoutes = require('../../../routes/installables'); @@ -74,6 +76,14 @@ describe('installable connector routes', () => { expect(catalogService.catalogFor).not.toHaveBeenCalled(); }); + it('mounts the catalog limiter before auth', () => { + const catalogRoute = installableRoutes.stack.find((layer) => layer.route + && layer.route.path === '/' + && layer.route.methods.get); + const catalogHandles = catalogRoute.route.stack.map((layer) => layer.handle); + expect(catalogHandles[0]).toBe(integrationRateLimit.listIntegrationsRateLimit); + }); + it('returns readiness enums and only the caller\'s safe parent/projection fields', async () => { const installationId = '64b64c48c4f37a6b2f34c444'; catalogService.catalogFor.mockResolvedValue({ installables: [{ installationId }] }); diff --git a/backend/routes/installables.ts b/backend/routes/installables.ts index 3163ead108..16e689e433 100644 --- a/backend/routes/installables.ts +++ b/backend/routes/installables.ts @@ -33,7 +33,7 @@ const { exchangeCode, } = require('../services/slackOAuthService'); import { Types } from 'mongoose'; -import { writeIntegrationsRateLimit } from '../middleware/integrationRateLimit'; +import { listIntegrationsRateLimit, writeIntegrationsRateLimit } from '../middleware/integrationRateLimit'; // eslint-disable-next-line global-require const { InstallLockLostError, @@ -137,7 +137,7 @@ const slackCallbackRedirect = (res: Res, state: 'pending' | 'error', code?: stri * The authenticated, user-scoped connector catalog. It intentionally sits * beside (rather than replaces) the legacy integration manifest catalog. */ -router.get('/', auth, async (req: AuthReq, res: Res) => { +router.get('/', listIntegrationsRateLimit, auth, async (req: AuthReq, res: Res) => { const userId = requesterId(req); if (!userId) return res.status(401).json({ error: 'Unauthorized' }); try { From 3a201890644f74708e8b0753f9074de0bc3e9e3f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:43:14 -0700 Subject: [PATCH 6/6] test: mock installable catalog limiter --- backend/__tests__/unit/routes/slackOAuth.installables.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/__tests__/unit/routes/slackOAuth.installables.test.js b/backend/__tests__/unit/routes/slackOAuth.installables.test.js index 3810ac385a..8f82105ad9 100644 --- a/backend/__tests__/unit/routes/slackOAuth.installables.test.js +++ b/backend/__tests__/unit/routes/slackOAuth.installables.test.js @@ -10,6 +10,7 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => { }); jest.mock('../../../middleware/integrationRateLimit', () => ({ writeIntegrationsRateLimit: (_req, _res, next) => next(), + listIntegrationsRateLimit: (_req, _res, next) => next(), })); jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); jest.mock('../../../models/Integration', () => ({