From 969a50e552cbf9208c69f55556b14aafd906ed23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:00:24 +0900 Subject: [PATCH 01/21] fix(review): require signed workspace authority on current main --- apps/review-service/src/http-boundary.test.ts | 332 +++++++++++++++++- apps/review-service/src/http-boundary.ts | 117 +++++- apps/review-service/src/main.test.ts | 100 +++++- apps/review-service/src/main.ts | 69 +++- .../src/review-controller-authority.test.ts | 195 ++++++++++ 5 files changed, 774 insertions(+), 39 deletions(-) create mode 100644 apps/review-service/src/review-controller-authority.test.ts 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(); + } + } + }); +}); From 53662a85645668faacaa38a0eeb839f9f1e1dfba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:29:14 +0900 Subject: [PATCH 02/21] fix(review): bind trusted workspace context to route --- apps/review-service/src/http-boundary.ts | 59 ++++++++++++++++++++---- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/review-service/src/http-boundary.ts b/apps/review-service/src/http-boundary.ts index c734aafa..d2bee0cf 100644 --- a/apps/review-service/src/http-boundary.ts +++ b/apps/review-service/src/http-boundary.ts @@ -25,11 +25,23 @@ export interface ReviewTrustedWorkspaceContextHeaders { signature: unknown; } +/** Server-owned request identity bound into each Review gateway context. */ +export interface ReviewTrustedRequestBinding { + method: unknown; + path: 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; +const REVIEW_HISTORY_PATH = '/v1/reviews/completions'; +const REVIEW_COMPLETION_PATHS = new Set([ + '/v1/reviews/daily-planning/completions', + '/v1/reviews/daily-shutdown/completions', + '/v1/reviews/weekly-review/completions', +]); function problemException( status: number, @@ -77,39 +89,68 @@ export function requireReviewGatewayContextSecret(secret: unknown): string { return secret; } +/** Accepts only the exact method/path combinations exposed by Review. */ +function requireReviewRequestBinding( + binding: ReviewTrustedRequestBinding, +): { method: 'GET' | 'POST'; path: string } { + if ( + binding.method === 'GET' && + binding.path === REVIEW_HISTORY_PATH + ) { + return { method: 'GET', path: REVIEW_HISTORY_PATH }; + } + if ( + binding.method === 'POST' && + typeof binding.path === 'string' && + REVIEW_COMPLETION_PATHS.has(binding.path) + ) { + return { method: 'POST', path: binding.path }; + } + return invalidGatewayContext(); +} + /** - * Computes the SHA-256 HMAC over the canonical `life-os.workspace.v1` payload. + * Computes the SHA-256 HMAC over the Review-specific request-bound context. * 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. + * gateway signs that normalized identifier plus the exact HTTP method and path. */ function workspaceContextDigest( workspaceId: string, issuedAt: string, + method: 'GET' | 'POST', + path: string, secret: string, ): Buffer { return createHmac('sha256', secret) - .update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8') + .update( + `life-os.review-context.v1\n${workspaceId}\n${issuedAt}\n${method}\n${path}`, + 'utf8', + ) .digest(); } /** - * Verifies the short-lived workspace context created after gateway authentication. - * A browser-selected workspace header is intentionally not an authorization input. + * Verifies the short-lived, method-and-path-bound workspace context created + * after gateway authentication. A browser-selected workspace header is never + * an authorization input, and a history context cannot be replayed as a write. */ export function requireTrustedWorkspaceContext( headers: ReviewTrustedWorkspaceContextHeaders, secret: unknown, + requestBinding: ReviewTrustedRequestBinding, nowSeconds = Math.floor(Date.now() / 1000), ): string { const verifiedSecret = requireReviewGatewayContextSecret(secret); + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + return unavailableGatewayContext(); + } + const { method, path } = requireReviewRequestBinding(requestBinding); 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 + !BASE64URL_SHA256_PATTERN.test(headers.signature) ) { return invalidGatewayContext(); } @@ -132,6 +173,8 @@ export function requireTrustedWorkspaceContext( const expected = workspaceContextDigest( workspaceId, headers.issuedAt, + method, + path, verifiedSecret, ); const actual = Buffer.from(headers.signature, 'base64url'); From 89221eaa84cd2d7b670e79adc15a902537ab5377 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:39:06 +0900 Subject: [PATCH 03/21] fix(review): bind controller routes to signed request context --- apps/review-service/src/main.ts | 42 ++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/review-service/src/main.ts b/apps/review-service/src/main.ts index 12c0b662..dd87b31f 100644 --- a/apps/review-service/src/main.ts +++ b/apps/review-service/src/main.ts @@ -15,6 +15,7 @@ import { requireReviewGatewayContextSecret, requireTrustedWorkspaceContext, toReviewHttpException, + type ReviewTrustedRequestBinding, } from './http-boundary'; import { type ReviewCompletionRecord, @@ -59,9 +60,12 @@ export class ReviewController { @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): Promise { - const trustedWorkspaceId = requireTrustedWorkspaceContext( + const trustedWorkspaceId = this.trustedWorkspaceId( { workspaceId, issuedAt, signature }, - process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + { + method: 'POST', + path: '/v1/reviews/daily-planning/completions', + }, ); return await this.complete(trustedWorkspaceId, 'daily-planning', body); } @@ -74,9 +78,12 @@ export class ReviewController { @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): Promise { - const trustedWorkspaceId = requireTrustedWorkspaceContext( + const trustedWorkspaceId = this.trustedWorkspaceId( { workspaceId, issuedAt, signature }, - process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + { + method: 'POST', + path: '/v1/reviews/daily-shutdown/completions', + }, ); return await this.complete(trustedWorkspaceId, 'daily-shutdown', body); } @@ -89,9 +96,12 @@ export class ReviewController { @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): Promise { - const trustedWorkspaceId = requireTrustedWorkspaceContext( + const trustedWorkspaceId = this.trustedWorkspaceId( { workspaceId, issuedAt, signature }, - process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + { + method: 'POST', + path: '/v1/reviews/weekly-review/completions', + }, ); return await this.complete(trustedWorkspaceId, 'weekly-review', body); } @@ -105,9 +115,9 @@ export class ReviewController { @Query('limit') limit: string | undefined, ): Promise { try { - const trustedWorkspaceId = requireTrustedWorkspaceContext( + const trustedWorkspaceId = this.trustedWorkspaceId( { workspaceId, issuedAt, signature }, - process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + { method: 'GET', path: '/v1/reviews/completions' }, ); return await this.reviewService.list( trustedWorkspaceId, @@ -118,6 +128,22 @@ export class ReviewController { } } + /** Returns workspace authority only when the signed context matches the exact Review route. */ + private trustedWorkspaceId( + headers: { + workspaceId: string | undefined; + issuedAt: string | undefined; + signature: string | undefined; + }, + requestBinding: ReviewTrustedRequestBinding, + ): string { + return requireTrustedWorkspaceContext( + headers, + process.env.REVIEW_GATEWAY_CONTEXT_SECRET, + requestBinding, + ); + } + /** Records one ritual only for a workspace ID already accepted by the trusted-context verifier. */ private async complete( workspaceId: string, From 8a29bae2827093fcd0c86428bf36b5e8ae744c72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:40:06 +0900 Subject: [PATCH 04/21] test(review): prove request-bound trusted context --- apps/review-service/src/http-boundary.test.ts | 144 ++++++++++++++++-- 1 file changed, 135 insertions(+), 9 deletions(-) diff --git a/apps/review-service/src/http-boundary.test.ts b/apps/review-service/src/http-boundary.test.ts index 2ec887aa..6c9605f7 100644 --- a/apps/review-service/src/http-boundary.test.ts +++ b/apps/review-service/src/http-boundary.test.ts @@ -6,6 +6,7 @@ import { requireRitualPath, requireTrustedWorkspaceContext, toReviewHttpException, + type ReviewTrustedRequestBinding, } from './http-boundary'; import { ReviewCompletionConflictError, @@ -14,8 +15,17 @@ import { import { ReviewPersistenceError } from './postgres-review-repository'; const WORKSPACE_ID = '018f47b2-c1d2-4a30-8c17-221fb579c042'; +const OTHER_WORKSPACE_ID = '018f47b2-c1d2-4a30-8c17-221fb579c043'; const SECRET = randomBytes(32).toString('base64url'); const NOW_SECONDS = 1_786_334_400; +const HISTORY_BINDING = { + method: 'GET', + path: '/v1/reviews/completions', +} as const; +const DAILY_PLANNING_BINDING = { + method: 'POST', + path: '/v1/reviews/daily-planning/completions', +} as const; const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; @@ -23,21 +33,34 @@ function response(error: HttpException): unknown { return error.getResponse(); } -function signature(issuedAt: string, workspaceId = WORKSPACE_ID): string { +function signature( + issuedAt: string, + binding: { method: 'GET' | 'POST'; path: string } = HISTORY_BINDING, + workspaceId = WORKSPACE_ID, +): string { return createHmac('sha256', SECRET) - .update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8') + .update( + `life-os.review-context.v1\n${workspaceId}\n${issuedAt}\n${binding.method}\n${binding.path}`, + 'utf8', + ) .digest('base64url'); } function expectTrustedContextRejection( headers: { workspaceId: unknown; issuedAt: unknown; signature: unknown }, secret: unknown, + requestBinding: ReviewTrustedRequestBinding, nowSeconds: number, status: number, code: string, ): void { const operation = () => - requireTrustedWorkspaceContext(headers, secret, nowSeconds); + requireTrustedWorkspaceContext( + headers, + secret, + requestBinding, + nowSeconds, + ); expect(operation).toThrow(HttpException); let thrown: unknown; @@ -63,6 +86,7 @@ describe('Review HTTP boundary', () => { signature: signature(issuedAt), }, SECRET, + HISTORY_BINDING, NOW_SECONDS, ), ).toBe(WORKSPACE_ID); @@ -72,6 +96,38 @@ describe('Review HTTP boundary', () => { }, ); + it('accepts a signed completion request binding', () => { + const issuedAt = String(NOW_SECONDS); + expect( + requireTrustedWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signature(issuedAt, DAILY_PLANNING_BINDING), + }, + SECRET, + DAILY_PLANNING_BINDING, + NOW_SECONDS, + ), + ).toBe(WORKSPACE_ID); + }); + + it('rejects replaying a history signature as a completion request', () => { + const issuedAt = String(NOW_SECONDS); + expectTrustedContextRejection( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signature(issuedAt, HISTORY_BINDING), + }, + SECRET, + DAILY_PLANNING_BINDING, + NOW_SECONDS, + 401, + 'invalid_gateway_context', + ); + }); + it('accepts the exact maximum context age', () => { const issuedAt = String(NOW_SECONDS - 60); expect( @@ -82,6 +138,7 @@ describe('Review HTTP boundary', () => { signature: signature(issuedAt), }, SECRET, + HISTORY_BINDING, NOW_SECONDS, ), ).toBe(WORKSPACE_ID); @@ -97,6 +154,7 @@ describe('Review HTTP boundary', () => { signature: signature(issuedAt), }, SECRET, + HISTORY_BINDING, NOW_SECONDS, ), ).toBe(WORKSPACE_ID); @@ -125,6 +183,7 @@ describe('Review HTTP boundary', () => { signature: nonCanonical, }, SECRET, + HISTORY_BINDING, NOW_SECONDS, ), ).toThrow(HttpException); @@ -140,6 +199,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS - 61)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -152,6 +212,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS + 6)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -164,6 +225,50 @@ describe('Review HTTP boundary', () => { signature: 'A'.repeat(43), }, secret: SECRET, + binding: HISTORY_BINDING, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'signature bound to another workspace', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: signature( + String(NOW_SECONDS), + HISTORY_BINDING, + OTHER_WORKSPACE_ID, + ), + }, + secret: SECRET, + binding: HISTORY_BINDING, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'unsupported request method', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + binding: { method: 'DELETE', path: '/v1/reviews/completions' }, + nowSeconds: NOW_SECONDS, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'unsupported request path', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: signature(String(NOW_SECONDS)), + }, + secret: SECRET, + binding: { method: 'POST', path: '/v1/reviews/completions' }, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -176,6 +281,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: 'too-short', + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 503, code: 'gateway_context_unavailable', @@ -188,6 +294,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: undefined, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 503, code: 'gateway_context_unavailable', @@ -200,6 +307,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -212,6 +320,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -224,6 +333,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -236,6 +346,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -248,6 +359,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -260,6 +372,7 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -272,6 +385,7 @@ describe('Review HTTP boundary', () => { signature: undefined, }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -284,6 +398,7 @@ describe('Review HTTP boundary', () => { signature: 123, }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -296,6 +411,7 @@ describe('Review HTTP boundary', () => { signature: 'A'.repeat(42), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -308,6 +424,7 @@ describe('Review HTTP boundary', () => { signature: '!'.repeat(43), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: NOW_SECONDS, status: 401, code: 'invalid_gateway_context', @@ -320,9 +437,10 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: Number.NaN, - status: 401, - code: 'invalid_gateway_context', + status: 503, + code: 'gateway_context_unavailable', }, { name: 'negative verifier clock', @@ -332,14 +450,22 @@ describe('Review HTTP boundary', () => { signature: signature(String(NOW_SECONDS)), }, secret: SECRET, + binding: HISTORY_BINDING, nowSeconds: -1, - status: 401, - code: 'invalid_gateway_context', + status: 503, + code: 'gateway_context_unavailable', }, ])( 'fails closed for $name', - ({ headers, secret, nowSeconds, status, code }) => { - expectTrustedContextRejection(headers, secret, nowSeconds, status, code); + ({ headers, secret, binding, nowSeconds, status, code }) => { + expectTrustedContextRejection( + headers, + secret, + binding, + nowSeconds, + status, + code, + ); }, ); From 412814ae45afa2310331dad42d636a13b06ea4ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:40:40 +0900 Subject: [PATCH 05/21] test(review): exercise signed authority at every route --- .../src/review-controller-authority.test.ts | 138 ++++++++++++------ 1 file changed, 91 insertions(+), 47 deletions(-) diff --git a/apps/review-service/src/review-controller-authority.test.ts b/apps/review-service/src/review-controller-authority.test.ts index f0dcb573..4a23de93 100644 --- a/apps/review-service/src/review-controller-authority.test.ts +++ b/apps/review-service/src/review-controller-authority.test.ts @@ -1,12 +1,9 @@ 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'); @@ -24,23 +21,20 @@ interface ReviewServiceSpies { interface RouteCase { readonly name: string; readonly serviceMethod: keyof ReviewServiceSpies; + readonly method: 'GET' | 'POST'; + readonly path: string; 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', + method: 'POST', + path: '/v1/reviews/daily-planning/completions', invoke: (controller, headers) => controller.completeDailyPlanning( headers.workspaceId, @@ -52,6 +46,8 @@ const ROUTES: readonly RouteCase[] = [ { name: 'completeDailyShutdown', serviceMethod: 'complete', + method: 'POST', + path: '/v1/reviews/daily-shutdown/completions', invoke: (controller, headers) => controller.completeDailyShutdown( headers.workspaceId, @@ -63,6 +59,8 @@ const ROUTES: readonly RouteCase[] = [ { name: 'completeWeeklyReview', serviceMethod: 'complete', + method: 'POST', + path: '/v1/reviews/weekly-review/completions', invoke: (controller, headers) => controller.completeWeeklyReview( headers.workspaceId, @@ -74,6 +72,8 @@ const ROUTES: readonly RouteCase[] = [ { name: 'listCompletions', serviceMethod: 'list', + method: 'GET', + path: '/v1/reviews/completions', invoke: (controller, headers) => controller.listCompletions( headers.workspaceId, @@ -97,11 +97,14 @@ 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 { +/** Produces a request-bound HMAC gateway assertion at one issue time. */ +function signedHeaders(route: RouteCase, issuedAtSeconds: number): RouteHeaders { const issuedAt = String(issuedAtSeconds); const signature = createHmac('sha256', CONTEXT_SECRET) - .update(`life-os.workspace.v1\n${WORKSPACE_ID}\n${issuedAt}`, 'utf8') + .update( + `life-os.review-context.v1\n${WORKSPACE_ID}\n${issuedAt}\n${route.method}\n${route.path}`, + 'utf8', + ) .digest('base64url'); return { workspaceId: WORKSPACE_ID, issuedAt, signature }; } @@ -125,20 +128,32 @@ afterEach(() => { }); 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('rejects a browser-selected workspace header without signed context', async () => { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + const service = serviceSpies(); + const controller = controllerWith(service); + const headers: RouteHeaders = { + workspaceId: WORKSPACE_ID, + issuedAt: undefined, + signature: undefined, + }; + + for (const route of ROUTES) { + vi.clearAllMocks(); + expect(await rejectedStatus(route.invoke(controller, headers))).toBe(401); + expect(service[route.serviceMethod], route.name).not.toHaveBeenCalled(); + } }); 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 nowSeconds = Math.floor(Date.now() / 1000); const service = serviceSpies(); const controller = controllerWith(service); for (const route of ROUTES) { vi.clearAllMocks(); - await route.invoke(controller, headers); + await route.invoke(controller, signedHeaders(route, nowSeconds)); expect(service[route.serviceMethod], route.name).toHaveBeenCalledTimes(1); expect(service[route.serviceMethod].mock.calls[0]?.[0], route.name).toBe( WORKSPACE_ID, @@ -148,46 +163,75 @@ describe.sequential('Review controller tenant authority contract', () => { 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) { + for (const route of ROUTES) { + const fresh = signedHeaders(route, nowSeconds); + const expired = signedHeaders(route, nowSeconds - 120); + const future = signedHeaders(route, 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 invalidContexts = [ + { + name: 'missing', + headers: { ...fresh, workspaceId: undefined }, + status: 401, + secretConfigured: true, + }, + { + name: 'expired', + headers: expired, + status: 401, + secretConfigured: true, + }, + { + name: 'future', + headers: future, + status: 401, + secretConfigured: true, + }, + { + name: 'tampered', + headers: { + ...fresh, + signature: tamperedDigest.toString('base64url'), + }, + status: 401, + secretConfigured: true, + }, + { + name: 'malformed', + headers: { ...fresh, workspaceId: 'not-a-uuid' }, + status: 401, + secretConfigured: true, + }, + { + name: 'secret-unconfigured', + headers: fresh, + status: 503, + secretConfigured: false, + }, + ] as const; + + for (const invalid of invalidContexts) { vi.clearAllMocks(); - if (secretConfigured) { + if (invalid.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); + await rejectedStatus(route.invoke(controller, invalid.headers)), + `${route.name}:${invalid.name}`, + ).toBe(invalid.status); expect( service[route.serviceMethod], - `${route.name}:${name}`, + `${route.name}:${invalid.name}`, ).not.toHaveBeenCalled(); } } From b3db07f790a67184a227df9b668ae1376227d8e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:49:04 +0900 Subject: [PATCH 06/21] style(review): format trusted context boundary --- apps/review-service/src/http-boundary.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/review-service/src/http-boundary.ts b/apps/review-service/src/http-boundary.ts index d2bee0cf..8f0d6ddf 100644 --- a/apps/review-service/src/http-boundary.ts +++ b/apps/review-service/src/http-boundary.ts @@ -93,10 +93,7 @@ export function requireReviewGatewayContextSecret(secret: unknown): string { function requireReviewRequestBinding( binding: ReviewTrustedRequestBinding, ): { method: 'GET' | 'POST'; path: string } { - if ( - binding.method === 'GET' && - binding.path === REVIEW_HISTORY_PATH - ) { + if (binding.method === 'GET' && binding.path === REVIEW_HISTORY_PATH) { return { method: 'GET', path: REVIEW_HISTORY_PATH }; } if ( From 447aaf8b3a870b442b499a4bbf22b706f539619c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:52:52 +0900 Subject: [PATCH 07/21] style(review): satisfy service formatting gate --- apps/review-service/src/http-boundary.test.ts | 7 +- .../src/review-controller-authority.test.ts | 66 ++++++++++--------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/apps/review-service/src/http-boundary.test.ts b/apps/review-service/src/http-boundary.test.ts index 6c9605f7..5329d7ef 100644 --- a/apps/review-service/src/http-boundary.test.ts +++ b/apps/review-service/src/http-boundary.test.ts @@ -55,12 +55,7 @@ function expectTrustedContextRejection( code: string, ): void { const operation = () => - requireTrustedWorkspaceContext( - headers, - secret, - requestBinding, - nowSeconds, - ); + requireTrustedWorkspaceContext(headers, secret, requestBinding, nowSeconds); expect(operation).toThrow(HttpException); let thrown: unknown; diff --git a/apps/review-service/src/review-controller-authority.test.ts b/apps/review-service/src/review-controller-authority.test.ts index 4a23de93..d4af2a52 100644 --- a/apps/review-service/src/review-controller-authority.test.ts +++ b/apps/review-service/src/review-controller-authority.test.ts @@ -128,38 +128,44 @@ afterEach(() => { }); describe.sequential('Review controller tenant authority contract', () => { - it('rejects a browser-selected workspace header without signed context', async () => { - process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; - const service = serviceSpies(); - const controller = controllerWith(service); - const headers: RouteHeaders = { - workspaceId: WORKSPACE_ID, - issuedAt: undefined, - signature: undefined, - }; - - for (const route of ROUTES) { - vi.clearAllMocks(); - expect(await rejectedStatus(route.invoke(controller, headers))).toBe(401); - expect(service[route.serviceMethod], route.name).not.toHaveBeenCalled(); - } - }); + it( + 'rejects a browser-selected workspace header without signed context', + async () => { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + const service = serviceSpies(); + const controller = controllerWith(service); + const headers: RouteHeaders = { + workspaceId: WORKSPACE_ID, + issuedAt: undefined, + signature: undefined, + }; + + for (const route of ROUTES) { + vi.clearAllMocks(); + expect(await rejectedStatus(route.invoke(controller, headers))).toBe(401); + expect(service[route.serviceMethod], route.name).not.toHaveBeenCalled(); + } + }, + ); - it('passes only the verified workspace to every Review domain route', async () => { - process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; - const nowSeconds = Math.floor(Date.now() / 1000); - const service = serviceSpies(); - const controller = controllerWith(service); + it( + 'passes only the verified workspace to every Review domain route', + async () => { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + const nowSeconds = Math.floor(Date.now() / 1000); + const service = serviceSpies(); + const controller = controllerWith(service); - for (const route of ROUTES) { - vi.clearAllMocks(); - await route.invoke(controller, signedHeaders(route, nowSeconds)); - expect(service[route.serviceMethod], route.name).toHaveBeenCalledTimes(1); - expect(service[route.serviceMethod].mock.calls[0]?.[0], route.name).toBe( - WORKSPACE_ID, - ); - } - }); + for (const route of ROUTES) { + vi.clearAllMocks(); + await route.invoke(controller, signedHeaders(route, nowSeconds)); + 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); From c10801708ce8200ae9f9fe9fd78da28eabe75eb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:17:07 +0900 Subject: [PATCH 08/21] test(review): bind controller fixtures to exact routes --- apps/review-service/src/main.test.ts | 54 ++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/review-service/src/main.test.ts b/apps/review-service/src/main.test.ts index 22f08214..8eb07c4b 100644 --- a/apps/review-service/src/main.test.ts +++ b/apps/review-service/src/main.test.ts @@ -42,12 +42,16 @@ function body() { }; } -function trustedContext(workspaceId = WORKSPACE_ID): readonly [string, string] { +function trustedContext( + method: 'GET' | 'POST', + path: string, + 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}`, + `life-os.review-context.v1\n${normalizedWorkspaceId}\n${issuedAt}\n${method}\n${path}`, 'utf8', ) .digest('base64url'); @@ -92,38 +96,62 @@ 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', }); + + const [dailyPlanningIssuedAt, dailyPlanningSignature] = trustedContext( + 'POST', + '/v1/reviews/daily-planning/completions', + ); await expect( controller.completeDailyPlanning( WORKSPACE_ID, - issuedAt, - signature, + dailyPlanningIssuedAt, + dailyPlanningSignature, body(), ), ).resolves.toMatchObject({ ritualKind: 'daily-planning' }); + + const [dailyShutdownIssuedAt, dailyShutdownSignature] = trustedContext( + 'POST', + '/v1/reviews/daily-shutdown/completions', + ); await expect( controller.completeDailyShutdown( WORKSPACE_ID, - issuedAt, - signature, + dailyShutdownIssuedAt, + dailyShutdownSignature, body(), ), ).resolves.toMatchObject({ ritualKind: 'daily-shutdown' }); + + const [weeklyReviewIssuedAt, weeklyReviewSignature] = trustedContext( + 'POST', + '/v1/reviews/weekly-review/completions', + ); await expect( controller.completeWeeklyReview( WORKSPACE_ID, - issuedAt, - signature, + weeklyReviewIssuedAt, + weeklyReviewSignature, body(), ), ).resolves.toMatchObject({ ritualKind: 'weekly-review' }); + + const [historyIssuedAt, historySignature] = trustedContext( + 'GET', + '/v1/reviews/completions', + ); await expect( - controller.listCompletions(WORKSPACE_ID, issuedAt, signature, '10'), + controller.listCompletions( + WORKSPACE_ID, + historyIssuedAt, + historySignature, + '10', + ), ).resolves.toHaveLength(3); }); @@ -156,7 +184,11 @@ describe('Review controller', () => { const controller = new ReviewController( new ReviewService(new InMemoryReviewRepository()), ); - const [issuedAt, signature] = trustedContext('invalid'); + const [issuedAt, signature] = trustedContext( + 'POST', + '/v1/reviews/daily-planning/completions', + 'invalid', + ); await expect( controller.completeDailyPlanning('invalid', issuedAt, signature, body()), ).rejects.toBeInstanceOf(HttpException); From 4c9e8440bc4531597de58a5a0c3aa04c90311d6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:00:41 +0900 Subject: [PATCH 09/21] test(review): cover completion path signature replay --- apps/review-service/src/http-boundary.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/review-service/src/http-boundary.test.ts b/apps/review-service/src/http-boundary.test.ts index 5329d7ef..23ccd63f 100644 --- a/apps/review-service/src/http-boundary.test.ts +++ b/apps/review-service/src/http-boundary.test.ts @@ -26,6 +26,10 @@ const DAILY_PLANNING_BINDING = { method: 'POST', path: '/v1/reviews/daily-planning/completions', } as const; +const WEEKLY_REVIEW_BINDING = { + method: 'POST', + path: '/v1/reviews/weekly-review/completions', +} as const; const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; @@ -123,6 +127,22 @@ describe('Review HTTP boundary', () => { ); }); + it('rejects replaying one completion signature on another completion path', () => { + const issuedAt = String(NOW_SECONDS); + expectTrustedContextRejection( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signature(issuedAt, DAILY_PLANNING_BINDING), + }, + SECRET, + WEEKLY_REVIEW_BINDING, + NOW_SECONDS, + 401, + 'invalid_gateway_context', + ); + }); + it('accepts the exact maximum context age', () => { const issuedAt = String(NOW_SECONDS - 60); expect( From 4a7cba72a755478639d7f8683c057b18c9add21c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:20:25 +0900 Subject: [PATCH 10/21] style(review): restore canonical request-binding format --- apps/review-service/src/http-boundary.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/review-service/src/http-boundary.ts b/apps/review-service/src/http-boundary.ts index 8f0d6ddf..d2bee0cf 100644 --- a/apps/review-service/src/http-boundary.ts +++ b/apps/review-service/src/http-boundary.ts @@ -93,7 +93,10 @@ export function requireReviewGatewayContextSecret(secret: unknown): string { function requireReviewRequestBinding( binding: ReviewTrustedRequestBinding, ): { method: 'GET' | 'POST'; path: string } { - if (binding.method === 'GET' && binding.path === REVIEW_HISTORY_PATH) { + if ( + binding.method === 'GET' && + binding.path === REVIEW_HISTORY_PATH + ) { return { method: 'GET', path: REVIEW_HISTORY_PATH }; } if ( From 8fd48fbfc8dbee33676aba6766d1ef6ebdddd2f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:28:10 +0900 Subject: [PATCH 11/21] style(review): format authority regression coverage --- .../src/review-controller-authority.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/review-service/src/review-controller-authority.test.ts b/apps/review-service/src/review-controller-authority.test.ts index d4af2a52..b5c250b0 100644 --- a/apps/review-service/src/review-controller-authority.test.ts +++ b/apps/review-service/src/review-controller-authority.test.ts @@ -98,7 +98,10 @@ function controllerWith(service: ReviewServiceSpies): ReviewController { } /** Produces a request-bound HMAC gateway assertion at one issue time. */ -function signedHeaders(route: RouteCase, issuedAtSeconds: number): RouteHeaders { +function signedHeaders( + route: RouteCase, + issuedAtSeconds: number, +): RouteHeaders { const issuedAt = String(issuedAtSeconds); const signature = createHmac('sha256', CONTEXT_SECRET) .update( @@ -142,7 +145,9 @@ describe.sequential('Review controller tenant authority contract', () => { for (const route of ROUTES) { vi.clearAllMocks(); - expect(await rejectedStatus(route.invoke(controller, headers))).toBe(401); + expect(await rejectedStatus(route.invoke(controller, headers))).toBe( + 401, + ); expect(service[route.serviceMethod], route.name).not.toHaveBeenCalled(); } }, @@ -159,7 +164,9 @@ describe.sequential('Review controller tenant authority contract', () => { for (const route of ROUTES) { vi.clearAllMocks(); await route.invoke(controller, signedHeaders(route, nowSeconds)); - expect(service[route.serviceMethod], route.name).toHaveBeenCalledTimes(1); + expect(service[route.serviceMethod], route.name).toHaveBeenCalledTimes( + 1, + ); expect(service[route.serviceMethod].mock.calls[0]?.[0], route.name).toBe( WORKSPACE_ID, ); From 967fa99994e15614b250917561a384a70dc952a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:28:40 +0900 Subject: [PATCH 12/21] style(review): normalize request-binding guard --- apps/review-service/src/http-boundary.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/review-service/src/http-boundary.ts b/apps/review-service/src/http-boundary.ts index d2bee0cf..8f0d6ddf 100644 --- a/apps/review-service/src/http-boundary.ts +++ b/apps/review-service/src/http-boundary.ts @@ -93,10 +93,7 @@ export function requireReviewGatewayContextSecret(secret: unknown): string { function requireReviewRequestBinding( binding: ReviewTrustedRequestBinding, ): { method: 'GET' | 'POST'; path: string } { - if ( - binding.method === 'GET' && - binding.path === REVIEW_HISTORY_PATH - ) { + if (binding.method === 'GET' && binding.path === REVIEW_HISTORY_PATH) { return { method: 'GET', path: REVIEW_HISTORY_PATH }; } if ( From f2b4d2766d3660fb67d371246bd7ed766ded00b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:05:33 +0900 Subject: [PATCH 13/21] chore(review): expose canonical formatter diff --- apps/review-service/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/review-service/package.json b/apps/review-service/package.json index 703a9fba..aa662a67 100644 --- a/apps/review-service/package.json +++ b/apps/review-service/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "nest build", "dev": "nest start --watch", - "lint": "tsc --noEmit && prettier --single-quote --check package.json migrations/README.md src/**/*.ts ../../docs/superpowers/plans/2026-08-04-guided-review-service-slice.md", + "lint": "tsc --noEmit && prettier --single-quote --write package.json migrations/README.md src/**/*.ts ../../docs/superpowers/plans/2026-08-04-guided-review-service-slice.md && git diff --exit-code -- src/http-boundary.test.ts src/http-boundary.ts src/review-controller-authority.test.ts", "test": "vitest run --passWithNoTests --no-file-parallelism", "typecheck": "tsc --noEmit" }, From 9990af03f7e05f7c67f7e68913b58ccccf22df72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:52:27 +0900 Subject: [PATCH 14/21] fix(review): validate all prettier lint targets --- apps/review-service/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/review-service/package.json b/apps/review-service/package.json index aa662a67..70d8b180 100644 --- a/apps/review-service/package.json +++ b/apps/review-service/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "nest build", "dev": "nest start --watch", - "lint": "tsc --noEmit && prettier --single-quote --write package.json migrations/README.md src/**/*.ts ../../docs/superpowers/plans/2026-08-04-guided-review-service-slice.md && git diff --exit-code -- src/http-boundary.test.ts src/http-boundary.ts src/review-controller-authority.test.ts", + "lint": "tsc --noEmit && prettier --single-quote --check package.json migrations/README.md 'src/**/*.ts' ../../docs/superpowers/plans/2026-08-04-guided-review-service-slice.md", "test": "vitest run --passWithNoTests --no-file-parallelism", "typecheck": "tsc --noEmit" }, From 50b0c5d4f2b33356dbd7c640d047b3094a63e9bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:28:43 +0900 Subject: [PATCH 15/21] style(review): apply canonical Prettier output --- apps/review-service/src/http-boundary.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/review-service/src/http-boundary.ts b/apps/review-service/src/http-boundary.ts index 8f0d6ddf..c93c0962 100644 --- a/apps/review-service/src/http-boundary.ts +++ b/apps/review-service/src/http-boundary.ts @@ -90,9 +90,10 @@ export function requireReviewGatewayContextSecret(secret: unknown): string { } /** Accepts only the exact method/path combinations exposed by Review. */ -function requireReviewRequestBinding( - binding: ReviewTrustedRequestBinding, -): { method: 'GET' | 'POST'; path: string } { +function requireReviewRequestBinding(binding: ReviewTrustedRequestBinding): { + method: 'GET' | 'POST'; + path: string; +} { if (binding.method === 'GET' && binding.path === REVIEW_HISTORY_PATH) { return { method: 'GET', path: REVIEW_HISTORY_PATH }; } From 7ed3556093171c12d5711925bac276f9a2d9e6e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:29:42 +0900 Subject: [PATCH 16/21] style(review): apply canonical controller test formatting --- .../src/review-controller-authority.test.ts | 70 ++++++++----------- 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/apps/review-service/src/review-controller-authority.test.ts b/apps/review-service/src/review-controller-authority.test.ts index b5c250b0..52e0eaa8 100644 --- a/apps/review-service/src/review-controller-authority.test.ts +++ b/apps/review-service/src/review-controller-authority.test.ts @@ -131,48 +131,38 @@ afterEach(() => { }); describe.sequential('Review controller tenant authority contract', () => { - it( - 'rejects a browser-selected workspace header without signed context', - async () => { - process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; - const service = serviceSpies(); - const controller = controllerWith(service); - const headers: RouteHeaders = { - workspaceId: WORKSPACE_ID, - issuedAt: undefined, - signature: undefined, - }; - - for (const route of ROUTES) { - vi.clearAllMocks(); - expect(await rejectedStatus(route.invoke(controller, headers))).toBe( - 401, - ); - expect(service[route.serviceMethod], route.name).not.toHaveBeenCalled(); - } - }, - ); + it('rejects a browser-selected workspace header without signed context', async () => { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + const service = serviceSpies(); + const controller = controllerWith(service); + const headers: RouteHeaders = { + workspaceId: WORKSPACE_ID, + issuedAt: undefined, + signature: undefined, + }; - it( - 'passes only the verified workspace to every Review domain route', - async () => { - process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; - const nowSeconds = Math.floor(Date.now() / 1000); - const service = serviceSpies(); - const controller = controllerWith(service); + for (const route of ROUTES) { + vi.clearAllMocks(); + expect(await rejectedStatus(route.invoke(controller, headers))).toBe(401); + expect(service[route.serviceMethod], route.name).not.toHaveBeenCalled(); + } + }); - for (const route of ROUTES) { - vi.clearAllMocks(); - await route.invoke(controller, signedHeaders(route, nowSeconds)); - expect(service[route.serviceMethod], route.name).toHaveBeenCalledTimes( - 1, - ); - expect(service[route.serviceMethod].mock.calls[0]?.[0], route.name).toBe( - WORKSPACE_ID, - ); - } - }, - ); + it('passes only the verified workspace to every Review domain route', async () => { + process.env.REVIEW_GATEWAY_CONTEXT_SECRET = CONTEXT_SECRET; + const nowSeconds = Math.floor(Date.now() / 1000); + const service = serviceSpies(); + const controller = controllerWith(service); + + for (const route of ROUTES) { + vi.clearAllMocks(); + await route.invoke(controller, signedHeaders(route, nowSeconds)); + 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); From 36ad281e00862d1b09d65d671e2a9698dba8d6a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:31:41 +0900 Subject: [PATCH 17/21] style(review): apply canonical boundary test formatting --- apps/review-service/src/http-boundary.test.ts | 92 +++++++++---------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/apps/review-service/src/http-boundary.test.ts b/apps/review-service/src/http-boundary.test.ts index 23ccd63f..fc2ebb1f 100644 --- a/apps/review-service/src/http-boundary.test.ts +++ b/apps/review-service/src/http-boundary.test.ts @@ -73,27 +73,24 @@ function expectTrustedContextRejection( } describe('Review HTTP boundary', () => { - 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, - HISTORY_BINDING, - NOW_SECONDS, - ), - ).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, + HISTORY_BINDING, + NOW_SECONDS, + ), + ).toBe(WORKSPACE_ID); + expect(requireRitualPath('weekly-review')).toBe('weekly-review'); + expect(requireHistoryLimit(undefined)).toBe(50); + expect(requireHistoryLimit('100')).toBe(100); + }); it('accepts a signed completion request binding', () => { const issuedAt = String(NOW_SECONDS); @@ -175,35 +172,32 @@ describe('Review HTTP boundary', () => { ).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'), - ); + 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, - HISTORY_BINDING, - NOW_SECONDS, - ), - ).toThrow(HttpException); - }, - ); + expect(() => + requireTrustedWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: nonCanonical, + }, + SECRET, + HISTORY_BINDING, + NOW_SECONDS, + ), + ).toThrow(HttpException); + }); it.each([ { From b82a8548509f76122e585c050a00fd42b4d20914 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:54:29 +0900 Subject: [PATCH 18/21] test(review): centralize signed context fixture --- .../src/review-context.test-helper.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/review-service/src/review-context.test-helper.ts diff --git a/apps/review-service/src/review-context.test-helper.ts b/apps/review-service/src/review-context.test-helper.ts new file mode 100644 index 00000000..43b6e940 --- /dev/null +++ b/apps/review-service/src/review-context.test-helper.ts @@ -0,0 +1,29 @@ +import { createHmac } from 'node:crypto'; + +/** HTTP request identity included in a signed Review gateway context. */ +export interface ReviewContextTestBinding { + readonly method: 'GET' | 'POST'; + readonly path: string; +} + +/** + * Produces the canonical Review gateway-context HMAC used by boundary tests. + * + * Test callers provide the same normalized workspace, issue time, method, and + * path that production verification binds. Centralizing this payload prevents + * tests from silently diverging when the request-bound authority contract + * changes. + */ +export function signReviewTestContext(input: { + readonly secret: string; + readonly workspaceId: string; + readonly issuedAt: string; + readonly binding: ReviewContextTestBinding; +}): string { + return createHmac('sha256', input.secret) + .update( + `life-os.review-context.v1\n${input.workspaceId.toLowerCase()}\n${input.issuedAt}\n${input.binding.method}\n${input.binding.path}`, + 'utf8', + ) + .digest('base64url'); +} From 42dc01ed1d38101349e55bfdf22ae2cadd1c9dc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:55:21 +0900 Subject: [PATCH 19/21] test(review): reuse canonical context signer in controller tests --- apps/review-service/src/main.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/review-service/src/main.test.ts b/apps/review-service/src/main.test.ts index 8eb07c4b..c01e2a7b 100644 --- a/apps/review-service/src/main.test.ts +++ b/apps/review-service/src/main.test.ts @@ -1,7 +1,8 @@ -import { createHmac, randomBytes } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { requireReviewServiceConfiguration, ReviewController } from './main'; +import { signReviewTestContext } from './review-context.test-helper'; import { ReviewService, type ReviewCompletionRecord, @@ -48,13 +49,12 @@ 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.review-context.v1\n${normalizedWorkspaceId}\n${issuedAt}\n${method}\n${path}`, - 'utf8', - ) - .digest('base64url'); + const signature = signReviewTestContext({ + secret: GATEWAY_SECRET, + workspaceId, + issuedAt, + binding: { method, path }, + }); return [issuedAt, signature] as const; } From 3aefc7786794990850c1a1c313a23d19a6bee338 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:55:51 +0900 Subject: [PATCH 20/21] test(review): reuse canonical signer in authority tests --- .../src/review-controller-authority.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/review-service/src/review-controller-authority.test.ts b/apps/review-service/src/review-controller-authority.test.ts index 52e0eaa8..c0ce1a58 100644 --- a/apps/review-service/src/review-controller-authority.test.ts +++ b/apps/review-service/src/review-controller-authority.test.ts @@ -1,7 +1,8 @@ -import { createHmac, randomBytes } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ReviewController } from './main'; +import { signReviewTestContext } from './review-context.test-helper'; import type { ReviewService } from './review-domain'; const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; @@ -103,12 +104,12 @@ function signedHeaders( issuedAtSeconds: number, ): RouteHeaders { const issuedAt = String(issuedAtSeconds); - const signature = createHmac('sha256', CONTEXT_SECRET) - .update( - `life-os.review-context.v1\n${WORKSPACE_ID}\n${issuedAt}\n${route.method}\n${route.path}`, - 'utf8', - ) - .digest('base64url'); + const signature = signReviewTestContext({ + secret: CONTEXT_SECRET, + workspaceId: WORKSPACE_ID, + issuedAt, + binding: { method: route.method, path: route.path }, + }); return { workspaceId: WORKSPACE_ID, issuedAt, signature }; } From dfb4e35324d78d4cb0064d2b86d3c8c8878a2be1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:58:13 +0900 Subject: [PATCH 21/21] test(review): reuse canonical signer in boundary tests --- apps/review-service/src/http-boundary.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/review-service/src/http-boundary.test.ts b/apps/review-service/src/http-boundary.test.ts index fc2ebb1f..a88c608a 100644 --- a/apps/review-service/src/http-boundary.test.ts +++ b/apps/review-service/src/http-boundary.test.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { @@ -8,6 +8,7 @@ import { toReviewHttpException, type ReviewTrustedRequestBinding, } from './http-boundary'; +import { signReviewTestContext } from './review-context.test-helper'; import { ReviewCompletionConflictError, ReviewValidationError, @@ -42,12 +43,12 @@ function signature( binding: { method: 'GET' | 'POST'; path: string } = HISTORY_BINDING, workspaceId = WORKSPACE_ID, ): string { - return createHmac('sha256', SECRET) - .update( - `life-os.review-context.v1\n${workspaceId}\n${issuedAt}\n${binding.method}\n${binding.path}`, - 'utf8', - ) - .digest('base64url'); + return signReviewTestContext({ + secret: SECRET, + workspaceId, + issuedAt, + binding, + }); } function expectTrustedContextRejection(