From 96caa03f29c647bf4f4938a3cf3780613d841fb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:13:59 +0900 Subject: [PATCH 01/13] test(gateway): define authenticated Planning Today composition --- apps/gateway/src/today-composition.test.ts | 148 +++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 apps/gateway/src/today-composition.test.ts diff --git a/apps/gateway/src/today-composition.test.ts b/apps/gateway/src/today-composition.test.ts new file mode 100644 index 00000000..a35dc125 --- /dev/null +++ b/apps/gateway/src/today-composition.test.ts @@ -0,0 +1,148 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + GatewayTodayError, + composePlanningToday, +} from './today-composition'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const SECRET = '0123456789abcdef0123456789abcdef'; +const NOW_SECONDS = 1_786_374_000; + +function json(body: unknown, status = 200): Response { + return Response.json(body, { status }); +} + +function planningToday(): Readonly> { + return { + version: 'life-os.today.v1', + aggregateId: '22222222-2222-4222-8222-222222222222', + revision: '33333333-3333-4333-8333-333333333333', + date: '2026-08-10', + actions: [], + }; +} + +describe('Gateway planning Today composition', () => { + it('derives workspace from identity and sends only signed service authority to Planning', async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, headers: new Headers(init?.headers) }); + if (url.endsWith('/v1/session')) return json({ workspaceId: WORKSPACE_ID }); + return json(planningToday()); + }; + + const result = await composePlanningToday( + 'session=opaque', + '2026-08-10', + { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + }, + fetcher, + NOW_SECONDS, + ); + + expect(result).toEqual({ + version: 'life-os.gateway-today.v1', + date: '2026-08-10', + planning: planningToday(), + degraded: ['habits_not_composed'], + }); + expect(calls).toHaveLength(2); + expect(calls[0]?.url).toBe('https://identity.example.test/v1/session'); + expect(calls[0]?.headers.get('cookie')).toBe('session=opaque'); + expect(calls[1]?.url).toBe( + 'https://planning.example.test/v1/today/2026-08-10', + ); + expect(calls[1]?.headers.get('cookie')).toBeNull(); + expect(calls[1]?.headers.get('x-life-os-workspace-id')).toBe(WORKSPACE_ID); + expect(calls[1]?.headers.get('x-life-os-context-issued-at')).toBe( + String(NOW_SECONDS), + ); + expect(calls[1]?.headers.get('x-life-os-context-signature')).toBe( + createHmac('sha256', SECRET) + .update( + `life-os.workspace.v1\n${WORKSPACE_ID}\n${NOW_SECONDS}`, + 'utf8', + ) + .digest('base64url'), + ); + }); + + it('returns an authentication-required failure without calling Planning', async () => { + const calls: string[] = []; + const fetcher = async (input: RequestInfo | URL) => { + calls.push(String(input)); + return json({ code: 'authentication_required' }, 401); + }; + + await expect( + composePlanningToday( + 'session=expired', + '2026-08-10', + { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + }, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject>({ + status: 401, + code: 'authentication_required', + }); + expect(calls).toEqual(['https://identity.example.test/v1/session']); + }); + + it('does not fabricate success when Planning is unavailable', async () => { + const fetcher = async (input: RequestInfo | URL) => + String(input).endsWith('/v1/session') + ? json({ workspaceId: WORKSPACE_ID }) + : json({ error: 'down' }, 503); + + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + }, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject>({ + status: 503, + code: 'today_composition_unavailable', + }); + }); + + it('fails closed on malformed Planning evidence', async () => { + const fetcher = async (input: RequestInfo | URL) => + String(input).endsWith('/v1/session') + ? json({ workspaceId: WORKSPACE_ID }) + : json({ ...planningToday(), aggregateId: 'not-a-uuid' }); + + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + }, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject>({ + status: 503, + code: 'today_composition_unavailable', + }); + }); +}); From 45ef868bbd92c946bb9ea01f930313284c3131a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:15:06 +0900 Subject: [PATCH 02/13] feat(gateway): compose authenticated Planning Today state --- apps/gateway/src/today-composition.ts | 357 ++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 apps/gateway/src/today-composition.ts diff --git a/apps/gateway/src/today-composition.ts b/apps/gateway/src/today-composition.ts new file mode 100644 index 00000000..37852523 --- /dev/null +++ b/apps/gateway/src/today-composition.ts @@ -0,0 +1,357 @@ +import { createHmac, randomUUID } from 'node:crypto'; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; +const MAXIMUM_COOKIE_BYTES = 4 * 1024; +const MAXIMUM_RESPONSE_BYTES = 64 * 1024; +const MAXIMUM_TODAY_ACTIONS = 50; +const MINIMUM_GATEWAY_SECRET_BYTES = 32; +const UPSTREAM_TIMEOUT_MS = 3_000; + +/** Environment values required by the bounded Gateway -> Identity -> Planning path. */ +export interface GatewayTodayEnvironment { + readonly IDENTITY_SERVICE_ORIGIN?: string; + readonly PLANNING_SERVICE_ORIGIN?: string; + readonly PLANNING_GATEWAY_CONTEXT_SECRET?: string; +} + +/** Minimal fetch surface used by production composition and deterministic tests. */ +export type GatewayTodayFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +/** Validated Planning-owned Today aggregate carried without cross-service persistence reads. */ +export interface GatewayPlanningToday { + readonly version: 'life-os.today.v1'; + readonly aggregateId: string; + readonly revision: string; + readonly date: string; + readonly actions: readonly unknown[]; +} + +/** Buyer-visible Gateway response while Habit composition remains explicitly degraded. */ +export interface GatewayTodayView { + readonly version: 'life-os.gateway-today.v1'; + readonly date: string; + readonly planning: GatewayPlanningToday; + readonly degraded: readonly ['habits_not_composed']; +} + +/** Credential-free typed failure translated by the public HTTP boundary. */ +export class GatewayTodayError extends Error { + constructor( + readonly status: 400 | 401 | 404 | 503, + readonly code: + | 'invalid_today_request' + | 'authentication_required' + | 'today_not_found' + | 'today_composition_unavailable', + message: string, + ) { + super(message); + this.name = 'GatewayTodayError'; + } +} + +function invalidRequest(): GatewayTodayError { + return new GatewayTodayError( + 400, + 'invalid_today_request', + 'Today composition request is invalid', + ); +} + +function unavailable(): GatewayTodayError { + return new GatewayTodayError( + 503, + 'today_composition_unavailable', + 'Today composition is unavailable', + ); +} + +function requireDate(value: string): string { + if (!DATE_PATTERN.test(value)) throw invalidRequest(); + const parsed = new Date(`${value}T00:00:00.000Z`); + if ( + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== value + ) { + throw invalidRequest(); + } + return value; +} + +function requireCookie(value: string | undefined): string | undefined { + if ( + value !== undefined && + (Buffer.byteLength(value, 'utf8') > MAXIMUM_COOKIE_BYTES || + /[\r\n\u0000]/u.test(value)) + ) { + throw invalidRequest(); + } + return value; +} + +function requireServiceOrigin(value: string | undefined): string { + if (!value || value.length > 2048 || /[\u0000-\u001f\u007f]/u.test(value)) { + throw unavailable(); + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw unavailable(); + } + if ( + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.username || + parsed.password || + parsed.pathname !== '/' || + parsed.search || + parsed.hash + ) { + throw unavailable(); + } + return parsed.origin; +} + +function requireGatewaySecret(value: string | undefined): string { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') < MINIMUM_GATEWAY_SECRET_BYTES || + Buffer.byteLength(value, 'utf8') > 4096 || + /[\r\n\u0000]/u.test(value) + ) { + throw unavailable(); + } + return value; +} + +function requireWorkspaceId(value: unknown): string { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + typeof (value as Record).workspaceId !== 'string' || + !UUID_V4_PATTERN.test( + (value as Record).workspaceId as string, + ) + ) { + throw unavailable(); + } + return ((value as Record).workspaceId as string).toLowerCase(); +} + +function requireNowSeconds(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) throw unavailable(); + return value; +} + +function serviceHeaders( + entries: Readonly>, +): Headers { + const headers = new Headers({ accept: 'application/json' }); + for (const [name, value] of Object.entries(entries)) { + if (value !== undefined) headers.set(name, value); + } + return headers; +} + +async function readBoundedText(response: Response): Promise { + const declaredLength = response.headers.get('content-length'); + if ( + declaredLength !== null && + (!/^\d+$/u.test(declaredLength) || + Number(declaredLength) > MAXIMUM_RESPONSE_BYTES) + ) { + throw unavailable(); + } + if (!response.body) throw unavailable(); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + let byteLength = 0; + let body = ''; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + byteLength += chunk.value.byteLength; + if (byteLength > MAXIMUM_RESPONSE_BYTES) { + await reader.cancel('Gateway upstream response exceeds byte limit'); + throw unavailable(); + } + body += decoder.decode(chunk.value, { stream: true }); + } + body += decoder.decode(); + } catch (error) { + try { + await reader.cancel('Gateway upstream response is invalid'); + } catch { + // Stream cancellation is best-effort after an upstream read failure. + } + if (error instanceof GatewayTodayError) throw error; + throw unavailable(); + } finally { + reader.releaseLock(); + } + if (!body) throw unavailable(); + return body; +} + +async function readBoundedJson(response: Response): Promise { + const contentType = response.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + .toLowerCase(); + if ( + contentType !== 'application/json' && + contentType !== 'application/problem+json' + ) { + throw unavailable(); + } + try { + return JSON.parse(await readBoundedText(response)) as unknown; + } catch (error) { + if (error instanceof GatewayTodayError) throw error; + throw unavailable(); + } +} + +function requirePlanningToday( + value: unknown, + expectedDate: string, +): GatewayPlanningToday { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw unavailable(); + } + const record = value as Record; + const exactKeys = ['version', 'aggregateId', 'revision', 'date', 'actions']; + if ( + Object.keys(record).length !== exactKeys.length || + exactKeys.some((key) => !Object.hasOwn(record, key)) || + record.version !== 'life-os.today.v1' || + record.date !== expectedDate || + typeof record.aggregateId !== 'string' || + !UUID_V4_PATTERN.test(record.aggregateId) || + typeof record.revision !== 'string' || + !UUID_V4_PATTERN.test(record.revision) || + !Array.isArray(record.actions) || + record.actions.length > MAXIMUM_TODAY_ACTIONS + ) { + throw unavailable(); + } + return Object.freeze({ + version: 'life-os.today.v1', + aggregateId: record.aggregateId.toLowerCase(), + revision: record.revision.toLowerCase(), + date: expectedDate, + actions: Object.freeze([...record.actions]), + }); +} + +function planningContextHeaders( + workspaceId: string, + secret: string, + nowSeconds: number, +): Readonly> { + const issuedAt = String(requireNowSeconds(nowSeconds)); + const signature = createHmac('sha256', secret) + .update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8') + .digest('base64url'); + return Object.freeze({ + 'x-life-os-workspace-id': workspaceId, + 'x-life-os-context-issued-at': issuedAt, + 'x-life-os-context-signature': signature, + }); +} + +/** + * Authenticates the browser session with Identity, derives workspace authority, + * and reads validated Planning-owned Today state without forwarding credentials. + */ +export async function composePlanningToday( + cookie: string | undefined, + date: string, + environment: GatewayTodayEnvironment, + fetcher: GatewayTodayFetch = fetch, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const safeDate = requireDate(date); + const safeCookie = requireCookie(cookie); + const identityOrigin = requireServiceOrigin( + environment.IDENTITY_SERVICE_ORIGIN, + ); + const planningOrigin = requireServiceOrigin( + environment.PLANNING_SERVICE_ORIGIN, + ); + const secret = requireGatewaySecret( + environment.PLANNING_GATEWAY_CONTEXT_SECRET, + ); + const correlationId = randomUUID(); + + let identityResponse: Response; + try { + identityResponse = await fetcher(new URL('/v1/session', identityOrigin), { + method: 'GET', + headers: serviceHeaders({ + cookie: safeCookie, + 'x-correlation-id': correlationId, + }), + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + } catch { + throw unavailable(); + } + if (identityResponse.status === 401) { + throw new GatewayTodayError( + 401, + 'authentication_required', + 'Authentication is required', + ); + } + if (identityResponse.status !== 200) throw unavailable(); + const workspaceId = requireWorkspaceId(await readBoundedJson(identityResponse)); + + let planningResponse: Response; + try { + planningResponse = await fetcher( + new URL(`/v1/today/${safeDate}`, planningOrigin), + { + method: 'GET', + headers: serviceHeaders({ + ...planningContextHeaders(workspaceId, secret, nowSeconds), + 'x-correlation-id': correlationId, + }), + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + } catch { + throw unavailable(); + } + if (planningResponse.status === 404) { + throw new GatewayTodayError( + 404, + 'today_not_found', + 'Today aggregate was not found', + ); + } + if (planningResponse.status !== 200) throw unavailable(); + const planning = requirePlanningToday( + await readBoundedJson(planningResponse), + safeDate, + ); + + return Object.freeze({ + version: 'life-os.gateway-today.v1', + date: safeDate, + planning, + degraded: Object.freeze(['habits_not_composed'] as const), + }); +} From 13d9f5c5333a6023154f1f805a2d86bf6fd254f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:15:39 +0900 Subject: [PATCH 03/13] feat(gateway): expose authenticated Planning Today slice --- apps/gateway/src/app.module.ts | 46 ++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index 46b7d142..983a1e6a 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -1,8 +1,21 @@ -import { Controller, Get, Header, HttpException, Module } from '@nestjs/common'; +import { + Controller, + Get, + Header, + Headers, + HttpException, + Module, + Query, +} from '@nestjs/common'; import { PROMETHEUS_CONTENT_TYPE } from '@life-os/observability'; import { gatewayMetrics } from './observability'; +import { + composePlanningToday, + GatewayTodayError, + type GatewayTodayView, +} from './today-composition'; -/** Bounded problem details returned while the real Today composition is unavailable. */ +/** Bounded problem details returned without dependency or credential details. */ interface GatewayProblemDetails { readonly type: 'about:blank'; readonly title: string; @@ -21,7 +34,7 @@ function problem(status: number, title: string, code: string): HttpException { return new HttpException(details, status); } -/** Exposes operational health and bounded metrics while product composition stays fail-closed. */ +/** Exposes operational health, authenticated Today composition, and bounded metrics. */ @Controller() export class HealthController { /** Returns a credential-free liveness response for the gateway process. */ @@ -30,15 +43,28 @@ export class HealthController { return { status: 'ok', service: 'gateway' }; } - /** Refuses to fabricate Today data until authenticated service composition is configured. */ + /** + * Authenticates through Identity and returns validated Planning-owned Today + * state. Habit state remains an explicit degraded capability, never fake data. + */ @Get('today') @Header('Cache-Control', 'no-store') - today(): never { - throw problem( - 503, - 'Today composition is unavailable', - 'today_composition_unavailable', - ); + async today( + @Headers('cookie') cookie: string | undefined, + @Query('date') date: string | undefined, + ): Promise { + try { + return await composePlanningToday(cookie, date ?? '', process.env); + } catch (error) { + if (error instanceof GatewayTodayError) { + throw problem(error.status, error.message, error.code); + } + throw problem( + 503, + 'Today composition is unavailable', + 'today_composition_unavailable', + ); + } } /** Renders the bounded in-memory metrics registry for Prometheus scrapes. */ From 5666077a6c5c33fe22be247ce8d2f4d89af9cdca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:16:05 +0900 Subject: [PATCH 04/13] test(gateway): cover Today HTTP failure boundaries --- apps/gateway/src/app.module.test.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/gateway/src/app.module.test.ts b/apps/gateway/src/app.module.test.ts index f26e3beb..06766df8 100644 --- a/apps/gateway/src/app.module.test.ts +++ b/apps/gateway/src/app.module.test.ts @@ -10,12 +10,30 @@ function problem(error: unknown): Readonly> { return response as Readonly>; } -describe('Gateway Today transitional boundary', () => { - it('never returns fabricated successful Today data while real composition is unavailable', () => { +describe('Gateway Today HTTP boundary', () => { + it('rejects a missing Today date before any dependency configuration is consulted', async () => { const controller = new HealthController(); try { - controller.today(); + await controller.today(undefined, undefined); + } catch (error) { + expect((error as HttpException).getStatus()).toBe(400); + expect(problem(error)).toEqual({ + type: 'about:blank', + title: 'Today composition request is invalid', + status: 400, + code: 'invalid_today_request', + }); + return; + } + throw new Error('Expected an invalid Today request to fail closed'); + }); + + it('keeps Today unavailable when trusted dependency configuration is absent', async () => { + const controller = new HealthController(); + + try { + await controller.today(undefined, '2026-08-10'); } catch (error) { expect((error as HttpException).getStatus()).toBe(503); expect(problem(error)).toEqual({ @@ -26,6 +44,6 @@ describe('Gateway Today transitional boundary', () => { }); return; } - throw new Error('Expected Today composition to fail closed'); + throw new Error('Expected unavailable Today dependencies to fail closed'); }); }); From 0b508870f4eda61733378e01b7261e617a43773f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:16:30 +0900 Subject: [PATCH 05/13] test(gateway): exercise authenticated Today HTTP transition --- apps/gateway/src/app.module.integration.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/app.module.integration.test.ts b/apps/gateway/src/app.module.integration.test.ts index feca6e0f..9fbd1af6 100644 --- a/apps/gateway/src/app.module.integration.test.ts +++ b/apps/gateway/src/app.module.integration.test.ts @@ -23,10 +23,24 @@ afterEach(async () => { }); describe('Gateway Today HTTP boundary', () => { - it('returns explicit non-cacheable unavailable evidence instead of fake Today data', async () => { + it('returns a bounded invalid-request problem when the required date is absent', async () => { const { baseUrl } = await createHarness(); const response = await fetch(`${baseUrl}/v1/today`); + expect(response.status).toBe(400); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ + type: 'about:blank', + title: 'Today composition request is invalid', + status: 400, + code: 'invalid_today_request', + }); + }); + + it('returns explicit non-cacheable unavailable evidence when trusted dependencies are not configured', async () => { + const { baseUrl } = await createHarness(); + + const response = await fetch(`${baseUrl}/v1/today?date=2026-08-10`); expect(response.status).toBe(503); expect(response.headers.get('cache-control')).toBe('no-store'); const body = await response.json(); From c72acc25eea9645bb3ae29f79928a65a08e68532 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:21:19 +0900 Subject: [PATCH 06/13] fix(gateway): repair Today composition test gates --- apps/gateway/src/today-composition.test.ts | 25 ++++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/gateway/src/today-composition.test.ts b/apps/gateway/src/today-composition.test.ts index a35dc125..caacb544 100644 --- a/apps/gateway/src/today-composition.test.ts +++ b/apps/gateway/src/today-composition.test.ts @@ -1,12 +1,9 @@ -import { createHmac } from 'node:crypto'; +import { createHmac, randomBytes } from 'node:crypto'; import { describe, expect, it } from 'vitest'; -import { - GatewayTodayError, - composePlanningToday, -} from './today-composition'; +import { composePlanningToday } from './today-composition'; const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; -const SECRET = '0123456789abcdef0123456789abcdef'; +const TEST_SIGNING_KEY = randomBytes(32).toString('base64url'); const NOW_SECONDS = 1_786_374_000; function json(body: unknown, status = 200): Response { @@ -39,7 +36,7 @@ describe('Gateway planning Today composition', () => { { IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, }, fetcher, NOW_SECONDS, @@ -63,7 +60,7 @@ describe('Gateway planning Today composition', () => { String(NOW_SECONDS), ); expect(calls[1]?.headers.get('x-life-os-context-signature')).toBe( - createHmac('sha256', SECRET) + createHmac('sha256', TEST_SIGNING_KEY) .update( `life-os.workspace.v1\n${WORKSPACE_ID}\n${NOW_SECONDS}`, 'utf8', @@ -86,12 +83,12 @@ describe('Gateway planning Today composition', () => { { IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, }, fetcher, NOW_SECONDS, ), - ).rejects.toMatchObject>({ + ).rejects.toMatchObject({ status: 401, code: 'authentication_required', }); @@ -111,12 +108,12 @@ describe('Gateway planning Today composition', () => { { IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, }, fetcher, NOW_SECONDS, ), - ).rejects.toMatchObject>({ + ).rejects.toMatchObject({ status: 503, code: 'today_composition_unavailable', }); @@ -135,12 +132,12 @@ describe('Gateway planning Today composition', () => { { IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, + PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, }, fetcher, NOW_SECONDS, ), - ).rejects.toMatchObject>({ + ).rejects.toMatchObject({ status: 503, code: 'today_composition_unavailable', }); From 701af76471d735ad32df817c016c2f7434f9543e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:26:20 +0900 Subject: [PATCH 07/13] fix(gateway): dispose failed Today upstream bodies --- apps/gateway/src/today-composition.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/today-composition.ts b/apps/gateway/src/today-composition.ts index 37852523..f39c3f86 100644 --- a/apps/gateway/src/today-composition.ts +++ b/apps/gateway/src/today-composition.ts @@ -159,6 +159,15 @@ function serviceHeaders( return headers; } +/** Releases an unread upstream response body before the connection is reused. */ +async function discardBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Upstream body disposal is best-effort on an already failing path. + } +} + async function readBoundedText(response: Response): Promise { const declaredLength = response.headers.get('content-length'); if ( @@ -308,13 +317,17 @@ export async function composePlanningToday( throw unavailable(); } if (identityResponse.status === 401) { + await discardBody(identityResponse); throw new GatewayTodayError( 401, 'authentication_required', 'Authentication is required', ); } - if (identityResponse.status !== 200) throw unavailable(); + if (identityResponse.status !== 200) { + await discardBody(identityResponse); + throw unavailable(); + } const workspaceId = requireWorkspaceId(await readBoundedJson(identityResponse)); let planningResponse: Response; @@ -336,13 +349,17 @@ export async function composePlanningToday( throw unavailable(); } if (planningResponse.status === 404) { + await discardBody(planningResponse); throw new GatewayTodayError( 404, 'today_not_found', 'Today aggregate was not found', ); } - if (planningResponse.status !== 200) throw unavailable(); + if (planningResponse.status !== 200) { + await discardBody(planningResponse); + throw unavailable(); + } const planning = requirePlanningToday( await readBoundedJson(planningResponse), safeDate, From e8f9993c6d4c5a01265d15f90d408d0f1461b510 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:26:57 +0900 Subject: [PATCH 08/13] test(gateway): prove failed Today bodies are released --- apps/gateway/src/today-composition.test.ts | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/apps/gateway/src/today-composition.test.ts b/apps/gateway/src/today-composition.test.ts index caacb544..eba1be40 100644 --- a/apps/gateway/src/today-composition.test.ts +++ b/apps/gateway/src/today-composition.test.ts @@ -10,6 +10,29 @@ function json(body: unknown, status = 200): Response { return Response.json(body, { status }); } +function cancellableJson( + body: unknown, + status: number, +): { response: Response; wasCancelled: () => boolean } { + let cancelled = false; + const bytes = new TextEncoder().encode(JSON.stringify(body)); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + }, + cancel() { + cancelled = true; + }, + }), + { + status, + headers: { 'content-type': 'application/json' }, + }, + ); + return { response, wasCancelled: () => cancelled }; +} + function planningToday(): Readonly> { return { version: 'life-os.today.v1', @@ -95,6 +118,28 @@ describe('Gateway planning Today composition', () => { expect(calls).toEqual(['https://identity.example.test/v1/session']); }); + it('cancels unread Identity error bodies before returning authentication failure', async () => { + const identityFailure = cancellableJson( + { code: 'authentication_required' }, + 401, + ); + + await expect( + composePlanningToday( + 'session=expired', + '2026-08-10', + { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, + }, + async () => identityFailure.response, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ status: 401, code: 'authentication_required' }); + expect(identityFailure.wasCancelled()).toBe(true); + }); + it('does not fabricate success when Planning is unavailable', async () => { const fetcher = async (input: RequestInfo | URL) => String(input).endsWith('/v1/session') @@ -119,6 +164,32 @@ describe('Gateway planning Today composition', () => { }); }); + it('cancels unread Planning error bodies before returning dependency failure', async () => { + const planningFailure = cancellableJson({ error: 'down' }, 503); + const fetcher = async (input: RequestInfo | URL) => + String(input).endsWith('/v1/session') + ? json({ workspaceId: WORKSPACE_ID }) + : planningFailure.response; + + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, + }, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ + status: 503, + code: 'today_composition_unavailable', + }); + expect(planningFailure.wasCancelled()).toBe(true); + }); + it('fails closed on malformed Planning evidence', async () => { const fetcher = async (input: RequestInfo | URL) => String(input).endsWith('/v1/session') From e120e450ed796f1e6057ff362b514e92448171fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:27:24 +0900 Subject: [PATCH 09/13] test(gateway): isolate Today dependency environment --- apps/gateway/src/app.module.test.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/app.module.test.ts b/apps/gateway/src/app.module.test.ts index 06766df8..48869ec6 100644 --- a/apps/gateway/src/app.module.test.ts +++ b/apps/gateway/src/app.module.test.ts @@ -2,6 +2,12 @@ import { HttpException } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { HealthController } from './app.module'; +const TODAY_DEPENDENCY_KEYS = [ + 'IDENTITY_SERVICE_ORIGIN', + 'PLANNING_SERVICE_ORIGIN', + 'PLANNING_GATEWAY_CONTEXT_SECRET', +] as const; + /** Returns a stable problem object from one expected gateway HTTP failure. */ function problem(error: unknown): Readonly> { expect(error).toBeInstanceOf(HttpException); @@ -10,6 +16,22 @@ function problem(error: unknown): Readonly> { return response as Readonly>; } +/** Runs one assertion with Today upstream configuration deterministically absent. */ +async function withoutTodayDependencies( + operation: () => Promise, +): Promise { + const previous = TODAY_DEPENDENCY_KEYS.map((key) => [key, process.env[key]] as const); + for (const key of TODAY_DEPENDENCY_KEYS) delete process.env[key]; + try { + return await operation(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + describe('Gateway Today HTTP boundary', () => { it('rejects a missing Today date before any dependency configuration is consulted', async () => { const controller = new HealthController(); @@ -33,7 +55,9 @@ describe('Gateway Today HTTP boundary', () => { const controller = new HealthController(); try { - await controller.today(undefined, '2026-08-10'); + await withoutTodayDependencies(() => + controller.today(undefined, '2026-08-10'), + ); } catch (error) { expect((error as HttpException).getStatus()).toBe(503); expect(problem(error)).toEqual({ From f25e4d66f97ee17a26b036ac44ed85106abbb5e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:28:07 +0900 Subject: [PATCH 10/13] test(gateway): isolate Today integration environment --- .../src/app.module.integration.test.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/app.module.integration.test.ts b/apps/gateway/src/app.module.integration.test.ts index 9fbd1af6..7bc0628d 100644 --- a/apps/gateway/src/app.module.integration.test.ts +++ b/apps/gateway/src/app.module.integration.test.ts @@ -1,10 +1,18 @@ import type { AddressInfo } from 'node:net'; import type { INestApplication } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { AppModule } from './app.module'; const applications: INestApplication[] = []; +const TODAY_DEPENDENCY_KEYS = [ + 'IDENTITY_SERVICE_ORIGIN', + 'PLANNING_SERVICE_ORIGIN', + 'PLANNING_GATEWAY_CONTEXT_SECRET', +] as const; +let previousTodayDependencies: ReadonlyArray< + readonly [(typeof TODAY_DEPENDENCY_KEYS)[number], string | undefined] +> = []; async function createHarness(): Promise<{ app: INestApplication; @@ -18,8 +26,23 @@ async function createHarness(): Promise<{ return { app, baseUrl: `http://127.0.0.1:${address.port}` }; } +beforeEach(() => { + previousTodayDependencies = TODAY_DEPENDENCY_KEYS.map( + (key) => [key, process.env[key]] as const, + ); + for (const key of TODAY_DEPENDENCY_KEYS) delete process.env[key]; +}); + afterEach(async () => { - await Promise.all(applications.splice(0).map((app) => app.close())); + try { + await Promise.all(applications.splice(0).map((app) => app.close())); + } finally { + for (const [key, value] of previousTodayDependencies) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + previousTodayDependencies = []; + } }); describe('Gateway Today HTTP boundary', () => { From 1875416ff8e3587ac0736ea8744af09f28b0073e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:42:23 +0900 Subject: [PATCH 11/13] fix(gateway): validate Planning action evidence --- apps/gateway/src/today-composition.ts | 30 ++++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/gateway/src/today-composition.ts b/apps/gateway/src/today-composition.ts index f39c3f86..caf6a110 100644 --- a/apps/gateway/src/today-composition.ts +++ b/apps/gateway/src/today-composition.ts @@ -22,13 +22,19 @@ export type GatewayTodayFetch = ( init?: RequestInit, ) => Promise; +/** Validated Planning action evidence with an opaque product identifier. */ +export interface GatewayPlanningTodayAction { + readonly id: string; + readonly [key: string]: unknown; +} + /** Validated Planning-owned Today aggregate carried without cross-service persistence reads. */ export interface GatewayPlanningToday { readonly version: 'life-os.today.v1'; readonly aggregateId: string; readonly revision: string; readonly date: string; - readonly actions: readonly unknown[]; + readonly actions: readonly GatewayPlanningTodayAction[]; } /** Buyer-visible Gateway response while Habit composition remains explicitly degraded. */ @@ -215,12 +221,7 @@ async function readBoundedJson(response: Response): Promise { ?.split(';', 1)[0] ?.trim() .toLowerCase(); - if ( - contentType !== 'application/json' && - contentType !== 'application/problem+json' - ) { - throw unavailable(); - } + if (contentType !== 'application/json') throw unavailable(); try { return JSON.parse(await readBoundedText(response)) as unknown; } catch (error) { @@ -229,6 +230,18 @@ async function readBoundedJson(response: Response): Promise { } } +/** Validates the minimum action identity contract before forwarding Planning evidence. */ +function requirePlanningAction(value: unknown): GatewayPlanningTodayAction { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw unavailable(); + } + const action = value as Record; + if (typeof action.id !== 'string' || !UUID_V4_PATTERN.test(action.id)) { + throw unavailable(); + } + return Object.freeze({ ...action, id: action.id.toLowerCase() }); +} + function requirePlanningToday( value: unknown, expectedDate: string, @@ -252,12 +265,13 @@ function requirePlanningToday( ) { throw unavailable(); } + const actions = record.actions.map(requirePlanningAction); return Object.freeze({ version: 'life-os.today.v1', aggregateId: record.aggregateId.toLowerCase(), revision: record.revision.toLowerCase(), date: expectedDate, - actions: Object.freeze([...record.actions]), + actions: Object.freeze(actions), }); } From c84ca3b441f979d729774948b51e3240556f9c5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:43:13 +0900 Subject: [PATCH 12/13] test(gateway): reject malformed Planning actions and media --- apps/gateway/src/today-composition.test.ts | 142 +++++++++++---------- 1 file changed, 78 insertions(+), 64 deletions(-) diff --git a/apps/gateway/src/today-composition.test.ts b/apps/gateway/src/today-composition.test.ts index eba1be40..9f94776d 100644 --- a/apps/gateway/src/today-composition.test.ts +++ b/apps/gateway/src/today-composition.test.ts @@ -33,16 +33,41 @@ function cancellableJson( return { response, wasCancelled: () => cancelled }; } -function planningToday(): Readonly> { +function planningToday(actions: readonly unknown[] = []): Readonly> { return { version: 'life-os.today.v1', aggregateId: '22222222-2222-4222-8222-222222222222', revision: '33333333-3333-4333-8333-333333333333', date: '2026-08-10', - actions: [], + actions, }; } +const ENVIRONMENT = { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, +} as const; + +async function expectPlanningUnavailable(planningResponse: Response): Promise { + const fetcher = async (input: RequestInfo | URL) => + String(input).endsWith('/v1/session') + ? json({ workspaceId: WORKSPACE_ID }) + : planningResponse; + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + ENVIRONMENT, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ + status: 503, + code: 'today_composition_unavailable', + }); +} + describe('Gateway planning Today composition', () => { it('derives workspace from identity and sends only signed service authority to Planning', async () => { const calls: Array<{ url: string; headers: Headers }> = []; @@ -56,11 +81,7 @@ describe('Gateway planning Today composition', () => { const result = await composePlanningToday( 'session=opaque', '2026-08-10', - { - IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', - PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, - }, + ENVIRONMENT, fetcher, NOW_SECONDS, ); @@ -92,6 +113,49 @@ describe('Gateway planning Today composition', () => { ); }); + it('validates and canonicalizes Planning action identity before forwarding', async () => { + const action = { + id: 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA', + title: 'Ship bounded Today composition', + status: 'open', + }; + const fetcher = async (input: RequestInfo | URL) => + String(input).endsWith('/v1/session') + ? json({ workspaceId: WORKSPACE_ID }) + : json(planningToday([action])); + + const result = await composePlanningToday( + 'session=opaque', + '2026-08-10', + ENVIRONMENT, + fetcher, + NOW_SECONDS, + ); + + expect(result.planning.actions).toEqual([ + { ...action, id: action.id.toLowerCase() }, + ]); + expect(Object.isFrozen(result.planning.actions[0])).toBe(true); + }); + + it.each([ + ['null action', null], + ['array action', []], + ['missing action id', { title: 'missing id' }], + ['invalid action id', { id: 'not-a-uuid' }], + ])('fails closed on %s evidence', async (_name, action) => { + await expectPlanningUnavailable(json(planningToday([action]))); + }); + + it('rejects problem-json as a successful upstream representation', async () => { + await expectPlanningUnavailable( + new Response(JSON.stringify(planningToday()), { + status: 200, + headers: { 'content-type': 'application/problem+json' }, + }), + ); + }); + it('returns an authentication-required failure without calling Planning', async () => { const calls: string[] = []; const fetcher = async (input: RequestInfo | URL) => { @@ -103,11 +167,7 @@ describe('Gateway planning Today composition', () => { composePlanningToday( 'session=expired', '2026-08-10', - { - IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', - PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, - }, + ENVIRONMENT, fetcher, NOW_SECONDS, ), @@ -128,11 +188,7 @@ describe('Gateway planning Today composition', () => { composePlanningToday( 'session=expired', '2026-08-10', - { - IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', - PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, - }, + ENVIRONMENT, async () => identityFailure.response, NOW_SECONDS, ), @@ -141,27 +197,7 @@ describe('Gateway planning Today composition', () => { }); it('does not fabricate success when Planning is unavailable', async () => { - const fetcher = async (input: RequestInfo | URL) => - String(input).endsWith('/v1/session') - ? json({ workspaceId: WORKSPACE_ID }) - : json({ error: 'down' }, 503); - - await expect( - composePlanningToday( - 'session=opaque', - '2026-08-10', - { - IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', - PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, - }, - fetcher, - NOW_SECONDS, - ), - ).rejects.toMatchObject({ - status: 503, - code: 'today_composition_unavailable', - }); + await expectPlanningUnavailable(json({ error: 'down' }, 503)); }); it('cancels unread Planning error bodies before returning dependency failure', async () => { @@ -175,11 +211,7 @@ describe('Gateway planning Today composition', () => { composePlanningToday( 'session=opaque', '2026-08-10', - { - IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', - PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, - }, + ENVIRONMENT, fetcher, NOW_SECONDS, ), @@ -191,26 +223,8 @@ describe('Gateway planning Today composition', () => { }); it('fails closed on malformed Planning evidence', async () => { - const fetcher = async (input: RequestInfo | URL) => - String(input).endsWith('/v1/session') - ? json({ workspaceId: WORKSPACE_ID }) - : json({ ...planningToday(), aggregateId: 'not-a-uuid' }); - - await expect( - composePlanningToday( - 'session=opaque', - '2026-08-10', - { - IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', - PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', - PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, - }, - fetcher, - NOW_SECONDS, - ), - ).rejects.toMatchObject({ - status: 503, - code: 'today_composition_unavailable', - }); + await expectPlanningUnavailable( + json({ ...planningToday(), aggregateId: 'not-a-uuid' }), + ); }); }); From 10685d5ddd07a0284984325f9226c222874285ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:53:45 +0900 Subject: [PATCH 13/13] test(gateway): cover Today composition failure boundaries --- apps/gateway/src/today-composition.test.ts | 161 ++++++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/today-composition.test.ts b/apps/gateway/src/today-composition.test.ts index 9f94776d..10190e95 100644 --- a/apps/gateway/src/today-composition.test.ts +++ b/apps/gateway/src/today-composition.test.ts @@ -33,7 +33,9 @@ function cancellableJson( return { response, wasCancelled: () => cancelled }; } -function planningToday(actions: readonly unknown[] = []): Readonly> { +function planningToday( + actions: readonly unknown[] = [], +): Readonly> { return { version: 'life-os.today.v1', aggregateId: '22222222-2222-4222-8222-222222222222', @@ -49,7 +51,9 @@ const ENVIRONMENT = { PLANNING_GATEWAY_CONTEXT_SECRET: TEST_SIGNING_KEY, } as const; -async function expectPlanningUnavailable(planningResponse: Response): Promise { +async function expectPlanningUnavailable( + planningResponse: Response, +): Promise { const fetcher = async (input: RequestInfo | URL) => String(input).endsWith('/v1/session') ? json({ workspaceId: WORKSPACE_ID }) @@ -68,6 +72,28 @@ async function expectPlanningUnavailable(planningResponse: Response): Promise[2], +): Promise { + let called = false; + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + environment, + async () => { + called = true; + return json({ workspaceId: WORKSPACE_ID }); + }, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ + status: 503, + code: 'today_composition_unavailable', + }); + expect(called).toBe(false); +} + describe('Gateway planning Today composition', () => { it('derives workspace from identity and sends only signed service authority to Planning', async () => { const calls: Array<{ url: string; headers: Headers }> = []; @@ -196,6 +222,50 @@ describe('Gateway planning Today composition', () => { expect(identityFailure.wasCancelled()).toBe(true); }); + it('maps Planning not-found without fabricating an aggregate', async () => { + const fetcher = async (input: RequestInfo | URL) => + String(input).endsWith('/v1/session') + ? json({ workspaceId: WORKSPACE_ID }) + : json({ code: 'today_not_found' }, 404); + + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + ENVIRONMENT, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ status: 404, code: 'today_not_found' }); + }); + + it.each([ + ['identity fetch exception', 0], + ['planning fetch exception', 1], + ])('fails closed on %s', async (_name, throwOnCall) => { + let call = 0; + const fetcher = async () => { + if (call === throwOnCall) { + throw new DOMException('upstream timed out', 'TimeoutError'); + } + call += 1; + return json({ workspaceId: WORKSPACE_ID }); + }; + + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + ENVIRONMENT, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ + status: 503, + code: 'today_composition_unavailable', + }); + }); + it('does not fabricate success when Planning is unavailable', async () => { await expectPlanningUnavailable(json({ error: 'down' }, 503)); }); @@ -222,6 +292,93 @@ describe('Gateway planning Today composition', () => { expect(planningFailure.wasCancelled()).toBe(true); }); + it.each([ + [ + 'wrong successful media type', + new Response(JSON.stringify(planningToday()), { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ], + [ + 'malformed content length', + new Response(JSON.stringify(planningToday()), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': 'unknown', + }, + }), + ], + [ + 'declared oversized body', + new Response(JSON.stringify(planningToday()), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': '65537', + }, + }), + ], + [ + 'streamed oversized body', + new Response(new Uint8Array(65_537), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ], + [ + 'invalid UTF-8 body', + new Response(Uint8Array.from([0xc3, 0x28]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ], + ])('fails closed on %s', async (_name, response) => { + await expectPlanningUnavailable(response); + }); + + it('fails closed on malformed Identity workspace authority', async () => { + const fetcher = async () => json({ workspaceId: 'not-a-uuid' }); + await expect( + composePlanningToday( + 'session=opaque', + '2026-08-10', + ENVIRONMENT, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ + status: 503, + code: 'today_composition_unavailable', + }); + }); + + it('fails closed when Planning exceeds the bounded action count', async () => { + const action = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }; + await expectPlanningUnavailable(json(planningToday(Array(51).fill(action)))); + }); + + it.each([ + [ + 'short signing secret', + { ...ENVIRONMENT, PLANNING_GATEWAY_CONTEXT_SECRET: 'too-short' }, + ], + [ + 'credential-bearing Identity origin', + { + ...ENVIRONMENT, + IDENTITY_SERVICE_ORIGIN: 'https://user:pass@identity.example.test', + }, + ], + [ + 'non-http Planning origin', + { ...ENVIRONMENT, PLANNING_SERVICE_ORIGIN: 'file:///tmp/planning' }, + ], + ])('rejects %s before dependency calls', async (_name, environment) => { + await expectConfigurationUnavailable(environment); + }); + it('fails closed on malformed Planning evidence', async () => { await expectPlanningUnavailable( json({ ...planningToday(), aggregateId: 'not-a-uuid' }),