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..1c4845e429 100644 --- a/backend/__tests__/unit/routes/installables.test.js +++ b/backend/__tests__/unit/routes/installables.test.js @@ -9,9 +9,27 @@ 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('../../../services/installable/installableCatalogService', () => ({ + catalogFor: jest.fn(), + providerReadiness: jest.fn(() => ({ available: true })), + 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(), uninstall: jest.fn(), @@ -33,6 +51,8 @@ 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'); @@ -46,6 +66,47 @@ 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('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 }] }); + + 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/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', () => ({ diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js new file mode 100644 index 0000000000..ac524df218 --- /dev/null +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -0,0 +1,103 @@ +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', + adminPause: { + reason: 'Safety review in progress.', + at: '2026-09-05T08:48:00.000Z', + adminId: 'admin-private-id', + }, + }, + }])); + + 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', + adminPause: { + reason: 'Safety review in progress.', + at: '2026-09-05T08:48:00.000Z', + }, + }, + }), + }), + 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|adminId|admin-private-id/); + }); + + 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..16e689e433 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 { @@ -28,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, @@ -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('/', listIntegrationsRateLimit, 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..8c33270770 --- /dev/null +++ b/backend/services/installable/installableCatalogService.ts @@ -0,0 +1,122 @@ +// 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; + 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; +}; + +// 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; }