diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b20741a..01c2b2db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4120874c..bd3f85d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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. @@ -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. diff --git a/apps/identity-service/migrations/0006_data_rights_request_ledger.sql b/apps/identity-service/migrations/0006_data_rights_request_ledger.sql new file mode 100644 index 00000000..8f73a660 --- /dev/null +++ b/apps/identity-service/migrations/0006_data_rights_request_ledger.sql @@ -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); diff --git a/apps/identity-service/src/data-rights-authenticated-application.test.ts b/apps/identity-service/src/data-rights-authenticated-application.test.ts new file mode 100644 index 00000000..087a187f --- /dev/null +++ b/apps/identity-service/src/data-rights-authenticated-application.test.ts @@ -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; + }, + options: { + readonly now: () => Date; + readonly maximumAgeMs: number; + }, + ): { + exportWorkspace(cookieHeader: string | undefined): Promise; + }; +} + +async function applicationConstructor(): Promise { + const modulePath = './data-rights-authenticated-application'; + const module = (await import(modulePath).catch(() => ({}))) as Readonly< + Record + >; + 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, + }, + ]); + }); +}); diff --git a/apps/identity-service/src/data-rights-authenticated-application.ts b/apps/identity-service/src/data-rights-authenticated-application.ts new file mode 100644 index 00000000..2609312a --- /dev/null +++ b/apps/identity-service/src/data-rights-authenticated-application.ts @@ -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; +} + +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 { + 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, + }), + ); + } +} diff --git a/apps/identity-service/src/data-rights-recent-auth.test.ts b/apps/identity-service/src/data-rights-recent-auth.test.ts new file mode 100644 index 00000000..93317b6a --- /dev/null +++ b/apps/identity-service/src/data-rights-recent-auth.test.ts @@ -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> + ).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'); + }); +}); diff --git a/apps/identity-service/src/data-rights-request-ledger.integration.test.ts b/apps/identity-service/src/data-rights-request-ledger.integration.test.ts new file mode 100644 index 00000000..2d3c24ca --- /dev/null +++ b/apps/identity-service/src/data-rights-request-ledger.integration.test.ts @@ -0,0 +1,240 @@ +import { randomUUID } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + DataRightsRequestConflictError, + PostgresDataRightsRequestLedger, + type DataRightsRequestSqlClient, + type DataRightsRequestSqlResult, +} from './data-rights-request-ledger'; + +const DATABASE_URL = process.env.IDENTITY_DATABASE_URL; +const describeWithDatabase = DATABASE_URL ? describe : describe.skip; +const TEST_DATABASE_NAME = 'life_os_data_rights_ledger_test'; +if (!/^[a-z][a-z0-9_]*$/u.test(TEST_DATABASE_NAME)) { + throw new Error('TEST_DATABASE_NAME must be a safe PostgreSQL identifier'); +} +const DROP_TEST_DATABASE = `DROP DATABASE IF EXISTS "${TEST_DATABASE_NAME}" WITH (FORCE)`; +const CREATE_TEST_DATABASE = `CREATE DATABASE "${TEST_DATABASE_NAME}"`; +const MIGRATION_DIRECTORY = resolve(__dirname, '../migrations'); + +class NodePostgresDataRightsClient implements DataRightsRequestSqlClient { + constructor(private readonly pool: Pool) {} + + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + const result = await this.pool.query(text, [...values]); + return { rows: result.rows as Row[], rowCount: result.rowCount }; + } +} + +describeWithDatabase('PostgreSQL data-rights request ledger', () => { + let adminPool: Pool; + let pool: Pool; + + beforeAll(async () => { + if (!DATABASE_URL) { + throw new Error('IDENTITY_DATABASE_URL is required for PostgreSQL integration tests'); + } + const adminUrl = new URL(DATABASE_URL); + adminUrl.pathname = '/postgres'; + adminPool = new Pool({ connectionString: adminUrl.toString() }); + await adminPool.query(DROP_TEST_DATABASE); + await adminPool.query(CREATE_TEST_DATABASE); + + const testUrl = new URL(DATABASE_URL); + testUrl.pathname = `/${TEST_DATABASE_NAME}`; + pool = new Pool({ connectionString: testUrl.toString() }); + const migrationFiles = (await readdir(MIGRATION_DIRECTORY)) + .filter((file) => file.endsWith('.sql')) + .sort(); + for (const migrationFile of migrationFiles) { + await pool.query( + await readFile(resolve(MIGRATION_DIRECTORY, migrationFile), 'utf8'), + ); + } + }, 30_000); + + afterAll(async () => { + try { + if (pool) await pool.end(); + } finally { + if (adminPool) { + try { + await adminPool.query(DROP_TEST_DATABASE); + } finally { + await adminPool.end(); + } + } + } + }); + + it('retains an immutable completion receipt after the source workspace and user are erased', async () => { + const userId = randomUUID(); + const workspaceId = randomUUID(); + const requestId = randomUUID(); + const idempotencyKey = randomUUID(); + const requestDigest = 'a'.repeat(64); + const receiptDigest = 'b'.repeat(64); + await pool.query( + `INSERT INTO identity.users (id, display_name) VALUES ($1::uuid, $2)`, + [userId, 'Data rights integration user'], + ); + await pool.query( + `INSERT INTO identity.workspaces (id, owner_user_id, name, kind) + VALUES ($1::uuid, $2::uuid, $3, 'personal')`, + [workspaceId, userId, 'Data rights integration workspace'], + ); + + const ledger = new PostgresDataRightsRequestLedger( + new NodePostgresDataRightsClient(pool), + ); + const created = await ledger.beginRequest({ + requestId, + workspaceId, + requestedByUserId: userId, + requestKind: 'erasure', + idempotencyKey, + requestDigest, + requestedAt: '2026-08-09T19:40:00.000Z', + }); + expect(created.kind).toBe('created'); + const replayed = await ledger.beginRequest({ + requestId: randomUUID(), + workspaceId, + requestedByUserId: userId, + requestKind: 'erasure', + idempotencyKey, + requestDigest, + requestedAt: '2026-08-09T19:41:00.000Z', + }); + expect(replayed.kind).toBe('replayed'); + expect(replayed.request.requestId).toBe(requestId); + + await expect( + ledger.completeRequest({ + requestId, + workspaceId, + receiptDigest, + completedAt: '2026-08-09T19:45:00.000Z', + }), + ).resolves.toMatchObject({ kind: 'completed' }); + await expect( + ledger.completeRequest({ + requestId, + workspaceId, + receiptDigest, + completedAt: '2026-08-09T19:46:00.000Z', + }), + ).resolves.toMatchObject({ kind: 'replayed' }); + await expect( + ledger.completeRequest({ + requestId, + workspaceId, + receiptDigest: 'c'.repeat(64), + completedAt: '2026-08-09T19:46:00.000Z', + }), + ).rejects.toBeInstanceOf(DataRightsRequestConflictError); + + const blockedMutation = await pool.query( + `UPDATE identity.data_rights_requests + SET receipt_digest = $2 + WHERE request_id = $1::uuid`, + [requestId, 'd'.repeat(64)], + ); + expect(blockedMutation.rowCount).toBe(0); + + await pool.query(`DELETE FROM identity.workspaces WHERE id = $1::uuid`, [workspaceId]); + await pool.query(`DELETE FROM identity.users WHERE id = $1::uuid`, [userId]); + + const retained = await pool.query<{ + request_id: string; + receipt_digest: string; + request_status: string; + }>( + `SELECT request_id, receipt_digest, request_status + FROM identity.data_rights_requests + WHERE request_id = $1::uuid`, + [requestId], + ); + expect(retained.rows).toEqual([ + { + request_id: requestId, + receipt_digest: receiptDigest, + request_status: 'completed', + }, + ]); + }); + + it('maps request-id reuse with a different idempotency key to a stable domain conflict', async () => { + const userId = randomUUID(); + const workspaceId = randomUUID(); + const requestId = randomUUID(); + const firstIdempotencyKey = randomUUID(); + await pool.query( + `INSERT INTO identity.users (id, display_name) VALUES ($1::uuid, $2)`, + [userId, 'Request collision integration user'], + ); + await pool.query( + `INSERT INTO identity.workspaces (id, owner_user_id, name, kind) + VALUES ($1::uuid, $2::uuid, $3, 'personal')`, + [workspaceId, userId, 'Request collision integration workspace'], + ); + const ledger = new PostgresDataRightsRequestLedger( + new NodePostgresDataRightsClient(pool), + ); + + await ledger.beginRequest({ + requestId, + workspaceId, + requestedByUserId: userId, + requestKind: 'export', + idempotencyKey: firstIdempotencyKey, + requestDigest: 'c'.repeat(64), + requestedAt: '2026-08-09T20:00:00.000Z', + }); + + await expect( + ledger.beginRequest({ + requestId, + workspaceId, + requestedByUserId: userId, + requestKind: 'export', + idempotencyKey: randomUUID(), + requestDigest: 'd'.repeat(64), + requestedAt: '2026-08-09T20:01:00.000Z', + }), + ).rejects.toBeInstanceOf(DataRightsRequestConflictError); + }); + + it('enforces request kind, digest, completion consistency, receipt digest, and time ordering constraints', async () => { + const base = [randomUUID(), randomUUID(), randomUUID(), randomUUID()]; + const invalidRows: ReadonlyArray = [ + [...base, 'invalid-kind', 'a'.repeat(64), 'pending', null, '2026-08-09T20:00:00.000Z', null], + [...base, 'export', 'not-a-digest', 'pending', null, '2026-08-09T20:00:00.000Z', null], + [...base, 'export', 'a'.repeat(64), 'completed', null, '2026-08-09T20:00:00.000Z', '2026-08-09T20:01:00.000Z'], + [...base, 'export', 'a'.repeat(64), 'completed', 'not-a-digest', '2026-08-09T20:00:00.000Z', '2026-08-09T20:01:00.000Z'], + [...base, 'export', 'a'.repeat(64), 'completed', 'b'.repeat(64), '2026-08-09T20:02:00.000Z', '2026-08-09T20:01:00.000Z'], + ]; + + for (const values of invalidRows) { + await expect( + pool.query( + `INSERT INTO identity.data_rights_requests ( + request_id, workspace_id, requested_by_user_id, idempotency_key, + request_kind, request_digest, request_status, receipt_digest, + requested_at, completed_at + ) VALUES ( + $1::uuid, $2::uuid, $3::uuid, $4::uuid, + $5, $6, $7, $8, $9::timestamptz, $10::timestamptz + )`, + [...values], + ), + ).rejects.toThrow(); + } + }); +}); diff --git a/apps/identity-service/src/data-rights-request-ledger.test.ts b/apps/identity-service/src/data-rights-request-ledger.test.ts new file mode 100644 index 00000000..c06ef9cf --- /dev/null +++ b/apps/identity-service/src/data-rights-request-ledger.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import { + DataRightsRequestConflictError, + DataRightsRequestValidationError, + PostgresDataRightsRequestLedger, + type DataRightsRequestSqlClient, + type DataRightsRequestSqlResult, +} from './data-rights-request-ledger'; + +const REQUEST_ID = '11111111-1111-4111-8111-111111111111'; +const WORKSPACE_ID = '22222222-2222-4222-8222-222222222222'; +const ACTOR_USER_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; +const REQUEST_DIGEST = 'a'.repeat(64); +const RECEIPT_DIGEST = 'b'.repeat(64); +const REQUESTED_AT = '2026-08-09T19:40:00.000Z'; +const COMPLETED_AT = '2026-08-09T19:45:00.000Z'; + +interface QueryCall { + readonly text: string; + readonly values: readonly unknown[]; +} + +class RecordingSqlClient implements DataRightsRequestSqlClient { + readonly calls: QueryCall[] = []; + + constructor(private readonly responses: unknown[][]) {} + + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + this.calls.push({ text, values }); + const rows = (this.responses.shift() ?? []) as Row[]; + return { rows, rowCount: rows.length }; + } +} + +function storedRow(overrides: Record = {}) { + return { + request_id: REQUEST_ID, + workspace_id: WORKSPACE_ID, + requested_by_user_id: ACTOR_USER_ID, + request_kind: 'export', + idempotency_key: IDEMPOTENCY_KEY, + request_digest: REQUEST_DIGEST, + request_status: 'pending', + receipt_digest: null, + requested_at: REQUESTED_AT, + completed_at: null, + ...overrides, + }; +} + +function beginInput(overrides: Record = {}) { + return { + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: ACTOR_USER_ID, + requestKind: 'export' as const, + idempotencyKey: IDEMPOTENCY_KEY, + requestDigest: REQUEST_DIGEST, + requestedAt: REQUESTED_AT, + ...overrides, + }; +} + +describe('PostgresDataRightsRequestLedger', () => { + it('creates a tenant-bound request through fixed parameterized SQL', async () => { + const client = new RecordingSqlClient([[storedRow()]]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect(ledger.beginRequest(beginInput())).resolves.toEqual({ + kind: 'created', + request: { + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: ACTOR_USER_ID, + requestKind: 'export', + idempotencyKey: IDEMPOTENCY_KEY, + requestDigest: REQUEST_DIGEST, + status: 'pending', + receiptDigest: null, + requestedAt: REQUESTED_AT, + completedAt: null, + }, + }); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]?.text).toContain('identity.data_rights_requests'); + expect(client.calls[0]?.text).toContain('ON CONFLICT DO NOTHING'); + expect(client.calls[0]?.text).not.toContain(REQUEST_DIGEST); + expect(client.calls[0]?.values).toEqual([ + REQUEST_ID, + WORKSPACE_ID, + ACTOR_USER_ID, + 'export', + IDEMPOTENCY_KEY, + REQUEST_DIGEST, + REQUESTED_AT, + ]); + }); + + it('returns an exact durable replay from pg-style Date timestamp values', async () => { + const client = new RecordingSqlClient([ + [], + [storedRow({ requested_at: new Date(REQUESTED_AT) })], + ]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect(ledger.beginRequest(beginInput())).resolves.toMatchObject({ + kind: 'replayed', + request: { + requestId: REQUEST_ID, + requestDigest: REQUEST_DIGEST, + requestedAt: REQUESTED_AT, + }, + }); + expect(client.calls).toHaveLength(2); + expect(client.calls[1]?.text).toContain('idempotency_key = $2::uuid'); + expect(client.calls[1]?.text).toContain('request_id = $3::uuid'); + expect(client.calls[1]?.values).toEqual([ + WORKSPACE_ID, + IDEMPOTENCY_KEY, + REQUEST_ID, + ]); + }); + + it('fails closed when an idempotency key is reused for another request', async () => { + const client = new RecordingSqlClient([ + [], + [storedRow({ request_digest: 'c'.repeat(64) })], + ]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect(ledger.beginRequest(beginInput())).rejects.toBeInstanceOf( + DataRightsRequestConflictError, + ); + }); + + it('maps two distinct collision rows to a stable domain conflict', async () => { + const otherRequestId = '55555555-5555-4555-8555-555555555555'; + const otherIdempotencyKey = '66666666-6666-4666-8666-666666666666'; + const client = new RecordingSqlClient([ + [], + [ + storedRow({ request_id: otherRequestId }), + storedRow({ idempotency_key: otherIdempotencyKey }), + ], + ]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect(ledger.beginRequest(beginInput())).rejects.toBeInstanceOf( + DataRightsRequestConflictError, + ); + }); + + it('stores one immutable terminal receipt and replays pg-style Date timestamps', async () => { + const completed = storedRow({ + request_status: 'completed', + receipt_digest: RECEIPT_DIGEST, + requested_at: new Date(REQUESTED_AT), + completed_at: new Date(COMPLETED_AT), + }); + const client = new RecordingSqlClient([[completed], [], [completed]]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect( + ledger.completeRequest({ + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + receiptDigest: RECEIPT_DIGEST, + completedAt: COMPLETED_AT, + }), + ).resolves.toMatchObject({ + kind: 'completed', + request: { requestedAt: REQUESTED_AT, completedAt: COMPLETED_AT }, + }); + await expect( + ledger.completeRequest({ + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + receiptDigest: RECEIPT_DIGEST, + completedAt: COMPLETED_AT, + }), + ).resolves.toMatchObject({ kind: 'replayed' }); + }); + + it('rejects a conflicting terminal receipt instead of rewriting audit evidence', async () => { + const client = new RecordingSqlClient([ + [], + [ + storedRow({ + request_status: 'completed', + receipt_digest: 'c'.repeat(64), + completed_at: COMPLETED_AT, + }), + ], + ]); + const ledger = new PostgresDataRightsRequestLedger(client); + + await expect( + ledger.completeRequest({ + requestId: REQUEST_ID, + workspaceId: WORKSPACE_ID, + receiptDigest: RECEIPT_DIGEST, + completedAt: COMPLETED_AT, + }), + ).rejects.toBeInstanceOf(DataRightsRequestConflictError); + }); + + it('rejects malformed ownership, digest, kind, and time before querying PostgreSQL', async () => { + for (const invalidInput of [ + beginInput({ workspaceId: 'not-a-uuid' }), + beginInput({ requestKind: 'delete' }), + beginInput({ requestDigest: 'not-a-digest' }), + beginInput({ requestedAt: 'not-an-instant' }), + ]) { + const client = new RecordingSqlClient([]); + const ledger = new PostgresDataRightsRequestLedger(client); + await expect(ledger.beginRequest(invalidInput as never)).rejects.toBeInstanceOf( + DataRightsRequestValidationError, + ); + expect(client.calls).toHaveLength(0); + } + }); +}); diff --git a/apps/identity-service/src/data-rights-request-ledger.ts b/apps/identity-service/src/data-rights-request-ledger.ts new file mode 100644 index 00000000..431d042b --- /dev/null +++ b/apps/identity-service/src/data-rights-request-ledger.ts @@ -0,0 +1,373 @@ +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 SHA256_PATTERN = /^[0-9a-f]{64}$/u; +const ISO_INSTANT_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; + +/** Query result exposed by the bounded data-rights persistence adapter. */ +export interface DataRightsRequestSqlResult { + readonly rows: Row[]; + readonly rowCount: number | null; +} + +/** Minimal fixed-query SQL boundary required by the data-rights request ledger. */ +export interface DataRightsRequestSqlClient { + query( + text: string, + values?: readonly unknown[], + ): Promise>; +} + +/** Durable operation types supported by the data-rights orchestration ledger. */ +export type DataRightsRequestKind = 'export' | 'erasure'; + +/** Durable request lifecycle exposed to the identity application layer. */ +export type DataRightsRequestStatus = 'pending' | 'completed'; + +/** Credential-free durable request record returned by the ledger. */ +export interface DataRightsRequestRecord { + readonly requestId: string; + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly requestKind: DataRightsRequestKind; + readonly idempotencyKey: string; + readonly requestDigest: string; + readonly status: DataRightsRequestStatus; + readonly receiptDigest: string | null; + readonly requestedAt: string; + readonly completedAt: string | null; +} + +/** Validated input for creating or replaying one data-rights request. */ +export interface BeginDataRightsRequest { + readonly requestId: string; + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly requestKind: DataRightsRequestKind; + readonly idempotencyKey: string; + readonly requestDigest: string; + readonly requestedAt: string; +} + +/** Validated input for binding an immutable terminal receipt to one request. */ +export interface CompleteDataRightsRequest { + readonly requestId: string; + readonly workspaceId: string; + readonly receiptDigest: string; + readonly completedAt: string; +} + +/** Fail-closed error for malformed request-ledger input. */ +export class DataRightsRequestValidationError extends Error { + /** Creates a fixed validation error without retaining the rejected value. */ + constructor() { + super('Data-rights request is invalid'); + this.name = 'DataRightsRequestValidationError'; + } +} + +/** Stable conflict for idempotency or immutable receipt reuse. */ +export class DataRightsRequestConflictError extends Error { + /** Creates a credential-free conflict without exposing stored tenant data. */ + constructor() { + super('Data-rights request conflicts with durable evidence'); + this.name = 'DataRightsRequestConflictError'; + } +} + +/** Fail-closed error when persisted request evidence violates ledger invariants. */ +export class DataRightsRequestPersistenceError extends Error { + /** Creates a fixed persistence-corruption error. */ + constructor() { + super('Persisted data-rights request is invalid'); + this.name = 'DataRightsRequestPersistenceError'; + } +} + +interface DataRightsRequestRow { + request_id: unknown; + workspace_id: unknown; + requested_by_user_id: unknown; + request_kind: unknown; + idempotency_key: unknown; + request_digest: unknown; + request_status: unknown; + receipt_digest: unknown; + requested_at: unknown; + completed_at: unknown; +} + +function invalidInput(): never { + throw new DataRightsRequestValidationError(); +} + +function invalidPersistence(): never { + throw new DataRightsRequestPersistenceError(); +} + +function requireInputUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidInput(); + } + return value.toLowerCase(); +} + +function requireStoredUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidPersistence(); + } + return value.toLowerCase(); +} + +function requireInputDigest(value: unknown): string { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + return invalidInput(); + } + return value; +} + +function requireStoredDigest(value: unknown): string { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + return invalidPersistence(); + } + return value; +} + +function parseInstant(value: unknown, invalid: () => never): string { + const candidate = + value instanceof Date ? value.toISOString() : typeof value === 'string' ? value : ''; + if (!ISO_INSTANT_PATTERN.test(candidate)) { + return invalid(); + } + const parsed = new Date(candidate); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== candidate) { + return invalid(); + } + return candidate; +} + +function requireInputInstant(value: unknown): string { + return parseInstant(value, invalidInput); +} + +function requireStoredInstant(value: unknown): string { + return parseInstant(value, invalidPersistence); +} + +function requireInputKind(value: unknown): DataRightsRequestKind { + if (value !== 'export' && value !== 'erasure') { + return invalidInput(); + } + return value; +} + +function requireStoredKind(value: unknown): DataRightsRequestKind { + if (value !== 'export' && value !== 'erasure') { + return invalidPersistence(); + } + return value; +} + +function requireStoredStatus(value: unknown): DataRightsRequestStatus { + if (value !== 'pending' && value !== 'completed') { + return invalidPersistence(); + } + return value; +} + +function parseRequestRow(row: DataRightsRequestRow): DataRightsRequestRecord { + const status = requireStoredStatus(row.request_status); + const receiptDigest = + row.receipt_digest === null ? null : requireStoredDigest(row.receipt_digest); + const completedAt = + row.completed_at === null ? null : requireStoredInstant(row.completed_at); + if ( + (status === 'pending' && (receiptDigest !== null || completedAt !== null)) || + (status === 'completed' && (receiptDigest === null || completedAt === null)) + ) { + return invalidPersistence(); + } + const requestedAt = requireStoredInstant(row.requested_at); + if (completedAt !== null && new Date(completedAt).getTime() < new Date(requestedAt).getTime()) { + return invalidPersistence(); + } + return Object.freeze({ + requestId: requireStoredUuid(row.request_id), + workspaceId: requireStoredUuid(row.workspace_id), + requestedByUserId: requireStoredUuid(row.requested_by_user_id), + requestKind: requireStoredKind(row.request_kind), + idempotencyKey: requireStoredUuid(row.idempotency_key), + requestDigest: requireStoredDigest(row.request_digest), + status, + receiptDigest, + requestedAt, + completedAt, + }); +} + +function oneOrUndefined(rows: readonly Row[]): Row | undefined { + if (rows.length > 1) { + return invalidPersistence(); + } + return rows[0]; +} + +function validateBeginInput(input: BeginDataRightsRequest): BeginDataRightsRequest { + return Object.freeze({ + requestId: requireInputUuid(input.requestId), + workspaceId: requireInputUuid(input.workspaceId), + requestedByUserId: requireInputUuid(input.requestedByUserId), + requestKind: requireInputKind(input.requestKind), + idempotencyKey: requireInputUuid(input.idempotencyKey), + requestDigest: requireInputDigest(input.requestDigest), + requestedAt: requireInputInstant(input.requestedAt), + }); +} + +function validateCompleteInput( + input: CompleteDataRightsRequest, +): CompleteDataRightsRequest { + return Object.freeze({ + requestId: requireInputUuid(input.requestId), + workspaceId: requireInputUuid(input.workspaceId), + receiptDigest: requireInputDigest(input.receiptDigest), + completedAt: requireInputInstant(input.completedAt), + }); +} + +function requireReplayIdentity( + record: DataRightsRequestRecord, + input: BeginDataRightsRequest, +): void { + if ( + record.workspaceId !== input.workspaceId || + record.requestedByUserId !== input.requestedByUserId || + record.requestKind !== input.requestKind || + record.idempotencyKey !== input.idempotencyKey || + record.requestDigest !== input.requestDigest + ) { + throw new DataRightsRequestConflictError(); + } +} + +/** + * PostgreSQL-backed durable ledger for replay-safe data-rights requests and + * immutable terminal receipt digests. + */ +export class PostgresDataRightsRequestLedger { + /** Creates the ledger over a least-authority fixed-query SQL client. */ + constructor(private readonly client: DataRightsRequestSqlClient) {} + + /** Creates one tenant-bound request or returns its exact durable replay. */ + async beginRequest(input: BeginDataRightsRequest): Promise<{ + readonly kind: 'created' | 'replayed'; + readonly request: DataRightsRequestRecord; + }> { + const safe = validateBeginInput(input); + const inserted = await this.client.query( + `INSERT INTO identity.data_rights_requests ( + request_id, + workspace_id, + requested_by_user_id, + request_kind, + idempotency_key, + request_digest, + request_status, + requested_at + ) VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5::uuid, $6, 'pending', $7::timestamptz) + ON CONFLICT DO NOTHING + RETURNING request_id, workspace_id, requested_by_user_id, request_kind, + idempotency_key, request_digest, request_status, receipt_digest, + requested_at, completed_at`, + [ + safe.requestId, + safe.workspaceId, + safe.requestedByUserId, + safe.requestKind, + safe.idempotencyKey, + safe.requestDigest, + safe.requestedAt, + ], + ); + const insertedRow = oneOrUndefined(inserted.rows); + if (insertedRow) { + const request = parseRequestRow(insertedRow); + requireReplayIdentity(request, safe); + return Object.freeze({ kind: 'created', request }); + } + + const existing = await this.client.query( + `SELECT request_id, workspace_id, requested_by_user_id, request_kind, + idempotency_key, request_digest, request_status, receipt_digest, + requested_at, completed_at + FROM identity.data_rights_requests + WHERE (workspace_id = $1::uuid AND idempotency_key = $2::uuid) + OR request_id = $3::uuid + LIMIT 2`, + [safe.workspaceId, safe.idempotencyKey, safe.requestId], + ); + if (existing.rows.length > 1) { + throw new DataRightsRequestConflictError(); + } + const existingRow = oneOrUndefined(existing.rows); + if (!existingRow) { + return invalidPersistence(); + } + const request = parseRequestRow(existingRow); + requireReplayIdentity(request, safe); + return Object.freeze({ kind: 'replayed', request }); + } + + /** Completes one pending request or replays the same immutable receipt. */ + async completeRequest(input: CompleteDataRightsRequest): Promise<{ + readonly kind: 'completed' | 'replayed'; + readonly request: DataRightsRequestRecord; + }> { + const safe = validateCompleteInput(input); + const updated = await this.client.query( + `UPDATE identity.data_rights_requests + SET request_status = 'completed', + receipt_digest = $3, + completed_at = $4::timestamptz + WHERE request_id = $1::uuid + AND workspace_id = $2::uuid + AND request_status = 'pending' + AND requested_at <= $4::timestamptz + RETURNING request_id, workspace_id, requested_by_user_id, request_kind, + idempotency_key, request_digest, request_status, receipt_digest, + requested_at, completed_at`, + [safe.requestId, safe.workspaceId, safe.receiptDigest, safe.completedAt], + ); + const updatedRow = oneOrUndefined(updated.rows); + if (updatedRow) { + const request = parseRequestRow(updatedRow); + if ( + request.requestId !== safe.requestId || + request.workspaceId !== safe.workspaceId || + request.receiptDigest !== safe.receiptDigest + ) { + return invalidPersistence(); + } + return Object.freeze({ kind: 'completed', request }); + } + + const existing = await this.client.query( + `SELECT request_id, workspace_id, requested_by_user_id, request_kind, + idempotency_key, request_digest, request_status, receipt_digest, + requested_at, completed_at + FROM identity.data_rights_requests + WHERE request_id = $1::uuid AND workspace_id = $2::uuid + LIMIT 2`, + [safe.requestId, safe.workspaceId], + ); + const existingRow = oneOrUndefined(existing.rows); + if (!existingRow) { + throw new DataRightsRequestConflictError(); + } + const request = parseRequestRow(existingRow); + if (request.status !== 'completed' || request.receiptDigest !== safe.receiptDigest) { + throw new DataRightsRequestConflictError(); + } + return Object.freeze({ kind: 'replayed', request }); + } +} diff --git a/apps/identity-service/src/oauth-http-boundary.ts b/apps/identity-service/src/oauth-http-boundary.ts index cef4de2c..d8d35127 100644 --- a/apps/identity-service/src/oauth-http-boundary.ts +++ b/apps/identity-service/src/oauth-http-boundary.ts @@ -91,6 +91,43 @@ function requirePositiveInteger(value: number, message: string): number { return value; } +/** + * Requires authentication provenance to fall within one bounded recent-authentication window. + */ +export function requireRecentAuthentication(input: { + readonly authenticatedAt: string; + readonly now: Date; + readonly maximumAgeMs: number; +}): string { + if ( + !(input.now instanceof Date) || + !Number.isFinite(input.now.getTime()) || + !Number.isSafeInteger(input.maximumAgeMs) || + input.maximumAgeMs <= 0 + ) { + throw new Error('Recent authentication policy is invalid'); + } + + if (typeof input.authenticatedAt !== 'string') { + throw new Error('Authentication provenance is invalid'); + } + const authenticatedAtMs = Date.parse(input.authenticatedAt); + if (!Number.isFinite(authenticatedAtMs)) { + throw new Error('Authentication provenance is invalid'); + } + const canonicalAuthenticatedAt = new Date(authenticatedAtMs).toISOString(); + if ( + canonicalAuthenticatedAt !== input.authenticatedAt || + authenticatedAtMs > input.now.getTime() + ) { + throw new Error('Authentication provenance is invalid'); + } + if (input.now.getTime() - authenticatedAtMs > input.maximumAgeMs) { + throw new Error('Recent authentication is required'); + } + return canonicalAuthenticatedAt; +} + /** * Parses a bounded Cookie header without decoding or accepting duplicate names. */ diff --git a/apps/identity-service/tests/session-authentication-migration.integration.test.ts b/apps/identity-service/tests/session-authentication-migration.integration.test.ts index 3183112a..cdddffe7 100644 --- a/apps/identity-service/tests/session-authentication-migration.integration.test.ts +++ b/apps/identity-service/tests/session-authentication-migration.integration.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { readdir, readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { Pool } from 'pg'; +import { Pool, type PoolClient } from 'pg'; import { describe, expect, it } from 'vitest'; const DATABASE_URL = process.env.IDENTITY_DATABASE_URL; @@ -59,18 +59,20 @@ async function withTemporaryDatabase( const adminPool = new Pool({ connectionString: databaseUrl(sourceUrl, 'postgres'), }); + let adminClient: PoolClient | undefined; let migrationPool: Pool | undefined; let lockHeld = false; try { - await adminPool.query('SELECT pg_advisory_lock($1::bigint)', [ + adminClient = await adminPool.connect(); + await adminClient.query('SELECT pg_advisory_lock($1::bigint)', [ TEMPORARY_DATABASE_LOCK_KEY, ]); lockHeld = true; - await adminPool.query( + await adminClient.query( 'DROP DATABASE IF EXISTS life_os_identity_migration_test WITH (FORCE)', ); - await adminPool.query('CREATE DATABASE life_os_identity_migration_test'); + await adminClient.query('CREATE DATABASE life_os_identity_migration_test'); migrationPool = new Pool({ connectionString: databaseUrl(sourceUrl, TEMPORARY_DATABASE_NAME), }); @@ -81,18 +83,19 @@ async function withTemporaryDatabase( await migrationPool?.end(); } finally { try { - if (lockHeld) { + if (lockHeld && adminClient) { try { - await adminPool.query( + await adminClient.query( 'DROP DATABASE IF EXISTS life_os_identity_migration_test WITH (FORCE)', ); } finally { - await adminPool.query('SELECT pg_advisory_unlock($1::bigint)', [ + await adminClient.query('SELECT pg_advisory_unlock($1::bigint)', [ TEMPORARY_DATABASE_LOCK_KEY, ]); } } } finally { + adminClient?.release(); await adminPool.end(); } } diff --git a/apps/planning-service/migrations/0003_durable_today_sync.sql b/apps/planning-service/migrations/0003_durable_today_sync.sql new file mode 100644 index 00000000..607ce9eb --- /dev/null +++ b/apps/planning-service/migrations/0003_durable_today_sync.sql @@ -0,0 +1,61 @@ +CREATE TABLE planning.today_aggregates ( + workspace_id uuid NOT NULL, + local_date date NOT NULL, + aggregate_id uuid NOT NULL, + revision_number bigint NOT NULL CHECK (revision_number >= 1), + revision_token uuid NOT NULL, + payload_json jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT today_aggregates_pk PRIMARY KEY (workspace_id, local_date), + CONSTRAINT today_aggregates_id_unique UNIQUE (aggregate_id), + CONSTRAINT today_aggregates_uuidv4_check CHECK ( + workspace_id::text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + AND aggregate_id::text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + AND revision_token::text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT today_aggregates_payload_check CHECK ( + jsonb_typeof(payload_json) = 'object' + AND payload_json ->> 'version' = 'life-os.today.v1' + AND payload_json ->> 'date' ~ '^\d{4}-\d{2}-\d{2}$' + AND EXTRACT(YEAR FROM local_date)::integer = + substring(payload_json ->> 'date' FROM 1 FOR 4)::integer + AND EXTRACT(MONTH FROM local_date)::integer = + substring(payload_json ->> 'date' FROM 6 FOR 2)::integer + AND EXTRACT(DAY FROM local_date)::integer = + substring(payload_json ->> 'date' FROM 9 FOR 2)::integer + AND jsonb_typeof(payload_json -> 'actions') = 'array' + ) +); + +CREATE TABLE planning.today_idempotency_records ( + workspace_id uuid NOT NULL, + idempotency_key uuid NOT NULL, + request_digest text NOT NULL, + result_kind text NOT NULL CHECK (result_kind IN ('created', 'updated')), + aggregate_id uuid NOT NULL, + revision_token uuid NOT NULL, + payload_json jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT today_idempotency_records_pk + PRIMARY KEY (workspace_id, idempotency_key), + CONSTRAINT today_idempotency_records_uuidv4_check CHECK ( + workspace_id::text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + AND idempotency_key::text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + AND aggregate_id::text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + AND revision_token::text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT today_idempotency_records_digest_check + CHECK (request_digest ~ '^[0-9a-f]{64}$'), + CONSTRAINT today_idempotency_records_payload_check CHECK ( + jsonb_typeof(payload_json) = 'object' + AND payload_json ->> 'version' = 'life-os.today.v1' + AND jsonb_typeof(payload_json -> 'actions') = 'array' + ) +); + +CREATE INDEX today_aggregates_workspace_updated_idx + ON planning.today_aggregates (workspace_id, updated_at DESC); + +CREATE INDEX today_idempotency_created_idx + ON planning.today_idempotency_records (workspace_id, created_at DESC); diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index b9a09d92..ae7746c4 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -6,11 +6,14 @@ import { Get, Header, Headers, + HttpException, Inject, Module, Param, Post, + Put, Query, + Res, } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { PROMETHEUS_CONTENT_TYPE } from '@life-os/observability'; @@ -27,11 +30,28 @@ import type { Goal, Project, Task } from './planning-domain'; import { PlanningService } from './planning-domain'; import { createPlanningRuntime, PlanningRuntime } from './planning-runtime'; import type { PlanningSearchResult } from './search'; +import { + parseTodayWritePrecondition, + requireTodayPathDate, + toTodayHttpException, +} from './today-http'; +import { + TodaySyncService, + TodayValidationError, + type DurableTodayAggregate, +} from './today-sync'; /** Dependency-injection token for the production planning runtime. */ export const PLANNING_RUNTIME = Symbol('PLANNING_RUNTIME'); /** Dependency-injection token for the planning domain service. */ export const PLANNING_SERVICE = Symbol('PLANNING_SERVICE'); +/** Dependency-injection token for durable Today synchronization. */ +export const TODAY_SYNC_SERVICE = Symbol('TODAY_SYNC_SERVICE'); + +interface PassthroughResponse { + statusCode: number; + setHeader(name: string, value: string): void; +} /** Requires the tenant workspace boundary used by legacy planning operations. */ function requireWorkspaceId(value: string | undefined): string { @@ -42,12 +62,36 @@ function requireWorkspaceId(value: string | undefined): string { return workspaceId; } +/** Returns a stable not-found problem without disclosing another tenant's state. */ +function todayNotFound(): HttpException { + return new HttpException( + { + type: 'about:blank', + title: 'Today aggregate was not found', + status: 404, + code: 'today_not_found', + }, + 404, + ); +} + +/** Applies no-store and the strong opaque revision ETag to a Today response. */ +function setTodayResponseHeaders( + response: PassthroughResponse, + aggregate: DurableTodayAggregate, +): void { + response.setHeader('cache-control', 'no-store'); + response.setHeader('etag', `"${aggregate.revision}"`); +} + /** Exposes tenant-scoped planning operations and operational endpoints. */ @Controller() export class PlanningController { constructor( @Inject(PLANNING_SERVICE) private readonly planningService: PlanningService, + @Inject(TODAY_SYNC_SERVICE) + private readonly todayService: TodaySyncService, ) {} /** Returns a credential-free liveness response for the planning service. */ @@ -87,6 +131,72 @@ export class PlanningController { } } + /** Returns one durable Today aggregate for the authenticated workspace/date. */ + @Get('today/:date') + async getToday( + @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, + @Param('date') date: string, + @Res({ passthrough: true }) response: PassthroughResponse, + ): Promise { + try { + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, + ); + const aggregate = await this.todayService.getToday( + trustedWorkspaceId, + date, + ); + if (!aggregate) { + throw todayNotFound(); + } + setTodayResponseHeaders(response, aggregate); + return aggregate; + } catch (error) { + if (error instanceof HttpException) throw error; + throw toTodayHttpException(error); + } + } + + /** Creates or replaces one complete Today aggregate behind HTTP preconditions. */ + @Put('today/:date') + async putToday( + @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, + @Headers('if-match') ifMatch: string | undefined, + @Headers('if-none-match') ifNoneMatch: string | undefined, + @Headers('idempotency-key') idempotencyKey: string | undefined, + @Param('date') date: string, + @Body() body: unknown, + @Res({ passthrough: true }) response: PassthroughResponse, + ): Promise { + try { + const trustedWorkspaceId = requireTrustedWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.PLANNING_GATEWAY_CONTEXT_SECRET, + ); + requireTodayPathDate(date, body); + if (typeof idempotencyKey !== 'string') { + throw new TodayValidationError(); + } + const result = await this.todayService.putToday( + trustedWorkspaceId, + body, + parseTodayWritePrecondition(ifMatch, ifNoneMatch), + idempotencyKey, + ); + response.statusCode = result.kind === 'created' ? 201 : 200; + setTodayResponseHeaders(response, result.aggregate); + return result.aggregate; + } catch (error) { + if (error instanceof HttpException) throw error; + throw toTodayHttpException(error); + } + } + /** Creates a goal inside the caller's required workspace. */ @Post('goals') async createGoal( @@ -206,6 +316,12 @@ export class PlanningController { useFactory: (runtime: PlanningRuntime): PlanningService => runtime.service, }, + { + provide: TODAY_SYNC_SERVICE, + inject: [PLANNING_RUNTIME], + useFactory: (runtime: PlanningRuntime): TodaySyncService => + runtime.todayService, + }, ], }) export class AppModule {} diff --git a/apps/planning-service/src/planning-runtime.test.ts b/apps/planning-service/src/planning-runtime.test.ts index 1570dac1..1f96080a 100644 --- a/apps/planning-service/src/planning-runtime.test.ts +++ b/apps/planning-service/src/planning-runtime.test.ts @@ -4,6 +4,7 @@ import { createPlanningPoolConfiguration, createPlanningRuntime, type PlanningPool, + type PlanningPoolConnection, } from './planning-runtime'; const DATABASE_URL = [ @@ -13,13 +14,39 @@ const DATABASE_URL = [ 'life_os', ].join('/'); +class FakePlanningConnection implements PlanningPoolConnection { + readonly calls: string[] = []; + released: boolean | undefined; + + constructor(private readonly failCommit = false) {} + + async query(text: string): Promise<{ rows: Row[] }> { + this.calls.push(text); + if (this.failCommit && text === 'COMMIT') { + throw new Error('commit failed'); + } + return { rows: [] }; + } + + release(destroy = false): void { + this.released = destroy; + } +} + class FakePlanningPool implements PlanningPool { endCalls = 0; + readonly connections: FakePlanningConnection[] = []; async query(): Promise<{ rows: Row[] }> { return { rows: [] }; } + async connect(): Promise { + const connection = new FakePlanningConnection(); + this.connections.push(connection); + return connection; + } + async end(): Promise { this.endCalls += 1; } @@ -92,4 +119,27 @@ describe('Planning runtime', () => { await runtime.close(); expect(pool.endCalls).toBe(1); }); + + it('uses a dedicated transaction for Today writes and rolls back domain failures', async () => { + const pool = new FakePlanningPool(); + const runtime = createPlanningRuntime( + { PLANNING_DATABASE_URL: DATABASE_URL }, + () => pool, + ); + + await expect( + runtime.todayService.putToday( + '11111111-1111-4111-8111-111111111111', + { version: 'life-os.today.v1', date: '2026-08-09', actions: [] }, + { kind: 'absent' }, + '22222222-2222-4222-8222-222222222222', + ), + ).rejects.toThrow(); + + expect(pool.connections).toHaveLength(1); + expect(pool.connections[0]?.calls[0]).toBe('BEGIN'); + expect(pool.connections[0]?.calls.at(-1)).toBe('ROLLBACK'); + expect(pool.connections[0]?.released).toBe(false); + await runtime.close(); + }); }); diff --git a/apps/planning-service/src/planning-runtime.ts b/apps/planning-service/src/planning-runtime.ts index bbdfdf77..a405f6e6 100644 --- a/apps/planning-service/src/planning-runtime.ts +++ b/apps/planning-service/src/planning-runtime.ts @@ -1,26 +1,56 @@ import type { OnApplicationShutdown } from '@nestjs/common'; -import { Pool, type PoolConfig } from 'pg'; +import { Pool, type PoolClient, type PoolConfig } from 'pg'; import { PlanningService } from './planning-domain'; import { type PlanningSqlClient, type PlanningSqlQueryResult, PostgresPlanningRepository, } from './postgres-planning-repository'; +import { + PostgresTodayRepository, + type TodayTransactionalSqlClient, +} from './postgres-today-repository'; +import { TodaySyncService } from './today-sync'; const MAXIMUM_CONFIGURATION_LENGTH = 8 * 1024; type RuntimeEnvironment = Readonly>; +export interface PlanningPoolConnection { + query( + text: string, + values?: readonly unknown[], + ): Promise>; + release(destroy?: boolean): void; +} + export interface PlanningPool { query( text: string, values?: readonly unknown[], ): Promise>; + connect(): Promise; end(): Promise; } export type PlanningPoolFactory = (configuration: PoolConfig) => PlanningPool; +class NodePostgresPlanningPoolConnection implements PlanningPoolConnection { + constructor(private readonly client: PoolClient) {} + + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + const result = await this.client.query(text, [...values]); + return { rows: result.rows as Row[] }; + } + + release(destroy = false): void { + this.client.release(destroy); + } +} + class NodePostgresPlanningPool implements PlanningPool { constructor(private readonly pool: Pool) {} @@ -32,12 +62,27 @@ class NodePostgresPlanningPool implements PlanningPool { return { rows: result.rows as Row[] }; } + async connect(): Promise { + return new NodePostgresPlanningPoolConnection(await this.pool.connect()); + } + async end(): Promise { await this.pool.end(); } } -class NodePostgresPlanningSqlClient implements PlanningSqlClient { +class ConnectionSqlClient implements PlanningSqlClient { + constructor(private readonly connection: PlanningPoolConnection) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + return await this.connection.query(text, values); + } +} + +class NodePostgresPlanningSqlClient implements TodayTransactionalSqlClient { constructor(private readonly pool: PlanningPool) {} async query( @@ -46,6 +91,28 @@ class NodePostgresPlanningSqlClient implements PlanningSqlClient { ): Promise> { return await this.pool.query(text, values); } + + async transaction( + operation: (client: PlanningSqlClient) => Promise, + ): Promise { + const connection = await this.pool.connect(); + let destroyConnection = false; + try { + await connection.query('BEGIN'); + const result = await operation(new ConnectionSqlClient(connection)); + await connection.query('COMMIT'); + return result; + } catch (error) { + try { + await connection.query('ROLLBACK'); + } catch { + destroyConnection = true; + } + throw error; + } finally { + connection.release(destroyConnection); + } + } } function requireConfiguration( @@ -131,6 +198,7 @@ export class PlanningRuntime implements OnApplicationShutdown { constructor( private readonly pool: PlanningPool, readonly service: PlanningService, + readonly todayService: TodaySyncService, ) {} async close(): Promise { @@ -151,8 +219,12 @@ export function createPlanningRuntime( poolFactory: PlanningPoolFactory = defaultPoolFactory, ): PlanningRuntime { const pool = poolFactory(createPlanningPoolConfiguration(environment)); - const repository = new PostgresPlanningRepository( - new NodePostgresPlanningSqlClient(pool), + const client = new NodePostgresPlanningSqlClient(pool); + const repository = new PostgresPlanningRepository(client); + const todayRepository = new PostgresTodayRepository(client); + return new PlanningRuntime( + pool, + new PlanningService(repository), + new TodaySyncService(todayRepository), ); - return new PlanningRuntime(pool, new PlanningService(repository)); } diff --git a/apps/planning-service/src/postgres-planning-repository.integration.test.ts b/apps/planning-service/src/postgres-planning-repository.integration.test.ts index fa87be1d..786a4769 100644 --- a/apps/planning-service/src/postgres-planning-repository.integration.test.ts +++ b/apps/planning-service/src/postgres-planning-repository.integration.test.ts @@ -15,6 +15,10 @@ import { createPlanningRuntime, type PlanningRuntime, } from './planning-runtime'; +import { + TodayIdempotencyConflictError, + TodayRevisionConflictError, +} from './today-sync'; const DATABASE_URL = process.env.PLANNING_DATABASE_URL; const describeWithPostgres = DATABASE_URL ? describe : describe.skip; @@ -32,6 +36,7 @@ async function applyMigrations(pool: Pool): Promise { for (const migration of [ '0001_initial_planning.sql', '0002_durable_repository_contract.sql', + '0003_durable_today_sync.sql', ]) { const sql = await readFile( resolve(__dirname, '../migrations', migration), @@ -52,6 +57,25 @@ function createRuntime(): PlanningRuntime { return runtime; } +function todayDraft(date: string, title = 'Durable Today') { + return { + version: 'life-os.today.v1' as const, + date, + actions: [ + { + id: randomUUID(), + title, + status: 'open' as const, + priority: 1 as const, + startMinute: 540, + durationMinutes: 60, + createdAt: '2026-08-09T00:00:00.000Z', + completedAt: null, + }, + ], + }; +} + describeWithPostgres('PostgreSQL Planning repository integration', () => { beforeAll(async () => { administrativePool = new Pool({ @@ -65,7 +89,12 @@ describeWithPostgres('PostgreSQL Planning repository integration', () => { beforeEach(async () => { await administrativePool.query( - 'TRUNCATE planning.tasks, planning.projects, planning.goals', + `TRUNCATE + planning.today_idempotency_records, + planning.today_aggregates, + planning.tasks, + planning.projects, + planning.goals`, ); }); @@ -229,4 +258,111 @@ describeWithPostgres('PostgreSQL Planning repository integration', () => { runtime.service.search(otherWorkspaceId, 'evidence'), ).resolves.toEqual([]); }); + + it('persists Today across restarts while isolating workspaces', async () => { + const workspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const date = '2026-08-09'; + const firstRuntime = createRuntime(); + const draft = todayDraft(date); + const created = await firstRuntime.todayService.putToday( + workspaceId, + draft, + { kind: 'absent' }, + randomUUID(), + ); + await firstRuntime.close(); + + const restartedRuntime = createRuntime(); + await expect( + restartedRuntime.todayService.getToday(workspaceId, date), + ).resolves.toEqual(created.aggregate); + await expect( + restartedRuntime.todayService.getToday(otherWorkspaceId, date), + ).resolves.toBeUndefined(); + }); + + it('serializes concurrent Today updates so only the exact current revision wins', async () => { + const workspaceId = randomUUID(); + const date = '2026-08-09'; + const runtime = createRuntime(); + const created = await runtime.todayService.putToday( + workspaceId, + todayDraft(date), + { kind: 'absent' }, + randomUUID(), + ); + + const outcomes = await Promise.allSettled([ + runtime.todayService.putToday( + workspaceId, + todayDraft(date, 'Device A edit'), + { kind: 'match', revision: created.aggregate.revision }, + randomUUID(), + ), + runtime.todayService.putToday( + workspaceId, + todayDraft(date, 'Device B edit'), + { kind: 'match', revision: created.aggregate.revision }, + randomUUID(), + ), + ]); + const fulfilled = outcomes.filter( + (outcome) => outcome.status === 'fulfilled', + ); + const rejected = outcomes.filter( + (outcome) => outcome.status === 'rejected', + ); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf( + TodayRevisionConflictError, + ); + const current = await runtime.todayService.getToday(workspaceId, date); + expect(current?.revision).toBe( + ( + fulfilled[0] as PromiseFulfilledResult<{ + aggregate: { revision: string }; + }> + ).value.aggregate.revision, + ); + }); + + it('replays the original Today response after later revisions and rejects key reuse', async () => { + const workspaceId = randomUUID(); + const date = '2026-08-09'; + const runtime = createRuntime(); + const createKey = randomUUID(); + const originalDraft = todayDraft(date, 'Original Today'); + const created = await runtime.todayService.putToday( + workspaceId, + originalDraft, + { kind: 'absent' }, + createKey, + ); + await runtime.todayService.putToday( + workspaceId, + todayDraft(date, 'Newer Today'), + { kind: 'match', revision: created.aggregate.revision }, + randomUUID(), + ); + + const replay = await runtime.todayService.putToday( + workspaceId, + originalDraft, + { kind: 'absent' }, + createKey, + ); + expect(replay.kind).toBe('replayed'); + expect(replay.aggregate).toEqual(created.aggregate); + await expect( + runtime.todayService.putToday( + workspaceId, + todayDraft(date, 'Conflicting reuse'), + { kind: 'absent' }, + createKey, + ), + ).rejects.toBeInstanceOf(TodayIdempotencyConflictError); + }); }); diff --git a/apps/planning-service/src/postgres-today-repository-input.test.ts b/apps/planning-service/src/postgres-today-repository-input.test.ts new file mode 100644 index 00000000..19fe8804 --- /dev/null +++ b/apps/planning-service/src/postgres-today-repository-input.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import type { PlanningSqlQueryResult } from './postgres-planning-repository'; +import { + PostgresTodayRepository, + type TodayTransactionalSqlClient, +} from './postgres-today-repository'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const DATE = '2026-08-09'; + +class RejectingQueryClient implements TodayTransactionalSqlClient { + queryCalls = 0; + + async query( + _text: string, + _values: readonly unknown[], + ): Promise> { + this.queryCalls += 1; + return { rows: [] }; + } + + async transaction( + operation: (client: TodayTransactionalSqlClient) => Promise, + ): Promise { + return await operation(this); + } +} + +describe('PostgresTodayRepository lookup scope', () => { + it('rejects a malformed workspace identifier before issuing SQL', async () => { + const client = new RejectingQueryClient(); + const repository = new PostgresTodayRepository(client); + + await expect(repository.getToday('not-a-uuid', DATE)).rejects.toThrow(); + expect(client.queryCalls).toBe(0); + }); + + it('rejects an impossible local date before issuing SQL', async () => { + const client = new RejectingQueryClient(); + const repository = new PostgresTodayRepository(client); + + await expect(repository.getToday(WORKSPACE_ID, '2026-02-30')).rejects.toThrow(); + expect(client.queryCalls).toBe(0); + }); +}); diff --git a/apps/planning-service/src/postgres-today-repository.test.ts b/apps/planning-service/src/postgres-today-repository.test.ts new file mode 100644 index 00000000..50c32ad8 --- /dev/null +++ b/apps/planning-service/src/postgres-today-repository.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import type { PlanningSqlQueryResult } from './postgres-planning-repository'; +import { + PostgresTodayRepository, + type TodayTransactionalSqlClient, +} from './postgres-today-repository'; +import { + TodayIdempotencyConflictError, + TodayPersistenceError, + TodayRevisionConflictError, + type TodayWriteCommand, +} from './today-sync'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const AGGREGATE_ID = '22222222-2222-4222-8222-222222222222'; +const REVISION = '33333333-3333-4333-8333-333333333333'; +const NEW_REVISION = '44444444-4444-4444-8444-444444444444'; +const IDEMPOTENCY_KEY = '55555555-5555-4555-8555-555555555555'; +const DATE = '2026-08-09'; +const REQUEST_DIGEST = 'a'.repeat(64); + +interface QueryCall { + text: string; + values: readonly unknown[]; +} + +class RecordingClient implements TodayTransactionalSqlClient { + readonly calls: QueryCall[] = []; + transactionCalls = 0; + + constructor(private readonly responses: unknown[][]) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + this.calls.push({ text, values }); + return { rows: (this.responses.shift() ?? []) as Row[] }; + } + + async transaction( + operation: (client: TodayTransactionalSqlClient) => Promise, + ): Promise { + this.transactionCalls += 1; + return await operation(this); + } +} + +function payload(title = 'Durable Today') { + return { + version: 'life-os.today.v1', + date: DATE, + actions: [ + { + id: '66666666-6666-4666-8666-666666666666', + title, + status: 'open', + priority: 1, + startMinute: 540, + durationMinutes: 60, + createdAt: '2026-08-09T00:00:00.000Z', + completedAt: null, + }, + ], + }; +} + +function aggregateRow( + revision = REVISION, + title = 'Durable Today', + aggregateId = AGGREGATE_ID, +) { + return { + workspace_id: WORKSPACE_ID, + local_date: DATE, + aggregate_id: aggregateId, + revision_token: revision, + payload_json: payload(title), + }; +} + +function replayRow( + digest = REQUEST_DIGEST, + resultKind = 'created', + revision = REVISION, + title = 'Original response', +) { + return { + request_digest: digest, + result_kind: resultKind, + aggregate_id: AGGREGATE_ID, + revision_token: revision, + payload_json: payload(title), + }; +} + +function command( + precondition: TodayWriteCommand['precondition'] = { + kind: 'match', + revision: REVISION, + }, +): TodayWriteCommand { + return { + workspaceId: WORKSPACE_ID, + draft: payload() as TodayWriteCommand['draft'], + precondition, + idempotencyKey: IDEMPOTENCY_KEY, + requestDigest: REQUEST_DIGEST, + newAggregateId: AGGREGATE_ID, + newRevision: NEW_REVISION, + }; +} + +describe('PostgresTodayRepository', () => { + it('reads only one workspace/date aggregate and validates durable output', async () => { + const client = new RecordingClient([[aggregateRow()]]); + const repository = new PostgresTodayRepository(client); + + await expect(repository.getToday(WORKSPACE_ID, DATE)).resolves.toEqual({ + ...payload(), + aggregateId: AGGREGATE_ID, + revision: REVISION, + }); + expect(client.calls[0]?.values).toEqual([WORKSPACE_ID, DATE]); + expect(client.calls[0]?.text).toContain('workspace_id = $1::uuid'); + expect(client.calls[0]?.text).toContain('local_date = $2::date'); + expect(client.calls[0]?.text).toContain('LIMIT 2'); + }); + + it('rejects malformed lookup identifiers before PostgreSQL sees them', async () => { + const malformedWorkspaceClient = new RecordingClient([]); + const malformedDateClient = new RecordingClient([]); + + await expect( + new PostgresTodayRepository(malformedWorkspaceClient).getToday( + 'not-a-uuid', + DATE, + ), + ).rejects.toBeInstanceOf(TodayPersistenceError); + await expect( + new PostgresTodayRepository(malformedDateClient).getToday( + WORKSPACE_ID, + '2026-02-30', + ), + ).rejects.toBeInstanceOf(TodayPersistenceError); + expect(malformedWorkspaceClient.calls).toHaveLength(0); + expect(malformedDateClient.calls).toHaveLength(0); + }); + + it('locks aggregate then idempotency key before an optimistic update', async () => { + const client = new RecordingClient([ + [], + [], + [], + [aggregateRow()], + [aggregateRow(NEW_REVISION)], + [{ stored: true }], + ]); + const repository = new PostgresTodayRepository(client); + + await expect(repository.writeToday(command())).resolves.toEqual({ + kind: 'updated', + aggregate: { + ...payload(), + aggregateId: AGGREGATE_ID, + revision: NEW_REVISION, + }, + }); + expect(client.transactionCalls).toBe(1); + expect(client.calls).toHaveLength(6); + expect(client.calls[0]?.text).toContain('pg_advisory_xact_lock'); + expect(client.calls[0]?.values).toEqual([WORKSPACE_ID, DATE]); + expect(client.calls[1]?.text).toContain('pg_advisory_xact_lock'); + expect(client.calls[1]?.values).toEqual([WORKSPACE_ID, IDEMPOTENCY_KEY]); + expect(client.calls[2]?.text).toContain('today_idempotency_records'); + expect(client.calls[3]?.text).toContain('today_aggregates'); + expect(client.calls[4]?.text).toContain('UPDATE planning.today_aggregates'); + expect(client.calls[5]?.text).toContain( + 'INSERT INTO planning.today_idempotency_records', + ); + expect(client.calls.every((call) => !call.text.includes('Durable Today'))).toBe( + true, + ); + }); + + it('creates an absent aggregate after the same ordered locks', async () => { + const client = new RecordingClient([ + [], + [], + [], + [], + [aggregateRow(NEW_REVISION)], + [{ stored: true }], + ]); + const repository = new PostgresTodayRepository(client); + + await expect( + repository.writeToday(command({ kind: 'absent' })), + ).resolves.toMatchObject({ kind: 'created' }); + expect(client.calls[4]?.text).toContain('INSERT INTO planning.today_aggregates'); + }); + + it('returns the original response for an exact idempotent replay', async () => { + const client = new RecordingClient([[], [], [replayRow()]]); + const repository = new PostgresTodayRepository(client); + + await expect(repository.writeToday(command())).resolves.toEqual({ + kind: 'replayed', + aggregate: { + ...payload('Original response'), + aggregateId: AGGREGATE_ID, + revision: REVISION, + }, + }); + expect(client.calls).toHaveLength(3); + }); + + it('fails closed when an idempotency key is reused for a different request', async () => { + const client = new RecordingClient([ + [], + [], + [replayRow('b'.repeat(64))], + ]); + const repository = new PostgresTodayRepository(client); + + await expect(repository.writeToday(command())).rejects.toBeInstanceOf( + TodayIdempotencyConflictError, + ); + }); + + it('fails closed on stale or missing optimistic revisions', async () => { + const stale = new PostgresTodayRepository( + new RecordingClient([[], [], [], [aggregateRow(NEW_REVISION)]]), + ); + const missing = new PostgresTodayRepository( + new RecordingClient([[], [], [], []]), + ); + + await expect(stale.writeToday(command())).rejects.toEqual( + new TodayRevisionConflictError(NEW_REVISION), + ); + await expect(missing.writeToday(command())).rejects.toEqual( + new TodayRevisionConflictError(null), + ); + }); + + it('rejects create-if-absent when an aggregate already exists', async () => { + const repository = new PostgresTodayRepository( + new RecordingClient([[], [], [], [aggregateRow()]]), + ); + + await expect( + repository.writeToday(command({ kind: 'absent' })), + ).rejects.toEqual(new TodayRevisionConflictError(REVISION)); + }); + + it('fails closed when mutation or replay persistence does not produce one row', async () => { + const missingMutation = new PostgresTodayRepository( + new RecordingClient([[], [], [], [aggregateRow()], []]), + ); + const missingReplayReceipt = new PostgresTodayRepository( + new RecordingClient([ + [], + [], + [], + [aggregateRow()], + [aggregateRow(NEW_REVISION)], + [], + ]), + ); + + await expect(missingMutation.writeToday(command())).rejects.toEqual( + new TodayRevisionConflictError(REVISION), + ); + await expect(missingReplayReceipt.writeToday(command())).rejects.toBeInstanceOf( + TodayPersistenceError, + ); + }); + + it('rejects malformed replay kinds and malformed or duplicate durable rows', async () => { + const malformedReplay = new PostgresTodayRepository( + new RecordingClient([[], [], [replayRow(REQUEST_DIGEST, 'invalid')]]), + ); + const malformed = aggregateRow(); + malformed.aggregate_id = 'not-a-uuid'; + const duplicate = aggregateRow(); + const malformedRepository = new PostgresTodayRepository( + new RecordingClient([[malformed]]), + ); + const duplicateRepository = new PostgresTodayRepository( + new RecordingClient([[duplicate, duplicate]]), + ); + + await expect(malformedReplay.writeToday(command())).rejects.toBeInstanceOf( + TodayPersistenceError, + ); + await expect( + malformedRepository.getToday(WORKSPACE_ID, DATE), + ).rejects.toBeInstanceOf(TodayPersistenceError); + await expect( + duplicateRepository.getToday(WORKSPACE_ID, DATE), + ).rejects.toBeInstanceOf(TodayPersistenceError); + }); +}); diff --git a/apps/planning-service/src/postgres-today-repository.ts b/apps/planning-service/src/postgres-today-repository.ts new file mode 100644 index 00000000..810551f1 --- /dev/null +++ b/apps/planning-service/src/postgres-today-repository.ts @@ -0,0 +1,283 @@ +import type { + PlanningSqlClient, + PlanningSqlQueryResult, +} from './postgres-planning-repository'; +import { + canonicalTodayDate, + canonicalTodayDraft, + canonicalTodayUuidV4, +} from './today-invariants'; +import { + TodayIdempotencyConflictError, + TodayPersistenceError, + TodayRevisionConflictError, + type DurableTodayAggregate, + type TodayRepository, + type TodayWriteCommand, + type TodayWriteResult, +} from './today-sync'; + +const DIGEST_PATTERN = /^[0-9a-f]{64}$/u; + +interface TodayAggregateRow { + workspace_id: unknown; + local_date: unknown; + aggregate_id: unknown; + revision_token: unknown; + payload_json: unknown; +} + +interface TodayReplayRow { + request_digest: unknown; + result_kind: unknown; + aggregate_id: unknown; + revision_token: unknown; + payload_json: unknown; +} + +/** SQL client that can pin a sequence of statements to one database transaction. */ +export interface TodayTransactionalSqlClient extends PlanningSqlClient { + transaction( + operation: (client: PlanningSqlClient) => Promise, + ): Promise; +} + +/** Rejects malformed persisted data without returning its contents. */ +function invalidPersistence(): never { + throw new TodayPersistenceError(); +} + +/** Parses a persistence row into the public aggregate while enforcing ownership. */ +function parseAggregateRow( + row: TodayAggregateRow, + expectedWorkspaceId: string, + expectedDate: string, +): DurableTodayAggregate { + const workspaceId = canonicalTodayUuidV4( + row.workspace_id, + invalidPersistence, + ); + if (workspaceId !== expectedWorkspaceId.toLowerCase()) { + return invalidPersistence(); + } + const date = canonicalTodayDate(row.local_date, invalidPersistence, true); + if (date !== expectedDate) { + return invalidPersistence(); + } + const draft = canonicalTodayDraft( + row.payload_json, + invalidPersistence, + expectedDate, + ); + return Object.freeze({ + ...draft, + aggregateId: canonicalTodayUuidV4(row.aggregate_id, invalidPersistence), + revision: canonicalTodayUuidV4(row.revision_token, invalidPersistence), + }); +} + +/** Accepts at most one durable row for a unique lookup. */ +function oneOrUndefined(rows: Row[]): Row | undefined { + if (rows.length > 1) return invalidPersistence(); + return rows[0]; +} + +/** Requires one stored idempotency result kind. */ +function requireStoredResultKind(value: unknown): 'created' | 'updated' { + if (value !== 'created' && value !== 'updated') return invalidPersistence(); + return value; +} + +/** Requires a stored request digest without accepting arbitrary text. */ +function requireDigest(value: unknown): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + return invalidPersistence(); + } + return value; +} + +/** Builds a public aggregate from one stored idempotency row. */ +function parseReplayAggregate( + row: TodayReplayRow, + command: TodayWriteCommand, +): DurableTodayAggregate { + requireStoredResultKind(row.result_kind); + return parseAggregateRow( + { + workspace_id: command.workspaceId, + local_date: command.draft.date, + aggregate_id: row.aggregate_id, + revision_token: row.revision_token, + payload_json: row.payload_json, + }, + command.workspaceId, + command.draft.date, + ); +} + +/** PostgreSQL adapter for atomic, tenant-scoped durable Today synchronization. */ +export class PostgresTodayRepository implements TodayRepository { + /** Creates the adapter over a transaction-capable planning SQL client. */ + constructor(private readonly client: TodayTransactionalSqlClient) {} + + /** Reads at most one aggregate from exactly one workspace and local date. */ + async getToday( + workspaceId: string, + date: string, + ): Promise { + const normalizedWorkspaceId = canonicalTodayUuidV4( + workspaceId, + invalidPersistence, + ); + const normalizedDate = canonicalTodayDate(date, invalidPersistence); + const result = await this.client.query( + `SELECT workspace_id, local_date, aggregate_id, revision_token, payload_json + FROM planning.today_aggregates + WHERE workspace_id = $1::uuid AND local_date = $2::date + LIMIT 2`, + [normalizedWorkspaceId, normalizedDate], + ); + const row = oneOrUndefined(result.rows); + return row + ? parseAggregateRow(row, normalizedWorkspaceId, normalizedDate) + : undefined; + } + + /** + * Acquires aggregate and idempotency locks as separate statements on one + * dedicated transaction. Any statement that waits for a lock therefore takes + * its READ COMMITTED snapshot only after the wait completes. + */ + async writeToday(command: TodayWriteCommand): Promise { + return await this.client.transaction(async (transaction) => { + await transaction.query( + `SELECT pg_advisory_xact_lock( + hashtextextended($1::text || ':' || $2::text, 0) + )`, + [command.workspaceId, command.draft.date], + ); + await transaction.query( + `SELECT pg_advisory_xact_lock( + hashtextextended($1::text || ':' || $2::text, 1) + )`, + [command.workspaceId, command.idempotencyKey], + ); + + const replayResult = await transaction.query( + `SELECT request_digest, result_kind, aggregate_id, revision_token, payload_json + FROM planning.today_idempotency_records + WHERE workspace_id = $1::uuid AND idempotency_key = $2::uuid + LIMIT 2`, + [command.workspaceId, command.idempotencyKey], + ); + const replay = oneOrUndefined(replayResult.rows); + if (replay) { + if (requireDigest(replay.request_digest) !== command.requestDigest) { + throw new TodayIdempotencyConflictError(); + } + return { + kind: 'replayed', + aggregate: parseReplayAggregate(replay, command), + }; + } + + const currentResult = await transaction.query( + `SELECT workspace_id, local_date, aggregate_id, revision_token, payload_json + FROM planning.today_aggregates + WHERE workspace_id = $1::uuid AND local_date = $2::date + LIMIT 2`, + [command.workspaceId, command.draft.date], + ); + const currentRow = oneOrUndefined(currentResult.rows); + const current = currentRow + ? parseAggregateRow( + currentRow, + command.workspaceId, + command.draft.date, + ) + : undefined; + + if (command.precondition.kind === 'absent') { + if (current) { + throw new TodayRevisionConflictError(current.revision); + } + } else if (!current || current.revision !== command.precondition.revision) { + throw new TodayRevisionConflictError(current?.revision ?? null); + } + + let resultKind: 'created' | 'updated'; + let mutationResult: PlanningSqlQueryResult; + if (command.precondition.kind === 'absent') { + resultKind = 'created'; + mutationResult = await transaction.query( + `INSERT INTO planning.today_aggregates + (workspace_id, local_date, aggregate_id, revision_number, + revision_token, payload_json, created_at, updated_at) + VALUES ($1::uuid, $2::date, $3::uuid, 1, $4::uuid, $5::jsonb, + clock_timestamp(), clock_timestamp()) + RETURNING workspace_id, local_date, aggregate_id, revision_token, payload_json`, + [ + command.workspaceId, + command.draft.date, + command.newAggregateId, + command.newRevision, + JSON.stringify(command.draft), + ], + ); + } else { + resultKind = 'updated'; + mutationResult = await transaction.query( + `UPDATE planning.today_aggregates + SET revision_number = revision_number + 1, + revision_token = $4::uuid, + payload_json = $5::jsonb, + updated_at = clock_timestamp() + WHERE workspace_id = $1::uuid + AND local_date = $2::date + AND revision_token = $3::uuid + RETURNING workspace_id, local_date, aggregate_id, revision_token, payload_json`, + [ + command.workspaceId, + command.draft.date, + command.precondition.revision, + command.newRevision, + JSON.stringify(command.draft), + ], + ); + } + + const mutationRow = oneOrUndefined(mutationResult.rows); + if (!mutationRow) { + throw new TodayRevisionConflictError(current?.revision ?? null); + } + const aggregate = parseAggregateRow( + mutationRow, + command.workspaceId, + command.draft.date, + ); + + const replayInsert = await transaction.query<{ stored: unknown }>( + `INSERT INTO planning.today_idempotency_records + (workspace_id, idempotency_key, request_digest, result_kind, + aggregate_id, revision_token, payload_json, created_at) + VALUES ($1::uuid, $2::uuid, $3, $4, + $5::uuid, $6::uuid, $7::jsonb, clock_timestamp()) + RETURNING TRUE AS stored`, + [ + command.workspaceId, + command.idempotencyKey, + command.requestDigest, + resultKind, + aggregate.aggregateId, + aggregate.revision, + JSON.stringify(command.draft), + ], + ); + if (replayInsert.rows.length !== 1 || replayInsert.rows[0]?.stored !== true) { + return invalidPersistence(); + } + + return { kind: resultKind, aggregate }; + }); + } +} diff --git a/apps/planning-service/src/today-http.test.ts b/apps/planning-service/src/today-http.test.ts new file mode 100644 index 00000000..09a4cf46 --- /dev/null +++ b/apps/planning-service/src/today-http.test.ts @@ -0,0 +1,92 @@ +import { HttpException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { + parseTodayWritePrecondition, + requireTodayPathDate, + toTodayHttpException, +} from './today-http'; +import { + TodayIdempotencyConflictError, + TodayPersistenceError, + TodayRevisionConflictError, + TodayValidationError, +} from './today-sync'; + +const REVISION = '33333333-3333-4333-8333-333333333333'; + +describe('Today HTTP boundary', () => { + it('uses If-None-Match star only for an explicit create and quoted If-Match for update', () => { + expect(parseTodayWritePrecondition(undefined, '*')).toEqual({ + kind: 'absent', + }); + expect(parseTodayWritePrecondition(`"${REVISION}"`, undefined)).toEqual({ + kind: 'match', + revision: REVISION, + }); + }); + + it('requires exactly one valid conditional request header', () => { + for (const [ifMatch, ifNoneMatch] of [ + [undefined, undefined], + [`"${REVISION}"`, '*'], + [REVISION, undefined], + ['*', undefined], + [`W/"${REVISION}"`, undefined], + [`"${REVISION}", "${REVISION}"`, undefined], + [undefined, '"anything"'], + ] as const) { + expect(() => parseTodayWritePrecondition(ifMatch, ifNoneMatch)).toThrow( + HttpException, + ); + } + }); + + it('requires a real route date identical to the body date', () => { + expect( + requireTodayPathDate('2026-08-09', { + date: '2026-08-09', + }), + ).toBe('2026-08-09'); + for (const invalid of [ + ['2026-02-30', { date: '2026-02-30' }], + ['2026-08-09', { date: '2026-08-10' }], + ['2026-08-09', null], + ] as const) { + expect(() => requireTodayPathDate(invalid[0], invalid[1])).toThrow( + TodayValidationError, + ); + } + }); + + it('maps domain failures to bounded HTTP problems', () => { + const conflict = toTodayHttpException( + new TodayRevisionConflictError(REVISION), + ); + expect(conflict.getStatus()).toBe(409); + expect(conflict.getResponse()).toEqual({ + type: 'about:blank', + title: 'Today changed on another device', + status: 409, + code: 'today_revision_conflict', + currentRevision: REVISION, + }); + expect( + toTodayHttpException(new TodayIdempotencyConflictError()).getStatus(), + ).toBe(409); + expect(toTodayHttpException(new TodayValidationError()).getStatus()).toBe( + 400, + ); + const persistence = toTodayHttpException(new TodayPersistenceError()); + expect(persistence.getStatus()).toBe(500); + expect(persistence.getResponse()).toEqual({ + type: 'about:blank', + title: 'Today synchronization data is unusable', + status: 500, + code: 'today_persistence_invalid', + }); + expect(toTodayHttpException(new Error('database down')).getStatus()).toBe( + 503, + ); + expect(toTodayHttpException(conflict)).toBe(conflict); + }); +}); diff --git a/apps/planning-service/src/today-http.ts b/apps/planning-service/src/today-http.ts new file mode 100644 index 00000000..718f7379 --- /dev/null +++ b/apps/planning-service/src/today-http.ts @@ -0,0 +1,139 @@ +import { HttpException } from '@nestjs/common'; +import { + canonicalTodayDate, + canonicalTodayUuidV4, +} from './today-invariants'; +import { + TodayIdempotencyConflictError, + TodayPersistenceError, + TodayRevisionConflictError, + TodayValidationError, + type TodayWritePrecondition, +} from './today-sync'; + +/** Bounded RFC 9457-compatible problem object for Today synchronization. */ +interface TodayProblem { + readonly type: 'about:blank'; + readonly title: string; + readonly status: number; + readonly code: string; + readonly currentRevision?: string | null; +} + +/** Creates one credential-free Today problem response. */ +function problem( + status: number, + title: string, + code: string, + currentRevision?: string | null, +): HttpException { + const body: TodayProblem = { + type: 'about:blank', + title, + status, + code, + ...(currentRevision === undefined ? {} : { currentRevision }), + }; + return new HttpException(body, status); +} + +/** Throws the shared domain validation error for malformed request content. */ +function invalidTodayRequest(): never { + throw new TodayValidationError(); +} + +/** Throws the stable HTTP problem for malformed conditional headers. */ +function invalidTodayPrecondition(): never { + throw problem( + 400, + 'Today write preconditions are invalid', + 'invalid_today_precondition', + ); +} + +/** Requires a real calendar date and exact agreement between route and body. */ +export function requireTodayPathDate( + routeDate: unknown, + body: unknown, +): string { + if ( + !body || + typeof body !== 'object' || + Array.isArray(body) || + (body as Record).date !== routeDate + ) { + throw new TodayValidationError(); + } + return canonicalTodayDate(routeDate, invalidTodayRequest); +} + +/** + * Converts HTTP conditional headers into the domain's explicit create/update + * precondition without accepting weak, list, wildcard-update, or unquoted tags. + */ +export function parseTodayWritePrecondition( + ifMatch: string | undefined, + ifNoneMatch: string | undefined, +): TodayWritePrecondition { + if (ifMatch === undefined && ifNoneMatch === undefined) { + throw problem( + 428, + 'A Today write precondition is required', + 'today_precondition_required', + ); + } + if (ifMatch !== undefined && ifNoneMatch !== undefined) { + return invalidTodayPrecondition(); + } + if (ifNoneMatch !== undefined) { + if (ifNoneMatch !== '*') return invalidTodayPrecondition(); + return Object.freeze({ kind: 'absent' }); + } + const match = /^"([^"\r\n]+)"$/u.exec(ifMatch ?? ''); + if (!match?.[1]) return invalidTodayPrecondition(); + return Object.freeze({ + kind: 'match', + revision: canonicalTodayUuidV4(match[1], invalidTodayPrecondition), + }); +} + +/** Maps Today domain/persistence failures to stable credential-free HTTP errors. */ +export function toTodayHttpException(error: unknown): HttpException { + if (error instanceof HttpException) { + return error; + } + if (error instanceof TodayRevisionConflictError) { + return problem( + 409, + 'Today changed on another device', + 'today_revision_conflict', + error.currentRevision, + ); + } + if (error instanceof TodayIdempotencyConflictError) { + return problem( + 409, + 'Today idempotency key conflicts with an earlier request', + 'today_idempotency_conflict', + ); + } + if (error instanceof TodayValidationError) { + return problem( + 400, + 'Today synchronization request is invalid', + 'invalid_today_request', + ); + } + if (error instanceof TodayPersistenceError) { + return problem( + 500, + 'Today synchronization data is unusable', + 'today_persistence_invalid', + ); + } + return problem( + 503, + 'Today synchronization is unavailable', + 'today_sync_unavailable', + ); +} diff --git a/apps/planning-service/src/today-invariants.test.ts b/apps/planning-service/src/today-invariants.test.ts new file mode 100644 index 00000000..3e4293b1 --- /dev/null +++ b/apps/planning-service/src/today-invariants.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + TODAY_VERSION, + canonicalTodayDate, + canonicalTodayDraft, + canonicalTodayUuidV4, +} from './today-invariants'; + +class InvariantFailure extends Error {} + +function fail(): never { + throw new InvariantFailure(); +} + +describe('shared Today invariants', () => { + it('canonicalizes shared identifier and calendar rules', () => { + expect( + canonicalTodayUuidV4('A0EBC2A3-3D39-4B78-88AF-7F952C9049AD', fail), + ).toBe('a0ebc2a3-3d39-4b78-88af-7f952c9049ad'); + expect(canonicalTodayDate('2026-08-10', fail)).toBe('2026-08-10'); + expect(() => canonicalTodayDate('2026-02-30', fail)).toThrow( + InvariantFailure, + ); + }); + + it('validates the complete draft once for domain and persistence callers', () => { + const draft = canonicalTodayDraft( + { + version: TODAY_VERSION, + date: '2026-08-10', + actions: [ + { + id: 'f4fd9ff3-d182-4516-a30e-b954c8b44ae2', + title: ' Finish the review ', + status: 'open', + priority: 1, + startMinute: 540, + durationMinutes: 30, + createdAt: '2026-08-09T21:00:00Z', + completedAt: null, + }, + ], + }, + fail, + '2026-08-10', + ); + + expect(draft).toEqual({ + version: 'life-os.today.v1', + date: '2026-08-10', + actions: [ + { + id: 'f4fd9ff3-d182-4516-a30e-b954c8b44ae2', + title: 'Finish the review', + status: 'open', + priority: 1, + startMinute: 540, + durationMinutes: 30, + createdAt: '2026-08-09T21:00:00.000Z', + completedAt: null, + }, + ], + }); + }); +}); diff --git a/apps/planning-service/src/today-invariants.ts b/apps/planning-service/src/today-invariants.ts new file mode 100644 index 00000000..dbaf65d3 --- /dev/null +++ b/apps/planning-service/src/today-invariants.ts @@ -0,0 +1,261 @@ +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}$/i; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; +const RFC_3339_UTC_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/u; +const MAXIMUM_ACTIONS = 50; +const MAXIMUM_TITLE_CODE_POINTS = 160; +const MAXIMUM_TITLE_BYTES = 1024; +const MINIMUM_DURATION_MINUTES = 15; +const MAXIMUM_DURATION_MINUTES = 240; +const MINUTES_PER_DAY = 24 * 60; + +/** Version identifier for the complete durable Today document. */ +export const TODAY_VERSION = 'life-os.today.v1' as const; + +/** Durable action state stored inside one workspace/date Today aggregate. */ +export interface DurableTodayAction { + readonly id: string; + readonly title: string; + readonly status: 'open' | 'done'; + readonly priority: 1 | 2 | 3 | null; + readonly startMinute: number | null; + readonly durationMinutes: number | null; + readonly createdAt: string; + readonly completedAt: string | null; +} + +/** Client-supplied complete Today state before server-owned identity/revision fields. */ +export interface DurableTodayDraft { + readonly version: typeof TODAY_VERSION; + readonly date: string; + readonly actions: readonly DurableTodayAction[]; +} + +/** Caller-owned failure factory used without coupling validation to one layer. */ +export type TodayInvariantFailure = () => never; + +/** Requires one canonical UUIDv4 identifier and lowercases it. */ +export function canonicalTodayUuidV4( + value: unknown, + fail: TodayInvariantFailure, +): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) return fail(); + return value.toLowerCase(); +} + +/** Requires one real canonical Gregorian calendar date. */ +export function canonicalTodayDate( + value: unknown, + fail: TodayInvariantFailure, + allowDateObject = false, +): string { + if (allowDateObject && value instanceof Date) { + if (Number.isNaN(value.getTime())) return fail(); + return value.toISOString().slice(0, 10); + } + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) return fail(); + const parsed = new Date(`${value}T00:00:00.000Z`); + if ( + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== value + ) { + return fail(); + } + return value; +} + +/** Requires one canonical UTC RFC3339 instant. */ +export function canonicalTodayInstant( + value: unknown, + fail: TodayInvariantFailure, +): string { + if (typeof value !== 'string' || !RFC_3339_UTC_PATTERN.test(value)) { + return fail(); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return fail(); + return parsed.toISOString(); +} + +/** Requires one bounded user-visible action title. */ +function canonicalTodayTitle( + value: unknown, + fail: TodayInvariantFailure, +): string { + if (typeof value !== 'string') return fail(); + const normalized = value.trim(); + if ( + !normalized || + [...normalized].length > MAXIMUM_TITLE_CODE_POINTS || + Buffer.byteLength(normalized, 'utf8') > MAXIMUM_TITLE_BYTES || + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(normalized) + ) { + return fail(); + } + return normalized; +} + +/** Requires one nullable quarter-hour schedule start. */ +function canonicalStartMinute( + value: unknown, + fail: TodayInvariantFailure, +): number | null { + if (value === null) return null; + if ( + !Number.isSafeInteger(value) || + (value as number) < 0 || + (value as number) >= MINUTES_PER_DAY || + (value as number) % 15 !== 0 + ) { + return fail(); + } + return value as number; +} + +/** Requires one nullable bounded quarter-hour duration. */ +function canonicalDuration( + value: unknown, + fail: TodayInvariantFailure, +): number | null { + if (value === null) return null; + if ( + !Number.isSafeInteger(value) || + (value as number) < MINIMUM_DURATION_MINUTES || + (value as number) > MAXIMUM_DURATION_MINUTES || + (value as number) % 15 !== 0 + ) { + return fail(); + } + return value as number; +} + +/** Validates one action and returns its canonical immutable representation. */ +function canonicalTodayAction( + value: unknown, + fail: TodayInvariantFailure, +): DurableTodayAction { + if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(); + const action = value as Record; + const exactKeys = [ + 'id', + 'title', + 'status', + 'priority', + 'startMinute', + 'durationMinutes', + 'createdAt', + 'completedAt', + ]; + if ( + Object.keys(action).length !== exactKeys.length || + exactKeys.some((key) => !Object.hasOwn(action, key)) + ) { + return fail(); + } + const status = action.status; + if (status !== 'open' && status !== 'done') return fail(); + const priority = action.priority; + if (priority !== null && priority !== 1 && priority !== 2 && priority !== 3) { + return fail(); + } + const startMinute = canonicalStartMinute(action.startMinute, fail); + const durationMinutes = canonicalDuration(action.durationMinutes, fail); + if ((startMinute === null) !== (durationMinutes === null)) return fail(); + if ( + startMinute !== null && + durationMinutes !== null && + startMinute + durationMinutes > MINUTES_PER_DAY + ) { + return fail(); + } + const completedAt = + action.completedAt === null + ? null + : canonicalTodayInstant(action.completedAt, fail); + if ( + (status === 'done' && completedAt === null) || + (status === 'open' && completedAt !== null) + ) { + return fail(); + } + return Object.freeze({ + id: canonicalTodayUuidV4(action.id, fail), + title: canonicalTodayTitle(action.title, fail), + status, + priority, + startMinute, + durationMinutes, + createdAt: canonicalTodayInstant(action.createdAt, fail), + completedAt, + }); +} + +/** Validates duplicate, priority and overlapping-open-schedule invariants. */ +function canonicalActionSet( + value: unknown, + fail: TodayInvariantFailure, +): readonly DurableTodayAction[] { + if (!Array.isArray(value) || value.length > MAXIMUM_ACTIONS) return fail(); + const actions = value.map((action) => canonicalTodayAction(action, fail)); + const identifiers = new Set(); + const priorities = new Set(); + for (const action of actions) { + if (identifiers.has(action.id)) return fail(); + identifiers.add(action.id); + if (action.priority !== null) { + if (priorities.has(action.priority)) return fail(); + priorities.add(action.priority); + } + } + const scheduled = actions + .filter( + (action) => + action.status === 'open' && + action.startMinute !== null && + action.durationMinutes !== null, + ) + .sort( + (left, right) => + (left.startMinute ?? 0) - (right.startMinute ?? 0) || + left.id.localeCompare(right.id), + ); + for (let index = 1; index < scheduled.length; index += 1) { + const previous = scheduled[index - 1]; + const current = scheduled[index]; + if ( + previous && + current && + (previous.startMinute ?? 0) + (previous.durationMinutes ?? 0) > + (current.startMinute ?? 0) + ) { + return fail(); + } + } + return Object.freeze(actions); +} + +/** Validates one complete Today draft for both domain and persistence callers. */ +export function canonicalTodayDraft( + value: unknown, + fail: TodayInvariantFailure, + expectedDate?: string, +): DurableTodayDraft { + if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(); + const draft = value as Record; + if ( + Object.keys(draft).length !== 3 || + draft.version !== TODAY_VERSION || + !Object.hasOwn(draft, 'date') || + !Object.hasOwn(draft, 'actions') + ) { + return fail(); + } + const date = canonicalTodayDate(draft.date, fail); + if (expectedDate !== undefined && date !== expectedDate) return fail(); + return Object.freeze({ + version: TODAY_VERSION, + date, + actions: canonicalActionSet(draft.actions, fail), + }); +} diff --git a/apps/planning-service/src/today-sync.test.ts b/apps/planning-service/src/today-sync.test.ts new file mode 100644 index 00000000..4b00e3f8 --- /dev/null +++ b/apps/planning-service/src/today-sync.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; +import { + InMemoryTodayRepository, + TodayIdempotencyConflictError, + TodayRevisionConflictError, + TodaySyncService, + TodayValidationError, +} from './today-sync'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_WORKSPACE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const DATE = '2026-08-09'; +const ACTION_ID = '22222222-2222-4222-8222-222222222222'; +const SECOND_ACTION_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; +const SECOND_IDEMPOTENCY_KEY = '55555555-5555-4555-8555-555555555555'; + +function draft(title = 'Ship durable Today') { + return { + version: 'life-os.today.v1' as const, + date: DATE, + actions: [ + { + id: ACTION_ID, + title, + status: 'open' as const, + priority: 1 as const, + startMinute: 9 * 60, + durationMinutes: 60, + createdAt: '2026-08-09T00:00:00.000Z', + completedAt: null, + }, + ], + }; +} + +describe('TodaySyncService', () => { + it('creates one durable workspace/date aggregate behind an absent precondition', async () => { + const service = new TodaySyncService(new InMemoryTodayRepository()); + + const result = await service.putToday( + WORKSPACE_ID, + draft(), + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ); + + expect(result.kind).toBe('created'); + expect(result.aggregate.aggregateId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(result.aggregate.revision).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(result.aggregate.date).toBe(DATE); + expect(result.aggregate.actions).toEqual(draft().actions); + await expect(service.getToday(WORKSPACE_ID, DATE)).resolves.toEqual( + result.aggregate, + ); + await expect(service.getToday(OTHER_WORKSPACE_ID, DATE)).resolves.toBeUndefined(); + }); + + it('updates only when the exact opaque revision matches and rotates the token', async () => { + const service = new TodaySyncService(new InMemoryTodayRepository()); + const created = await service.putToday( + WORKSPACE_ID, + draft(), + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ); + + const updated = await service.putToday( + WORKSPACE_ID, + draft('Ship Today across devices'), + { kind: 'match', revision: created.aggregate.revision }, + SECOND_IDEMPOTENCY_KEY, + ); + + expect(updated.kind).toBe('updated'); + expect(updated.aggregate.aggregateId).toBe(created.aggregate.aggregateId); + expect(updated.aggregate.revision).not.toBe(created.aggregate.revision); + expect(updated.aggregate.actions[0]?.title).toBe('Ship Today across devices'); + }); + + it('returns only the current opaque revision on stale-write conflicts', async () => { + const service = new TodaySyncService(new InMemoryTodayRepository()); + const created = await service.putToday( + WORKSPACE_ID, + draft(), + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ); + + await expect( + service.putToday( + WORKSPACE_ID, + draft('Stale overwrite'), + { kind: 'match', revision: '66666666-6666-4666-8666-666666666666' }, + SECOND_IDEMPOTENCY_KEY, + ), + ).rejects.toEqual(new TodayRevisionConflictError(created.aggregate.revision)); + }); + + it('replays an exact idempotency key without rotating revision and rejects conflicting reuse', async () => { + const service = new TodaySyncService(new InMemoryTodayRepository()); + const first = await service.putToday( + WORKSPACE_ID, + draft(), + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ); + const replay = await service.putToday( + WORKSPACE_ID, + draft(), + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ); + + expect(replay.kind).toBe('replayed'); + expect(replay.aggregate).toEqual(first.aggregate); + await expect( + service.putToday( + WORKSPACE_ID, + draft('Conflicting replay'), + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ), + ).rejects.toBeInstanceOf(TodayIdempotencyConflictError); + }); + + it('rejects invalid priority and schedule state before persistence', async () => { + const service = new TodaySyncService(new InMemoryTodayRepository()); + const invalid = { + ...draft(), + actions: [ + ...draft().actions, + { + id: SECOND_ACTION_ID, + title: 'Overlapping priority', + status: 'open' as const, + priority: 1 as const, + startMinute: 9 * 60 + 30, + durationMinutes: 60, + createdAt: '2026-08-09T00:01:00.000Z', + completedAt: null, + }, + ], + }; + + await expect( + service.putToday( + WORKSPACE_ID, + invalid, + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ), + ).rejects.toBeInstanceOf(TodayValidationError); + }); + + it('requires completed actions to carry completion evidence and UUIDv4 ownership inputs', async () => { + const service = new TodaySyncService(new InMemoryTodayRepository()); + const invalid = { + ...draft(), + actions: [ + { + ...draft().actions[0], + status: 'done' as const, + completedAt: null, + }, + ], + }; + + await expect( + service.putToday( + WORKSPACE_ID, + invalid, + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ), + ).rejects.toBeInstanceOf(TodayValidationError); + await expect( + service.putToday( + '12345', + draft(), + { kind: 'absent' }, + IDEMPOTENCY_KEY, + ), + ).rejects.toBeInstanceOf(TodayValidationError); + }); +}); diff --git a/apps/planning-service/src/today-sync.ts b/apps/planning-service/src/today-sync.ts new file mode 100644 index 00000000..d00e6576 --- /dev/null +++ b/apps/planning-service/src/today-sync.ts @@ -0,0 +1,245 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + TODAY_VERSION, + canonicalTodayDate, + canonicalTodayDraft, + canonicalTodayUuidV4, + type DurableTodayAction, + type DurableTodayDraft, +} from './today-invariants'; + +export type { DurableTodayAction, DurableTodayDraft } from './today-invariants'; + +/** Server-owned durable Today aggregate returned to authenticated callers. */ +export interface DurableTodayAggregate extends DurableTodayDraft { + readonly aggregateId: string; + readonly revision: string; +} + +/** Write precondition for initial creation or exact-revision replacement. */ +export type TodayWritePrecondition = + | { readonly kind: 'absent' } + | { readonly kind: 'match'; readonly revision: string }; + +/** Result of a durable Today write, including exact idempotent replay. */ +export interface TodayWriteResult { + readonly kind: 'created' | 'updated' | 'replayed'; + readonly aggregate: DurableTodayAggregate; +} + +/** Stable validation failure for malformed or self-inconsistent Today state. */ +export class TodayValidationError extends Error { + /** Creates a credential-free validation error. */ + constructor() { + super('Today synchronization request is invalid'); + this.name = 'TodayValidationError'; + } +} + +/** Stable failure when durable Today rows violate repository invariants. */ +export class TodayPersistenceError extends Error { + /** Creates a credential-free persistence validation failure. */ + constructor() { + super('Persisted Today data is invalid'); + this.name = 'TodayPersistenceError'; + } +} + +/** Optimistic-concurrency failure exposing only the current opaque revision token. */ +export class TodayRevisionConflictError extends Error { + /** Creates a stale-write conflict without returning server-side content. */ + constructor(readonly currentRevision: string | null) { + super('Today revision does not match'); + this.name = 'TodayRevisionConflictError'; + } +} + +/** Idempotency-key reuse failure when the same key is bound to another request. */ +export class TodayIdempotencyConflictError extends Error { + /** Creates a fixed non-sensitive idempotency conflict. */ + constructor() { + super('Today idempotency key was already used for a different request'); + this.name = 'TodayIdempotencyConflictError'; + } +} + +/** Persistence command containing only fully validated values. */ +export interface TodayWriteCommand { + readonly workspaceId: string; + readonly draft: DurableTodayDraft; + readonly precondition: TodayWritePrecondition; + readonly idempotencyKey: string; + readonly requestDigest: string; + readonly newAggregateId: string; + readonly newRevision: string; +} + +/** Persistence boundary for one durable Today aggregate per workspace/local date. */ +export interface TodayRepository { + /** Returns the exact workspace/date aggregate or no record. */ + getToday( + workspaceId: string, + date: string, + ): Promise; + /** Atomically applies one validated optimistic/idempotent write command. */ + writeToday(command: TodayWriteCommand): Promise; +} + +interface IdempotencyRecord { + readonly requestDigest: string; + readonly result: TodayWriteResult; +} + +/** Throws the domain validation error expected by shared invariant helpers. */ +function invalidTodayInput(): never { + throw new TodayValidationError(); +} + +/** In-memory adapter used by deterministic domain tests. */ +export class InMemoryTodayRepository implements TodayRepository { + private readonly aggregates = new Map(); + private readonly idempotency = new Map(); + + /** Reads only the requested workspace/date key. */ + async getToday( + workspaceId: string, + date: string, + ): Promise { + return this.aggregates.get(aggregateKey(workspaceId, date)); + } + + /** Applies replay detection and optimistic concurrency as one in-memory operation. */ + async writeToday(command: TodayWriteCommand): Promise { + const replayKey = `${command.workspaceId}\n${command.idempotencyKey}`; + const replay = this.idempotency.get(replayKey); + if (replay) { + if (replay.requestDigest !== command.requestDigest) { + throw new TodayIdempotencyConflictError(); + } + return { kind: 'replayed', aggregate: replay.result.aggregate }; + } + + const key = aggregateKey(command.workspaceId, command.draft.date); + const current = this.aggregates.get(key); + if (command.precondition.kind === 'absent') { + if (current) { + throw new TodayRevisionConflictError(current.revision); + } + } else if (!current || current.revision !== command.precondition.revision) { + throw new TodayRevisionConflictError(current?.revision ?? null); + } + + const aggregate: DurableTodayAggregate = Object.freeze({ + version: TODAY_VERSION, + aggregateId: current?.aggregateId ?? command.newAggregateId, + revision: command.newRevision, + date: command.draft.date, + actions: command.draft.actions, + }); + const result: TodayWriteResult = { + kind: current ? 'updated' : 'created', + aggregate, + }; + this.aggregates.set(key, aggregate); + this.idempotency.set(replayKey, { + requestDigest: command.requestDigest, + result, + }); + return result; + } +} + +/** Validates the initial-creation or exact-revision write condition. */ +function requirePrecondition( + value: TodayWritePrecondition, +): TodayWritePrecondition { + if (value.kind === 'absent') { + return Object.freeze({ kind: 'absent' }); + } + if (value.kind === 'match') { + return Object.freeze({ + kind: 'match', + revision: canonicalTodayUuidV4(value.revision, invalidTodayInput), + }); + } + throw new TodayValidationError(); +} + +/** Builds a stable aggregate key without exposing it outside the adapter. */ +function aggregateKey(workspaceId: string, date: string): string { + return `${workspaceId}\n${date}`; +} + +/** Hashes the canonical validated request for idempotency-key binding. */ +function requestDigest( + workspaceId: string, + draft: DurableTodayDraft, + precondition: TodayWritePrecondition, +): string { + return createHash('sha256') + .update( + JSON.stringify({ + workspaceId, + draft, + precondition, + }), + 'utf8', + ) + .digest('hex'); +} + +/** Coordinates validated tenant-scoped optimistic Today synchronization. */ +export class TodaySyncService { + /** Creates the service over one persistence adapter. */ + constructor(private readonly repository: TodayRepository) {} + + /** Returns one durable Today aggregate without crossing workspace ownership. */ + async getToday( + workspaceId: string, + date: string, + ): Promise { + const safeWorkspaceId = canonicalTodayUuidV4( + workspaceId, + invalidTodayInput, + ); + const safeDate = canonicalTodayDate(date, invalidTodayInput); + const aggregate = await this.repository.getToday(safeWorkspaceId, safeDate); + if (!aggregate) return undefined; + if (aggregate.date !== safeDate) { + throw new TodayValidationError(); + } + return aggregate; + } + + /** Creates or replaces a complete Today aggregate with replay and stale-write safety. */ + async putToday( + workspaceId: string, + draft: unknown, + precondition: TodayWritePrecondition, + idempotencyKey: string, + ): Promise { + const safeWorkspaceId = canonicalTodayUuidV4( + workspaceId, + invalidTodayInput, + ); + const safeDraft = canonicalTodayDraft(draft, invalidTodayInput); + const safePrecondition = requirePrecondition(precondition); + const safeIdempotencyKey = canonicalTodayUuidV4( + idempotencyKey, + invalidTodayInput, + ); + return await this.repository.writeToday({ + workspaceId: safeWorkspaceId, + draft: safeDraft, + precondition: safePrecondition, + idempotencyKey: safeIdempotencyKey, + requestDigest: requestDigest( + safeWorkspaceId, + safeDraft, + safePrecondition, + ), + newAggregateId: randomUUID(), + newRevision: randomUUID(), + }); + } +} diff --git a/apps/planning-service/tests/postgres-today-lock-order.integration.test.ts b/apps/planning-service/tests/postgres-today-lock-order.integration.test.ts new file mode 100644 index 00000000..4e7f7053 --- /dev/null +++ b/apps/planning-service/tests/postgres-today-lock-order.integration.test.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { describe, expect, it } from 'vitest'; +import { + createPlanningRuntime, + type PlanningRuntime, +} from '../src/planning-runtime'; + +const DATABASE_URL = process.env.PLANNING_DATABASE_URL; +const TEMPORARY_DATABASE_NAME = 'life_os_today_lock_test'; +const describeWithDatabase = DATABASE_URL ? describe : describe.skip; + +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error('PLANNING_DATABASE_URL is required for PostgreSQL integration tests'); + } + return DATABASE_URL; +} + +function databaseUrl(sourceUrl: string, name: string): string { + const parsed = new URL(sourceUrl); + parsed.pathname = `/${name}`; + return parsed.toString(); +} + +async function applyPlanningMigrations(pool: Pool): Promise { + for (const migrationFile of [ + '0001_initial_planning.sql', + '0002_durable_repository_contract.sql', + '0003_durable_today_sync.sql', + ]) { + const sql = await readFile( + resolve(__dirname, '../migrations', migrationFile), + 'utf8', + ); + await pool.query(sql); + } +} + +function draft(date: string) { + return { + version: 'life-os.today.v1' as const, + date, + actions: [], + }; +} + +describeWithDatabase('PostgreSQL Today lock ordering', () => { + it('serializes repeated identical concurrent creates into one mutation and one replay', async () => { + const sourceUrl = requireDatabaseUrl(); + const adminPool = new Pool({ + connectionString: databaseUrl(sourceUrl, 'postgres'), + }); + let migrationPool: Pool | undefined; + let runtime: PlanningRuntime | undefined; + let primaryFailure: unknown; + + try { + await adminPool.query( + 'DROP DATABASE IF EXISTS life_os_today_lock_test WITH (FORCE)', + ); + await adminPool.query('CREATE DATABASE life_os_today_lock_test'); + const temporaryUrl = databaseUrl(sourceUrl, TEMPORARY_DATABASE_NAME); + migrationPool = new Pool({ connectionString: temporaryUrl }); + await applyPlanningMigrations(migrationPool); + runtime = createPlanningRuntime({ + PLANNING_DATABASE_URL: temporaryUrl, + PLANNING_DATABASE_POOL_MAX: '4', + PLANNING_DATABASE_CONNECT_TIMEOUT_MS: '5000', + PLANNING_DATABASE_IDLE_TIMEOUT_MS: '1000', + }); + + for (let iteration = 0; iteration < 12; iteration += 1) { + const workspaceId = randomUUID(); + const idempotencyKey = randomUUID(); + const date = '2026-08-09'; + const sameDraft = draft(date); + + const outcomes = await Promise.all([ + runtime.todayService.putToday( + workspaceId, + sameDraft, + { kind: 'absent' }, + idempotencyKey, + ), + runtime.todayService.putToday( + workspaceId, + sameDraft, + { kind: 'absent' }, + idempotencyKey, + ), + ]); + const kinds = outcomes.map((outcome) => outcome.kind).sort(); + + expect(kinds).toEqual(['created', 'replayed']); + expect(outcomes[0]?.aggregate).toEqual(outcomes[1]?.aggregate); + } + } catch (error) { + primaryFailure = error; + throw error; + } finally { + const cleanupFailures: unknown[] = []; + const cleanups: Array<() => Promise> = [ + async () => await runtime?.close(), + async () => await migrationPool?.end(), + async () => + await adminPool.query('DROP DATABASE IF EXISTS life_os_today_lock_test'), + async () => await adminPool.end(), + ]; + for (const cleanup of cleanups) { + try { + await cleanup(); + } catch (error) { + cleanupFailures.push(error); + } + } + if (cleanupFailures.length > 0) { + const cleanupError = new AggregateError( + cleanupFailures, + 'Today lock test cleanup failed', + ); + if (primaryFailure instanceof Error) { + if (primaryFailure.cause === undefined) { + Object.defineProperty(primaryFailure, 'cause', { + configurable: true, + value: cleanupError, + }); + } + } else if (primaryFailure === undefined) { + throw cleanupError; + } + } + } + }, 30_000); +}); diff --git a/apps/web/app/api/planning/today/[date]/route.ts b/apps/web/app/api/planning/today/[date]/route.ts new file mode 100644 index 00000000..82e10710 --- /dev/null +++ b/apps/web/app/api/planning/today/[date]/route.ts @@ -0,0 +1,24 @@ +import { handleTodaySyncRequest } from '../../../../today-sync-client'; + +/** Next.js 15 asynchronous dynamic route context for one local calendar date. */ +interface TodayRouteContext { + params: Promise<{ date: string }>; +} + +/** Returns one authenticated workspace Today aggregate. */ +export async function GET( + request: Request, + context: TodayRouteContext, +): Promise { + const { date } = await context.params; + return await handleTodaySyncRequest(request, date, process.env, fetch); +} + +/** Creates or replaces one complete Today aggregate behind explicit preconditions. */ +export async function PUT( + request: Request, + context: TodayRouteContext, +): Promise { + const { date } = await context.params; + return await handleTodaySyncRequest(request, date, process.env, fetch); +} diff --git a/apps/web/app/components/today-workspace-sync-panel.tsx b/apps/web/app/components/today-workspace-sync-panel.tsx new file mode 100644 index 00000000..d29b9a22 --- /dev/null +++ b/apps/web/app/components/today-workspace-sync-panel.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useRef, useState } from 'react'; +import type { MessageCatalog } from '../localization'; +import type { TodayDraft } from '../today-state'; +import { + fetchWorkspaceToday, + saveWorkspaceToday, +} from '../today-workspace-sync'; + +type SyncState = + | 'local' + | 'checking' + | 'missing' + | 'found' + | 'saved' + | 'loaded' + | 'conflict' + | 'unauthenticated' + | 'unavailable'; + +function statusMessage(messages: MessageCatalog, state: SyncState): string { + switch (state) { + case 'checking': + return messages.workspaceCheckingStatus; + case 'missing': + return messages.workspaceMissingStatus; + case 'found': + return messages.workspaceFoundStatus; + case 'saved': + return messages.workspaceSavedStatus; + case 'loaded': + return messages.workspaceLoadedStatus; + case 'conflict': + return messages.workspaceConflictStatus; + case 'unauthenticated': + return messages.workspaceSignInStatus; + case 'unavailable': + return messages.workspaceUnavailableStatus; + case 'local': + default: + return messages.workspaceLocalOnlyStatus; + } +} + +/** + * Presents explicit local/durable choices. It intentionally performs no + * network request on mount so a browser-local draft can never be uploaded or + * even reconciled until the user chooses an action. + */ +export function TodayWorkspaceSyncPanel({ + draft, + messages, + onUseDraft, +}: { + readonly draft: TodayDraft; + readonly messages: MessageCatalog; + readonly onUseDraft: (draft: TodayDraft) => void; +}) { + const [state, setState] = useState('local'); + const [workspaceDraft, setWorkspaceDraft] = useState(null); + const [workspaceRevision, setWorkspaceRevision] = useState( + null, + ); + const currentDraft = useRef(draft); + currentDraft.current = draft; + + async function checkWorkspace(): Promise { + setState('checking'); + const result = await fetchWorkspaceToday(draft.date); + switch (result.kind) { + case 'found': + setWorkspaceDraft(result.draft); + setWorkspaceRevision(result.revision); + setState('found'); + return; + case 'missing': + setWorkspaceDraft(null); + setWorkspaceRevision(null); + setState('missing'); + return; + case 'unauthenticated': + setWorkspaceDraft(null); + setWorkspaceRevision(null); + setState('unauthenticated'); + return; + case 'unavailable': + default: + setWorkspaceDraft(null); + setWorkspaceRevision(null); + setState('unavailable'); + } + } + + async function saveLocal(): Promise { + if ( + state !== 'missing' && + state !== 'found' && + state !== 'saved' && + state !== 'loaded' + ) { + return; + } + const submittedDraft = draft; + setState('checking'); + const result = await saveWorkspaceToday(submittedDraft, workspaceRevision); + switch (result.kind) { + case 'saved': + setWorkspaceDraft(result.draft); + setWorkspaceRevision(result.revision); + if (currentDraft.current === submittedDraft) { + onUseDraft(result.draft); + } + setState('saved'); + return; + case 'conflict': + setWorkspaceDraft(null); + setWorkspaceRevision(null); + setState('conflict'); + return; + case 'unauthenticated': + setWorkspaceDraft(null); + setWorkspaceRevision(null); + setState('unauthenticated'); + return; + case 'unavailable': + default: + setState('unavailable'); + } + } + + function useWorkspace(): void { + if (!workspaceDraft || state !== 'found') return; + onUseDraft(workspaceDraft); + setState('loaded'); + } + + const canSave = + state === 'missing' || + state === 'found' || + state === 'saved' || + state === 'loaded'; + const saveLabel = + state === 'missing' + ? messages.moveLocalToWorkspace + : state === 'found' + ? messages.replaceWorkspaceWithLocal + : messages.saveLocalToWorkspace; + + return ( +
+
+
+

{messages.workspaceSyncEyebrow}

+

{messages.workspaceSyncHeading}

+
+ {state === 'saved' || state === 'loaded' ? '✓' : '↔'} +
+

{messages.workspaceSyncDescription}

+

+ {statusMessage(messages, state)} +

+
+ + {canSave ? ( + + ) : null} + {state === 'found' && workspaceDraft ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/app/offline/page.tsx b/apps/web/app/offline/page.tsx index b48366b4..e12da81a 100644 --- a/apps/web/app/offline/page.tsx +++ b/apps/web/app/offline/page.tsx @@ -13,8 +13,8 @@ export default function OfflinePage() {

LifeOS is offline.

Your browser-local Today draft remains on this device. Reconnect and - retry to load the application; this offline page does not read or cache - your planning data. + retry to load the application; this offline page does not read or + cache your planning data.

Try again diff --git a/apps/web/app/onboarding/onboarding-flow.tsx b/apps/web/app/onboarding/onboarding-flow.tsx index 8a1ad0e7..2c971fac 100644 --- a/apps/web/app/onboarding/onboarding-flow.tsx +++ b/apps/web/app/onboarding/onboarding-flow.tsx @@ -7,10 +7,7 @@ import { scheduleTodayAction, toggleTodayPriority, } from '../today-state'; -import { - parseStoredTodayDraft, - serializeTodayDraft, -} from '../today-storage'; +import { parseStoredTodayDraft, serializeTodayDraft } from '../today-storage'; import styles from './onboarding.module.css'; const TODAY_STORAGE_KEY = 'life-os.today-draft.v1'; @@ -63,9 +60,7 @@ export function OnboardingFlow({ const date = localDate(); const stored = window.localStorage.getItem(TODAY_STORAGE_KEY); previousToday = stored; - previousCompletion = window.localStorage.getItem( - ONBOARDING_STORAGE_KEY, - ); + previousCompletion = window.localStorage.getItem(ONBOARDING_STORAGE_KEY); let draft = parseStoredTodayDraft(stored, date); const actionId = globalThis.crypto.randomUUID(); draft = addTodayAction(draft, { @@ -128,7 +123,9 @@ export function OnboardingFlow({ LifeOS -

First plan · {generatedAt.slice(0, 10)}

+

+ First plan · {generatedAt.slice(0, 10)} +

Start with one believable commitment.

LifeOS works best when a direction becomes a visible action with an @@ -137,8 +134,8 @@ export function OnboardingFlow({

Local-first boundary

- This first plan is saved only in this browser. It is not synchronized - to an account or shared workspace yet. + This first plan is saved only in this browser. It is not + synchronized to an account or shared workspace yet.

@@ -170,7 +167,9 @@ export function OnboardingFlow({ -

Choose something you can start without another planning session.

+

+ Choose something you can start without another planning session. +

div:first-child { max-width: 760px; } -.eyebrow { margin: 0 0 9px; color: #6b776e; font-size: .73rem; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } -h1, h2, h3, p { overflow-wrap: anywhere; } -h1 { margin: 0; max-width: 680px; font-size: clamp(2.6rem, 7vw, 5.6rem); line-height: .95; letter-spacing: -.065em; } -h2 { margin: 0; font-size: clamp(1.45rem, 3vw, 2rem); letter-spacing: -.035em; } -h3 { margin: 0; font-size: 1.04rem; } -.lede { max-width: 670px; margin: 22px 0 0; color: #5d685f; font-size: 1.03rem; line-height: 1.7; } -.progress-card { flex: 0 0 auto; display: grid; place-items: center; min-width: 112px; padding: 18px; border: 1px solid #d9ded7; border-radius: 18px; background: white; box-shadow: 0 14px 36px rgba(32,55,39,.07); } -.progress-card strong { font-size: 2.2rem; line-height: 1; } -.progress-card span { margin-top: 6px; color: #718078; font-size: .72rem; font-weight: 750; text-transform: uppercase; letter-spacing: .1em; } +.today-main { + width: min(1180px, 100%); + margin: 0 auto; + padding: 54px clamp(24px, 5vw, 72px) 80px; +} +.today-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 36px; +} +.today-header > div:first-child { + max-width: 760px; +} +.eyebrow { + margin: 0 0 9px; + color: #6b776e; + font-size: 0.73rem; + font-weight: 850; + letter-spacing: 0.13em; + text-transform: uppercase; +} +h1, +h2, +h3, +p { + overflow-wrap: anywhere; +} +h1 { + margin: 0; + max-width: 680px; + font-size: clamp(2.6rem, 7vw, 5.6rem); + line-height: 0.95; + letter-spacing: -0.065em; +} +h2 { + margin: 0; + font-size: clamp(1.45rem, 3vw, 2rem); + letter-spacing: -0.035em; +} +h3 { + margin: 0; + font-size: 1.04rem; +} +.lede { + max-width: 670px; + margin: 22px 0 0; + color: #5d685f; + font-size: 1.03rem; + line-height: 1.7; +} +.progress-card { + flex: 0 0 auto; + display: grid; + place-items: center; + min-width: 112px; + padding: 18px; + border: 1px solid #d9ded7; + border-radius: 18px; + background: white; + box-shadow: 0 14px 36px rgba(32, 55, 39, 0.07); +} +.progress-card strong { + font-size: 2.2rem; + line-height: 1; +} +.progress-card span { + margin-top: 6px; + color: #718078; + font-size: 0.72rem; + font-weight: 750; + text-transform: uppercase; + letter-spacing: 0.1em; +} -.capture-bar { margin: 42px 0 14px; padding: 22px; border: 1px solid #dce1da; border-radius: 18px; background: white; box-shadow: 0 14px 40px rgba(30,55,38,.06); } -.capture-bar label { display: block; margin-bottom: 10px; font-weight: 800; } -.capture-bar > div { display: grid; grid-template-columns: 1fr auto; gap: 10px; } -.capture-bar input { width: 100%; min-width: 0; padding: 13px 14px; border: 1px solid #cbd4cb; border-radius: 11px; background: #fafbf8; } -.capture-bar button, .priority-row button, .backlog-list button { border: 0; border-radius: 11px; padding: 11px 15px; background: #17382c; color: white; font-weight: 800; } -.capture-bar small { display: block; margin-top: 8px; color: #758079; } -.sr-status { min-height: 24px; margin: 0 0 22px; color: #725323; font-size: .88rem; } +.capture-bar { + margin: 42px 0 14px; + padding: 22px; + border: 1px solid #dce1da; + border-radius: 18px; + background: white; + box-shadow: 0 14px 40px rgba(30, 55, 38, 0.06); +} +.capture-bar label { + display: block; + margin-bottom: 10px; + font-weight: 800; +} +.capture-bar > div { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; +} +.capture-bar input { + width: 100%; + min-width: 0; + padding: 13px 14px; + border: 1px solid #cbd4cb; + border-radius: 11px; + background: #fafbf8; +} +.capture-bar button, +.priority-row button, +.backlog-list button { + border: 0; + border-radius: 11px; + padding: 11px 15px; + background: #17382c; + color: white; + font-weight: 800; +} +.capture-bar small { + display: block; + margin-top: 8px; + color: #758079; +} +.sr-status { + min-height: 24px; + margin: 0 0 22px; + color: #725323; + font-size: 0.88rem; +} -.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 30px 0 16px; } -.section-heading.compact { margin: 0 0 15px; } -.section-heading.compact > span { display: grid; place-items: center; min-width: 30px; height: 30px; border-radius: 50%; background: #edf0ea; color: #526057; font-weight: 800; } -.capacity-pill { padding: 7px 10px; border-radius: 999px; background: #e6ece5; color: #425449; font-size: .8rem; font-weight: 850; } -.empty-state { display: flex; gap: 20px; align-items: center; padding: 30px; border: 1px dashed #bcc7bd; border-radius: 18px; background: rgba(255,255,255,.55); } -.empty-state > span { color: #b07934; font-size: 2rem; font-weight: 900; } -.empty-state p { margin: 7px 0 0; color: #68736b; line-height: 1.55; } +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; + margin: 30px 0 16px; +} +.section-heading.compact { + margin: 0 0 15px; +} +.section-heading.compact > span { + display: grid; + place-items: center; + min-width: 30px; + height: 30px; + border-radius: 50%; + background: #edf0ea; + color: #526057; + font-weight: 800; +} +.capacity-pill { + padding: 7px 10px; + border-radius: 999px; + background: #e6ece5; + color: #425449; + font-size: 0.8rem; + font-weight: 850; +} +.empty-state { + display: flex; + gap: 20px; + align-items: center; + padding: 30px; + border: 1px dashed #bcc7bd; + border-radius: 18px; + background: rgba(255, 255, 255, 0.55); +} +.empty-state > span { + color: #b07934; + font-size: 2rem; + font-weight: 900; +} +.empty-state p { + margin: 7px 0 0; + color: #68736b; + line-height: 1.55; +} -.priority-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } -.priority-list > li { display: grid; grid-template-columns: 54px 1fr; gap: 18px; padding: 20px; border: 1px solid #dce1da; border-radius: 17px; background: white; box-shadow: 0 9px 26px rgba(34,55,41,.045); } -.priority-list > li.is-done { background: #f4f6f2; } -.priority-list > li.is-done h3 { color: #667168; text-decoration: line-through; } -.priority-number { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: #f1c967; color: #17382c; font-weight: 900; } -.priority-content { min-width: 0; } -.priority-row { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; } -.priority-row p { margin: 6px 0 0; color: #748078; font-size: .82rem; } -.priority-row button { background: #edf1eb; color: #244135; } -.schedule-controls { display: flex; align-items: end; flex-wrap: wrap; gap: 11px; margin-top: 15px; padding-top: 14px; border-top: 1px solid #edf0eb; } -.schedule-controls label { display: grid; gap: 5px; color: #6b766e; font-size: .7rem; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; } -.schedule-controls input, .schedule-controls select { min-height: 38px; padding: 7px 9px; border: 1px solid #cbd4cb; border-radius: 9px; background: white; color: #172019; } -.text-button { border: 0; padding: 7px 3px; background: transparent; color: #6b5631; font-size: .82rem; font-weight: 800; text-decoration: underline; text-underline-offset: 3px; } +.priority-list { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} +.priority-list > li { + display: grid; + grid-template-columns: 54px 1fr; + gap: 18px; + padding: 20px; + border: 1px solid #dce1da; + border-radius: 17px; + background: white; + box-shadow: 0 9px 26px rgba(34, 55, 41, 0.045); +} +.priority-list > li.is-done { + background: #f4f6f2; +} +.priority-list > li.is-done h3 { + color: #667168; + text-decoration: line-through; +} +.priority-number { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 14px; + background: #f1c967; + color: #17382c; + font-weight: 900; +} +.priority-content { + min-width: 0; +} +.priority-row { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; +} +.priority-row p { + margin: 6px 0 0; + color: #748078; + font-size: 0.82rem; +} +.priority-row button { + background: #edf1eb; + color: #244135; +} +.schedule-controls { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 11px; + margin-top: 15px; + padding-top: 14px; + border-top: 1px solid #edf0eb; +} +.schedule-controls label { + display: grid; + gap: 5px; + color: #6b766e; + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.schedule-controls input, +.schedule-controls select { + min-height: 38px; + padding: 7px 9px; + border: 1px solid #cbd4cb; + border-radius: 9px; + background: white; + color: #172019; +} +.text-button { + border: 0; + padding: 7px 3px; + background: transparent; + color: #6b5631; + font-size: 0.82rem; + font-weight: 800; + text-decoration: underline; + text-underline-offset: 3px; +} -.lower-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 34px; } -.list-card { min-width: 0; padding: 22px; border: 1px solid #dce1da; border-radius: 18px; background: white; } -.quiet-empty { margin: 0; color: #758078; line-height: 1.5; } -.backlog-list, .completed-list { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; } -.backlog-list li { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-top: 1px solid #edf0eb; } -.backlog-list li:first-child, .completed-list li:first-child { border-top: 0; } -.backlog-list span { min-width: 0; } -.backlog-list button { flex: 0 0 auto; padding: 8px 10px; background: #e7eee8; color: #254336; font-size: .76rem; } -.completed-list li { display: grid; grid-template-columns: 26px 1fr auto; gap: 10px; align-items: center; padding: 12px 0; border-top: 1px solid #edf0eb; } -.completed-list li > span { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #dceadf; color: #24533a; font-weight: 900; } -.completed-list div { min-width: 0; display: grid; gap: 3px; } -.completed-list small { color: #7a847e; } +.lower-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 34px; +} +.list-card { + min-width: 0; + padding: 22px; + border: 1px solid #dce1da; + border-radius: 18px; + background: white; +} +.quiet-empty { + margin: 0; + color: #758078; + line-height: 1.5; +} +.backlog-list, +.completed-list { + display: grid; + gap: 9px; + margin: 0; + padding: 0; + list-style: none; +} +.backlog-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 0; + border-top: 1px solid #edf0eb; +} +.backlog-list li:first-child, +.completed-list li:first-child { + border-top: 0; +} +.backlog-list span { + min-width: 0; +} +.backlog-list button { + flex: 0 0 auto; + padding: 8px 10px; + background: #e7eee8; + color: #254336; + font-size: 0.76rem; +} +.completed-list li { + display: grid; + grid-template-columns: 26px 1fr auto; + gap: 10px; + align-items: center; + padding: 12px 0; + border-top: 1px solid #edf0eb; +} +.completed-list li > span { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: #dceadf; + color: #24533a; + font-weight: 900; +} +.completed-list div { + min-width: 0; + display: grid; + gap: 3px; +} +.completed-list small { + color: #7a847e; +} @media (max-width: 900px) { - .today-shell { grid-template-columns: 1fr; } - .today-sidebar { position: static; height: auto; flex-direction: row; align-items: center; gap: 18px; padding: 15px 18px; } - .today-sidebar nav { margin-left: auto; grid-auto-flow: column; } - .local-note { display: none; } - .today-main { padding-top: 38px; } + .today-shell { + grid-template-columns: 1fr; + } + .today-sidebar { + position: static; + height: auto; + flex-direction: row; + align-items: center; + gap: 18px; + padding: 15px 18px; + } + .today-sidebar nav { + margin-left: auto; + grid-auto-flow: column; + } + .local-note { + display: none; + } + .today-main { + padding-top: 38px; + } } @media (max-width: 680px) { - .today-sidebar nav a:not(.active) { display: none; } - .today-header { display: grid; } - .progress-card { width: 100%; grid-auto-flow: column; justify-content: start; gap: 9px; } - .capture-bar > div { grid-template-columns: 1fr; } - .priority-list > li { grid-template-columns: 42px 1fr; padding: 16px; gap: 12px; } - .priority-number { width: 40px; height: 40px; } - .priority-row { display: grid; } - .priority-row button { width: 100%; } - .lower-grid { grid-template-columns: 1fr; } - .backlog-list li { align-items: flex-start; flex-direction: column; } - .backlog-list button { width: 100%; } + .today-sidebar nav a:not(.active) { + display: none; + } + .today-header { + display: grid; + } + .progress-card { + width: 100%; + grid-auto-flow: column; + justify-content: start; + gap: 9px; + } + .capture-bar > div { + grid-template-columns: 1fr; + } + .priority-list > li { + grid-template-columns: 42px 1fr; + padding: 16px; + gap: 12px; + } + .priority-number { + width: 40px; + height: 40px; + } + .priority-row { + display: grid; + } + .priority-row button { + width: 100%; + } + .lower-grid { + grid-template-columns: 1fr; + } + .backlog-list li { + align-items: flex-start; + flex-direction: column; + } + .backlog-list button { + width: 100%; + } } @media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } } diff --git a/apps/web/app/today-client.tsx b/apps/web/app/today-client.tsx index e01c4ed5..e49ae72d 100644 --- a/apps/web/app/today-client.tsx +++ b/apps/web/app/today-client.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { QuickCapture } from './components/quick-capture'; +import { TodayWorkspaceSyncPanel } from './components/today-workspace-sync-panel'; import { chooseSupportedLocale, formatMessage, @@ -223,6 +224,14 @@ export function TodayClient({ generatedAt }: { readonly generatedAt: string }) { + { + setDraft(workspaceDraft); + setMessageKey(null); + }} + />

{messageKey ? messages[messageKey] : ''}

diff --git a/apps/web/app/today-state.test.ts b/apps/web/app/today-state.test.ts index 607fd6e1..146af0fc 100644 --- a/apps/web/app/today-state.test.ts +++ b/apps/web/app/today-state.test.ts @@ -11,10 +11,7 @@ import { toggleTodayCompletion, toggleTodayPriority, } from './today-state'; -import { - parseStoredTodayDraft, - serializeTodayDraft, -} from './today-storage'; +import { parseStoredTodayDraft, serializeTodayDraft } from './today-storage'; const DATE = '2026-08-04'; const CREATED_AT = '2026-08-04T00:00:00.000Z'; @@ -82,11 +79,7 @@ describe('Today draft', () => { it('records completion evidence while retaining the committed priority', () => { let draft = toggleTodayPriority(withActions(1), IDS[0]); draft = scheduleTodayAction(draft, IDS[0], 23 * 60, 60); - draft = toggleTodayCompletion( - draft, - IDS[0], - '2026-08-04T23:59:00.000Z', - ); + draft = toggleTodayCompletion(draft, IDS[0], '2026-08-04T23:59:00.000Z'); assert.equal(draft.actions[0]?.status, 'done'); assert.equal(draft.actions[0]?.priority, 1); diff --git a/apps/web/app/today-state.ts b/apps/web/app/today-state.ts index 764f6236..aab4115e 100644 --- a/apps/web/app/today-state.ts +++ b/apps/web/app/today-state.ts @@ -1,8 +1,7 @@ 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}$/i; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; -const INSTANT_PATTERN = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/; +const INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/; const MAXIMUM_ACTIONS = 50; const MAXIMUM_TITLE_LENGTH = 160; const MINIMUM_DURATION_MINUTES = 15; @@ -219,7 +218,8 @@ function normalizeAction(value: unknown): TodayAction { function byCreationThenId(left: TodayAction, right: TodayAction): number { return ( - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id) + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id) ); } @@ -273,7 +273,10 @@ export function createEmptyTodayDraft(date: string): TodayDraft { }); } -export function parseTodayDraft(value: unknown, expectedDate: string): TodayDraft { +export function parseTodayDraft( + value: unknown, + expectedDate: string, +): TodayDraft { const record = requireRecord(value); requireExactKeys(record, ['version', 'date', 'actions']); if (record.version !== DRAFT_VERSION || !Array.isArray(record.actions)) { diff --git a/apps/web/app/today-sync-client-review-regression.test.ts b/apps/web/app/today-sync-client-review-regression.test.ts new file mode 100644 index 00000000..0cb52b33 --- /dev/null +++ b/apps/web/app/today-sync-client-review-regression.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { describe, it } from 'node:test'; +import { handleTodaySyncRequest } from './today-sync-client'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const REVISION = '22222222-2222-4222-8222-222222222222'; +const DATE = '2026-08-09'; +const ENVIRONMENT = { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: 'a'.repeat(32), +}; + +function identityResponse(): Response { + return Response.json({ workspaceId: WORKSPACE_ID }); +} + +function aggregate() { + return { + version: 'life-os.today.v1', + aggregateId: '44444444-4444-4444-8444-444444444444', + revision: REVISION, + date: DATE, + actions: [], + }; +} + +describe('Today synchronization review regressions', () => { + it('accepts case-insensitive JSON media types at both browser and provider boundaries', async () => { + const request = new Request( + `https://life.example.test/api/planning/today/${DATE}`, + { + method: 'PUT', + headers: { + cookie: 'session=opaque', + 'content-type': 'Application/JSON; Charset=UTF-8', + 'if-none-match': '*', + 'idempotency-key': randomUUID(), + }, + body: JSON.stringify({ + version: 'life-os.today.v1', + date: DATE, + actions: [], + }), + }, + ); + const fetcher = async (input: RequestInfo | URL) => { + if (String(input).endsWith('/v1/session')) return identityResponse(); + return new Response(JSON.stringify(aggregate()), { + status: 201, + headers: { + 'content-type': 'Application/JSON; Charset=UTF-8', + etag: `\"${REVISION}\"`, + }, + }); + }; + + const response = await handleTodaySyncRequest( + request, + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), aggregate()); + }); + + it('recognizes revision conflicts by stable machine fields even when the human title changes', async () => { + const request = new Request( + `https://life.example.test/api/planning/today/${DATE}`, + { + method: 'PUT', + headers: { + cookie: 'session=opaque', + 'content-type': 'application/json', + 'if-match': `\"${REVISION}\"`, + 'idempotency-key': randomUUID(), + }, + body: JSON.stringify({ + version: 'life-os.today.v1', + date: DATE, + actions: [], + }), + }, + ); + const fetcher = async (input: RequestInfo | URL) => { + if (String(input).endsWith('/v1/session')) return identityResponse(); + return new Response( + JSON.stringify({ + type: 'about:blank', + title: 'The durable Today changed while you were editing', + status: 409, + code: 'today_revision_conflict', + currentRevision: REVISION, + }), + { + status: 409, + headers: { + 'content-type': 'Application/Problem+JSON; Charset=UTF-8', + }, + }, + ); + }; + + const response = await handleTodaySyncRequest( + request, + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { + type: 'about:blank', + title: 'Today changed on another device', + status: 409, + code: 'today_revision_conflict', + currentRevision: REVISION, + }); + }); +}); diff --git a/apps/web/app/today-sync-client.test.ts b/apps/web/app/today-sync-client.test.ts new file mode 100644 index 00000000..fcb197af --- /dev/null +++ b/apps/web/app/today-sync-client.test.ts @@ -0,0 +1,445 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { describe, it } from 'node:test'; +import { handleTodaySyncRequest } from './today-sync-client'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const REVISION = '22222222-2222-4222-8222-222222222222'; +const ACTION_ID = '33333333-3333-4333-8333-333333333333'; +const DATE = '2026-08-09'; +const SECRET = 'a'.repeat(32); +const ENVIRONMENT = { + IDENTITY_SERVICE_ORIGIN: 'https://identity.example.test', + PLANNING_SERVICE_ORIGIN: 'https://planning.example.test', + PLANNING_GATEWAY_CONTEXT_SECRET: SECRET, +}; + +function aggregate(title = 'Durable Today') { + return { + version: 'life-os.today.v1', + aggregateId: '44444444-4444-4444-8444-444444444444', + revision: REVISION, + date: DATE, + actions: [ + { + id: ACTION_ID, + title, + status: 'open', + priority: 1, + startMinute: 540, + durationMinutes: 60, + createdAt: '2026-08-09T00:00:00.000Z', + completedAt: null, + }, + ], + }; +} + +function jsonResponse( + value: unknown, + status = 200, + headers?: HeadersInit, +): Response { + return Response.json(value, { + status, + ...(headers === undefined ? {} : { headers }), + }); +} + +function validPutRequest( + headers: HeadersInit, + body: unknown = { + version: 'life-os.today.v1', + date: DATE, + actions: aggregate().actions, + }, +): Request { + return new Request(`https://life.example.test/api/planning/today/${DATE}`, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + ...Object.fromEntries(new Headers(headers)), + }, + body: JSON.stringify(body), + }); +} + +describe('Today synchronization BFF', () => { + it('authenticates the browser, derives workspace server-side, and returns bounded GET state with ETag', async () => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + if (url.endsWith('/v1/session')) { + return jsonResponse({ workspaceId: WORKSPACE_ID }); + } + return jsonResponse(aggregate(), 200, { etag: `\"${REVISION}\"` }); + }; + const request = new Request( + `https://life.example.test/api/planning/today/${DATE}`, + { headers: { cookie: 'session=browser-secret' } }, + ); + + const response = await handleTodaySyncRequest( + request, + DATE, + ENVIRONMENT, + fetcher, + 1_786_259_200, + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('etag'), `\"${REVISION}\"`); + assert.deepEqual(await response.json(), aggregate()); + assert.equal(calls.length, 2); + assert.equal(calls[0]?.url, 'https://identity.example.test/v1/session'); + assert.equal(calls[0]?.init?.headers instanceof Headers, true); + assert.equal( + (calls[0]?.init?.headers as Headers).get('cookie'), + 'session=browser-secret', + ); + assert.equal( + calls[1]?.url, + `https://planning.example.test/v1/today/${DATE}`, + ); + const planningHeaders = calls[1]?.init?.headers as Headers; + assert.equal(planningHeaders.has('cookie'), false); + assert.equal(planningHeaders.get('x-life-os-workspace-id'), WORKSPACE_ID); + assert.equal( + planningHeaders.get('x-life-os-context-issued-at'), + '1786259200', + ); + assert.equal( + planningHeaders.get('x-life-os-context-signature')?.length, + 43, + ); + }); + + it('forwards only the complete Today document and explicit concurrency/idempotency headers on PUT', async () => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const idempotencyKey = randomUUID(); + const draft = { + version: 'life-os.today.v1', + date: DATE, + actions: aggregate().actions, + }; + const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + if (url.endsWith('/v1/session')) { + return jsonResponse({ workspaceId: WORKSPACE_ID }); + } + return jsonResponse(aggregate(), 200, { etag: `\"${REVISION}\"` }); + }; + const request = new Request( + `https://life.example.test/api/planning/today/${DATE}`, + { + method: 'PUT', + headers: { + cookie: 'session=browser-secret', + 'content-type': 'application/json', + 'if-match': `\"${REVISION}\"`, + 'idempotency-key': idempotencyKey, + 'x-workspace-id': 'attacker-selected-workspace', + }, + body: JSON.stringify(draft), + }, + ); + + const response = await handleTodaySyncRequest( + request, + DATE, + ENVIRONMENT, + fetcher, + 1_786_259_200, + ); + + assert.equal(response.status, 200); + const planningCall = calls[1]; + assert.equal(planningCall?.init?.method, 'PUT'); + const planningHeaders = planningCall?.init?.headers as Headers; + assert.equal(planningHeaders.get('if-match'), `\"${REVISION}\"`); + assert.equal(planningHeaders.get('idempotency-key'), idempotencyKey); + assert.equal(planningHeaders.get('x-workspace-id'), null); + assert.equal(planningHeaders.get('x-life-os-workspace-id'), WORKSPACE_ID); + assert.equal(planningCall?.init?.body, JSON.stringify(draft)); + }); + + it('uses If-None-Match for explicit first migration and does not silently infer overwrite authority', async () => { + const calls: RequestInit[] = []; + const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push(init ?? {}); + if (String(input).endsWith('/v1/session')) { + return jsonResponse({ workspaceId: WORKSPACE_ID }); + } + return jsonResponse(aggregate(), 201, { etag: `\"${REVISION}\"` }); + }; + const request = new Request( + `https://life.example.test/api/planning/today/${DATE}`, + { + method: 'PUT', + headers: { + cookie: 'session=browser-secret', + 'content-type': 'application/json', + 'if-none-match': '*', + 'idempotency-key': randomUUID(), + }, + body: JSON.stringify({ + version: 'life-os.today.v1', + date: DATE, + actions: aggregate().actions, + }), + }, + ); + + const response = await handleTodaySyncRequest( + request, + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 201); + const planningHeaders = calls[1]?.headers as Headers; + assert.equal(planningHeaders.get('if-none-match'), '*'); + assert.equal(planningHeaders.get('if-match'), null); + }); + + it('does not call planning when identity is unauthenticated', async () => { + let calls = 0; + const fetcher = async () => { + calls += 1; + return jsonResponse({ code: 'unauthorized' }, 401); + }; + const request = new Request( + `https://life.example.test/api/planning/today/${DATE}`, + ); + + const response = await handleTodaySyncRequest( + request, + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 401); + assert.equal(calls, 1); + assert.deepEqual(await response.json(), { + type: 'about:blank', + title: 'Authentication is required', + status: 401, + code: 'authentication_required', + }); + }); + + it('passes through only bounded known revision conflicts for explicit reconciliation', async () => { + const fetcher = async (input: RequestInfo | URL) => { + if (String(input).endsWith('/v1/session')) { + return jsonResponse({ workspaceId: WORKSPACE_ID }); + } + return jsonResponse( + { + type: 'about:blank', + title: 'Today changed on another device', + status: 409, + code: 'today_revision_conflict', + currentRevision: REVISION, + injected: 'must not pass through', + }, + 409, + ); + }; + const request = new Request( + `https://life.example.test/api/planning/today/${DATE}`, + { + method: 'PUT', + headers: { + cookie: 'session=browser-secret', + 'content-type': 'application/json', + 'if-match': `\"${REVISION}\"`, + 'idempotency-key': randomUUID(), + }, + body: JSON.stringify({ + version: 'life-os.today.v1', + date: DATE, + actions: aggregate().actions, + }), + }, + ); + + const response = await handleTodaySyncRequest( + request, + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { + type: 'about:blank', + title: 'Today changed on another device', + status: 409, + code: 'today_revision_conflict', + currentRevision: REVISION, + }); + }); + + it('fails closed on invalid dates and invalid browser media types before dependencies run', async () => { + const neverFetch = async () => { + throw new Error('fetch must not run'); + }; + const invalidDate = await handleTodaySyncRequest( + new Request('https://life.example.test/api/planning/today/not-a-date'), + 'not-a-date', + ENVIRONMENT, + neverFetch, + ); + assert.equal(invalidDate.status, 400); + + const invalidPut = await handleTodaySyncRequest( + new Request(`https://life.example.test/api/planning/today/${DATE}`, { + method: 'PUT', + headers: { + 'content-type': 'text/plain', + 'if-none-match': '*', + 'idempotency-key': randomUUID(), + }, + body: '{}', + }), + DATE, + ENVIRONMENT, + neverFetch, + ); + assert.equal(invalidPut.status, 400); + }); + + it('rejects every malformed browser write-authority header combination before authentication', async () => { + const neverFetch = async () => { + throw new Error('fetch must not run'); + }; + const cases: HeadersInit[] = [ + { + 'if-match': `\"${REVISION}\"`, + 'if-none-match': '*', + 'idempotency-key': randomUUID(), + }, + { 'idempotency-key': randomUUID() }, + { 'if-none-match': '*', 'idempotency-key': 'not-a-uuid' }, + { 'if-none-match': 'anything-else', 'idempotency-key': randomUUID() }, + ]; + + for (const headerCase of cases) { + const response = await handleTodaySyncRequest( + validPutRequest(headerCase), + DATE, + ENVIRONMENT, + neverFetch, + ); + assert.equal(response.status, 400); + } + }); + + it('rejects mismatched Today body version or route date before authentication', async () => { + const neverFetch = async () => { + throw new Error('fetch must not run'); + }; + for (const body of [ + { version: 'life-os.today.v2', date: DATE, actions: [] }, + { version: 'life-os.today.v1', date: '2026-08-10', actions: [] }, + ]) { + const response = await handleTodaySyncRequest( + validPutRequest( + { + 'if-none-match': '*', + 'idempotency-key': randomUUID(), + }, + body, + ), + DATE, + ENVIRONMENT, + neverFetch, + ); + assert.equal(response.status, 400); + } + }); + + it('maps a missing durable Today to the bounded not-found problem', async () => { + const fetcher = async (input: RequestInfo | URL) => { + if (String(input).endsWith('/v1/session')) { + return jsonResponse({ workspaceId: WORKSPACE_ID }); + } + return jsonResponse({ code: 'today_not_found' }, 404); + }; + const response = await handleTodaySyncRequest( + new Request(`https://life.example.test/api/planning/today/${DATE}`), + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { + type: 'about:blank', + title: 'Today aggregate was not found', + status: 404, + code: 'today_not_found', + }); + }); + + it('fails closed when the upstream ETag disagrees with the aggregate revision', async () => { + const differentRevision = '55555555-5555-4555-8555-555555555555'; + const fetcher = async (input: RequestInfo | URL) => { + if (String(input).endsWith('/v1/session')) { + return jsonResponse({ workspaceId: WORKSPACE_ID }); + } + return jsonResponse( + { ...aggregate(), revision: differentRevision }, + 200, + { etag: `\"${REVISION}\"` }, + ); + }; + const response = await handleTodaySyncRequest( + new Request(`https://life.example.test/api/planning/today/${DATE}`), + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 503); + }); + + it('fails closed when an upstream payload exceeds the bounded response budget', async () => { + const fetcher = async (input: RequestInfo | URL) => { + if (String(input).endsWith('/v1/session')) { + return jsonResponse({ workspaceId: WORKSPACE_ID }); + } + return jsonResponse({ oversized: 'x'.repeat(65 * 1024) }, 200, { + etag: `\"${REVISION}\"`, + }); + }; + const response = await handleTodaySyncRequest( + new Request(`https://life.example.test/api/planning/today/${DATE}`), + DATE, + ENVIRONMENT, + fetcher, + ); + + assert.equal(response.status, 503); + }); + + it('rejects unsupported browser methods before authentication', async () => { + const neverFetch = async () => { + throw new Error('fetch must not run'); + }; + const response = await handleTodaySyncRequest( + new Request(`https://life.example.test/api/planning/today/${DATE}`, { + method: 'DELETE', + }), + DATE, + ENVIRONMENT, + neverFetch, + ); + + assert.equal(response.status, 400); + }); +}); diff --git a/apps/web/app/today-sync-client.ts b/apps/web/app/today-sync-client.ts new file mode 100644 index 00000000..b7189cc1 --- /dev/null +++ b/apps/web/app/today-sync-client.ts @@ -0,0 +1,413 @@ +import { randomUUID } from 'node:crypto'; +import { + createPlanningContextHeaders, + parseSessionWorkspace, + requireGatewaySecret, + requireServiceOrigin, +} from './planning-search-client'; + +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}$/i; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; +const MAXIMUM_COOKIE_BYTES = 4 * 1024; +const MAXIMUM_BROWSER_BODY_BYTES = 64 * 1024; +const MAXIMUM_UPSTREAM_BODY_BYTES = 64 * 1024; +const UPSTREAM_TIMEOUT_MS = 3_000; + +type WebEnvironment = Readonly>; +export type TodaySyncFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +interface TodayProblem { + readonly type: 'about:blank'; + readonly title: string; + readonly status: number; + readonly code: string; + readonly currentRevision?: string | null; +} + +/** Returns one no-store problem response without dependency details. */ +function problemResponse( + status: number, + title: string, + code: string, + currentRevision?: string | null, +): Response { + const body: TodayProblem = { + type: 'about:blank', + title, + status, + code, + ...(currentRevision === undefined ? {} : { currentRevision }), + }; + return Response.json(body, { + status, + headers: { + 'cache-control': 'no-store', + 'content-type': 'application/problem+json', + }, + }); +} + +/** Returns the fixed browser input failure. */ +function invalidRequest(): Response { + return problemResponse( + 400, + 'Today synchronization request is invalid', + 'invalid_today_request', + ); +} + +/** Returns the fixed dependency failure. */ +function unavailable(): Response { + return problemResponse( + 503, + 'Today synchronization is unavailable', + 'today_sync_unavailable', + ); +} + +/** Accepts one real canonical local calendar date. */ +function requireDate(value: string): string { + if (!DATE_PATTERN.test(value)) throw new Error('invalid date'); + const parsed = new Date(`${value}T00:00:00.000Z`); + if ( + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== value + ) { + throw new Error('invalid date'); + } + return value; +} + +/** Accepts one bounded cookie header without header-injection bytes. */ +function requireCookie(request: Request): string | undefined { + const cookie = request.headers.get('cookie') ?? undefined; + if ( + cookie !== undefined && + (Buffer.byteLength(cookie, 'utf8') > MAXIMUM_COOKIE_BYTES || + /[\r\n\u0000]/u.test(cookie)) + ) { + throw new Error('invalid cookie'); + } + return cookie; +} + +/** Normalizes a media type token because RFC media types are case-insensitive. */ +function mediaType(value: string | null): string | undefined { + return value?.split(';', 1)[0]?.trim().toLowerCase(); +} + +/** Reads one body with a strict byte cap before returning text. */ +async function readBoundedText( + response: Response, + maximumBytes: number, +): Promise { + const declared = response.headers.get('content-length'); + if ( + declared !== null && + (!/^\d+$/u.test(declared) || Number(declared) > maximumBytes) + ) { + throw new Error('body too large'); + } + if (!response.body) throw new Error('missing body'); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + let bytes = 0; + let body = ''; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > maximumBytes) { + await reader.cancel('body too large'); + throw new Error('body too large'); + } + body += decoder.decode(chunk.value, { stream: true }); + } + body += decoder.decode(); + } finally { + reader.releaseLock(); + } + if (!body) throw new Error('missing body'); + return body; +} + +/** Reads bounded JSON from an allowed JSON media type. */ +async function readBoundedJson( + response: Response, + maximumBytes: number, +): Promise { + const contentType = mediaType(response.headers.get('content-type')); + if ( + contentType !== 'application/json' && + contentType !== 'application/problem+json' + ) { + throw new Error('invalid media type'); + } + return JSON.parse(await readBoundedText(response, maximumBytes)) as unknown; +} + +/** Reads and validates the complete browser PUT body before contacting identity. */ +async function readBrowserPutBody( + request: Request, + date: string, +): Promise { + if (mediaType(request.headers.get('content-type')) !== 'application/json') { + throw new Error('invalid media type'); + } + if (request.body === null) throw new Error('missing body'); + const browserResponse = new Response(request.body); + const text = await readBoundedText( + browserResponse, + MAXIMUM_BROWSER_BODY_BYTES, + ); + const parsed = JSON.parse(text) as unknown; + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + (parsed as Record).version !== 'life-os.today.v1' || + (parsed as Record).date !== date || + !Array.isArray((parsed as Record).actions) + ) { + throw new Error('invalid body'); + } + return JSON.stringify(parsed); +} + +/** Requires a strong revision ETag returned by planning-service. */ +function requireEtag(value: string | null): string { + const match = /^"([0-9a-f-]+)"$/iu.exec(value ?? ''); + if (!match?.[1] || !UUID_V4_PATTERN.test(match[1])) { + throw new Error('invalid etag'); + } + return `"${match[1].toLowerCase()}"`; +} + +/** Restricts browser write authority to one strong match or explicit create. */ +function requireWriteHeaders( + request: Request, +): Readonly> { + const ifMatch = request.headers.get('if-match'); + const ifNoneMatch = request.headers.get('if-none-match'); + const idempotencyKey = request.headers.get('idempotency-key'); + if ( + !idempotencyKey || + !UUID_V4_PATTERN.test(idempotencyKey) || + (ifMatch === null && ifNoneMatch === null) || + (ifMatch !== null && ifNoneMatch !== null) + ) { + throw new Error('invalid write headers'); + } + if (ifNoneMatch !== null) { + if (ifNoneMatch !== '*') throw new Error('invalid create precondition'); + return Object.freeze({ + 'if-none-match': '*', + 'idempotency-key': idempotencyKey.toLowerCase(), + }); + } + const etag = requireEtag(ifMatch); + return Object.freeze({ + 'if-match': etag, + 'idempotency-key': idempotencyKey.toLowerCase(), + }); +} + +/** Creates headers without copying arbitrary browser-selected authority. */ +function headers( + entries: Readonly>, +): Headers { + const result = new Headers({ accept: 'application/json' }); + for (const [name, value] of Object.entries(entries)) { + if (value !== undefined) result.set(name, value); + } + return result; +} + +/** Narrows a dependency revision conflict to fields the UI is allowed to reconcile. */ +function parseRevisionConflict(value: unknown): string | null | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return undefined; + const record = value as Record; + if ( + record.type !== 'about:blank' || + record.status !== 409 || + record.code !== 'today_revision_conflict' + ) { + return undefined; + } + if (record.currentRevision === null) return null; + if ( + typeof record.currentRevision === 'string' && + UUID_V4_PATTERN.test(record.currentRevision) + ) { + return record.currentRevision.toLowerCase(); + } + return undefined; +} + +/** Validates the upstream Today aggregate before returning it to the browser. */ +function parseAggregate( + value: unknown, + expectedDate: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid aggregate'); + } + const record = value as Record; + const keys = ['version', 'aggregateId', 'revision', 'date', 'actions']; + if ( + Object.keys(record).length !== keys.length || + keys.some((key) => !Object.hasOwn(record, key)) || + record.version !== 'life-os.today.v1' || + record.date !== expectedDate || + typeof record.aggregateId !== 'string' || + !UUID_V4_PATTERN.test(record.aggregateId) || + typeof record.revision !== 'string' || + !UUID_V4_PATTERN.test(record.revision) || + !Array.isArray(record.actions) || + record.actions.length > 50 + ) { + throw new Error('invalid aggregate'); + } + return record; +} + +/** + * Authenticates via identity, derives workspace scope server-side, signs a + * short-lived planning context, and proxies bounded Today GET/PUT operations. + */ +export async function handleTodaySyncRequest( + request: Request, + date: string, + environment: WebEnvironment, + fetcher: TodaySyncFetch = fetch, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + let safeDate: string; + let putBody: string | undefined; + let writeHeaders: Readonly> = {}; + try { + safeDate = requireDate(date); + if (request.method !== 'GET' && request.method !== 'PUT') { + return invalidRequest(); + } + if (request.method === 'PUT') { + writeHeaders = requireWriteHeaders(request); + putBody = await readBrowserPutBody(request, safeDate); + } + } catch { + return invalidRequest(); + } + + try { + const identityOrigin = requireServiceOrigin( + environment.IDENTITY_SERVICE_ORIGIN, + ); + const planningOrigin = requireServiceOrigin( + environment.PLANNING_SERVICE_ORIGIN, + ); + const secret = requireGatewaySecret( + environment.PLANNING_GATEWAY_CONTEXT_SECRET, + ); + const cookie = requireCookie(request); + const correlationId = randomUUID(); + const identityResponse = await fetcher( + new URL('/v1/session', identityOrigin), + { + method: 'GET', + headers: headers({ cookie, 'x-correlation-id': correlationId }), + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + if (identityResponse.status === 401) { + return problemResponse( + 401, + 'Authentication is required', + 'authentication_required', + ); + } + if (identityResponse.status !== 200) return unavailable(); + const workspaceId = parseSessionWorkspace( + await readBoundedJson(identityResponse, MAXIMUM_UPSTREAM_BODY_BYTES), + ); + const planningContext = createPlanningContextHeaders( + workspaceId, + secret, + nowSeconds, + ); + const planningResponse = await fetcher( + new URL(`/v1/today/${safeDate}`, planningOrigin), + { + method: request.method, + headers: headers({ + ...planningContext, + ...writeHeaders, + ...(request.method === 'PUT' + ? { 'content-type': 'application/json' } + : {}), + 'x-correlation-id': correlationId, + }), + ...(request.method === 'PUT' && putBody !== undefined + ? { body: putBody } + : {}), + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + if (planningResponse.status === 404 && request.method === 'GET') { + return problemResponse( + 404, + 'Today aggregate was not found', + 'today_not_found', + ); + } + if (planningResponse.status === 409) { + const conflict = parseRevisionConflict( + await readBoundedJson(planningResponse, MAXIMUM_UPSTREAM_BODY_BYTES), + ); + if (conflict !== undefined) { + return problemResponse( + 409, + 'Today changed on another device', + 'today_revision_conflict', + conflict, + ); + } + return problemResponse( + 409, + 'Today write conflicts with an earlier request', + 'today_write_conflict', + ); + } + if (planningResponse.status !== 200 && planningResponse.status !== 201) { + return unavailable(); + } + const aggregate = parseAggregate( + await readBoundedJson(planningResponse, MAXIMUM_UPSTREAM_BODY_BYTES), + safeDate, + ); + const etag = requireEtag(planningResponse.headers.get('etag')); + if (`"${String(aggregate.revision).toLowerCase()}"` !== etag) { + return unavailable(); + } + return Response.json(aggregate, { + status: planningResponse.status, + headers: { + 'cache-control': 'no-store', + 'content-type': 'application/json', + etag, + 'x-correlation-id': correlationId, + }, + }); + } catch { + return unavailable(); + } +} diff --git a/apps/web/app/today-workspace-sync.test.ts b/apps/web/app/today-workspace-sync.test.ts new file mode 100644 index 00000000..7d5403e3 --- /dev/null +++ b/apps/web/app/today-workspace-sync.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createEmptyTodayDraft, addTodayAction } from './today-state'; +import { + fetchWorkspaceToday, + saveWorkspaceToday, + toDurableTodayDocument, +} from './today-workspace-sync'; + +const DATE = '2026-08-09'; +const ACTION_ID = '33333333-3333-4333-8333-333333333333'; +const REVISION = '22222222-2222-4222-8222-222222222222'; + +function draft() { + return addTodayAction(createEmptyTodayDraft(DATE), { + id: ACTION_ID, + title: 'Move this only when I ask', + createdAt: '2026-08-09T00:00:00.000Z', + }); +} + +function aggregate() { + return { + ...toDurableTodayDocument(draft()), + aggregateId: '44444444-4444-4444-8444-444444444444', + revision: REVISION, + }; +} + +describe('browser Today workspace synchronization', () => { + it('converts a validated local draft without a workspace identifier', () => { + assert.deepEqual(toDurableTodayDocument(draft()), { + version: 'life-os.today.v1', + date: DATE, + actions: draft().actions, + }); + }); + + it('checks durable state only when explicitly called and converts it back to local state', async () => { + const calls: Array<{ input: string; init: RequestInit | undefined }> = []; + const result = await fetchWorkspaceToday(DATE, async (input, init) => { + calls.push({ input: String(input), init }); + return Response.json(aggregate(), { + status: 200, + headers: { etag: `"${REVISION}"` }, + }); + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.input, `/api/planning/today/${DATE}`); + assert.equal(calls[0]?.init?.method, 'GET'); + assert.equal(result.kind, 'found'); + if (result.kind === 'found') { + assert.deepEqual(result.draft, draft()); + assert.equal(result.revision, REVISION); + } + }); + + it('creates durable state only through an explicit save using If-None-Match and a fresh idempotency key', async () => { + let captured: RequestInit | undefined; + const result = await saveWorkspaceToday( + draft(), + null, + async (_input, init) => { + captured = init; + return Response.json(aggregate(), { + status: 201, + headers: { etag: `"${REVISION}"` }, + }); + }, + ); + + assert.equal(result.kind, 'saved'); + const headers = captured?.headers as Headers; + assert.equal(headers.get('if-none-match'), '*'); + assert.equal(headers.get('if-match'), null); + assert.match( + headers.get('idempotency-key') ?? '', + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + assert.equal( + captured?.body, + JSON.stringify(toDurableTodayDocument(draft())), + ); + }); + + it('updates only against the last explicitly observed strong revision', async () => { + let captured: RequestInit | undefined; + await saveWorkspaceToday(draft(), REVISION, async (_input, init) => { + captured = init; + return Response.json(aggregate(), { + status: 200, + headers: { etag: `"${REVISION}"` }, + }); + }); + + const headers = captured?.headers as Headers; + assert.equal(headers.get('if-match'), `"${REVISION}"`); + assert.equal(headers.get('if-none-match'), null); + }); + + it('reports authentication, absence, conflict, and dependency failure as explicit states', async () => { + assert.deepEqual( + await fetchWorkspaceToday(DATE, async () => + Response.json({}, { status: 401 }), + ), + { kind: 'unauthenticated' }, + ); + assert.deepEqual( + await fetchWorkspaceToday(DATE, async () => + Response.json({}, { status: 404 }), + ), + { kind: 'missing' }, + ); + assert.deepEqual( + await saveWorkspaceToday(draft(), REVISION, async () => + Response.json( + { + type: 'about:blank', + title: 'Today changed on another device', + status: 409, + code: 'today_revision_conflict', + currentRevision: '55555555-5555-4555-8555-555555555555', + }, + { status: 409 }, + ), + ), + { + kind: 'conflict', + currentRevision: '55555555-5555-4555-8555-555555555555', + }, + ); + assert.deepEqual( + await fetchWorkspaceToday(DATE, async () => + Response.json({}, { status: 503 }), + ), + { kind: 'unavailable' }, + ); + }); + + it('fails closed on malformed durable response content instead of overwriting the local draft', async () => { + const result = await fetchWorkspaceToday(DATE, async () => + Response.json( + { ...aggregate(), actions: [{ id: 'attacker-data' }] }, + { status: 200, headers: { etag: `"${REVISION}"` } }, + ), + ); + assert.deepEqual(result, { kind: 'unavailable' }); + }); +}); diff --git a/apps/web/app/today-workspace-sync.ts b/apps/web/app/today-workspace-sync.ts new file mode 100644 index 00000000..41a00d14 --- /dev/null +++ b/apps/web/app/today-workspace-sync.ts @@ -0,0 +1,232 @@ +import { parseTodayDraft, type TodayDraft } from './today-state'; + +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}$/i; +const MAXIMUM_RESPONSE_BYTES = 64 * 1024; + +type BrowserFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +/** Valid durable Today document sent to the same-origin BFF. */ +export interface DurableTodayDocument { + readonly version: 'life-os.today.v1'; + readonly date: string; + readonly actions: TodayDraft['actions']; +} + +export type WorkspaceTodayReadResult = + | { + readonly kind: 'found'; + readonly draft: TodayDraft; + readonly revision: string; + } + | { readonly kind: 'missing' } + | { readonly kind: 'unauthenticated' } + | { readonly kind: 'unavailable' }; + +export type WorkspaceTodaySaveResult = + | { + readonly kind: 'saved'; + readonly draft: TodayDraft; + readonly revision: string; + } + | { readonly kind: 'conflict'; readonly currentRevision: string | null } + | { readonly kind: 'unauthenticated' } + | { readonly kind: 'unavailable' }; + +/** Converts validated browser-local state to the distinct durable wire version. */ +export function toDurableTodayDocument( + draft: TodayDraft, +): DurableTodayDocument { + const safeDraft = parseTodayDraft(draft, draft.date); + return Object.freeze({ + version: 'life-os.today.v1', + date: safeDraft.date, + actions: safeDraft.actions, + }); +} + +/** Reads a response body only after enforcing an explicit byte cap. */ +async function readBoundedJson(response: Response): Promise { + const declared = response.headers.get('content-length'); + if ( + declared !== null && + (!/^\d+$/u.test(declared) || Number(declared) > MAXIMUM_RESPONSE_BYTES) + ) { + throw new Error('response too large'); + } + const contentType = response.headers.get('content-type')?.split(';', 1)[0]; + if ( + contentType !== 'application/json' && + contentType !== 'application/problem+json' + ) { + throw new Error('unexpected media type'); + } + if (!response.body) throw new Error('missing body'); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + let bytes = 0; + let body = ''; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > MAXIMUM_RESPONSE_BYTES) { + await reader.cancel('response too large'); + throw new Error('response too large'); + } + body += decoder.decode(chunk.value, { stream: true }); + } + body += decoder.decode(); + } finally { + reader.releaseLock(); + } + return JSON.parse(body) as unknown; +} + +/** Requires one strong UUIDv4 ETag and returns the unquoted opaque revision. */ +function requireRevision(response: Response): string { + const match = /^"([0-9a-f-]+)"$/iu.exec(response.headers.get('etag') ?? ''); + if (!match?.[1] || !UUID_V4_PATTERN.test(match[1])) { + throw new Error('invalid revision'); + } + return match[1].toLowerCase(); +} + +/** Converts one trusted-BFF durable response back to validated local draft state. */ +function parseDurableToday(value: unknown, date: string): TodayDraft { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid durable Today'); + } + const record = value as Record; + const keys = ['version', 'aggregateId', 'revision', 'date', 'actions']; + if ( + Object.keys(record).length !== keys.length || + keys.some((key) => !Object.hasOwn(record, key)) || + record.version !== 'life-os.today.v1' || + record.date !== date || + typeof record.aggregateId !== 'string' || + !UUID_V4_PATTERN.test(record.aggregateId) || + typeof record.revision !== 'string' || + !UUID_V4_PATTERN.test(record.revision) + ) { + throw new Error('invalid durable Today'); + } + return parseTodayDraft( + { + version: 'life-os.today-draft.v1', + date, + actions: record.actions, + }, + date, + ); +} + +/** Narrows the bounded BFF conflict shape to the current opaque revision token. */ +async function parseConflict( + response: Response, +): Promise { + const value = await readBoundedJson(response); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + if ( + record.type !== 'about:blank' || + record.title !== 'Today changed on another device' || + record.status !== 409 || + record.code !== 'today_revision_conflict' + ) { + return undefined; + } + if (record.currentRevision === null) return null; + if ( + typeof record.currentRevision === 'string' && + UUID_V4_PATTERN.test(record.currentRevision) + ) { + return record.currentRevision.toLowerCase(); + } + return undefined; +} + +/** Explicitly checks whether a durable Today aggregate already exists. */ +export async function fetchWorkspaceToday( + date: string, + fetcher: BrowserFetch = fetch, +): Promise { + try { + const response = await fetcher( + `/api/planning/today/${encodeURIComponent(date)}`, + { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + headers: { accept: 'application/json' }, + }, + ); + if (response.status === 401) return { kind: 'unauthenticated' }; + if (response.status === 404) return { kind: 'missing' }; + if (response.status !== 200) return { kind: 'unavailable' }; + const revision = requireRevision(response); + const draft = parseDurableToday(await readBoundedJson(response), date); + return { kind: 'found', draft, revision }; + } catch { + return { kind: 'unavailable' }; + } +} + +/** + * Explicitly saves browser-local state. A null revision means the user is + * intentionally creating a missing durable aggregate; otherwise the last + * observed strong revision is required so stale tabs cannot overwrite state. + */ +export async function saveWorkspaceToday( + draft: TodayDraft, + revision: string | null, + fetcher: BrowserFetch = fetch, +): Promise { + try { + const document = toDurableTodayDocument(draft); + if (revision !== null && !UUID_V4_PATTERN.test(revision)) { + return { kind: 'unavailable' }; + } + const requestHeaders = new Headers({ + accept: 'application/json', + 'content-type': 'application/json', + 'idempotency-key': globalThis.crypto.randomUUID(), + }); + if (revision === null) requestHeaders.set('if-none-match', '*'); + else requestHeaders.set('if-match', `"${revision.toLowerCase()}"`); + const response = await fetcher( + `/api/planning/today/${encodeURIComponent(document.date)}`, + { + method: 'PUT', + credentials: 'same-origin', + cache: 'no-store', + headers: requestHeaders, + body: JSON.stringify(document), + }, + ); + if (response.status === 401) return { kind: 'unauthenticated' }; + if (response.status === 409) { + const currentRevision = await parseConflict(response); + return currentRevision === undefined + ? { kind: 'unavailable' } + : { kind: 'conflict', currentRevision }; + } + if (response.status !== 200 && response.status !== 201) { + return { kind: 'unavailable' }; + } + const nextRevision = requireRevision(response); + const nextDraft = parseDurableToday( + await readBoundedJson(response), + document.date, + ); + return { kind: 'saved', draft: nextDraft, revision: nextRevision }; + } catch { + return { kind: 'unavailable' }; + } +} diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts index fe1ba27b..40de4304 100644 --- a/apps/web/e2e/accessibility.spec.ts +++ b/apps/web/e2e/accessibility.spec.ts @@ -23,11 +23,15 @@ test('switches the complete core workflow to Korean and persists the choice', as await page.getByRole('button', { name: '기록', exact: true }).click(); await expect(page.getByText('한국어 접근성 점검')).toBeVisible(); - await page.getByLabel('워크스페이스 영구 기록 검색').fill('한'); + await page + .getByRole('textbox', { name: '워크스페이스 영구 기록 검색' }) + .fill('한'); await page.getByRole('button', { name: '검색', exact: true }).click(); - await expect(page.getByRole('status')).toContainText( - '워크스페이스를 검색하려면 두 글자 이상 입력하세요.', - ); + await expect( + page.getByRole('status').filter({ + hasText: '워크스페이스를 검색하려면 두 글자 이상 입력하세요.', + }), + ).toBeVisible(); await page.reload(); await expect(page.locator('html')).toHaveAttribute('lang', 'ko'); @@ -45,7 +49,11 @@ test('exposes semantic landmarks, visible keyboard focus, and reduced motion', a await expect( page.getByRole('navigation', { name: 'Primary navigation' }), ).toBeVisible(); - await expect(page.getByRole('status')).toBeVisible(); + await expect( + page.getByRole('status').filter({ + hasText: 'This Today is browser-local only.', + }), + ).toBeVisible(); await expect(page.locator('html')).toHaveCSS('scroll-behavior', 'auto'); await page.keyboard.press('Tab'); diff --git a/apps/web/e2e/mobile-pwa.spec.ts b/apps/web/e2e/mobile-pwa.spec.ts index 6ba1e568..a4ec4c43 100644 --- a/apps/web/e2e/mobile-pwa.spec.ts +++ b/apps/web/e2e/mobile-pwa.spec.ts @@ -9,7 +9,9 @@ const EXPECTED_PUBLIC_CACHE_PATHS = [ test('exposes a standards-based install manifest', async ({ request }) => { const response = await request.get('/manifest.webmanifest'); expect(response.ok()).toBe(true); - expect(response.headers()['content-type']).toContain('application/manifest+json'); + expect(response.headers()['content-type']).toContain( + 'application/manifest+json', + ); const manifest = (await response.json()) as { readonly id?: string; @@ -73,9 +75,7 @@ test('registers one bounded shell cache without storing Today HTML', async ({ }), ) ).flat(); - return requests - .map((request) => new URL(request.url).pathname) - .sort(); + return requests.map((request) => new URL(request.url).pathname).sort(); }); } @@ -99,7 +99,9 @@ test('serves the credential-free fallback for an offline navigation', async ({ .toBe('registered'); await page.reload(); await expect - .poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))) + .poll(() => + page.evaluate(() => Boolean(navigator.serviceWorker.controller)), + ) .toBe(true); await context.setOffline(true); @@ -109,7 +111,9 @@ test('serves the credential-free fallback for an offline navigation', async ({ page.getByRole('heading', { name: 'LifeOS is offline.' }), ).toBeVisible(); await expect( - page.getByText('this offline page does not read or cache your planning data'), + page.getByText( + 'this offline page does not read or cache your planning data', + ), ).toBeVisible(); } finally { await context.setOffline(false); diff --git a/apps/web/e2e/onboarding.spec.ts b/apps/web/e2e/onboarding.spec.ts index 94ad9966..fa3c7cf1 100644 --- a/apps/web/e2e/onboarding.spec.ts +++ b/apps/web/e2e/onboarding.spec.ts @@ -70,7 +70,10 @@ test('restores the existing Today draft when completion storage fails', async ({ value: string, ): void { if (key === 'life-os.onboarding-completion.v1') { - throw new DOMException('Simulated storage failure', 'QuotaExceededError'); + throw new DOMException( + 'Simulated storage failure', + 'QuotaExceededError', + ); } nativeSetItem.call(this, key, value); }; @@ -107,7 +110,9 @@ test('restores the existing Today draft when completion storage fails', async ({ .toBeNull(); }); -test('fails closed when required planning inputs are absent', async ({ page }) => { +test('fails closed when required planning inputs are absent', async ({ + page, +}) => { await page.getByRole('button', { name: 'Create my first plan' }).click(); await expect( page.getByText('Name a direction and one visible next action.'), diff --git a/apps/web/e2e/quick-capture-search.spec.ts b/apps/web/e2e/quick-capture-search.spec.ts index d9dea973..430f6774 100644 --- a/apps/web/e2e/quick-capture-search.spec.ts +++ b/apps/web/e2e/quick-capture-search.spec.ts @@ -57,16 +57,20 @@ test('keeps browser-local capture distinct from durable workspace search', async await expect(backlog.getByText('Write a local release note')).toBeVisible(); await expect(page.getByText(/Stored only in this browser/)).toBeVisible(); - await page.getByLabel('Search durable workspace').fill('release evidence'); + await page + .getByRole('textbox', { name: 'Search durable workspace' }) + .fill('release evidence'); await page.getByRole('button', { name: 'Search' }).click(); const results = page.getByRole('list', { name: 'Workspace search results' }); await expect(results.getByText('Release confidence')).toBeVisible(); await expect(results.getByText('Release evidence project')).toBeVisible(); await expect(results.getByText('Review release evidence')).toBeVisible(); - await expect(page.getByRole('status')).toContainText( - '3 durable workspace results found.', - ); + await expect( + page.getByRole('status').filter({ + hasText: '3 durable workspace results found.', + }), + ).toBeVisible(); await expect(results).not.toContainText('Write a local release note'); }); @@ -86,12 +90,16 @@ test('announces empty, unauthenticated, and unavailable search states', async ({ }); }); - const searchInput = page.getByLabel('Search durable workspace'); + const searchInput = page.getByRole('textbox', { + name: 'Search durable workspace', + }); await searchInput.fill('nothing here'); await searchInput.press('Enter'); - await expect(page.getByRole('status')).toContainText( - 'No durable workspace records matched.', - ); + await expect( + page.getByRole('status').filter({ + hasText: 'No durable workspace records matched.', + }), + ).toBeVisible(); responseStatus = 401; responseBody = { @@ -102,9 +110,11 @@ test('announces empty, unauthenticated, and unavailable search states', async ({ }; await searchInput.fill('private work'); await searchInput.press('Enter'); - await expect(page.getByRole('status')).toContainText( - 'Sign in to search durable workspace records.', - ); + await expect( + page.getByRole('status').filter({ + hasText: 'Sign in to search durable workspace records.', + }), + ).toBeVisible(); responseStatus = 503; responseBody = { @@ -115,9 +125,11 @@ test('announces empty, unauthenticated, and unavailable search states', async ({ }; await searchInput.fill('retry later'); await searchInput.press('Enter'); - await expect(page.getByRole('status')).toContainText( - 'Workspace search is temporarily unavailable.', - ); + await expect( + page.getByRole('status').filter({ + hasText: 'Workspace search is temporarily unavailable.', + }), + ).toBeVisible(); }); test('keeps capture and search keyboard-operable on a mobile viewport', async ({ @@ -139,10 +151,14 @@ test('keeps capture and search keyboard-operable on a mobile viewport', async ({ 'Mobile capture', ); - const searchInput = page.getByLabel('Search durable workspace'); + const searchInput = page.getByRole('textbox', { + name: 'Search durable workspace', + }); await searchInput.fill('Mobile search'); await searchInput.press('Enter'); - await expect(page.getByRole('status')).toContainText( - 'No durable workspace records matched.', - ); + await expect( + page.getByRole('status').filter({ + hasText: 'No durable workspace records matched.', + }), + ).toBeVisible(); }); diff --git a/apps/web/e2e/today-flow.spec.ts b/apps/web/e2e/today-flow.spec.ts index e1fa34ce..fe8f5001 100644 --- a/apps/web/e2e/today-flow.spec.ts +++ b/apps/web/e2e/today-flow.spec.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; +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}$/i; + test.beforeEach(async ({ page }) => { await page.goto('/'); await page.evaluate(() => window.localStorage.clear()); @@ -9,15 +12,21 @@ test.beforeEach(async ({ page }) => { test('captures, commits, schedules, persists, and completes an action', async ({ page, }) => { - await page.getByLabel('What needs your attention?').fill('Review the release evidence'); + await page + .getByLabel('Capture locally for Today') + .fill('Review the release evidence'); await page.getByRole('button', { name: 'Capture' }).click(); const backlog = page.getByRole('region', { name: 'Backlog' }); await expect(backlog.getByText('Review the release evidence')).toBeVisible(); await backlog.getByRole('button', { name: 'Make priority' }).click(); - await page.getByLabel('Start time for Review the release evidence').fill('09:00'); - await page.getByLabel('Duration for Review the release evidence').selectOption('60'); + await page + .getByLabel('Start time for Review the release evidence') + .fill('09:00'); + await page + .getByLabel('Duration for Review the release evidence') + .selectOption('60'); await expect(page.getByText('09:00–10:00')).toBeVisible(); await page.reload(); @@ -26,12 +35,19 @@ test('captures, commits, schedules, persists, and completes an action', async ({ await page.getByRole('button', { name: 'Complete' }).click(); const completed = page.getByRole('region', { name: 'Completed' }); - await expect(completed.getByText('Review the release evidence')).toBeVisible(); + await expect( + completed.getByText('Review the release evidence'), + ).toBeVisible(); }); test('enforces the visible three-priority capacity', async ({ page }) => { - for (const title of ['First priority', 'Second priority', 'Third priority', 'Fourth action']) { - await page.getByLabel('What needs your attention?').fill(title); + for (const title of [ + 'First priority', + 'Second priority', + 'Third priority', + 'Fourth action', + ]) { + await page.getByLabel('Capture locally for Today').fill(title); await page.getByRole('button', { name: 'Capture' }).click(); } @@ -42,12 +58,294 @@ test('enforces the visible three-priority capacity', async ({ page }) => { await buttons.nth(0).click(); await expect(page.getByText('3 / 3')).toBeVisible(); - await expect(backlog.getByRole('button', { name: 'Make priority' })).toBeDisabled(); + await expect( + backlog.getByRole('button', { name: 'Make priority' }), + ).toBeDisabled(); +}); + +test('keeps a local Today private until the user explicitly migrates it', async ({ + page, +}) => { + let requestCount = 0; + let putCount = 0; + const revision = '22222222-2222-4222-8222-222222222222'; + + await page.route('**/api/planning/today/**', async (route) => { + requestCount += 1; + const request = route.request(); + if (request.method() === 'GET') { + await route.fulfill({ + status: 404, + contentType: 'application/problem+json', + body: JSON.stringify({ + type: 'about:blank', + title: 'Today aggregate was not found', + status: 404, + code: 'today_not_found', + }), + }); + return; + } + + expect(request.method()).toBe('PUT'); + putCount += 1; + const requestHeaders = request.headers(); + expect(requestHeaders['if-none-match']).toBe('*'); + expect(requestHeaders['if-match']).toBeUndefined(); + expect(requestHeaders['idempotency-key']).toMatch(UUID_V4_PATTERN); + + const document = request.postDataJSON() as { + version: string; + date: string; + actions: unknown[]; + }; + await route.fulfill({ + status: 201, + contentType: 'application/json', + headers: { etag: `"${revision}"` }, + body: JSON.stringify({ + ...document, + aggregateId: '44444444-4444-4444-8444-444444444444', + revision, + }), + }); + }); + + await page.reload(); + await expect( + page.getByText('This Today is browser-local only.'), + ).toBeVisible(); + expect(requestCount).toBe(0); + + await page + .getByLabel('Capture locally for Today') + .fill('Keep this local first'); + await page.getByRole('button', { name: 'Capture' }).click(); + await expect(page.getByText('Keep this local first')).toBeVisible(); + expect(requestCount).toBe(0); + + await page.getByRole('button', { name: 'Check workspace Today' }).click(); + await expect( + page.getByText( + 'No durable Today exists for this date. Your local draft is still unchanged.', + ), + ).toBeVisible(); + expect(requestCount).toBe(1); + + await page + .getByRole('button', { name: 'Move local draft to workspace' }) + .click(); + await expect( + page.getByText( + 'The current local Today is saved durably. Later local edits still require another explicit save.', + ), + ).toBeVisible(); + expect(requestCount).toBe(2); + expect(putCount).toBe(1); +}); + +test('keeps the local draft after a failed save and retries only after another explicit check', async ({ + page, +}) => { + let getCount = 0; + let putCount = 0; + const idempotencyKeys: string[] = []; + const revision = '77777777-7777-4777-8777-777777777777'; + + await page.route('**/api/planning/today/**', async (route) => { + const request = route.request(); + if (request.method() === 'GET') { + getCount += 1; + await route.fulfill({ + status: 404, + contentType: 'application/problem+json', + body: JSON.stringify({ + type: 'about:blank', + title: 'Today aggregate was not found', + status: 404, + code: 'today_not_found', + }), + }); + return; + } + + expect(request.method()).toBe('PUT'); + putCount += 1; + const requestHeaders = request.headers(); + expect(requestHeaders['if-none-match']).toBe('*'); + expect(requestHeaders['if-match']).toBeUndefined(); + const idempotencyKey = requestHeaders['idempotency-key'] ?? ''; + expect(idempotencyKey).toMatch(UUID_V4_PATTERN); + idempotencyKeys.push(idempotencyKey); + + if (putCount === 1) { + await route.fulfill({ + status: 503, + contentType: 'application/problem+json', + body: JSON.stringify({ + type: 'about:blank', + title: 'Today synchronization is unavailable', + status: 503, + code: 'today_sync_unavailable', + }), + }); + return; + } + + const document = request.postDataJSON() as { + version: string; + date: string; + actions: unknown[]; + }; + await route.fulfill({ + status: 201, + contentType: 'application/json', + headers: { etag: `"${revision}"` }, + body: JSON.stringify({ + ...document, + aggregateId: '88888888-8888-4888-8888-888888888888', + revision, + }), + }); + }); + + await page.reload(); + await page + .getByLabel('Capture locally for Today') + .fill('Survive a workspace outage'); + await page.getByRole('button', { name: 'Capture' }).click(); + await page.getByRole('button', { name: 'Check workspace Today' }).click(); + await page + .getByRole('button', { name: 'Move local draft to workspace' }) + .click(); + + await expect( + page.getByText( + 'Workspace Today is temporarily unavailable. Your local draft remains unchanged.', + ), + ).toBeVisible(); + await expect(page.getByText('Survive a workspace outage')).toBeVisible(); + expect(getCount).toBe(1); + expect(putCount).toBe(1); + await expect( + page.getByRole('button', { name: 'Move local draft to workspace' }), + ).toHaveCount(0); + + await page.getByRole('button', { name: 'Check workspace Today' }).click(); + await page + .getByRole('button', { name: 'Move local draft to workspace' }) + .click(); + + await expect( + page.getByText( + 'The current local Today is saved durably. Later local edits still require another explicit save.', + ), + ).toBeVisible(); + expect(getCount).toBe(2); + expect(putCount).toBe(2); + expect(idempotencyKeys).toHaveLength(2); + expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]); +}); + +test('surfaces a stale-device conflict and requires an explicit recheck before using newer workspace state', async ({ + page, +}) => { + const oldRevision = '22222222-2222-4222-8222-222222222222'; + const newRevision = '55555555-5555-4555-8555-555555555555'; + let getCount = 0; + let putCount = 0; + + await page.route('**/api/planning/today/**', async (route) => { + const request = route.request(); + const date = new URL(request.url()).pathname.split('/').at(-1) ?? ''; + if (request.method() === 'GET') { + getCount += 1; + const revision = getCount === 1 ? oldRevision : newRevision; + const title = + getCount === 1 ? 'Older workspace copy' : 'Newer device copy'; + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { etag: `"${revision}"` }, + body: JSON.stringify({ + version: 'life-os.today.v1', + aggregateId: '44444444-4444-4444-8444-444444444444', + revision, + date, + actions: [ + { + id: '66666666-6666-4666-8666-666666666666', + title, + status: 'open', + priority: 1, + startMinute: null, + durationMinutes: null, + createdAt: `${date}T00:00:00.000Z`, + completedAt: null, + }, + ], + }), + }); + return; + } + + expect(request.method()).toBe('PUT'); + putCount += 1; + const requestHeaders = request.headers(); + expect(requestHeaders['if-match']).toBe(`"${oldRevision}"`); + expect(requestHeaders['if-none-match']).toBeUndefined(); + await route.fulfill({ + status: 409, + contentType: 'application/problem+json', + body: JSON.stringify({ + type: 'about:blank', + title: 'Today changed on another device', + status: 409, + code: 'today_revision_conflict', + currentRevision: newRevision, + }), + }); + }); + + await page.reload(); + await page + .getByLabel('Capture locally for Today') + .fill('Local conflicting edit'); + await page.getByRole('button', { name: 'Capture' }).click(); + await expect(page.getByText('Local conflicting edit')).toBeVisible(); + + await page.getByRole('button', { name: 'Check workspace Today' }).click(); + await expect( + page.getByText( + 'A durable Today exists. Review your choice before replacing either copy.', + ), + ).toBeVisible(); + + await page + .getByRole('button', { name: 'Replace workspace with this local draft' }) + .click(); + await expect( + page.getByText( + 'Another device changed Today. Check the workspace again before deciding which copy to keep.', + ), + ).toBeVisible(); + await expect(page.getByText('Local conflicting edit')).toBeVisible(); + expect(putCount).toBe(1); + + await page.getByRole('button', { name: 'Check workspace Today' }).click(); + await page + .getByRole('button', { name: 'Use workspace Today in this browser' }) + .click(); + await expect(page.getByText('Newer device copy')).toBeVisible(); + await expect(page.getByText('Local conflicting edit')).toHaveCount(0); + expect(getCount).toBe(2); }); test('keeps core controls usable at a mobile viewport', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await expect(page.getByRole('heading', { name: 'Make today believable.' })).toBeVisible(); - await expect(page.getByLabel('What needs your attention?')).toBeEditable(); + await expect( + page.getByRole('heading', { name: 'Make today believable.' }), + ).toBeVisible(); + await expect(page.getByLabel('Capture locally for Today')).toBeEditable(); await expect(page.getByRole('button', { name: 'Capture' })).toBeVisible(); }); diff --git a/apps/web/e2e/today-save-race.spec.ts b/apps/web/e2e/today-save-race.spec.ts new file mode 100644 index 00000000..dce58e12 --- /dev/null +++ b/apps/web/e2e/today-save-race.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => window.localStorage.clear()); + await page.reload(); +}); + +test('preserves local edits made while an explicit workspace save is in flight', async ({ + page, +}) => { + const revision = '22222222-2222-4222-8222-222222222222'; + let releaseSave: (() => void) | undefined; + let markSaveStarted: (() => void) | undefined; + const saveStarted = new Promise((resolve) => { + markSaveStarted = resolve; + }); + const saveReleased = new Promise((resolve) => { + releaseSave = resolve; + }); + + await page.route('**/api/planning/today/**', async (route) => { + const request = route.request(); + if (request.method() === 'GET') { + await route.fulfill({ + status: 404, + contentType: 'application/problem+json', + body: JSON.stringify({ + type: 'about:blank', + title: 'Today aggregate was not found', + status: 404, + code: 'today_not_found', + }), + }); + return; + } + + const submitted = request.postDataJSON() as { + version: string; + date: string; + actions: unknown[]; + }; + markSaveStarted?.(); + await saveReleased; + await route.fulfill({ + status: 201, + contentType: 'application/json', + headers: { etag: `\"${revision}\"` }, + body: JSON.stringify({ + ...submitted, + aggregateId: '44444444-4444-4444-8444-444444444444', + revision, + }), + }); + }); + + await page + .getByLabel('Capture locally for Today') + .fill('Submitted before save starts'); + await page.getByRole('button', { name: 'Capture' }).click(); + await page.getByRole('button', { name: 'Check workspace Today' }).click(); + + await page + .getByRole('button', { name: 'Move local draft to workspace' }) + .click(); + await saveStarted; + + await page + .getByLabel('Capture locally for Today') + .fill('Edited while save is pending'); + await page.getByRole('button', { name: 'Capture' }).click(); + await expect(page.getByText('Edited while save is pending')).toBeVisible(); + + releaseSave?.(); + + await expect( + page.getByText( + 'The current local Today is saved durably. Later local edits still require another explicit save.', + ), + ).toBeVisible(); + await expect(page.getByText('Edited while save is pending')).toBeVisible(); + await expect(page.getByText('Submitted before save starts')).toBeVisible(); +}); diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c7e36f78..c31d20cf 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -8,7 +8,24 @@ "backlogNavigation": "Backlog", "completedNavigation": "Completed", "localDraftTitle": "Local draft", - "localDraftDescription": "Saved in this browser until workspace sync is connected.", + "localDraftDescription": "Saved in this browser. LifeOS never uploads this draft until you explicitly choose a workspace sync action.", + "workspaceSyncEyebrow": "Workspace sync", + "workspaceSyncHeading": "Choose when Today becomes durable.", + "workspaceSyncDescription": "Checking, loading, and saving are explicit. Your local draft is never uploaded in the background.", + "workspaceLocalOnlyStatus": "This Today is browser-local only.", + "workspaceMissingStatus": "No durable Today exists for this date. Your local draft is still unchanged.", + "workspaceFoundStatus": "A durable Today exists. Review your choice before replacing either copy.", + "workspaceSavedStatus": "The current local Today is saved durably. Later local edits still require another explicit save.", + "workspaceLoadedStatus": "The durable Today was explicitly loaded into this browser and is also stored locally.", + "workspaceConflictStatus": "Another device changed Today. Check the workspace again before deciding which copy to keep.", + "workspaceSignInStatus": "Sign in before checking or saving a durable Today.", + "workspaceUnavailableStatus": "Workspace Today is temporarily unavailable. Your local draft remains unchanged.", + "workspaceCheckingStatus": "Checking workspace Today…", + "checkWorkspaceToday": "Check workspace Today", + "moveLocalToWorkspace": "Move local draft to workspace", + "replaceWorkspaceWithLocal": "Replace workspace with this local draft", + "saveLocalToWorkspace": "Save local changes to workspace", + "useWorkspaceToday": "Use workspace Today in this browser", "todayDate": "Today · {date}", "todayHeading": "Make today believable.", "todayDescription": "Capture what is pulling at your attention, commit to no more than three priorities, and give each one an honest place on the clock.", @@ -54,7 +71,7 @@ "captureInputLabel": "Capture locally for Today", "capturePlaceholder": "Write the next visible action…", "captureButton": "Capture", - "captureCounter": "{count}/160 · Stored only in this browser until sync is connected.", + "captureCounter": "{count}/160 · Stored only in this browser until you explicitly sync it.", "searchFormLabel": "Search durable workspace planning records", "searchInputLabel": "Search durable workspace", "searchPlaceholder": "Goal, project, or task title…", diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index adc8a50f..ecb570fe 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -8,7 +8,24 @@ "backlogNavigation": "대기 목록", "completedNavigation": "완료", "localDraftTitle": "로컬 초안", - "localDraftDescription": "워크스페이스 동기화가 연결되기 전까지 이 브라우저에 저장됩니다.", + "localDraftDescription": "이 브라우저에 저장됩니다. 워크스페이스 동기화 작업을 직접 선택하기 전에는 LifeOS가 이 초안을 업로드하지 않습니다.", + "workspaceSyncEyebrow": "워크스페이스 동기화", + "workspaceSyncHeading": "오늘 계획을 언제 영구 저장할지 직접 선택하세요.", + "workspaceSyncDescription": "확인, 불러오기, 저장은 모두 명시적으로 실행합니다. 로컬 초안은 백그라운드에서 자동 업로드되지 않습니다.", + "workspaceLocalOnlyStatus": "이 오늘 계획은 현재 이 브라우저에만 있습니다.", + "workspaceMissingStatus": "이 날짜의 영구 저장된 오늘 계획이 없습니다. 로컬 초안은 그대로 유지됩니다.", + "workspaceFoundStatus": "영구 저장된 오늘 계획이 있습니다. 어느 사본을 교체할지 확인한 뒤 선택하세요.", + "workspaceSavedStatus": "현재 로컬 오늘 계획을 영구 저장했습니다. 이후 로컬 변경도 다시 명시적으로 저장해야 합니다.", + "workspaceLoadedStatus": "영구 저장된 오늘 계획을 이 브라우저에 직접 불러왔고 로컬에도 저장했습니다.", + "workspaceConflictStatus": "다른 기기에서 오늘 계획을 변경했습니다. 워크스페이스를 다시 확인한 뒤 유지할 사본을 선택하세요.", + "workspaceSignInStatus": "영구 저장된 오늘 계획을 확인하거나 저장하려면 로그인하세요.", + "workspaceUnavailableStatus": "현재 워크스페이스 오늘 계획을 사용할 수 없습니다. 로컬 초안은 그대로 유지됩니다.", + "workspaceCheckingStatus": "워크스페이스 오늘 계획을 확인하고 있습니다…", + "checkWorkspaceToday": "워크스페이스 오늘 계획 확인", + "moveLocalToWorkspace": "로컬 초안을 워크스페이스로 이동", + "replaceWorkspaceWithLocal": "워크스페이스를 이 로컬 초안으로 교체", + "saveLocalToWorkspace": "로컬 변경을 워크스페이스에 저장", + "useWorkspaceToday": "워크스페이스 오늘 계획을 이 브라우저에서 사용", "todayDate": "오늘 · {date}", "todayHeading": "실행 가능한 하루를 만드세요.", "todayDescription": "마음에 걸리는 일을 기록하고, 우선순위를 세 개 이하로 정한 뒤, 각 일을 실제 시간표에 배치하세요.", @@ -54,7 +71,7 @@ "captureInputLabel": "오늘 할 일을 로컬에 기록", "capturePlaceholder": "다음에 할 구체적인 일을 적으세요…", "captureButton": "기록", - "captureCounter": "{count}/160 · 동기화가 연결되기 전까지 이 브라우저에만 저장됩니다.", + "captureCounter": "{count}/160 · 직접 동기화하기 전까지 이 브라우저에만 저장됩니다.", "searchFormLabel": "영구 저장된 워크스페이스 계획 검색", "searchInputLabel": "워크스페이스 영구 기록 검색", "searchPlaceholder": "목표, 프로젝트 또는 작업 제목…", diff --git a/apps/web/package.json b/apps/web/package.json index 1ebc530b..0da40e82 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,8 +5,8 @@ "scripts": { "build": "next build", "dev": "next dev -p 3000", - "lint": "tsc --noEmit && prettier --single-quote --check package.json messages/en.json messages/ko.json app/localization.ts app/localization.test.ts app/planning-search-client.ts app/planning-search-client.test.ts app/ai-proposal-client-core.ts app/ai-proposal-client.ts app/ai-proposal-client.test.ts app/ai-proposal-scope-regression.test.ts app/ai-proposal-identity-stream-regression.test.ts app/api/planning/search/route.ts app/api/ai/proposals/route.ts \"app/api/ai/proposals/[proposalId]/route.ts\" \"app/api/ai/proposals/[proposalId]/decisions/route.ts\" app/api/ai/proposals/routes.test.ts app/design-tokens.css app/layout.tsx app/today-client.tsx app/components/planning-search-state.ts app/components/planning-search-state.test.ts app/components/quick-capture.tsx app/components/quick-capture.module.css e2e/accessibility.spec.ts e2e/quick-capture-search.spec.ts", - "test": "tsx --test app/localization.test.ts app/today-state.test.ts app/planning-search-client.test.ts app/ai-proposal-client.test.ts app/ai-proposal-scope-regression.test.ts app/ai-proposal-identity-stream-regression.test.ts app/api/ai/proposals/routes.test.ts app/components/planning-search-state.test.ts", + "lint": "tsc --noEmit && prettier --single-quote --check package.json \"messages/*.json\" \"app/**/*.{ts,tsx,css,json}\" \"e2e/**/*.{ts,tsx}\"", + "test": "tsx --test app/localization.test.ts app/today-state.test.ts app/planning-search-client.test.ts app/today-sync-client.test.ts app/today-sync-client-review-regression.test.ts app/today-workspace-sync.test.ts app/ai-proposal-client.test.ts app/ai-proposal-scope-regression.test.ts app/ai-proposal-identity-stream-regression.test.ts app/api/ai/proposals/routes.test.ts app/components/planning-search-state.test.ts", "test:e2e": "playwright test", "typecheck": "tsc --noEmit" }, diff --git a/docs/operations/durable-today-synchronization.md b/docs/operations/durable-today-synchronization.md new file mode 100644 index 00000000..1bde7b83 --- /dev/null +++ b/docs/operations/durable-today-synchronization.md @@ -0,0 +1,115 @@ +# Durable Today synchronization runbook + +**Status:** Implemented on active PR #127 +**Owner:** Planning bounded context with web BFF mediation + +## Purpose + +This runbook covers the durable Today aggregate introduced for cross-device planning. Browser-local Today remains a separate local draft. No page load, capture, schedule change, or completion automatically uploads that local draft. + +## Runtime boundaries + +- Browser calls only the same-origin web BFF. +- The BFF introspects the browser session with identity-service and derives the workspace UUID server-side. +- The browser cookie is never forwarded to planning-service. +- The BFF signs a short-lived workspace context with `PLANNING_GATEWAY_CONTEXT_SECRET` and forwards only bounded Today data plus conditional/idempotency headers. +- Planning-service owns the PostgreSQL tables, revision rotation, idempotency replay, and conflict decision. +- Calendar, habit, notification, review, and AI services must not read or mutate the Today tables directly. + +## Required runtime configuration + +The web/BFF path requires: + +- `IDENTITY_SERVICE_ORIGIN` +- `PLANNING_SERVICE_ORIGIN` +- `PLANNING_GATEWAY_CONTEXT_SECRET` with at least 32 bytes + +Planning-service requires: + +- `PLANNING_DATABASE_URL` +- the same trusted gateway-context secret through the existing planning-service configuration contract +- migration `apps/planning-service/migrations/0003_durable_today_sync.sql` + +Secrets must be injected through deployment secret management. Do not place them in browser bundles, logs, issue comments, screenshots, retained workflow artifacts, or repository fixtures. + +## User workflow + +### First durable save + +1. User creates or edits the browser-local Today plan. +2. No workspace request occurs automatically. +3. User selects **Check workspace Today**. +4. When no aggregate exists, the UI reports that the local draft remains unchanged. +5. User selects **Move local draft to workspace**. +6. Browser sends a fresh UUIDv4 idempotency key and `If-None-Match: *` through the BFF. +7. Planning-service creates the aggregate atomically and returns a strong opaque revision in `ETag`. + +### Existing aggregate + +1. User explicitly checks workspace Today. +2. BFF returns the complete bounded aggregate and strong `ETag`. +3. User may explicitly use workspace state in the browser or replace workspace state with the current local draft. +4. Replacement sends the last observed revision in `If-Match` and a fresh idempotency key. + +### Stale-device conflict + +A stale `If-Match` returns HTTP 409 with machine code `today_revision_conflict` and only `currentRevision`. The response does not contain the current Today document. + +The client must: + +1. keep the local draft unchanged; +2. tell the user another device changed Today; +3. require **Check workspace Today** again; +4. after the fresh read, let the user explicitly choose workspace state or explicitly replace it. + +Do not automatically retry a stale write with the newly exposed revision. Doing so would convert conflict detection into silent overwrite authority. + +## Dependency outage and retry + +Identity or planning dependency failures are represented as browser-safe unavailable states. The local draft remains the working copy. To retry safely: + +1. user rechecks workspace state; +2. if absent, a new explicit create can use `If-None-Match: *`; +3. if present, the newly observed `ETag` becomes the only valid update precondition; +4. a client must not reuse a revision learned only from a failed or malformed response. + +An exact idempotency-key retry is allowed to return the original response even after later revisions. Reusing the same key for a different request digest fails closed. + +## Database model + +Planning-service owns: + +- `planning.today_aggregates` +- `planning.today_idempotency_records` + +The aggregate primary key is `(workspace_id, local_date)`. Public aggregate and revision identities are opaque UUIDv4 values. Writes use fixed parameterized SQL and advisory-lock serialization so concurrent contenders cannot both commit from the same observed revision. + +## Verification + +Before merge or release, require exact-current-head evidence for: + +- planning domain/unit tests; +- real PostgreSQL restart, tenant-isolation, concurrent-update, replay, and conflicting-key tests; +- BFF authentication/credential-separation tests; +- browser explicit-migration and stale-conflict journeys; +- CI browser acceptance in Chromium; +- formatting, lint, typecheck, build, Compose validation; +- AppGuardrail, Semgrep, Security Scan, Commercial Readiness, CodeRabbit, and all actionable human/automated review findings. + +A predecessor-head pass does not transfer after any source or base change. + +## Diagnosis guide + +| Symptom | First boundary to inspect | Safe action | +| --- | --- | --- | +| Browser shows sign-in required | identity `/v1/session` | Verify session validity; do not add client-selected workspace IDs. | +| Browser shows workspace unavailable | BFF dependency call or bounded-response validation | Inspect exact upstream status/timeout without exposing payloads; retry only after a fresh user action. | +| `today_revision_conflict` | current aggregate revision | Preserve local state and perform a fresh explicit read. | +| `today_idempotency_conflict` | idempotency-key/request digest pair | Generate a new key for a genuinely new request; never coerce the stored record. | +| Repeated database serialization failure | planning PostgreSQL statement/lock boundary | Inspect transaction evidence and current revision; do not weaken optimistic concurrency. | +| Cross-workspace result | authenticated context / SQL tenant predicate | Treat as a security incident; fail closed and stop release. | +| Browser acceptance fails before app starts | Playwright/browser bootstrap | RCA runner/dependency failure separately from product behavior; do not mark browser journey passing. | + +## Rollback + +The feature has no destructive Today delete route. If application rollback is required after schema deployment, leave the new tables in place until a separately reviewed forward migration or data-retention decision exists; do not drop durable user state merely to match an older binary. Older binaries must not be granted direct access to the new tables. diff --git a/docs/research/2026-08-09-durable-today-sync-standards.md b/docs/research/2026-08-09-durable-today-sync-standards.md new file mode 100644 index 00000000..39df291e --- /dev/null +++ b/docs/research/2026-08-09-durable-today-sync-standards.md @@ -0,0 +1,73 @@ +# Durable Today synchronization standards + +**Date:** 2026-08-09 +**Status:** Implemented on active PR +**Tracking:** PR #127 +**Scope:** HTTP concurrency, local-to-durable migration, bounded errors, browser accessibility + +## Decision summary + +Durable Today synchronization treats one `(workspace_id, local_date)` aggregate as a versioned HTTP resource. Browser-local Today data remains local until the user explicitly checks or saves workspace state. Initial creation uses `If-None-Match: *`; updates require a strong opaque `ETag` returned by the server and an exact `If-Match`. Missing write preconditions fail before mutation. Stale writes fail without returning the current document, and the UI requires another explicit read before either copy can replace the other. + +This design separates four authorities: + +1. the browser may author a local draft but cannot select a workspace identity; +2. identity-session introspection determines workspace scope; +3. planning-service owns durable persistence, revision rotation, and idempotency replay; +4. the user explicitly decides when local or workspace state replaces the other. + +## Standards mapping + +### HTTP conditional requests + +RFC 9110 defines entity tags and conditional request fields. `If-Match` is specifically suitable for preventing lost updates on state-changing requests, while `If-None-Match: *` can prevent an unsafe request from overwriting an existing representation during create. LifeOS therefore rejects wildcard/weak/list update validators and exposes one strong opaque revision token for the complete Today aggregate. + +The implementation deliberately uses a complete-document `PUT` rather than merging an untrusted partial document. A successful mutation rotates the revision token. A client that has not observed the current revision cannot claim overwrite authority. + +### Required preconditions + +RFC 6585 defines HTTP 428 `Precondition Required` for servers that require requests to be conditional. LifeOS uses 428 when neither an explicit create precondition nor an exact revision update precondition is present. This prevents an accidental unconditional overwrite from becoming a valid state transition. + +### Problem details and disclosure minimization + +RFC 9457 standardizes problem-details responses and cautions against exposing implementation internals through API errors. Today synchronization therefore returns fixed browser-safe problem shapes. A stale-write response may expose only the current opaque revision token; it does not expose the workspace document, database errors, credentials, SQL, internal URLs, or stack traces. + +### Accessible explicit control + +WCAG 2.2 is the accessibility target for the web UI. Synchronization state is conveyed with text in an `aria-live` status region rather than color alone, and checking, saving, replacing, and using workspace state remain ordinary keyboard-operable buttons. Browser acceptance tests exercise the workflow at desktop and mobile Chromium profiles. + +## Product implications + +- No background upload occurs on page load or local edit. +- A local draft remains usable when identity or planning services are unavailable. +- Conflict handling preserves both sides until the user performs another explicit check and chooses a direction. +- Idempotency and optimistic concurrency are separate: an idempotency key identifies one request replay, while the revision token proves the client's observed resource version. +- `local_date` is a literal user-local calendar date. The server does not reinterpret it through a deployment timezone. +- Calendar, notification, habit, review, and AI side effects are outside the planning persistence transaction and must consume explicit APIs/events rather than direct table access. + +## Verification obligations + +The active implementation must retain tests for: + +- first creation with `If-None-Match: *`; +- strong exact `If-Match` update and revision rotation; +- missing/malformed precondition rejection; +- exact idempotent replay and conflicting key reuse; +- two-device stale-write conflict; +- workspace isolation and restart persistence against real PostgreSQL; +- explicit browser-local migration with zero network activity before user action; +- explicit recheck after a multi-device conflict; +- bounded and credential-free problem responses; +- keyboard/mobile/browser acceptance. + +## References + +All references below are final published RFCs or final W3C Recommendations; this evidence set contains no draft standard or preprint. + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9110 — **Final RFC** + +Nottingham, M., & Fielding, R. (2012). *Additional HTTP status codes* (RFC 6585). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6585 — **Final RFC** + +Nottingham, M., Wilde, E., & Dalal, S. (2023). *Problem details for HTTP APIs* (RFC 9457). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9457 — **Final RFC** + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ — **Final W3C Recommendation** diff --git a/docs/superpowers/plans/2026-08-04-data-rights-orchestration-slice.md b/docs/superpowers/plans/2026-08-04-data-rights-orchestration-slice.md index bc239bf5..0ccd7d9d 100644 --- a/docs/superpowers/plans/2026-08-04-data-rights-orchestration-slice.md +++ b/docs/superpowers/plans/2026-08-04-data-rights-orchestration-slice.md @@ -2,21 +2,32 @@ ## Goal -Add the first production-oriented application boundary for workspace data portability and erasure without allowing a request body to select another tenant or allowing one service to delete data before every participating bounded context is ready. +Add production-oriented application boundaries for workspace data portability and erasure without allowing a request body to select another tenant or allowing one service to delete data before every participating bounded context is ready. -## Scope +## Current implementation status +- Protected main now derives data-rights ownership from an authenticated session and preserves the actual authentication instant across session rotation; recent-auth enforcement no longer mistakes a rotated session for fresh authentication. - `apps/identity-service/src/data-rights.ts` defines the trusted workspace context, contributor contract, deterministic export manifest, fail-closed erasure preflight, idempotent execution context, and post-erasure verification receipt. - `apps/identity-service/src/data-rights.integration.test.ts` provides boundary evidence for deterministic exports, tenant isolation, secret-field rejection, no mutation during export, no partial deletion after a failed preflight, deterministic erasure order, bounded recovery evidence, and complete absence verification. +- **Implemented on active PR:** `data-rights-request-ledger.ts` and migration `0006_data_rights_request_ledger.sql` persist tenant-bound request identity, exact idempotency replay, request/receipt SHA-256 digests, lifecycle state, and one immutable terminal receipt without retaining a foreign-key dependency that would either erase the receipt or block source identity/workspace erasure. +- The active implementation includes a real PostgreSQL regression proving that a completed erasure receipt remains available after the source workspace and user rows are deleted. -This slice intentionally does not expose a public HTTP route or claim production deletion support. A later slice must derive the actor and workspace from an authenticated session, register concrete contributors for every data-owning service, persist request state, and provide user-visible status and download workflows. +This remains a partial product journey. The durable ledger is intentionally a bounded persistence primitive; it does not by itself claim complete public export/deletion UX, concrete participation by every data-owning service, encrypted export delivery, legal-hold policy, backup-expiry behavior, or operator-visible recovery. ## Trust boundaries -- `workspaceId` and `actorUserId` enter only through `DataRightsWorkspaceContext`, which is expected to be built by the authenticated gateway or session layer. Contributor payloads cannot override either value. +- `workspaceId` and `actorUserId` enter only through `DataRightsWorkspaceContext`, which is built from the authenticated session boundary rather than client-selected ownership fields. +- Sensitive data-rights operations require the configured recent-authentication window before orchestration begins. - Contributors are registered by application composition, have bounded unique names, and receive only the trusted context plus an idempotency key for destructive execution. -- Export values are normalized to inert JSON, sorted canonically, bounded by depth and byte limits, and rejected when they contain secret-shaped or prototype-pollution keys. +- Contributor export values are normalized to inert JSON, sorted canonically, bounded by depth and byte limits, and rejected when they contain secret-shaped or prototype-pollution keys. - The export digest covers the schema version, tenant, requesting actor, generation time, ordered contributor sections, and all normalized data. +- The durable request ledger stores only opaque UUIDv4 authority identifiers, request kind, SHA-256 digests, lifecycle timestamps, and status. It does not store exported personal content. + +## Durable request and receipt contract + +A data-rights request uses one workspace-scoped UUIDv4 idempotency key. The first accepted request persists its opaque request identity, trusted workspace/user identifiers, operation kind, request digest, and request time. An exact replay returns the original durable request. Reusing the same idempotency key with a different actor, operation, or request digest fails closed. Reusing a durable `request_id` for a different request is likewise mapped to the same stable credential-free domain conflict rather than exposing a raw PostgreSQL unique-violation error. + +Completion is one-way. A pending request may record one SHA-256 receipt digest and completion instant. Replaying that same receipt is safe; a different terminal digest cannot rewrite prior audit evidence. The ledger intentionally retains opaque workspace/user UUID references after the source rows are erased so completion evidence can survive the operation it proves. Retention duration and subsequent disposal of those audit references remain a separately governed privacy/operability decision. ## Erasure safety @@ -26,10 +37,11 @@ A dependency failure after destructive execution begins returns only the ordered ## Follow-up work -1. Add authenticated HTTP commands and status resources without accepting ownership fields in JSON. -2. Add a durable request ledger with immutable audit events, expiry, rate limits, and legal-hold decisions. -3. Register concrete identity, planning, habit, AI audit, calendar, review, notification, and integration contributors. -4. Stream encrypted exports to object storage with short-lived download authorization and explicit retention deletion. -5. Add cross-service recovery drills proving a failed execution can be safely replayed to completion. +1. Wire the authenticated data-rights application and orchestration flow to the durable ledger and expose bounded status resources without accepting ownership fields in JSON. +2. Register concrete identity, planning, habit, AI audit, calendar, review, notification, privacy, and integration contributors. +3. Add immutable operational/audit events, bounded retention/expiry, rate limits, and legal-hold decisions around the request lifecycle. +4. Stream encrypted exports to object storage with short-lived download authorization, explicit download audit, and retention deletion. +5. Define backup-expiry behavior so erased source data cannot be silently reintroduced through unsupported restores. +6. Add cross-service recovery drills proving a failed destructive execution can be safely replayed to completion. -Refs #21 and #58. +Refs #21, #55, and #58. diff --git a/docs/superpowers/plans/2026-08-09-durable-today-workspace-sync.md b/docs/superpowers/plans/2026-08-09-durable-today-workspace-sync.md new file mode 100644 index 00000000..0007fe42 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-durable-today-workspace-sync.md @@ -0,0 +1,49 @@ +# Durable Today workspace synchronization implementation plan + +**Date:** 2026-08-09 +**Issue:** #121 +**Branch:** `feat/durable-today-sync` + +## Goal + +Deliver a reviewable end-to-end slice in which a signed-in user can explicitly migrate browser-local Today state to planning-owned PostgreSQL storage, reopen it through the authenticated workspace boundary, and reconcile stale-device edits without silent overwrite. + +## Completed on the active branch + +- [x] Define `life-os.today.v1` aggregate and UUIDv4/revision/idempotency invariants. +- [x] Add RED/GREEN domain contracts for create, exact revision update, replay, conflicting key reuse, priority/schedule limits, completion evidence, and tenant isolation. +- [x] Add planning-owned PostgreSQL migration and repository. +- [x] Verify restart persistence, cross-workspace isolation, concurrent same-revision contenders, exact replay, and conflicting reuse against real PostgreSQL integration tests. +- [x] Expose bounded planning `GET`/`PUT` Today HTTP routes. +- [x] Require explicit create/update preconditions and return bounded stale-write conflicts. +- [x] Reuse the authenticated web BFF boundary so browser credentials never reach planning-service and client-supplied workspace IDs are ignored. +- [x] Add explicit browser check/save/load/reconcile states with no background upload. +- [x] Add desktop/mobile browser journey for first local-to-durable migration. +- [x] Add browser journey for stale-device conflict, recheck, and explicit newer-workspace selection. +- [x] Add CI browser-acceptance job and a workflow contract test requiring it. +- [x] Add scoped design, operations, and APA 7 standards documentation. +- [x] Reconcile capability evidence with the protected buyer-gap accounting semantics merged through #131; configured capability maturity no longer implies whole-product gap exhaustion. + +## Remaining before Ready + +- [ ] Obtain exact-current-head CI browser acceptance rather than predecessor-head or queued evidence. +- [ ] Obtain exact-current-head planning/web tests, PostgreSQL integration, build, Compose, AppGuardrail, Semgrep, Security Scan, Commercial Readiness, and CodeRabbit evidence. +- [ ] Resolve every actionable exact-head human/automated review finding. +- [ ] Reconcile root `ARCHITECTURE.md`, `AGENTS.md`, `CLAUDE.md`, `CHANGELOG.md`, and canonical product documentation after the active documentation-baseline writer on PR #126 is no longer moving; do not race that branch. +- [ ] Re-evaluate branch ancestry against the then-current protected `main`; refresh only when it has integration value and preserve all exact-head evidence semantics. +- [ ] Mark Ready only after implementation/documentation contracts are complete on one stable head. + +## Validation checklist + +The final head must prove: + +1. browser-local state causes zero workspace requests until explicit action; +2. first migration uses `If-None-Match: *` plus UUIDv4 idempotency key; +3. updates use the last explicitly observed strong `ETag` in `If-Match`; +4. stale writes preserve local state and require a fresh explicit read; +5. exact replay returns the original result and conflicting key reuse fails closed; +6. one concurrent same-revision writer wins in real PostgreSQL; +7. a different workspace cannot read the aggregate; +8. action content, cookies, credentials, SQL and internal errors do not escape public problems/artifacts; +9. the Playwright browser journey is a real CI gate, not an unexecuted repository fixture; +10. all required repository security/review gates apply to the unchanged final head. diff --git a/docs/superpowers/specs/2026-08-09-durable-today-workspace-sync-design.md b/docs/superpowers/specs/2026-08-09-durable-today-workspace-sync-design.md new file mode 100644 index 00000000..25d9a121 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-durable-today-workspace-sync-design.md @@ -0,0 +1,110 @@ +# Durable Today workspace synchronization design + +**Date:** 2026-08-09 +**Status:** Implemented on active PR #127 +**Issue:** #121 + +## Problem + +The Today interface originally persisted only in browser `localStorage`. That protects privacy and offline usability but does not satisfy a signed-in user who expects the same plan on another device. A naive background sync would create a worse failure mode: a stale tab or second device could silently overwrite newer state, and a browser-local plan could be uploaded without the user deliberately choosing to make it durable. + +## Decision + +Add one planning-owned, versioned durable Today aggregate keyed by authenticated workspace and user-local calendar date. Keep the browser-local draft as a distinct copy. Durable reads and writes happen only after explicit user actions. + +### Authority boundaries + +- Browser owns the local draft and chooses only the date/document it wants to inspect or save. +- Identity-service owns session validity and the workspace bound to the session. +- Web BFF derives the workspace and signs the trusted planning context. +- Planning-service owns aggregate validation, PostgreSQL state, idempotency, optimistic concurrency, and revision rotation. +- The user owns reconciliation direction after a conflict. + +The browser cannot provide a workspace identifier. Planning-service receives no browser cookie or bearer credential. + +## Aggregate contract + +`life-os.today.v1` contains: + +- local date; +- complete ordered action collection; +- opaque aggregate UUIDv4; +- opaque revision UUIDv4; +- bounded action UUIDv4/title/status/priority/schedule/completion fields. + +One `(workspace_id, local_date)` row is authoritative. The wire response carries the opaque revision both in the body and as a strong HTTP `ETag`. + +## Mutation contract + +### Create + +A first save requires `If-None-Match: *` plus a UUIDv4 `Idempotency-Key`. Creation fails if the aggregate already exists. + +### Update + +A later save requires exactly one strong quoted `If-Match` equal to the last explicitly observed revision. A successful update keeps the aggregate ID and rotates the revision token. + +### Conflict + +A stale update returns 409 `today_revision_conflict` with only `currentRevision`. The server does not send the newer document in the conflict response. The client keeps its local draft and requires a new explicit GET before a user can choose either copy. + +### Replay + +An exact idempotency-key/request-digest replay returns the original result, including its original revision and document, even if the aggregate advanced later. Reusing that key for a different digest is a hard conflict. + +## Persistence and concurrency + +Planning-service owns `planning.today_aggregates` and `planning.today_idempotency_records`. SQL is fixed and parameterized. One advisory-locked statement serializes the aggregate/idempotency decision, validates the current revision, performs create/update, records replay evidence, and returns one bounded outcome. + +Restart persistence, cross-workspace isolation, concurrent same-revision contenders, exact replay, and conflicting key reuse are verified against real PostgreSQL. + +## Browser migration flow + +1. Local Today loads from browser storage with no workspace request. +2. User selects **Check workspace Today**. +3. If missing, local state remains unchanged and the user may explicitly migrate it. +4. If found, local state remains unchanged and the user may explicitly load workspace state or replace it with local state. +5. A save conflict preserves local state and requires another explicit check. +6. Dependency failure preserves local state and does not infer overwrite authority from a failed response. + +No effect or mount callback automatically uploads local Today. + +## Error and privacy design + +- Bounded response bodies are read before JSON parsing. +- Accepted media types are restricted to JSON/problem JSON. +- Browser cookies are bounded and forwarded only to identity-service. +- Planning-service errors are mapped to stable problem shapes. +- Public stale-write evidence contains an opaque revision only, never action content. +- No delete endpoint is included in this slice. +- Calendar, reminders, habits, reviews, and AI proposals are outside the planning write transaction. + +## Accessibility + +Sync state is text, not color-only. Status changes use a polite live region. Every state transition is initiated through keyboard-operable native buttons. Browser acceptance runs the same journeys in desktop and mobile Chromium projects. + +## Alternatives considered + +### Automatic background sync + +Rejected. It violates the explicit migration/privacy contract and creates silent overwrite risk. + +### Last-write-wins timestamp + +Rejected. Clock ordering does not prove the writer observed the state it is replacing and hides concurrent edits. + +### Partial PATCH merge + +Deferred. Today scheduling/priorities have aggregate-level invariants; accepting partial merges would require a separately designed conflict model. + +### Browser-selected workspace + +Rejected. Tenant ownership must derive from authenticated server context. + +### Cross-service shared Today table + +Rejected. Planning owns its database; integrations must use APIs/events. + +## Acceptance + +The slice can become Ready only when the exact head has deterministic unit/integration coverage, real PostgreSQL concurrency/restart evidence, browser migration/conflict journeys, CI browser acceptance, security scans, CodeRabbit/review evidence, and required repository documentation. Canonical root documentation touched concurrently by PR #126 must be reconciled after that branch is integrated rather than raced. diff --git a/packages/commercial-readiness/src/workflow-contract.test.mjs b/packages/commercial-readiness/src/workflow-contract.test.mjs index 2a42eeb8..eafe12b5 100644 --- a/packages/commercial-readiness/src/workflow-contract.test.mjs +++ b/packages/commercial-readiness/src/workflow-contract.test.mjs @@ -12,6 +12,34 @@ async function repositoryFile(path) { return await readFile(resolve(repositoryRoot, path), 'utf8'); } +function yamlTopLevelBlock(source, key) { + const lines = source.split(/\r?\n/u); + const start = lines.findIndex((line) => line === `${key}:`); + assert.notEqual(start, -1, `missing top-level YAML key: ${key}`); + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + if (/^[A-Za-z0-9_.-]+:\s*(?:#.*)?$/u.test(lines[index] ?? '')) { + end = index; + break; + } + } + return lines.slice(start, end).join('\n'); +} + +function yamlJobBlock(source, jobName) { + const lines = source.split(/\r?\n/u); + const start = lines.findIndex((line) => line === ` ${jobName}:`); + assert.notEqual(start, -1, `missing workflow job: ${jobName}`); + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + if (/^ [A-Za-z0-9_.-]+:\s*(?:#.*)?$/u.test(lines[index] ?? '')) { + end = index; + break; + } + } + return lines.slice(start, end).join('\n'); +} + describe('commercial readiness workflow contract', () => { it('runs hourly at a non-round minute and keeps writes off pull requests', async () => { const workflow = await repositoryFile( @@ -51,6 +79,22 @@ describe('commercial readiness workflow contract', () => { } }); + it('binds browser acceptance commands to the pull-request CI job', async () => { + const workflow = await repositoryFile('.github/workflows/ci.yml'); + const triggerBlock = yamlTopLevelBlock(workflow, 'on'); + const browserJob = yamlJobBlock(workflow, 'browser-acceptance'); + + assert.match(triggerBlock, /^ pull_request:\s*$/mu); + assert.match( + browserJob, + /^\s+run:\s*pnpm --filter @life-os\/web exec playwright install --with-deps chromium\s*$/mu, + ); + assert.match( + browserJob, + /^\s+run:\s*pnpm --filter @life-os\/web test:e2e\s*$/mu, + ); + }); + it('requires all review and security gates before merge mode can execute', async () => { const policy = JSON.parse( await repositoryFile('product/commercial-readiness-policy.json'),