diff --git a/apps/review-service/src/http-boundary.test.ts b/apps/review-service/src/http-boundary.test.ts index dfde5e26..2ec887aa 100644 --- a/apps/review-service/src/http-boundary.test.ts +++ b/apps/review-service/src/http-boundary.test.ts @@ -1,9 +1,10 @@ +import { createHmac, randomBytes } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { requireHistoryLimit, requireRitualPath, - requireWorkspaceHeader, + requireTrustedWorkspaceContext, toReviewHttpException, } from './http-boundary'; import { @@ -13,23 +14,336 @@ import { import { ReviewPersistenceError } from './postgres-review-repository'; const WORKSPACE_ID = '018f47b2-c1d2-4a30-8c17-221fb579c042'; +const SECRET = randomBytes(32).toString('base64url'); +const NOW_SECONDS = 1_786_334_400; +const BASE64URL_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; function response(error: HttpException): unknown { return error.getResponse(); } +function signature(issuedAt: string, workspaceId = WORKSPACE_ID): string { + return createHmac('sha256', SECRET) + .update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8') + .digest('base64url'); +} + +function expectTrustedContextRejection( + headers: { workspaceId: unknown; issuedAt: unknown; signature: unknown }, + secret: unknown, + nowSeconds: number, + status: number, + code: string, +): void { + const operation = () => + requireTrustedWorkspaceContext(headers, secret, nowSeconds); + expect(operation).toThrow(HttpException); + + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HttpException); + expect(response(thrown as HttpException)).toMatchObject({ status, code }); +} + describe('Review HTTP boundary', () => { - it('accepts only bounded workspace, ritual, and history values', () => { - expect(requireWorkspaceHeader(WORKSPACE_ID.toUpperCase())).toBe( - WORKSPACE_ID, - ); - expect(requireRitualPath('weekly-review')).toBe('weekly-review'); - expect(requireHistoryLimit(undefined)).toBe(50); - expect(requireHistoryLimit('100')).toBe(100); + it( + 'accepts fresh signed workspace context and bounded ritual/history values', + () => { + const issuedAt = String(NOW_SECONDS - 30); + expect( + requireTrustedWorkspaceContext( + { + workspaceId: WORKSPACE_ID.toUpperCase(), + issuedAt, + signature: signature(issuedAt), + }, + SECRET, + NOW_SECONDS, + ), + ).toBe(WORKSPACE_ID); + expect(requireRitualPath('weekly-review')).toBe('weekly-review'); + expect(requireHistoryLimit(undefined)).toBe(50); + expect(requireHistoryLimit('100')).toBe(100); + }, + ); + + it('accepts the exact maximum context age', () => { + const issuedAt = String(NOW_SECONDS - 60); + expect( + requireTrustedWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signature(issuedAt), + }, + SECRET, + NOW_SECONDS, + ), + ).toBe(WORKSPACE_ID); }); + it('accepts the exact maximum future clock skew', () => { + const issuedAt = String(NOW_SECONDS + 5); + expect( + requireTrustedWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signature(issuedAt), + }, + SECRET, + NOW_SECONDS, + ), + ).toBe(WORKSPACE_ID); + }); + + it( + 'rejects a non-canonical base64url alias for the same signature bytes', + () => { + const issuedAt = String(NOW_SECONDS); + const canonical = signature(issuedAt); + const finalIndex = BASE64URL_ALPHABET.indexOf(canonical.at(-1) ?? ''); + expect(finalIndex).toBeGreaterThanOrEqual(0); + expect(finalIndex % 4).toBe(0); + const aliasCharacter = BASE64URL_ALPHABET[finalIndex + 1]; + expect(aliasCharacter).toBeDefined(); + const nonCanonical = `${canonical.slice(0, -1)}${aliasCharacter}`; + expect(Buffer.from(nonCanonical, 'base64url')).toEqual( + Buffer.from(canonical, 'base64url'), + ); + + expect(() => + requireTrustedWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: nonCanonical, + }, + SECRET, + NOW_SECONDS, + ), + ).toThrow(HttpException); + }, + ); + + it.each([ + { + name: 'stale timestamp', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS - 61), + signature: signature(String(NOW_SECONDS - 61)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'future timestamp', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS + 6), + signature: signature(String(NOW_SECONDS + 6)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'forged signature', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: 'A'.repeat(43), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'short verifier secret', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: 'too-short', + nowSeconds: NOW_SECONDS, + status: 503, + code: 'gateway_context_unavailable', + }, + { + name: 'missing verifier secret', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: undefined, + nowSeconds: NOW_SECONDS, + status: 503, + code: 'gateway_context_unavailable', + }, + { + name: 'missing workspace header', + headers: { + workspaceId: undefined, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'non-string workspace header', + headers: { + workspaceId: 123, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'invalid workspace UUID', + headers: { + workspaceId: 'not-a-uuid', + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'missing issued-at header', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: undefined, + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'non-string issued-at header', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: 123, + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'nonnumeric issued-at header', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: 'not-a-timestamp', + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'missing signature header', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: undefined, + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'non-string signature header', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: 123, + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'wrong-length signature', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: 'A'.repeat(42), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'invalid base64url signature characters', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: '!'.repeat(43), + }, + secret: SECRET, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'non-integer verifier clock', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: Number.NaN, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'negative verifier clock', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + nowSeconds: -1, + status: 401, + code: 'invalid_gateway_context', + }, + ])( + 'fails closed for $name', + ({ headers, secret, nowSeconds, status, code }) => { + expectTrustedContextRejection(headers, secret, nowSeconds, status, code); + }, + ); + it.each([ - () => requireWorkspaceHeader('not-a-workspace'), () => requireRitualPath('execute'), () => requireHistoryLimit('101'), ])('returns bounded problems for invalid boundary input', (operation) => { diff --git a/apps/review-service/src/http-boundary.ts b/apps/review-service/src/http-boundary.ts index 51a0c894..c734aafa 100644 --- a/apps/review-service/src/http-boundary.ts +++ b/apps/review-service/src/http-boundary.ts @@ -1,3 +1,4 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { ReviewCompletionConflictError, @@ -17,6 +18,19 @@ export interface ReviewProblemDetails { code: string; } +/** Signed tenant authority emitted by the authenticated gateway boundary. */ +export interface ReviewTrustedWorkspaceContextHeaders { + workspaceId: unknown; + issuedAt: unknown; + signature: unknown; +} + +const UNIX_SECONDS_PATTERN = /^(?:0|[1-9]\d{0,12})$/u; +const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const MINIMUM_GATEWAY_SECRET_BYTES = 32; +const MAXIMUM_CONTEXT_AGE_SECONDS = 60; +const MAXIMUM_FUTURE_SKEW_SECONDS = 5; + function problemException( status: number, title: string, @@ -31,17 +45,104 @@ function problemException( return new HttpException(problem, status); } -/** Requires a tenant UUIDv4 exclusively from the trusted workspace header. */ -export function requireWorkspaceHeader(value: string | undefined): string { +/** Rejects malformed, forged, stale, or future trusted context with a credential-free 401 problem. */ +function invalidGatewayContext(): never { + throw problemException( + 401, + 'Trusted gateway context is invalid', + 'invalid_gateway_context', + ); +} + +/** Reports verifier configuration that cannot authenticate context as a bounded 503 problem. */ +function unavailableGatewayContext(): never { + throw problemException( + 503, + 'Trusted gateway context is unavailable', + 'gateway_context_unavailable', + ); +} + +/** + * Requires verifier key material that is long enough to authenticate gateway + * workspace assertions. The returned value is safe to pass to the HMAC verifier. + */ +export function requireReviewGatewayContextSecret(secret: unknown): string { + if ( + typeof secret !== 'string' || + Buffer.byteLength(secret, 'utf8') < MINIMUM_GATEWAY_SECRET_BYTES + ) { + return unavailableGatewayContext(); + } + return secret; +} + +/** + * Computes the SHA-256 HMAC over the canonical `life-os.workspace.v1` payload. + * The workspace ID must already be normalized to lowercase UUIDv4 form, so the + * gateway must sign that normalized identifier rather than the raw header value. + */ +function workspaceContextDigest( + workspaceId: string, + issuedAt: string, + secret: string, +): Buffer { + return createHmac('sha256', secret) + .update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8') + .digest(); +} + +/** + * Verifies the short-lived workspace context created after gateway authentication. + * A browser-selected workspace header is intentionally not an authorization input. + */ +export function requireTrustedWorkspaceContext( + headers: ReviewTrustedWorkspaceContextHeaders, + secret: unknown, + nowSeconds = Math.floor(Date.now() / 1000), +): string { + const verifiedSecret = requireReviewGatewayContextSecret(secret); + if ( + typeof headers.workspaceId !== 'string' || + typeof headers.issuedAt !== 'string' || + typeof headers.signature !== 'string' || + !UNIX_SECONDS_PATTERN.test(headers.issuedAt) || + !BASE64URL_SHA256_PATTERN.test(headers.signature) || + !Number.isSafeInteger(nowSeconds) || + nowSeconds < 0 + ) { + return invalidGatewayContext(); + } + + let workspaceId: string; try { - return requireReviewWorkspaceId(value); + workspaceId = requireReviewWorkspaceId(headers.workspaceId); } catch { - throw problemException( - 400, - 'A valid x-workspace-id header is required', - 'invalid_workspace', - ); + return invalidGatewayContext(); + } + + const issuedAtSeconds = Number(headers.issuedAt); + if ( + issuedAtSeconds > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS || + issuedAtSeconds < nowSeconds - MAXIMUM_CONTEXT_AGE_SECONDS + ) { + return invalidGatewayContext(); + } + + const expected = workspaceContextDigest( + workspaceId, + headers.issuedAt, + verifiedSecret, + ); + const actual = Buffer.from(headers.signature, 'base64url'); + if ( + actual.length !== expected.length || + actual.toString('base64url') !== headers.signature || + !timingSafeEqual(actual, expected) + ) { + return invalidGatewayContext(); } + return workspaceId; } /** Requires a supported ritual kind from the bounded route parameter. */ diff --git a/apps/review-service/src/main.test.ts b/apps/review-service/src/main.test.ts index 3f5cee32..22f08214 100644 --- a/apps/review-service/src/main.test.ts +++ b/apps/review-service/src/main.test.ts @@ -1,6 +1,7 @@ +import { createHmac, randomBytes } from 'node:crypto'; import { HttpException } from '@nestjs/common'; -import { describe, expect, it } from 'vitest'; -import { ReviewController } from './main'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { requireReviewServiceConfiguration, ReviewController } from './main'; import { ReviewService, type ReviewCompletionRecord, @@ -10,6 +11,8 @@ import { const WORKSPACE_ID = '018f47b2-c1d2-4a30-8c17-221fb579c042'; const IDEMPOTENCY_KEY = 'd1191b96-b7f4-4d8f-b1f7-9e2838686d5f'; const COMPLETION_ID = '3f044b68-c515-4a52-8862-38af0047b88d'; +const GATEWAY_SECRET = randomBytes(32).toString('base64url'); +let previousGatewaySecret: string | undefined; class InMemoryReviewRepository implements ReviewRepository { readonly records: ReviewCompletionRecord[] = []; @@ -39,7 +42,48 @@ function body() { }; } +function trustedContext(workspaceId = WORKSPACE_ID): readonly [string, string] { + const issuedAt = String(Math.floor(Date.now() / 1000)); + const normalizedWorkspaceId = workspaceId.toLowerCase(); + const signature = createHmac('sha256', GATEWAY_SECRET) + .update( + `life-os.workspace.v1\n${normalizedWorkspaceId}\n${issuedAt}`, + 'utf8', + ) + .digest('base64url'); + return [issuedAt, signature] as const; +} + +function expectGatewayContextUnavailable(operation: () => unknown): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HttpException); + const exception = thrown as HttpException; + expect(exception.getStatus()).toBe(503); + expect(exception.getResponse()).toMatchObject({ + status: 503, + code: 'gateway_context_unavailable', + }); +} + describe('Review controller', () => { + beforeEach(() => { + previousGatewaySecret = process.env.REVIEW_GATEWAY_CONTEXT_SECRET; + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET; + }); + + afterEach(() => { + if (previousGatewaySecret === undefined) { + delete process.env.REVIEW_GATEWAY_CONTEXT_SECRET; + } else { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = previousGatewaySecret; + } + }); + it('exposes health and all three guided completion routes', async () => { const repository = new InMemoryReviewRepository(); const service = new ReviewService( @@ -48,31 +92,73 @@ describe('Review controller', () => { () => '2026-08-03T20:00:01.000Z', ); const controller = new ReviewController(service); + const [issuedAt, signature] = trustedContext(); expect(controller.health()).toEqual({ status: 'ok', service: 'review-service', }); await expect( - controller.completeDailyPlanning(WORKSPACE_ID, body()), + controller.completeDailyPlanning( + WORKSPACE_ID, + issuedAt, + signature, + body(), + ), ).resolves.toMatchObject({ ritualKind: 'daily-planning' }); await expect( - controller.completeDailyShutdown(WORKSPACE_ID, body()), + controller.completeDailyShutdown( + WORKSPACE_ID, + issuedAt, + signature, + body(), + ), ).resolves.toMatchObject({ ritualKind: 'daily-shutdown' }); await expect( - controller.completeWeeklyReview(WORKSPACE_ID, body()), + controller.completeWeeklyReview( + WORKSPACE_ID, + issuedAt, + signature, + body(), + ), ).resolves.toMatchObject({ ritualKind: 'weekly-review' }); await expect( - controller.listCompletions(WORKSPACE_ID, '10'), + controller.listCompletions(WORKSPACE_ID, issuedAt, signature, '10'), ).resolves.toHaveLength(3); }); + it('keeps startup and readiness fail-closed for unsafe gateway secrets', () => { + const controller = new ReviewController( + new ReviewService(new InMemoryReviewRepository()), + ); + + delete process.env.REVIEW_GATEWAY_CONTEXT_SECRET; + expectGatewayContextUnavailable(() => + requireReviewServiceConfiguration(process.env), + ); + expectGatewayContextUnavailable(() => controller.ready()); + + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = 'too-short'; + expectGatewayContextUnavailable(() => + requireReviewServiceConfiguration(process.env), + ); + expectGatewayContextUnavailable(() => controller.ready()); + + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET; + expect(() => requireReviewServiceConfiguration(process.env)).not.toThrow(); + expect(controller.ready()).toEqual({ + status: 'ready', + service: 'review-service', + }); + }); + it('fails closed before the domain for invalid workspace ownership', async () => { const controller = new ReviewController( new ReviewService(new InMemoryReviewRepository()), ); + const [issuedAt, signature] = trustedContext('invalid'); await expect( - controller.completeDailyPlanning('invalid', body()), + controller.completeDailyPlanning('invalid', issuedAt, signature, body()), ).rejects.toBeInstanceOf(HttpException); }); }); diff --git a/apps/review-service/src/main.ts b/apps/review-service/src/main.ts index 421273f0..12c0b662 100644 --- a/apps/review-service/src/main.ts +++ b/apps/review-service/src/main.ts @@ -12,7 +12,8 @@ import { import { NestFactory } from '@nestjs/core'; import { requireHistoryLimit, - requireWorkspaceHeader, + requireReviewGatewayContextSecret, + requireTrustedWorkspaceContext, toReviewHttpException, } from './http-boundary'; import { @@ -41,42 +42,75 @@ export class ReviewController { return { status: 'ok', service: 'review-service' }; } + /** Returns readiness only when signed workspace authority can be verified. */ + @Get('ready') + ready(): { status: 'ready'; service: 'review-service' } { + requireReviewGatewayContextSecret( + process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + ); + return { status: 'ready', service: 'review-service' }; + } + /** Records a completed daily planning ritual. */ @Post('reviews/daily-planning/completions') async completeDailyPlanning( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): Promise { - return await this.complete(workspaceHeader, 'daily-planning', body); + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + ); + return await this.complete(trustedWorkspaceId, 'daily-planning', body); } /** Records a completed daily shutdown ritual. */ @Post('reviews/daily-shutdown/completions') async completeDailyShutdown( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): Promise { - return await this.complete(workspaceHeader, 'daily-shutdown', body); + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + ); + return await this.complete(trustedWorkspaceId, 'daily-shutdown', body); } /** Records a completed Monday-anchored weekly review ritual. */ @Post('reviews/weekly-review/completions') async completeWeeklyReview( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): Promise { - return await this.complete(workspaceHeader, 'weekly-review', body); + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + ); + return await this.complete(trustedWorkspaceId, 'weekly-review', body); } /** Lists deterministic immutable completion history for one workspace. */ @Get('reviews/completions') async listCompletions( - @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Query('limit') limit: string | undefined, ): Promise { try { + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + ); return await this.reviewService.list( - requireWorkspaceHeader(workspaceHeader), + trustedWorkspaceId, requireHistoryLimit(limit), ); } catch (error) { @@ -84,17 +118,14 @@ export class ReviewController { } } + /** Records one ritual only for a workspace ID already accepted by the trusted-context verifier. */ private async complete( - workspaceHeader: string | undefined, + workspaceId: string, ritualKind: ReviewRitualKind, body: unknown, ): Promise { try { - return await this.reviewService.complete( - requireWorkspaceHeader(workspaceHeader), - ritualKind, - body, - ); + return await this.reviewService.complete(workspaceId, ritualKind, body); } catch (error) { throw toReviewHttpException(error); } @@ -118,8 +149,16 @@ export class ReviewController { }) export class AppModule {} +/** Verifies required security configuration before Review accepts traffic. */ +export function requireReviewServiceConfiguration( + env: NodeJS.ProcessEnv, +): void { + requireReviewGatewayContextSecret(env.REVIEW_GATEWAY_CONTEXT_SECRET); +} + /** Boots the versioned review service on its configured public port. */ async function bootstrap(): Promise { + requireReviewServiceConfiguration(process.env); const app = await NestFactory.create(AppModule); app.setGlobalPrefix('v1'); app.enableShutdownHooks(); diff --git a/apps/review-service/src/review-controller-authority.test.ts b/apps/review-service/src/review-controller-authority.test.ts new file mode 100644 index 00000000..f0dcb573 --- /dev/null +++ b/apps/review-service/src/review-controller-authority.test.ts @@ -0,0 +1,195 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { HttpException } from '@nestjs/common'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ReviewController } from './main'; +import type { ReviewService } from './review-domain'; + +const controllerSource = readFileSync(resolve(__dirname, 'main.ts'), 'utf8'); +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const CONTEXT_SECRET = randomBytes(32).toString('base64url'); + +interface RouteHeaders { + readonly workspaceId: string | undefined; + readonly issuedAt: string | undefined; + readonly signature: string | undefined; +} + +interface ReviewServiceSpies { + readonly complete: ReturnType; + readonly list: ReturnType; +} + +interface RouteCase { + readonly name: string; + readonly serviceMethod: keyof ReviewServiceSpies; + readonly invoke: ( + controller: ReviewController, + headers: RouteHeaders, + ) => Promise; +} + +type InvalidContextCase = readonly [ + name: string, + headers: RouteHeaders, + status: number, + secretConfigured: boolean, +]; + +const ROUTES: readonly RouteCase[] = [ + { + name: 'completeDailyPlanning', + serviceMethod: 'complete', + invoke: (controller, headers) => + controller.completeDailyPlanning( + headers.workspaceId, + headers.issuedAt, + headers.signature, + {}, + ), + }, + { + name: 'completeDailyShutdown', + serviceMethod: 'complete', + invoke: (controller, headers) => + controller.completeDailyShutdown( + headers.workspaceId, + headers.issuedAt, + headers.signature, + {}, + ), + }, + { + name: 'completeWeeklyReview', + serviceMethod: 'complete', + invoke: (controller, headers) => + controller.completeWeeklyReview( + headers.workspaceId, + headers.issuedAt, + headers.signature, + {}, + ), + }, + { + name: 'listCompletions', + serviceMethod: 'list', + invoke: (controller, headers) => + controller.listCompletions( + headers.workspaceId, + headers.issuedAt, + headers.signature, + '10', + ), + }, +]; + +/** Creates observable Review service boundaries without persistence. */ +function serviceSpies(): ReviewServiceSpies { + return { + complete: vi.fn().mockResolvedValue({}), + list: vi.fn().mockResolvedValue([]), + }; +} + +/** Creates a controller whose domain calls can prove tenant authority flow. */ +function controllerWith(service: ReviewServiceSpies): ReviewController { + return new ReviewController(service as unknown as ReviewService); +} + +/** Produces a versioned HMAC gateway assertion at one issue time. */ +function signedHeaders(issuedAtSeconds: number): RouteHeaders { + const issuedAt = String(issuedAtSeconds); + const signature = createHmac('sha256', CONTEXT_SECRET) + .update(`life-os.workspace.v1\n${WORKSPACE_ID}\n${issuedAt}`, 'utf8') + .digest('base64url'); + return { workspaceId: WORKSPACE_ID, issuedAt, signature }; +} + +/** Returns the bounded HTTP status from an expected rejected route call. */ +async function rejectedStatus(operation: Promise): Promise { + try { + await operation; + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + return (error as HttpException).getStatus(); + } + throw new Error( + 'Expected Review route to reject untrusted workspace context', + ); +} + +afterEach(() => { + delete process.env.REVIEW_GATEWAY_CONTEXT_SECRET; + vi.restoreAllMocks(); +}); + +describe.sequential('Review controller tenant authority contract', () => { + it('rejects legacy browser-selectable workspace authority', () => { + expect(controllerSource).not.toContain("@Headers('x-workspace-id')"); + expect(controllerSource).not.toContain('requireWorkspaceHeader('); + }); + + it('passes only the verified workspace to every Review domain route', async () => { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + const headers = signedHeaders(Math.floor(Date.now() / 1000)); + const service = serviceSpies(); + const controller = controllerWith(service); + + for (const route of ROUTES) { + vi.clearAllMocks(); + await route.invoke(controller, headers); + expect(service[route.serviceMethod], route.name).toHaveBeenCalledTimes(1); + expect(service[route.serviceMethod].mock.calls[0]?.[0], route.name).toBe( + WORKSPACE_ID, + ); + } + }); + + it('rejects untrusted contexts before every Review domain call', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const fresh = signedHeaders(nowSeconds); + const expired = signedHeaders(nowSeconds - 120); + const future = signedHeaders(nowSeconds + 120); + const tamperedDigest = Buffer.from(fresh.signature ?? '', 'base64url'); + const firstTamperedByte = tamperedDigest.at(0); + if (firstTamperedByte === undefined) { + throw new Error('Expected a SHA-256 gateway signature'); + } + tamperedDigest[0] = firstTamperedByte ^ 0xff; + const tampered = { + ...fresh, + signature: tamperedDigest.toString('base64url'), + }; + const malformed = { ...fresh, workspaceId: 'not-a-uuid' }; + const invalidContexts: readonly InvalidContextCase[] = [ + ['missing', { ...fresh, workspaceId: undefined }, 401, true], + ['expired', expired, 401, true], + ['future', future, 401, true], + ['tampered', tampered, 401, true], + ['malformed', malformed, 401, true], + ['secret-unconfigured', fresh, 503, false], + ]; + const service = serviceSpies(); + const controller = controllerWith(service); + + for (const [name, headers, status, secretConfigured] of invalidContexts) { + for (const route of ROUTES) { + vi.clearAllMocks(); + if (secretConfigured) { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + } else { + delete process.env.REVIEW_GATEWAY_CONTEXT_SECRET; + } + expect( + await rejectedStatus(route.invoke(controller, headers)), + `${route.name}:${name}`, + ).toBe(status); + expect( + service[route.serviceMethod], + `${route.name}:${name}`, + ).not.toHaveBeenCalled(); + } + } + }); +});