From 610c5d4761f33b1265d7223b115d7bd2ad392127 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:09:45 +0900 Subject: [PATCH 01/12] test(planning): define trusted data-rights transport contract --- ...planning-data-rights-http-boundary.test.ts | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 apps/planning-service/src/planning-data-rights-http-boundary.test.ts diff --git a/apps/planning-service/src/planning-data-rights-http-boundary.test.ts b/apps/planning-service/src/planning-data-rights-http-boundary.test.ts new file mode 100644 index 00000000..a3594214 --- /dev/null +++ b/apps/planning-service/src/planning-data-rights-http-boundary.test.ts @@ -0,0 +1,165 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION } from './planning-data-rights'; +import { + parseTrustedPlanningDataRightsRequest, + toPlanningDataRightsHttpException, +} from './planning-data-rights-http-boundary'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; +const SECRET = randomBytes(32).toString('base64url'); +const NOW_SECONDS = 1_786_334_400; +const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; + +const exportRequest = Object.freeze({ + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'export' as const, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, +}); + +/** Signs one exact Planning contributor request using the production canonical input order. */ +function signature( + request: Record, + issuedAt: string, + path = CONTRIBUTOR_PATH, +): string { + const idempotencyKey = + request.operation === 'erase' ? String(request.idempotencyKey) : '-'; + return createHmac('sha256', SECRET) + .update( + [ + 'life-os.planning-data-rights-context.v1', + String(request.contractVersion), + String(request.workspaceId), + String(request.requestedByUserId), + String(request.requestId), + String(request.operation), + idempotencyKey, + issuedAt, + 'POST', + path, + ].join('\n'), + 'utf8', + ) + .digest('base64url'); +} + +/** Returns the bounded HTTP status from one rejected trusted-boundary 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 Planning data-rights transport to reject'); +} + +describe('Planning data-rights HTTP authority', () => { + it('accepts a fresh request bound to tenant, actor, purpose, method, and path', async () => { + const issuedAt = String(NOW_SECONDS); + await expect( + parseTrustedPlanningDataRightsRequest( + exportRequest, + { issuedAt, signature: signature(exportRequest, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ).resolves.toEqual(exportRequest); + }); + + it('accepts destructive idempotency only when the signed key matches the request', async () => { + const request = Object.freeze({ + ...exportRequest, + operation: 'erase' as const, + idempotencyKey: IDEMPOTENCY_KEY, + }); + const issuedAt = String(NOW_SECONDS); + await expect( + parseTrustedPlanningDataRightsRequest( + request, + { issuedAt, signature: signature(request, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ).resolves.toEqual(request); + + await expect( + parseTrustedPlanningDataRightsRequest( + { ...request, idempotencyKey: REQUEST_ID }, + { issuedAt, signature: signature(request, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ).rejects.toBeInstanceOf(HttpException); + }); + + it.each([ + { + name: 'wrong path', + secret: SECRET, + binding: { method: 'POST', path: '/v1/internal/data-rights/other' }, + issuedAt: String(NOW_SECONDS), + }, + { + name: 'wrong method', + secret: SECRET, + binding: { method: 'GET', path: CONTRIBUTOR_PATH }, + issuedAt: String(NOW_SECONDS), + }, + { + name: 'stale evidence', + secret: SECRET, + binding: { method: 'POST', path: CONTRIBUTOR_PATH }, + issuedAt: String(NOW_SECONDS - 61), + }, + { + name: 'missing verifier secret', + secret: undefined, + binding: { method: 'POST', path: CONTRIBUTOR_PATH }, + issuedAt: String(NOW_SECONDS), + }, + ])('fails closed for $name', async ({ secret, binding, issuedAt }) => { + const status = await rejectedStatus( + parseTrustedPlanningDataRightsRequest( + exportRequest, + { issuedAt, signature: signature(exportRequest, issuedAt) }, + secret, + binding, + NOW_SECONDS, + ), + ); + expect(status).toBe(secret === undefined ? 503 : 401); + }); + + it('rejects undeclared request fields before contributor code can observe them', async () => { + const issuedAt = String(NOW_SECONDS); + const request = { ...exportRequest, unexpected: 'authority' }; + await expect( + parseTrustedPlanningDataRightsRequest( + request, + { issuedAt, signature: signature(request, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ).rejects.toBeInstanceOf(HttpException); + }); + + it('sanitizes contributor failures into a credential-free 503 problem', () => { + const exception = toPlanningDataRightsHttpException( + new Error('postgres password and internal topology'), + ); + expect(exception.getStatus()).toBe(503); + expect(JSON.stringify(exception.getResponse())).not.toContain('password'); + }); +}); From c17d728562bf3106f2f97d80976e35e54669c326 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:10:17 +0900 Subject: [PATCH 02/12] feat(planning): authenticate data-rights contributor transport --- .../src/planning-data-rights-http-boundary.ts | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 apps/planning-service/src/planning-data-rights-http-boundary.ts diff --git a/apps/planning-service/src/planning-data-rights-http-boundary.ts b/apps/planning-service/src/planning-data-rights-http-boundary.ts new file mode 100644 index 00000000..8a9052f9 --- /dev/null +++ b/apps/planning-service/src/planning-data-rights-http-boundary.ts @@ -0,0 +1,261 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; +import { + DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + type DataRightsContributorRequest, +} from './planning-data-rights'; + +/** Short-lived service-authentication headers for the internal Planning contributor route. */ +export interface TrustedPlanningDataRightsContextHeaders { + readonly issuedAt: unknown; + readonly signature: unknown; +} + +/** Server-observed HTTP identity bound into one Planning data-rights proof. */ +export interface PlanningDataRightsRequestBinding { + readonly method: unknown; + readonly path: unknown; +} + +interface PlanningDataRightsProblemDetails { + readonly type: 'about:blank'; + readonly title: string; + readonly status: number; + readonly code: string; +} + +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}$/iu; +const UNIX_SECONDS_PATTERN = /^(?:0|[1-9]\d{0,12})$/u; +const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; +const MINIMUM_CONTEXT_SECRET_BYTES = 32; +const MAXIMUM_CONTEXT_AGE_SECONDS = 60; +const MAXIMUM_FUTURE_SKEW_SECONDS = 5; + +type NormalizedRequest = DataRightsContributorRequest & + Readonly<{ + workspaceId: string; + requestedByUserId: string; + requestId: string; + }>; + +/** Builds one bounded RFC 7807-style problem without reflecting untrusted detail. */ +function problemException( + status: number, + title: string, + code: string, +): HttpException { + const problem: PlanningDataRightsProblemDetails = { + type: 'about:blank', + title, + status, + code, + }; + return new HttpException(problem, status); +} + +/** Rejects malformed contributor request data before it reaches Planning persistence. */ +function invalidRequest(): never { + throw problemException( + 400, + 'Planning data-rights request is invalid', + 'invalid_data_rights_request', + ); +} + +/** Rejects forged, stale, future, or route-mismatched service authority. */ +function invalidContext(): never { + throw problemException( + 401, + 'Planning data-rights authority is invalid', + 'invalid_data_rights_context', + ); +} + +/** Rejects verifier configuration that cannot authenticate an internal caller. */ +function unavailableContext(): never { + throw problemException( + 503, + 'Planning data-rights authority is unavailable', + 'data_rights_context_unavailable', + ); +} + +/** Requires a plain JSON object before any field can influence authority. */ +function requireRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return invalidRequest(); + } + return value as Record; +} + +/** Requires an exact operation-specific field set with no undeclared authority input. */ +function requireExactKeys( + record: Record, + expectedKeys: readonly string[], +): void { + const expected = new Set(expectedKeys); + const actual = Object.keys(record); + if ( + actual.length !== expected.size || + actual.some((key) => !expected.has(key)) + ) { + invalidRequest(); + } +} + +/** Requires and canonicalizes one opaque UUIDv4 product identity. */ +function requireUuidV4(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidRequest(); + } + return value.toLowerCase(); +} + +/** Normalizes exactly the protected v1 contributor request schema. */ +function normalizeRequest(body: unknown): NormalizedRequest { + const request = requireRecord(body); + const commonKeys = [ + 'contractVersion', + 'operation', + 'workspaceId', + 'requestedByUserId', + 'requestId', + ] as const; + if ( + request.contractVersion !== DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION || + (request.operation !== 'export' && + request.operation !== 'erase_preflight' && + request.operation !== 'erase' && + request.operation !== 'verify_erased') + ) { + return invalidRequest(); + } + + const workspaceId = requireUuidV4(request.workspaceId); + const requestedByUserId = requireUuidV4(request.requestedByUserId); + const requestId = requireUuidV4(request.requestId); + if (request.operation === 'erase') { + requireExactKeys(request, [...commonKeys, 'idempotencyKey']); + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'erase', + workspaceId, + requestedByUserId, + requestId, + idempotencyKey: requireUuidV4(request.idempotencyKey), + }; + } + + requireExactKeys(request, commonKeys); + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: request.operation, + workspaceId, + requestedByUserId, + requestId, + }; +} + +/** Requires the one exact POST resource that owns Planning contributor transport. */ +function requireRequestBinding( + binding: PlanningDataRightsRequestBinding, +): { readonly method: 'POST'; readonly path: typeof CONTRIBUTOR_PATH } { + if (binding.method !== 'POST' || binding.path !== CONTRIBUTOR_PATH) { + return invalidContext(); + } + return { method: 'POST', path: CONTRIBUTOR_PATH }; +} + +/** Computes the canonical request-bound HMAC for one normalized contributor operation. */ +function requestDigest( + request: NormalizedRequest, + issuedAt: string, + binding: Readonly<{ method: 'POST'; path: typeof CONTRIBUTOR_PATH }>, + secret: string, +): Buffer { + const idempotencyKey = + request.operation === 'erase' ? request.idempotencyKey : '-'; + return createHmac('sha256', secret) + .update( + [ + 'life-os.planning-data-rights-context.v1', + request.contractVersion, + request.workspaceId, + request.requestedByUserId, + request.requestId, + request.operation, + idempotencyKey, + issuedAt, + binding.method, + binding.path, + ].join('\n'), + 'utf8', + ) + .digest(); +} + +/** + * Validates the exact contributor request and short-lived Identity-to-Planning authority. + * + * Tenant, actor, request, operation, destructive idempotency identity, lifetime, + * method, and route are all HMAC-bound. The existing Planning contributor remains + * the sole persistence authority and preserves its durable idempotent erase receipt. + */ +export async function parseTrustedPlanningDataRightsRequest( + body: unknown, + headers: TrustedPlanningDataRightsContextHeaders, + secret: unknown, + requestBinding: PlanningDataRightsRequestBinding, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const request = normalizeRequest(body); + if ( + typeof secret !== 'string' || + Buffer.byteLength(secret, 'utf8') < MINIMUM_CONTEXT_SECRET_BYTES + ) { + return unavailableContext(); + } + const binding = requireRequestBinding(requestBinding); + if ( + 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 invalidContext(); + } + + const issuedAtSeconds = Number(headers.issuedAt); + if ( + !Number.isSafeInteger(issuedAtSeconds) || + issuedAtSeconds > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS || + issuedAtSeconds < nowSeconds - MAXIMUM_CONTEXT_AGE_SECONDS + ) { + return invalidContext(); + } + + const expected = requestDigest(request, headers.issuedAt, binding, secret); + const actual = Buffer.from(headers.signature, 'base64url'); + if ( + actual.length !== expected.length || + actual.toString('base64url') !== headers.signature || + !timingSafeEqual(actual, expected) + ) { + return invalidContext(); + } + return request; +} + +/** Maps contributor/runtime failures to one bounded credential-free transport error. */ +export function toPlanningDataRightsHttpException(error: unknown): HttpException { + void error; + return problemException( + 503, + 'Planning data-rights operation is unavailable', + 'data_rights_unavailable', + ); +} From ae86a1cc8c7225d2eb5c46d69f1dc39da413b34c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:11:11 +0900 Subject: [PATCH 03/12] feat(planning): expose authenticated data-rights contributor --- apps/planning-service/src/main.ts | 61 ++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index 4452c649..ebbfca45 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -25,7 +25,17 @@ import { planningMetrics, planningObservabilityMiddleware, } from './observability'; -import type { Goal, Project, Task } from './planning-domain'; +import type { + DataRightsContributorResponse, + Goal, + Project, + Task, +} from './planning-data-rights'; +import { + parseTrustedPlanningDataRightsRequest, + toPlanningDataRightsHttpException, +} from './planning-data-rights-http-boundary'; +import type { Goal as PlanningGoal, Project as PlanningProject, Task as PlanningTask } from './planning-domain'; import { PlanningService } from './planning-domain'; import { createPlanningRuntime, PlanningRuntime } from './planning-runtime'; import type { PlanningSearchResult } from './search'; @@ -197,7 +207,7 @@ export class PlanningController { @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: { title?: unknown }, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -218,7 +228,7 @@ export class PlanningController { @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, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -239,7 +249,7 @@ export class PlanningController { @Headers('x-life-os-context-signature') signature: string | undefined, @Param('goalId') goalId: string, @Body() body: { title?: unknown }, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -262,7 +272,7 @@ export class PlanningController { @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, @Headers('x-life-os-context-signature') signature: string | undefined, @Param('goalId') goalId: string, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -286,7 +296,7 @@ export class PlanningController { @Headers('x-life-os-context-signature') signature: string | undefined, @Param('projectId') projectId: string, @Body() body: { title?: unknown }, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -309,7 +319,7 @@ export class PlanningController { @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, @Headers('x-life-os-context-signature') signature: string | undefined, @Param('projectId') projectId: string, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -326,9 +336,41 @@ export class PlanningController { } } +/** Internal service-authenticated transport for Planning-owned data-rights work. */ +@Controller('internal/data-rights') +export class PlanningDataRightsController { + constructor( + @Inject(PLANNING_RUNTIME) + private readonly runtime: PlanningRuntime, + ) {} + + /** Executes only the exact v1 contributor request authorized by Identity. */ + @Post('contributor') + async contribute( + @Headers('x-life-os-data-rights-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-data-rights-signature') signature: string | undefined, + @Body() body: unknown, + ): Promise { + const request = await parseTrustedPlanningDataRightsRequest( + body, + { issuedAt, signature }, + process.env.PLANNING_DATA_RIGHTS_CONTEXT_SECRET, + { + method: 'POST', + path: '/v1/internal/data-rights/contributor', + }, + ); + try { + return await this.runtime.dataRightsContributor.handle(request); + } catch (error) { + throw toPlanningDataRightsHttpException(error); + } + } +} + /** Root NestJS module for the production planning-service process. */ @Module({ - controllers: [PlanningController], + controllers: [PlanningController, PlanningDataRightsController], providers: [ { provide: PLANNING_RUNTIME, @@ -337,8 +379,7 @@ export class PlanningController { { provide: PLANNING_SERVICE, inject: [PLANNING_RUNTIME], - useFactory: (runtime: PlanningRuntime): PlanningService => - runtime.service, + useFactory: (runtime: PlanningRuntime): PlanningService => runtime.service, }, { provide: TODAY_SYNC_SERVICE, From 87e41456ea4c6c559b67df339eea43b9aa90690c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:12:11 +0900 Subject: [PATCH 04/12] fix(planning): keep domain response types on planning boundary --- apps/planning-service/src/main.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index ebbfca45..c22c458c 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -25,17 +25,12 @@ import { planningMetrics, planningObservabilityMiddleware, } from './observability'; -import type { - DataRightsContributorResponse, - Goal, - Project, - Task, -} from './planning-data-rights'; +import type { DataRightsContributorResponse } from './planning-data-rights'; import { parseTrustedPlanningDataRightsRequest, toPlanningDataRightsHttpException, } from './planning-data-rights-http-boundary'; -import type { Goal as PlanningGoal, Project as PlanningProject, Task as PlanningTask } from './planning-domain'; +import type { Goal, Project, Task } from './planning-domain'; import { PlanningService } from './planning-domain'; import { createPlanningRuntime, PlanningRuntime } from './planning-runtime'; import type { PlanningSearchResult } from './search'; @@ -207,7 +202,7 @@ export class PlanningController { @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: { title?: unknown }, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -228,7 +223,7 @@ export class PlanningController { @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, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -249,7 +244,7 @@ export class PlanningController { @Headers('x-life-os-context-signature') signature: string | undefined, @Param('goalId') goalId: string, @Body() body: { title?: unknown }, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -272,7 +267,7 @@ export class PlanningController { @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, @Headers('x-life-os-context-signature') signature: string | undefined, @Param('goalId') goalId: string, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -296,7 +291,7 @@ export class PlanningController { @Headers('x-life-os-context-signature') signature: string | undefined, @Param('projectId') projectId: string, @Body() body: { title?: unknown }, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, @@ -319,7 +314,7 @@ export class PlanningController { @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, @Headers('x-life-os-context-signature') signature: string | undefined, @Param('projectId') projectId: string, - ): Promise { + ): Promise { try { const trustedWorkspaceId = requireTrustedWorkspaceContext( { workspaceId, issuedAt, signature }, From b7720a0c0207a8e6f47d9ac61943bee680c02c8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:12:40 +0900 Subject: [PATCH 05/12] test(planning): prove data-rights controller authority flow --- ...g-data-rights-controller-authority.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 apps/planning-service/src/planning-data-rights-controller-authority.test.ts diff --git a/apps/planning-service/src/planning-data-rights-controller-authority.test.ts b/apps/planning-service/src/planning-data-rights-controller-authority.test.ts new file mode 100644 index 00000000..1d6a1c92 --- /dev/null +++ b/apps/planning-service/src/planning-data-rights-controller-authority.test.ts @@ -0,0 +1,102 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PlanningDataRightsController } from './main'; +import { + DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + type DataRightsContributorResponse, +} from './planning-data-rights'; +import type { PlanningRuntime } from './planning-runtime'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const SECRET = randomBytes(32).toString('base64url'); +const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; + +const request = Object.freeze({ + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'export' as const, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, +}); + +/** Signs one exact Planning contributor request at the supplied Unix second. */ +function signature(issuedAt: string): string { + return createHmac('sha256', SECRET) + .update( + [ + 'life-os.planning-data-rights-context.v1', + request.contractVersion, + request.workspaceId, + request.requestedByUserId, + request.requestId, + request.operation, + '-', + issuedAt, + 'POST', + CONTRIBUTOR_PATH, + ].join('\n'), + 'utf8', + ) + .digest('base64url'); +} + +/** Creates the smallest runtime-shaped collaborator observable by the controller. */ +function controllerWith(handle: ReturnType): PlanningDataRightsController { + const runtime = { + dataRightsContributor: { handle }, + } as unknown as PlanningRuntime; + return new PlanningDataRightsController(runtime); +} + +afterEach(() => { + delete process.env.PLANNING_DATA_RIGHTS_CONTEXT_SECRET; + vi.restoreAllMocks(); +}); + +describe('Planning data-rights controller authority', () => { + it('passes only a verified normalized request to the owning contributor', async () => { + process.env.PLANNING_DATA_RIGHTS_CONTEXT_SECRET = SECRET; + const response: DataRightsContributorResponse = { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + contributor: 'planning.service', + requestId: REQUEST_ID, + operation: 'erase_preflight', + ready: true, + blockers: [], + }; + const handle = vi.fn().mockResolvedValue(response); + const controller = controllerWith(handle); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute(issuedAt, signature(issuedAt), request), + ).resolves.toEqual(response); + expect(handle).toHaveBeenCalledTimes(1); + expect(handle).toHaveBeenCalledWith(request); + }); + + it('rejects forged authority before the contributor can observe a request', async () => { + process.env.PLANNING_DATA_RIGHTS_CONTEXT_SECRET = SECRET; + const handle = vi.fn(); + const controller = controllerWith(handle); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute(issuedAt, 'A'.repeat(43), request), + ).rejects.toMatchObject({ status: 401 }); + expect(handle).not.toHaveBeenCalled(); + }); + + it('fails closed when the service verifier is not configured', async () => { + const handle = vi.fn(); + const controller = controllerWith(handle); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute(issuedAt, signature(issuedAt), request), + ).rejects.toMatchObject({ status: 503 }); + expect(handle).not.toHaveBeenCalled(); + }); +}); From 3cf2f063db616646355b2ed0ebe20931a41370e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:15:30 +0900 Subject: [PATCH 06/12] docs(planning): expose data-rights verifier configuration --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 362945b1..ffefafd3 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000 IDENTITY_SERVICE_ORIGIN=http://127.0.0.1:4101 PLANNING_SERVICE_ORIGIN=http://127.0.0.1:4102 PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes +PLANNING_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes HABIT_SERVICE_ORIGIN=http://127.0.0.1:4103 HABIT_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes AI_SERVICE_ORIGIN=http://127.0.0.1:4105 From 47ae5f839cb37d25841d0e2d0033e5766227d8be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:41:19 +0900 Subject: [PATCH 07/12] style(planning): restore canonical formatter output --- apps/planning-service/src/main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index c22c458c..b7d81818 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -374,7 +374,8 @@ export class PlanningDataRightsController { { provide: PLANNING_SERVICE, inject: [PLANNING_RUNTIME], - useFactory: (runtime: PlanningRuntime): PlanningService => runtime.service, + useFactory: (runtime: PlanningRuntime): PlanningService => + runtime.service, }, { provide: TODAY_SYNC_SERVICE, From 558cf3ef60ad768bee6aa0a77bd0309ef7886dc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:56:04 +0900 Subject: [PATCH 08/12] test(planning): bind contributor controller to actual request --- ...g-data-rights-controller-authority.test.ts | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/planning-service/src/planning-data-rights-controller-authority.test.ts b/apps/planning-service/src/planning-data-rights-controller-authority.test.ts index 1d6a1c92..26af458d 100644 --- a/apps/planning-service/src/planning-data-rights-controller-authority.test.ts +++ b/apps/planning-service/src/planning-data-rights-controller-authority.test.ts @@ -12,6 +12,10 @@ const USER_ID = '22222222-2222-4222-8222-222222222222'; const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; const SECRET = randomBytes(32).toString('base64url'); const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; +const HTTP_REQUEST = Object.freeze({ + method: 'POST', + originalUrl: CONTRIBUTOR_PATH, +}); const request = Object.freeze({ contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, @@ -62,16 +66,23 @@ describe('Planning data-rights controller authority', () => { contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, contributor: 'planning.service', requestId: REQUEST_ID, - operation: 'erase_preflight', - ready: true, - blockers: [], + operation: 'export', + schemaVersion: 'planning.data-rights.v1', + recordCount: 0, + sha256: '0'.repeat(64), + data: {}, }; const handle = vi.fn().mockResolvedValue(response); const controller = controllerWith(handle); const issuedAt = String(Math.floor(Date.now() / 1000)); await expect( - controller.contribute(issuedAt, signature(issuedAt), request), + controller.contribute( + HTTP_REQUEST, + issuedAt, + signature(issuedAt), + request, + ), ).resolves.toEqual(response); expect(handle).toHaveBeenCalledTimes(1); expect(handle).toHaveBeenCalledWith(request); @@ -84,7 +95,24 @@ describe('Planning data-rights controller authority', () => { const issuedAt = String(Math.floor(Date.now() / 1000)); await expect( - controller.contribute(issuedAt, 'A'.repeat(43), request), + controller.contribute(HTTP_REQUEST, issuedAt, 'A'.repeat(43), request), + ).rejects.toMatchObject({ status: 401 }); + expect(handle).not.toHaveBeenCalled(); + }); + + it('rejects a signature replayed onto a different actual HTTP binding', async () => { + process.env.PLANNING_DATA_RIGHTS_CONTEXT_SECRET = SECRET; + const handle = vi.fn(); + const controller = controllerWith(handle); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute( + { method: 'GET', originalUrl: CONTRIBUTOR_PATH }, + issuedAt, + signature(issuedAt), + request, + ), ).rejects.toMatchObject({ status: 401 }); expect(handle).not.toHaveBeenCalled(); }); @@ -95,7 +123,12 @@ describe('Planning data-rights controller authority', () => { const issuedAt = String(Math.floor(Date.now() / 1000)); await expect( - controller.contribute(issuedAt, signature(issuedAt), request), + controller.contribute( + HTTP_REQUEST, + issuedAt, + signature(issuedAt), + request, + ), ).rejects.toMatchObject({ status: 503 }); expect(handle).not.toHaveBeenCalled(); }); From 48d7579c3130eac13977ee629f6c8b0e99f30f1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:57:06 +0900 Subject: [PATCH 09/12] fix(planning): bind data-rights signature to actual request --- apps/planning-service/src/main.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index b7d81818..84293d65 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -12,6 +12,7 @@ import { Post, Put, Query, + Req, Res, } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; @@ -57,6 +58,11 @@ interface PassthroughResponse { setHeader(name: string, value: string): void; } +interface RequestBindingSource { + readonly method?: unknown; + readonly originalUrl?: unknown; +} + /** Returns a stable not-found problem without disclosing another tenant's state. */ function todayNotFound(): HttpException { return new HttpException( @@ -342,6 +348,7 @@ export class PlanningDataRightsController { /** Executes only the exact v1 contributor request authorized by Identity. */ @Post('contributor') async contribute( + @Req() httpRequest: RequestBindingSource, @Headers('x-life-os-data-rights-issued-at') issuedAt: string | undefined, @Headers('x-life-os-data-rights-signature') signature: string | undefined, @Body() body: unknown, @@ -351,8 +358,8 @@ export class PlanningDataRightsController { { issuedAt, signature }, process.env.PLANNING_DATA_RIGHTS_CONTEXT_SECRET, { - method: 'POST', - path: '/v1/internal/data-rights/contributor', + method: httpRequest.method, + path: httpRequest.originalUrl, }, ); try { From 31f61c5626d9dce24eef50d7a23de810d0f6527d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:57:33 +0900 Subject: [PATCH 10/12] test(planning): assert exact data-rights rejection status --- .../src/planning-data-rights-http-boundary.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/planning-service/src/planning-data-rights-http-boundary.test.ts b/apps/planning-service/src/planning-data-rights-http-boundary.test.ts index a3594214..c026f3ae 100644 --- a/apps/planning-service/src/planning-data-rights-http-boundary.test.ts +++ b/apps/planning-service/src/planning-data-rights-http-boundary.test.ts @@ -92,7 +92,7 @@ describe('Planning data-rights HTTP authority', () => { ), ).resolves.toEqual(request); - await expect( + const status = await rejectedStatus( parseTrustedPlanningDataRightsRequest( { ...request, idempotencyKey: REQUEST_ID }, { issuedAt, signature: signature(request, issuedAt) }, @@ -100,7 +100,8 @@ describe('Planning data-rights HTTP authority', () => { { method: 'POST', path: CONTRIBUTOR_PATH }, NOW_SECONDS, ), - ).rejects.toBeInstanceOf(HttpException); + ); + expect(status).toBe(401); }); it.each([ @@ -144,7 +145,7 @@ describe('Planning data-rights HTTP authority', () => { it('rejects undeclared request fields before contributor code can observe them', async () => { const issuedAt = String(NOW_SECONDS); const request = { ...exportRequest, unexpected: 'authority' }; - await expect( + const status = await rejectedStatus( parseTrustedPlanningDataRightsRequest( request, { issuedAt, signature: signature(request, issuedAt) }, @@ -152,7 +153,8 @@ describe('Planning data-rights HTTP authority', () => { { method: 'POST', path: CONTRIBUTOR_PATH }, NOW_SECONDS, ), - ).rejects.toBeInstanceOf(HttpException); + ); + expect(status).toBe(400); }); it('sanitizes contributor failures into a credential-free 503 problem', () => { From ec30110e9d2eaf8ef5803da4a9406690a6d0d9f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:58:03 +0900 Subject: [PATCH 11/12] chore(env): preserve current secret examples in sorted order --- .env.example | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index ffefafd3..937dde1d 100644 --- a/.env.example +++ b/.env.example @@ -27,11 +27,12 @@ NOTIFICATION_REMINDER_BATCH_SIZE=50 NATS_URL=nats://nats:4222 CORS_ALLOWED_ORIGINS=http://localhost:3000 IDENTITY_SERVICE_ORIGIN=http://127.0.0.1:4101 -PLANNING_SERVICE_ORIGIN=http://127.0.0.1:4102 -PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes PLANNING_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes -HABIT_SERVICE_ORIGIN=http://127.0.0.1:4103 +PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes +PLANNING_SERVICE_ORIGIN=http://127.0.0.1:4102 +HABIT_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes HABIT_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes +HABIT_SERVICE_ORIGIN=http://127.0.0.1:4103 AI_SERVICE_ORIGIN=http://127.0.0.1:4105 AI_GATEWAY_ACTIVE_KEY_ID=gateway-2026-08-a AI_GATEWAY_ACTIVE_KEY_SECRET=replace-with-at-least-32-random-bytes From 17c9e6b226ed1f4798d7b18b8a6dc533c7ac1282 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 08:51:38 +0900 Subject: [PATCH 12/12] docs(planning): explain data-rights request binding --- apps/planning-service/src/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index 84293d65..06561478 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -58,6 +58,11 @@ interface PassthroughResponse { setHeader(name: string, value: string): void; } +/** + * Provides untrusted HTTP request binding values for data-rights signature verification. + * `method` and `originalUrl` come from the inbound Nest/Express request and are + * validated before they can authorize a Planning-owned contributor operation. + */ interface RequestBindingSource { readonly method?: unknown; readonly originalUrl?: unknown;