Skip to content
Open
2 changes: 1 addition & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1318,7 +1318,7 @@ async function main(): Promise<void> {
auditStore: authAuditStore,
io: socketManager.getIO(),
});
await app.register(callbackAuthRoutes, { registry, authManager });
await app.register(callbackAuthRoutes, { authManager, registry });
await app.register(authorizationRoutes, {
authManager,
ruleStore: authRuleStore,
Expand Down
102 changes: 102 additions & 0 deletions packages/api/src/routes/callback-auth-prehandler.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown> | 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;
}
6 changes: 0 additions & 6 deletions packages/api/src/routes/callback-auth-schema.ts

This file was deleted.

39 changes: 18 additions & 21 deletions packages/api/src/routes/callback-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallbackAuthRoutesOptions> = 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 } : {}),
Expand All @@ -63,18 +58,16 @@ export const callbackAuthRoutes: FastifyPluginAsync<CallbackAuthRoutesOptions> =

// 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) {
Expand All @@ -83,7 +76,11 @@ export const callbackAuthRoutes: FastifyPluginAsync<CallbackAuthRoutesOptions> =
}

// 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' };
}
Expand Down
31 changes: 13 additions & 18 deletions packages/api/src/routes/callback-bootcamp-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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(),
Expand All @@ -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' };
}

Expand Down Expand Up @@ -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' };
}

Expand Down
17 changes: 7 additions & 10 deletions packages/api/src/routes/callback-document-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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' };
Expand Down
20 changes: 7 additions & 13 deletions packages/api/src/routes/callback-game-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand Down
Loading