Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
113 commits
Select commit Hold shift + click to select a range
8b1da24
test(planning): define durable Today synchronization contract
seonghobae Aug 9, 2026
d371945
feat(planning): implement durable Today domain contract
seonghobae Aug 9, 2026
0b49064
test(planning): define atomic Today persistence contract
seonghobae Aug 9, 2026
7b612f1
feat(planning): persist Today with atomic optimistic writes
seonghobae Aug 9, 2026
a0683aa
feat(planning): add durable Today persistence schema
seonghobae Aug 9, 2026
d59254c
feat(planning): compose durable Today runtime
seonghobae Aug 9, 2026
68eebee
test(planning): define Today HTTP precondition boundary
seonghobae Aug 9, 2026
f96671e
feat(planning): enforce authenticated Today HTTP preconditions
seonghobae Aug 9, 2026
74304cd
feat(planning): expose authenticated Today sync API
seonghobae Aug 9, 2026
ab09f5d
fix(planning): avoid duplicate migration ordinal
seonghobae Aug 9, 2026
32774fa
fix(planning): order durable Today migration after repository constra…
seonghobae Aug 9, 2026
185c3b6
test(planning): verify durable Today PostgreSQL behavior
seonghobae Aug 9, 2026
c22afed
test(web): define authenticated Today sync BFF contract
seonghobae Aug 9, 2026
864eaea
feat(web): add authenticated durable Today BFF
seonghobae Aug 9, 2026
bf04a5f
feat(web): expose same-origin Today sync route
seonghobae Aug 9, 2026
a202eae
test(web): include durable Today sync in quality gates
seonghobae Aug 9, 2026
fdc2ef0
test(web): define explicit local-to-workspace Today migration
seonghobae Aug 9, 2026
ec00594
feat(web): implement explicit local-to-workspace Today migration
seonghobae Aug 9, 2026
89a5065
test(web): verify workspace Today migration path
seonghobae Aug 9, 2026
8e0758e
feat(web): label local and durable Today states
seonghobae Aug 9, 2026
ce60e74
feat(web): localize durable Today sync states
seonghobae Aug 9, 2026
28df9b5
feat(web): add explicit durable Today controls
seonghobae Aug 9, 2026
83df8e2
feat(web): connect explicit workspace Today controls
seonghobae Aug 9, 2026
f9653ce
test(web): verify explicit durable Today migration
seonghobae Aug 9, 2026
28f8a27
test(ci): require browser journey verification
seonghobae Aug 9, 2026
1f0754a
ci(web): execute Playwright buyer journeys
seonghobae Aug 9, 2026
ac0218f
test(web): verify stale Today reconciliation
seonghobae Aug 9, 2026
8edf7f3
docs(research): ground durable Today synchronization
seonghobae Aug 9, 2026
02f9a28
docs(planning): add durable Today operations runbook
seonghobae Aug 9, 2026
8cc9d42
docs(planning): design durable Today workspace sync
seonghobae Aug 9, 2026
c6303a5
docs(planning): plan durable Today workspace sync
seonghobae Aug 9, 2026
64218f4
test(web): preserve local Today across retry
seonghobae Aug 9, 2026
2c15d21
test(web): scope accessibility live-region assertions
seonghobae Aug 9, 2026
71a3ae4
test(web): bind durable search acceptance to semantic controls
seonghobae Aug 9, 2026
ea633c3
test(web): follow the current Today capture label
seonghobae Aug 9, 2026
bb06f3a
style(planning): restore canonical provider formatting
seonghobae Aug 9, 2026
3c46feb
style(planning): format concurrent Today assertion
seonghobae Aug 9, 2026
c3fa5a7
fix(web): remove unreachable Today save disabled check
seonghobae Aug 9, 2026
607ddb4
fix(web): narrow optional Today request body
seonghobae Aug 9, 2026
3b093be
test(web): make Today fetch fixtures exact-optional safe
seonghobae Aug 9, 2026
4e473d4
test(web): preserve explicit optional fetch init in Today sync
seonghobae Aug 9, 2026
0eadfb6
style(web): format Today workspace synchronization
seonghobae Aug 9, 2026
d77e30c
fix(planning): acquire Today advisory locks in order
seonghobae Aug 9, 2026
9727606
test(planning): enforce deterministic Today lock order
seonghobae Aug 9, 2026
79555c3
test(planning): stress identical Today replay concurrency
seonghobae Aug 9, 2026
a9ae43d
Merge 79555c3896d92a5609f90b473cf2cf0d809be467 into 38dde3fa44f6ad24d…
seonghobae Aug 9, 2026
57110d1
style(web): format Today sync boundary
seonghobae Aug 9, 2026
a5a2586
style(web): format Today workspace client
seonghobae Aug 9, 2026
86e9507
style(web): format Today sync tests
seonghobae Aug 9, 2026
5939d11
style(web): format Today workspace tests
seonghobae Aug 9, 2026
4f1c251
test(planning): match SQL client result contract
seonghobae Aug 9, 2026
24dd54e
test(planning): isolate lock-order integration fixture
seonghobae Aug 9, 2026
c087cdf
test(planning): move lock-order fixture out of production source
seonghobae Aug 9, 2026
ec3235b
test(planning): remove dynamic SQL from lock-order fixture
seonghobae Aug 9, 2026
eb13ee3
style(web): format Today workspace sync client
seonghobae Aug 9, 2026
a30e0b2
style(web): format Today sync boundary
seonghobae Aug 9, 2026
1df3a2d
style(web): format Today sync tests
seonghobae Aug 9, 2026
451ffcb
fix(web): parse strong ETags with valid regex syntax
seonghobae Aug 9, 2026
3ca84c6
fix(web): use valid strong ETag parser in BFF
seonghobae Aug 9, 2026
ceb638e
Merge branch 'main' into feat/durable-today-sync
opencode-agent[bot] Aug 9, 2026
6807483
feat(identity): enforce recent-authentication policy for data rights …
seonghobae Aug 9, 2026
0984658
test(planning): exercise Today concurrency independently
seonghobae Aug 9, 2026
19aaa88
fix(planning): make Today date constraint DateStyle-independent
seonghobae Aug 9, 2026
e9602d6
fix(planning): type Today SQL parameters explicitly
seonghobae Aug 9, 2026
be5e6d9
test(identity): bind data-rights ownership to recent authenticated se…
seonghobae Aug 9, 2026
f8575d7
fix(planning): serialize Today writes in explicit transactions
seonghobae Aug 9, 2026
cb69c71
fix(planning): pin Today transactions to one PostgreSQL connection
seonghobae Aug 9, 2026
931b783
test(planning): cover transactional Today persistence
seonghobae Aug 9, 2026
67f2e96
test(planning): verify transaction lifecycle and cleanup
seonghobae Aug 9, 2026
386d631
test(ci): bind Today concurrency to contributor head
seonghobae Aug 9, 2026
e38cdc4
test(planning): remove SQL-text lock-order surrogate
seonghobae Aug 9, 2026
aed4878
ci: capture exact Today prettier patch
seonghobae Aug 9, 2026
8ec3175
test(web): expose in-flight Today save overwrite
seonghobae Aug 9, 2026
17a0de7
fix(web): preserve edits during Today save
seonghobae Aug 9, 2026
2714b6d
ci: apply bounded Today formatting
seonghobae Aug 9, 2026
e53349e
ci: expose read-only Today format patch
seonghobae Aug 9, 2026
09d65b9
style(web): format Today sync client
seonghobae Aug 9, 2026
518fdce
style(web): format Today workspace sync
seonghobae Aug 9, 2026
e9a68b1
style(web): format Today sync tests
seonghobae Aug 9, 2026
935a8b9
chore(ci): remove Today format diagnostic
seonghobae Aug 9, 2026
bc3af48
test(planning): reject malformed Today lookup scope before SQL
seonghobae Aug 9, 2026
12de7f7
ci: verify Today lookup validation red
seonghobae Aug 9, 2026
ece37c5
ci: apply verified Today lookup validation
seonghobae Aug 9, 2026
f5f57eb
fix(ci): compare repair lease to contributor head
seonghobae Aug 9, 2026
d73afed
fix(ci): include staged self-removal in repair lease
seonghobae Aug 9, 2026
945ada5
test(planning): reject malformed Today repository lookups
seonghobae Aug 9, 2026
e4076fc
test(planning): distinguish corrupted Today persistence
seonghobae Aug 9, 2026
2250163
fix(planning): classify invalid Today persistence separately
seonghobae Aug 9, 2026
23f768b
fix(planning): validate Today lookup scope before SQL
seonghobae Aug 9, 2026
c3b7f21
test(planning): fail explicitly on leaked Today connections
seonghobae Aug 9, 2026
f3f2fcf
chore(ci): remove superseded Today repair workflow
seonghobae Aug 9, 2026
ea52117
fix(web): lint complete source globs
seonghobae Aug 9, 2026
a9b4b9d
test(planning): make Today concurrency cleanup deterministic
seonghobae Aug 9, 2026
14e700f
docs(today): classify standards publication status
seonghobae Aug 9, 2026
fec9ce3
docs(today): align validation and readiness plan
seonghobae Aug 9, 2026
a8e8361
test(planning): define shared Today invariants contract
seonghobae Aug 9, 2026
a5f53a2
feat(planning): centralize Today validation invariants
seonghobae Aug 9, 2026
a4213da
refactor(planning): reuse shared Today invariants
seonghobae Aug 9, 2026
37f91b7
refactor(planning): share Today invariants with persistence
seonghobae Aug 9, 2026
e490723
refactor(planning): reuse Today invariants at HTTP boundary
seonghobae Aug 9, 2026
b3a4021
test(planning): import Today persistence error from domain boundary
seonghobae Aug 9, 2026
8b928af
test(planning): use shared Today persistence error boundary
seonghobae Aug 9, 2026
5767be7
fix(web): preserve destructive-copy warning in Korean
seonghobae Aug 9, 2026
2969193
test(ci): bind browser acceptance to its workflow job
seonghobae Aug 9, 2026
39fc503
test(web): close Today BFF authority branch gaps
seonghobae Aug 9, 2026
63842d0
ci(web): stage one-shot canonical formatter
seonghobae Aug 9, 2026
55d9a51
style(web): apply canonical formatter output
github-actions[bot] Aug 9, 2026
2cd8c76
feat(identity): persist data-rights request receipts (#138)
seonghobae Aug 9, 2026
9e2ac42
test(web): expose Today media-type and conflict coupling
seonghobae Aug 9, 2026
d9002ae
test(web): execute Today review regressions
seonghobae Aug 9, 2026
9cc7f5e
fix(web): preserve existing test dependencies
seonghobae Aug 9, 2026
297057a
fix(web): normalize Today media types and conflict semantics
seonghobae Aug 9, 2026
8260c6b
Merge 297057ab3007de67a9db82855c22a0be44004fce into 2cd8c766d2c835893…
seonghobae Aug 9, 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
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,48 @@ jobs:
jq -e '(.streams | type) == "number" and (.consumers | type) == "number"' \
>/dev/null

today-concurrency:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
PLANNING_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test
services:
postgres:
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
env:
POSTGRES_DB: life_os_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d life_os_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout exact contributor head
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

- name: Enable Corepack
run: corepack enable

- name: Install reproducible dependencies
run: pnpm install --frozen-lockfile

- name: Run Today concurrency regression
run: >-
pnpm --filter @life-os/planning-service exec vitest run
tests/postgres-today-lock-order.integration.test.ts

validate:
needs: compose_runtime
runs-on: ubuntu-latest
Expand Down Expand Up @@ -128,3 +170,29 @@ jobs:

- name: Validate Compose
run: docker compose config --quiet

browser-acceptance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

- name: Enable Corepack
run: corepack enable

- name: Install reproducible dependencies
run: pnpm install --frozen-lockfile

- name: Install Chromium for Playwright
run: pnpm --filter @life-os/web exec playwright install --with-deps chromium

- name: Run browser journey acceptance
run: pnpm --filter @life-os/web test:e2e
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to LifeOS are documented in this file.

### Added

- A durable PostgreSQL data-rights request ledger with workspace-scoped idempotency, immutable request and terminal receipt digests, one-way completion state, and real integration evidence that erasure receipts survive removal of the source workspace and user.
- Migration `0006_data_rights_request_ledger.sql` for the service-owned identity ledger, retaining only bounded opaque authority references and digest/status/timestamp evidence rather than exported personal payloads.
- An hourly and manually dispatchable NVIDIA NIM live-conformance harness that pins contextual-orchestrator to an exact reviewed commit, compares strong single-route reasoning with bounded conducted workflows, and retains only validated credential-free quality, safety, orchestration, usage, and ablation evidence.
- A versioned, immutable AI proposal quality evaluator that separates production validity, semantic operation conformance, evidence grounding, benign utility, forbidden-text leakage, and prompt-injection resistance across realistic English, Korean, temporal, empty-context, completed-item, and adversarial fixtures.
- An explicit `contextual-orchestrator` proposal-model mode with bounded OpenAI-compatible transport, strict structured output, model provenance, and an independent local rule-based default.
Expand All @@ -23,6 +25,7 @@ All notable changes to LifeOS are documented in this file.

### Fixed

- Data-rights request-ID and idempotency collisions now resolve through stable credential-free domain conflicts instead of exposing raw PostgreSQL uniqueness errors, including ambiguous dual-collision evidence.
- The OpenCode development loop now prevents project settings from overriding its pinned offline NVIDIA model, records catalog failures accurately, parses the accepted candidate's exact Compose file outside the model account, and requires digest-pinned PostgreSQL queries plus NATS JetStream probes in pull-request CI.
- Live contextual-orchestrator responses now classify successful empty bodies as evaluation failures, emit exactly one terminal observation, canonicalize retained timestamps safely, and preserve null metric denominators instead of fabricating deltas.
- Stale AI proposal revision conflicts now belong to the technology-independent audit domain while the PostgreSQL adapter preserves its compatibility export.
Expand All @@ -33,6 +36,7 @@ All notable changes to LifeOS are documented in this file.

### Security

- The data-rights request ledger keeps personal export payloads out of durable audit rows and normalizes primary-key/idempotency collisions before dependency errors can escape the service boundary.
- The commercial-development model account no longer performs Docker commands, never receives Docker-socket authority, and cannot trigger provider-wide model discovery through the credential bridge.
- The scheduled live-model harness uses only `NVIDIA_NIM_API_KEY`, seeds it through the encrypted contextual-orchestrator credential registry, installs hash-locked dependencies from an exact commit, confines LifeOS traffic to loopback, allowlists NVIDIA NIM egress, and excludes provider credentials, prompts, responses, traces, and hidden reasoning from retained artifacts.
- Proposal quality reports now discard nested model failures and response bodies, normalize labeled sentinel checks, expose no provider credential or mutation dependency, and measure prompt-injection resistance together with benign utility instead of rewarding blanket refusal.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
CREATE TABLE identity.data_rights_requests (
request_id uuid PRIMARY KEY,
-- Deliberately not a foreign key: completed request evidence must survive
-- source-workspace erasure for bounded audit and reconciliation retention.
workspace_id uuid NOT NULL,
-- Deliberately not a foreign key: erasing the identity source record must
-- neither delete this receipt nor make user erasure impossible.
requested_by_user_id uuid NOT NULL,
request_kind text NOT NULL,
idempotency_key uuid NOT NULL,
request_digest character(64) NOT NULL,
request_status text NOT NULL DEFAULT 'pending',
receipt_digest character(64),
requested_at timestamptz NOT NULL,
completed_at timestamptz,
CONSTRAINT data_rights_request_kind_valid
CHECK (request_kind IN ('export', 'erasure')),
CONSTRAINT data_rights_request_digest_valid
CHECK (request_digest ~ '^[0-9a-f]{64}$'),
CONSTRAINT data_rights_request_status_valid
CHECK (request_status IN ('pending', 'completed')),
CONSTRAINT data_rights_receipt_digest_valid
CHECK (receipt_digest IS NULL OR receipt_digest ~ '^[0-9a-f]{64}$'),
CONSTRAINT data_rights_request_completion_consistent
CHECK (
(request_status = 'pending' AND receipt_digest IS NULL AND completed_at IS NULL)
OR
(request_status = 'completed' AND receipt_digest IS NOT NULL AND completed_at IS NOT NULL)
),
CONSTRAINT data_rights_request_time_order
CHECK (completed_at IS NULL OR completed_at >= requested_at),
CONSTRAINT data_rights_workspace_idempotency_unique
UNIQUE (workspace_id, idempotency_key)
);

CREATE FUNCTION identity.preserve_completed_data_rights_receipt()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.request_status = 'completed'
AND (
NEW.request_status IS DISTINCT FROM OLD.request_status
OR NEW.receipt_digest IS DISTINCT FROM OLD.receipt_digest
OR NEW.completed_at IS DISTINCT FROM OLD.completed_at
) THEN
RETURN NULL;
END IF;
RETURN NEW;
END;
$$;

CREATE TRIGGER data_rights_receipt_immutable_guard
BEFORE UPDATE ON identity.data_rights_requests
FOR EACH ROW
EXECUTE FUNCTION identity.preserve_completed_data_rights_receipt();

CREATE INDEX data_rights_requests_workspace_time_idx
ON identity.data_rights_requests (workspace_id, requested_at DESC);
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';

const SESSION_BODY = Object.freeze({
sessionId: '11111111-1111-4111-8111-111111111111',
userId: '22222222-2222-4222-8222-222222222222',
workspaceId: '33333333-3333-4333-8333-333333333333',
authenticatedAt: '2026-08-09T17:55:00.000Z',
createdAt: '2026-08-09T17:56:00.000Z',
expiresAt: '2026-08-10T17:56:00.000Z',
});

interface AuthenticatedApplicationConstructor {
new (
sessions: {
introspectSession(cookieHeader: string | undefined): Promise<{
statusCode: 200;
body: typeof SESSION_BODY;
}>;
},
dataRights: {
exportWorkspace(context: {
readonly workspaceId: string;
readonly actorUserId: string;
}): Promise<unknown>;
},
options: {
readonly now: () => Date;
readonly maximumAgeMs: number;
},
): {
exportWorkspace(cookieHeader: string | undefined): Promise<unknown>;
};
}

async function applicationConstructor(): Promise<AuthenticatedApplicationConstructor> {
const modulePath = './data-rights-authenticated-application';
const module = (await import(modulePath).catch(() => ({}))) as Readonly<
Record<string, unknown>
>;
expect(typeof module.AuthenticatedDataRightsApplication).toBe('function');
return module.AuthenticatedDataRightsApplication as AuthenticatedApplicationConstructor;
}

describe('AuthenticatedDataRightsApplication', () => {
it('derives export ownership only from the authenticated recent session', async () => {
const AuthenticatedDataRightsApplication = await applicationConstructor();
const contexts: unknown[] = [];
const sessions = {
async introspectSession(cookieHeader: string | undefined) {
expect(cookieHeader).toBe('life_os_session=opaque-session');
return { statusCode: 200 as const, body: SESSION_BODY };
},
};
const dataRights = {
async exportWorkspace(context: {
readonly workspaceId: string;
readonly actorUserId: string;
}) {
contexts.push(context);
return { schemaVersion: 'life-os.data-export.v1' };
},
};
const application = new AuthenticatedDataRightsApplication(
sessions,
dataRights,
{
now: () => new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
},
);

await expect(
application.exportWorkspace('life_os_session=opaque-session'),
).resolves.toEqual({ schemaVersion: 'life-os.data-export.v1' });
expect(contexts).toEqual([
{
workspaceId: SESSION_BODY.workspaceId,
actorUserId: SESSION_BODY.userId,
},
]);
});
});
Comment on lines +44 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

실패 경로 테스트를 추가하십시오.

이 파일은 성공 경로만 검증합니다. session.statusCode !== 200 분기와 오래된 인증 거부 분기는 실행되지 않습니다. 두 분기는 이 경계의 보안 계약입니다. 또한 인증이 실패하면 dataRights.exportWorkspace가 호출되지 않아야 한다는 점도 검증해야 합니다.

As per coding guidelines: "Tests must prove realistic domain accuracy and failure behavior, not only mocked call counts." 및 "Packages that enforce coverage gates must retain 100% statement, branch, function, and line coverage."

💚 추가 테스트 제안
+  it('rejects an unauthenticated session before any data-rights work', async () => {
+    const AuthenticatedDataRightsApplication = await applicationConstructor();
+    const contexts: unknown[] = [];
+    const application = new AuthenticatedDataRightsApplication(
+      {
+        async introspectSession() {
+          return { statusCode: 401 as unknown as 200, body: SESSION_BODY };
+        },
+      },
+      {
+        async exportWorkspace(context: {
+          readonly workspaceId: string;
+          readonly actorUserId: string;
+        }) {
+          contexts.push(context);
+          return {};
+        },
+      },
+      {
+        now: () => new Date('2026-08-09T18:00:00.000Z'),
+        maximumAgeMs: 10 * 60 * 1000,
+      },
+    );
+
+    await expect(
+      application.exportWorkspace('life_os_session=opaque-session'),
+    ).rejects.toThrow('Authentication is required');
+    expect(contexts).toEqual([]);
+  });
+
+  it('rejects a stale authentication instant before any data-rights work', async () => {
+    const AuthenticatedDataRightsApplication = await applicationConstructor();
+    const contexts: unknown[] = [];
+    const application = new AuthenticatedDataRightsApplication(
+      {
+        async introspectSession() {
+          return { statusCode: 200 as const, body: SESSION_BODY };
+        },
+      },
+      {
+        async exportWorkspace(context: {
+          readonly workspaceId: string;
+          readonly actorUserId: string;
+        }) {
+          contexts.push(context);
+          return {};
+        },
+      },
+      {
+        now: () => new Date('2026-08-09T19:00:00.000Z'),
+        maximumAgeMs: 10 * 60 * 1000,
+      },
+    );
+
+    await expect(
+      application.exportWorkspace('life_os_session=opaque-session'),
+    ).rejects.toThrow('Recent authentication is required');
+    expect(contexts).toEqual([]);
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('AuthenticatedDataRightsApplication', () => {
it('derives export ownership only from the authenticated recent session', async () => {
const AuthenticatedDataRightsApplication = await applicationConstructor();
const contexts: unknown[] = [];
const sessions = {
async introspectSession(cookieHeader: string | undefined) {
expect(cookieHeader).toBe('life_os_session=opaque-session');
return { statusCode: 200 as const, body: SESSION_BODY };
},
};
const dataRights = {
async exportWorkspace(context: {
readonly workspaceId: string;
readonly actorUserId: string;
}) {
contexts.push(context);
return { schemaVersion: 'life-os.data-export.v1' };
},
};
const application = new AuthenticatedDataRightsApplication(
sessions,
dataRights,
{
now: () => new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
},
);
await expect(
application.exportWorkspace('life_os_session=opaque-session'),
).resolves.toEqual({ schemaVersion: 'life-os.data-export.v1' });
expect(contexts).toEqual([
{
workspaceId: SESSION_BODY.workspaceId,
actorUserId: SESSION_BODY.userId,
},
]);
});
});
describe('AuthenticatedDataRightsApplication', () => {
it('derives export ownership only from the authenticated recent session', async () => {
const AuthenticatedDataRightsApplication = await applicationConstructor();
const contexts: unknown[] = [];
const sessions = {
async introspectSession(cookieHeader: string | undefined) {
expect(cookieHeader).toBe('life_os_session=opaque-session');
return { statusCode: 200 as const, body: SESSION_BODY };
},
};
const dataRights = {
async exportWorkspace(context: {
readonly workspaceId: string;
readonly actorUserId: string;
}) {
contexts.push(context);
return { schemaVersion: 'life-os.data-export.v1' };
},
};
const application = new AuthenticatedDataRightsApplication(
sessions,
dataRights,
{
now: () => new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
},
);
await expect(
application.exportWorkspace('life_os_session=opaque-session'),
).resolves.toEqual({ schemaVersion: 'life-os.data-export.v1' });
expect(contexts).toEqual([
{
workspaceId: SESSION_BODY.workspaceId,
actorUserId: SESSION_BODY.userId,
},
]);
});
it('rejects an unauthenticated session before any data-rights work', async () => {
const AuthenticatedDataRightsApplication = await applicationConstructor();
const contexts: unknown[] = [];
const application = new AuthenticatedDataRightsApplication(
{
async introspectSession() {
return { statusCode: 401 as unknown as 200, body: SESSION_BODY };
},
},
{
async exportWorkspace(context: {
readonly workspaceId: string;
readonly actorUserId: string;
}) {
contexts.push(context);
return {};
},
},
{
now: () => new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
},
);
await expect(
application.exportWorkspace('life_os_session=opaque-session'),
).rejects.toThrow('Authentication is required');
expect(contexts).toEqual([]);
});
it('rejects a stale authentication instant before any data-rights work', async () => {
const AuthenticatedDataRightsApplication = await applicationConstructor();
const contexts: unknown[] = [];
const application = new AuthenticatedDataRightsApplication(
{
async introspectSession() {
return { statusCode: 200 as const, body: SESSION_BODY };
},
},
{
async exportWorkspace(context: {
readonly workspaceId: string;
readonly actorUserId: string;
}) {
contexts.push(context);
return {};
},
},
{
now: () => new Date('2026-08-09T19:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
},
);
await expect(
application.exportWorkspace('life_os_session=opaque-session'),
).rejects.toThrow('Recent authentication is required');
expect(contexts).toEqual([]);
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/identity-service/src/data-rights-authenticated-application.test.ts`
around lines 44 - 82, Extend the AuthenticatedDataRightsApplication tests with
failure cases for introspectSession responses where statusCode is not 200 and
where the authenticated session is expired beyond maximumAgeMs. Assert each
exportWorkspace call rejects with the expected authentication error and verify
dataRights.exportWorkspace is never invoked, while preserving the existing
successful-session assertions.

Source: Coding guidelines

58 changes: 58 additions & 0 deletions apps/identity-service/src/data-rights-authenticated-application.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { DataRightsWorkspaceContext } from './data-rights';
import { requireRecentAuthentication } from './oauth-http-boundary';

interface SessionView {
readonly userId: string;
readonly workspaceId: string;
readonly authenticatedAt: string;
}

interface SessionIntrospectionApplication {
introspectSession(cookieHeader: string | undefined): Promise<{
readonly statusCode: number;
readonly body: SessionView;
}>;
}

interface DataRightsExportApplication {
exportWorkspace(context: DataRightsWorkspaceContext): Promise<unknown>;
}

interface RecentAuthenticationOptions {
readonly now: () => Date;
readonly maximumAgeMs: number;
}

/**
* Establishes the authenticated application boundary for data-rights exports.
* Workspace and actor ownership are derived exclusively from the opaque session,
* and the request is rejected before data-rights work when authentication is stale.
*/
export class AuthenticatedDataRightsApplication {
constructor(
private readonly sessions: SessionIntrospectionApplication,
private readonly dataRights: DataRightsExportApplication,
private readonly options: RecentAuthenticationOptions,
) {}

/**
* Exports the session-owned workspace after enforcing the configured recent-authentication window.
*/
async exportWorkspace(cookieHeader: string | undefined): Promise<unknown> {
const session = await this.sessions.introspectSession(cookieHeader);
if (session.statusCode !== 200) {
throw new Error('Authentication is required');
}
requireRecentAuthentication({
authenticatedAt: session.body.authenticatedAt,
now: this.options.now(),
maximumAgeMs: this.options.maximumAgeMs,
});
return this.dataRights.exportWorkspace(
Object.freeze({
workspaceId: session.body.workspaceId,
actorUserId: session.body.userId,
}),
);
}
}
75 changes: 75 additions & 0 deletions apps/identity-service/src/data-rights-recent-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import * as oauthBoundary from './oauth-http-boundary';

type RecentAuthenticationGate = (input: {
readonly authenticatedAt: string;
readonly now: Date;
readonly maximumAgeMs: number;
}) => string;

function recentAuthenticationGate(): RecentAuthenticationGate {
const candidate = (
oauthBoundary as unknown as Readonly<Record<string, unknown>>
).requireRecentAuthentication;
expect(typeof candidate).toBe('function');
return candidate as RecentAuthenticationGate;
}

describe('data-rights recent authentication gate', () => {
it('accepts an authentication instant at the exact maximum age boundary', () => {
const requireRecentAuthentication = recentAuthenticationGate();

expect(
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:50:00.000Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toBe('2026-08-09T17:50:00.000Z');
});

it('rejects a stale authentication instant even when the session itself is still valid', () => {
const requireRecentAuthentication = recentAuthenticationGate();

expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:49:59.999Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Recent authentication is required');
});

it('fails closed on future, malformed, or invalid policy timestamps', () => {
const requireRecentAuthentication = recentAuthenticationGate();

expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T18:00:00.001Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Authentication provenance is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: 'not-an-instant',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Authentication provenance is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:55:00.000Z',
now: new Date('invalid'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Recent authentication policy is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:55:00.000Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 0,
}),
).toThrow('Recent authentication policy is invalid');
});
Comment on lines +43 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

정규화되지 않은 시각 문자열 사례를 추가하십시오.

requireRecentAuthenticationcanonicalAuthenticatedAt !== input.authenticatedAt 조건으로 비정규 ISO 문자열을 거부합니다. 현재 테스트는 이 조건을 단독으로 실행하지 않습니다. 48행 값은 정규 형식이면서 미래 시각이므로 조건의 다른 절을 통과합니다. 밀리초가 없는 값 하나를 추가하면 이 분기가 검증됩니다.

💚 추가 사례 제안
     expect(() =>
       requireRecentAuthentication({
         authenticatedAt: 'not-an-instant',
         now: new Date('2026-08-09T18:00:00.000Z'),
         maximumAgeMs: 10 * 60 * 1000,
       }),
     ).toThrow('Authentication provenance is invalid');
+    expect(() =>
+      requireRecentAuthentication({
+        authenticatedAt: '2026-08-09T17:55:00Z',
+        now: new Date('2026-08-09T18:00:00.000Z'),
+        maximumAgeMs: 10 * 60 * 1000,
+      }),
+    ).toThrow('Authentication provenance is invalid');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('fails closed on future, malformed, or invalid policy timestamps', () => {
const requireRecentAuthentication = recentAuthenticationGate();
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T18:00:00.001Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Authentication provenance is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: 'not-an-instant',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Authentication provenance is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:55:00.000Z',
now: new Date('invalid'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Recent authentication policy is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:55:00.000Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 0,
}),
).toThrow('Recent authentication policy is invalid');
});
it('fails closed on future, malformed, or invalid policy timestamps', () => {
const requireRecentAuthentication = recentAuthenticationGate();
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T18:00:00.001Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Authentication provenance is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: 'not-an-instant',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Authentication provenance is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:55:00Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Authentication provenance is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:55:00.000Z',
now: new Date('invalid'),
maximumAgeMs: 10 * 60 * 1000,
}),
).toThrow('Recent authentication policy is invalid');
expect(() =>
requireRecentAuthentication({
authenticatedAt: '2026-08-09T17:55:00.000Z',
now: new Date('2026-08-09T18:00:00.000Z'),
maximumAgeMs: 0,
}),
).toThrow('Recent authentication policy is invalid');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/identity-service/src/data-rights-recent-auth.test.ts` around lines 43 -
74, Extend the `fails closed on future, malformed, or invalid policy timestamps`
test to include a valid, non-canonical ISO timestamp without milliseconds, such
as the same historical instant as an existing case. Assert that
`requireRecentAuthentication` throws `Authentication provenance is invalid`,
isolating the `canonicalAuthenticatedAt !== input.authenticatedAt` branch.

Source: Coding guidelines

});
Loading
Loading