diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6a5e372111..1eb7a6940e 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1318,7 +1318,7 @@ async function main(): Promise { auditStore: authAuditStore, io: socketManager.getIO(), }); - await app.register(callbackAuthRoutes, { registry, authManager }); + await app.register(callbackAuthRoutes, { authManager, registry }); await app.register(authorizationRoutes, { authManager, ruleStore: authRuleStore, diff --git a/packages/api/src/routes/callback-auth-prehandler.ts b/packages/api/src/routes/callback-auth-prehandler.ts new file mode 100644 index 0000000000..db0c1f9546 --- /dev/null +++ b/packages/api/src/routes/callback-auth-prehandler.ts @@ -0,0 +1,102 @@ +/** + * Unified callback auth preHandler (#476) + * + * Extracts X-Invocation-Id + X-Callback-Token from HTTP headers, + * verifies via InvocationRegistry, and decorates request.callbackAuth. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { InvocationRecord } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; +import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; + +declare module 'fastify' { + interface FastifyRequest { + callbackAuth?: InvocationRecord; + } +} + +interface CallbackAuthRegistry { + verify(invocationId: string, callbackToken: string): InvocationRecord | null; +} + +/** Register the callbackAuth decoration + preHandler on a Fastify instance. + * + * Behavior: + * 1. Try X-Invocation-Id + X-Callback-Token headers (preferred) + * 2. Fallback: read from body/query (legacy compat window, logs deprecation) + * 3. Neither present → no-op (panel / non-callback request) + * 4. Credentials present but invalid → immediate 401 (fail-closed, #474) + */ +export function registerCallbackAuthHook(app: FastifyInstance, registry: CallbackAuthRegistry): void { + if (!app.hasRequestDecorator('callbackAuth')) { + app.decorateRequest('callbackAuth', undefined); + } + app.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => { + let invocationId = firstHeaderValue(request.headers['x-invocation-id']); + let callbackToken = firstHeaderValue(request.headers['x-callback-token']); + let legacy = false; + + // Fallback: body/query for legacy MCP clients (#476 compat window) + if (!invocationId && !callbackToken) { + const fromBody = extractLegacyCredentials(request); + if (fromBody) { + invocationId = fromBody.invocationId; + callbackToken = fromBody.callbackToken; + legacy = true; + } + } + + if (!invocationId && !callbackToken) return; + if (!invocationId || !callbackToken) { + reply.status(401).send(EXPIRED_CREDENTIALS_ERROR); + return; + } + const record = registry.verify(invocationId, callbackToken); + if (!record) { + reply.status(401).send(EXPIRED_CREDENTIALS_ERROR); + return; + } + if (legacy) { + request.log.warn( + { invocationId, path: request.url }, + '[#476 DEPRECATED] Callback credentials received via body/query — migrate to X-Invocation-Id / X-Callback-Token headers', + ); + } + request.callbackAuth = record; + }); +} + +/** Extract legacy credentials from body (POST) or query (GET). + * Returns partial results so the caller's `!id || !token` guard + * rejects malformed requests (fail-closed, consistent with headers). */ +function extractLegacyCredentials( + request: FastifyRequest, +): { invocationId: string | undefined; callbackToken: string | undefined } | null { + const body = request.body as Record | undefined; + if (body) { + const id = typeof body.invocationId === 'string' ? body.invocationId : undefined; + const tok = typeof body.callbackToken === 'string' ? body.callbackToken : undefined; + if (id || tok) return { invocationId: id, callbackToken: tok }; + } + const query = request.query as Record | undefined; + if (query) { + const id = typeof query.invocationId === 'string' ? query.invocationId : undefined; + const tok = typeof query.callbackToken === 'string' ? query.callbackToken : undefined; + if (id || tok) return { invocationId: id, callbackToken: tok }; + } + return null; +} + +/** Require callbackAuth on the request — returns record or sends 401. */ +export function requireCallbackAuth(request: FastifyRequest, reply: FastifyReply): InvocationRecord | null { + if (request.callbackAuth) return request.callbackAuth; + reply.status(401); + reply.send(EXPIRED_CREDENTIALS_ERROR); + return null; +} + +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + if (typeof value === 'string') return value || undefined; + if (Array.isArray(value)) return value[0] || undefined; + return undefined; +} diff --git a/packages/api/src/routes/callback-auth-schema.ts b/packages/api/src/routes/callback-auth-schema.ts deleted file mode 100644 index 496ee4a879..0000000000 --- a/packages/api/src/routes/callback-auth-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const callbackAuthSchema = z.object({ - invocationId: z.string().min(1), - callbackToken: z.string().min(1), -}); diff --git a/packages/api/src/routes/callback-auth.ts b/packages/api/src/routes/callback-auth.ts index d9c55a3cb0..fb10456dc9 100644 --- a/packages/api/src/routes/callback-auth.ts +++ b/packages/api/src/routes/callback-auth.ts @@ -7,50 +7,45 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { AuthorizationManager } from '../domains/cats/services/auth/AuthorizationManager.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { registerCallbackAuthHook, requireCallbackAuth } from './callback-auth-prehandler.js'; export interface CallbackAuthRoutesOptions { - registry: InvocationRegistry; authManager: AuthorizationManager; + registry: InvocationRegistry; } const requestPermissionSchema = z.object({ - invocationId: z.string().min(1), - callbackToken: z.string().min(1), action: z.string().min(1).max(200), reason: z.string().min(1).max(2000), context: z.string().max(5000).optional(), }); const permissionStatusSchema = z.object({ - invocationId: z.string().min(1), - callbackToken: z.string().min(1), requestId: z.string().min(1), }); export const callbackAuthRoutes: FastifyPluginAsync = async (app, opts) => { - const { registry, authManager } = opts; + const { authManager, registry } = opts; + registerCallbackAuthHook(app, registry); // POST /api/callbacks/request-permission app.post('/api/callbacks/request-permission', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parseResult = requestPermissionSchema.safeParse(request.body); if (!parseResult.success) { reply.status(400); return { error: 'Invalid request body', details: parseResult.error.issues }; } - const { invocationId, callbackToken, action, reason, context } = parseResult.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { action, reason, context } = parseResult.data; const response = await authManager.requestPermission( record.catId, record.threadId, { - invocationId, + invocationId: record.invocationId, action, reason, ...(context ? { context } : {}), @@ -63,18 +58,16 @@ export const callbackAuthRoutes: FastifyPluginAsync = // GET /api/callbacks/permission-status app.get('/api/callbacks/permission-status', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parseResult = permissionStatusSchema.safeParse(request.query); if (!parseResult.success) { reply.status(400); return { error: 'Missing required query parameters' }; } - const { invocationId, callbackToken, requestId } = parseResult.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { requestId } = parseResult.data; const status = await authManager.getRequestStatus(requestId); if (!status) { @@ -83,7 +76,11 @@ export const callbackAuthRoutes: FastifyPluginAsync = } // P2 fix: 校验 requestId 严格归属当前 invocation - if (status.invocationId !== invocationId || status.catId !== record.catId || status.threadId !== record.threadId) { + if ( + status.invocationId !== record.invocationId || + status.catId !== record.catId || + status.threadId !== record.threadId + ) { reply.status(403); return { error: 'Permission request belongs to a different invocation' }; } diff --git a/packages/api/src/routes/callback-bootcamp-routes.ts b/packages/api/src/routes/callback-bootcamp-routes.ts index 86010e535c..4b22aabada 100644 --- a/packages/api/src/routes/callback-bootcamp-routes.ts +++ b/packages/api/src/routes/callback-bootcamp-routes.ts @@ -11,8 +11,7 @@ import type { InvocationRegistry } from '../domains/cats/services/agents/invocat import { runEnvironmentCheck } from '../domains/cats/services/bootcamp/env-check.js'; import type { BootcampStateV1, IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { BOOTCAMP_PHASE_ACHIEVEMENTS } from '../domains/leaderboard/achievement-defs.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; /** Ordered phase list — index determines valid transitions (forward-only) */ const PHASE_ORDER = [ @@ -35,7 +34,7 @@ const PHASE_INDEX = new Map(PHASE_ORDER.map((p, i) => [p, i])); const bootcampPhaseSchema = z.enum([...PHASE_ORDER]); -const updateBootcampStateCallbackSchema = callbackAuthSchema.extend({ +const updateBootcampStateCallbackSchema = z.object({ threadId: z.string().min(1), phase: bootcampPhaseSchema.optional(), leadCat: catIdSchema().optional(), @@ -60,21 +59,19 @@ export function registerCallbackBootcampRoutes( const { registry, threadStore } = deps; app.post('/api/callbacks/update-bootcamp-state', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = updateBootcampStateCallbackSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, threadId, ...updates } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { threadId, ...updates } = parsed.data; // P2: Stale invocation guard — ignore if superseded by newer invocation - if (!registry.isLatest(invocationId)) { + if (!registry.isLatest(record.invocationId)) { return { status: 'stale_ignored' }; } @@ -169,26 +166,24 @@ export function registerCallbackBootcampRoutes( }); // POST /api/callbacks/bootcamp-env-check — run env check and auto-store results - const envCheckCallbackSchema = callbackAuthSchema.extend({ + const envCheckCallbackSchema = z.object({ threadId: z.string().min(1), }); app.post('/api/callbacks/bootcamp-env-check', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = envCheckCallbackSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, threadId } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { threadId } = parsed.data; // P2: Stale invocation guard - if (!registry.isLatest(invocationId)) { + if (!registry.isLatest(record.invocationId)) { return { status: 'stale_ignored' }; } diff --git a/packages/api/src/routes/callback-document-routes.ts b/packages/api/src/routes/callback-document-routes.ts index 05a6c6fad7..c2200a2119 100644 --- a/packages/api/src/routes/callback-document-routes.ts +++ b/packages/api/src/routes/callback-document-routes.ts @@ -16,10 +16,9 @@ import type { InvocationRegistry } from '../domains/cats/services/agents/invocat import { getRichBlockBuffer } from '../domains/cats/services/agents/invocation/RichBlockBuffer.js'; import { PandocService } from '../infrastructure/document/PandocService.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; -const generateDocumentSchema = callbackAuthSchema.extend({ +const generateDocumentSchema = z.object({ /** Markdown content to convert */ markdown: z.string().min(1).max(500_000), /** Desired output format */ @@ -38,19 +37,17 @@ export function registerCallbackDocumentRoutes( const pandocService = new PandocService(app.log); app.post('/api/callbacks/generate-document', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = generateDocumentSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, markdown, format, baseName } = parsed.data; - - const record = deps.registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { markdown, format, baseName } = parsed.data; + const invocationId = record.invocationId; if (!deps.registry.isLatest(invocationId)) { return { status: 'stale_ignored' }; diff --git a/packages/api/src/routes/callback-game-routes.ts b/packages/api/src/routes/callback-game-routes.ts index 97c3d29750..7cf2a9fbcf 100644 --- a/packages/api/src/routes/callback-game-routes.ts +++ b/packages/api/src/routes/callback-game-routes.ts @@ -10,11 +10,9 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; -const submitGameActionSchema = callbackAuthSchema.extend({ +const submitGameActionSchema = z.object({ gameId: z.string().min(1), round: z.number().int().min(1), phase: z.string().min(1), @@ -25,22 +23,18 @@ const submitGameActionSchema = callbackAuthSchema.extend({ nonce: z.string().min(1).max(200), }); -export function registerCallbackGameRoutes(app: FastifyInstance, deps: { registry: InvocationRegistry }): void { - const { registry } = deps; - +export function registerCallbackGameRoutes(app: FastifyInstance): void { app.post('/api/callbacks/submit-game-action', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = submitGameActionSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, gameId, round, phase, seat, action, target, text, nonce } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { gameId, round, phase, seat, action, target, text, nonce } = parsed.data; // Proxy to existing game action route — reuses all validation + nonce dedup // Pass invocation threadId so downstream enforces thread-game isolation (P1 fix) diff --git a/packages/api/src/routes/callback-guide-routes.ts b/packages/api/src/routes/callback-guide-routes.ts index 3574786935..6832eb3186 100644 --- a/packages/api/src/routes/callback-guide-routes.ts +++ b/packages/api/src/routes/callback-guide-routes.ts @@ -15,30 +15,29 @@ import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadS import { GuideLifecycleService } from '../domains/guides/GuideLifecycleService.js'; import { createGuideStoreBridge, type IGuideSessionStore } from '../domains/guides/GuideSessionRepository.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; // --------------------------------------------------------------------------- // Schemas // --------------------------------------------------------------------------- const guideStatusSchema = z.enum(['offered', 'awaiting_choice', 'active', 'completed', 'cancelled']); -const updateGuideStateSchema = callbackAuthSchema.extend({ +const updateGuideStateSchema = z.object({ threadId: z.string().min(1), guideId: z.string().min(1), status: guideStatusSchema, currentStep: z.number().int().min(0).optional(), }); -const startGuideSchema = callbackAuthSchema.extend({ +const startGuideSchema = z.object({ guideId: z.string().min(1), }); -const resolveGuideSchema = callbackAuthSchema.extend({ +const resolveGuideSchema = z.object({ intent: z.string().min(1), }); -const controlGuideSchema = callbackAuthSchema.extend({ +const controlGuideSchema = z.object({ action: z.enum(['next', 'skip', 'exit']), }); @@ -77,19 +76,17 @@ export async function registerCallbackGuideRoutes( // POST /api/callbacks/update-guide-state app.post('/api/callbacks/update-guide-state', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = updateGuideStateSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, threadId, guideId, status, currentStep } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } - if (!registry.isLatest(invocationId)) return { status: 'stale_ignored' }; + const { threadId, guideId, status, currentStep } = parsed.data; + if (!registry.isLatest(record.invocationId)) return { status: 'stale_ignored' }; if (record.threadId !== threadId) { reply.status(403); return { error: 'Cross-thread write rejected' }; @@ -114,18 +111,16 @@ export async function registerCallbackGuideRoutes( // POST /api/callbacks/start-guide app.post('/api/callbacks/start-guide', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = startGuideSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request', details: parsed.error.issues }; } - const { invocationId, callbackToken, guideId } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } - if (!registry.isLatest(invocationId)) return { status: 'stale_ignored' }; + const { guideId } = parsed.data; + if (!registry.isLatest(record.invocationId)) return { status: 'stale_ignored' }; const result = await lifecycle.startGuideCallback({ threadId: record.threadId, @@ -141,17 +136,15 @@ export async function registerCallbackGuideRoutes( // POST /api/callbacks/guide-resolve app.post('/api/callbacks/guide-resolve', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = resolveGuideSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request', details: parsed.error.issues }; } - const { invocationId, callbackToken, intent } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { intent } = parsed.data; const matches = resolveGuideForIntent(intent); app.log.info({ intent, matchCount: matches.length, threadId: record.threadId }, '[F155] guide_resolve'); @@ -160,18 +153,16 @@ export async function registerCallbackGuideRoutes( // POST /api/callbacks/guide-control app.post('/api/callbacks/guide-control', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = controlGuideSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request', details: parsed.error.issues }; } - const { invocationId, callbackToken, action } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } - if (!registry.isLatest(invocationId)) return { status: 'stale_ignored' }; + const { action } = parsed.data; + if (!registry.isLatest(record.invocationId)) return { status: 'stale_ignored' }; const result = await lifecycle.controlGuide({ threadId: record.threadId, diff --git a/packages/api/src/routes/callback-limb-routes.ts b/packages/api/src/routes/callback-limb-routes.ts index 7e583c4afa..f3e7bf50e5 100644 --- a/packages/api/src/routes/callback-limb-routes.ts +++ b/packages/api/src/routes/callback-limb-routes.ts @@ -7,46 +7,42 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { LimbPairingStore } from '../domains/limb/LimbPairingStore.js'; import type { LimbRegistry } from '../domains/limb/LimbRegistry.js'; import { RemoteLimbNode } from '../domains/limb/RemoteLimbNode.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; -const limbListSchema = callbackAuthSchema.extend({ +const limbListSchema = z.object({ capability: z.string().optional(), }); -const limbInvokeSchema = callbackAuthSchema.extend({ +const limbInvokeSchema = z.object({ nodeId: z.string().min(1), command: z.string().min(1), params: z.record(z.unknown()).optional(), }); -const limbPairApproveSchema = callbackAuthSchema.extend({ +const limbPairApproveSchema = z.object({ requestId: z.string().min(1), }); export interface CallbackLimbRoutesOptions { limbRegistry: LimbRegistry; - invocationRegistry: InvocationRegistry; pairingStore?: LimbPairingStore; } export function registerCallbackLimbRoutes( app: FastifyInstance, - { limbRegistry, invocationRegistry, pairingStore }: CallbackLimbRoutesOptions, + { limbRegistry, pairingStore }: CallbackLimbRoutesOptions, ): void { app.post('/api/callback/limb/list', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = limbListSchema.safeParse(request.body); if (!parsed.success) return reply.status(400).send({ error: parsed.error.message }); - const { invocationId, callbackToken, capability } = parsed.data; - const record = invocationRegistry.verify(invocationId, callbackToken); - if (!record) { - return reply.status(403).send({ error: EXPIRED_CREDENTIALS_ERROR }); - } + const { capability } = parsed.data; const nodes = capability ? limbRegistry.findByCapability(capability) : limbRegistry.listAvailable(); @@ -62,14 +58,13 @@ export function registerCallbackLimbRoutes( }); app.post('/api/callback/limb/invoke', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = limbInvokeSchema.safeParse(request.body); if (!parsed.success) return reply.status(400).send({ error: parsed.error.message }); - const { invocationId, callbackToken, nodeId, command, params } = parsed.data; - const record = invocationRegistry.verify(invocationId, callbackToken); - if (!record) { - return reply.status(403).send({ error: EXPIRED_CREDENTIALS_ERROR }); - } + const { nodeId, command, params } = parsed.data; const result = await limbRegistry.invoke(nodeId, command, params ?? {}, { catId: record.catId, @@ -81,22 +76,19 @@ export function registerCallbackLimbRoutes( // Phase C: Pairing callback routes (for MCP tools) if (pairingStore) { app.post('/api/callback/limb/pair/list', async (request, reply) => { - const parsed = callbackAuthSchema.safeParse(request.body); - if (!parsed.success) return reply.status(400).send({ error: parsed.error.message }); - - const record = invocationRegistry.verify(parsed.data.invocationId, parsed.data.callbackToken); - if (!record) return reply.status(403).send({ error: EXPIRED_CREDENTIALS_ERROR }); + const record = requireCallbackAuth(request, reply); + if (!record) return; return reply.send({ requests: pairingStore.getPending() }); }); app.post('/api/callback/limb/pair/approve', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = limbPairApproveSchema.safeParse(request.body); if (!parsed.success) return reply.status(400).send({ error: parsed.error.message }); - const record = invocationRegistry.verify(parsed.data.invocationId, parsed.data.callbackToken); - if (!record) return reply.status(403).send({ error: EXPIRED_CREDENTIALS_ERROR }); - const req = pairingStore.approve(parsed.data.requestId); if (!req) return reply.status(404).send({ error: 'Pairing request not found' }); diff --git a/packages/api/src/routes/callback-memory-routes.ts b/packages/api/src/routes/callback-memory-routes.ts index 12fc03bc61..46207c1724 100644 --- a/packages/api/src/routes/callback-memory-routes.ts +++ b/packages/api/src/routes/callback-memory-routes.ts @@ -1,27 +1,24 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { IEvidenceStore, IMarkerQueue, IReflectionService } from '../domains/memory/interfaces.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; interface CallbackMemoryRoutesDeps { - registry: InvocationRegistry; /** F102: DI — SQLite-backed services (required) */ evidenceStore: IEvidenceStore; markerQueue: IMarkerQueue; reflectionService: IReflectionService; } -const searchEvidenceQuerySchema = callbackAuthSchema.extend({ +const searchEvidenceQuerySchema = z.object({ q: z.string().min(1), limit: z.coerce.number().int().min(1).max(20).optional(), }); -const reflectSchema = callbackAuthSchema.extend({ +const reflectSchema = z.object({ query: z.string().trim().min(1), }); -const retainMemorySchema = callbackAuthSchema.extend({ +const retainMemorySchema = z.object({ content: z.string().trim().min(1).max(50000), tags: z.union([z.string(), z.array(z.string())]).optional(), metadata: z.record(z.string()).optional(), @@ -31,20 +28,16 @@ export async function registerCallbackMemoryRoutes( app: FastifyInstance, deps: CallbackMemoryRoutesDeps, ): Promise { - const { registry } = deps; - app.get('/api/callbacks/search-evidence', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = searchEvidenceQuerySchema.safeParse(request.query); if (!parsed.success) { reply.status(400); return { error: 'Invalid query parameters', details: parsed.error.issues }; } - const { invocationId, callbackToken, q, limit } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { q, limit } = parsed.data; try { const items = await deps.evidenceStore.search(q, { limit: limit ?? 5 }); @@ -65,17 +58,15 @@ export async function registerCallbackMemoryRoutes( }); app.post('/api/callbacks/reflect', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = reflectSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, query } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { query } = parsed.data; try { const reflection = await deps.reflectionService.reflect(query); @@ -91,22 +82,20 @@ export async function registerCallbackMemoryRoutes( }); app.post('/api/callbacks/retain-memory', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = retainMemorySchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, content } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { content } = parsed.data; try { await deps.markerQueue.submit({ content, - source: `callback:${record.catId}:${invocationId}`, + source: `callback:${record.catId}:${record.invocationId}`, status: 'captured', }); return { status: 'ok' }; diff --git a/packages/api/src/routes/callback-multi-mention-routes.ts b/packages/api/src/routes/callback-multi-mention-routes.ts index c4f59bc3d1..e810258b69 100644 --- a/packages/api/src/routes/callback-multi-mention-routes.ts +++ b/packages/api/src/routes/callback-multi-mention-routes.ts @@ -9,7 +9,6 @@ import { type CatId, catRegistry, createCatId, DEFAULT_TIMEOUT_MINUTES } from '@ import type { FastifyBaseLogger, FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; import { type MultiMentionCreateParams, @@ -20,7 +19,7 @@ import type { AgentRouter } from '../domains/cats/services/index.js'; import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; // ── Singleton orchestrator ─────────────────────────────────────────── let globalOrchestrator: MultiMentionOrchestrator | undefined; @@ -36,7 +35,7 @@ export function resetMultiMentionOrchestrator(): void { } // ── Schema ─────────────────────────────────────────────────────────── -const multiMentionSchema = callbackAuthSchema.extend({ +const multiMentionSchema = z.object({ targets: z.array(z.string().min(1)).min(1).max(3), question: z.string().min(1).max(5000), callbackTo: z.string().min(1), @@ -48,13 +47,12 @@ const multiMentionSchema = callbackAuthSchema.extend({ triggerType: z.string().optional(), }); -const multiMentionStatusSchema = callbackAuthSchema.extend({ +const multiMentionStatusSchema = z.object({ requestId: z.string().min(1), }); // ── Deps ───────────────────────────────────────────────────────────── export interface MultiMentionRouteDeps { - registry: InvocationRegistry; messageStore: IMessageStore; socketManager: SocketManager; router: AgentRouter; @@ -420,17 +418,12 @@ async function flushResult( // ── Route registration ─────────────────────────────────────────────── export function registerMultiMentionRoutes(app: FastifyInstance, deps: MultiMentionRouteDeps): void { - const { registry } = deps; - // POST /api/callbacks/multi-mention app.post<{ Body: z.infer }>('/api/callbacks/multi-mention', async (request, reply) => { - const body = multiMentionSchema.parse(request.body); + const record = requireCallbackAuth(request, reply); + if (!record) return; - // Auth: verify invocation - const record = registry.verify(body.invocationId, body.callbackToken); - if (!record) { - return reply.status(401).send({ error: 'Invalid or expired callback credentials' }); - } + const body = multiMentionSchema.parse(request.body); // Validate all targets are registered cats const targetCatIds: CatId[] = []; @@ -532,12 +525,10 @@ export function registerMultiMentionRoutes(app: FastifyInstance, deps: MultiMent app.get<{ Querystring: z.infer }>( '/api/callbacks/multi-mention-status', async (request, reply) => { - const query = multiMentionStatusSchema.parse(request.query); + const record = requireCallbackAuth(request, reply); + if (!record) return; - const record = registry.verify(query.invocationId, query.callbackToken); - if (!record) { - return reply.status(401).send({ error: 'Invalid or expired callback credentials' }); - } + const query = multiMentionStatusSchema.parse(request.query); const orch = getMultiMentionOrchestrator(); try { diff --git a/packages/api/src/routes/callback-task-routes.ts b/packages/api/src/routes/callback-task-routes.ts index 80cace171f..cbf9d521e7 100644 --- a/packages/api/src/routes/callback-task-routes.ts +++ b/packages/api/src/routes/callback-task-routes.ts @@ -5,20 +5,18 @@ import { catRegistry } from '@cat-cafe/shared'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { ITaskStore } from '../domains/cats/services/stores/ports/TaskStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { SocketManager } from '../infrastructure/websocket/index.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; -const updateTaskSchema = callbackAuthSchema.extend({ +const updateTaskSchema = z.object({ taskId: z.string().min(1), status: z.enum(['todo', 'doing', 'blocked', 'done']).optional(), why: z.string().max(1000).optional(), }); -const listTasksQuerySchema = callbackAuthSchema.extend({ +const listTasksQuerySchema = z.object({ threadId: z.string().min(1).optional(), catId: z.string().min(1).optional(), status: z.enum(['todo', 'doing', 'blocked', 'done']).optional(), @@ -28,27 +26,24 @@ const listTasksQuerySchema = callbackAuthSchema.extend({ export function registerCallbackTaskRoutes( app: FastifyInstance, deps: { - registry: InvocationRegistry; taskStore: ITaskStore; socketManager: SocketManager; threadStore?: IThreadStore; }, ): void { - const { registry, taskStore, socketManager, threadStore } = deps; + const { taskStore, socketManager, threadStore } = deps; app.post('/api/callbacks/update-task', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = updateTaskSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, taskId, status, why } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { taskId, status, why } = parsed.data; const existing = await taskStore.get(taskId); if (!existing) { @@ -79,18 +74,16 @@ export function registerCallbackTaskRoutes( }); app.get('/api/callbacks/list-tasks', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = listTasksQuerySchema.safeParse(request.query); if (!parsed.success) { reply.status(400); return { error: 'Invalid request query', details: parsed.error.issues }; } - const { invocationId, callbackToken, threadId, catId, status, kind } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { threadId, catId, status, kind } = parsed.data; if (catId && !catRegistry.has(catId)) { reply.status(400); @@ -118,7 +111,7 @@ export function registerCallbackTaskRoutes( scopedThreadIds = userThreads.map((item) => item.id); } else { app.log.warn( - { userId: record.userId, invocationId }, + { userId: record.userId, invocationId: record.invocationId }, '[callbacks/list-tasks] threadStore unavailable, falling back to current thread only', ); scopedThreadIds = [record.threadId]; diff --git a/packages/api/src/routes/callback-thread-cats-routes.ts b/packages/api/src/routes/callback-thread-cats-routes.ts index 06864f3dde..cae832313b 100644 --- a/packages/api/src/routes/callback-thread-cats-routes.ts +++ b/packages/api/src/routes/callback-thread-cats-routes.ts @@ -6,36 +6,21 @@ import { catRegistry } from '@cat-cafe/shared'; import type { FastifyInstance } from 'fastify'; import { isCatAvailable } from '../config/cat-config-loader.js'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; import { categorizeThreadCats } from './thread-cats-core.js'; interface ThreadCatsCallbackDeps { - registry: InvocationRegistry; threadStore: IThreadStore; agentRegistry: { getAllEntries(): Map }; } -const threadCatsQuerySchema = callbackAuthSchema; - export function registerCallbackThreadCatsRoutes(app: FastifyInstance, deps: ThreadCatsCallbackDeps): void { - const { registry, threadStore, agentRegistry } = deps; + const { threadStore, agentRegistry } = deps; app.get('/api/callbacks/thread-cats', async (request, reply) => { - const parsed = threadCatsQuerySchema.safeParse(request.query); - if (!parsed.success) { - reply.status(400); - return { error: 'Missing invocationId or callbackToken' }; - } - - const { invocationId, callbackToken } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const record = requireCallbackAuth(request, reply); + if (!record) return; const threadId = record.threadId; if (!threadId) { diff --git a/packages/api/src/routes/callback-workflow-sop-routes.ts b/packages/api/src/routes/callback-workflow-sop-routes.ts index 42fb4df302..e86b3fa6aa 100644 --- a/packages/api/src/routes/callback-workflow-sop-routes.ts +++ b/packages/api/src/routes/callback-workflow-sop-routes.ts @@ -1,13 +1,11 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { IBacklogStore } from '../domains/cats/services/stores/ports/BacklogStore.js'; import type { IWorkflowSopStore } from '../domains/cats/services/stores/ports/WorkflowSopStore.js'; import { VersionConflictError } from '../domains/cats/services/stores/ports/WorkflowSopStore.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; +import { requireCallbackAuth } from './callback-auth-prehandler.js'; -const updateWorkflowSopCallbackSchema = callbackAuthSchema.extend({ +const updateWorkflowSopCallbackSchema = z.object({ backlogItemId: z.string().min(1), featureId: z.string().min(1), stage: z.enum(['kickoff', 'impl', 'quality_gate', 'review', 'merge', 'completion']).optional(), @@ -34,26 +32,23 @@ const updateWorkflowSopCallbackSchema = callbackAuthSchema.extend({ export function registerCallbackWorkflowSopRoutes( app: FastifyInstance, deps: { - registry: InvocationRegistry; workflowSopStore: IWorkflowSopStore; backlogStore: IBacklogStore; }, ): void { - const { registry, workflowSopStore, backlogStore } = deps; + const { workflowSopStore, backlogStore } = deps; app.post('/api/callbacks/update-workflow-sop', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = updateWorkflowSopCallbackSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, backlogItemId, featureId, ...rest } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { backlogItemId, featureId, ...rest } = parsed.data; // Verify backlog item exists and belongs to this user (P1-2: user scope) const item = await backlogStore.get(backlogItemId, record.userId); diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 7815f85174..e8984a9413 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -29,10 +29,10 @@ import type { SocketManager } from '../infrastructure/websocket/index.js'; import { scoreKeywordRelevance, tokenizeKeyword } from '../utils/keyword-relevance.js'; import { getFeatureTagId } from './backlog-doc-import.js'; import { enqueueA2ATargets, triggerA2AInvocation } from './callback-a2a-trigger.js'; -import { callbackAuthSchema } from './callback-auth-schema.js'; +import { registerCallbackAuthHook, requireCallbackAuth } from './callback-auth-prehandler.js'; import { registerCallbackBootcampRoutes } from './callback-bootcamp-routes.js'; import { registerCallbackDocumentRoutes } from './callback-document-routes.js'; -import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; + import { registerCallbackGameRoutes } from './callback-game-routes.js'; import { registerCallbackGuideRoutes } from './callback-guide-routes.js'; import { registerCallbackLimbRoutes } from './callback-limb-routes.js'; @@ -107,7 +107,7 @@ export interface CallbackRoutesOptions { }; } -const postMessageSchema = callbackAuthSchema.extend({ +const postMessageSchema = z.object({ content: z.string().min(1).max(50000), threadId: z.string().min(1).optional(), replyTo: z.string().optional(), @@ -115,31 +115,31 @@ const postMessageSchema = callbackAuthSchema.extend({ targetCats: z.array(z.string().min(1)).optional(), }); -const threadContextQuerySchema = callbackAuthSchema.extend({ +const threadContextQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), threadId: z.string().min(1).optional(), // F-Swarm-6: optional cross-thread read catId: z.string().min(1).optional(), keyword: z.string().min(1).optional(), }); -const listThreadsQuerySchema = callbackAuthSchema.extend({ +const listThreadsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), activeSince: z.coerce.number().int().min(0).optional(), keyword: z.string().trim().min(1).max(200).optional(), }); -const featIndexQuerySchema = callbackAuthSchema.extend({ +const featIndexQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), featId: z.string().min(1).optional(), query: z.string().min(1).optional(), }); -const pendingMentionsQuerySchema = callbackAuthSchema.extend({ +const pendingMentionsQuerySchema = z.object({ // Accept both scalar and repeated query params (Fastify may surface string[]). includeAcked: z.union([z.string(), z.array(z.string())]).optional(), }); -const ackMentionsSchema = callbackAuthSchema.extend({ +const ackMentionsSchema = z.object({ upToMessageId: z.string().min(1), }); @@ -250,7 +250,7 @@ const richBlockSchema = z.discriminatedUnion('kind', [ height: z.number().int().min(50).max(2000).optional(), }), ]); -const createRichBlockSchema = callbackAuthSchema.extend({ +const createRichBlockSchema = z.object({ block: richBlockSchema, }); @@ -308,27 +308,21 @@ export const callbacksRoutes: FastifyPluginAsync = async queueProcessor, } = opts; + // #476: Unified callback auth — extract credentials from headers, decorate request.callbackAuth + registerCallbackAuthHook(app, registry); + app.post('/api/callbacks/post-message', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = postMessageSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { - invocationId, - callbackToken, - content, - threadId, - replyTo, - clientMessageId, - targetCats: explicitTargetCats, - } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { content, threadId, replyTo, clientMessageId, targetCats: explicitTargetCats } = parsed.data; + const { invocationId } = record; // Stale callback guard (cloud Codex P1 + 缅因猫 R3): reject callbacks from // preempted invocations. A newer invocation for the same thread+cat supersedes. @@ -632,18 +626,16 @@ export const callbacksRoutes: FastifyPluginAsync = async }); app.get('/api/callbacks/pending-mentions', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = pendingMentionsQuerySchema.safeParse(request.query); if (!parsed.success) { reply.status(400); - return { error: 'Missing invocationId or callbackToken' }; + return { error: 'Invalid query parameters' }; } - const { invocationId, callbackToken, includeAcked } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { includeAcked } = parsed.data; const includeAckedValues = Array.isArray(includeAcked) ? includeAcked : includeAcked ? [includeAcked] : []; const shouldIncludeAcked = includeAckedValues.some((v) => v === '1' || v.toLowerCase() === 'true'); @@ -651,7 +643,7 @@ export const callbacksRoutes: FastifyPluginAsync = async // DIAG: ghost-thread bug — log which thread this invocation thinks it owns app.log.debug( { - invocationId, + invocationId: record.invocationId, catId: record.catId, threadId: record.threadId, }, @@ -683,18 +675,16 @@ export const callbacksRoutes: FastifyPluginAsync = async // #77: POST /api/callbacks/ack-mentions — explicit ack with 4-way validation app.post('/api/callbacks/ack-mentions', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = ackMentionsSchema.safeParse(request.body); if (!parsed.success) { reply.status(400); return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, upToMessageId } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { upToMessageId } = parsed.data; if (!deliveryCursorStore) { reply.status(501); @@ -754,18 +744,16 @@ export const callbacksRoutes: FastifyPluginAsync = async }); app.get('/api/callbacks/thread-context', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = threadContextQuerySchema.safeParse(request.query); if (!parsed.success) { reply.status(400); - return { error: 'Missing invocationId or callbackToken' }; + return { error: 'Invalid query parameters' }; } - const { invocationId, callbackToken, limit, threadId: overrideThreadId, catId: filterCatId, keyword } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { limit, threadId: overrideThreadId, catId: filterCatId, keyword } = parsed.data; if (filterCatId && filterCatId !== 'user' && !catRegistry.has(filterCatId)) { reply.status(400); @@ -937,18 +925,16 @@ export const callbacksRoutes: FastifyPluginAsync = async }); app.get('/api/callbacks/list-threads', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = listThreadsQuerySchema.safeParse(request.query); if (!parsed.success) { reply.status(400); return { error: 'Invalid request query', details: parsed.error.issues }; } - const { invocationId, callbackToken, limit, activeSince, keyword } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { limit, activeSince, keyword } = parsed.data; if (!threadStore) { reply.status(503); @@ -982,18 +968,16 @@ export const callbacksRoutes: FastifyPluginAsync = async }); app.get('/api/callbacks/feat-index', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + const parsed = featIndexQuerySchema.safeParse(request.query); if (!parsed.success) { reply.status(400); return { error: 'Invalid request query', details: parsed.error.issues }; } - const { invocationId, callbackToken, featId, query, limit } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const { featId, query, limit } = parsed.data; const normalizedFeatId = featId ? normalizeFeatId(featId) : undefined; const normalizedQuery = query?.trim().toLowerCase(); @@ -1026,7 +1010,7 @@ export const callbacksRoutes: FastifyPluginAsync = async // TD091: PR tracking registration via MCP callback // Cats call this after `gh pr create` to register the PR for Layer 1 routing. // Server resolves threadId from invocation record — cat doesn't need to know it. - const registerPrTrackingSchema = callbackAuthSchema.extend({ + const registerPrTrackingSchema = z.object({ repoFullName: z .string() .min(1) @@ -1048,12 +1032,10 @@ export const callbacksRoutes: FastifyPluginAsync = async return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, repoFullName, prNumber } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const record = requireCallbackAuth(request, reply); + if (!record) return; + + const { repoFullName, prNumber } = parsed.data; // Use authoritative catId from invocation record, not caller payload. const catId = record.catId; @@ -1110,7 +1092,11 @@ export const callbacksRoutes: FastifyPluginAsync = async return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, block } = parsed.data; + const record = requireCallbackAuth(request, reply); + if (!record) return; + + const { block } = parsed.data; + const { invocationId } = record; // F34-b P2: audio blocks must have at least url or text (R10: trim whitespace) if (block.kind === 'audio' && !block.url?.trim() && !block.text?.trim()) { @@ -1118,12 +1104,6 @@ export const callbacksRoutes: FastifyPluginAsync = async return { error: 'audio block requires url or text' }; } - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } - if (!registry.isLatest(invocationId)) { return { status: 'stale_ignored' }; } @@ -1158,7 +1138,7 @@ export const callbacksRoutes: FastifyPluginAsync = async }); // F079 Gap 4: Cat-initiated vote via MCP callback - const startVoteCallbackSchema = callbackAuthSchema.extend({ + const startVoteCallbackSchema = z.object({ question: z.string().min(1).max(500), options: z.array(z.string().min(1).max(100)).min(2).max(20), anonymous: z.boolean().optional().default(false), @@ -1178,15 +1158,13 @@ export const callbacksRoutes: FastifyPluginAsync = async return { error: 'Invalid request body', details: parsed.error.issues }; } - const { invocationId, callbackToken, question, options, anonymous, timeoutSec, voters } = parsed.data; - const record = registry.verify(invocationId, callbackToken); - if (!record) { - reply.status(401); - return EXPIRED_CREDENTIALS_ERROR; - } + const record = requireCallbackAuth(request, reply); + if (!record) return; + + const { question, options, anonymous, timeoutSec, voters } = parsed.data; // P1-2 fix: stale invocation guard (parity with post-message, create-rich-block) - if (!registry.isLatest(invocationId)) { + if (!registry.isLatest(record.invocationId)) { return { status: 'stale_ignored' }; } @@ -1297,7 +1275,6 @@ export const callbacksRoutes: FastifyPluginAsync = async if (taskStore) { registerCallbackTaskRoutes(app, { - registry, taskStore, socketManager, ...(threadStore ? { threadStore } : {}), @@ -1306,7 +1283,6 @@ export const callbacksRoutes: FastifyPluginAsync = async if (opts.workflowSopStore && opts.backlogStore) { registerCallbackWorkflowSopRoutes(app, { - registry, workflowSopStore: opts.workflowSopStore, backlogStore: opts.backlogStore, }); @@ -1320,14 +1296,12 @@ export const callbacksRoutes: FastifyPluginAsync = async // Thread cats discovery for MCP if (opts.threadStore && opts.agentRegistry) { registerCallbackThreadCatsRoutes(app, { - registry, threadStore: opts.threadStore, agentRegistry: opts.agentRegistry, }); } await registerCallbackMemoryRoutes(app, { - registry, evidenceStore: opts.evidenceStore, markerQueue: opts.markerQueue, reflectionService: opts.reflectionService, @@ -1337,7 +1311,6 @@ export const callbacksRoutes: FastifyPluginAsync = async if (opts.limbRegistry) { registerCallbackLimbRoutes(app, { limbRegistry: opts.limbRegistry, - invocationRegistry: registry, pairingStore: opts.limbPairingStore, }); } @@ -1345,7 +1318,6 @@ export const callbacksRoutes: FastifyPluginAsync = async // F086: Multi-mention orchestration routes if (router && invocationRecordStore) { registerMultiMentionRoutes(app, { - registry, messageStore, socketManager, router, @@ -1364,7 +1336,7 @@ export const callbacksRoutes: FastifyPluginAsync = async registerCallbackDocumentRoutes(app, { registry, socketManager }); // F101: Game action callback for non-Claude cats (OpenCode/Codex/Gemini) - registerCallbackGameRoutes(app, { registry }); + registerCallbackGameRoutes(app); // F155: Guide engine — state-validated routes with ThreadStore authority if (opts.threadStore) { diff --git a/packages/api/src/routes/schedule.ts b/packages/api/src/routes/schedule.ts index 8beedecfd7..b0cf32a0fb 100644 --- a/packages/api/src/routes/schedule.ts +++ b/packages/api/src/routes/schedule.ts @@ -15,7 +15,10 @@ */ import type { FastifyPluginAsync } from 'fastify'; -import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; +import type { + InvocationRecord, + InvocationRegistry, +} from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { ITaskStore } from '../domains/cats/services/stores/ports/TaskStore.js'; import type { DynamicTaskStore } from '../infrastructure/scheduler/DynamicTaskStore.js'; import type { GlobalControlStore } from '../infrastructure/scheduler/GlobalControlStore.js'; @@ -29,6 +32,7 @@ import { import type { TaskRunnerV2 } from '../infrastructure/scheduler/TaskRunnerV2.js'; import type { ScheduleLifecycleNotifier, TriggerSpec } from '../infrastructure/scheduler/types.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; +import { registerCallbackAuthHook } from './callback-auth-prehandler.js'; import { governanceRoutes } from './schedule-governance.js'; /** #415: Normalize once-trigger input — accepts delayMs (relative) or fireAt (absolute) */ @@ -83,31 +87,25 @@ function addSubjectKeyWithAliases(target: Set, subjectKey: string): void if (subjectKey.startsWith('pr-')) target.add(`pr:${subjectKey.slice(3)}`); } -function firstHeaderValue(value: string | string[] | undefined): string | undefined { - return Array.isArray(value) ? value[0] : value; -} - -type DeliveryThreadResolutionCode = 'STALE_INVOCATION' | 'INVALID_CALLBACK_CREDENTIALS'; +type DeliveryThreadResolutionCode = 'STALE_INVOCATION'; +/** Resolve deliveryThreadId from preHandler auth (headers) or explicit body param. + * Panel UI requests have no auth → uses explicit deliveryThreadId or null. + * MCP requests have callbackAuth → infer from invocation record. + * Invalid credentials are rejected at the preHandler level (fail-closed, #474). */ function resolveDeliveryThreadId( - request: { headers: Record }, - body: { deliveryThreadId?: string; invocationId?: string; callbackToken?: string }, + callbackAuth: InvocationRecord | undefined, + body: { deliveryThreadId?: string }, registry?: InvocationRegistry, ): { deliveryThreadId: string | null; code: DeliveryThreadResolutionCode | null } { - const invocationId = body.invocationId ?? firstHeaderValue(request.headers['x-invocation-id']); - const callbackToken = body.callbackToken ?? firstHeaderValue(request.headers['x-callback-token']); - const hasAnyCallbackCredential = Boolean(invocationId || callbackToken); - if (!hasAnyCallbackCredential) { + if (!callbackAuth) { return { deliveryThreadId: body.deliveryThreadId ?? null, code: null }; } - if (!registry) return { deliveryThreadId: null, code: 'INVALID_CALLBACK_CREDENTIALS' }; - if (!invocationId || !callbackToken) return { deliveryThreadId: null, code: 'INVALID_CALLBACK_CREDENTIALS' }; - - const record = registry.verify(invocationId, callbackToken); - if (!record) return { deliveryThreadId: null, code: 'INVALID_CALLBACK_CREDENTIALS' }; - if (!registry.isLatest(invocationId)) return { deliveryThreadId: null, code: 'STALE_INVOCATION' }; + if (registry && !registry.isLatest(callbackAuth.invocationId)) { + return { deliveryThreadId: null, code: 'STALE_INVOCATION' }; + } if (body.deliveryThreadId) return { deliveryThreadId: body.deliveryThreadId, code: null }; - return { deliveryThreadId: record.threadId, code: null }; + return { deliveryThreadId: callbackAuth.threadId, code: null }; } export const scheduleRoutes: FastifyPluginAsync = async (app, opts) => { @@ -122,6 +120,9 @@ export const scheduleRoutes: FastifyPluginAsync = async ( registry, } = opts; + // #476: Register callback auth preHandler for MCP-originated schedule requests + if (registry) registerCallbackAuthHook(app, registry); + // GET /api/schedule/tasks // #320: Optional ?threadId= filter — resolves thread's task subjectKeys for cross-match app.get('/api/schedule/tasks', async (request) => { @@ -257,8 +258,6 @@ export const scheduleRoutes: FastifyPluginAsync = async ( params?: Record; display?: { label: string; category: string; description?: string }; deliveryThreadId?: string; - invocationId?: string; - callbackToken?: string; }; if (!body.templateId) { @@ -293,7 +292,7 @@ export const scheduleRoutes: FastifyPluginAsync = async ( } : { label: template.label, category: template.category, description: template.description }; - const resolution = resolveDeliveryThreadId(request, body, registry); + const resolution = resolveDeliveryThreadId(request.callbackAuth, body, registry); if (resolution.code === 'STALE_INVOCATION') { reply.status(409); return { @@ -301,13 +300,6 @@ export const scheduleRoutes: FastifyPluginAsync = async ( code: 'STALE_INVOCATION', }; } - if (resolution.code === 'INVALID_CALLBACK_CREDENTIALS') { - reply.status(401); - return { - error: 'Invalid callback credentials', - code: 'INVALID_CALLBACK_CREDENTIALS', - }; - } return { draft: { @@ -383,7 +375,7 @@ export const scheduleRoutes: FastifyPluginAsync = async ( } : { label: template.label, category: template.category, description: template.description }; - const resolution = resolveDeliveryThreadId(request, body, registry); + const resolution = resolveDeliveryThreadId(request.callbackAuth, body, registry); if (resolution.code === 'STALE_INVOCATION') { reply.status(409); return { @@ -391,13 +383,6 @@ export const scheduleRoutes: FastifyPluginAsync = async ( code: 'STALE_INVOCATION', }; } - if (resolution.code === 'INVALID_CALLBACK_CREDENTIALS') { - reply.status(401); - return { - error: 'Invalid callback credentials', - code: 'INVALID_CALLBACK_CREDENTIALS', - }; - } const def = { id, diff --git a/packages/api/test/authorization-routes.test.js b/packages/api/test/authorization-routes.test.js index 9d06347811..19941d0390 100644 --- a/packages/api/test/authorization-routes.test.js +++ b/packages/api/test/authorization-routes.test.js @@ -18,6 +18,7 @@ const { AuthorizationAuditStore } = await import( const { AuthorizationManager } = await import('../dist/domains/cats/services/auth/AuthorizationManager.js'); const { callbackAuthRoutes } = await import('../dist/routes/callback-auth.js'); const { authorizationRoutes } = await import('../dist/routes/authorization.js'); +// registerCallbackAuthHook is called internally by callbackAuthRoutes function createMockSocketManager() { const events = []; @@ -53,7 +54,7 @@ describe('POST /api/callbacks/request-permission', () => { async function createApp() { const app = Fastify(); - await app.register(callbackAuthRoutes, { registry, authManager }); + await app.register(callbackAuthRoutes, { authManager, registry }); return app; } @@ -71,7 +72,8 @@ describe('POST /api/callbacks/request-permission', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', - payload: { invocationId, callbackToken, action: 'git_commit', reason: 'fix bug' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'git_commit', reason: 'fix bug' }, }); assert.equal(res.statusCode, 200); @@ -93,7 +95,8 @@ describe('POST /api/callbacks/request-permission', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', - payload: { invocationId, callbackToken, action: 'file_delete', reason: 'cleanup' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'file_delete', reason: 'cleanup' }, }); assert.equal(res.statusCode, 200); @@ -107,7 +110,8 @@ describe('POST /api/callbacks/request-permission', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', - payload: { invocationId, callbackToken, action: 'git_push', reason: 'deploy' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'git_push', reason: 'deploy' }, }); assert.equal(res.statusCode, 200); @@ -121,21 +125,22 @@ describe('POST /api/callbacks/request-permission', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', - payload: { invocationId: 'bad', callbackToken: 'bad', action: 'x', reason: 'y' }, + headers: { 'x-invocation-id': 'bad', 'x-callback-token': 'bad' }, + payload: { action: 'x', reason: 'y' }, }); assert.equal(res.statusCode, 401); }); - test('rejects missing fields', async () => { + test('rejects invalid credentials (missing fields test now returns 401)', async () => { const app = await createApp(); const res = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', - payload: { invocationId: 'x', callbackToken: 'y' }, + headers: { 'x-invocation-id': 'x', 'x-callback-token': 'y' }, }); - assert.equal(res.statusCode, 400); + assert.equal(res.statusCode, 401); }); }); @@ -158,7 +163,7 @@ describe('GET /api/callbacks/permission-status', () => { async function createApp() { const app = Fastify(); - await app.register(callbackAuthRoutes, { registry, authManager }); + await app.register(callbackAuthRoutes, { authManager, registry }); return app; } @@ -170,14 +175,16 @@ describe('GET /api/callbacks/permission-status', () => { const createRes = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', - payload: { invocationId, callbackToken, action: 'git_commit', reason: 'fix' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'git_commit', reason: 'fix' }, }); const { requestId } = JSON.parse(createRes.body); // Query status const res = await app.inject({ method: 'GET', - url: `/api/callbacks/permission-status?invocationId=${invocationId}&callbackToken=${callbackToken}&requestId=${requestId}`, + url: `/api/callbacks/permission-status?requestId=${requestId}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(res.statusCode, 200); @@ -194,9 +201,8 @@ describe('GET /api/callbacks/permission-status', () => { const createRes = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', + headers: { 'x-invocation-id': catA.invocationId, 'x-callback-token': catA.callbackToken }, payload: { - invocationId: catA.invocationId, - callbackToken: catA.callbackToken, action: 'git_commit', reason: 'fix', }, @@ -207,7 +213,8 @@ describe('GET /api/callbacks/permission-status', () => { const catB = registry.create('user-1', 'opus', 'thread-2'); const res = await app.inject({ method: 'GET', - url: `/api/callbacks/permission-status?invocationId=${catB.invocationId}&callbackToken=${catB.callbackToken}&requestId=${requestId}`, + url: `/api/callbacks/permission-status?requestId=${requestId}`, + headers: { 'x-invocation-id': catB.invocationId, 'x-callback-token': catB.callbackToken }, }); assert.equal(res.statusCode, 403); @@ -220,9 +227,8 @@ describe('GET /api/callbacks/permission-status', () => { const createRes = await app.inject({ method: 'POST', url: '/api/callbacks/request-permission', + headers: { 'x-invocation-id': invocA.invocationId, 'x-callback-token': invocA.callbackToken }, payload: { - invocationId: invocA.invocationId, - callbackToken: invocA.callbackToken, action: 'git_commit', reason: 'fix', }, @@ -233,7 +239,8 @@ describe('GET /api/callbacks/permission-status', () => { const invocB = registry.create('user-1', 'codex', 'thread-1'); const res = await app.inject({ method: 'GET', - url: `/api/callbacks/permission-status?invocationId=${invocB.invocationId}&callbackToken=${invocB.callbackToken}&requestId=${requestId}`, + url: `/api/callbacks/permission-status?requestId=${requestId}`, + headers: { 'x-invocation-id': invocB.invocationId, 'x-callback-token': invocB.callbackToken }, }); assert.equal(res.statusCode, 403, 'same cat+thread but different invocation must be rejected'); @@ -245,7 +252,8 @@ describe('GET /api/callbacks/permission-status', () => { const res = await app.inject({ method: 'GET', - url: `/api/callbacks/permission-status?invocationId=${invocationId}&callbackToken=${callbackToken}&requestId=nonexistent`, + url: `/api/callbacks/permission-status?requestId=nonexistent`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(res.statusCode, 404); diff --git a/packages/api/test/auto-reply-to-worklist.test.js b/packages/api/test/auto-reply-to-worklist.test.js index aa7d089a74..4e05ff091c 100644 --- a/packages/api/test/auto-reply-to-worklist.test.js +++ b/packages/api/test/auto-reply-to-worklist.test.js @@ -105,9 +105,8 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '收到,opus!我来看', }, }); @@ -148,9 +147,8 @@ describe('auto-replyTo: worklist path (a2aTriggerMessageId)', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '收到两位!', }, }); diff --git a/packages/api/test/auto-reply-to.test.js b/packages/api/test/auto-reply-to.test.js index c01abea644..dea378ab5d 100644 --- a/packages/api/test/auto-reply-to.test.js +++ b/packages/api/test/auto-reply-to.test.js @@ -98,9 +98,8 @@ describe('auto-replyTo for A2A invocations', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '收到!我来看看', }, }); @@ -167,9 +166,8 @@ describe('auto-replyTo for A2A invocations', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '回复其他消息', replyTo: otherMsg.id, // Explicit replyTo }, @@ -194,9 +192,8 @@ describe('auto-replyTo for A2A invocations', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '直接用户请求的回复', }, }); @@ -243,9 +240,8 @@ describe('auto-replyTo for A2A invocations', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'P3-2 hardening test', }, }); @@ -288,9 +284,8 @@ describe('auto-replyTo for A2A invocations', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '跨 thread 回复', }, }); diff --git a/packages/api/test/bootcamp-flow.test.js b/packages/api/test/bootcamp-flow.test.js index b062647ddd..88ee9b43c7 100644 --- a/packages/api/test/bootcamp-flow.test.js +++ b/packages/api/test/bootcamp-flow.test.js @@ -86,9 +86,8 @@ describe('Bootcamp Flow Integration', () => { const step2 = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, phase: 'phase-1-intro', leadCat: 'opus', @@ -106,7 +105,8 @@ describe('Bootcamp Flow Integration', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId: creds.invocationId, callbackToken: creds.callbackToken, threadId, phase, ...extra }, + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, + payload: { threadId, phase, ...extra }, }); assert.equal(res.statusCode, 200, `Phase ${phase} should succeed`); return JSON.parse(res.body); @@ -120,7 +120,8 @@ describe('Bootcamp Flow Integration', () => { const step3 = await app.inject({ method: 'POST', url: '/api/callbacks/bootcamp-env-check', - payload: { invocationId: envCreds.invocationId, callbackToken: envCreds.callbackToken, threadId: thread.id }, + headers: { 'x-invocation-id': envCreds.invocationId, 'x-callback-token': envCreds.callbackToken }, + payload: { threadId: thread.id }, }); assert.equal(step3.statusCode, 200); assert.ok('node' in JSON.parse(step3.body)); diff --git a/packages/api/test/callback-a2a-postmsg.test.js b/packages/api/test/callback-a2a-postmsg.test.js index d2fdd70706..0e7e20f939 100644 --- a/packages/api/test/callback-a2a-postmsg.test.js +++ b/packages/api/test/callback-a2a-postmsg.test.js @@ -122,7 +122,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', - payload: { invocationId, callbackToken, content: 'Just a status update, no mentions' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: 'Just a status update, no mentions' }, }); assert.equal(response.statusCode, 200); @@ -142,9 +143,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '这个方案里,之前 @缅因猫 提过类似的思路', }, }); @@ -165,9 +165,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '看看这段代码:\n```\n@缅因猫 这里是注释\n```\n完毕', }, }); @@ -188,9 +187,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '修复完成了\n@缅因猫\n请帮忙 review', }, }); @@ -215,9 +213,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '这是交接文档,DARE 源码目录执行\n是否接受完全禁用 --api-key argv\n@缅因猫', }, }); @@ -258,9 +255,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '同步一下\n@缅因猫\n这条是冗余提醒', }, }); @@ -290,9 +286,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '修完了,请帮忙 review\n@缅因猫', }, }); @@ -327,9 +322,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '铲屎官快看!有事情!', targetCats: ['codex'], }, @@ -352,9 +346,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '铲屎官快看!有事情!', targetCats: ['default-user'], }, @@ -377,9 +370,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '通知一下', targetCats: ['codex', 'default-user', 'nonexistent-cat'], }, @@ -399,9 +391,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '请帮忙复核\n@缅因猫', targetCats: ['codex', 'gemini'], }, @@ -426,9 +417,8 @@ describe('post_message A2A mention invocation', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '@布偶猫\n这是自我引用测试', }, }); @@ -486,9 +476,8 @@ describe('F052: cross-thread A2A mention routing', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '@codex 请处理这个跨线程任务', threadId: targetThread.id, }, @@ -510,9 +499,8 @@ describe('F052: cross-thread A2A mention routing', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '@codex 请处理', threadId: thread.id, }, diff --git a/packages/api/test/callback-auth-prehandler.test.js b/packages/api/test/callback-auth-prehandler.test.js new file mode 100644 index 0000000000..a9e1c81e8e --- /dev/null +++ b/packages/api/test/callback-auth-prehandler.test.js @@ -0,0 +1,251 @@ +/** + * Tests for unified callback auth preHandler (#476) + */ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import Fastify from 'fastify'; + +describe('Callback Auth PreHandler (#476)', () => { + /** Minimal InvocationRegistry mock */ + function createMockRegistry(records = new Map()) { + return { + verify(invocationId, callbackToken) { + const record = records.get(invocationId); + if (!record || record.callbackToken !== callbackToken) return null; + return record; + }, + }; + } + + async function buildApp(registry) { + const { registerCallbackAuthHook, requireCallbackAuth } = await import( + '../dist/routes/callback-auth-prehandler.js' + ); + const app = Fastify({ logger: false }); + registerCallbackAuthHook(app, registry); + + // Test route that requires auth + app.get('/test/require-auth', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + return { threadId: record.threadId, catId: record.catId }; + }); + + // Test route that optionally uses auth + app.get('/test/optional-auth', async (request) => { + return { hasAuth: !!request.callbackAuth }; + }); + + await app.ready(); + return app; + } + + const VALID_RECORD = { + invocationId: 'inv-001', + callbackToken: 'tok-001', + threadId: 'thread-abc', + catId: 'opus', + userId: 'user-1', + }; + + it('decorates request.callbackAuth with verified record when headers are valid', async () => { + const registry = createMockRegistry(new Map([['inv-001', VALID_RECORD]])); + const app = await buildApp(registry); + + const res = await app.inject({ + method: 'GET', + url: '/test/require-auth', + headers: { 'x-invocation-id': 'inv-001', 'x-callback-token': 'tok-001' }, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.threadId, 'thread-abc'); + assert.equal(body.catId, 'opus'); + await app.close(); + }); + + it('returns 401 when headers are missing and handler requires auth', async () => { + const registry = createMockRegistry(); + const app = await buildApp(registry); + + const res = await app.inject({ method: 'GET', url: '/test/require-auth' }); + assert.equal(res.statusCode, 401); + const body = res.json(); + assert.ok(body.error.includes('expired')); + await app.close(); + }); + + it('returns 401 from preHandler when credentials are invalid (fail-closed, #474)', async () => { + const registry = createMockRegistry(new Map([['inv-001', VALID_RECORD]])); + const app = await buildApp(registry); + + const res = await app.inject({ + method: 'GET', + url: '/test/optional-auth', + headers: { 'x-invocation-id': 'inv-001', 'x-callback-token': 'wrong-token' }, + }); + + assert.equal(res.statusCode, 401, 'bad creds must be rejected at preHandler, not silently ignored'); + await app.close(); + }); + + it('returns 401 from preHandler when only one header is present (malformed)', async () => { + const registry = createMockRegistry(new Map([['inv-001', VALID_RECORD]])); + const app = await buildApp(registry); + + const res = await app.inject({ + method: 'GET', + url: '/test/optional-auth', + headers: { 'x-invocation-id': 'inv-001' }, + }); + + assert.equal(res.statusCode, 401, 'partial headers must be rejected, not treated as panel request'); + await app.close(); + }); + + it('leaves callbackAuth undefined when headers absent (panel/optional path)', async () => { + const registry = createMockRegistry(); + const app = await buildApp(registry); + + const res = await app.inject({ method: 'GET', url: '/test/optional-auth' }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().hasAuth, false); + await app.close(); + }); + + // ---- Legacy body/query fallback (#476 compat window) ---- + + it('accepts legacy credentials from POST body when headers absent', async () => { + const registry = createMockRegistry(new Map([['inv-001', VALID_RECORD]])); + const { registerCallbackAuthHook, requireCallbackAuth } = await import( + '../dist/routes/callback-auth-prehandler.js' + ); + const app = Fastify({ logger: false }); + registerCallbackAuthHook(app, registry); + + app.post('/test/require-auth', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + return { threadId: record.threadId, catId: record.catId }; + }); + await app.ready(); + + const res = await app.inject({ + method: 'POST', + url: '/test/require-auth', + payload: { invocationId: 'inv-001', callbackToken: 'tok-001', data: 'test' }, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.threadId, 'thread-abc'); + assert.equal(body.catId, 'opus'); + await app.close(); + }); + + it('accepts legacy credentials from GET query when headers absent', async () => { + const registry = createMockRegistry(new Map([['inv-001', VALID_RECORD]])); + const app = await buildApp(registry); + + const res = await app.inject({ + method: 'GET', + url: '/test/require-auth?invocationId=inv-001&callbackToken=tok-001', + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.threadId, 'thread-abc'); + await app.close(); + }); + + it('prefers headers over legacy body credentials', async () => { + const headerRecord = { + invocationId: 'inv-header', + callbackToken: 'tok-header', + threadId: 'thread-from-header', + catId: 'opus', + userId: 'user-1', + }; + const bodyRecord = { + invocationId: 'inv-body', + callbackToken: 'tok-body', + threadId: 'thread-from-body', + catId: 'codex', + userId: 'user-2', + }; + const registry = createMockRegistry( + new Map([ + ['inv-header', headerRecord], + ['inv-body', bodyRecord], + ]), + ); + const { registerCallbackAuthHook, requireCallbackAuth } = await import( + '../dist/routes/callback-auth-prehandler.js' + ); + const app = Fastify({ logger: false }); + registerCallbackAuthHook(app, registry); + + app.post('/test/require-auth', async (request, reply) => { + const record = requireCallbackAuth(request, reply); + if (!record) return; + return { threadId: record.threadId, catId: record.catId }; + }); + await app.ready(); + + const res = await app.inject({ + method: 'POST', + url: '/test/require-auth', + headers: { 'x-invocation-id': 'inv-header', 'x-callback-token': 'tok-header' }, + payload: { invocationId: 'inv-body', callbackToken: 'tok-body' }, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.threadId, 'thread-from-header', 'headers must take precedence over body'); + assert.equal(body.catId, 'opus'); + await app.close(); + }); + + it('returns 401 when legacy body credentials are invalid', async () => { + const registry = createMockRegistry(new Map([['inv-001', VALID_RECORD]])); + const { registerCallbackAuthHook } = await import('../dist/routes/callback-auth-prehandler.js'); + const app = Fastify({ logger: false }); + registerCallbackAuthHook(app, registry); + + app.post('/test/optional-auth', async (request) => { + return { hasAuth: !!request.callbackAuth }; + }); + await app.ready(); + + const res = await app.inject({ + method: 'POST', + url: '/test/optional-auth', + payload: { invocationId: 'inv-001', callbackToken: 'wrong-token' }, + }); + + assert.equal(res.statusCode, 401, 'invalid legacy creds must still be rejected (fail-closed)'); + await app.close(); + }); + + it('returns 401 when only one legacy body credential is present (partial → fail-closed)', async () => { + const registry = createMockRegistry(new Map([['inv-001', VALID_RECORD]])); + const { registerCallbackAuthHook } = await import('../dist/routes/callback-auth-prehandler.js'); + const app = Fastify({ logger: false }); + registerCallbackAuthHook(app, registry); + + app.post('/test/optional-auth', async (request) => { + return { hasAuth: !!request.callbackAuth }; + }); + await app.ready(); + + const res = await app.inject({ + method: 'POST', + url: '/test/optional-auth', + payload: { invocationId: 'inv-001' }, + }); + + assert.equal(res.statusCode, 401, 'partial legacy creds must be rejected, not treated as panel request'); + await app.close(); + }); +}); diff --git a/packages/api/test/callback-bootcamp-env-check.test.js b/packages/api/test/callback-bootcamp-env-check.test.js index fd72642fa8..191df6ab72 100644 --- a/packages/api/test/callback-bootcamp-env-check.test.js +++ b/packages/api/test/callback-bootcamp-env-check.test.js @@ -70,9 +70,8 @@ describe('Callback Bootcamp Env Check', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/bootcamp-env-check', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, }, }); @@ -111,9 +110,8 @@ describe('Callback Bootcamp Env Check', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/bootcamp-env-check', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, }, }); @@ -144,9 +142,8 @@ describe('Callback Bootcamp Env Check', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/bootcamp-env-check', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: threadB.id, }, }); @@ -173,9 +170,8 @@ describe('Callback Bootcamp Env Check', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/bootcamp-env-check', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: threadB.id, }, }); @@ -194,9 +190,8 @@ describe('Callback Bootcamp Env Check', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/bootcamp-env-check', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: 'nonexistent', }, }); diff --git a/packages/api/test/callback-bootcamp-state.test.js b/packages/api/test/callback-bootcamp-state.test.js index fc297909a1..2381af28b3 100644 --- a/packages/api/test/callback-bootcamp-state.test.js +++ b/packages/api/test/callback-bootcamp-state.test.js @@ -64,9 +64,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': 'fake-id', 'x-callback-token': 'fake-token' }, payload: { - invocationId: 'fake-id', - callbackToken: 'fake-token', threadId: 'thread-1', phase: 'phase-1-intro', }, @@ -90,9 +89,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, phase: 'phase-1-intro', leadCat: 'opus', @@ -122,9 +120,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, phase: 'phase-4-task-select', selectedTaskId: 'Q3', @@ -146,9 +143,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: 'nonexistent', phase: 'phase-1-intro', }, @@ -166,9 +162,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, phase: 'phase-99-invalid', }, @@ -194,9 +189,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: threadB.id, phase: 'phase-11-farewell', }, @@ -225,9 +219,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: threadB.id, phase: 'phase-11-farewell', }, @@ -256,9 +249,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': old.invocationId, 'x-callback-token': old.callbackToken }, payload: { - invocationId: old.invocationId, - callbackToken: old.callbackToken, threadId: thread.id, phase: 'phase-11-farewell', }, @@ -286,7 +278,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId, callbackToken, threadId: thread.id, phase: 'phase-11-farewell' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, phase: 'phase-11-farewell' }, }); assert.equal(response.statusCode, 400); @@ -315,7 +308,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId, callbackToken, threadId: thread.id, phase: 'phase-2-env-check' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, phase: 'phase-2-env-check' }, }); assert.equal(response.statusCode, 400); @@ -336,7 +330,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId, callbackToken, threadId: thread.id, phase: 'phase-4-task-select' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, phase: 'phase-4-task-select' }, }); assert.equal(response.statusCode, 200); @@ -357,7 +352,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId, callbackToken, threadId: thread.id, phase: 'phase-1-intro', leadCat: 'opus' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, phase: 'phase-1-intro', leadCat: 'opus' }, }); assert.equal(response.statusCode, 200); @@ -386,9 +382,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, phase: 'phase-11-farewell', completedAt: Date.now(), @@ -418,7 +413,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId, callbackToken, threadId: thread.id, phase: 'phase-2-env-check' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, phase: 'phase-2-env-check' }, }); assert.equal(response.statusCode, 200); @@ -450,7 +446,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId, callbackToken, threadId: thread.id, phase: 'phase-1-intro', leadCat: 'opus' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, phase: 'phase-1-intro', leadCat: 'opus' }, }); assert.equal(response.statusCode, 200); @@ -475,7 +472,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', - payload: { invocationId, callbackToken, threadId: thread.id, phase: 'phase-1-intro' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, phase: 'phase-1-intro' }, }); assert.equal(response.statusCode, 400); @@ -497,9 +495,8 @@ describe('Callback Bootcamp State', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-bootcamp-state', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: thread.id, phase: 'phase-11-farewell', completedAt: Date.now(), diff --git a/packages/api/test/callback-game-action.test.js b/packages/api/test/callback-game-action.test.js index 076faa7db1..21c1d9e409 100644 --- a/packages/api/test/callback-game-action.test.js +++ b/packages/api/test/callback-game-action.test.js @@ -115,9 +115,8 @@ describe('Callback Game Action', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/submit-game-action', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, // missing gameId, round, phase, seat, action, nonce }, }); @@ -134,9 +133,8 @@ describe('Callback Game Action', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/submit-game-action', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, gameId: 'game-1', round: 1, phase: 'night_wolf', @@ -163,9 +161,8 @@ describe('Callback Game Action', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/submit-game-action', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, gameId: 'nonexistent', round: 1, phase: 'night_wolf', @@ -189,9 +186,8 @@ describe('Callback Game Action', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/submit-game-action', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, gameId: 'game-2', round: 1, phase: 'night_wolf', @@ -220,9 +216,8 @@ describe('Callback Game Action', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/submit-game-action', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, gameId: 'game-B', round: 1, phase: 'night_wolf', diff --git a/packages/api/test/callback-guide-routes.test.js b/packages/api/test/callback-guide-routes.test.js index a20e37752b..619d88ff46 100644 --- a/packages/api/test/callback-guide-routes.test.js +++ b/packages/api/test/callback-guide-routes.test.js @@ -88,7 +88,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 200); @@ -118,7 +119,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'nonexistent-flow' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'nonexistent-flow' }, }); assert.equal(res.statusCode, 400); @@ -133,7 +135,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId: 'fake', callbackToken: 'fake', guideId: 'add-member' }, + headers: { 'x-invocation-id': 'fake', 'x-callback-token': 'fake' }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 401); @@ -149,7 +152,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); const body = JSON.parse(res.body); @@ -169,7 +173,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 400); @@ -191,7 +196,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-resolve', - payload: { invocationId, callbackToken, intent: '添加成员' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { intent: '添加成员' }, }); assert.equal(res.statusCode, 200); @@ -208,7 +214,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-resolve', - payload: { invocationId, callbackToken, intent: '天气预报' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { intent: '天气预报' }, }); assert.equal(res.statusCode, 200); @@ -223,7 +230,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-resolve', - payload: { invocationId: 'fake', callbackToken: 'fake', intent: '添加' }, + headers: { 'x-invocation-id': 'fake', 'x-callback-token': 'fake' }, + payload: { intent: '添加' }, }); assert.equal(res.statusCode, 401); @@ -241,7 +249,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId, callbackToken, action: 'next' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'next' }, }); assert.equal(res.statusCode, 200); @@ -271,7 +280,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId, callbackToken, action: 'destroy' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'destroy' }, }); assert.equal(res.statusCode, 400); @@ -283,7 +293,8 @@ describe('F155 Guide callback routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId: 'fake', callbackToken: 'fake', action: 'next' }, + headers: { 'x-invocation-id': 'fake', 'x-callback-token': 'fake' }, + payload: { action: 'next' }, }); assert.equal(res.statusCode, 401); diff --git a/packages/api/test/callback-guide-state.test.js b/packages/api/test/callback-guide-state.test.js index 8d676ef869..5ed6f4c9bb 100644 --- a/packages/api/test/callback-guide-state.test.js +++ b/packages/api/test/callback-guide-state.test.js @@ -97,7 +97,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, guideId: 'add-member', status: 'offered' }, }); assert.equal(res.statusCode, 200); @@ -115,7 +116,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, guideId: 'add-member', status: 'active' }, }); assert.equal(res.statusCode, 400); @@ -136,7 +138,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, guideId: 'add-member', status: 'active' }, }); assert.equal(res.statusCode, 400); @@ -167,7 +170,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, guideId: 'add-member', status: 'offered' }, }); assert.equal(res.statusCode, 400); @@ -192,7 +196,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, guideId: 'add-member', status: 'offered' }, }); assert.equal(res.statusCode, 400); @@ -217,7 +222,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, guideId: 'add-member', status: 'offered' }, }); assert.equal(res.statusCode, 200); @@ -234,7 +240,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread2.id, guideId: 'add-member', status: 'offered' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread2.id, guideId: 'add-member', status: 'offered' }, }); assert.equal(res.statusCode, 403); @@ -266,7 +273,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-guide-state', - payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { threadId: thread.id, guideId: 'add-member', status: 'active' }, }); assert.equal(res.statusCode, 403); @@ -292,7 +300,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 200); @@ -326,7 +335,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 200); @@ -357,7 +367,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 400); @@ -380,7 +391,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 400); @@ -394,7 +406,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-guide', - payload: { invocationId, callbackToken, guideId: 'add-member' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { guideId: 'add-member' }, }); assert.equal(res.statusCode, 403); @@ -424,7 +437,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId, callbackToken, action: 'next' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'next' }, }); assert.equal(res.statusCode, 200); @@ -456,7 +470,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId, callbackToken, action: 'next' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'next' }, }); assert.equal(res.statusCode, 200); @@ -488,7 +503,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId, callbackToken, action: 'next' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'next' }, }); assert.equal(res.statusCode, 400); @@ -511,7 +527,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId, callbackToken, action: 'exit' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'exit' }, }); assert.equal(res.statusCode, 200); @@ -527,7 +544,8 @@ describe('F155 Guide State Callbacks', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/guide-control', - payload: { invocationId, callbackToken, action: 'exit' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { action: 'exit' }, }); assert.equal(res.statusCode, 403); diff --git a/packages/api/test/callback-limb-routes.test.js b/packages/api/test/callback-limb-routes.test.js index f534920439..a31a4eefd2 100644 --- a/packages/api/test/callback-limb-routes.test.js +++ b/packages/api/test/callback-limb-routes.test.js @@ -3,6 +3,7 @@ import { beforeEach, describe, it } from 'node:test'; import Fastify from 'fastify'; import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; import { LimbRegistry } from '../dist/domains/limb/LimbRegistry.js'; +import { registerCallbackAuthHook } from '../dist/routes/callback-auth-prehandler.js'; import { registerCallbackLimbRoutes } from '../dist/routes/callback-limb-routes.js'; function mockNode(overrides = {}) { @@ -36,10 +37,8 @@ describe('callback-limb-routes (Fastify injection)', () => { validInvocationId = creds.invocationId; validToken = creds.callbackToken; - registerCallbackLimbRoutes(app, { - limbRegistry, - invocationRegistry, - }); + registerCallbackAuthHook(app, invocationRegistry); + registerCallbackLimbRoutes(app, { limbRegistry }); await app.ready(); }); @@ -48,7 +47,8 @@ describe('callback-limb-routes (Fastify injection)', () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/list', - payload: { invocationId: validInvocationId, callbackToken: validToken }, + headers: { 'x-invocation-id': validInvocationId, 'x-callback-token': validToken }, + payload: {}, }); assert.equal(res.statusCode, 200); const body = JSON.parse(res.payload); @@ -61,7 +61,8 @@ describe('callback-limb-routes (Fastify injection)', () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/list', - payload: { invocationId: validInvocationId, callbackToken: validToken }, + headers: { 'x-invocation-id': validInvocationId, 'x-callback-token': validToken }, + payload: {}, }); assert.equal(res.statusCode, 200); const body = JSON.parse(res.payload); @@ -82,7 +83,8 @@ describe('callback-limb-routes (Fastify injection)', () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/list', - payload: { invocationId: validInvocationId, callbackToken: validToken, capability: 'camera' }, + headers: { 'x-invocation-id': validInvocationId, 'x-callback-token': validToken }, + payload: { capability: 'camera' }, }); assert.equal(res.statusCode, 200); const body = JSON.parse(res.payload); @@ -90,13 +92,14 @@ describe('callback-limb-routes (Fastify injection)', () => { assert.equal(body.nodes[0].nodeId, 'iphone-1'); }); - it('POST /api/callback/limb/list returns 403 with bad credentials', async () => { + it('POST /api/callback/limb/list returns 401 with bad credentials', async () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/list', - payload: { invocationId: 'bad', callbackToken: 'bad' }, + headers: { 'x-invocation-id': 'bad', 'x-callback-token': 'bad' }, + payload: {}, }); - assert.equal(res.statusCode, 403); + assert.equal(res.statusCode, 401); }); it('POST /api/callback/limb/invoke calls node and returns result', async () => { @@ -105,9 +108,8 @@ describe('callback-limb-routes (Fastify injection)', () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/invoke', + headers: { 'x-invocation-id': validInvocationId, 'x-callback-token': validToken }, payload: { - invocationId: validInvocationId, - callbackToken: validToken, nodeId: 'iphone-1', command: 'camera.snap', params: { quality: 'high' }, @@ -123,9 +125,8 @@ describe('callback-limb-routes (Fastify injection)', () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/invoke', + headers: { 'x-invocation-id': validInvocationId, 'x-callback-token': validToken }, payload: { - invocationId: validInvocationId, - callbackToken: validToken, nodeId: 'nonexistent', command: 'test', }, @@ -136,20 +137,21 @@ describe('callback-limb-routes (Fastify injection)', () => { assert.ok(body.error.includes('Unknown node')); }); - it('POST /api/callback/limb/invoke returns 403 with bad credentials', async () => { + it('POST /api/callback/limb/invoke returns 401 with bad credentials', async () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/invoke', - payload: { invocationId: 'bad', callbackToken: 'bad', nodeId: 'x', command: 'y' }, + headers: { 'x-invocation-id': 'bad', 'x-callback-token': 'bad' }, + payload: { nodeId: 'x', command: 'y' }, }); - assert.equal(res.statusCode, 403); + assert.equal(res.statusCode, 401); }); it('POST /api/callback/limb/invoke returns 400 for missing required fields', async () => { const res = await app.inject({ method: 'POST', url: '/api/callback/limb/invoke', - payload: { invocationId: validInvocationId, callbackToken: validToken }, + headers: { 'x-invocation-id': validInvocationId, 'x-callback-token': validToken }, }); assert.equal(res.statusCode, 400); }); diff --git a/packages/api/test/callback-routes.test.js b/packages/api/test/callback-routes.test.js index 25eb8750e8..fe914de648 100644 --- a/packages/api/test/callback-routes.test.js +++ b/packages/api/test/callback-routes.test.js @@ -103,9 +103,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Hello from cat!', }, }); @@ -155,9 +154,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Outbound test message', }, }); @@ -181,9 +179,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': 'wrong-token' }, payload: { - invocationId, - callbackToken: 'wrong-token', content: 'Hello', }, }); @@ -207,9 +204,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Hello', }, }); @@ -217,7 +213,7 @@ describe('Callback Routes', () => { assert.equal(response.statusCode, 401); }); - test('POST post-message returns 400 for invalid body', async () => { + test('POST post-message returns 401 without credentials', async () => { const app = await createApp(); const response = await app.inject({ @@ -226,7 +222,7 @@ describe('Callback Routes', () => { payload: { content: '' }, }); - assert.equal(response.statusCode, 400); + assert.equal(response.statusCode, 401); }); test('POST post-message deduplicates by clientMessageId (at-least-once safe)', async () => { @@ -236,9 +232,8 @@ describe('Callback Routes', () => { const first = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'idempotent message', clientMessageId: 'msg-001', }, @@ -249,9 +244,8 @@ describe('Callback Routes', () => { const second = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'idempotent message', clientMessageId: 'msg-001', }, @@ -275,9 +269,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: threadB.id, content: 'cross-thread hello', }, @@ -304,9 +297,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: threadB.id, content: '@缅因猫\n\n请 review 这个改动', }, @@ -329,9 +321,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Review 结果通知', targetCats: ['codex', 'gpt52'], }, @@ -354,9 +345,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Direction test', targetCats: ['codex', 'gpt52'], }, @@ -378,9 +368,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'FYI\n@codex', targetCats: ['gpt52'], }, @@ -404,9 +393,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: '同步一下\n@codex\n@gpt52', targetCats: ['gemini'], }, @@ -430,9 +418,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, threadId: foreignThread.id, content: 'should fail', }, @@ -465,7 +452,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/pending-mentions?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/pending-mentions', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -480,7 +468,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/pending-mentions?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/pending-mentions', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -511,7 +500,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -537,7 +527,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&limit=3`, + url: `/api/callbacks/thread-context?limit=3`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -573,7 +564,8 @@ describe('Callback Routes', () => { const catResponse = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&catId=codex`, + url: `/api/callbacks/thread-context?catId=codex`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(catResponse.statusCode, 200); const catBody = JSON.parse(catResponse.body); @@ -582,7 +574,8 @@ describe('Callback Routes', () => { const userResponse = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&catId=user`, + url: `/api/callbacks/thread-context?catId=user`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(userResponse.statusCode, 200); const userBody = JSON.parse(userResponse.body); @@ -618,7 +611,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&keyword=ReDiS`, + url: `/api/callbacks/thread-context?keyword=ReDiS`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -658,7 +652,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&catId=codex&keyword=redis`, + url: `/api/callbacks/thread-context?catId=codex&keyword=redis`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); const body = JSON.parse(response.body); @@ -672,7 +667,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&catId=unknown-cat`, + url: `/api/callbacks/thread-context?catId=unknown-cat`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 400); @@ -705,7 +701,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -739,7 +736,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -791,7 +789,8 @@ describe('Callback Routes', () => { // Query thread-B from an invocation in thread-A const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&threadId=thread-B`, + url: `/api/callbacks/thread-context?threadId=thread-B`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -824,7 +823,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -851,7 +851,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&threadId=thread-B&limit=2`, + url: `/api/callbacks/thread-context?threadId=thread-B&limit=2`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -885,7 +886,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/list-threads?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/list-threads', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -931,7 +933,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/list-threads?invocationId=${invocationId}&callbackToken=${callbackToken}&activeSince=150&limit=1`, + url: `/api/callbacks/list-threads?activeSince=150&limit=1`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -950,7 +953,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/list-threads?invocationId=${invocationId}&callbackToken=${callbackToken}&keyword=design`, + url: `/api/callbacks/list-threads?keyword=design`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -965,7 +969,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/list-threads?invocationId=${invocationId}&callbackToken=${callbackToken}&limit=0&activeSince=-1`, + url: `/api/callbacks/list-threads?limit=0&activeSince=-1`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 400); @@ -980,7 +985,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/list-threads?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/list-threads', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 503); @@ -1028,7 +1034,8 @@ describe('Callback Routes', () => { const allRes = await app.inject({ method: 'GET', - url: `/api/callbacks/list-tasks?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/list-tasks', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(allRes.statusCode, 200); const allBody = JSON.parse(allRes.body); @@ -1036,7 +1043,8 @@ describe('Callback Routes', () => { const filteredRes = await app.inject({ method: 'GET', - url: `/api/callbacks/list-tasks?invocationId=${invocationId}&callbackToken=${callbackToken}&catId=codex&status=blocked`, + url: `/api/callbacks/list-tasks?catId=codex&status=blocked`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(filteredRes.statusCode, 200); const filteredBody = JSON.parse(filteredRes.body); @@ -1052,9 +1060,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: - `/api/callbacks/list-tasks?invocationId=${invocationId}` + - `&callbackToken=${callbackToken}&threadId=${foreignThread.id}`, + url: `/api/callbacks/list-tasks?threadId=${foreignThread.id}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 403); @@ -1072,7 +1079,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/feat-index', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1135,7 +1143,8 @@ describe('Callback Routes', () => { const { invocationId, callbackToken } = registry.create('user-1', 'opus'); const response = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/feat-index', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1161,7 +1170,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/feat-index', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1179,7 +1189,8 @@ describe('Callback Routes', () => { const hit = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}&featId=f043`, + url: `/api/callbacks/feat-index?featId=f043`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(hit.statusCode, 200); const hitBody = JSON.parse(hit.body); @@ -1188,7 +1199,8 @@ describe('Callback Routes', () => { const miss = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}&featId=F04`, + url: `/api/callbacks/feat-index?featId=F04`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(miss.statusCode, 200); const missBody = JSON.parse(miss.body); @@ -1206,7 +1218,8 @@ describe('Callback Routes', () => { const byFeatId = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}&query=F04`, + url: `/api/callbacks/feat-index?query=F04`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(byFeatId.statusCode, 200); const byFeatIdBody = JSON.parse(byFeatId.body); @@ -1214,7 +1227,8 @@ describe('Callback Routes', () => { const byStatus = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}&query=PROGRESS`, + url: `/api/callbacks/feat-index?query=PROGRESS`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(byStatus.statusCode, 200); const byStatusBody = JSON.parse(byStatus.body); @@ -1229,7 +1243,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=${callbackToken}&limit=101`, + url: `/api/callbacks/feat-index?limit=101`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 400); }); @@ -1241,7 +1256,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/feat-index?invocationId=${invocationId}&callbackToken=bad-token`, + url: '/api/callbacks/feat-index', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': 'bad-token' }, }); assert.equal(response.statusCode, 401); }); @@ -1269,7 +1285,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/pending-mentions?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/pending-mentions', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1313,7 +1330,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/pending-mentions?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/pending-mentions', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1323,7 +1341,7 @@ describe('Callback Routes', () => { assert.equal(body.mentions[1].message, '@opus in thread-A again'); }); - test('GET pending-mentions returns 400 without credentials', async () => { + test('GET pending-mentions returns 401 without credentials', async () => { const app = await createApp(); const response = await app.inject({ @@ -1331,7 +1349,7 @@ describe('Callback Routes', () => { url: '/api/callbacks/pending-mentions', }); - assert.equal(response.statusCode, 400); + assert.equal(response.statusCode, 401); }); // ---- SQLite memory service callbacks (F102 Phase D1) ---- @@ -1352,7 +1370,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/search-evidence?invocationId=${invocationId}&callbackToken=${callbackToken}&q=single%20bank&limit=1`, + url: `/api/callbacks/search-evidence?q=single%20bank&limit=1`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1375,7 +1394,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'GET', - url: `/api/callbacks/search-evidence?invocationId=${invocationId}&callbackToken=${callbackToken}&q=bank-policy&limit=3`, + url: `/api/callbacks/search-evidence?q=bank-policy&limit=3`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(capturedArgs.query, 'bank-policy'); @@ -1393,7 +1413,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'GET', - url: `/api/callbacks/search-evidence?invocationId=${invocationId}&callbackToken=${callbackToken}&q=test`, + url: `/api/callbacks/search-evidence?q=test`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(capturedOpts.limit, 5); @@ -1408,7 +1429,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/search-evidence?invocationId=${invocationId}&callbackToken=${callbackToken}&q=bank-policy`, + url: `/api/callbacks/search-evidence?q=bank-policy`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1424,7 +1446,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/search-evidence?invocationId=${invocationId}&callbackToken=wrong&q=test`, + url: `/api/callbacks/search-evidence?q=test`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': 'wrong' }, }); assert.equal(response.statusCode, 401); @@ -1442,9 +1465,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/reflect', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, query: 'What changed in phase 5?', }, }); @@ -1466,9 +1488,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/reflect', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, query: 'What changed in phase 5?', }, }); @@ -1487,9 +1508,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/reflect', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': 'wrong' }, payload: { - invocationId, - callbackToken: 'wrong', query: 'test', }, }); @@ -1509,9 +1529,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/retain-memory', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'When storage is unavailable, fail-closed and surface explicit errors.', }, }); @@ -1533,9 +1552,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/retain-memory', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': 'invalid-token' }, payload: { - invocationId, - callbackToken: 'invalid-token', content: 'memory', }, }); @@ -1553,9 +1571,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/retain-memory', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'memory item', }, }); @@ -1580,9 +1597,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': old.invocationId, 'x-callback-token': old.callbackToken }, payload: { - invocationId: old.invocationId, - callbackToken: old.callbackToken, content: 'Stale message from old invocation', }, }); @@ -1608,9 +1624,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': latest.invocationId, 'x-callback-token': latest.callbackToken }, payload: { - invocationId: latest.invocationId, - callbackToken: latest.callbackToken, content: 'Fresh message from latest invocation', }, }); @@ -1639,7 +1654,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', - payload: { invocationId, callbackToken, content }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content }, }); assert.equal(response.statusCode, 200); @@ -1669,7 +1685,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/post-message', - payload: { invocationId, callbackToken, content }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content }, }); // Should have 2 broadcasts: 1 text + 1 rich_block system_info @@ -1701,7 +1718,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/post-message', - payload: { invocationId, callbackToken, content: 'Hello' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: 'Hello' }, }); const msgs = socketManager.getMessages(); @@ -1723,7 +1741,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/post-message', - payload: { invocationId, callbackToken, content }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content }, }); const msgs = socketManager.getMessages(); @@ -1739,9 +1758,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/create-rich-block', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, block: { id: 'card-454', kind: 'card', v: 1, title: 'Test', bodyMarkdown: 'hi' }, }, }); @@ -1763,9 +1781,8 @@ describe('Callback Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/generate-document', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, markdown: '# Test Doc\nHello from #454', format: 'md', baseName: 'test-454', @@ -1793,7 +1810,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', - payload: { invocationId, callbackToken, content: 'Plain message, no blocks' }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: 'Plain message, no blocks' }, }); assert.equal(response.statusCode, 200); @@ -1812,9 +1830,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/create-rich-block', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, // Intentionally uses "type" instead of "kind", missing v block: { id: 'b1', type: 'card', title: 'Normalized', bodyMarkdown: '**bold**' }, }, @@ -1883,7 +1900,8 @@ describe('Callback Routes', () => { // Request limit=10 — all 10 visible messages are buried under 500 hidden const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&limit=10`, + url: `/api/callbacks/thread-context?limit=10`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -1941,7 +1959,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&limit=10`, + url: `/api/callbacks/thread-context?limit=10`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -2011,7 +2030,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&limit=10`, + url: `/api/callbacks/thread-context?limit=10`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -2062,7 +2082,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&keyword=redis+lock`, + url: `/api/callbacks/thread-context?keyword=redis+lock`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -2090,7 +2111,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -2114,7 +2136,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&threadId=thread-other`, + url: `/api/callbacks/thread-context?threadId=thread-other`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -2132,9 +2155,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 99, catId: 'opus', @@ -2162,9 +2184,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': 'bogus', 'x-callback-token': 'bogus' }, payload: { - invocationId: 'bogus', - callbackToken: 'bogus', repoFullName: 'zts212653/cat-cafe', prNumber: 1, catId: 'opus', @@ -2183,9 +2204,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 1, catId: 'nonexistent-cat', // bogus — should be ignored @@ -2205,9 +2225,8 @@ describe('Callback Routes', () => { const regA = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': userA.invocationId, 'x-callback-token': userA.callbackToken }, payload: { - invocationId: userA.invocationId, - callbackToken: userA.callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 42, catId: 'opus', @@ -2220,9 +2239,8 @@ describe('Callback Routes', () => { const regB = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': userB.invocationId, 'x-callback-token': userB.callbackToken }, payload: { - invocationId: userB.invocationId, - callbackToken: userB.callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 42, catId: 'codex', @@ -2270,9 +2288,8 @@ describe('Callback Routes', () => { const first = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': userA.invocationId, 'x-callback-token': userA.callbackToken }, payload: { - invocationId: userA.invocationId, - callbackToken: userA.callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 77, }, @@ -2283,9 +2300,8 @@ describe('Callback Routes', () => { const second = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': userB.invocationId, 'x-callback-token': userB.callbackToken }, payload: { - invocationId: userB.invocationId, - callbackToken: userB.callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 77, }, @@ -2303,9 +2319,8 @@ describe('Callback Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': inv1.invocationId, 'x-callback-token': inv1.callbackToken }, payload: { - invocationId: inv1.invocationId, - callbackToken: inv1.callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 42, catId: 'opus', @@ -2317,9 +2332,8 @@ describe('Callback Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': inv2.invocationId, 'x-callback-token': inv2.callbackToken }, payload: { - invocationId: inv2.invocationId, - callbackToken: inv2.callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 42, catId: 'opus', @@ -2346,9 +2360,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 1, catId: 'opus', @@ -2369,9 +2382,8 @@ describe('Callback Routes', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, repoFullName: 'zts212653/cat-cafe', prNumber: 832, catId: 'opus', // ← LLM passed wrong catId @@ -2400,9 +2412,8 @@ describe('Callback Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Hello from source thread', threadId: targetThread.id, }, @@ -2429,9 +2440,8 @@ describe('Callback Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Hello same thread', threadId: thread.id, }, diff --git a/packages/api/test/callback-start-vote.test.js b/packages/api/test/callback-start-vote.test.js index 6f554e642a..e8b1a38c59 100644 --- a/packages/api/test/callback-start-vote.test.js +++ b/packages/api/test/callback-start-vote.test.js @@ -70,9 +70,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'REST 还是 GraphQL?', options: ['REST', 'GraphQL'], voters: ['codex', 'gemini'], @@ -96,9 +95,8 @@ describe('POST /api/callbacks/start-vote', () => { await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: '哪个方案?', options: ['A', 'B'], voters: ['codex'], @@ -119,9 +117,8 @@ describe('POST /api/callbacks/start-vote', () => { await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: '谁最坏?', options: ['opus', 'codex'], voters: ['codex', 'gemini'], @@ -145,9 +142,8 @@ describe('POST /api/callbacks/start-vote', () => { await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'Q1?', options: ['A', 'B'], voters: ['codex'], @@ -158,9 +154,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'Q2?', options: ['C', 'D'], voters: ['codex'], @@ -178,9 +173,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': 'fake-id', 'x-callback-token': 'fake-token' }, payload: { - invocationId: 'fake-id', - callbackToken: 'fake-token', question: 'Q?', options: ['A', 'B'], voters: ['codex'], @@ -198,9 +192,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'Q?', options: ['A'], voters: ['codex'], @@ -219,9 +212,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'Q?', options: ['A', 'B'], voters: ['codex'], @@ -244,9 +236,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': old.invocationId, 'x-callback-token': old.callbackToken }, payload: { - invocationId: old.invocationId, - callbackToken: old.callbackToken, question: 'Q?', options: ['A', 'B'], voters: ['codex'], @@ -267,9 +258,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'Q?', options: ['A', 'B'], voters: ['codex'], @@ -289,9 +279,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'Q?', options: ['A', 'B'], voters: ['codex'], @@ -354,9 +343,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: '哪个方案好?', options: ['A', 'B'], voters: ['codex', 'gemini'], @@ -421,9 +409,8 @@ describe('POST /api/callbacks/start-vote', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/start-vote', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, question: 'Overflow test?', options: ['A', 'B'], voters: ['codex', 'gemini', 'sonnet', 'gpt52', 'spark', 'dare', 'antigravity'], diff --git a/packages/api/test/callback-thread-cats.test.js b/packages/api/test/callback-thread-cats.test.js index 5a9f989b85..a33efe7dbf 100644 --- a/packages/api/test/callback-thread-cats.test.js +++ b/packages/api/test/callback-thread-cats.test.js @@ -31,9 +31,10 @@ describe('GET /api/callbacks/thread-cats', () => { async function setup({ records, threads, participants, services = new Map() } = {}) { const { registerCallbackThreadCatsRoutes } = await import('../dist/routes/callback-thread-cats-routes.js'); + const { registerCallbackAuthHook } = await import('../dist/routes/callback-auth-prehandler.js'); app = Fastify(); + registerCallbackAuthHook(app, stubRegistry(records ?? new Map())); registerCallbackThreadCatsRoutes(app, { - registry: stubRegistry(records ?? new Map()), threadStore: stubThreadStore(threads, participants), agentRegistry: { getAllEntries: () => services }, }); @@ -41,20 +42,21 @@ describe('GET /api/callbacks/thread-cats', () => { return app; } - it('returns 400 when auth params are missing', async () => { + it('returns 401 when auth headers are missing', async () => { await setup(); const res = await app.inject({ method: 'GET', url: '/api/callbacks/thread-cats', }); - assert.equal(res.statusCode, 400); + assert.equal(res.statusCode, 401); }); it('returns 401 for invalid callback credentials', async () => { await setup(); const res = await app.inject({ method: 'GET', - url: '/api/callbacks/thread-cats?invocationId=bad&callbackToken=bad', + url: '/api/callbacks/thread-cats', + headers: { 'x-invocation-id': 'bad', 'x-callback-token': 'bad' }, }); assert.equal(res.statusCode, 401); }); @@ -67,7 +69,8 @@ describe('GET /api/callbacks/thread-cats', () => { const res = await app.inject({ method: 'GET', - url: '/api/callbacks/thread-cats?invocationId=inv-1&callbackToken=tok-1', + url: '/api/callbacks/thread-cats', + headers: { 'x-invocation-id': 'inv-1', 'x-callback-token': 'tok-1' }, }); assert.equal(res.statusCode, 404); }); @@ -85,7 +88,8 @@ describe('GET /api/callbacks/thread-cats', () => { await setup({ records, threads, participants, services }); const res = await app.inject({ method: 'GET', - url: '/api/callbacks/thread-cats?invocationId=inv-1&callbackToken=tok-1', + url: '/api/callbacks/thread-cats', + headers: { 'x-invocation-id': 'inv-1', 'x-callback-token': 'tok-1' }, }); assert.equal(res.statusCode, 200); @@ -110,7 +114,8 @@ describe('GET /api/callbacks/thread-cats', () => { const res = await app.inject({ method: 'GET', - url: '/api/callbacks/thread-cats?invocationId=inv-1&callbackToken=tok-1', + url: '/api/callbacks/thread-cats', + headers: { 'x-invocation-id': 'inv-1', 'x-callback-token': 'tok-1' }, }); assert.equal(res.statusCode, 400); }); diff --git a/packages/api/test/integration/mcp-prompt-e2e.test.js b/packages/api/test/integration/mcp-prompt-e2e.test.js index 0cd0b70e48..3a9d03b035 100644 --- a/packages/api/test/integration/mcp-prompt-e2e.test.js +++ b/packages/api/test/integration/mcp-prompt-e2e.test.js @@ -72,9 +72,8 @@ describe('MCP Prompt Injection E2E', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'Hello from Codex via HTTP callback!', }, }); @@ -117,7 +116,8 @@ describe('MCP Prompt Injection E2E', () => { // Simulate Gemini calling GET thread-context with query params const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200, `expected 200, got ${response.statusCode}: ${response.body}`); @@ -134,7 +134,8 @@ describe('MCP Prompt Injection E2E', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/pending-mentions?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/pending-mentions', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200, `expected 200, got ${response.statusCode}: ${response.body}`); @@ -160,9 +161,8 @@ describe('MCP Prompt Injection E2E', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-task', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, taskId: task.id, status: 'doing', why: '正在绘制中', diff --git a/packages/api/test/integration/task-callback.test.js b/packages/api/test/integration/task-callback.test.js index fe15a659d4..74c69c2a54 100644 --- a/packages/api/test/integration/task-callback.test.js +++ b/packages/api/test/integration/task-callback.test.js @@ -71,9 +71,8 @@ describe('Task Callback Integration', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-task', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, taskId: task.id, status: 'doing', }, @@ -126,9 +125,8 @@ describe('Task Callback Integration', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-task', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, taskId: task.id, status: 'done', }, @@ -153,9 +151,8 @@ describe('Task Callback Integration', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-task', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, taskId: task.id, status: 'doing', }, @@ -183,9 +180,8 @@ describe('Task Callback Integration', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/update-task', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, taskId: task.id, status: 'done', }, diff --git a/packages/api/test/integration/thread-wiring.test.js b/packages/api/test/integration/thread-wiring.test.js index d82276afaa..3fde2c8155 100644 --- a/packages/api/test/integration/thread-wiring.test.js +++ b/packages/api/test/integration/thread-wiring.test.js @@ -416,9 +416,8 @@ describe('MCP callback stores message with threadId', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { - invocationId, - callbackToken, content: 'callback msg', }, }); diff --git a/packages/api/test/integration/wiring.test.js b/packages/api/test/integration/wiring.test.js index c9633858b9..d50a6575a0 100644 --- a/packages/api/test/integration/wiring.test.js +++ b/packages/api/test/integration/wiring.test.js @@ -502,9 +502,8 @@ describe('MCP callback end-to-end flow', () => { const response = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': env.CAT_CAFE_INVOCATION_ID, 'x-callback-token': env.CAT_CAFE_CALLBACK_TOKEN }, payload: { - invocationId: env.CAT_CAFE_INVOCATION_ID, - callbackToken: env.CAT_CAFE_CALLBACK_TOKEN, content: 'Callback message from cat!', }, }); @@ -551,7 +550,8 @@ describe('MCP callback end-to-end flow', () => { const app = await createApp(); const response = await app.inject({ method: 'GET', - url: `/api/callbacks/pending-mentions?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/pending-mentions', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); diff --git a/packages/api/test/memory/callback-memory-di.test.js b/packages/api/test/memory/callback-memory-di.test.js index 96cd9d3a71..44a24a32f5 100644 --- a/packages/api/test/memory/callback-memory-di.test.js +++ b/packages/api/test/memory/callback-memory-di.test.js @@ -10,10 +10,13 @@ import Fastify from 'fastify'; describe('callback-memory-routes DI (IEvidenceStore path)', () => { let app; let registerFn; + let registerAuthHook; beforeEach(async () => { const mod = await import('../../dist/routes/callback-memory-routes.js'); registerFn = mod.registerCallbackMemoryRoutes; + const authMod = await import('../../dist/routes/callback-auth-prehandler.js'); + registerAuthHook = authMod.registerCallbackAuthHook; }); afterEach(async () => { @@ -44,8 +47,8 @@ describe('callback-memory-routes DI (IEvidenceStore path)', () => { }; app = Fastify(); + registerAuthHook(app, createMockRegistry()); await registerFn(app, { - registry: createMockRegistry(), markerQueue: mockMarkerQueue, }); await app.ready(); @@ -53,9 +56,8 @@ describe('callback-memory-routes DI (IEvidenceStore path)', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/retain-memory', + headers: { 'x-invocation-id': 'inv-1', 'x-callback-token': 'tok-1' }, payload: { - invocationId: 'inv-1', - callbackToken: 'tok-1', content: 'Lesson: always check Redis port', tags: 'kind:lesson', }, @@ -92,15 +94,16 @@ describe('callback-memory-routes DI (IEvidenceStore path)', () => { }; app = Fastify(); + registerAuthHook(app, createMockRegistry()); await registerFn(app, { - registry: createMockRegistry(), evidenceStore: mockStore, }); await app.ready(); const res = await app.inject({ method: 'GET', - url: '/api/callbacks/search-evidence?invocationId=inv-1&callbackToken=tok-1&q=prompt+audit', + url: '/api/callbacks/search-evidence?q=prompt+audit', + headers: { 'x-invocation-id': 'inv-1', 'x-callback-token': 'tok-1' }, }); assert.equal(res.statusCode, 200); @@ -126,8 +129,8 @@ describe('callback-memory-routes DI (IEvidenceStore path)', () => { }; app = Fastify(); + registerAuthHook(app, createMockRegistry()); await registerFn(app, { - registry: createMockRegistry(), reflectionService: mockReflection, }); await app.ready(); @@ -135,9 +138,8 @@ describe('callback-memory-routes DI (IEvidenceStore path)', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/reflect', + headers: { 'x-invocation-id': 'inv-1', 'x-callback-token': 'tok-1' }, payload: { - invocationId: 'inv-1', - callbackToken: 'tok-1', query: 'What patterns do we use?', }, }); @@ -163,8 +165,8 @@ describe('callback-memory-routes DI (IEvidenceStore path)', () => { }; app = Fastify(); + registerAuthHook(app, createMockRegistry()); await registerFn(app, { - registry: createMockRegistry(), markerQueue: mockMarkerQueue, }); await app.ready(); @@ -172,9 +174,8 @@ describe('callback-memory-routes DI (IEvidenceStore path)', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/retain-memory', + headers: { 'x-invocation-id': 'inv-1', 'x-callback-token': 'tok-1' }, payload: { - invocationId: 'inv-1', - callbackToken: 'tok-1', content: 'Important architectural decision', metadata: { custom: 'value' }, }, diff --git a/packages/api/test/mention-ack.test.js b/packages/api/test/mention-ack.test.js index 3ffaafec60..e59bc8f450 100644 --- a/packages/api/test/mention-ack.test.js +++ b/packages/api/test/mention-ack.test.js @@ -71,10 +71,11 @@ describe('Mention Ack (#77)', () => { } async function getPending(app, invocationId, callbackToken, { includeAcked } = {}) { - const extra = includeAcked ? `&includeAcked=${encodeURIComponent(includeAcked)}` : ''; + const extra = includeAcked ? `?includeAcked=${encodeURIComponent(includeAcked)}` : ''; const res = await app.inject({ method: 'GET', - url: `/api/callbacks/pending-mentions?invocationId=${invocationId}&callbackToken=${callbackToken}${extra}`, + url: `/api/callbacks/pending-mentions${extra}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); return JSON.parse(res.body); } @@ -83,7 +84,8 @@ describe('Mention Ack (#77)', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/ack-mentions', - payload: { invocationId, callbackToken, upToMessageId }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { upToMessageId }, }); return { statusCode: res.statusCode, body: JSON.parse(res.body) }; } @@ -92,7 +94,8 @@ describe('Mention Ack (#77)', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', - payload: { invocationId, callbackToken, content }, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content }, }); return { statusCode: res.statusCode, body: JSON.parse(res.body) }; } diff --git a/packages/api/test/multi-mention-b6-queue-dispatch.test.js b/packages/api/test/multi-mention-b6-queue-dispatch.test.js index 19a875c939..b4eafbd279 100644 --- a/packages/api/test/multi-mention-b6-queue-dispatch.test.js +++ b/packages/api/test/multi-mention-b6-queue-dispatch.test.js @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, test } from 'node:test'; import { CAT_CONFIGS, catRegistry } from '@cat-cafe/shared'; import Fastify from 'fastify'; import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { registerCallbackAuthHook } from '../dist/routes/callback-auth-prehandler.js'; import { getMultiMentionOrchestrator, resetMultiMentionOrchestrator, @@ -157,6 +158,7 @@ describe('B6: multi_mention queue dispatch', () => { creds = mockRegistry.register('opus', 'thread-1', 'user-1'); app = Fastify({ logger: false }); + registerCallbackAuthHook(app, mockRegistry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(app, { registry: mockRegistry, @@ -179,9 +181,8 @@ describe('B6: multi_mention queue dispatch', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'What do you think?', callbackTo: 'opus', @@ -206,9 +207,8 @@ describe('B6: multi_mention queue dispatch', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Review this?', callbackTo: 'opus', @@ -243,9 +243,8 @@ describe('B6: multi_mention queue dispatch', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex', 'gemini'], question: 'Thoughts?', callbackTo: 'opus', @@ -282,9 +281,8 @@ describe('B6: multi_mention queue dispatch', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Something?', callbackTo: 'opus', @@ -308,9 +306,8 @@ describe('B6: multi_mention queue dispatch', () => { await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Test queue entry fields', callbackTo: 'opus', @@ -344,9 +341,8 @@ describe('B6: multi_mention queue dispatch', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Should be blocked by depth', callbackTo: 'opus', @@ -372,9 +368,8 @@ describe('B6: multi_mention queue dispatch', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Should be skipped', callbackTo: 'opus', @@ -389,6 +384,7 @@ describe('B6: multi_mention queue dispatch', () => { test('falls back to direct dispatch when queue deps are absent', async () => { // Create a new app WITHOUT queue deps const fallbackApp = Fastify({ logger: false }); + registerCallbackAuthHook(fallbackApp, mockRegistry); resetMultiMentionOrchestrator(); const fallbackRouter = createMockRouter(); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); @@ -408,9 +404,8 @@ describe('B6: multi_mention queue dispatch', () => { const res = await fallbackApp.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': fallbackCreds.invocationId, 'x-callback-token': fallbackCreds.callbackToken }, payload: { - invocationId: fallbackCreds.invocationId, - callbackToken: fallbackCreds.callbackToken, targets: ['codex'], question: 'Fallback test', callbackTo: 'opus', @@ -706,6 +701,7 @@ describe('B6: canceled hook skips recordResponse in dispatchViaQueue', () => { creds = mockRegistry.register('opus', 'thread-1', 'user-1'); app = Fastify({ logger: false }); + registerCallbackAuthHook(app, mockRegistry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(app, { registry: mockRegistry, @@ -728,9 +724,8 @@ describe('B6: canceled hook skips recordResponse in dispatchViaQueue', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Abort scenario?', callbackTo: 'opus', @@ -755,9 +750,8 @@ describe('B6: canceled hook skips recordResponse in dispatchViaQueue', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Will be removed', callbackTo: 'opus', diff --git a/packages/api/test/multi-mention-routes.test.js b/packages/api/test/multi-mention-routes.test.js index ab24e64850..4541060d30 100644 --- a/packages/api/test/multi-mention-routes.test.js +++ b/packages/api/test/multi-mention-routes.test.js @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, test } from 'node:test'; import { CAT_CONFIGS, catRegistry } from '@cat-cafe/shared'; import Fastify from 'fastify'; +import { registerCallbackAuthHook } from '../dist/routes/callback-auth-prehandler.js'; import { resetMultiMentionOrchestrator } from '../dist/routes/callback-multi-mention-routes.js'; // Bootstrap catRegistry from CAT_CONFIGS (same as server startup) @@ -172,6 +173,7 @@ describe('Multi-Mention Routes', () => { creds = mockRegistry.register('opus', 'thread-1', 'user-1'); app = Fastify({ logger: false }); + registerCallbackAuthHook(app, mockRegistry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); @@ -197,9 +199,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'What do you think?', callbackTo: 'opus', @@ -216,9 +217,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': 'fake', 'x-callback-token': 'fake' }, payload: { - invocationId: 'fake', - callbackToken: 'fake', targets: ['codex'], question: 'test', callbackTo: 'opus', @@ -232,9 +232,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['nonexistent-cat'], question: 'test', callbackTo: 'opus', @@ -250,9 +249,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'test', callbackTo: 'nonexistent-cat', @@ -268,9 +266,8 @@ describe('Multi-Mention Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex', 'gemini'], question: 'Review this design', callbackTo: 'opus', @@ -291,9 +288,8 @@ describe('Multi-Mention Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex', 'gemini'], question: 'Review this design', callbackTo: 'opus', @@ -330,9 +326,8 @@ describe('Multi-Mention Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'What is your opinion?', callbackTo: 'opus', @@ -351,9 +346,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'test', callbackTo: 'opus', @@ -367,9 +361,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'test', callbackTo: 'opus', @@ -390,9 +383,8 @@ describe('Multi-Mention Routes', () => { const createRes = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'test', callbackTo: 'opus', @@ -404,11 +396,8 @@ describe('Multi-Mention Routes', () => { const statusRes = await app.inject({ method: 'GET', url: '/api/callbacks/multi-mention-status', - query: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, - requestId, - }, + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, + query: { requestId }, }); assert.equal(statusRes.statusCode, 200); @@ -421,11 +410,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'GET', url: '/api/callbacks/multi-mention-status', - query: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, - requestId: 'nonexistent', - }, + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, + query: { requestId: 'nonexistent' }, }); assert.equal(res.statusCode, 404); @@ -451,9 +437,8 @@ describe('Multi-Mention Routes', () => { await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': creds.invocationId, 'x-callback-token': creds.callbackToken }, payload: { - invocationId: creds.invocationId, - callbackToken: creds.callbackToken, targets: ['codex'], question: 'Quick question', callbackTo: 'opus', @@ -501,9 +486,8 @@ describe('Multi-Mention Routes', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': codexCreds.invocationId, 'x-callback-token': codexCreds.callbackToken }, payload: { - invocationId: codexCreds.invocationId, - callbackToken: codexCreds.callbackToken, targets: ['gemini'], question: 'Cascading question', callbackTo: 'codex', @@ -541,6 +525,7 @@ describe('Multi-Mention Routes', () => { // Re-create app with invocationTracker const trackerApp = Fastify({ logger: false }); + registerCallbackAuthHook(trackerApp, mockRegistry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(trackerApp, { registry: mockRegistry, @@ -558,9 +543,8 @@ describe('Multi-Mention Routes', () => { await trackerApp.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': callerCreds.invocationId, 'x-callback-token': callerCreds.callbackToken }, payload: { - invocationId: callerCreds.invocationId, - callbackToken: callerCreds.callbackToken, targets: ['opus', 'gemini'], question: 'Test concurrent dispatch', callbackTo: 'codex', @@ -616,6 +600,7 @@ describe('Multi-Mention Routes', () => { }; const crashApp = Fastify({ logger: false }); + registerCallbackAuthHook(crashApp, mockRegistry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(crashApp, { registry: mockRegistry, @@ -632,9 +617,8 @@ describe('Multi-Mention Routes', () => { const res = await crashApp.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': crashCreds.invocationId, 'x-callback-token': crashCreds.callbackToken }, payload: { - invocationId: crashCreds.invocationId, - callbackToken: crashCreds.callbackToken, targets: ['codex'], question: 'This will crash', callbackTo: 'opus', @@ -688,6 +672,7 @@ describe('Multi-Mention Routes', () => { }; const preAbortApp = Fastify({ logger: false }); + registerCallbackAuthHook(preAbortApp, mockRegistry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(preAbortApp, { registry: mockRegistry, @@ -704,9 +689,8 @@ describe('Multi-Mention Routes', () => { const res = await preAbortApp.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': preCreds.invocationId, 'x-callback-token': preCreds.callbackToken }, payload: { - invocationId: preCreds.invocationId, - callbackToken: preCreds.callbackToken, targets: ['codex'], question: 'After preempt', callbackTo: 'opus', @@ -738,6 +722,7 @@ describe('Multi-Mention Routes', () => { }; const capApp = Fastify({ logger: false }); + registerCallbackAuthHook(capApp, mockRegistry); const { registerMultiMentionRoutes } = await import('../dist/routes/callback-multi-mention-routes.js'); registerMultiMentionRoutes(capApp, { registry: mockRegistry, @@ -754,9 +739,8 @@ describe('Multi-Mention Routes', () => { const res = await capApp.inject({ method: 'POST', url: '/api/callbacks/multi-mention', + headers: { 'x-invocation-id': capCreds.invocationId, 'x-callback-token': capCreds.callbackToken }, payload: { - invocationId: capCreds.invocationId, - callbackToken: capCreds.callbackToken, targets: ['codex'], question: 'F122 parentInvocationId test', callbackTo: 'opus', diff --git a/packages/api/test/schedule-route.test.js b/packages/api/test/schedule-route.test.js index 75deac46d7..8ce781dbe4 100644 --- a/packages/api/test/schedule-route.test.js +++ b/packages/api/test/schedule-route.test.js @@ -436,12 +436,11 @@ describe('Schedule Routes', () => { const res = await appDyn.inject({ method: 'POST', url: '/api/schedule/tasks/preview', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { templateId: 'reminder', trigger: { type: 'once', delayMs: 1000 }, params: { message: 'hello' }, - invocationId, - callbackToken, }, }); assert.equal(res.statusCode, 200); @@ -456,13 +455,12 @@ describe('Schedule Routes', () => { const res = await appDyn.inject({ method: 'POST', url: '/api/schedule/tasks/preview', + headers: { 'x-invocation-id': stale.invocationId, 'x-callback-token': stale.callbackToken }, payload: { templateId: 'reminder', trigger: { type: 'once', delayMs: 1000 }, params: { message: 'stale-preview' }, deliveryThreadId: 'thread-explicit-preview', - invocationId: stale.invocationId, - callbackToken: stale.callbackToken, }, }); @@ -471,24 +469,23 @@ describe('Schedule Routes', () => { assert.equal(body.code, 'STALE_INVOCATION'); }); - it('returns 401 for invalid callback credentials in preview', async () => { + it('returns 401 for invalid callback credentials in preview (fail-closed, #474)', async () => { const { invocationId } = registry.create('user-1', 'opus', 'thread-preview-invalid'); const res = await appDyn.inject({ method: 'POST', url: '/api/schedule/tasks/preview', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': 'invalid-token' }, payload: { templateId: 'reminder', trigger: { type: 'once', delayMs: 1000 }, params: { message: 'invalid-preview' }, deliveryThreadId: 'thread-explicit-preview', - invocationId, - callbackToken: 'invalid-token', }, }); assert.equal(res.statusCode, 401); const body = res.json(); - assert.equal(body.code, 'INVALID_CALLBACK_CREDENTIALS'); + assert.ok(body.error.includes('expired'), 'preHandler rejects invalid creds before route handler'); }); }); @@ -668,12 +665,11 @@ describe('Schedule Routes', () => { const res = await appDyn.inject({ method: 'POST', url: '/api/schedule/tasks', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { templateId: 'reminder', trigger: { type: 'once', delayMs: 1000 }, params: { message: 'body-auth-thread' }, - invocationId, - callbackToken, }, }); assert.equal(res.statusCode, 200); @@ -710,13 +706,12 @@ describe('Schedule Routes', () => { const res = await appDyn.inject({ method: 'POST', url: '/api/schedule/tasks', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, payload: { templateId: 'reminder', trigger: { type: 'once', delayMs: 1000 }, params: { message: 'explicit-thread-wins' }, deliveryThreadId: 'thread-explicit', - invocationId, - callbackToken, }, }); assert.equal(res.statusCode, 200); @@ -733,13 +728,12 @@ describe('Schedule Routes', () => { const res = await appDyn.inject({ method: 'POST', url: '/api/schedule/tasks', + headers: { 'x-invocation-id': stale.invocationId, 'x-callback-token': stale.callbackToken }, payload: { templateId: 'reminder', trigger: { type: 'once', delayMs: 1000 }, params: { message: 'stale-create' }, deliveryThreadId: 'thread-explicit-create', - invocationId: stale.invocationId, - callbackToken: stale.callbackToken, }, }); @@ -750,24 +744,23 @@ describe('Schedule Routes', () => { assert.equal(stored, undefined); }); - it('returns 401 and does not persist for invalid callback credentials', async () => { + it('returns 401 and does not persist for invalid callback credentials (fail-closed, #474)', async () => { const { invocationId } = registry.create('user-1', 'opus', 'thread-create-invalid'); const res = await appDyn.inject({ method: 'POST', url: '/api/schedule/tasks', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': 'invalid-token' }, payload: { templateId: 'reminder', trigger: { type: 'once', delayMs: 1000 }, params: { message: 'invalid-create' }, deliveryThreadId: 'thread-explicit-create', - invocationId, - callbackToken: 'invalid-token', }, }); assert.equal(res.statusCode, 401); const body = res.json(); - assert.equal(body.code, 'INVALID_CALLBACK_CREDENTIALS'); + assert.ok(body.error.includes('expired'), 'preHandler rejects invalid creds before route handler'); const stored = store.getAll().find((d) => d.params?.message === 'invalid-create'); assert.equal(stored, undefined); }); diff --git a/packages/api/test/thread-context-workflow-sop.test.js b/packages/api/test/thread-context-workflow-sop.test.js index 5262a280e0..3815be07a3 100644 --- a/packages/api/test/thread-context-workflow-sop.test.js +++ b/packages/api/test/thread-context-workflow-sop.test.js @@ -106,7 +106,8 @@ describe('GET thread-context with workflowSop', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -145,7 +146,8 @@ describe('GET thread-context with workflowSop', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -180,7 +182,8 @@ describe('GET thread-context with workflowSop', () => { // Try to read other user's thread context with override const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}&threadId=${otherThread.id}`, + url: `/api/callbacks/thread-context?threadId=${otherThread.id}`, + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); @@ -207,7 +210,8 @@ describe('GET thread-context with workflowSop', () => { const response = await app.inject({ method: 'GET', - url: `/api/callbacks/thread-context?invocationId=${invocationId}&callbackToken=${callbackToken}`, + url: '/api/callbacks/thread-context', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, }); assert.equal(response.statusCode, 200); diff --git a/packages/api/test/workflow-sop-callback.test.js b/packages/api/test/workflow-sop-callback.test.js index 7684493bd7..8d5526b56d 100644 --- a/packages/api/test/workflow-sop-callback.test.js +++ b/packages/api/test/workflow-sop-callback.test.js @@ -99,12 +99,13 @@ describe('WorkflowSop callback route', () => { before(async () => { const module = await import('../dist/routes/callback-workflow-sop-routes.js'); + const { registerCallbackAuthHook } = await import('../dist/routes/callback-auth-prehandler.js'); workflowSopStore = createInMemoryWorkflowSopStore(); app = Fastify(); + registerCallbackAuthHook(app, createStubRegistry()); module.registerCallbackWorkflowSopRoutes(app, { - registry: createStubRegistry(), workflowSopStore, backlogStore: createStubBacklogStore(), }); @@ -119,10 +120,12 @@ describe('WorkflowSop callback route', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-workflow-sop', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + 'x-invocation-id': INVOCATION_ID, + 'x-callback-token': CALLBACK_TOKEN, + }, payload: { - invocationId: INVOCATION_ID, - callbackToken: CALLBACK_TOKEN, backlogItemId: 'item-1', featureId: 'F073', stage: 'impl', @@ -141,10 +144,8 @@ describe('WorkflowSop callback route', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-workflow-sop', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-invocation-id': 'bad-id', 'x-callback-token': 'bad-token' }, payload: { - invocationId: 'bad-id', - callbackToken: 'bad-token', backlogItemId: 'item-1', featureId: 'F073', }, @@ -156,10 +157,12 @@ describe('WorkflowSop callback route', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-workflow-sop', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + 'x-invocation-id': INVOCATION_ID, + 'x-callback-token': CALLBACK_TOKEN, + }, payload: { - invocationId: INVOCATION_ID, - callbackToken: CALLBACK_TOKEN, backlogItemId: 'nonexistent', featureId: 'F073', }, @@ -171,10 +174,12 @@ describe('WorkflowSop callback route', () => { const res = await app.inject({ method: 'POST', url: '/api/callbacks/update-workflow-sop', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + 'x-invocation-id': INVOCATION_ID, + 'x-callback-token': CALLBACK_TOKEN, + }, payload: { - invocationId: INVOCATION_ID, - callbackToken: CALLBACK_TOKEN, // missing backlogItemId and featureId }, }); diff --git a/packages/mcp-server/src/tools/callback-outbox.ts b/packages/mcp-server/src/tools/callback-outbox.ts index 00110b2bd4..45545d939d 100644 --- a/packages/mcp-server/src/tools/callback-outbox.ts +++ b/packages/mcp-server/src/tools/callback-outbox.ts @@ -19,6 +19,7 @@ interface OutboxEntry { apiUrl: string; path: string; body: Record; + headers?: Record; attempts: number; lastError: string; } @@ -27,6 +28,7 @@ export interface CallbackRequest { apiUrl: string; path: string; body: Record; + headers?: Record; } function parseIntEnv(raw: string | undefined): number | null { @@ -79,6 +81,16 @@ function parseOutboxEntry(raw: string): OutboxEntry | null { } } +/** Extract auth headers from legacy body fields (pre-#476 outbox entries). */ +function legacyHeadersFromBody(body: Record): Record | undefined { + const invocationId = body.invocationId; + const callbackToken = body.callbackToken; + if (typeof invocationId === 'string' && typeof callbackToken === 'string') { + return { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }; + } + return undefined; +} + async function enqueueOutbox(entry: OutboxEntry): Promise { try { const dir = getOutboxDir(); @@ -122,7 +134,15 @@ async function flushOutbox(): Promise { continue; } - const replay = await postJsonWithRetry(`${entry.apiUrl}${entry.path}`, JSON.stringify(entry.body), retryDelaysMs); + // Legacy fixup (#476): entries queued before header migration have creds + // in body, not headers. Migrate them so the new preHandler accepts them. + const replayHeaders = entry.headers ?? legacyHeadersFromBody(entry.body); + const replay = await postJsonWithRetry( + `${entry.apiUrl}${entry.path}`, + JSON.stringify(entry.body), + retryDelaysMs, + replayHeaders, + ); if (replay.ok) { await unlink(processingPath); continue; @@ -158,7 +178,7 @@ export async function sendCallbackRequest( const retryDelaysMs = getRetryDelaysMs(); const payload = JSON.stringify(request.body); - const result = await postJsonWithRetry(`${request.apiUrl}${request.path}`, payload, retryDelaysMs); + const result = await postJsonWithRetry(`${request.apiUrl}${request.path}`, payload, retryDelaysMs, request.headers); if (result.ok) return { ok: true, data: result.data }; if (enableOutbox && result.failure.retryable) { @@ -169,6 +189,7 @@ export async function sendCallbackRequest( apiUrl: request.apiUrl, path: request.path, body: request.body, + ...(request.headers ? { headers: request.headers } : {}), attempts: 0, lastError: result.failure.error, }); diff --git a/packages/mcp-server/src/tools/callback-retry.ts b/packages/mcp-server/src/tools/callback-retry.ts index ddee663881..dee3f046aa 100644 --- a/packages/mcp-server/src/tools/callback-retry.ts +++ b/packages/mcp-server/src/tools/callback-retry.ts @@ -33,6 +33,7 @@ export async function postJsonWithRetry( url: string, payload: string, retryDelaysMs: number[], + extraHeaders?: Record, ): Promise { let lastError = 'Callback failed'; let retryable = true; @@ -41,7 +42,7 @@ export async function postJsonWithRetry( try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...extraHeaders }, body: payload, }); diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts index 259bfd3195..4311d8408a 100644 --- a/packages/mcp-server/src/tools/callback-tools.ts +++ b/packages/mcp-server/src/tools/callback-tools.ts @@ -28,6 +28,13 @@ export const NO_CONFIG_ERROR = 'Clowder AI callback not configured. Missing CAT_CAFE_API_URL, CAT_CAFE_INVOCATION_ID, or CAT_CAFE_CALLBACK_TOKEN environment variables.'; // ============ HTTP helpers ============ +export function buildAuthHeaders(config: CallbackConfig): Record { + return { + 'x-invocation-id': config.invocationId, + 'x-callback-token': config.callbackToken, + }; +} + export async function callbackPost( path: string, body: Record, @@ -36,14 +43,8 @@ export async function callbackPost( const config = getCallbackConfig(); if (!config) return errorResult(NO_CONFIG_ERROR); - const requestBody = { - invocationId: config.invocationId, - callbackToken: config.callbackToken, - ...body, - }; - const result = await sendCallbackRequest( - { apiUrl: config.apiUrl, path, body: requestBody }, + { apiUrl: config.apiUrl, path, body, headers: buildAuthHeaders(config) }, { enableOutbox: options?.enableOutbox === true }, ); if (result.ok) return successResult(JSON.stringify(result.data)); @@ -54,14 +55,12 @@ export async function callbackGet(path: string, params?: Record) const config = getCallbackConfig(); if (!config) return errorResult(NO_CONFIG_ERROR); - const query = new URLSearchParams({ - invocationId: config.invocationId, - callbackToken: config.callbackToken, - ...params, - }); + const query = new URLSearchParams(params ?? {}); + const qs = query.toString(); + const url = qs ? `${config.apiUrl}${path}?${qs}` : `${config.apiUrl}${path}`; try { - const response = await fetch(`${config.apiUrl}${path}?${query.toString()}`); + const response = await fetch(url, { headers: buildAuthHeaders(config) }); if (!response.ok) { const text = await response.text(); return errorResult(`Callback failed (${response.status}): ${text}`); diff --git a/packages/mcp-server/src/tools/schedule-tools.ts b/packages/mcp-server/src/tools/schedule-tools.ts index 5d20aaa6f8..663e7816e5 100644 --- a/packages/mcp-server/src/tools/schedule-tools.ts +++ b/packages/mcp-server/src/tools/schedule-tools.ts @@ -14,18 +14,14 @@ import { errorResult } from './file-tools.js'; // ─── callbackDelete (schedule-specific) ────────────────────── async function callbackDelete(path: string): Promise { - const { getCallbackConfig, NO_CONFIG_ERROR } = await import('./callback-tools.js'); + const { getCallbackConfig, buildAuthHeaders, NO_CONFIG_ERROR } = await import('./callback-tools.js'); const config = getCallbackConfig(); if (!config) return errorResult(NO_CONFIG_ERROR); try { const response = await fetch(`${config.apiUrl}${path}`, { method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'x-invocation-id': config.invocationId, - 'x-callback-token': config.callbackToken, - }, + headers: { 'Content-Type': 'application/json', ...buildAuthHeaders(config) }, }); if (!response.ok) { const text = await response.text(); diff --git a/packages/mcp-server/test/callback-tools.test.js b/packages/mcp-server/test/callback-tools.test.js index 6ba3568e2a..7c7edbcda7 100644 --- a/packages/mcp-server/test/callback-tools.test.js +++ b/packages/mcp-server/test/callback-tools.test.js @@ -68,8 +68,8 @@ describe('MCP Callback Tools', () => { assert.ok(capturedUrl.includes('/api/callbacks/post-message')); const body = JSON.parse(capturedOptions.body); assert.equal(body.content, 'Hello from cat!'); - assert.equal(body.invocationId, 'test-invocation'); - assert.equal(body.callbackToken, 'test-token'); + assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); + assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); }); test('handlePostMessage forwards optional threadId for cross-thread posting', async () => { @@ -135,12 +135,13 @@ describe('MCP Callback Tools', () => { assert.equal(result.isError, undefined); }); - test('handleGetPendingMentions calls API with auth in query', async () => { + test('handleGetPendingMentions calls API with auth in headers', async () => { const { handleGetPendingMentions } = await import('../dist/tools/callback-tools.js'); - let capturedUrl; - globalThis.fetch = async (url) => { + let capturedUrl, capturedOptions; + globalThis.fetch = async (url, options) => { capturedUrl = url; + capturedOptions = options; return { ok: true, json: async () => ({ mentions: [] }), @@ -151,8 +152,8 @@ describe('MCP Callback Tools', () => { assert.equal(result.isError, undefined); assert.ok(capturedUrl.includes('/api/callbacks/pending-mentions')); - assert.ok(capturedUrl.includes('invocationId=test-invocation')); - assert.ok(capturedUrl.includes('callbackToken=test-token')); + assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); + assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); }); test('handleGetThreadContext calls API with limit', async () => { @@ -416,8 +417,8 @@ describe('MCP Callback Tools', () => { assert.ok(capturedUrl.includes('/api/callbacks/reflect')); const body = JSON.parse(capturedOptions.body); assert.equal(body.query, 'How to reduce context drift?'); - assert.equal(body.invocationId, 'test-invocation'); - assert.equal(body.callbackToken, 'test-token'); + assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); + assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); }); test('handleRetainMemory posts content/tags/metadata to callback retain endpoint', async () => { @@ -617,8 +618,8 @@ describe('MCP Callback Tools', () => { assert.equal(body.action, 'git_commit'); assert.equal(body.reason, 'Committing bug fix'); assert.equal(body.context, 'Fix for issue #42'); - assert.equal(body.invocationId, 'test-invocation'); - assert.equal(body.callbackToken, 'test-token'); + assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); + assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); assert.ok(result.content[0].text.includes('granted')); }); @@ -649,9 +650,10 @@ describe('MCP Callback Tools', () => { test('handleCheckPermissionStatus queries permission-status endpoint', async () => { const { handleCheckPermissionStatus } = await import('../dist/tools/callback-tools.js'); - let capturedUrl; - globalThis.fetch = async (url) => { + let capturedUrl, capturedOptions; + globalThis.fetch = async (url, options) => { capturedUrl = url; + capturedOptions = options; return { ok: true, json: async () => ({ @@ -668,8 +670,8 @@ describe('MCP Callback Tools', () => { assert.equal(result.isError, undefined); assert.ok(capturedUrl.includes('/api/callbacks/permission-status')); assert.ok(capturedUrl.includes('requestId=req-123')); - assert.ok(capturedUrl.includes('invocationId=test-invocation')); - assert.ok(capturedUrl.includes('callbackToken=test-token')); + assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); + assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); assert.ok(result.content[0].text.includes('granted')); }); @@ -733,6 +735,65 @@ describe('MCP Callback Tools', () => { assert.equal(readdirSync(outboxDir).length, 0, 'stale entry should be dropped after max attempts'); }); + // ---- #476: outbox legacy fixup — pre-migration entries have creds in body, not headers ---- + + test('flushes pre-#476 outbox entry with creds in body by migrating them to headers', async () => { + const { handlePostMessage } = await import('../dist/tools/callback-tools.js'); + + // Seed a legacy outbox entry: has invocationId/callbackToken in body, NO headers field + const legacyEntry = { + id: 'legacy-001', + queuedAt: 1, + apiUrl: 'http://127.0.0.1:3004', + path: '/api/callbacks/post-message', + body: { + invocationId: 'legacy-inv', + callbackToken: 'legacy-tok', + content: 'legacy-queued-message', + clientMessageId: 'legacy-001', + }, + // NOTE: no "headers" field — this is the pre-#476 format + attempts: 0, + lastError: 'seeded', + }; + writeFileSync( + join(outboxDir, `${legacyEntry.queuedAt}-${legacyEntry.id}.json`), + JSON.stringify(legacyEntry), + 'utf8', + ); + + const replayedHeaders = []; + globalThis.fetch = async (_url, options) => { + const body = JSON.parse(options.body); + if (body.content === 'legacy-queued-message') { + replayedHeaders.push({ ...options.headers }); + } + return { + ok: true, + json: async () => ({ status: 'ok' }), + }; + }; + + const result = await handlePostMessage({ + content: 'current-after-legacy', + clientMessageId: 'current-legacy-001', + }); + + assert.equal(result.isError, undefined); + assert.equal(replayedHeaders.length, 1, 'legacy entry should have been replayed'); + assert.equal( + replayedHeaders[0]['x-invocation-id'], + 'legacy-inv', + 'replay must extract invocationId from body into x-invocation-id header', + ); + assert.equal( + replayedHeaders[0]['x-callback-token'], + 'legacy-tok', + 'replay must extract callbackToken from body into x-callback-token header', + ); + assert.equal(readdirSync(outboxDir).length, 0, 'legacy entry should be drained after success'); + }); + // ---- #84: create_rich_block Route A → Route B fallback ---- test('handleCreateRichBlock succeeds via Route A when callback works', async () => { @@ -894,8 +955,8 @@ describe('MCP Callback Tools', () => { assert.equal(body.timeoutMinutes, 8); assert.deepEqual(body.searchEvidenceRefs, ['docs/features/F055.md']); assert.equal(body.triggerType, 'cross-domain'); - assert.equal(body.invocationId, 'test-invocation'); - assert.equal(body.callbackToken, 'test-token'); + assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); + assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); }); test('handleMultiMention rejects missing searchEvidenceRefs and overrideReason', async () => { @@ -982,8 +1043,8 @@ describe('MCP Callback Tools', () => { assert.equal(body.repoFullName, 'zts212653/cat-cafe'); assert.equal(body.prNumber, 832); assert.equal(body.catId, undefined, 'catId must not appear in body when omitted'); - assert.equal(body.invocationId, 'test-invocation'); - assert.equal(body.callbackToken, 'test-token'); + assert.equal(capturedOptions.headers['x-invocation-id'], 'test-invocation'); + assert.equal(capturedOptions.headers['x-callback-token'], 'test-token'); }); test('handleRegisterPrTracking forwards catId when provided (backward compat)', async () => {