Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes
PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-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
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
Expand Down
51 changes: 50 additions & 1 deletion apps/planning-service/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
Expand All @@ -25,6 +26,11 @@ import {
planningMetrics,
planningObservabilityMiddleware,
} from './observability';
import type { DataRightsContributorResponse } from './planning-data-rights';
import {
parseTrustedPlanningDataRightsRequest,
toPlanningDataRightsHttpException,
} from './planning-data-rights-http-boundary';
import type { Goal, Project, Task } from './planning-domain';
import { PlanningService } from './planning-domain';
import { createPlanningRuntime, PlanningRuntime } from './planning-runtime';
Expand Down Expand Up @@ -52,6 +58,16 @@ 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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/** Returns a stable not-found problem without disclosing another tenant's state. */
function todayNotFound(): HttpException {
return new HttpException(
Expand Down Expand Up @@ -326,9 +342,42 @@ 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(
@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,
): Promise<DataRightsContributorResponse> {
const request = await parseTrustedPlanningDataRightsRequest(
body,
{ issuedAt, signature },
process.env.PLANNING_DATA_RIGHTS_CONTEXT_SECRET,
{
method: httpRequest.method,
path: httpRequest.originalUrl,
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 HTTP_REQUEST = Object.freeze({
method: 'POST',
originalUrl: CONTRIBUTOR_PATH,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<typeof vi.fn>): 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: '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(
HTTP_REQUEST,
issuedAt,
signature(issuedAt),
request,
),
).resolves.toEqual(response);
expect(handle).toHaveBeenCalledTimes(1);
expect(handle).toHaveBeenCalledWith(request);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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(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();
});

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(
HTTP_REQUEST,
issuedAt,
signature(issuedAt),
request,
),
).rejects.toMatchObject({ status: 503 });
expect(handle).not.toHaveBeenCalled();
});
});
Loading
Loading