diff --git a/apps/gateway/src/app.module.integration.test.ts b/apps/gateway/src/app.module.integration.test.ts index feca6e0f..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,15 +26,44 @@ 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', () => { - 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(); diff --git a/apps/gateway/src/app.module.test.ts b/apps/gateway/src/app.module.test.ts index f26e3beb..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,12 +16,48 @@ 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', () => { +/** 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(); + + try { + 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 { - controller.today(); + await withoutTodayDependencies(() => + controller.today(undefined, '2026-08-10'), + ); } catch (error) { expect((error as HttpException).getStatus()).toBe(503); expect(problem(error)).toEqual({ @@ -26,6 +68,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'); }); }); 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. */ diff --git a/apps/gateway/src/today-composition.test.ts b/apps/gateway/src/today-composition.test.ts new file mode 100644 index 00000000..10190e95 --- /dev/null +++ b/apps/gateway/src/today-composition.test.ts @@ -0,0 +1,387 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { composePlanningToday } from './today-composition'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const TEST_SIGNING_KEY = randomBytes(32).toString('base64url'); +const NOW_SECONDS = 1_786_374_000; + +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( + 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, + }; +} + +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', + }); +} + +async function expectConfigurationUnavailable( + environment: Parameters[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 }> = []; + 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', + ENVIRONMENT, + 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', TEST_SIGNING_KEY) + .update( + `life-os.workspace.v1\n${WORKSPACE_ID}\n${NOW_SECONDS}`, + 'utf8', + ) + .digest('base64url'), + ); + }); + + 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) => { + calls.push(String(input)); + return json({ code: 'authentication_required' }, 401); + }; + + await expect( + composePlanningToday( + 'session=expired', + '2026-08-10', + ENVIRONMENT, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ + status: 401, + code: 'authentication_required', + }); + 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', + ENVIRONMENT, + async () => identityFailure.response, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ status: 401, code: 'authentication_required' }); + 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)); + }); + + 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', + ENVIRONMENT, + fetcher, + NOW_SECONDS, + ), + ).rejects.toMatchObject({ + status: 503, + code: 'today_composition_unavailable', + }); + 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' }), + ); + }); +}); diff --git a/apps/gateway/src/today-composition.ts b/apps/gateway/src/today-composition.ts new file mode 100644 index 00000000..caf6a110 --- /dev/null +++ b/apps/gateway/src/today-composition.ts @@ -0,0 +1,388 @@ +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 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 GatewayPlanningTodayAction[]; +} + +/** 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; +} + +/** 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 ( + 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') throw unavailable(); + try { + return JSON.parse(await readBoundedText(response)) as unknown; + } catch (error) { + if (error instanceof GatewayTodayError) throw error; + throw unavailable(); + } +} + +/** 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, +): 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(); + } + 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(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) { + await discardBody(identityResponse); + throw new GatewayTodayError( + 401, + 'authentication_required', + 'Authentication is required', + ); + } + if (identityResponse.status !== 200) { + await discardBody(identityResponse); + 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) { + await discardBody(planningResponse); + throw new GatewayTodayError( + 404, + 'today_not_found', + 'Today aggregate was not found', + ); + } + if (planningResponse.status !== 200) { + await discardBody(planningResponse); + 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), + }); +}