/** * Federation grant scope schema and validator. * * Source of truth: docs/federation/PRD.md §8.1 * * This module is intentionally pure — no DB, no NestJS, no CA wiring. * It is reusable from grant CRUD (M2-06) and scope enforcement (M3+). */ import { z } from 'zod'; // --------------------------------------------------------------------------- // Allowlist of federation resources (canonical — M3+ will extend this list) // --------------------------------------------------------------------------- export const FEDERATION_RESOURCE_VALUES = [ 'tasks', 'notes', 'memory', 'credentials', 'api_keys', ] as const; export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number]; /** * Sensitive resources require explicit admin approval (PRD §8.4). * The parser warns when these appear in `resources`; M2-06 grant CRUD * will add a hard gate on top of this warning. */ const SENSITIVE_RESOURCES: ReadonlySet = new Set(['credentials', 'api_keys']); // --------------------------------------------------------------------------- // Sub-schemas // --------------------------------------------------------------------------- const ResourceArraySchema = z .array(z.enum(FEDERATION_RESOURCE_VALUES)) .nonempty({ message: 'resources must contain at least one value' }) .refine((arr) => new Set(arr).size === arr.length, { message: 'resources must not contain duplicate values', }); const ResourceFilterSchema = z.object({ include_teams: z.array(z.string()).optional(), include_personal: z.boolean().default(true), }); // --------------------------------------------------------------------------- // Top-level schema // --------------------------------------------------------------------------- export const FederationScopeSchema = z .object({ resources: ResourceArraySchema, excluded_resources: z .array(z.enum(FEDERATION_RESOURCE_VALUES)) .default([]) .refine((arr) => new Set(arr).size === arr.length, { message: 'excluded_resources must not contain duplicate values', }), filters: z.record(z.string(), ResourceFilterSchema).optional(), max_rows_per_query: z .number() .int({ message: 'max_rows_per_query must be an integer' }) .min(1, { message: 'max_rows_per_query must be at least 1' }) .max(10000, { message: 'max_rows_per_query must be at most 10000' }), }) .superRefine((data, ctx) => { const resourceSet = new Set(data.resources); // Intersection guard: a resource cannot be both granted and excluded for (const r of data.excluded_resources) { if (resourceSet.has(r)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Resource "${r}" appears in both resources and excluded_resources`, path: ['excluded_resources'], }); } } // Filter keys must be a subset of resources if (data.filters) { for (const key of Object.keys(data.filters)) { if (!resourceSet.has(key as FederationResource)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `filters key "${key}" references a resource not present in resources`, path: ['filters', key], }); } } } }); export type FederationScope = z.infer; // --------------------------------------------------------------------------- // Error class // --------------------------------------------------------------------------- export class FederationScopeError extends Error { constructor(message: string) { super(message); this.name = 'FederationScopeError'; } } // --------------------------------------------------------------------------- // Typed parser // --------------------------------------------------------------------------- /** * Parse and validate an unknown value as a FederationScope. * * Throws `FederationScopeError` with aggregated Zod issues on failure. * * Emits `console.warn` when sensitive resources (`credentials`, `api_keys`) * are present in `resources` — per PRD §8.4, these require explicit admin * approval. M2-06 grant CRUD will add a hard gate on top of this warning. */ export function parseFederationScope(input: unknown): FederationScope { const result = FederationScopeSchema.safeParse(input); if (!result.success) { const issues = result.error.issues .map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`) .join('\n'); throw new FederationScopeError(`Invalid federation scope:\n${issues}`); } const scope = result.data; // Sentinel warning for sensitive resources (PRD §8.4) for (const resource of scope.resources) { if (SENSITIVE_RESOURCES.has(resource)) { console.warn( `[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`, ); } } return scope; }