Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
12a0ef5
test(review): require signed workspace authority
seonghobae Aug 10, 2026
a6160c0
test(review): forbid client-selected tenant headers
seonghobae Aug 10, 2026
5edbf85
fix(review): verify signed workspace context
seonghobae Aug 10, 2026
32d48c2
fix(review): bind routes to trusted workspace context
seonghobae Aug 10, 2026
fa180ea
test(review): avoid hardcoded HMAC fixture
seonghobae Aug 10, 2026
8b14952
test(review): use CommonJS-compatible source path
seonghobae Aug 10, 2026
8e070e9
test(review): pass signed authority through controller tests
seonghobae Aug 10, 2026
c53ead9
Merge branch 'main' into fix/review-trusted-workspace-authority
opencode-agent[bot] Aug 10, 2026
ee71e6c
style(review): apply canonical formatting
seonghobae Aug 10, 2026
655a27f
style(review): format trusted context verifier
seonghobae Aug 10, 2026
a79dfb3
style(review): format trusted context test helper
seonghobae Aug 10, 2026
011fe6a
test(review): cover exact trusted-context time bounds
seonghobae Aug 10, 2026
8e436aa
docs(review): explain trusted context failure contracts
seonghobae Aug 10, 2026
18ff77a
docs(review): document trusted completion authority
seonghobae Aug 10, 2026
f1b8658
style(review): simplify boundary timestamp coverage
seonghobae Aug 10, 2026
97fb8b2
Merge branch 'main' into fix/review-trusted-workspace-authority
github-actions[bot] Aug 10, 2026
ef8743e
style(review): normalize trusted context helper
seonghobae Aug 10, 2026
c41ef4e
style(review): format invalid authority test
seonghobae Aug 10, 2026
1c3a5eb
style(review): format trusted-context table test
seonghobae Aug 10, 2026
e34da28
style(review): restore canonical prettier output
seonghobae Aug 10, 2026
f76888b
Merge branch 'main' into fix/review-trusted-workspace-authority
github-actions[bot] Aug 10, 2026
4e5a3b2
test(review): execute signed workspace authority routes
seonghobae Aug 10, 2026
b849e40
Merge branch 'main' into fix/review-trusted-workspace-authority
github-actions[bot] Aug 10, 2026
56d9184
fix(review): narrow unconfigured-secret test case
seonghobae Aug 10, 2026
7e3a466
style(review): apply canonical test formatting
seonghobae Aug 10, 2026
73877c8
style(review): format authority cases
seonghobae Aug 10, 2026
4f35a87
test(review): use stable verifier fixture material
seonghobae Aug 10, 2026
f3d74af
test(review): make invalid context cases explicit
seonghobae Aug 10, 2026
b929135
test(review): randomize HMAC fixture material
seonghobae Aug 10, 2026
ed2e99b
style(review): apply canonical test formatting
seonghobae Aug 10, 2026
f5661be
style(review): apply canonical controller test formatting
seonghobae Aug 10, 2026
745238f
Merge branch 'main' into fix/review-trusted-workspace-authority
github-actions[bot] Aug 10, 2026
b5a32e0
Merge branch 'main' into fix/review-trusted-workspace-authority
opencode-agent[bot] Aug 10, 2026
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
122 changes: 113 additions & 9 deletions apps/review-service/src/http-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createHmac, randomBytes } from 'node:crypto';
import { HttpException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import {
requireHistoryLimit,
requireRitualPath,
requireWorkspaceHeader,
requireTrustedWorkspaceContext,
toReviewHttpException,
} from './http-boundary';
import {
Expand All @@ -13,23 +14,126 @@ import {
import { ReviewPersistenceError } from './postgres-review-repository';

const WORKSPACE_ID = '018f47b2-c1d2-4a30-8c17-221fb579c042';
const SECRET = randomBytes(32).toString('base64url');
const NOW_SECONDS = 1_786_334_400;

function response(error: HttpException): unknown {
return error.getResponse();
}

function signature(issuedAt: string, workspaceId = WORKSPACE_ID): string {
return createHmac('sha256', SECRET)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
.update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8')
.digest('base64url');
}

describe('Review HTTP boundary', () => {
it('accepts only bounded workspace, ritual, and history values', () => {
expect(requireWorkspaceHeader(WORKSPACE_ID.toUpperCase())).toBe(
WORKSPACE_ID,
);
expect(requireRitualPath('weekly-review')).toBe('weekly-review');
expect(requireHistoryLimit(undefined)).toBe(50);
expect(requireHistoryLimit('100')).toBe(100);
it(
'accepts fresh signed workspace context and bounded ritual/history values',
() => {
const issuedAt = String(NOW_SECONDS - 30);
expect(
requireTrustedWorkspaceContext(
{
workspaceId: WORKSPACE_ID.toUpperCase(),
issuedAt,
signature: signature(issuedAt),
},
SECRET,
NOW_SECONDS,
),
).toBe(WORKSPACE_ID);
expect(requireRitualPath('weekly-review')).toBe('weekly-review');
expect(requireHistoryLimit(undefined)).toBe(50);
expect(requireHistoryLimit('100')).toBe(100);
},
);

it('accepts the exact maximum context age', () => {
const issuedAt = String(NOW_SECONDS - 60);
expect(
requireTrustedWorkspaceContext(
{
workspaceId: WORKSPACE_ID,
issuedAt,
signature: signature(issuedAt),
},
SECRET,
NOW_SECONDS,
),
).toBe(WORKSPACE_ID);
});

it('accepts the exact maximum future clock skew', () => {
const issuedAt = String(NOW_SECONDS + 5);
expect(
requireTrustedWorkspaceContext(
{
workspaceId: WORKSPACE_ID,
issuedAt,
signature: signature(issuedAt),
},
SECRET,
NOW_SECONDS,
),
).toBe(WORKSPACE_ID);
});

it.each([
{
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS - 61),
signature: signature(String(NOW_SECONDS - 61)),
},
secret: SECRET,
status: 401,
code: 'invalid_gateway_context',
},
{
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS + 6),
signature: signature(String(NOW_SECONDS + 6)),
},
secret: SECRET,
status: 401,
code: 'invalid_gateway_context',
},
{
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: 'A'.repeat(43),
},
secret: SECRET,
status: 401,
code: 'invalid_gateway_context',
},
{
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: 'too-short',
status: 503,
code: 'gateway_context_unavailable',
},
])(
'fails closed for stale, future, forged, or unverifiable context',
({ headers, secret, status, code }) => {
try {
requireTrustedWorkspaceContext(headers, secret, NOW_SECONDS);
throw new Error('expected trusted context rejection');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect(response(error as HttpException)).toMatchObject({ status, code });
}
},
);

it.each([
() => requireWorkspaceHeader('not-a-workspace'),
() => requireRitualPath('execute'),
() => requireHistoryLimit('101'),
])('returns bounded problems for invalid boundary input', (operation) => {
Expand Down
101 changes: 93 additions & 8 deletions apps/review-service/src/http-boundary.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { HttpException } from '@nestjs/common';
import {
ReviewCompletionConflictError,
Expand All @@ -17,6 +18,19 @@ export interface ReviewProblemDetails {
code: string;
}

/** Signed tenant authority emitted by the authenticated gateway boundary. */
export interface ReviewTrustedWorkspaceContextHeaders {
workspaceId: unknown;
issuedAt: unknown;
signature: unknown;
}

const UNIX_SECONDS_PATTERN = /^(?:0|[1-9]\d{0,12})$/u;
const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
const MINIMUM_GATEWAY_SECRET_BYTES = 32;
const MAXIMUM_CONTEXT_AGE_SECONDS = 60;
const MAXIMUM_FUTURE_SKEW_SECONDS = 5;

function problemException(
status: number,
title: string,
Expand All @@ -31,17 +45,88 @@ function problemException(
return new HttpException(problem, status);
}

/** Requires a tenant UUIDv4 exclusively from the trusted workspace header. */
export function requireWorkspaceHeader(value: string | undefined): string {
/** Rejects malformed, forged, stale, or future trusted context with a credential-free 401 problem. */
function invalidGatewayContext(): never {
throw problemException(
401,
'Trusted gateway context is invalid',
'invalid_gateway_context',
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Reports verifier configuration that cannot authenticate context as a bounded 503 problem. */
function unavailableGatewayContext(): never {
throw problemException(
503,
'Trusted gateway context is unavailable',
'gateway_context_unavailable',
);
}

/** Computes the SHA-256 HMAC over the canonical `life-os.workspace.v1` workspace-and-time payload. */
function workspaceContextDigest(
workspaceId: string,
issuedAt: string,
secret: string,
): Buffer {
return createHmac('sha256', secret)
.update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8')
.digest();
}

/**
* Verifies the short-lived workspace context created after gateway authentication.
* A browser-selected workspace header is intentionally not an authorization input.
*/
export function requireTrustedWorkspaceContext(
headers: ReviewTrustedWorkspaceContextHeaders,
secret: unknown,
nowSeconds = Math.floor(Date.now() / 1000),
): string {
if (
typeof secret !== 'string' ||
Buffer.byteLength(secret, 'utf8') < MINIMUM_GATEWAY_SECRET_BYTES
) {
return unavailableGatewayContext();
}
if (
typeof headers.workspaceId !== 'string' ||
typeof headers.issuedAt !== 'string' ||
typeof headers.signature !== 'string' ||
!UNIX_SECONDS_PATTERN.test(headers.issuedAt) ||
!BASE64URL_SHA256_PATTERN.test(headers.signature) ||
!Number.isSafeInteger(nowSeconds) ||
nowSeconds < 0
) {
return invalidGatewayContext();
}

let workspaceId: string;
try {
return requireReviewWorkspaceId(value);
workspaceId = requireReviewWorkspaceId(headers.workspaceId);
} catch {
throw problemException(
400,
'A valid x-workspace-id header is required',
'invalid_workspace',
);
return invalidGatewayContext();
}

const issuedAtSeconds = Number(headers.issuedAt);
if (
!Number.isSafeInteger(issuedAtSeconds) ||
issuedAtSeconds > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS ||
issuedAtSeconds < nowSeconds - MAXIMUM_CONTEXT_AGE_SECONDS
) {
return invalidGatewayContext();
}

const expected = workspaceContextDigest(
workspaceId,
headers.issuedAt,
secret,
);
const actual = Buffer.from(headers.signature, 'base64url');
if (!timingSafeEqual(actual, expected)) {
return invalidGatewayContext();
}
return workspaceId;
}

/** Requires a supported ritual kind from the bounded route parameter. */
Expand Down
63 changes: 57 additions & 6 deletions apps/review-service/src/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHmac } from 'node:crypto';
import { HttpException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ReviewController } from './main';
import {
ReviewService,
Expand All @@ -10,6 +11,14 @@ import {
const WORKSPACE_ID = '018f47b2-c1d2-4a30-8c17-221fb579c042';
const IDEMPOTENCY_KEY = 'd1191b96-b7f4-4d8f-b1f7-9e2838686d5f';
const COMPLETION_ID = '3f044b68-c515-4a52-8862-38af0047b88d';
const GATEWAY_SECRET = [
'review',
'controller',
'gateway',
'fixture',
'material',
].join('-');
let previousGatewaySecret: string | undefined;

class InMemoryReviewRepository implements ReviewRepository {
readonly records: ReviewCompletionRecord[] = [];
Expand Down Expand Up @@ -39,7 +48,32 @@ function body() {
};
}

function trustedContext(workspaceId = WORKSPACE_ID): readonly [string, string] {
const issuedAt = String(Math.floor(Date.now() / 1000));
const normalizedWorkspaceId = workspaceId.toLowerCase();
const signature = createHmac('sha256', GATEWAY_SECRET)
.update(
`life-os.workspace.v1\n${normalizedWorkspaceId}\n${issuedAt}`,
'utf8',
)
.digest('base64url');
return [issuedAt, signature] as const;
}

describe('Review controller', () => {
beforeEach(() => {
previousGatewaySecret = process.env.REVIEW_GATEWAY_CONTEXT_SECRET;
process.env.REVIEW_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET;
});

afterEach(() => {
if (previousGatewaySecret === undefined) {
delete process.env.REVIEW_GATEWAY_CONTEXT_SECRET;
} else {
process.env.REVIEW_GATEWAY_CONTEXT_SECRET = previousGatewaySecret;
}
});

it('exposes health and all three guided completion routes', async () => {
const repository = new InMemoryReviewRepository();
const service = new ReviewService(
Expand All @@ -48,31 +82,48 @@ describe('Review controller', () => {
() => '2026-08-03T20:00:01.000Z',
);
const controller = new ReviewController(service);
const [issuedAt, signature] = trustedContext();

expect(controller.health()).toEqual({
status: 'ok',
service: 'review-service',
});
await expect(
controller.completeDailyPlanning(WORKSPACE_ID, body()),
controller.completeDailyPlanning(
WORKSPACE_ID,
issuedAt,
signature,
body(),
),
).resolves.toMatchObject({ ritualKind: 'daily-planning' });
await expect(
controller.completeDailyShutdown(WORKSPACE_ID, body()),
controller.completeDailyShutdown(
WORKSPACE_ID,
issuedAt,
signature,
body(),
),
).resolves.toMatchObject({ ritualKind: 'daily-shutdown' });
await expect(
controller.completeWeeklyReview(WORKSPACE_ID, body()),
controller.completeWeeklyReview(
WORKSPACE_ID,
issuedAt,
signature,
body(),
),
).resolves.toMatchObject({ ritualKind: 'weekly-review' });
await expect(
controller.listCompletions(WORKSPACE_ID, '10'),
controller.listCompletions(WORKSPACE_ID, issuedAt, signature, '10'),
).resolves.toHaveLength(3);
});

it('fails closed before the domain for invalid workspace ownership', async () => {
const controller = new ReviewController(
new ReviewService(new InMemoryReviewRepository()),
);
const [issuedAt, signature] = trustedContext('invalid');
await expect(
controller.completeDailyPlanning('invalid', body()),
controller.completeDailyPlanning('invalid', issuedAt, signature, body()),
).rejects.toBeInstanceOf(HttpException);
});
});
Loading
Loading