Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
7a878a5
fix(review): verify signed workspace context
seonghobae Aug 10, 2026
75a4a7d
test(review): prove signed workspace context
seonghobae Aug 10, 2026
2dbc4f3
test(review): exercise signed controller boundary
seonghobae Aug 10, 2026
4e2866a
fix(review): enforce signed context on all routes
seonghobae Aug 10, 2026
08bd564
test(review): execute signed workspace authority routes
seonghobae Aug 10, 2026
c8a2f05
style(review): format trusted context tests
seonghobae Aug 10, 2026
15f22d5
Merge branch 'main' into fix/review-trusted-workspace-authority-curre…
github-actions[bot] Aug 10, 2026
d4c62cd
style(review): format controller authority tests
seonghobae Aug 10, 2026
825920a
style(review): apply canonical HTTP boundary formatting
seonghobae Aug 10, 2026
131a6d7
style(review): restore canonical controller test formatting
seonghobae Aug 10, 2026
910d115
style(review): apply canonical test formatting
seonghobae Aug 10, 2026
ef9d17e
Merge branch 'main' into fix/review-trusted-workspace-authority-curre…
github-actions[bot] Aug 10, 2026
767234c
test(review): reject non-canonical gateway signatures
seonghobae Aug 10, 2026
220065a
test(review): make forged context deterministic
seonghobae Aug 10, 2026
c188514
fix(review): require canonical gateway signatures
seonghobae Aug 10, 2026
29192e3
Merge branch 'main' into fix/review-trusted-workspace-authority-curre…
opencode-agent[bot] Aug 10, 2026
ff36512
fix(review): make signature tamper test type-safe
seonghobae Aug 10, 2026
779fa7f
test(review): prefer behavioral authority assertions
seonghobae Aug 10, 2026
b164e16
test(review): cover trusted-context rejection branches
seonghobae Aug 10, 2026
c281ee2
fix(review): remove unreachable context branch
seonghobae Aug 10, 2026
89f167f
test(review): require fail-closed startup readiness
seonghobae Aug 10, 2026
8495112
refactor(review): centralize gateway secret validation
seonghobae Aug 10, 2026
228b16e
fix(review): fail closed before serving without gateway secret
seonghobae Aug 10, 2026
0ece26b
Merge branch 'main' into fix/review-trusted-workspace-authority-curre…
opencode-agent[bot] Aug 10, 2026
c4dd759
test(review): assert fail-closed gateway configuration status
seonghobae Aug 10, 2026
b8ecb22
Merge branch 'main' into fix/review-trusted-workspace-authority-curre…
opencode-agent[bot] Aug 10, 2026
49120ae
style(review): apply prettier formatting
seonghobae Aug 10, 2026
714e351
style(review): restore prettier-canonical listen call
seonghobae Aug 10, 2026
35dc131
style(review): restore canonical listen formatting
seonghobae Aug 10, 2026
c6881ad
style(review): normalize test import formatting
seonghobae Aug 10, 2026
7627133
style(review): normalize authority assertion formatting
seonghobae Aug 10, 2026
0efe6f6
style(review): satisfy service formatting gate
seonghobae Aug 10, 2026
58c71f3
style(review): restore prettier-compatible authority assertion
seonghobae Aug 10, 2026
ba5550c
style(review): format service configuration boundary
seonghobae Aug 10, 2026
342fafb
style(review): format authority rejection boundary
seonghobae Aug 10, 2026
18d8cc6
style(review): align authority assertion with formatter
seonghobae Aug 10, 2026
c7b3f1a
style(review): align HTTP boundary test formatting
seonghobae Aug 10, 2026
d6efe6f
style(review): format trusted context regression
seonghobae Aug 10, 2026
985a665
style(review): format base64url alias regression
seonghobae Aug 10, 2026
63ebe57
style(review): satisfy package Prettier gate
seonghobae Aug 10, 2026
9897683
test(review): simplify canonical signature alias fixture
seonghobae Aug 10, 2026
ed2ddbe
style(review): apply canonical boundary test formatting
seonghobae Aug 10, 2026
d153a70
Merge branch 'main' into fix/review-trusted-workspace-authority-curre…
github-actions[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
332 changes: 323 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,336 @@ 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;
const BASE64URL_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';

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

function signature(issuedAt: string, workspaceId = WORKSPACE_ID): string {
return createHmac('sha256', SECRET)
.update(`life-os.workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8')
.digest('base64url');
}

function expectTrustedContextRejection(
headers: { workspaceId: unknown; issuedAt: unknown; signature: unknown },
secret: unknown,
nowSeconds: number,
status: number,
code: string,
): void {
const operation = () =>
requireTrustedWorkspaceContext(headers, secret, nowSeconds);
expect(operation).toThrow(HttpException);

let thrown: unknown;
try {
operation();
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(HttpException);
expect(response(thrown as HttpException)).toMatchObject({ status, code });
}

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(
'rejects a non-canonical base64url alias for the same signature bytes',
() => {
const issuedAt = String(NOW_SECONDS);
const canonical = signature(issuedAt);
const finalIndex = BASE64URL_ALPHABET.indexOf(canonical.at(-1) ?? '');
expect(finalIndex).toBeGreaterThanOrEqual(0);
expect(finalIndex % 4).toBe(0);
const aliasCharacter = BASE64URL_ALPHABET[finalIndex + 1];
expect(aliasCharacter).toBeDefined();
const nonCanonical = `${canonical.slice(0, -1)}${aliasCharacter}`;
expect(Buffer.from(nonCanonical, 'base64url')).toEqual(
Buffer.from(canonical, 'base64url'),
);

expect(() =>
requireTrustedWorkspaceContext(
{
workspaceId: WORKSPACE_ID,
issuedAt,
signature: nonCanonical,
},
SECRET,
NOW_SECONDS,
),
).toThrow(HttpException);
},
);

it.each([
{
name: 'stale timestamp',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS - 61),
signature: signature(String(NOW_SECONDS - 61)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'future timestamp',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS + 6),
signature: signature(String(NOW_SECONDS + 6)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'forged signature',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: 'A'.repeat(43),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'short verifier secret',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: 'too-short',
nowSeconds: NOW_SECONDS,
status: 503,
code: 'gateway_context_unavailable',
},
{
name: 'missing verifier secret',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: undefined,
nowSeconds: NOW_SECONDS,
status: 503,
code: 'gateway_context_unavailable',
},
{
name: 'missing workspace header',
headers: {
workspaceId: undefined,
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'non-string workspace header',
headers: {
workspaceId: 123,
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'invalid workspace UUID',
headers: {
workspaceId: 'not-a-uuid',
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'missing issued-at header',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: undefined,
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'non-string issued-at header',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: 123,
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'nonnumeric issued-at header',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: 'not-a-timestamp',
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'missing signature header',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: undefined,
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'non-string signature header',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: 123,
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'wrong-length signature',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: 'A'.repeat(42),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'invalid base64url signature characters',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: '!'.repeat(43),
},
secret: SECRET,
nowSeconds: NOW_SECONDS,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'non-integer verifier clock',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: Number.NaN,
status: 401,
code: 'invalid_gateway_context',
},
{
name: 'negative verifier clock',
headers: {
workspaceId: WORKSPACE_ID,
issuedAt: String(NOW_SECONDS),
signature: signature(String(NOW_SECONDS)),
},
secret: SECRET,
nowSeconds: -1,
status: 401,
code: 'invalid_gateway_context',
},
])(
'fails closed for $name',
({ headers, secret, nowSeconds, status, code }) => {
expectTrustedContextRejection(headers, secret, nowSeconds, status, code);
},
);

it.each([
() => requireWorkspaceHeader('not-a-workspace'),
() => requireRitualPath('execute'),
() => requireHistoryLimit('101'),
])('returns bounded problems for invalid boundary input', (operation) => {
Expand Down
Loading