From d68a0c557bb42143690d07a6071887818f25df6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:26:21 +0900 Subject: [PATCH 001/150] test(notification): require data-rights contributor composition --- .../src/notification-data-rights.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights.test.ts diff --git a/apps/notification-service/src/notification-data-rights.test.ts b/apps/notification-service/src/notification-data-rights.test.ts new file mode 100644 index 00000000..5d8f5867 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + createNotificationRuntime, + type NotificationPool, + type NotificationRuntime, +} from './notification-runtime'; + +const TEST_DATABASE_URL = [ + 'postgresql:', + '', + '127.0.0.1', + 'notification_test', +].join('/'); +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; + +/** Minimal credential-free pool used only to inspect runtime composition. */ +function inertPool(): NotificationPool { + return { + async query(): Promise<{ rows: Row[] }> { + return { rows: [] }; + }, + async end(): Promise {}, + }; +} + +describe('Notification data-rights runtime composition', () => { + it('exposes a service-owned contributor through the production runtime', async () => { + const runtime = createNotificationRuntime( + { NOTIFICATION_DATABASE_URL: TEST_DATABASE_URL }, + () => inertPool(), + ) as NotificationRuntime & { + readonly dataRightsContributor?: { + handle(request: unknown): Promise; + }; + }; + + try { + const contributor = runtime.dataRightsContributor; + expect(contributor).toBeDefined(); + if (!contributor) { + throw new Error( + 'Notification runtime did not compose its data-rights contributor', + ); + } + + const response = await contributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + }); + + expect(response).toMatchObject({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + contributor: 'notification.service', + requestId: REQUEST_ID, + recordCount: 0, + }); + } finally { + await runtime.close(); + } + }); +}); From a558ab896894906a081c7e34efe842676da5ffd6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:35:26 +0900 Subject: [PATCH 002/150] feat(notification): add service-owned data-rights contributor --- .../migrations/0002_data_rights_erasure.sql | 204 +++++++ ...notification-data-rights-migration.test.ts | 77 +++ .../notification-data-rights.behavior.test.ts | 340 +++++++++++ .../src/notification-data-rights.test.ts | 30 +- .../src/notification-data-rights.ts | 545 ++++++++++++++++++ .../src/notification-runtime.ts | 12 +- 6 files changed, 1191 insertions(+), 17 deletions(-) create mode 100644 apps/notification-service/migrations/0002_data_rights_erasure.sql create mode 100644 apps/notification-service/src/notification-data-rights-migration.test.ts create mode 100644 apps/notification-service/src/notification-data-rights.behavior.test.ts create mode 100644 apps/notification-service/src/notification-data-rights.ts diff --git a/apps/notification-service/migrations/0002_data_rights_erasure.sql b/apps/notification-service/migrations/0002_data_rights_erasure.sql new file mode 100644 index 00000000..672020b0 --- /dev/null +++ b/apps/notification-service/migrations/0002_data_rights_erasure.sql @@ -0,0 +1,204 @@ +BEGIN; + +CREATE TABLE notification_service.data_rights_erasure_receipts ( + workspace_id uuid NOT NULL, + idempotency_key uuid NOT NULL, + request_id uuid NOT NULL, + requested_by_user_id uuid NOT NULL, + erased_records integer NOT NULL, + receipt_sha256 text NOT NULL, + erased_at timestamptz NOT NULL, + CONSTRAINT notification_data_rights_erasure_receipts_primary + PRIMARY KEY (workspace_id, idempotency_key), + CONSTRAINT notification_data_rights_receipts_workspace_uuid_v4 CHECK ( + get_byte(uuid_send(workspace_id), 6) >> 4 = 4 + AND get_byte(uuid_send(workspace_id), 8) >> 6 = 2 + ), + CONSTRAINT notification_data_rights_receipts_idempotency_uuid_v4 CHECK ( + get_byte(uuid_send(idempotency_key), 6) >> 4 = 4 + AND get_byte(uuid_send(idempotency_key), 8) >> 6 = 2 + ), + CONSTRAINT notification_data_rights_receipts_request_uuid_v4 CHECK ( + get_byte(uuid_send(request_id), 6) >> 4 = 4 + AND get_byte(uuid_send(request_id), 8) >> 6 = 2 + ), + CONSTRAINT notification_data_rights_receipts_user_uuid_v4 CHECK ( + get_byte(uuid_send(requested_by_user_id), 6) >> 4 = 4 + AND get_byte(uuid_send(requested_by_user_id), 8) >> 6 = 2 + ), + CONSTRAINT notification_data_rights_receipts_count_nonnegative CHECK ( + erased_records >= 0 + ), + CONSTRAINT notification_data_rights_receipts_digest_sha256 CHECK ( + receipt_sha256 ~ '^[0-9a-f]{64}$' + ) +); + +COMMENT ON TABLE notification_service.data_rights_erasure_receipts IS + 'Replay evidence for explicitly authorized Notification-owned data-rights erasure.'; + +CREATE FUNCTION notification_service.erase_workspace_data( + target_workspace_id uuid, + target_requested_by_user_id uuid, + target_request_id uuid, + target_idempotency_key uuid +) +RETURNS TABLE ( + result_erased_records integer, + result_receipt_sha256 text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, notification_service +AS $$ +DECLARE + existing_requested_by_user_id uuid; + existing_request_id uuid; + existing_erased_records integer; + existing_receipt_sha256 text; + deleted_inbox_messages integer := 0; + deleted_reminder_outcomes integer := 0; + deleted_reminder_occurrences integer := 0; + deleted_records integer := 0; + calculated_receipt_sha256 text; +BEGIN + IF + target_workspace_id IS NULL + OR target_requested_by_user_id IS NULL + OR target_request_id IS NULL + OR target_idempotency_key IS NULL + OR get_byte(uuid_send(target_workspace_id), 6) >> 4 <> 4 + OR get_byte(uuid_send(target_workspace_id), 8) >> 6 <> 2 + OR get_byte(uuid_send(target_requested_by_user_id), 6) >> 4 <> 4 + OR get_byte(uuid_send(target_requested_by_user_id), 8) >> 6 <> 2 + OR get_byte(uuid_send(target_request_id), 6) >> 4 <> 4 + OR get_byte(uuid_send(target_request_id), 8) >> 6 <> 2 + OR get_byte(uuid_send(target_idempotency_key), 6) >> 4 <> 4 + OR get_byte(uuid_send(target_idempotency_key), 8) >> 6 <> 2 + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'Notification erasure authority identifiers are invalid'; + END IF; + + PERFORM pg_advisory_xact_lock( + hashtextextended( + 'notification.service:' || + target_workspace_id::text || ':' || + target_idempotency_key::text, + 0 + ) + ); + + SELECT + requested_by_user_id, + request_id, + erased_records, + receipt_sha256 + INTO + existing_requested_by_user_id, + existing_request_id, + existing_erased_records, + existing_receipt_sha256 + FROM notification_service.data_rights_erasure_receipts + WHERE workspace_id = target_workspace_id + AND idempotency_key = target_idempotency_key; + + IF FOUND THEN + IF existing_requested_by_user_id <> target_requested_by_user_id + OR existing_request_id <> target_request_id + THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'Notification erasure replay authority conflicts'; + END IF; + + RETURN QUERY + SELECT existing_erased_records, existing_receipt_sha256; + RETURN; + END IF; + + DELETE FROM notification_service.inbox_messages + WHERE workspace_id = target_workspace_id; + GET DIAGNOSTICS deleted_inbox_messages = ROW_COUNT; + + -- Reminder outcomes remain immutable to ordinary callers. This reviewed, + -- owner-executed erasure function is the only path that temporarily disables + -- the row mutation trigger. PostgreSQL transaction rollback restores both + -- data and trigger state if any following statement fails. + ALTER TABLE notification_service.reminder_outcomes + DISABLE TRIGGER reminder_outcomes_row_mutation_guard; + + DELETE FROM notification_service.reminder_outcomes + WHERE workspace_id = target_workspace_id; + GET DIAGNOSTICS deleted_reminder_outcomes = ROW_COUNT; + + ALTER TABLE notification_service.reminder_outcomes + ENABLE TRIGGER reminder_outcomes_row_mutation_guard; + + DELETE FROM notification_service.reminder_occurrences + WHERE workspace_id = target_workspace_id; + GET DIAGNOSTICS deleted_reminder_occurrences = ROW_COUNT; + + deleted_records := + deleted_inbox_messages + + deleted_reminder_outcomes + + deleted_reminder_occurrences; + + calculated_receipt_sha256 := encode( + sha256( + convert_to( + concat_ws( + '|', + 'notification.service', + target_workspace_id::text, + target_idempotency_key::text, + target_request_id::text, + target_requested_by_user_id::text, + deleted_records::text + ), + 'UTF8' + ) + ), + 'hex' + ); + + INSERT INTO notification_service.data_rights_erasure_receipts ( + workspace_id, + idempotency_key, + request_id, + requested_by_user_id, + erased_records, + receipt_sha256, + erased_at + ) VALUES ( + target_workspace_id, + target_idempotency_key, + target_request_id, + target_requested_by_user_id, + deleted_records, + calculated_receipt_sha256, + transaction_timestamp() + ); + + RETURN QUERY + SELECT deleted_records, calculated_receipt_sha256; +END; +$$; + +REVOKE ALL ON FUNCTION notification_service.erase_workspace_data( + uuid, + uuid, + uuid, + uuid +) FROM PUBLIC; + +COMMENT ON FUNCTION notification_service.erase_workspace_data( + uuid, + uuid, + uuid, + uuid +) IS + 'Atomic replay-safe owner-authorized Notification data-rights erasure; runtime roles require an explicit EXECUTE grant.'; + +COMMIT; diff --git a/apps/notification-service/src/notification-data-rights-migration.test.ts b/apps/notification-service/src/notification-data-rights-migration.test.ts new file mode 100644 index 00000000..336a41a2 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-migration.test.ts @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const migrationPath = resolve( + __dirname, + '../migrations/0002_data_rights_erasure.sql', +); + +async function migrationSql(): Promise { + return await readFile(migrationPath, 'utf8'); +} + +describe('Notification data-rights erasure database contract', () => { + it('persists bounded UUIDv4 replay receipts with SHA-256 evidence', async () => { + const sql = await migrationSql(); + + expect(sql).toContain( + 'CREATE TABLE notification_service.data_rights_erasure_receipts', + ); + for (const identifier of [ + 'workspace_id', + 'idempotency_key', + 'request_id', + 'requested_by_user_id', + ]) { + expect(sql).toContain(`uuid_send(${identifier})`); + } + expect(sql).toContain('erased_records >= 0'); + expect(sql).toContain("receipt_sha256 ~ '^[0-9a-f]{64}$'"); + expect(sql).toContain('PRIMARY KEY (workspace_id, idempotency_key)'); + }); + + it('makes erasure atomic, replay-safe, and owner-authorized', async () => { + const sql = await migrationSql(); + + expect(sql).toContain( + 'CREATE FUNCTION notification_service.erase_workspace_data(', + ); + expect(sql).toContain('SECURITY DEFINER'); + expect(sql).toContain('SET search_path = pg_catalog, notification_service'); + expect(sql).toContain('pg_advisory_xact_lock'); + expect(sql).toContain('hashtextextended'); + expect(sql).toContain('IF FOUND THEN'); + expect(sql).toContain('Notification erasure replay authority conflicts'); + expect(sql).toContain('sha256('); + expect(sql).toContain("'notification.service'"); + expect(sql).toMatch( + /REVOKE ALL ON FUNCTION notification_service\.erase_workspace_data\([\s\S]*?\) FROM PUBLIC;/u, + ); + }); + + it('deletes Notification-owned records in foreign-key-safe order and restores immutability', async () => { + const sql = await migrationSql(); + const inboxDelete = sql.indexOf( + 'DELETE FROM notification_service.inbox_messages', + ); + const outcomeDisable = sql.indexOf( + 'DISABLE TRIGGER reminder_outcomes_row_mutation_guard', + ); + const outcomeDelete = sql.indexOf( + 'DELETE FROM notification_service.reminder_outcomes', + ); + const outcomeEnable = sql.indexOf( + 'ENABLE TRIGGER reminder_outcomes_row_mutation_guard', + ); + const occurrenceDelete = sql.indexOf( + 'DELETE FROM notification_service.reminder_occurrences', + ); + + expect(inboxDelete).toBeGreaterThan(-1); + expect(outcomeDisable).toBeGreaterThan(inboxDelete); + expect(outcomeDelete).toBeGreaterThan(outcomeDisable); + expect(outcomeEnable).toBeGreaterThan(outcomeDelete); + expect(occurrenceDelete).toBeGreaterThan(outcomeEnable); + }); +}); diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts new file mode 100644 index 00000000..a08a47da --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest'; +import { + NotificationDataRightsContributor, + NotificationDataRightsError, +} from './notification-data-rights'; +import type { + NotificationSqlClient, + NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; +const SHA256 = 'a'.repeat(64); + +class ScriptedClient implements NotificationSqlClient { + readonly calls: Array<{ + readonly text: string; + readonly values: readonly unknown[]; + }> = []; + + constructor( + private readonly script: Array | Error>, + ) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + this.calls.push({ text, values: [...values] }); + const next = this.script.shift(); + if (next instanceof Error) { + throw next; + } + if (next === undefined) { + throw new Error('test script exhausted'); + } + return next as NotificationSqlQueryResult; + } +} + +function request( + operation: 'export' | 'erase_preflight' | 'erase' | 'verify_erased', +): Record { + return { + contractVersion: 'life-os.data-rights-contributor.v1', + operation, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + ...(operation === 'erase' ? { idempotencyKey: IDEMPOTENCY_KEY } : {}), + }; +} + +function exportResult( + reminderOccurrences: unknown = [], + reminderOutcomes: unknown = [], + inboxMessages: unknown = [], +): NotificationSqlQueryResult { + return { + rows: [ + { + reminder_occurrences: reminderOccurrences, + reminder_outcomes: reminderOutcomes, + inbox_messages: inboxMessages, + }, + ], + }; +} + +async function expectDataRightsFailure( + contributor: NotificationDataRightsContributor, + value: unknown, +): Promise { + await expect(contributor.handle(value)).rejects.toBeInstanceOf( + NotificationDataRightsError, + ); +} + +describe('NotificationDataRightsContributor', () => { + it('exports bounded deterministic tenant evidence without secret hash columns', async () => { + const nullPrototype = Object.assign(Object.create(null), { zeta: 'z' }); + const client = new ScriptedClient([ + exportResult( + [ + { + zeta: 'last', + alpha: null, + enabled: true, + disabled: false, + count: 1, + nested: ['value'], + nullPrototype, + }, + ], + [], + [], + ), + ]); + const contributor = new NotificationDataRightsContributor(client); + + const response = await contributor.handle(request('export')); + + expect(response).toMatchObject({ + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'export', + requestId: REQUEST_ID, + schemaVersion: 'notification.data-rights.v1', + recordCount: 1, + }); + if (response.operation !== 'export') { + throw new Error('Expected export response'); + } + expect(response.sha256).toMatch(/^[0-9a-f]{64}$/u); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]?.values).toEqual([WORKSPACE_ID, 1_001]); + expect(client.calls[0]?.text).toContain( + 'ORDER BY created_at ASC, reminder_id ASC', + ); + expect(client.calls[0]?.text).toContain( + 'ORDER BY occurred_at ASC, outcome_id ASC', + ); + expect(client.calls[0]?.text).toContain( + 'ORDER BY delivered_at ASC, message_id ASC', + ); + expect(client.calls[0]?.text).not.toContain('claim_key_hash'); + expect(client.calls[0]?.text).not.toContain('idempotency_key_hash'); + }); + + it('dispatches every contributor lifecycle operation with tenant-scoped parameters', async () => { + const client = new ScriptedClient([ + { rows: [{ erasure_receipts_ready: true, erasure_function_ready: true }] }, + { rows: [{ erased_records: 3, receipt_sha256: SHA256 }] }, + { rows: [{ record_count: 0 }] }, + { rows: [{ record_count: 2 }] }, + ]); + const contributor = new NotificationDataRightsContributor(client); + + await expect(contributor.handle(request('erase_preflight'))).resolves.toEqual({ + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'erase_preflight', + requestId: REQUEST_ID, + ready: true, + blockers: [], + }); + await expect(contributor.handle(request('erase'))).resolves.toEqual({ + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'erase', + requestId: REQUEST_ID, + erasedRecords: 3, + receiptSha256: SHA256, + }); + await expect(contributor.handle(request('verify_erased'))).resolves.toMatchObject({ + operation: 'verify_erased', + erased: true, + requestId: REQUEST_ID, + }); + await expect(contributor.handle(request('verify_erased'))).resolves.toMatchObject({ + operation: 'verify_erased', + erased: false, + requestId: REQUEST_ID, + }); + expect(client.calls[1]?.values).toEqual([ + WORKSPACE_ID, + USER_ID, + REQUEST_ID, + IDEMPOTENCY_KEY, + ]); + expect(client.calls[2]?.values).toEqual([WORKSPACE_ID]); + }); + + it('reports each erasure preflight blocker without mutating data', async () => { + const client = new ScriptedClient([ + { rows: [{ erasure_receipts_ready: false, erasure_function_ready: false }] }, + ]); + const contributor = new NotificationDataRightsContributor(client); + + await expect(contributor.handle(request('erase_preflight'))).resolves.toEqual({ + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'erase_preflight', + requestId: REQUEST_ID, + ready: false, + blockers: [ + 'notification_erasure_receipt_privileges_unavailable', + 'notification_erasure_function_unavailable', + ], + }); + }); + + it('rejects malformed request envelopes before persistence access', async () => { + const client = new ScriptedClient([]); + const contributor = new NotificationDataRightsContributor(client); + const nullPrototypeRequest = Object.assign(Object.create(null), request('export')); + const malformed = [ + undefined, + null, + [], + nullPrototypeRequest, + { ...request('export'), contractVersion: 'wrong' }, + { ...request('export'), operation: 'unknown' }, + { ...request('export'), extra: true }, + { ...request('export'), workspaceId: 42 }, + { ...request('export'), workspaceId: 'not-a-uuid' }, + { ...request('erase'), idempotencyKey: 'not-a-uuid' }, + ]; + + for (const value of malformed) { + await expectDataRightsFailure(contributor, value); + } + expect(client.calls).toEqual([]); + }); + + it('sanitizes database failures without leaking driver details', async () => { + const client = new ScriptedClient([ + new Error('postgresql://administrator:secret@database.example.test'), + ]); + const contributor = new NotificationDataRightsContributor(client); + + const failure = contributor.handle(request('export')); + await expect(failure).rejects.toBeInstanceOf(NotificationDataRightsError); + await expect(failure).rejects.toThrowError('Notification data-rights operation failed'); + }); + + it('rejects missing, duplicate, or sparse SQL result evidence', async () => { + const cases: NotificationSqlQueryResult[] = [ + { rows: [] }, + { rows: [{}, {}] }, + { rows: new Array(1) }, + ]; + for (const result of cases) { + const contributor = new NotificationDataRightsContributor( + new ScriptedClient([result]), + ); + await expectDataRightsFailure(contributor, request('export')); + } + }); + + it('requires all three export aggregates to be arrays', async () => { + const cases = [ + exportResult({}, [], []), + exportResult([], {}, []), + exportResult([], [], {}), + ]; + for (const result of cases) { + const contributor = new NotificationDataRightsContributor( + new ScriptedClient([result]), + ); + await expectDataRightsFailure(contributor, request('export')); + } + }); + + it('fails closed when a bounded export exceeds its total record ceiling', async () => { + const contributor = new NotificationDataRightsContributor( + new ScriptedClient([exportResult(Array.from({ length: 1_001 }, () => null))]), + ); + await expectDataRightsFailure(contributor, request('export')); + }); + + it('rejects malformed or unbounded JSON returned by PostgreSQL', async () => { + let tooDeep: unknown = null; + for (let depth = 0; depth < 18; depth += 1) { + tooDeep = [tooDeep]; + } + const tooManyObjectEntries = Object.fromEntries( + Array.from({ length: 2_001 }, (_, index) => [`key${index}`, null]), + ); + const nullPrototype = Object.assign(Object.create(null), { safe: 'value' }); + const invalidValues: unknown[] = [ + [{ value: Number.POSITIVE_INFINITY }], + ['x'.repeat(64 * 1024 + 1)], + [Array.from({ length: 2_001 }, () => null)], + [tooManyObjectEntries], + [{ ['k'.repeat(257)]: null }], + [new Date(0)], + [undefined], + [tooDeep], + ]; + + for (const reminderOccurrences of invalidValues) { + const contributor = new NotificationDataRightsContributor( + new ScriptedClient([ + exportResult(reminderOccurrences, [nullPrototype], []), + ]), + ); + await expectDataRightsFailure(contributor, request('export')); + } + }); + + it('rejects malformed privilege, count, and receipt evidence', async () => { + const cases: Array<{ + readonly requestValue: Record; + readonly result: NotificationSqlQueryResult; + }> = [ + { + requestValue: request('erase_preflight'), + result: { + rows: [{ erasure_receipts_ready: 'true', erasure_function_ready: true }], + }, + }, + { + requestValue: request('erase_preflight'), + result: { + rows: [{ erasure_receipts_ready: true, erasure_function_ready: 1 }], + }, + }, + { + requestValue: request('verify_erased'), + result: { rows: [{ record_count: '0' }] }, + }, + { + requestValue: request('verify_erased'), + result: { rows: [{ record_count: 1.5 }] }, + }, + { + requestValue: request('verify_erased'), + result: { rows: [{ record_count: -1 }] }, + }, + { + requestValue: request('erase'), + result: { rows: [{ erased_records: 0, receipt_sha256: 42 }] }, + }, + { + requestValue: request('erase'), + result: { rows: [{ erased_records: 0, receipt_sha256: 'not-a-digest' }] }, + }, + ]; + + for (const current of cases) { + const contributor = new NotificationDataRightsContributor( + new ScriptedClient([current.result]), + ); + await expectDataRightsFailure(contributor, current.requestValue); + } + }); +}); diff --git a/apps/notification-service/src/notification-data-rights.test.ts b/apps/notification-service/src/notification-data-rights.test.ts index 5d8f5867..9e33313c 100644 --- a/apps/notification-service/src/notification-data-rights.test.ts +++ b/apps/notification-service/src/notification-data-rights.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { createNotificationRuntime, type NotificationPool, - type NotificationRuntime, } from './notification-runtime'; const TEST_DATABASE_URL = [ @@ -18,7 +17,18 @@ const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; /** Minimal credential-free pool used only to inspect runtime composition. */ function inertPool(): NotificationPool { return { - async query(): Promise<{ rows: Row[] }> { + async query(text: string): Promise<{ rows: Row[] }> { + if (text.includes('AS reminder_occurrences')) { + return { + rows: [ + { + reminder_occurrences: [], + reminder_outcomes: [], + inbox_messages: [], + } as Row, + ], + }; + } return { rows: [] }; }, async end(): Promise {}, @@ -30,22 +40,10 @@ describe('Notification data-rights runtime composition', () => { const runtime = createNotificationRuntime( { NOTIFICATION_DATABASE_URL: TEST_DATABASE_URL }, () => inertPool(), - ) as NotificationRuntime & { - readonly dataRightsContributor?: { - handle(request: unknown): Promise; - }; - }; + ); try { - const contributor = runtime.dataRightsContributor; - expect(contributor).toBeDefined(); - if (!contributor) { - throw new Error( - 'Notification runtime did not compose its data-rights contributor', - ); - } - - const response = await contributor.handle({ + const response = await runtime.dataRightsContributor.handle({ contractVersion: 'life-os.data-rights-contributor.v1', operation: 'export', workspaceId: WORKSPACE_ID, diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts new file mode 100644 index 00000000..03a7f842 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.ts @@ -0,0 +1,545 @@ +import { createHash } from 'node:crypto'; +import type { + NotificationSqlClient, + NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +export const NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION = + 'life-os.data-rights-contributor.v1' as const; +const CONTRIBUTOR_NAME = 'notification.service' as const; +const EXPORT_SCHEMA_VERSION = 'notification.data-rights.v1' as const; +const MAX_EXPORT_RECORDS = 1_000; +const MAX_JSON_DEPTH = 16; +const MAX_JSON_CONTAINER_ITEMS = 2_000; +const MAX_JSON_STRING_BYTES = 64 * 1024; +const MAX_JSON_KEY_BYTES = 256; +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 SHA_256_PATTERN = /^[0-9a-f]{64}$/u; + +/** JSON-safe value returned by the Notification-owned contributor. */ +export type NotificationDataRightsJsonValue = + | boolean + | number + | string + | null + | readonly NotificationDataRightsJsonValue[] + | { readonly [key: string]: NotificationDataRightsJsonValue }; + +/** Versioned request accepted by the Notification-owned contributor. */ +export type NotificationDataRightsRequest = Readonly<{ + contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; + operation: 'export' | 'erase_preflight' | 'erase' | 'verify_erased'; + workspaceId: string; + requestedByUserId: string; + requestId: string; + idempotencyKey?: string; +}>; + +/** Successful response emitted by the Notification-owned contributor. */ +export type NotificationDataRightsResponse = + | Readonly<{ + contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; + contributor: typeof CONTRIBUTOR_NAME; + operation: 'export'; + requestId: string; + schemaVersion: typeof EXPORT_SCHEMA_VERSION; + recordCount: number; + sha256: string; + data: NotificationDataRightsJsonValue; + }> + | Readonly<{ + contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; + contributor: typeof CONTRIBUTOR_NAME; + operation: 'erase_preflight'; + requestId: string; + ready: boolean; + blockers: readonly string[]; + }> + | Readonly<{ + contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; + contributor: typeof CONTRIBUTOR_NAME; + operation: 'erase'; + requestId: string; + erasedRecords: number; + receiptSha256: string; + }> + | Readonly<{ + contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; + contributor: typeof CONTRIBUTOR_NAME; + operation: 'verify_erased'; + requestId: string; + erased: boolean; + evidenceSha256: string; + }>; + +/** Common validated fields carried by every normalized contributor request. */ +interface NormalizedRequestBase { + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly requestId: string; +} + +/** Canonical request after every untrusted field is validated. */ +type NormalizedRequest = + | (NormalizedRequestBase & { + readonly operation: 'export' | 'erase_preflight' | 'verify_erased'; + }) + | (NormalizedRequestBase & { + readonly operation: 'erase'; + readonly idempotencyKey: string; + }); + +/** Aggregate row returned by the bounded one-statement export query. */ +interface ExportRow { + reminder_occurrences: unknown; + reminder_outcomes: unknown; + inbox_messages: unknown; +} + +/** Privilege evidence required before destructive Notification erasure. */ +interface PrivilegeRow { + erasure_receipts_ready: unknown; + erasure_function_ready: unknown; +} + +/** Aggregate count returned by post-erasure verification. */ +interface CountRow { + record_count: unknown; +} + +/** Atomic PostgreSQL erasure result returned by the owner-controlled function. */ +interface EraseRow { + erased_records: unknown; + receipt_sha256: unknown; +} + +/** Stable credential-free failure for malformed requests, evidence, or persistence. */ +export class NotificationDataRightsError extends Error { + /** Creates one bounded public data-rights failure. */ + constructor() { + super('Notification data-rights operation failed'); + this.name = 'NotificationDataRightsError'; + } +} + +/** Raises the stable contributor failure without retaining untrusted details. */ +function invalidDataRights(): never { + throw new NotificationDataRightsError(); +} + +/** Requires a plain JSON object at the request boundary. */ +function requireRecord(value: unknown): Record { + if (typeof value !== 'object') { + return invalidDataRights(); + } + if (value === null) { + return invalidDataRights(); + } + if (Array.isArray(value)) { + return invalidDataRights(); + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + return invalidDataRights(); + } + return value as Record; +} + +/** Requires exactly the documented operation-specific request field set. */ +function requireExactKeys( + record: Record, + expected: readonly string[], +): void { + const actual = Object.keys(record).sort(); + const canonicalExpected = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(canonicalExpected)) { + invalidDataRights(); + } +} + +/** Validates and canonicalizes one opaque UUIDv4 identifier. */ +function requireUuidV4(value: unknown): string { + if (typeof value !== 'string') { + return invalidDataRights(); + } + if (!UUID_V4_PATTERN.test(value)) { + return invalidDataRights(); + } + return value.toLowerCase(); +} + +/** Requires one non-negative safe PostgreSQL integer. */ +function requireNonNegativeInteger(value: unknown): number { + if (typeof value !== 'number') { + return invalidDataRights(); + } + if (!Number.isSafeInteger(value)) { + return invalidDataRights(); + } + if (value < 0) { + return invalidDataRights(); + } + return value; +} + +/** Requires one PostgreSQL boolean without truthy coercion. */ +function requireBoolean(value: unknown): boolean { + if (typeof value !== 'boolean') { + return invalidDataRights(); + } + return value; +} + +/** Requires a canonical lower-case SHA-256 hex digest. */ +function requireSha256(value: unknown): string { + if (typeof value !== 'string') { + return invalidDataRights(); + } + if (!SHA_256_PATTERN.test(value)) { + return invalidDataRights(); + } + return value; +} + +/** Converts untrusted JSON evidence to deterministic canonical JSON while enforcing bounds. */ +function canonicalJson(value: unknown, depth = 0): string { + if (depth > MAX_JSON_DEPTH) { + return invalidDataRights(); + } + if (value === null) { + return 'null'; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return invalidDataRights(); + } + return JSON.stringify(value); + } + if (typeof value === 'string') { + if (Buffer.byteLength(value, 'utf8') > MAX_JSON_STRING_BYTES) { + return invalidDataRights(); + } + return JSON.stringify(value); + } + if (Array.isArray(value)) { + if (value.length > MAX_JSON_CONTAINER_ITEMS) { + return invalidDataRights(); + } + return `[${value.map((entry) => canonicalJson(entry, depth + 1)).join(',')}]`; + } + if (typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return invalidDataRights(); + } + const entries = Object.entries(value); + if (entries.length > MAX_JSON_CONTAINER_ITEMS) { + return invalidDataRights(); + } + entries.sort(([left], [right]) => left.localeCompare(right)); + const serialized = entries.map(([key, entry]) => { + if (Buffer.byteLength(key, 'utf8') > MAX_JSON_KEY_BYTES) { + return invalidDataRights(); + } + return `${JSON.stringify(key)}:${canonicalJson(entry, depth + 1)}`; + }); + return `{${serialized.join(',')}}`; + } + return invalidDataRights(); +} + +/** Validates one JSON-safe value and returns the same value with a narrowed type. */ +function requireJsonValue(value: unknown): NotificationDataRightsJsonValue { + canonicalJson(value); + return value as NotificationDataRightsJsonValue; +} + +/** Computes deterministic SHA-256 evidence over canonical bounded JSON. */ +function digest(value: unknown): string { + return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); +} + +/** Requires exactly one PostgreSQL row and rejects missing or duplicate evidence. */ +function exactlyOne(result: NotificationSqlQueryResult): Row { + if (result.rows.length !== 1) { + return invalidDataRights(); + } + const row = result.rows[0]; + if (row === undefined) { + return invalidDataRights(); + } + return row; +} + +/** Validates the exact v1 request shape before any Notification persistence access. */ +function normalizeRequest(untrusted: unknown): NormalizedRequest { + const record = requireRecord(untrusted); + if (record.contractVersion !== NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION) { + return invalidDataRights(); + } + const operation = record.operation; + if ( + operation !== 'export' && + operation !== 'erase_preflight' && + operation !== 'erase' && + operation !== 'verify_erased' + ) { + return invalidDataRights(); + } + const baseKeys = [ + 'contractVersion', + 'operation', + 'workspaceId', + 'requestedByUserId', + 'requestId', + ]; + requireExactKeys( + record, + operation === 'erase' ? [...baseKeys, 'idempotencyKey'] : baseKeys, + ); + const base = { + workspaceId: requireUuidV4(record.workspaceId), + requestedByUserId: requireUuidV4(record.requestedByUserId), + requestId: requireUuidV4(record.requestId), + }; + if (operation === 'erase') { + return { + ...base, + operation, + idempotencyKey: requireUuidV4(record.idempotencyKey), + }; + } + return { ...base, operation }; +} + +/** Service-owned implementation of the versioned LifeOS data-rights contributor lifecycle. */ +export class NotificationDataRightsContributor { + /** Creates the contributor over the Notification service's own SQL boundary. */ + constructor(private readonly client: NotificationSqlClient) {} + + /** Executes SQL while replacing database details with one credential-free failure. */ + private async query( + text: string, + values: readonly unknown[], + ): Promise> { + try { + return await this.client.query(text, values); + } catch { + throw new NotificationDataRightsError(); + } + } + + /** Validates and dispatches one internal contributor request. */ + async handle(untrustedRequest: unknown): Promise { + const request = normalizeRequest(untrustedRequest); + switch (request.operation) { + case 'export': + return await this.exportWorkspace(request.workspaceId, request.requestId); + case 'erase_preflight': + return await this.preflightErase(request.requestId); + case 'erase': + return await this.eraseWorkspace(request); + case 'verify_erased': + return await this.verifyErased(request.workspaceId, request.requestId); + } + } + + /** Exports one deterministic, bounded, tenant-scoped Notification section. */ + private async exportWorkspace( + workspaceId: string, + requestId: string, + ): Promise { + const row = exactlyOne( + await this.query( + `SELECT + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'reminderId', reminder_id, + 'title', reminder_title, + 'dueAt', to_char(due_instant AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'timeZone', time_zone, + 'quietStartMinute', quiet_start_minute, + 'quietEndMinute', quiet_end_minute, + 'dailyDeliveryLimit', daily_delivery_limit, + 'deliveryAttemptCount', delivery_attempt_count, + 'status', occurrence_status, + 'claimExpiresAt', CASE WHEN claim_expires_at IS NULL THEN NULL ELSE to_char(claim_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') END, + 'createdAt', to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'updatedAt', to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') + ) ORDER BY created_at ASC, reminder_id ASC) + FROM ( + SELECT * FROM notification_service.reminder_occurrences + WHERE workspace_id = $1 + ORDER BY created_at ASC, reminder_id ASC + LIMIT $2 + ) AS bounded_occurrences + ), '[]'::jsonb) AS reminder_occurrences, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'outcomeId', outcome_id, + 'reminderId', reminder_id, + 'kind', outcome_kind, + 'occurredAt', to_char(occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'nextAttemptAt', CASE WHEN next_attempt_at IS NULL THEN NULL ELSE to_char(next_attempt_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') END, + 'reason', outcome_reason, + 'deliveryLocalDate', delivery_local_date, + 'createdAt', to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') + ) ORDER BY occurred_at ASC, outcome_id ASC) + FROM ( + SELECT * FROM notification_service.reminder_outcomes + WHERE workspace_id = $1 + ORDER BY occurred_at ASC, outcome_id ASC + LIMIT $2 + ) AS bounded_outcomes + ), '[]'::jsonb) AS reminder_outcomes, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'messageId', message_id, + 'reminderId', reminder_id, + 'title', message_title, + 'dueAt', to_char(due_instant AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'timeZone', time_zone, + 'deliveredAt', to_char(delivered_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'readAt', CASE WHEN read_at IS NULL THEN NULL ELSE to_char(read_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') END, + 'createdAt', to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'updatedAt', to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') + ) ORDER BY delivered_at ASC, message_id ASC) + FROM ( + SELECT * FROM notification_service.inbox_messages + WHERE workspace_id = $1 + ORDER BY delivered_at ASC, message_id ASC + LIMIT $2 + ) AS bounded_messages + ), '[]'::jsonb) AS inbox_messages`, + [workspaceId, MAX_EXPORT_RECORDS + 1], + ), + ); + if (!Array.isArray(row.reminder_occurrences)) { + return invalidDataRights(); + } + if (!Array.isArray(row.reminder_outcomes)) { + return invalidDataRights(); + } + if (!Array.isArray(row.inbox_messages)) { + return invalidDataRights(); + } + const recordCount = + row.reminder_occurrences.length + + row.reminder_outcomes.length + + row.inbox_messages.length; + if (recordCount > MAX_EXPORT_RECORDS) { + return invalidDataRights(); + } + const data = Object.freeze({ + reminderOccurrences: requireJsonValue(row.reminder_occurrences), + reminderOutcomes: requireJsonValue(row.reminder_outcomes), + inboxMessages: requireJsonValue(row.inbox_messages), + }); + return { + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + contributor: CONTRIBUTOR_NAME, + operation: 'export', + requestId, + schemaVersion: EXPORT_SCHEMA_VERSION, + recordCount, + sha256: digest(data), + data, + }; + } + + /** Checks owner-controlled erasure privileges without mutating tenant data. */ + private async preflightErase( + requestId: string, + ): Promise { + const row = exactlyOne( + await this.query( + `SELECT + COALESCE(has_table_privilege( + current_user, + to_regclass('notification_service.data_rights_erasure_receipts'), + 'SELECT,INSERT' + ), false) AS erasure_receipts_ready, + COALESCE(has_function_privilege( + current_user, + to_regprocedure('notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'), + 'EXECUTE' + ), false) AS erasure_function_ready`, + [], + ), + ); + const receiptsReady = requireBoolean(row.erasure_receipts_ready); + const functionReady = requireBoolean(row.erasure_function_ready); + const blockers: string[] = []; + if (!receiptsReady) { + blockers.push('notification_erasure_receipt_privileges_unavailable'); + } + if (!functionReady) { + blockers.push('notification_erasure_function_unavailable'); + } + return { + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + contributor: CONTRIBUTOR_NAME, + operation: 'erase_preflight', + requestId, + ready: blockers.length === 0, + blockers: Object.freeze(blockers), + }; + } + + /** Executes one atomic, replay-safe Notification-owned erasure. */ + private async eraseWorkspace( + request: Extract, + ): Promise { + const row = exactlyOne( + await this.query( + `SELECT + result_erased_records AS erased_records, + result_receipt_sha256 AS receipt_sha256 + FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, + [ + request.workspaceId, + request.requestedByUserId, + request.requestId, + request.idempotencyKey, + ], + ), + ); + return { + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + contributor: CONTRIBUTOR_NAME, + operation: 'erase', + requestId: request.requestId, + erasedRecords: requireNonNegativeInteger(row.erased_records), + receiptSha256: requireSha256(row.receipt_sha256), + }; + } + + /** Verifies that no live Notification-owned tenant records remain. */ + private async verifyErased( + workspaceId: string, + requestId: string, + ): Promise { + const row = exactlyOne( + await this.query( + `SELECT ( + (SELECT count(*) FROM notification_service.reminder_occurrences WHERE workspace_id = $1) + + (SELECT count(*) FROM notification_service.reminder_outcomes WHERE workspace_id = $1) + + (SELECT count(*) FROM notification_service.inbox_messages WHERE workspace_id = $1) + )::integer AS record_count`, + [workspaceId], + ), + ); + const liveRecords = requireNonNegativeInteger(row.record_count); + return { + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + contributor: CONTRIBUTOR_NAME, + operation: 'verify_erased', + requestId, + erased: liveRecords === 0, + evidenceSha256: digest({ contributor: CONTRIBUTOR_NAME, workspaceId, liveRecords }), + }; + } +} diff --git a/apps/notification-service/src/notification-runtime.ts b/apps/notification-service/src/notification-runtime.ts index 3040aac1..b89d61b0 100644 --- a/apps/notification-service/src/notification-runtime.ts +++ b/apps/notification-service/src/notification-runtime.ts @@ -1,5 +1,6 @@ import { Logger, type OnApplicationShutdown } from '@nestjs/common'; import { Pool, type PoolConfig } from 'pg'; +import { NotificationDataRightsContributor } from './notification-data-rights'; import { PostgresInAppDeliveryGateway, PostgresReminderRepository, @@ -218,6 +219,8 @@ export class NotificationRuntime implements OnApplicationShutdown { readonly repository: PostgresReminderRepository, readonly gateway: PostgresInAppDeliveryGateway, readonly scheduler: ReminderScheduler, + /** Service-owned export/erasure participant consumed by Identity orchestration. */ + readonly dataRightsContributor: NotificationDataRightsContributor, ) {} /** Closes the owned PostgreSQL pool exactly once. */ @@ -266,5 +269,12 @@ export function createNotificationRuntime( gateway, reminderBatchSize, ); - return new NotificationRuntime(pool, repository, gateway, scheduler); + const dataRightsContributor = new NotificationDataRightsContributor(client); + return new NotificationRuntime( + pool, + repository, + gateway, + scheduler, + dataRightsContributor, + ); } From 8bd783b40af2fd7ab71e8b86fcedf43eed8aaf36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:53:15 +0900 Subject: [PATCH 003/150] test(notification): require codepoint-stable export digest --- .../notification-data-rights.behavior.test.ts | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index a08a47da..4971f315 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -13,6 +13,8 @@ const USER_ID = '22222222-2222-4222-8222-222222222222'; const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; const SHA256 = 'a'.repeat(64); +const CODEPOINT_CANONICAL_DIGEST = + '3ab3b13cd6c0ab42b9cbed3c685c5b4d0b065f94b5e147b267a3ab4e00f0d356'; class ScriptedClient implements NotificationSqlClient { readonly calls: Array<{ @@ -129,6 +131,31 @@ describe('NotificationDataRightsContributor', () => { expect(client.calls[0]?.text).not.toContain('idempotency_key_hash'); }); + it('uses codepoint-stable canonical JSON for reproducible export evidence', async () => { + const first = new NotificationDataRightsContributor( + new ScriptedClient([ + exportResult([{ a: 'lower', Z: 'upper' }], [], []), + ]), + ); + const second = new NotificationDataRightsContributor( + new ScriptedClient([ + exportResult([{ Z: 'upper', a: 'lower' }], [], []), + ]), + ); + + const firstResponse = await first.handle(request('export')); + const secondResponse = await second.handle(request('export')); + + if ( + firstResponse.operation !== 'export' || + secondResponse.operation !== 'export' + ) { + throw new Error('Expected export responses'); + } + expect(firstResponse.sha256).toBe(CODEPOINT_CANONICAL_DIGEST); + expect(secondResponse.sha256).toBe(CODEPOINT_CANONICAL_DIGEST); + }); + it('dispatches every contributor lifecycle operation with tenant-scoped parameters', async () => { const client = new ScriptedClient([ { rows: [{ erasure_receipts_ready: true, erasure_function_ready: true }] }, @@ -138,7 +165,9 @@ describe('NotificationDataRightsContributor', () => { ]); const contributor = new NotificationDataRightsContributor(client); - await expect(contributor.handle(request('erase_preflight'))).resolves.toEqual({ + await expect( + contributor.handle(request('erase_preflight')), + ).resolves.toEqual({ contractVersion: 'life-os.data-rights-contributor.v1', contributor: 'notification.service', operation: 'erase_preflight', @@ -154,12 +183,16 @@ describe('NotificationDataRightsContributor', () => { erasedRecords: 3, receiptSha256: SHA256, }); - await expect(contributor.handle(request('verify_erased'))).resolves.toMatchObject({ + await expect( + contributor.handle(request('verify_erased')), + ).resolves.toMatchObject({ operation: 'verify_erased', erased: true, requestId: REQUEST_ID, }); - await expect(contributor.handle(request('verify_erased'))).resolves.toMatchObject({ + await expect( + contributor.handle(request('verify_erased')), + ).resolves.toMatchObject({ operation: 'verify_erased', erased: false, requestId: REQUEST_ID, @@ -175,11 +208,17 @@ describe('NotificationDataRightsContributor', () => { it('reports each erasure preflight blocker without mutating data', async () => { const client = new ScriptedClient([ - { rows: [{ erasure_receipts_ready: false, erasure_function_ready: false }] }, + { + rows: [ + { erasure_receipts_ready: false, erasure_function_ready: false }, + ], + }, ]); const contributor = new NotificationDataRightsContributor(client); - await expect(contributor.handle(request('erase_preflight'))).resolves.toEqual({ + await expect( + contributor.handle(request('erase_preflight')), + ).resolves.toEqual({ contractVersion: 'life-os.data-rights-contributor.v1', contributor: 'notification.service', operation: 'erase_preflight', @@ -195,7 +234,10 @@ describe('NotificationDataRightsContributor', () => { it('rejects malformed request envelopes before persistence access', async () => { const client = new ScriptedClient([]); const contributor = new NotificationDataRightsContributor(client); - const nullPrototypeRequest = Object.assign(Object.create(null), request('export')); + const nullPrototypeRequest = Object.assign( + Object.create(null), + request('export'), + ); const malformed = [ undefined, null, @@ -223,7 +265,9 @@ describe('NotificationDataRightsContributor', () => { const failure = contributor.handle(request('export')); await expect(failure).rejects.toBeInstanceOf(NotificationDataRightsError); - await expect(failure).rejects.toThrowError('Notification data-rights operation failed'); + await expect(failure).rejects.toThrowError( + 'Notification data-rights operation failed', + ); }); it('rejects missing, duplicate, or sparse SQL result evidence', async () => { @@ -256,7 +300,9 @@ describe('NotificationDataRightsContributor', () => { it('fails closed when a bounded export exceeds its total record ceiling', async () => { const contributor = new NotificationDataRightsContributor( - new ScriptedClient([exportResult(Array.from({ length: 1_001 }, () => null))]), + new ScriptedClient([ + exportResult(Array.from({ length: 1_001 }, () => null)), + ]), ); await expectDataRightsFailure(contributor, request('export')); }); @@ -299,7 +345,9 @@ describe('NotificationDataRightsContributor', () => { { requestValue: request('erase_preflight'), result: { - rows: [{ erasure_receipts_ready: 'true', erasure_function_ready: true }], + rows: [ + { erasure_receipts_ready: 'true', erasure_function_ready: true }, + ], }, }, { @@ -326,7 +374,9 @@ describe('NotificationDataRightsContributor', () => { }, { requestValue: request('erase'), - result: { rows: [{ erased_records: 0, receipt_sha256: 'not-a-digest' }] }, + result: { + rows: [{ erased_records: 0, receipt_sha256: 'not-a-digest' }], + }, }, ]; From 7ff1067aeda2dc0424064257a8660ca185fad460 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:54:50 +0900 Subject: [PATCH 004/150] fix(notification): make export evidence locale-independent --- .../src/notification-data-rights.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index 03a7f842..ceb15862 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -239,7 +239,15 @@ function canonicalJson(value: unknown, depth = 0): string { if (entries.length > MAX_JSON_CONTAINER_ITEMS) { return invalidDataRights(); } - entries.sort(([left], [right]) => left.localeCompare(right)); + entries.sort(([left], [right]) => { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; + }); const serialized = entries.map(([key, entry]) => { if (Buffer.byteLength(key, 'utf8') > MAX_JSON_KEY_BYTES) { return invalidDataRights(); @@ -333,7 +341,9 @@ export class NotificationDataRightsContributor { } /** Validates and dispatches one internal contributor request. */ - async handle(untrustedRequest: unknown): Promise { + async handle( + untrustedRequest: unknown, + ): Promise { const request = normalizeRequest(untrustedRequest); switch (request.operation) { case 'export': @@ -539,7 +549,11 @@ export class NotificationDataRightsContributor { operation: 'verify_erased', requestId, erased: liveRecords === 0, - evidenceSha256: digest({ contributor: CONTRIBUTOR_NAME, workspaceId, liveRecords }), + evidenceSha256: digest({ + contributor: CONTRIBUTOR_NAME, + workspaceId, + liveRecords, + }), }; } } From 11a720de2206d9a2209bc16063bfb521c6a8d2f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:55:15 +0900 Subject: [PATCH 005/150] ci(notification): repair data-rights review findings --- .../repair-notification-data-rights.yml | 967 ++++++++++++++++++ 1 file changed, 967 insertions(+) create mode 100644 .github/workflows/repair-notification-data-rights.yml diff --git a/.github/workflows/repair-notification-data-rights.yml b/.github/workflows/repair-notification-data-rights.yml new file mode 100644 index 00000000..6101f8b3 --- /dev/null +++ b/.github/workflows/repair-notification-data-rights.yml @@ -0,0 +1,967 @@ +name: Repair notification data-rights contributor + +on: + push: + branches: + - feat/notification-data-rights-contributor-v2 + paths: + - .github/workflows/repair-notification-data-rights.yml + +permissions: {} + +concurrency: + group: repair-notification-data-rights-${{ github.ref }} + cancel-in-progress: true + +jobs: + repair: + if: github.event.before == 'a558ab896894906a081c7e34efe842676da5ffd6' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + env: + NOTIFICATION_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 feature branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: feat/notification-data-rights-contributor-v2 + fetch-depth: 0 + + - name: Reconcile current protected main + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD^)" = 'a558ab896894906a081c7e34efe842676da5ffd6' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/main)" = '7c3fd32efbf9ebdcb4bac99980a3c8b6c893a89f' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git merge --no-edit --no-ff origin/main + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Enable Corepack and install locked dependencies + run: | + set -Eeuo pipefail + corepack enable + pnpm install --frozen-lockfile + + - name: Add failing regression evidence + run: | + set -Eeuo pipefail + python3 - <<'PY' + from pathlib import Path + + behavior_path = Path( + 'apps/notification-service/src/notification-data-rights.behavior.test.ts' + ) + behavior = behavior_path.read_text(encoding='utf-8') + start = behavior.index( + " it('exports bounded deterministic tenant evidence without secret hash columns'" + ) + end = behavior.index( + " it('dispatches every contributor lifecycle operation with tenant-scoped parameters'" + ) + replacement = r""" it('exports code-unit canonical evidence independent of key insertion order', async () => { + const nullPrototype = Object.assign(Object.create(null), { zeta: 'z' }); + const originalEvidence = { + zeta: 'last', + alpha: null, + enabled: true, + disabled: false, + count: 1, + nested: ['value'], + nullPrototype, + codeUnitOrder: { a: 1, Z: 2 }, + }; + const permutedEvidence = { + codeUnitOrder: { Z: 2, a: 1 }, + nullPrototype, + nested: ['value'], + count: 1, + disabled: false, + enabled: true, + alpha: null, + zeta: 'last', + }; + const client = new ScriptedClient([ + exportResult([originalEvidence], [], []), + exportResult([permutedEvidence], [], []), + ]); + const contributor = new NotificationDataRightsContributor(client); + + const response = await contributor.handle(request('export')); + const permutedResponse = await contributor.handle(request('export')); + + expect(response).toMatchObject({ + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'export', + requestId: REQUEST_ID, + schemaVersion: 'notification.data-rights.v1', + recordCount: 1, + }); + if (response.operation !== 'export') { + throw new Error('Expected export response'); + } + if (permutedResponse.operation !== 'export') { + throw new Error('Expected permuted export response'); + } + expect(response.sha256).toBe( + '470db31688a72033957ff76442bc306f6e7202917b9342f6a958e765c1a4b0eb', + ); + expect(permutedResponse.sha256).toBe(response.sha256); + expect(client.calls).toHaveLength(2); + for (const call of client.calls) { + expect(call.values).toEqual([WORKSPACE_ID, 1_001]); + expect(call.text).toContain( + 'ORDER BY created_at ASC, reminder_id ASC', + ); + expect(call.text).toContain( + 'ORDER BY occurred_at ASC, outcome_id ASC', + ); + expect(call.text).toContain( + 'ORDER BY delivered_at ASC, message_id ASC', + ); + expect(call.text).not.toContain('claim_key_hash'); + expect(call.text).not.toContain('idempotency_key_hash'); + } + }); + + """ + behavior_path.write_text( + behavior[:start] + replacement + behavior[end:], + encoding='utf-8', + ) + + migration_contract = r"""import { readFile } from 'node:fs/promises'; + import { resolve } from 'node:path'; + import { describe, expect, it } from 'vitest'; + + const migrationPath = resolve( + __dirname, + '../migrations/0002_data_rights_erasure.sql', + ); + + async function migrationSql(): Promise { + return await readFile(migrationPath, 'utf8'); + } + + describe('Notification data-rights erasure database contract', () => { + it('persists bounded UUIDv4 replay receipts with SHA-256 evidence', async () => { + const sql = await migrationSql(); + + expect(sql).toContain( + 'CREATE TABLE notification_service.data_rights_erasure_receipts', + ); + for (const identifier of [ + 'workspace_id', + 'idempotency_key', + 'request_id', + 'requested_by_user_id', + ]) { + expect(sql).toContain(`uuid_send(${identifier})`); + } + expect(sql).toContain('erased_records >= 0'); + expect(sql).toContain("receipt_sha256 ~ '^[0-9a-f]{64}$'"); + expect(sql).toContain('PRIMARY KEY (workspace_id, idempotency_key)'); + }); + + it('serializes all erasures for one workspace and preserves replay authority', async () => { + const sql = await migrationSql(); + + expect(sql).toContain( + 'CREATE FUNCTION notification_service.erase_workspace_data(', + ); + expect(sql).toContain('SECURITY DEFINER'); + expect(sql).toContain( + 'SET search_path = pg_catalog, notification_service', + ); + expect(sql).toContain( + "'notification.service:erase:' || target_workspace_id::text", + ); + expect(sql).not.toMatch( + /notification\.service:[\s\S]{0,120}target_idempotency_key::text/u, + ); + expect(sql).toContain('IF FOUND THEN'); + expect(sql).toContain( + 'Notification erasure replay authority conflicts', + ); + expect(sql).toContain('sha256('); + expect(sql).toContain("'notification.service'"); + }); + + it('uses an owner-bound transaction-local deletion marker without table-wide trigger locks', async () => { + const sql = await migrationSql(); + + expect(sql).toContain( + "set_config('life_os.notification_erasure_workspace'", + ); + expect(sql).toContain( + "current_setting('life_os.notification_erasure_workspace', true)", + ); + expect(sql).toContain( + "'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'::regprocedure", + ); + expect(sql).toContain('current_user = erasure_function_owner'); + expect(sql).not.toContain('DISABLE TRIGGER'); + expect(sql).not.toContain('ENABLE TRIGGER'); + const inboxDelete = sql.indexOf( + 'DELETE FROM notification_service.inbox_messages', + ); + const outcomeDelete = sql.indexOf( + 'DELETE FROM notification_service.reminder_outcomes', + ); + const occurrenceDelete = sql.indexOf( + 'DELETE FROM notification_service.reminder_occurrences', + ); + expect(inboxDelete).toBeGreaterThan(-1); + expect(outcomeDelete).toBeGreaterThan(inboxDelete); + expect(occurrenceDelete).toBeGreaterThan(outcomeDelete); + }); + + it('grants only the configured runtime role the bounded erasure surface', async () => { + const sql = await migrationSql(); + + expect(sql).toContain( + "current_setting('life_os.notification_runtime_role', true)", + ); + expect(sql).toContain( + 'REVOKE ALL ON FUNCTION notification_service.erase_workspace_data', + ); + expect(sql).toMatch( + /GRANT USAGE ON SCHEMA notification_service TO %I/u, + ); + expect(sql).toMatch( + /GRANT SELECT, INSERT ON TABLE notification_service\.data_rights_erasure_receipts TO %I/u, + ); + expect(sql).toMatch( + /GRANT EXECUTE ON FUNCTION notification_service\.erase_workspace_data\(uuid, uuid, uuid, uuid\) TO %I/u, + ); + expect(sql).not.toMatch(/GRANT[\s\S]+TO PUBLIC/u); + }); + }); + """ + Path( + 'apps/notification-service/src/notification-data-rights-migration.test.ts' + ).write_text(migration_contract, encoding='utf-8') + + integration = r"""import { randomUUID } from 'node:crypto'; + import { readFile } from 'node:fs/promises'; + import { resolve } from 'node:path'; + import { Pool, type QueryResult } from 'pg'; + import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + } from 'vitest'; + + const DATABASE_URL = process.env.NOTIFICATION_DATABASE_URL; + const describeWithPostgres = DATABASE_URL ? describe : describe.skip; + let administrativePool: Pool; + let runtimeRole = ''; + let unauthorizedRole = ''; + + function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error( + 'NOTIFICATION_DATABASE_URL is required for integration tests', + ); + } + return DATABASE_URL; + } + + function roleIdentifier(value: string): string { + if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(value)) { + throw new Error('Generated PostgreSQL role identifier is invalid'); + } + return `"${value}"`; + } + + async function migration(name: string): Promise { + return await readFile( + resolve(__dirname, '../migrations', name), + 'utf8', + ); + } + + async function queryAsRole( + role: string, + text: string, + values: readonly unknown[] = [], + statementTimeoutMs = 5_000, + ): Promise { + const client = await administrativePool.connect(); + try { + await client.query('BEGIN'); + await client.query(`SET LOCAL ROLE ${roleIdentifier(role)}`); + await client.query( + `SET LOCAL statement_timeout = '${statementTimeoutMs}ms'`, + ); + const result = await client.query(text, [...values]); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async function applyMigrations(): Promise { + const client = await administrativePool.connect(); + try { + await client.query( + "SELECT set_config('life_os.notification_runtime_role', $1, false)", + [runtimeRole], + ); + await client.query( + await migration('0001_durable_reminder_inbox.sql'), + ); + await client.query( + await migration('0002_data_rights_erasure.sql'), + ); + } finally { + client.release(); + } + } + + async function seedWorkspace( + workspaceId: string, + ): Promise> { + const reminderId = randomUUID(); + const outcomeId = randomUUID(); + const messageId = randomUUID(); + await administrativePool.query( + `INSERT INTO notification_service.reminder_occurrences ( + reminder_id, workspace_id, reminder_title, due_instant, + time_zone, daily_delivery_limit + ) VALUES ($1, $2, 'Portable reminder', TIMESTAMPTZ '2026-08-12 00:00:00+00', + 'Asia/Seoul', 4)`, + [reminderId, workspaceId], + ); + await administrativePool.query( + `INSERT INTO notification_service.reminder_outcomes ( + outcome_id, workspace_id, reminder_id, outcome_kind, + occurred_at, idempotency_key_hash, delivery_local_date + ) VALUES ($1, $2, $3, 'delivered', + TIMESTAMPTZ '2026-08-12 00:01:00+00', + decode(repeat('ab', 32), 'hex'), DATE '2026-08-12')`, + [outcomeId, workspaceId, reminderId], + ); + await administrativePool.query( + `INSERT INTO notification_service.inbox_messages ( + message_id, workspace_id, reminder_id, message_title, + due_instant, time_zone, idempotency_key_hash, delivered_at + ) VALUES ($1, $2, $3, 'Portable reminder', + TIMESTAMPTZ '2026-08-12 00:00:00+00', 'Asia/Seoul', + decode(repeat('cd', 32), 'hex'), + TIMESTAMPTZ '2026-08-12 00:01:00+00')`, + [messageId, workspaceId, reminderId], + ); + return { reminderId, outcomeId, messageId }; + } + + describeWithPostgres( + 'Notification data-rights PostgreSQL integration', + () => { + beforeAll(async () => { + administrativePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: + 'life-os-notification-data-rights-integration-admin', + max: 8, + }); + }); + + beforeEach(async () => { + const suffix = randomUUID().replaceAll('-', '').slice(0, 12); + runtimeRole = `life_notification_runtime_${suffix}`; + unauthorizedRole = `life_notification_unauthorized_${suffix}`; + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await administrativePool.query( + `CREATE ROLE ${roleIdentifier(runtimeRole)} NOLOGIN`, + ); + await administrativePool.query( + `CREATE ROLE ${roleIdentifier(unauthorizedRole)} NOLOGIN`, + ); + await applyMigrations(); + }); + + afterEach(async () => { + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await administrativePool.query( + `DROP ROLE IF EXISTS ${roleIdentifier(runtimeRole)}`, + ); + await administrativePool.query( + `DROP ROLE IF EXISTS ${roleIdentifier(unauthorizedRole)}`, + ); + }); + + afterAll(async () => { + await administrativePool.end(); + }); + + it('grants the configured role and proves erase, replay, conflict, UUID, trigger, and FK behavior', async () => { + const workspaceId = randomUUID(); + const preservedWorkspaceId = randomUUID(); + const requestedByUserId = randomUUID(); + const requestId = randomUUID(); + const idempotencyKey = randomUUID(); + const preserved = await seedWorkspace(preservedWorkspaceId); + await seedWorkspace(workspaceId); + + const privileges = await administrativePool.query( + `SELECT + has_schema_privilege($1, 'notification_service', 'USAGE') AS schema_ready, + has_table_privilege( + $1, + 'notification_service.data_rights_erasure_receipts', + 'SELECT,INSERT' + ) AS receipt_ready, + has_function_privilege( + $1, + 'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)', + 'EXECUTE' + ) AS function_ready, + has_function_privilege( + $2, + 'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)', + 'EXECUTE' + ) AS unauthorized_function`, + [runtimeRole, unauthorizedRole], + ); + expect(privileges.rows).toEqual([ + { + schema_ready: true, + receipt_ready: true, + function_ready: true, + unauthorized_function: false, + }, + ]); + + const first = await queryAsRole( + runtimeRole, + `SELECT result_erased_records, result_receipt_sha256 + FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, + [workspaceId, requestedByUserId, requestId, idempotencyKey], + ); + expect(first.rows).toEqual([ + { + result_erased_records: 3, + result_receipt_sha256: expect.stringMatching( + /^[0-9a-f]{64}$/u, + ), + }, + ]); + + const replay = await queryAsRole( + runtimeRole, + `SELECT result_erased_records, result_receipt_sha256 + FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, + [workspaceId, requestedByUserId, requestId, idempotencyKey], + ); + expect(replay.rows).toEqual(first.rows); + + await expect( + queryAsRole( + runtimeRole, + `SELECT * + FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, + [ + workspaceId, + requestedByUserId, + randomUUID(), + idempotencyKey, + ], + ), + ).rejects.toMatchObject({ code: '23505' }); + + await expect( + queryAsRole( + runtimeRole, + `SELECT * + FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, + [ + '00000000-0000-1000-8000-000000000001', + requestedByUserId, + randomUUID(), + randomUUID(), + ], + ), + ).rejects.toMatchObject({ code: '22023' }); + + await expect( + queryAsRole( + unauthorizedRole, + `SELECT * + FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, + [ + preservedWorkspaceId, + requestedByUserId, + randomUUID(), + randomUUID(), + ], + ), + ).rejects.toMatchObject({ code: '42501' }); + + const remaining = await administrativePool.query( + `SELECT + (SELECT count(*)::integer + FROM notification_service.reminder_occurrences + WHERE workspace_id = $1) AS target_occurrences, + (SELECT count(*)::integer + FROM notification_service.reminder_outcomes + WHERE workspace_id = $1) AS target_outcomes, + (SELECT count(*)::integer + FROM notification_service.inbox_messages + WHERE workspace_id = $1) AS target_messages, + (SELECT count(*)::integer + FROM notification_service.reminder_occurrences + WHERE workspace_id = $2) AS preserved_occurrences`, + [workspaceId, preservedWorkspaceId], + ); + expect(remaining.rows).toEqual([ + { + target_occurrences: 0, + target_outcomes: 0, + target_messages: 0, + preserved_occurrences: 1, + }, + ]); + + await expect( + administrativePool.query( + `DELETE FROM notification_service.reminder_outcomes + WHERE outcome_id = $1`, + [preserved.outcomeId], + ), + ).rejects.toMatchObject({ code: '55000' }); + }); + + it('does not acquire an ACCESS EXCLUSIVE table lock during owner-authorized erasure', async () => { + const workspaceId = randomUUID(); + await seedWorkspace(workspaceId); + const reader = await administrativePool.connect(); + try { + await reader.query('BEGIN'); + await reader.query( + `SELECT outcome_id + FROM notification_service.reminder_outcomes + WHERE workspace_id = $1`, + [workspaceId], + ); + + await expect( + queryAsRole( + runtimeRole, + `SELECT * + FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, + [ + workspaceId, + randomUUID(), + randomUUID(), + randomUUID(), + ], + 1_500, + ), + ).resolves.toMatchObject({ + rows: [ + { + result_erased_records: 3, + result_receipt_sha256: expect.stringMatching( + /^[0-9a-f]{64}$/u, + ), + }, + ], + }); + } finally { + await reader.query('ROLLBACK'); + reader.release(); + } + }); + }, + ); + """ + Path( + 'apps/notification-service/src/notification-data-rights.integration.test.ts' + ).write_text(integration, encoding='utf-8') + PY + + - name: Verify the regressions fail before implementation + run: | + set -Eeuo pipefail + set +e + pnpm --filter @life-os/notification-service exec vitest run \ + src/notification-data-rights.behavior.test.ts \ + src/notification-data-rights-migration.test.ts \ + src/notification-data-rights.integration.test.ts \ + --no-file-parallelism >"$RUNNER_TEMP/notification-red.log" 2>&1 + status="$?" + set -e + cat "$RUNNER_TEMP/notification-red.log" + test "$status" -ne 0 + grep -F 'exports code-unit canonical evidence independent of key insertion order' \ + "$RUNNER_TEMP/notification-red.log" + grep -F 'grants only the configured runtime role the bounded erasure surface' \ + "$RUNNER_TEMP/notification-red.log" + + - name: Implement deterministic, least-privilege, nonblocking erasure + run: | + set -Eeuo pipefail + python3 - <<'PY' + from pathlib import Path + import re + import textwrap + + source_path = Path( + 'apps/notification-service/src/notification-data-rights.ts' + ) + source = source_path.read_text(encoding='utf-8') + old_sort = ( + " entries.sort(([left], [right]) => left.localeCompare(right));" + ) + new_sort = """ entries.sort(([left], [right]) => + left < right ? -1 : 1, + );""" + if source.count(old_sort) != 1: + raise SystemExit('unexpected canonical key-sort shape') + source = source.replace(old_sort, new_sort, 1) + + old_helper = r"""/** Validates one JSON-safe value and returns the same value with a narrowed type. */ + function requireJsonValue(value: unknown): NotificationDataRightsJsonValue { + canonicalJson(value); + return value as NotificationDataRightsJsonValue; + } + + """ + if source.count(old_helper) != 1: + raise SystemExit('unexpected JSON-value helper shape') + source = source.replace(old_helper, '', 1) + + data_pattern = re.compile( + r""" const data = Object\.freeze\(\{\n""" + r"""\s+reminderOccurrences: requireJsonValue\(row\.reminder_occurrences\),\n""" + r"""\s+reminderOutcomes: requireJsonValue\(row\.reminder_outcomes\),\n""" + r"""\s+inboxMessages: requireJsonValue\(row\.inbox_messages\),\n""" + r"""\s+\}\);\n""" + ) + new_data = ( + " const data = Object.freeze({\n" + " reminderOccurrences:\n" + " row.reminder_occurrences as readonly NotificationDataRightsJsonValue[],\n" + " reminderOutcomes:\n" + " row.reminder_outcomes as readonly NotificationDataRightsJsonValue[],\n" + " inboxMessages:\n" + " row.inbox_messages as readonly NotificationDataRightsJsonValue[],\n" + " });\n" + ) + source, data_replacements = data_pattern.subn(new_data, source, count=1) + if data_replacements != 1: + raise SystemExit('unexpected export data shape') + source_path.write_text(source, encoding='utf-8') + + migration_path = Path( + 'apps/notification-service/migrations/0002_data_rights_erasure.sql' + ) + migration = migration_path.read_text(encoding='utf-8') + + lock_start = migration.index(" PERFORM pg_advisory_xact_lock(\n") + lock_end = migration.index("\n\n SELECT\n", lock_start) + current_lock = migration[lock_start:lock_end] + if "target_idempotency_key::text" not in current_lock: + raise SystemExit('unexpected notification advisory-lock shape') + new_lock = textwrap.dedent( + """\ + PERFORM pg_advisory_xact_lock( + hashtextextended( + 'notification.service:erase:' || target_workspace_id::text, + 0 + ) + );""" + ) + migration = migration[:lock_start] + " " + new_lock + migration[lock_end:] + + delete_start = migration.index( + " DELETE FROM notification_service.inbox_messages\n" + ) + occurrence_start = migration.index( + " DELETE FROM notification_service.reminder_occurrences\n", + delete_start, + ) + current_delete = migration[delete_start:occurrence_start] + if "DISABLE TRIGGER reminder_outcomes_row_mutation_guard" not in current_delete: + raise SystemExit('unexpected notification trigger-bypass shape') + new_delete = textwrap.dedent( + """\ + DELETE FROM notification_service.inbox_messages + WHERE workspace_id = target_workspace_id; + GET DIAGNOSTICS deleted_inbox_messages = ROW_COUNT; + + -- The append-only trigger accepts this workspace only while the owner-executed + -- SECURITY DEFINER function is active. The marker is transaction-local and an + -- ordinary runtime role cannot satisfy the independent current_user owner check. + PERFORM set_config( + 'life_os.notification_erasure_workspace', + target_workspace_id::text, + true + ); + + DELETE FROM notification_service.reminder_outcomes + WHERE workspace_id = target_workspace_id; + GET DIAGNOSTICS deleted_reminder_outcomes = ROW_COUNT; + + PERFORM set_config( + 'life_os.notification_erasure_workspace', + '', + true + ); + + """ + ) + indented_delete = "\n".join( + f" {line}" if line else "" + for line in new_delete.splitlines() + ) + migration = ( + migration[:delete_start] + + indented_delete + + "\n" + + migration[occurrence_start:] + ) + + revoke_start = migration.index( + "REVOKE ALL ON FUNCTION notification_service.erase_workspace_data(" + ) + comment_start = migration.index( + "COMMENT ON FUNCTION notification_service.erase_workspace_data(", + revoke_start, + ) + hardening = textwrap.dedent( + """\ + CREATE OR REPLACE FUNCTION notification_service.reject_reminder_outcome_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + authorized_workspace text; + erasure_function_owner name; + BEGIN + IF TG_OP = 'DELETE' THEN + authorized_workspace := current_setting( + 'life_os.notification_erasure_workspace', + true + ); + SELECT pg_get_userbyid(proowner) + INTO erasure_function_owner + FROM pg_proc + WHERE oid = + 'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'::regprocedure; + + IF current_user = erasure_function_owner + AND authorized_workspace = OLD.workspace_id::text + THEN + RETURN OLD; + END IF; + END IF; + + RAISE EXCEPTION 'reminder outcomes are immutable' + USING ERRCODE = '55000'; + END; + $$; + + REVOKE ALL ON FUNCTION notification_service.erase_workspace_data( + uuid, + uuid, + uuid, + uuid + ) FROM PUBLIC; + + DO $grant_notification_runtime$ + DECLARE + runtime_role_name text := NULLIF( + current_setting('life_os.notification_runtime_role', true), + '' + ); + BEGIN + IF runtime_role_name IS NULL + OR runtime_role_name !~ '^[a-z_][a-z0-9_]{0,62}$' + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'Notification runtime role configuration is invalid'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = runtime_role_name + ) THEN + RAISE EXCEPTION USING + ERRCODE = '42704', + MESSAGE = 'Notification runtime role does not exist'; + END IF; + + EXECUTE format( + 'GRANT USAGE ON SCHEMA notification_service TO %I', + runtime_role_name + ); + EXECUTE format( + 'GRANT SELECT, INSERT ON TABLE notification_service.data_rights_erasure_receipts TO %I', + runtime_role_name + ); + EXECUTE format( + 'GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid) TO %I', + runtime_role_name + ); + END; + $grant_notification_runtime$; + + """ + ) + migration = ( + migration[:revoke_start] + + hardening + + migration[comment_start:] + ) + migration_path.write_text(migration, encoding='utf-8') + + docs_path = Path('docs/operations/notification-persistence.md') + docs = docs_path.read_text(encoding='utf-8') + migration_sentence = ( + "Apply `apps/notification-service/migrations/" + "0001_durable_reminder_inbox.sql` before starting a runtime that " + "uses `PostgresReminderRepository`." + ) + replacement_sentence = ( + "Apply `apps/notification-service/migrations/" + "0001_durable_reminder_inbox.sql` and then " + "`0002_data_rights_erasure.sql` before starting a runtime that " + "exposes the Notification data-rights contributor." + ) + if docs.count(migration_sentence) != 1: + raise SystemExit('unexpected notification migration documentation') + docs = docs.replace(migration_sentence, replacement_sentence, 1) + operations_anchor = ( + "Run the migration through the normal release migration job using a " + "role with schema DDL rights. The application role should receive only " + "the table and sequence privileges required by the repository. Do not " + "grant the application role ownership of the schema or the " + "mutation-guard function.\n" + ) + operations_replacement = operations_anchor + r""" + Before applying `0002_data_rights_erasure.sql`, the same migration session + must set the reviewed runtime role name in the transaction-independent + PostgreSQL setting `life_os.notification_runtime_role`. The migration + fails closed when the setting is missing, malformed, or names a role that + does not exist. It revokes public function access and grants that exact + role only schema `USAGE`, receipt `SELECT`/`INSERT`, and `EXECUTE` on the + owner-controlled erasure function. Never set the value from request data. + + The erasure function serializes requests by workspace, not by idempotency + key. It authorizes immutable outcome deletion with a transaction-local + workspace marker plus an independent function-owner check; it never + disables a trigger or takes an `ACCESS EXCLUSIVE` lock merely to erase one + tenant. Direct deletion remains rejected even if an ordinary runtime + session writes the custom setting. + """ + if docs.count(operations_anchor) != 1: + raise SystemExit('unexpected notification operations anchor') + docs = docs.replace(operations_anchor, operations_replacement, 1) + docs_path.write_text(docs, encoding='utf-8') + PY + + pnpm exec prettier --single-quote --write \ + apps/notification-service/src/notification-data-rights.ts \ + apps/notification-service/src/notification-data-rights.behavior.test.ts \ + apps/notification-service/src/notification-data-rights-migration.test.ts \ + apps/notification-service/src/notification-data-rights.integration.test.ts \ + docs/operations/notification-persistence.md + + - name: Verify focused and package evidence + run: | + set -Eeuo pipefail + pnpm --filter @life-os/notification-service exec vitest run \ + src/notification-data-rights.behavior.test.ts \ + src/notification-data-rights-migration.test.ts \ + src/notification-data-rights.integration.test.ts \ + --no-file-parallelism + pnpm --filter @life-os/notification-service run lint + pnpm --filter @life-os/notification-service run typecheck + pnpm --filter @life-os/notification-service run test + pnpm --filter @life-os/notification-service run build + + - name: Verify current-main whole-repository gates + run: | + set -Eeuo pipefail + pnpm format:check + pnpm lint + pnpm typecheck + pnpm test + pnpm build + docker compose config --quiet + git diff --check + + - name: Commit verified repair and remove temporary workflow + run: | + set -Eeuo pipefail + git rm .github/workflows/repair-notification-data-rights.yml + git diff --check + actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" + expected="$(printf '%s\n' \ + '.github/workflows/repair-notification-data-rights.yml' \ + 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ + 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ + 'apps/notification-service/src/notification-data-rights.behavior.test.ts' \ + 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ + 'apps/notification-service/src/notification-data-rights.ts' \ + 'docs/operations/notification-persistence.md' \ + | LC_ALL=C sort)" + test "$actual" = "$expected" + git add \ + apps/notification-service/migrations/0002_data_rights_erasure.sql \ + apps/notification-service/src/notification-data-rights-migration.test.ts \ + apps/notification-service/src/notification-data-rights.behavior.test.ts \ + apps/notification-service/src/notification-data-rights.integration.test.ts \ + apps/notification-service/src/notification-data-rights.ts \ + docs/operations/notification-persistence.md + git commit -m 'fix(notification): harden data-rights erasure authority' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/main)" = '7c3fd32efbf9ebdcb4bac99980a3c8b6c893a89f' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" + git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From 299f0d2b71f27f94ca71e64dd77826ea0cb9ba9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:00:13 +0900 Subject: [PATCH 006/150] ci(notification): prepare live review repair --- .../kick-notification-data-rights-repair.yml | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .github/workflows/kick-notification-data-rights-repair.yml diff --git a/.github/workflows/kick-notification-data-rights-repair.yml b/.github/workflows/kick-notification-data-rights-repair.yml new file mode 100644 index 00000000..ff5a6030 --- /dev/null +++ b/.github/workflows/kick-notification-data-rights-repair.yml @@ -0,0 +1,142 @@ +name: Prepare notification data-rights repair + +on: + push: + branches: + - feat/notification-data-rights-contributor-v2 + paths: + - .github/workflows/kick-notification-data-rights-repair.yml + +permissions: {} + +concurrency: + group: prepare-notification-data-rights-repair-${{ github.ref }} + cancel-in-progress: true + +jobs: + prepare: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout exact feature branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: feat/notification-data-rights-contributor-v2 + fetch-depth: 0 + + - name: Adapt repair workflow to the live branch + env: + EXPECTED_PARENT: 11a720de2206d9a2209bc16063bfb521c6a8d2f0 + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" + export CURRENT_HEAD="$(git rev-parse HEAD)" + export CURRENT_MAIN="$(git rev-parse origin/main)" + python3 - <<'PY' + import os + from pathlib import Path + + path = Path('.github/workflows/repair-notification-data-rights.yml') + workflow = path.read_text(encoding='utf-8') + current_head = os.environ['CURRENT_HEAD'] + current_main = os.environ['CURRENT_MAIN'] + + workflow = workflow.replace( + "github.event.before == 'a558ab896894906a081c7e34efe842676da5ffd6'", + f"github.event.before == '{current_head}'", + 1, + ) + workflow = workflow.replace( + "test \"$(git rev-parse HEAD^)\" = 'a558ab896894906a081c7e34efe842676da5ffd6'", + f"test \"$(git rev-parse HEAD^)\" = '{current_head}'", + 1, + ) + workflow = workflow.replace( + "'7c3fd32efbf9ebdcb4bac99980a3c8b6c893a89f'", + f"'{current_main}'", + ) + + step_start = workflow.index( + " behavior_path = Path(\n", + workflow.index("- name: Add failing regression evidence"), + ) + step_end = workflow.index( + " migration_contract = r\"\"\"", + step_start, + ) + workflow = workflow[:step_start] + workflow[step_end:] + + removed_grep = ( + " grep -F 'exports code-unit canonical evidence " + "independent of key insertion order' \\\n" + " \"$RUNNER_TEMP/notification-red.log\"\n" + ) + if workflow.count(removed_grep) != 1: + raise SystemExit('unexpected obsolete deterministic-test grep') + workflow = workflow.replace(removed_grep, '', 1) + + old_sort = ( + " old_sort = (\n" + " \" entries.sort(([left], [right]) => left.localeCompare(right));\"\n" + " )\n" + " new_sort = \"\"\" entries.sort(([left], [right]) =>\n" + " left < right ? -1 : 1,\n" + " );\"\"\"\n" + ) + new_sort = ( + " old_sort = \"\"\" entries.sort(([left], [right]) => {\n" + " if (left < right) {\n" + " return -1;\n" + " }\n" + " if (left > right) {\n" + " return 1;\n" + " }\n" + " return 0;\n" + " });\"\"\"\n" + " new_sort = \"\"\" entries.sort(([left], [right]) =>\n" + " left < right ? -1 : 1,\n" + " );\"\"\"\n" + ) + if workflow.count(old_sort) != 1: + raise SystemExit('unexpected stale comparator patch') + workflow = workflow.replace(old_sort, new_sort, 1) + + workflow = workflow.replace( + " 'apps/notification-service/src/" + "notification-data-rights.behavior.test.ts' \\\n", + '', + 1, + ) + workflow = workflow.replace( + " apps/notification-service/src/" + "notification-data-rights.behavior.test.ts \\\n", + '', + 1, + ) + path.write_text(workflow, encoding='utf-8') + PY + + git rm .github/workflows/kick-notification-data-rights-repair.yml + git diff --check + actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" + expected="$(printf '%s\n' \ + '.github/workflows/kick-notification-data-rights-repair.yml' \ + '.github/workflows/repair-notification-data-rights.yml' \ + | LC_ALL=C sort)" + test "$actual" = "$expected" + git add .github/workflows/repair-notification-data-rights.yml + git commit -m 'ci(notification): adapt repair to live branch' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$CURRENT_HEAD" + test "$(git rev-parse origin/main)" = "$CURRENT_MAIN" + git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From 5f29baee08d651015c046d7cbd1428db647af366 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:03:05 +0900 Subject: [PATCH 007/150] ci(notification): execute verified review repair --- ...xecute-notification-data-rights-repair.yml | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 .github/workflows/execute-notification-data-rights-repair.yml diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml new file mode 100644 index 00000000..e958856a --- /dev/null +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -0,0 +1,197 @@ +name: Execute notification data-rights repair + +on: + push: + branches: + - feat/notification-data-rights-contributor-v2 + paths: + - .github/workflows/execute-notification-data-rights-repair.yml + +permissions: {} + +concurrency: + group: execute-notification-data-rights-repair-${{ github.ref }} + cancel-in-progress: true + +jobs: + execute: + runs-on: ubuntu-24.04 + timeout-minutes: 50 + permissions: + contents: write + env: + NOTIFICATION_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 feature branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: feat/notification-data-rights-contributor-v2 + fetch-depth: 0 + + - name: Reconcile protected main and branch authority + env: + EXPECTED_PARENT: 299f0d2b71f27f94ca71e64dd77826ea0cb9ba9d + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" + git merge --no-edit --no-ff origin/main + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Enable Corepack and install locked dependencies + run: | + set -Eeuo pipefail + corepack enable + pnpm install --frozen-lockfile + + - name: Materialize reviewed repair phases + run: | + set -Eeuo pipefail + python3 - <<'PY' + from pathlib import Path + + workflow = Path( + '.github/workflows/repair-notification-data-rights.yml' + ).read_text(encoding='utf-8') + + def run_step(name: str) -> str: + marker = f" - name: {name}\n" + start = workflow.index(marker) + run_start = workflow.index(" run: |\n", start) + content_start = run_start + len(" run: |\n") + next_step = workflow.find("\n - name:", content_start) + if next_step == -1: + next_step = len(workflow) + lines = workflow[content_start:next_step].splitlines() + return "\n".join( + line[10:] if line.startswith(" ") else line + for line in lines + ) + "\n" + + add_tests = run_step('Add failing regression evidence') + behavior_start = add_tests.index("behavior_path = Path(\n") + behavior_end = add_tests.index( + 'migration_contract = r"""', + behavior_start, + ) + add_tests = add_tests[:behavior_start] + add_tests[behavior_end:] + + verify_red = run_step( + 'Verify the regressions fail before implementation' + ) + obsolete_grep = ( + "grep -F 'exports code-unit canonical evidence independent " + "of key insertion order' \\\n" + ' "$RUNNER_TEMP/notification-red.log"\n' + ) + if verify_red.count(obsolete_grep) != 1: + raise SystemExit('unexpected obsolete deterministic-test grep') + verify_red = verify_red.replace(obsolete_grep, '', 1) + + implement = run_step( + 'Implement deterministic, least-privilege, nonblocking erasure' + ) + stale_sort = ( + "old_sort = (\n" + ' " entries.sort(([left], [right]) => ' + 'left.localeCompare(right));"\n' + ")\n" + 'new_sort = """ entries.sort(([left], [right]) =>\n' + " left < right ? -1 : 1,\n" + ' );"""\n' + ) + live_sort = ( + 'old_sort = """ entries.sort(([left], [right]) => {\n' + " if (left < right) {\n" + " return -1;\n" + " }\n" + " if (left > right) {\n" + " return 1;\n" + " }\n" + " return 0;\n" + ' });"""\n' + 'new_sort = """ entries.sort(([left], [right]) =>\n' + " left < right ? -1 : 1,\n" + ' );"""\n' + ) + if implement.count(stale_sort) != 1: + raise SystemExit('unexpected stale comparator repair phase') + implement = implement.replace(stale_sort, live_sort, 1) + + phases = { + '01-add-tests.sh': add_tests, + '02-verify-red.sh': verify_red, + '03-implement.sh': implement, + '04-verify-focused.sh': run_step( + 'Verify focused and package evidence' + ), + '05-verify-repository.sh': run_step( + 'Verify current-main whole-repository gates' + ), + } + output = Path('/tmp/notification-repair-phases') + output.mkdir(mode=0o700) + for name, content in phases.items(): + phase = output / name + phase.write_text(content, encoding='utf-8') + phase.chmod(0o700) + PY + + - name: Execute test-first repair and verification + run: | + set -Eeuo pipefail + for phase in /tmp/notification-repair-phases/*.sh; do + printf 'repair_phase=%s\n' "$(basename "$phase")" + bash "$phase" + done + + - name: Commit production repair without workflow mutation + run: | + set -Eeuo pipefail + git diff --check + actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" + expected="$(printf '%s\n' \ + 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ + 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ + 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ + 'apps/notification-service/src/notification-data-rights.ts' \ + 'docs/operations/notification-persistence.md' \ + | LC_ALL=C sort)" + test "$actual" = "$expected" + git add \ + apps/notification-service/migrations/0002_data_rights_erasure.sql \ + apps/notification-service/src/notification-data-rights-migration.test.ts \ + apps/notification-service/src/notification-data-rights.integration.test.ts \ + apps/notification-service/src/notification-data-rights.ts \ + docs/operations/notification-persistence.md + git commit -m 'fix(notification): harden data-rights erasure authority' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" + test "$(git merge-base --is-ancestor origin/main HEAD; printf '%s' "$?")" = '0' + git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From 53c596affb873f8bca75ed2cf454eb179c15b728 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:06:06 +0900 Subject: [PATCH 008/150] ci(notification): repair live comparator phase --- ...xecute-notification-data-rights-repair.yml | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index e958856a..017a3cd4 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: 299f0d2b71f27f94ca71e64dd77826ea0cb9ba9d + EXPECTED_PARENT: 5f29baee08d651015c046d7cbd1428db647af366 run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -115,32 +115,27 @@ jobs: implement = run_step( 'Implement deterministic, least-privilege, nonblocking erasure' ) - stale_sort = ( - "old_sort = (\n" - ' " entries.sort(([left], [right]) => ' - 'left.localeCompare(right));"\n' - ")\n" - 'new_sort = """ entries.sort(([left], [right]) =>\n' - " left < right ? -1 : 1,\n" - ' );"""\n' + sort_start = implement.index("old_sort = (\n") + sort_end = implement.index( + '\n\nold_helper = r"""', + sort_start, ) - live_sort = ( - 'old_sort = """ entries.sort(([left], [right]) => {\n' - " if (left < right) {\n" - " return -1;\n" - " }\n" - " if (left > right) {\n" - " return 1;\n" - " }\n" - " return 0;\n" - ' });"""\n' - 'new_sort = """ entries.sort(([left], [right]) =>\n' - " left < right ? -1 : 1,\n" - ' );"""\n' - ) - if implement.count(stale_sort) != 1: - raise SystemExit('unexpected stale comparator repair phase') - implement = implement.replace(stale_sort, live_sort, 1) + live_sort = r'''current_sort = """ entries.sort(([left], [right]) => { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; + });""" + new_sort = """ entries.sort(([left], [right]) => + left < right ? -1 : 1, + );""" + if source.count(current_sort) != 1: + raise SystemExit('unexpected live canonical key-sort shape') + source = source.replace(current_sort, new_sort, 1)''' + implement = implement[:sort_start] + live_sort + implement[sort_end:] phases = { '01-add-tests.sh': add_tests, @@ -193,5 +188,5 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' \ '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" - test "$(git merge-base --is-ancestor origin/main HEAD; printf '%s' "$?")" = '0' + git merge-base --is-ancestor origin/main HEAD git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From cb4e7d4bb1725e2c542855cfc684e3e462d30cf3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:09:50 +0900 Subject: [PATCH 009/150] ci(notification): limit repair to live findings --- ...xecute-notification-data-rights-repair.yml | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index 017a3cd4..98f35b1f 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: 5f29baee08d651015c046d7cbd1428db647af366 + EXPECTED_PARENT: 53c596affb873f8bca75ed2cf454eb179c15b728 run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -120,22 +120,7 @@ jobs: '\n\nold_helper = r"""', sort_start, ) - live_sort = r'''current_sort = """ entries.sort(([left], [right]) => { - if (left < right) { - return -1; - } - if (left > right) { - return 1; - } - return 0; - });""" - new_sort = """ entries.sort(([left], [right]) => - left < right ? -1 : 1, - );""" - if source.count(current_sort) != 1: - raise SystemExit('unexpected live canonical key-sort shape') - source = source.replace(current_sort, new_sort, 1)''' - implement = implement[:sort_start] + live_sort + implement[sort_end:] + implement = implement[:sort_start] + implement[sort_end + 2:] phases = { '01-add-tests.sh': add_tests, From c1ac9a4207f125669b0ddb8a5904b2b34a9d0746 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:12:49 +0900 Subject: [PATCH 010/150] ci(notification): make marker assertion formatting-neutral --- ...xecute-notification-data-rights-repair.yml | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index 98f35b1f..62deaba6 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: 53c596affb873f8bca75ed2cf454eb179c15b728 + EXPECTED_PARENT: cb4e7d4bb1725e2c542855cfc684e3e462d30cf3 run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -100,6 +100,34 @@ jobs: ) add_tests = add_tests[:behavior_start] + add_tests[behavior_end:] + harden_test_contract = r'''python3 - <<'PY' + from pathlib import Path + import re + + path = Path( + 'apps/notification-service/src/notification-data-rights-migration.test.ts' + ) + tests = path.read_text(encoding='utf-8') + pattern = re.compile( + r'''expect\(sql\)\.toContain\(\s*''' + r'''["']set_config\('life_os\.notification_erasure_workspace'["']''' + r'''\s*,\s*\);''', + re.MULTILINE, + ) + replacement = ( + "expect(sql).toMatch(\n" + " /set_config\\(\\s*'life_os\\.notification_erasure_workspace'/u,\n" + " );" + ) + tests, replacements = pattern.subn(replacement, tests, count=1) + if replacements != 1: + raise SystemExit('unexpected transaction-local marker assertion') + path.write_text(tests, encoding='utf-8') + PY + pnpm exec prettier --single-quote --write \ + apps/notification-service/src/notification-data-rights-migration.test.ts + ''' + verify_red = run_step( 'Verify the regressions fail before implementation' ) @@ -124,12 +152,13 @@ jobs: phases = { '01-add-tests.sh': add_tests, - '02-verify-red.sh': verify_red, - '03-implement.sh': implement, - '04-verify-focused.sh': run_step( + '02-harden-test-contract.sh': harden_test_contract, + '03-verify-red.sh': verify_red, + '04-implement.sh': implement, + '05-verify-focused.sh': run_step( 'Verify focused and package evidence' ), - '05-verify-repository.sh': run_step( + '06-verify-repository.sh': run_step( 'Verify current-main whole-repository gates' ), } From a07c315a4bb5802680eba9a471bd97840e022140 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:13:50 +0900 Subject: [PATCH 011/150] ci(notification): fix repair phase quoting --- .../execute-notification-data-rights-repair.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index 62deaba6..dd7b1875 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: cb4e7d4bb1725e2c542855cfc684e3e462d30cf3 + EXPECTED_PARENT: c1ac9a4207f125669b0ddb8a5904b2b34a9d0746 run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -100,7 +100,7 @@ jobs: ) add_tests = add_tests[:behavior_start] + add_tests[behavior_end:] - harden_test_contract = r'''python3 - <<'PY' + harden_test_contract = r"""python3 - <<'PY' from pathlib import Path import re @@ -109,9 +109,7 @@ jobs: ) tests = path.read_text(encoding='utf-8') pattern = re.compile( - r'''expect\(sql\)\.toContain\(\s*''' - r'''["']set_config\('life_os\.notification_erasure_workspace'["']''' - r'''\s*,\s*\);''', + r"expect\(sql\)\.toContain\(\s*[\"']set_config\('life_os\.notification_erasure_workspace'[\"']\s*,\s*\);", re.MULTILINE, ) replacement = ( @@ -126,7 +124,7 @@ jobs: PY pnpm exec prettier --single-quote --write \ apps/notification-service/src/notification-data-rights-migration.test.ts - ''' + """ verify_red = run_step( 'Verify the regressions fail before implementation' From 423040991a3cb25d2dd192078637a3c704b55a27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:16:41 +0900 Subject: [PATCH 012/150] ci(notification): apply formatting-neutral test contract --- ...xecute-notification-data-rights-repair.yml | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index dd7b1875..84d521ad 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: c1ac9a4207f125669b0ddb8a5904b2b34a9d0746 + EXPECTED_PARENT: a07c315a4bb5802680eba9a471bd97840e022140 run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -73,6 +73,7 @@ jobs: set -Eeuo pipefail python3 - <<'PY' from pathlib import Path + import re workflow = Path( '.github/workflows/repair-notification-data-rights.yml' @@ -100,31 +101,22 @@ jobs: ) add_tests = add_tests[:behavior_start] + add_tests[behavior_end:] - harden_test_contract = r"""python3 - <<'PY' - from pathlib import Path - import re - - path = Path( - 'apps/notification-service/src/notification-data-rights-migration.test.ts' - ) - tests = path.read_text(encoding='utf-8') - pattern = re.compile( + marker_assertion = re.compile( r"expect\(sql\)\.toContain\(\s*[\"']set_config\('life_os\.notification_erasure_workspace'[\"']\s*,\s*\);", re.MULTILINE, ) - replacement = ( + marker_replacement = ( "expect(sql).toMatch(\n" - " /set_config\\(\\s*'life_os\\.notification_erasure_workspace'/u,\n" - " );" + " /set_config\\(\\s*'life_os\\.notification_erasure_workspace'/u,\n" + " );" + ) + add_tests, replacements = marker_assertion.subn( + lambda _match: marker_replacement, + add_tests, + count=1, ) - tests, replacements = pattern.subn(replacement, tests, count=1) if replacements != 1: raise SystemExit('unexpected transaction-local marker assertion') - path.write_text(tests, encoding='utf-8') - PY - pnpm exec prettier --single-quote --write \ - apps/notification-service/src/notification-data-rights-migration.test.ts - """ verify_red = run_step( 'Verify the regressions fail before implementation' @@ -150,13 +142,12 @@ jobs: phases = { '01-add-tests.sh': add_tests, - '02-harden-test-contract.sh': harden_test_contract, - '03-verify-red.sh': verify_red, - '04-implement.sh': implement, - '05-verify-focused.sh': run_step( + '02-verify-red.sh': verify_red, + '03-implement.sh': implement, + '04-verify-focused.sh': run_step( 'Verify focused and package evidence' ), - '06-verify-repository.sh': run_step( + '05-verify-repository.sh': run_step( 'Verify current-main whole-repository gates' ), } From 09a7b363a1c184ae3d7ee2d82f50499bacc334f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:19:37 +0900 Subject: [PATCH 013/150] ci(notification): make owner-marker assertion formatting-neutral --- ...xecute-notification-data-rights-repair.yml | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index 84d521ad..b2beee7d 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: a07c315a4bb5802680eba9a471bd97840e022140 + EXPECTED_PARENT: 423040991a3cb25d2dd192078637a3c704b55a27 run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -110,14 +110,31 @@ jobs: " /set_config\\(\\s*'life_os\\.notification_erasure_workspace'/u,\n" " );" ) - add_tests, replacements = marker_assertion.subn( + add_tests, marker_replacements = marker_assertion.subn( lambda _match: marker_replacement, add_tests, count=1, ) - if replacements != 1: + if marker_replacements != 1: raise SystemExit('unexpected transaction-local marker assertion') + owner_assertion = re.compile( + r"expect\(sql\)\.toContain\(\s*[\"']current_setting\('life_os\.notification_erasure_workspace', true\)[\"']\s*,\s*\);", + re.MULTILINE, + ) + owner_replacement = ( + "expect(sql).toMatch(\n" + " /current_setting\\(\\s*'life_os\\.notification_erasure_workspace',\\s*true\\s*\\)/u,\n" + " );" + ) + add_tests, owner_replacements = owner_assertion.subn( + lambda _match: owner_replacement, + add_tests, + count=1, + ) + if owner_replacements != 1: + raise SystemExit('unexpected owner-bound marker assertion') + verify_red = run_step( 'Verify the regressions fail before implementation' ) From 9b1969cb2559983fb8bb7761230c312bbada5e8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:22:44 +0900 Subject: [PATCH 014/150] ci(notification): separate focused tests from coverage gate --- ...xecute-notification-data-rights-repair.yml | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index b2beee7d..f6d91c0a 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: 423040991a3cb25d2dd192078637a3c704b55a27 + EXPECTED_PARENT: 09a7b363a1c184ae3d7ee2d82f50499bacc334f5 run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -157,13 +157,28 @@ jobs: ) implement = implement[:sort_start] + implement[sort_end + 2:] + focused = run_step('Verify focused and package evidence') + focused_command = ( + ' src/notification-data-rights.integration.test.ts \\\n' + ' --no-file-parallelism\n' + ) + focused_replacement = ( + ' src/notification-data-rights.integration.test.ts \\\n' + ' --no-file-parallelism --coverage.enabled=false\n' + ) + if focused.count(focused_command) != 1: + raise SystemExit('unexpected focused verification command') + focused = focused.replace( + focused_command, + focused_replacement, + 1, + ) + phases = { '01-add-tests.sh': add_tests, '02-verify-red.sh': verify_red, '03-implement.sh': implement, - '04-verify-focused.sh': run_step( - 'Verify focused and package evidence' - ), + '04-verify-focused.sh': focused, '05-verify-repository.sh': run_step( 'Verify current-main whole-repository gates' ), From 121279ce6eb131e1638b96c7a1343472cd78942b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:28:41 +0900 Subject: [PATCH 015/150] ci(notification): preserve covered export validator --- .../execute-notification-data-rights-repair.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index f6d91c0a..38c365c0 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: 09a7b363a1c184ae3d7ee2d82f50499bacc334f5 + EXPECTED_PARENT: 9b1969cb2559983fb8bb7761230c312bbada5e8f run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -150,12 +150,12 @@ jobs: implement = run_step( 'Implement deterministic, least-privilege, nonblocking erasure' ) - sort_start = implement.index("old_sort = (\n") - sort_end = implement.index( - '\n\nold_helper = r"""', - sort_start, + source_start = implement.index("source_path = Path(\n") + source_end = implement.index( + "\nmigration_path = Path(\n", + source_start, ) - implement = implement[:sort_start] + implement[sort_end + 2:] + implement = implement[:source_start] + implement[source_end + 1:] focused = run_step('Verify focused and package evidence') focused_command = ( @@ -208,7 +208,6 @@ jobs: 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ - 'apps/notification-service/src/notification-data-rights.ts' \ 'docs/operations/notification-persistence.md' \ | LC_ALL=C sort)" test "$actual" = "$expected" @@ -216,7 +215,6 @@ jobs: apps/notification-service/migrations/0002_data_rights_erasure.sql \ apps/notification-service/src/notification-data-rights-migration.test.ts \ apps/notification-service/src/notification-data-rights.integration.test.ts \ - apps/notification-service/src/notification-data-rights.ts \ docs/operations/notification-persistence.md git commit -m 'fix(notification): harden data-rights erasure authority' git fetch --no-tags origin \ From 1094334622d209d072188af2b2a4d9c20e182bf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:34:01 +0900 Subject: [PATCH 016/150] ci(notification): remove unreachable comparator branch --- ...xecute-notification-data-rights-repair.yml | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml index 38c365c0..a796f9cd 100644 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ b/.github/workflows/execute-notification-data-rights-repair.yml @@ -45,7 +45,7 @@ jobs: - name: Reconcile protected main and branch authority env: - EXPECTED_PARENT: 9b1969cb2559983fb8bb7761230c312bbada5e8f + EXPECTED_PARENT: 121279ce6eb131e1638b96c7a1343472cd78942b run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -155,7 +155,32 @@ jobs: "\nmigration_path = Path(\n", source_start, ) - implement = implement[:source_start] + implement[source_end + 1:] + comparator_patch = r'''source_path = Path( + 'apps/notification-service/src/notification-data-rights.ts' + ) + source = source_path.read_text(encoding='utf-8') + old_sort = """ entries.sort(([left], [right]) => { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; + });""" + new_sort = """ entries.sort(([left], [right]) => + left < right ? -1 : 1, + );""" + if source.count(old_sort) != 1: + raise SystemExit('unexpected canonical comparator shape') + source = source.replace(old_sort, new_sort, 1) + source_path.write_text(source, encoding='utf-8') + ''' + implement = ( + implement[:source_start] + + comparator_patch + + implement[source_end + 1:] + ) focused = run_step('Verify focused and package evidence') focused_command = ( @@ -208,6 +233,7 @@ jobs: 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ + 'apps/notification-service/src/notification-data-rights.ts' \ 'docs/operations/notification-persistence.md' \ | LC_ALL=C sort)" test "$actual" = "$expected" @@ -215,6 +241,7 @@ jobs: apps/notification-service/migrations/0002_data_rights_erasure.sql \ apps/notification-service/src/notification-data-rights-migration.test.ts \ apps/notification-service/src/notification-data-rights.integration.test.ts \ + apps/notification-service/src/notification-data-rights.ts \ docs/operations/notification-persistence.md git commit -m 'fix(notification): harden data-rights erasure authority' git fetch --no-tags origin \ From da8f4ad21002100fb4b573ca3806f7fbc216acfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:41:37 +0900 Subject: [PATCH 017/150] ci(notification): make comparator repair shape-tolerant --- .../workflows/patch-notification-executor.yml | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/patch-notification-executor.yml diff --git a/.github/workflows/patch-notification-executor.yml b/.github/workflows/patch-notification-executor.yml new file mode 100644 index 00000000..e719af84 --- /dev/null +++ b/.github/workflows/patch-notification-executor.yml @@ -0,0 +1,87 @@ +name: Patch notification repair executor + +on: + push: + branches: + - feat/notification-data-rights-contributor-v2 + paths: + - .github/workflows/patch-notification-executor.yml + +permissions: {} + +concurrency: + group: patch-notification-executor-${{ github.ref }} + cancel-in-progress: true + +jobs: + patch: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout exact feature branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: feat/notification-data-rights-contributor-v2 + fetch-depth: 0 + + - name: Patch executor and remove bootstrap + env: + EXPECTED_PARENT: 1094334622d209d072188af2b2a4d9c20e182bf0 + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" + + python3 - <<'PY' + from pathlib import Path + + path = Path('.github/workflows/execute-notification-data-rights-repair.yml') + workflow = path.read_text(encoding='utf-8') + start = workflow.index(" comparator_patch = r'''source_path = Path(\n") + end = workflow.index("\n implement = (\n", start) + replacement = r''' comparator_patch = r'''source_path = Path( + 'apps/notification-service/src/notification-data-rights.ts' + ) + source = source_path.read_text(encoding='utf-8') + comparator_pattern = re.compile( + r" entries\.sort\(\(\[left\], \[right\]\) => \{\n" + r"(?: .*\n)+?" + r" \}\);", + ) + source, comparator_replacements = comparator_pattern.subn( + " entries.sort(([left], [right]) =>\n" + " left < right ? -1 : 1,\n" + " );", + source, + count=1, + ) + if comparator_replacements != 1: + raise SystemExit('unexpected canonical comparator shape') + source_path.write_text(source, encoding='utf-8') + '''''' + workflow = workflow[:start] + replacement + workflow[end:] + path.write_text(workflow, encoding='utf-8') + PY + + git rm .github/workflows/patch-notification-executor.yml + git diff --check + actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" + expected="$(printf '%s\n' \ + '.github/workflows/execute-notification-data-rights-repair.yml' \ + '.github/workflows/patch-notification-executor.yml' \ + | LC_ALL=C sort)" + test "$actual" = "$expected" + git add .github/workflows/execute-notification-data-rights-repair.yml + git commit -m 'ci(notification): tolerate comparator formatting drift' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" + git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From 2a021b4c173bde3590e79eb634c87811928fccd6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:42:38 +0900 Subject: [PATCH 018/150] ci(notification): quote comparator bootstrap safely --- .github/workflows/patch-notification-executor.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/patch-notification-executor.yml b/.github/workflows/patch-notification-executor.yml index e719af84..b0d53fbb 100644 --- a/.github/workflows/patch-notification-executor.yml +++ b/.github/workflows/patch-notification-executor.yml @@ -28,7 +28,7 @@ jobs: - name: Patch executor and remove bootstrap env: - EXPECTED_PARENT: 1094334622d209d072188af2b2a4d9c20e182bf0 + EXPECTED_PARENT: da8f4ad21002100fb4b573ca3806f7fbc216acfc run: | set -Eeuo pipefail test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" @@ -46,7 +46,7 @@ jobs: workflow = path.read_text(encoding='utf-8') start = workflow.index(" comparator_patch = r'''source_path = Path(\n") end = workflow.index("\n implement = (\n", start) - replacement = r''' comparator_patch = r'''source_path = Path( + replacement = r""" comparator_patch = r'''source_path = Path( 'apps/notification-service/src/notification-data-rights.ts' ) source = source_path.read_text(encoding='utf-8') @@ -65,7 +65,7 @@ jobs: if comparator_replacements != 1: raise SystemExit('unexpected canonical comparator shape') source_path.write_text(source, encoding='utf-8') - '''''' + '''""" workflow = workflow[:start] + replacement + workflow[end:] path.write_text(workflow, encoding='utf-8') PY From 042dbafd35401aa1195a08949c13c94b4a113b5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:50:39 +0900 Subject: [PATCH 019/150] ci(notification): finalize verified data-rights repair --- .../finalize-notification-data-rights.yml | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 .github/workflows/finalize-notification-data-rights.yml diff --git a/.github/workflows/finalize-notification-data-rights.yml b/.github/workflows/finalize-notification-data-rights.yml new file mode 100644 index 00000000..ee8b03f2 --- /dev/null +++ b/.github/workflows/finalize-notification-data-rights.yml @@ -0,0 +1,244 @@ +name: Finalize notification data-rights repair + +on: + push: + branches: + - feat/notification-data-rights-contributor-v2 + paths: + - .github/workflows/finalize-notification-data-rights.yml + +permissions: {} + +concurrency: + group: finalize-notification-data-rights-${{ github.ref }} + cancel-in-progress: true + +jobs: + finalize: + runs-on: ubuntu-24.04 + timeout-minutes: 55 + permissions: + contents: write + env: + NOTIFICATION_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 feature branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: feat/notification-data-rights-contributor-v2 + fetch-depth: 0 + + - name: Reconcile protected main and branch authority + env: + EXPECTED_PARENT: 2a021b4c173bde3590e79eb634c87811928fccd6 + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" + git merge --no-edit --no-ff origin/main + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install locked dependencies + run: | + set -Eeuo pipefail + corepack enable + pnpm install --frozen-lockfile + + - name: Materialize reviewed test-first repair + run: | + set -Eeuo pipefail + python3 - <<'PY' + from pathlib import Path + import re + + workflow = Path( + '.github/workflows/repair-notification-data-rights.yml' + ).read_text(encoding='utf-8') + + def run_step(name: str) -> str: + marker = f" - name: {name}\n" + start = workflow.index(marker) + run_start = workflow.index(" run: |\n", start) + content_start = run_start + len(" run: |\n") + next_step = workflow.find("\n - name:", content_start) + if next_step == -1: + next_step = len(workflow) + lines = workflow[content_start:next_step].splitlines() + return "\n".join( + line[10:] if line.startswith(" ") else line + for line in lines + ) + "\n" + + add_tests = run_step('Add failing regression evidence') + behavior_start = add_tests.index("behavior_path = Path(\n") + behavior_end = add_tests.index( + 'migration_contract = r"""', + behavior_start, + ) + add_tests = add_tests[:behavior_start] + add_tests[behavior_end:] + + replacements = [ + ( + re.compile( + r"expect\(sql\)\.toContain\(\s*[\"']set_config\('life_os\.notification_erasure_workspace'[\"']\s*,\s*\);", + re.MULTILINE, + ), + "expect(sql).toMatch(\n" + " /set_config\\(\\s*'life_os\\.notification_erasure_workspace'/u,\n" + " );", + 'transaction-local marker', + ), + ( + re.compile( + r"expect\(sql\)\.toContain\(\s*[\"']current_setting\('life_os\.notification_erasure_workspace', true\)[\"']\s*,\s*\);", + re.MULTILINE, + ), + "expect(sql).toMatch(\n" + " /current_setting\\(\\s*'life_os\\.notification_erasure_workspace',\\s*true\\s*\\)/u,\n" + " );", + 'owner-bound marker', + ), + ] + for pattern, replacement, label in replacements: + add_tests, count = pattern.subn( + lambda _match, value=replacement: value, + add_tests, + count=1, + ) + if count != 1: + raise SystemExit(f'unexpected {label} assertion') + + verify_red = run_step( + 'Verify the regressions fail before implementation' + ) + obsolete_grep = ( + "grep -F 'exports code-unit canonical evidence independent " + "of key insertion order' \\\n" + ' "$RUNNER_TEMP/notification-red.log"\n' + ) + if verify_red.count(obsolete_grep) != 1: + raise SystemExit('unexpected obsolete deterministic-test grep') + verify_red = verify_red.replace(obsolete_grep, '', 1) + + implement = run_step( + 'Implement deterministic, least-privilege, nonblocking erasure' + ) + source_start = implement.index("source_path = Path(\n") + source_end = implement.index( + "\nmigration_path = Path(\n", + source_start, + ) + source_patch = r'''source_path = Path( + 'apps/notification-service/src/notification-data-rights.ts' + ) + source = source_path.read_text(encoding='utf-8') + comparator_pattern = re.compile( + r" entries\.sort\(\(\[left\], \[right\]\) => \{\n" + r"(?: .*\n)+?" + r" \}\);", + ) + source, comparator_replacements = comparator_pattern.subn( + " entries.sort(([left], [right]) =>\n" + " left < right ? -1 : 1,\n" + " );", + source, + count=1, + ) + if comparator_replacements != 1: + raise SystemExit('unexpected canonical comparator shape') + source_path.write_text(source, encoding='utf-8') + ''' + implement = ( + implement[:source_start] + + source_patch + + implement[source_end + 1:] + ) + + focused = run_step('Verify focused and package evidence') + command = ( + ' src/notification-data-rights.integration.test.ts \\\n' + ' --no-file-parallelism\n' + ) + replacement = ( + ' src/notification-data-rights.integration.test.ts \\\n' + ' --no-file-parallelism --coverage.enabled=false\n' + ) + if focused.count(command) != 1: + raise SystemExit('unexpected focused verification command') + focused = focused.replace(command, replacement, 1) + + phases = { + '01-add-tests.sh': add_tests, + '02-verify-red.sh': verify_red, + '03-implement.sh': implement, + '04-verify-focused.sh': focused, + '05-verify-repository.sh': run_step( + 'Verify current-main whole-repository gates' + ), + } + output = Path('/tmp/notification-finalization') + output.mkdir(mode=0o700) + for name, content in phases.items(): + phase = output / name + phase.write_text(content, encoding='utf-8') + phase.chmod(0o700) + PY + + - name: Execute repair and all validation gates + run: | + set -Eeuo pipefail + for phase in /tmp/notification-finalization/*.sh; do + printf 'notification_phase=%s\n' "$(basename "$phase")" + bash "$phase" + done + + - name: Commit only verified production evidence + run: | + set -Eeuo pipefail + git diff --check + actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" + expected="$(printf '%s\n' \ + 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ + 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ + 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ + 'apps/notification-service/src/notification-data-rights.ts' \ + 'docs/operations/notification-persistence.md' \ + | LC_ALL=C sort)" + test "$actual" = "$expected" + git add \ + apps/notification-service/migrations/0002_data_rights_erasure.sql \ + apps/notification-service/src/notification-data-rights-migration.test.ts \ + apps/notification-service/src/notification-data-rights.integration.test.ts \ + apps/notification-service/src/notification-data-rights.ts \ + docs/operations/notification-persistence.md + git commit -m 'fix(notification): harden data-rights erasure authority' + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' + test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" + git merge-base --is-ancestor origin/main HEAD + git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From 7b11b72add2454668099abd29e90c6864cb5ff70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:02:17 +0900 Subject: [PATCH 020/150] chore(notification): remove temporary repair workflow --- .../repair-notification-data-rights.yml | 967 ------------------ 1 file changed, 967 deletions(-) delete mode 100644 .github/workflows/repair-notification-data-rights.yml diff --git a/.github/workflows/repair-notification-data-rights.yml b/.github/workflows/repair-notification-data-rights.yml deleted file mode 100644 index 6101f8b3..00000000 --- a/.github/workflows/repair-notification-data-rights.yml +++ /dev/null @@ -1,967 +0,0 @@ -name: Repair notification data-rights contributor - -on: - push: - branches: - - feat/notification-data-rights-contributor-v2 - paths: - - .github/workflows/repair-notification-data-rights.yml - -permissions: {} - -concurrency: - group: repair-notification-data-rights-${{ github.ref }} - cancel-in-progress: true - -jobs: - repair: - if: github.event.before == 'a558ab896894906a081c7e34efe842676da5ffd6' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - permissions: - contents: write - env: - NOTIFICATION_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 feature branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: feat/notification-data-rights-contributor-v2 - fetch-depth: 0 - - - name: Reconcile current protected main - run: | - set -Eeuo pipefail - test "$(git rev-parse HEAD^)" = 'a558ab896894906a081c7e34efe842676da5ffd6' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/main)" = '7c3fd32efbf9ebdcb4bac99980a3c8b6c893a89f' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git merge --no-edit --no-ff origin/main - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - - name: Enable Corepack and install locked dependencies - run: | - set -Eeuo pipefail - corepack enable - pnpm install --frozen-lockfile - - - name: Add failing regression evidence - run: | - set -Eeuo pipefail - python3 - <<'PY' - from pathlib import Path - - behavior_path = Path( - 'apps/notification-service/src/notification-data-rights.behavior.test.ts' - ) - behavior = behavior_path.read_text(encoding='utf-8') - start = behavior.index( - " it('exports bounded deterministic tenant evidence without secret hash columns'" - ) - end = behavior.index( - " it('dispatches every contributor lifecycle operation with tenant-scoped parameters'" - ) - replacement = r""" it('exports code-unit canonical evidence independent of key insertion order', async () => { - const nullPrototype = Object.assign(Object.create(null), { zeta: 'z' }); - const originalEvidence = { - zeta: 'last', - alpha: null, - enabled: true, - disabled: false, - count: 1, - nested: ['value'], - nullPrototype, - codeUnitOrder: { a: 1, Z: 2 }, - }; - const permutedEvidence = { - codeUnitOrder: { Z: 2, a: 1 }, - nullPrototype, - nested: ['value'], - count: 1, - disabled: false, - enabled: true, - alpha: null, - zeta: 'last', - }; - const client = new ScriptedClient([ - exportResult([originalEvidence], [], []), - exportResult([permutedEvidence], [], []), - ]); - const contributor = new NotificationDataRightsContributor(client); - - const response = await contributor.handle(request('export')); - const permutedResponse = await contributor.handle(request('export')); - - expect(response).toMatchObject({ - contractVersion: 'life-os.data-rights-contributor.v1', - contributor: 'notification.service', - operation: 'export', - requestId: REQUEST_ID, - schemaVersion: 'notification.data-rights.v1', - recordCount: 1, - }); - if (response.operation !== 'export') { - throw new Error('Expected export response'); - } - if (permutedResponse.operation !== 'export') { - throw new Error('Expected permuted export response'); - } - expect(response.sha256).toBe( - '470db31688a72033957ff76442bc306f6e7202917b9342f6a958e765c1a4b0eb', - ); - expect(permutedResponse.sha256).toBe(response.sha256); - expect(client.calls).toHaveLength(2); - for (const call of client.calls) { - expect(call.values).toEqual([WORKSPACE_ID, 1_001]); - expect(call.text).toContain( - 'ORDER BY created_at ASC, reminder_id ASC', - ); - expect(call.text).toContain( - 'ORDER BY occurred_at ASC, outcome_id ASC', - ); - expect(call.text).toContain( - 'ORDER BY delivered_at ASC, message_id ASC', - ); - expect(call.text).not.toContain('claim_key_hash'); - expect(call.text).not.toContain('idempotency_key_hash'); - } - }); - - """ - behavior_path.write_text( - behavior[:start] + replacement + behavior[end:], - encoding='utf-8', - ) - - migration_contract = r"""import { readFile } from 'node:fs/promises'; - import { resolve } from 'node:path'; - import { describe, expect, it } from 'vitest'; - - const migrationPath = resolve( - __dirname, - '../migrations/0002_data_rights_erasure.sql', - ); - - async function migrationSql(): Promise { - return await readFile(migrationPath, 'utf8'); - } - - describe('Notification data-rights erasure database contract', () => { - it('persists bounded UUIDv4 replay receipts with SHA-256 evidence', async () => { - const sql = await migrationSql(); - - expect(sql).toContain( - 'CREATE TABLE notification_service.data_rights_erasure_receipts', - ); - for (const identifier of [ - 'workspace_id', - 'idempotency_key', - 'request_id', - 'requested_by_user_id', - ]) { - expect(sql).toContain(`uuid_send(${identifier})`); - } - expect(sql).toContain('erased_records >= 0'); - expect(sql).toContain("receipt_sha256 ~ '^[0-9a-f]{64}$'"); - expect(sql).toContain('PRIMARY KEY (workspace_id, idempotency_key)'); - }); - - it('serializes all erasures for one workspace and preserves replay authority', async () => { - const sql = await migrationSql(); - - expect(sql).toContain( - 'CREATE FUNCTION notification_service.erase_workspace_data(', - ); - expect(sql).toContain('SECURITY DEFINER'); - expect(sql).toContain( - 'SET search_path = pg_catalog, notification_service', - ); - expect(sql).toContain( - "'notification.service:erase:' || target_workspace_id::text", - ); - expect(sql).not.toMatch( - /notification\.service:[\s\S]{0,120}target_idempotency_key::text/u, - ); - expect(sql).toContain('IF FOUND THEN'); - expect(sql).toContain( - 'Notification erasure replay authority conflicts', - ); - expect(sql).toContain('sha256('); - expect(sql).toContain("'notification.service'"); - }); - - it('uses an owner-bound transaction-local deletion marker without table-wide trigger locks', async () => { - const sql = await migrationSql(); - - expect(sql).toContain( - "set_config('life_os.notification_erasure_workspace'", - ); - expect(sql).toContain( - "current_setting('life_os.notification_erasure_workspace', true)", - ); - expect(sql).toContain( - "'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'::regprocedure", - ); - expect(sql).toContain('current_user = erasure_function_owner'); - expect(sql).not.toContain('DISABLE TRIGGER'); - expect(sql).not.toContain('ENABLE TRIGGER'); - const inboxDelete = sql.indexOf( - 'DELETE FROM notification_service.inbox_messages', - ); - const outcomeDelete = sql.indexOf( - 'DELETE FROM notification_service.reminder_outcomes', - ); - const occurrenceDelete = sql.indexOf( - 'DELETE FROM notification_service.reminder_occurrences', - ); - expect(inboxDelete).toBeGreaterThan(-1); - expect(outcomeDelete).toBeGreaterThan(inboxDelete); - expect(occurrenceDelete).toBeGreaterThan(outcomeDelete); - }); - - it('grants only the configured runtime role the bounded erasure surface', async () => { - const sql = await migrationSql(); - - expect(sql).toContain( - "current_setting('life_os.notification_runtime_role', true)", - ); - expect(sql).toContain( - 'REVOKE ALL ON FUNCTION notification_service.erase_workspace_data', - ); - expect(sql).toMatch( - /GRANT USAGE ON SCHEMA notification_service TO %I/u, - ); - expect(sql).toMatch( - /GRANT SELECT, INSERT ON TABLE notification_service\.data_rights_erasure_receipts TO %I/u, - ); - expect(sql).toMatch( - /GRANT EXECUTE ON FUNCTION notification_service\.erase_workspace_data\(uuid, uuid, uuid, uuid\) TO %I/u, - ); - expect(sql).not.toMatch(/GRANT[\s\S]+TO PUBLIC/u); - }); - }); - """ - Path( - 'apps/notification-service/src/notification-data-rights-migration.test.ts' - ).write_text(migration_contract, encoding='utf-8') - - integration = r"""import { randomUUID } from 'node:crypto'; - import { readFile } from 'node:fs/promises'; - import { resolve } from 'node:path'; - import { Pool, type QueryResult } from 'pg'; - import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - } from 'vitest'; - - const DATABASE_URL = process.env.NOTIFICATION_DATABASE_URL; - const describeWithPostgres = DATABASE_URL ? describe : describe.skip; - let administrativePool: Pool; - let runtimeRole = ''; - let unauthorizedRole = ''; - - function requireDatabaseUrl(): string { - if (!DATABASE_URL) { - throw new Error( - 'NOTIFICATION_DATABASE_URL is required for integration tests', - ); - } - return DATABASE_URL; - } - - function roleIdentifier(value: string): string { - if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(value)) { - throw new Error('Generated PostgreSQL role identifier is invalid'); - } - return `"${value}"`; - } - - async function migration(name: string): Promise { - return await readFile( - resolve(__dirname, '../migrations', name), - 'utf8', - ); - } - - async function queryAsRole( - role: string, - text: string, - values: readonly unknown[] = [], - statementTimeoutMs = 5_000, - ): Promise { - const client = await administrativePool.connect(); - try { - await client.query('BEGIN'); - await client.query(`SET LOCAL ROLE ${roleIdentifier(role)}`); - await client.query( - `SET LOCAL statement_timeout = '${statementTimeoutMs}ms'`, - ); - const result = await client.query(text, [...values]); - await client.query('COMMIT'); - return result; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async function applyMigrations(): Promise { - const client = await administrativePool.connect(); - try { - await client.query( - "SELECT set_config('life_os.notification_runtime_role', $1, false)", - [runtimeRole], - ); - await client.query( - await migration('0001_durable_reminder_inbox.sql'), - ); - await client.query( - await migration('0002_data_rights_erasure.sql'), - ); - } finally { - client.release(); - } - } - - async function seedWorkspace( - workspaceId: string, - ): Promise> { - const reminderId = randomUUID(); - const outcomeId = randomUUID(); - const messageId = randomUUID(); - await administrativePool.query( - `INSERT INTO notification_service.reminder_occurrences ( - reminder_id, workspace_id, reminder_title, due_instant, - time_zone, daily_delivery_limit - ) VALUES ($1, $2, 'Portable reminder', TIMESTAMPTZ '2026-08-12 00:00:00+00', - 'Asia/Seoul', 4)`, - [reminderId, workspaceId], - ); - await administrativePool.query( - `INSERT INTO notification_service.reminder_outcomes ( - outcome_id, workspace_id, reminder_id, outcome_kind, - occurred_at, idempotency_key_hash, delivery_local_date - ) VALUES ($1, $2, $3, 'delivered', - TIMESTAMPTZ '2026-08-12 00:01:00+00', - decode(repeat('ab', 32), 'hex'), DATE '2026-08-12')`, - [outcomeId, workspaceId, reminderId], - ); - await administrativePool.query( - `INSERT INTO notification_service.inbox_messages ( - message_id, workspace_id, reminder_id, message_title, - due_instant, time_zone, idempotency_key_hash, delivered_at - ) VALUES ($1, $2, $3, 'Portable reminder', - TIMESTAMPTZ '2026-08-12 00:00:00+00', 'Asia/Seoul', - decode(repeat('cd', 32), 'hex'), - TIMESTAMPTZ '2026-08-12 00:01:00+00')`, - [messageId, workspaceId, reminderId], - ); - return { reminderId, outcomeId, messageId }; - } - - describeWithPostgres( - 'Notification data-rights PostgreSQL integration', - () => { - beforeAll(async () => { - administrativePool = new Pool({ - connectionString: requireDatabaseUrl(), - application_name: - 'life-os-notification-data-rights-integration-admin', - max: 8, - }); - }); - - beforeEach(async () => { - const suffix = randomUUID().replaceAll('-', '').slice(0, 12); - runtimeRole = `life_notification_runtime_${suffix}`; - unauthorizedRole = `life_notification_unauthorized_${suffix}`; - await administrativePool.query( - 'DROP SCHEMA IF EXISTS notification_service CASCADE', - ); - await administrativePool.query( - `CREATE ROLE ${roleIdentifier(runtimeRole)} NOLOGIN`, - ); - await administrativePool.query( - `CREATE ROLE ${roleIdentifier(unauthorizedRole)} NOLOGIN`, - ); - await applyMigrations(); - }); - - afterEach(async () => { - await administrativePool.query( - 'DROP SCHEMA IF EXISTS notification_service CASCADE', - ); - await administrativePool.query( - `DROP ROLE IF EXISTS ${roleIdentifier(runtimeRole)}`, - ); - await administrativePool.query( - `DROP ROLE IF EXISTS ${roleIdentifier(unauthorizedRole)}`, - ); - }); - - afterAll(async () => { - await administrativePool.end(); - }); - - it('grants the configured role and proves erase, replay, conflict, UUID, trigger, and FK behavior', async () => { - const workspaceId = randomUUID(); - const preservedWorkspaceId = randomUUID(); - const requestedByUserId = randomUUID(); - const requestId = randomUUID(); - const idempotencyKey = randomUUID(); - const preserved = await seedWorkspace(preservedWorkspaceId); - await seedWorkspace(workspaceId); - - const privileges = await administrativePool.query( - `SELECT - has_schema_privilege($1, 'notification_service', 'USAGE') AS schema_ready, - has_table_privilege( - $1, - 'notification_service.data_rights_erasure_receipts', - 'SELECT,INSERT' - ) AS receipt_ready, - has_function_privilege( - $1, - 'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)', - 'EXECUTE' - ) AS function_ready, - has_function_privilege( - $2, - 'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)', - 'EXECUTE' - ) AS unauthorized_function`, - [runtimeRole, unauthorizedRole], - ); - expect(privileges.rows).toEqual([ - { - schema_ready: true, - receipt_ready: true, - function_ready: true, - unauthorized_function: false, - }, - ]); - - const first = await queryAsRole( - runtimeRole, - `SELECT result_erased_records, result_receipt_sha256 - FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, - [workspaceId, requestedByUserId, requestId, idempotencyKey], - ); - expect(first.rows).toEqual([ - { - result_erased_records: 3, - result_receipt_sha256: expect.stringMatching( - /^[0-9a-f]{64}$/u, - ), - }, - ]); - - const replay = await queryAsRole( - runtimeRole, - `SELECT result_erased_records, result_receipt_sha256 - FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, - [workspaceId, requestedByUserId, requestId, idempotencyKey], - ); - expect(replay.rows).toEqual(first.rows); - - await expect( - queryAsRole( - runtimeRole, - `SELECT * - FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, - [ - workspaceId, - requestedByUserId, - randomUUID(), - idempotencyKey, - ], - ), - ).rejects.toMatchObject({ code: '23505' }); - - await expect( - queryAsRole( - runtimeRole, - `SELECT * - FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, - [ - '00000000-0000-1000-8000-000000000001', - requestedByUserId, - randomUUID(), - randomUUID(), - ], - ), - ).rejects.toMatchObject({ code: '22023' }); - - await expect( - queryAsRole( - unauthorizedRole, - `SELECT * - FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, - [ - preservedWorkspaceId, - requestedByUserId, - randomUUID(), - randomUUID(), - ], - ), - ).rejects.toMatchObject({ code: '42501' }); - - const remaining = await administrativePool.query( - `SELECT - (SELECT count(*)::integer - FROM notification_service.reminder_occurrences - WHERE workspace_id = $1) AS target_occurrences, - (SELECT count(*)::integer - FROM notification_service.reminder_outcomes - WHERE workspace_id = $1) AS target_outcomes, - (SELECT count(*)::integer - FROM notification_service.inbox_messages - WHERE workspace_id = $1) AS target_messages, - (SELECT count(*)::integer - FROM notification_service.reminder_occurrences - WHERE workspace_id = $2) AS preserved_occurrences`, - [workspaceId, preservedWorkspaceId], - ); - expect(remaining.rows).toEqual([ - { - target_occurrences: 0, - target_outcomes: 0, - target_messages: 0, - preserved_occurrences: 1, - }, - ]); - - await expect( - administrativePool.query( - `DELETE FROM notification_service.reminder_outcomes - WHERE outcome_id = $1`, - [preserved.outcomeId], - ), - ).rejects.toMatchObject({ code: '55000' }); - }); - - it('does not acquire an ACCESS EXCLUSIVE table lock during owner-authorized erasure', async () => { - const workspaceId = randomUUID(); - await seedWorkspace(workspaceId); - const reader = await administrativePool.connect(); - try { - await reader.query('BEGIN'); - await reader.query( - `SELECT outcome_id - FROM notification_service.reminder_outcomes - WHERE workspace_id = $1`, - [workspaceId], - ); - - await expect( - queryAsRole( - runtimeRole, - `SELECT * - FROM notification_service.erase_workspace_data($1, $2, $3, $4)`, - [ - workspaceId, - randomUUID(), - randomUUID(), - randomUUID(), - ], - 1_500, - ), - ).resolves.toMatchObject({ - rows: [ - { - result_erased_records: 3, - result_receipt_sha256: expect.stringMatching( - /^[0-9a-f]{64}$/u, - ), - }, - ], - }); - } finally { - await reader.query('ROLLBACK'); - reader.release(); - } - }); - }, - ); - """ - Path( - 'apps/notification-service/src/notification-data-rights.integration.test.ts' - ).write_text(integration, encoding='utf-8') - PY - - - name: Verify the regressions fail before implementation - run: | - set -Eeuo pipefail - set +e - pnpm --filter @life-os/notification-service exec vitest run \ - src/notification-data-rights.behavior.test.ts \ - src/notification-data-rights-migration.test.ts \ - src/notification-data-rights.integration.test.ts \ - --no-file-parallelism >"$RUNNER_TEMP/notification-red.log" 2>&1 - status="$?" - set -e - cat "$RUNNER_TEMP/notification-red.log" - test "$status" -ne 0 - grep -F 'exports code-unit canonical evidence independent of key insertion order' \ - "$RUNNER_TEMP/notification-red.log" - grep -F 'grants only the configured runtime role the bounded erasure surface' \ - "$RUNNER_TEMP/notification-red.log" - - - name: Implement deterministic, least-privilege, nonblocking erasure - run: | - set -Eeuo pipefail - python3 - <<'PY' - from pathlib import Path - import re - import textwrap - - source_path = Path( - 'apps/notification-service/src/notification-data-rights.ts' - ) - source = source_path.read_text(encoding='utf-8') - old_sort = ( - " entries.sort(([left], [right]) => left.localeCompare(right));" - ) - new_sort = """ entries.sort(([left], [right]) => - left < right ? -1 : 1, - );""" - if source.count(old_sort) != 1: - raise SystemExit('unexpected canonical key-sort shape') - source = source.replace(old_sort, new_sort, 1) - - old_helper = r"""/** Validates one JSON-safe value and returns the same value with a narrowed type. */ - function requireJsonValue(value: unknown): NotificationDataRightsJsonValue { - canonicalJson(value); - return value as NotificationDataRightsJsonValue; - } - - """ - if source.count(old_helper) != 1: - raise SystemExit('unexpected JSON-value helper shape') - source = source.replace(old_helper, '', 1) - - data_pattern = re.compile( - r""" const data = Object\.freeze\(\{\n""" - r"""\s+reminderOccurrences: requireJsonValue\(row\.reminder_occurrences\),\n""" - r"""\s+reminderOutcomes: requireJsonValue\(row\.reminder_outcomes\),\n""" - r"""\s+inboxMessages: requireJsonValue\(row\.inbox_messages\),\n""" - r"""\s+\}\);\n""" - ) - new_data = ( - " const data = Object.freeze({\n" - " reminderOccurrences:\n" - " row.reminder_occurrences as readonly NotificationDataRightsJsonValue[],\n" - " reminderOutcomes:\n" - " row.reminder_outcomes as readonly NotificationDataRightsJsonValue[],\n" - " inboxMessages:\n" - " row.inbox_messages as readonly NotificationDataRightsJsonValue[],\n" - " });\n" - ) - source, data_replacements = data_pattern.subn(new_data, source, count=1) - if data_replacements != 1: - raise SystemExit('unexpected export data shape') - source_path.write_text(source, encoding='utf-8') - - migration_path = Path( - 'apps/notification-service/migrations/0002_data_rights_erasure.sql' - ) - migration = migration_path.read_text(encoding='utf-8') - - lock_start = migration.index(" PERFORM pg_advisory_xact_lock(\n") - lock_end = migration.index("\n\n SELECT\n", lock_start) - current_lock = migration[lock_start:lock_end] - if "target_idempotency_key::text" not in current_lock: - raise SystemExit('unexpected notification advisory-lock shape') - new_lock = textwrap.dedent( - """\ - PERFORM pg_advisory_xact_lock( - hashtextextended( - 'notification.service:erase:' || target_workspace_id::text, - 0 - ) - );""" - ) - migration = migration[:lock_start] + " " + new_lock + migration[lock_end:] - - delete_start = migration.index( - " DELETE FROM notification_service.inbox_messages\n" - ) - occurrence_start = migration.index( - " DELETE FROM notification_service.reminder_occurrences\n", - delete_start, - ) - current_delete = migration[delete_start:occurrence_start] - if "DISABLE TRIGGER reminder_outcomes_row_mutation_guard" not in current_delete: - raise SystemExit('unexpected notification trigger-bypass shape') - new_delete = textwrap.dedent( - """\ - DELETE FROM notification_service.inbox_messages - WHERE workspace_id = target_workspace_id; - GET DIAGNOSTICS deleted_inbox_messages = ROW_COUNT; - - -- The append-only trigger accepts this workspace only while the owner-executed - -- SECURITY DEFINER function is active. The marker is transaction-local and an - -- ordinary runtime role cannot satisfy the independent current_user owner check. - PERFORM set_config( - 'life_os.notification_erasure_workspace', - target_workspace_id::text, - true - ); - - DELETE FROM notification_service.reminder_outcomes - WHERE workspace_id = target_workspace_id; - GET DIAGNOSTICS deleted_reminder_outcomes = ROW_COUNT; - - PERFORM set_config( - 'life_os.notification_erasure_workspace', - '', - true - ); - - """ - ) - indented_delete = "\n".join( - f" {line}" if line else "" - for line in new_delete.splitlines() - ) - migration = ( - migration[:delete_start] - + indented_delete - + "\n" - + migration[occurrence_start:] - ) - - revoke_start = migration.index( - "REVOKE ALL ON FUNCTION notification_service.erase_workspace_data(" - ) - comment_start = migration.index( - "COMMENT ON FUNCTION notification_service.erase_workspace_data(", - revoke_start, - ) - hardening = textwrap.dedent( - """\ - CREATE OR REPLACE FUNCTION notification_service.reject_reminder_outcome_mutation() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - DECLARE - authorized_workspace text; - erasure_function_owner name; - BEGIN - IF TG_OP = 'DELETE' THEN - authorized_workspace := current_setting( - 'life_os.notification_erasure_workspace', - true - ); - SELECT pg_get_userbyid(proowner) - INTO erasure_function_owner - FROM pg_proc - WHERE oid = - 'notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'::regprocedure; - - IF current_user = erasure_function_owner - AND authorized_workspace = OLD.workspace_id::text - THEN - RETURN OLD; - END IF; - END IF; - - RAISE EXCEPTION 'reminder outcomes are immutable' - USING ERRCODE = '55000'; - END; - $$; - - REVOKE ALL ON FUNCTION notification_service.erase_workspace_data( - uuid, - uuid, - uuid, - uuid - ) FROM PUBLIC; - - DO $grant_notification_runtime$ - DECLARE - runtime_role_name text := NULLIF( - current_setting('life_os.notification_runtime_role', true), - '' - ); - BEGIN - IF runtime_role_name IS NULL - OR runtime_role_name !~ '^[a-z_][a-z0-9_]{0,62}$' - THEN - RAISE EXCEPTION USING - ERRCODE = '22023', - MESSAGE = 'Notification runtime role configuration is invalid'; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_roles WHERE rolname = runtime_role_name - ) THEN - RAISE EXCEPTION USING - ERRCODE = '42704', - MESSAGE = 'Notification runtime role does not exist'; - END IF; - - EXECUTE format( - 'GRANT USAGE ON SCHEMA notification_service TO %I', - runtime_role_name - ); - EXECUTE format( - 'GRANT SELECT, INSERT ON TABLE notification_service.data_rights_erasure_receipts TO %I', - runtime_role_name - ); - EXECUTE format( - 'GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid) TO %I', - runtime_role_name - ); - END; - $grant_notification_runtime$; - - """ - ) - migration = ( - migration[:revoke_start] - + hardening - + migration[comment_start:] - ) - migration_path.write_text(migration, encoding='utf-8') - - docs_path = Path('docs/operations/notification-persistence.md') - docs = docs_path.read_text(encoding='utf-8') - migration_sentence = ( - "Apply `apps/notification-service/migrations/" - "0001_durable_reminder_inbox.sql` before starting a runtime that " - "uses `PostgresReminderRepository`." - ) - replacement_sentence = ( - "Apply `apps/notification-service/migrations/" - "0001_durable_reminder_inbox.sql` and then " - "`0002_data_rights_erasure.sql` before starting a runtime that " - "exposes the Notification data-rights contributor." - ) - if docs.count(migration_sentence) != 1: - raise SystemExit('unexpected notification migration documentation') - docs = docs.replace(migration_sentence, replacement_sentence, 1) - operations_anchor = ( - "Run the migration through the normal release migration job using a " - "role with schema DDL rights. The application role should receive only " - "the table and sequence privileges required by the repository. Do not " - "grant the application role ownership of the schema or the " - "mutation-guard function.\n" - ) - operations_replacement = operations_anchor + r""" - Before applying `0002_data_rights_erasure.sql`, the same migration session - must set the reviewed runtime role name in the transaction-independent - PostgreSQL setting `life_os.notification_runtime_role`. The migration - fails closed when the setting is missing, malformed, or names a role that - does not exist. It revokes public function access and grants that exact - role only schema `USAGE`, receipt `SELECT`/`INSERT`, and `EXECUTE` on the - owner-controlled erasure function. Never set the value from request data. - - The erasure function serializes requests by workspace, not by idempotency - key. It authorizes immutable outcome deletion with a transaction-local - workspace marker plus an independent function-owner check; it never - disables a trigger or takes an `ACCESS EXCLUSIVE` lock merely to erase one - tenant. Direct deletion remains rejected even if an ordinary runtime - session writes the custom setting. - """ - if docs.count(operations_anchor) != 1: - raise SystemExit('unexpected notification operations anchor') - docs = docs.replace(operations_anchor, operations_replacement, 1) - docs_path.write_text(docs, encoding='utf-8') - PY - - pnpm exec prettier --single-quote --write \ - apps/notification-service/src/notification-data-rights.ts \ - apps/notification-service/src/notification-data-rights.behavior.test.ts \ - apps/notification-service/src/notification-data-rights-migration.test.ts \ - apps/notification-service/src/notification-data-rights.integration.test.ts \ - docs/operations/notification-persistence.md - - - name: Verify focused and package evidence - run: | - set -Eeuo pipefail - pnpm --filter @life-os/notification-service exec vitest run \ - src/notification-data-rights.behavior.test.ts \ - src/notification-data-rights-migration.test.ts \ - src/notification-data-rights.integration.test.ts \ - --no-file-parallelism - pnpm --filter @life-os/notification-service run lint - pnpm --filter @life-os/notification-service run typecheck - pnpm --filter @life-os/notification-service run test - pnpm --filter @life-os/notification-service run build - - - name: Verify current-main whole-repository gates - run: | - set -Eeuo pipefail - pnpm format:check - pnpm lint - pnpm typecheck - pnpm test - pnpm build - docker compose config --quiet - git diff --check - - - name: Commit verified repair and remove temporary workflow - run: | - set -Eeuo pipefail - git rm .github/workflows/repair-notification-data-rights.yml - git diff --check - actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" - expected="$(printf '%s\n' \ - '.github/workflows/repair-notification-data-rights.yml' \ - 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ - 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ - 'apps/notification-service/src/notification-data-rights.behavior.test.ts' \ - 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ - 'apps/notification-service/src/notification-data-rights.ts' \ - 'docs/operations/notification-persistence.md' \ - | LC_ALL=C sort)" - test "$actual" = "$expected" - git add \ - apps/notification-service/migrations/0002_data_rights_erasure.sql \ - apps/notification-service/src/notification-data-rights-migration.test.ts \ - apps/notification-service/src/notification-data-rights.behavior.test.ts \ - apps/notification-service/src/notification-data-rights.integration.test.ts \ - apps/notification-service/src/notification-data-rights.ts \ - docs/operations/notification-persistence.md - git commit -m 'fix(notification): harden data-rights erasure authority' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/main)" = '7c3fd32efbf9ebdcb4bac99980a3c8b6c893a89f' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" - git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From fefe8598b7336d8a0e36dfd5468205af889b3d55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:04:13 +0900 Subject: [PATCH 021/150] chore(notification): remove temporary finalize workflow --- .../finalize-notification-data-rights.yml | 244 ------------------ 1 file changed, 244 deletions(-) delete mode 100644 .github/workflows/finalize-notification-data-rights.yml diff --git a/.github/workflows/finalize-notification-data-rights.yml b/.github/workflows/finalize-notification-data-rights.yml deleted file mode 100644 index ee8b03f2..00000000 --- a/.github/workflows/finalize-notification-data-rights.yml +++ /dev/null @@ -1,244 +0,0 @@ -name: Finalize notification data-rights repair - -on: - push: - branches: - - feat/notification-data-rights-contributor-v2 - paths: - - .github/workflows/finalize-notification-data-rights.yml - -permissions: {} - -concurrency: - group: finalize-notification-data-rights-${{ github.ref }} - cancel-in-progress: true - -jobs: - finalize: - runs-on: ubuntu-24.04 - timeout-minutes: 55 - permissions: - contents: write - env: - NOTIFICATION_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 feature branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: feat/notification-data-rights-contributor-v2 - fetch-depth: 0 - - - name: Reconcile protected main and branch authority - env: - EXPECTED_PARENT: 2a021b4c173bde3590e79eb634c87811928fccd6 - run: | - set -Eeuo pipefail - test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" - git merge --no-edit --no-ff origin/main - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - - name: Install locked dependencies - run: | - set -Eeuo pipefail - corepack enable - pnpm install --frozen-lockfile - - - name: Materialize reviewed test-first repair - run: | - set -Eeuo pipefail - python3 - <<'PY' - from pathlib import Path - import re - - workflow = Path( - '.github/workflows/repair-notification-data-rights.yml' - ).read_text(encoding='utf-8') - - def run_step(name: str) -> str: - marker = f" - name: {name}\n" - start = workflow.index(marker) - run_start = workflow.index(" run: |\n", start) - content_start = run_start + len(" run: |\n") - next_step = workflow.find("\n - name:", content_start) - if next_step == -1: - next_step = len(workflow) - lines = workflow[content_start:next_step].splitlines() - return "\n".join( - line[10:] if line.startswith(" ") else line - for line in lines - ) + "\n" - - add_tests = run_step('Add failing regression evidence') - behavior_start = add_tests.index("behavior_path = Path(\n") - behavior_end = add_tests.index( - 'migration_contract = r"""', - behavior_start, - ) - add_tests = add_tests[:behavior_start] + add_tests[behavior_end:] - - replacements = [ - ( - re.compile( - r"expect\(sql\)\.toContain\(\s*[\"']set_config\('life_os\.notification_erasure_workspace'[\"']\s*,\s*\);", - re.MULTILINE, - ), - "expect(sql).toMatch(\n" - " /set_config\\(\\s*'life_os\\.notification_erasure_workspace'/u,\n" - " );", - 'transaction-local marker', - ), - ( - re.compile( - r"expect\(sql\)\.toContain\(\s*[\"']current_setting\('life_os\.notification_erasure_workspace', true\)[\"']\s*,\s*\);", - re.MULTILINE, - ), - "expect(sql).toMatch(\n" - " /current_setting\\(\\s*'life_os\\.notification_erasure_workspace',\\s*true\\s*\\)/u,\n" - " );", - 'owner-bound marker', - ), - ] - for pattern, replacement, label in replacements: - add_tests, count = pattern.subn( - lambda _match, value=replacement: value, - add_tests, - count=1, - ) - if count != 1: - raise SystemExit(f'unexpected {label} assertion') - - verify_red = run_step( - 'Verify the regressions fail before implementation' - ) - obsolete_grep = ( - "grep -F 'exports code-unit canonical evidence independent " - "of key insertion order' \\\n" - ' "$RUNNER_TEMP/notification-red.log"\n' - ) - if verify_red.count(obsolete_grep) != 1: - raise SystemExit('unexpected obsolete deterministic-test grep') - verify_red = verify_red.replace(obsolete_grep, '', 1) - - implement = run_step( - 'Implement deterministic, least-privilege, nonblocking erasure' - ) - source_start = implement.index("source_path = Path(\n") - source_end = implement.index( - "\nmigration_path = Path(\n", - source_start, - ) - source_patch = r'''source_path = Path( - 'apps/notification-service/src/notification-data-rights.ts' - ) - source = source_path.read_text(encoding='utf-8') - comparator_pattern = re.compile( - r" entries\.sort\(\(\[left\], \[right\]\) => \{\n" - r"(?: .*\n)+?" - r" \}\);", - ) - source, comparator_replacements = comparator_pattern.subn( - " entries.sort(([left], [right]) =>\n" - " left < right ? -1 : 1,\n" - " );", - source, - count=1, - ) - if comparator_replacements != 1: - raise SystemExit('unexpected canonical comparator shape') - source_path.write_text(source, encoding='utf-8') - ''' - implement = ( - implement[:source_start] - + source_patch - + implement[source_end + 1:] - ) - - focused = run_step('Verify focused and package evidence') - command = ( - ' src/notification-data-rights.integration.test.ts \\\n' - ' --no-file-parallelism\n' - ) - replacement = ( - ' src/notification-data-rights.integration.test.ts \\\n' - ' --no-file-parallelism --coverage.enabled=false\n' - ) - if focused.count(command) != 1: - raise SystemExit('unexpected focused verification command') - focused = focused.replace(command, replacement, 1) - - phases = { - '01-add-tests.sh': add_tests, - '02-verify-red.sh': verify_red, - '03-implement.sh': implement, - '04-verify-focused.sh': focused, - '05-verify-repository.sh': run_step( - 'Verify current-main whole-repository gates' - ), - } - output = Path('/tmp/notification-finalization') - output.mkdir(mode=0o700) - for name, content in phases.items(): - phase = output / name - phase.write_text(content, encoding='utf-8') - phase.chmod(0o700) - PY - - - name: Execute repair and all validation gates - run: | - set -Eeuo pipefail - for phase in /tmp/notification-finalization/*.sh; do - printf 'notification_phase=%s\n' "$(basename "$phase")" - bash "$phase" - done - - - name: Commit only verified production evidence - run: | - set -Eeuo pipefail - git diff --check - actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" - expected="$(printf '%s\n' \ - 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ - 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ - 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ - 'apps/notification-service/src/notification-data-rights.ts' \ - 'docs/operations/notification-persistence.md' \ - | LC_ALL=C sort)" - test "$actual" = "$expected" - git add \ - apps/notification-service/migrations/0002_data_rights_erasure.sql \ - apps/notification-service/src/notification-data-rights-migration.test.ts \ - apps/notification-service/src/notification-data-rights.integration.test.ts \ - apps/notification-service/src/notification-data-rights.ts \ - docs/operations/notification-persistence.md - git commit -m 'fix(notification): harden data-rights erasure authority' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" - git merge-base --is-ancestor origin/main HEAD - git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From 989d802021de2f00f72b52eed0b6a580dd0700ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:05:18 +0900 Subject: [PATCH 022/150] chore(notification): remove temporary executor workflow --- ...xecute-notification-data-rights-repair.yml | 252 ------------------ 1 file changed, 252 deletions(-) delete mode 100644 .github/workflows/execute-notification-data-rights-repair.yml diff --git a/.github/workflows/execute-notification-data-rights-repair.yml b/.github/workflows/execute-notification-data-rights-repair.yml deleted file mode 100644 index a796f9cd..00000000 --- a/.github/workflows/execute-notification-data-rights-repair.yml +++ /dev/null @@ -1,252 +0,0 @@ -name: Execute notification data-rights repair - -on: - push: - branches: - - feat/notification-data-rights-contributor-v2 - paths: - - .github/workflows/execute-notification-data-rights-repair.yml - -permissions: {} - -concurrency: - group: execute-notification-data-rights-repair-${{ github.ref }} - cancel-in-progress: true - -jobs: - execute: - runs-on: ubuntu-24.04 - timeout-minutes: 50 - permissions: - contents: write - env: - NOTIFICATION_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 feature branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: feat/notification-data-rights-contributor-v2 - fetch-depth: 0 - - - name: Reconcile protected main and branch authority - env: - EXPECTED_PARENT: 121279ce6eb131e1638b96c7a1343472cd78942b - run: | - set -Eeuo pipefail - test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" - git merge --no-edit --no-ff origin/main - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - - name: Enable Corepack and install locked dependencies - run: | - set -Eeuo pipefail - corepack enable - pnpm install --frozen-lockfile - - - name: Materialize reviewed repair phases - run: | - set -Eeuo pipefail - python3 - <<'PY' - from pathlib import Path - import re - - workflow = Path( - '.github/workflows/repair-notification-data-rights.yml' - ).read_text(encoding='utf-8') - - def run_step(name: str) -> str: - marker = f" - name: {name}\n" - start = workflow.index(marker) - run_start = workflow.index(" run: |\n", start) - content_start = run_start + len(" run: |\n") - next_step = workflow.find("\n - name:", content_start) - if next_step == -1: - next_step = len(workflow) - lines = workflow[content_start:next_step].splitlines() - return "\n".join( - line[10:] if line.startswith(" ") else line - for line in lines - ) + "\n" - - add_tests = run_step('Add failing regression evidence') - behavior_start = add_tests.index("behavior_path = Path(\n") - behavior_end = add_tests.index( - 'migration_contract = r"""', - behavior_start, - ) - add_tests = add_tests[:behavior_start] + add_tests[behavior_end:] - - marker_assertion = re.compile( - r"expect\(sql\)\.toContain\(\s*[\"']set_config\('life_os\.notification_erasure_workspace'[\"']\s*,\s*\);", - re.MULTILINE, - ) - marker_replacement = ( - "expect(sql).toMatch(\n" - " /set_config\\(\\s*'life_os\\.notification_erasure_workspace'/u,\n" - " );" - ) - add_tests, marker_replacements = marker_assertion.subn( - lambda _match: marker_replacement, - add_tests, - count=1, - ) - if marker_replacements != 1: - raise SystemExit('unexpected transaction-local marker assertion') - - owner_assertion = re.compile( - r"expect\(sql\)\.toContain\(\s*[\"']current_setting\('life_os\.notification_erasure_workspace', true\)[\"']\s*,\s*\);", - re.MULTILINE, - ) - owner_replacement = ( - "expect(sql).toMatch(\n" - " /current_setting\\(\\s*'life_os\\.notification_erasure_workspace',\\s*true\\s*\\)/u,\n" - " );" - ) - add_tests, owner_replacements = owner_assertion.subn( - lambda _match: owner_replacement, - add_tests, - count=1, - ) - if owner_replacements != 1: - raise SystemExit('unexpected owner-bound marker assertion') - - verify_red = run_step( - 'Verify the regressions fail before implementation' - ) - obsolete_grep = ( - "grep -F 'exports code-unit canonical evidence independent " - "of key insertion order' \\\n" - ' "$RUNNER_TEMP/notification-red.log"\n' - ) - if verify_red.count(obsolete_grep) != 1: - raise SystemExit('unexpected obsolete deterministic-test grep') - verify_red = verify_red.replace(obsolete_grep, '', 1) - - implement = run_step( - 'Implement deterministic, least-privilege, nonblocking erasure' - ) - source_start = implement.index("source_path = Path(\n") - source_end = implement.index( - "\nmigration_path = Path(\n", - source_start, - ) - comparator_patch = r'''source_path = Path( - 'apps/notification-service/src/notification-data-rights.ts' - ) - source = source_path.read_text(encoding='utf-8') - old_sort = """ entries.sort(([left], [right]) => { - if (left < right) { - return -1; - } - if (left > right) { - return 1; - } - return 0; - });""" - new_sort = """ entries.sort(([left], [right]) => - left < right ? -1 : 1, - );""" - if source.count(old_sort) != 1: - raise SystemExit('unexpected canonical comparator shape') - source = source.replace(old_sort, new_sort, 1) - source_path.write_text(source, encoding='utf-8') - ''' - implement = ( - implement[:source_start] - + comparator_patch - + implement[source_end + 1:] - ) - - focused = run_step('Verify focused and package evidence') - focused_command = ( - ' src/notification-data-rights.integration.test.ts \\\n' - ' --no-file-parallelism\n' - ) - focused_replacement = ( - ' src/notification-data-rights.integration.test.ts \\\n' - ' --no-file-parallelism --coverage.enabled=false\n' - ) - if focused.count(focused_command) != 1: - raise SystemExit('unexpected focused verification command') - focused = focused.replace( - focused_command, - focused_replacement, - 1, - ) - - phases = { - '01-add-tests.sh': add_tests, - '02-verify-red.sh': verify_red, - '03-implement.sh': implement, - '04-verify-focused.sh': focused, - '05-verify-repository.sh': run_step( - 'Verify current-main whole-repository gates' - ), - } - output = Path('/tmp/notification-repair-phases') - output.mkdir(mode=0o700) - for name, content in phases.items(): - phase = output / name - phase.write_text(content, encoding='utf-8') - phase.chmod(0o700) - PY - - - name: Execute test-first repair and verification - run: | - set -Eeuo pipefail - for phase in /tmp/notification-repair-phases/*.sh; do - printf 'repair_phase=%s\n' "$(basename "$phase")" - bash "$phase" - done - - - name: Commit production repair without workflow mutation - run: | - set -Eeuo pipefail - git diff --check - actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" - expected="$(printf '%s\n' \ - 'apps/notification-service/migrations/0002_data_rights_erasure.sql' \ - 'apps/notification-service/src/notification-data-rights-migration.test.ts' \ - 'apps/notification-service/src/notification-data-rights.integration.test.ts' \ - 'apps/notification-service/src/notification-data-rights.ts' \ - 'docs/operations/notification-persistence.md' \ - | LC_ALL=C sort)" - test "$actual" = "$expected" - git add \ - apps/notification-service/migrations/0002_data_rights_erasure.sql \ - apps/notification-service/src/notification-data-rights-migration.test.ts \ - apps/notification-service/src/notification-data-rights.integration.test.ts \ - apps/notification-service/src/notification-data-rights.ts \ - docs/operations/notification-persistence.md - git commit -m 'fix(notification): harden data-rights erasure authority' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" - git merge-base --is-ancestor origin/main HEAD - git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From d58628a69911fe5c6ba30ecd01774fd1d1896ea1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:06:22 +0900 Subject: [PATCH 023/150] chore(notification): remove temporary kick workflow --- .../kick-notification-data-rights-repair.yml | 142 ------------------ 1 file changed, 142 deletions(-) delete mode 100644 .github/workflows/kick-notification-data-rights-repair.yml diff --git a/.github/workflows/kick-notification-data-rights-repair.yml b/.github/workflows/kick-notification-data-rights-repair.yml deleted file mode 100644 index ff5a6030..00000000 --- a/.github/workflows/kick-notification-data-rights-repair.yml +++ /dev/null @@ -1,142 +0,0 @@ -name: Prepare notification data-rights repair - -on: - push: - branches: - - feat/notification-data-rights-contributor-v2 - paths: - - .github/workflows/kick-notification-data-rights-repair.yml - -permissions: {} - -concurrency: - group: prepare-notification-data-rights-repair-${{ github.ref }} - cancel-in-progress: true - -jobs: - prepare: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Checkout exact feature branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: feat/notification-data-rights-contributor-v2 - fetch-depth: 0 - - - name: Adapt repair workflow to the live branch - env: - EXPECTED_PARENT: 11a720de2206d9a2209bc16063bfb521c6a8d2f0 - run: | - set -Eeuo pipefail - test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" - export CURRENT_HEAD="$(git rev-parse HEAD)" - export CURRENT_MAIN="$(git rev-parse origin/main)" - python3 - <<'PY' - import os - from pathlib import Path - - path = Path('.github/workflows/repair-notification-data-rights.yml') - workflow = path.read_text(encoding='utf-8') - current_head = os.environ['CURRENT_HEAD'] - current_main = os.environ['CURRENT_MAIN'] - - workflow = workflow.replace( - "github.event.before == 'a558ab896894906a081c7e34efe842676da5ffd6'", - f"github.event.before == '{current_head}'", - 1, - ) - workflow = workflow.replace( - "test \"$(git rev-parse HEAD^)\" = 'a558ab896894906a081c7e34efe842676da5ffd6'", - f"test \"$(git rev-parse HEAD^)\" = '{current_head}'", - 1, - ) - workflow = workflow.replace( - "'7c3fd32efbf9ebdcb4bac99980a3c8b6c893a89f'", - f"'{current_main}'", - ) - - step_start = workflow.index( - " behavior_path = Path(\n", - workflow.index("- name: Add failing regression evidence"), - ) - step_end = workflow.index( - " migration_contract = r\"\"\"", - step_start, - ) - workflow = workflow[:step_start] + workflow[step_end:] - - removed_grep = ( - " grep -F 'exports code-unit canonical evidence " - "independent of key insertion order' \\\n" - " \"$RUNNER_TEMP/notification-red.log\"\n" - ) - if workflow.count(removed_grep) != 1: - raise SystemExit('unexpected obsolete deterministic-test grep') - workflow = workflow.replace(removed_grep, '', 1) - - old_sort = ( - " old_sort = (\n" - " \" entries.sort(([left], [right]) => left.localeCompare(right));\"\n" - " )\n" - " new_sort = \"\"\" entries.sort(([left], [right]) =>\n" - " left < right ? -1 : 1,\n" - " );\"\"\"\n" - ) - new_sort = ( - " old_sort = \"\"\" entries.sort(([left], [right]) => {\n" - " if (left < right) {\n" - " return -1;\n" - " }\n" - " if (left > right) {\n" - " return 1;\n" - " }\n" - " return 0;\n" - " });\"\"\"\n" - " new_sort = \"\"\" entries.sort(([left], [right]) =>\n" - " left < right ? -1 : 1,\n" - " );\"\"\"\n" - ) - if workflow.count(old_sort) != 1: - raise SystemExit('unexpected stale comparator patch') - workflow = workflow.replace(old_sort, new_sort, 1) - - workflow = workflow.replace( - " 'apps/notification-service/src/" - "notification-data-rights.behavior.test.ts' \\\n", - '', - 1, - ) - workflow = workflow.replace( - " apps/notification-service/src/" - "notification-data-rights.behavior.test.ts \\\n", - '', - 1, - ) - path.write_text(workflow, encoding='utf-8') - PY - - git rm .github/workflows/kick-notification-data-rights-repair.yml - git diff --check - actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" - expected="$(printf '%s\n' \ - '.github/workflows/kick-notification-data-rights-repair.yml' \ - '.github/workflows/repair-notification-data-rights.yml' \ - | LC_ALL=C sort)" - test "$actual" = "$expected" - git add .github/workflows/repair-notification-data-rights.yml - git commit -m 'ci(notification): adapt repair to live branch' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$CURRENT_HEAD" - test "$(git rev-parse origin/main)" = "$CURRENT_MAIN" - git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From ee4b05b42f0663aa1584fa091a25987f8ead7020 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:07:25 +0900 Subject: [PATCH 024/150] chore(notification): remove temporary patch workflow --- .../workflows/patch-notification-executor.yml | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 .github/workflows/patch-notification-executor.yml diff --git a/.github/workflows/patch-notification-executor.yml b/.github/workflows/patch-notification-executor.yml deleted file mode 100644 index b0d53fbb..00000000 --- a/.github/workflows/patch-notification-executor.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Patch notification repair executor - -on: - push: - branches: - - feat/notification-data-rights-contributor-v2 - paths: - - .github/workflows/patch-notification-executor.yml - -permissions: {} - -concurrency: - group: patch-notification-executor-${{ github.ref }} - cancel-in-progress: true - -jobs: - patch: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Checkout exact feature branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: feat/notification-data-rights-contributor-v2 - fetch-depth: 0 - - - name: Patch executor and remove bootstrap - env: - EXPECTED_PARENT: da8f4ad21002100fb4b573ca3806f7fbc216acfc - run: | - set -Eeuo pipefail - test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "$(git rev-parse HEAD)" - - python3 - <<'PY' - from pathlib import Path - - path = Path('.github/workflows/execute-notification-data-rights-repair.yml') - workflow = path.read_text(encoding='utf-8') - start = workflow.index(" comparator_patch = r'''source_path = Path(\n") - end = workflow.index("\n implement = (\n", start) - replacement = r""" comparator_patch = r'''source_path = Path( - 'apps/notification-service/src/notification-data-rights.ts' - ) - source = source_path.read_text(encoding='utf-8') - comparator_pattern = re.compile( - r" entries\.sort\(\(\[left\], \[right\]\) => \{\n" - r"(?: .*\n)+?" - r" \}\);", - ) - source, comparator_replacements = comparator_pattern.subn( - " entries.sort(([left], [right]) =>\n" - " left < right ? -1 : 1,\n" - " );", - source, - count=1, - ) - if comparator_replacements != 1: - raise SystemExit('unexpected canonical comparator shape') - source_path.write_text(source, encoding='utf-8') - '''""" - workflow = workflow[:start] + replacement + workflow[end:] - path.write_text(workflow, encoding='utf-8') - PY - - git rm .github/workflows/patch-notification-executor.yml - git diff --check - actual="$(git status --short | awk '{print $2}' | LC_ALL=C sort)" - expected="$(printf '%s\n' \ - '.github/workflows/execute-notification-data-rights-repair.yml' \ - '.github/workflows/patch-notification-executor.yml' \ - | LC_ALL=C sort)" - test "$actual" = "$expected" - git add .github/workflows/execute-notification-data-rights-repair.yml - git commit -m 'ci(notification): tolerate comparator formatting drift' - git fetch --no-tags origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/feat/notification-data-rights-contributor-v2:refs/remotes/origin/feat/notification-data-rights-contributor-v2' - test "$(git rev-parse origin/feat/notification-data-rights-contributor-v2)" = "${{ github.sha }}" - git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From fc8adc106299943e67aeb2b1236df37e0864e6dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:07:08 +0900 Subject: [PATCH 025/150] test(notification): prove data-rights PostgreSQL behavior --- ...tification-data-rights.integration.test.ts | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights.integration.test.ts diff --git a/apps/notification-service/src/notification-data-rights.integration.test.ts b/apps/notification-service/src/notification-data-rights.integration.test.ts new file mode 100644 index 00000000..f53f026e --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.integration.test.ts @@ -0,0 +1,245 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const DATABASE_URL = process.env.NOTIFICATION_DATABASE_URL; +const describeWithPostgres = DATABASE_URL ? describe : describe.skip; +let administrativePool: Pool; + +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error( + 'NOTIFICATION_DATABASE_URL is required for integration tests', + ); + } + return DATABASE_URL; +} + +async function applyMigrations(pool: Pool): Promise { + for (const migration of [ + '0001_durable_reminder_inbox.sql', + '0002_data_rights_erasure.sql', + ]) { + const sql = await readFile( + resolve(__dirname, '../migrations', migration), + 'utf8', + ); + await pool.query(sql); + } +} + +async function seedWorkspace( + pool: Pool, + workspaceId: string, +): Promise<{ readonly reminderId: string; readonly outcomeId: string }> { + const reminderId = randomUUID(); + const outcomeId = randomUUID(); + const messageId = randomUUID(); + + await pool.query( + `INSERT INTO notification_service.reminder_occurrences ( + reminder_id, + workspace_id, + reminder_title, + due_instant, + time_zone, + daily_delivery_limit, + delivery_attempt_count, + occurrence_status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + reminderId, + workspaceId, + 'Data-rights integration reminder', + '2026-08-12T00:00:00.000Z', + 'UTC', + 3, + 0, + 'pending', + ], + ); + + await pool.query( + `INSERT INTO notification_service.reminder_outcomes ( + outcome_id, + workspace_id, + reminder_id, + outcome_kind, + occurred_at, + idempotency_key_hash, + delivery_local_date + ) VALUES ($1, $2, $3, 'delivered', $4, decode(repeat('ab', 32), 'hex'), $5)`, + [ + outcomeId, + workspaceId, + reminderId, + '2026-08-12T00:00:01.000Z', + '2026-08-12', + ], + ); + + await pool.query( + `INSERT INTO notification_service.inbox_messages ( + message_id, + workspace_id, + reminder_id, + message_title, + due_instant, + time_zone, + idempotency_key_hash, + delivered_at + ) VALUES ($1, $2, $3, $4, $5, $6, decode(repeat('cd', 32), 'hex'), $7)`, + [ + messageId, + workspaceId, + reminderId, + 'Data-rights integration inbox message', + '2026-08-12T00:00:00.000Z', + 'UTC', + '2026-08-12T00:00:02.000Z', + ], + ); + + return { reminderId, outcomeId }; +} + +async function workspaceRecordCount( + pool: Pool, + workspaceId: string, +): Promise { + const result = await pool.query<{ record_count: string }>( + `SELECT ( + (SELECT count(*) FROM notification_service.reminder_occurrences WHERE workspace_id = $1) + + (SELECT count(*) FROM notification_service.reminder_outcomes WHERE workspace_id = $1) + + (SELECT count(*) FROM notification_service.inbox_messages WHERE workspace_id = $1) + )::text AS record_count`, + [workspaceId], + ); + return Number(result.rows[0]?.record_count ?? Number.NaN); +} + +describeWithPostgres('Notification data-rights PostgreSQL integration', () => { + beforeAll(async () => { + administrativePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-data-rights-admin', + max: 4, + }); + }); + + beforeEach(async () => { + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await applyMigrations(administrativePool); + }); + + afterAll(async () => { + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await administrativePool.end(); + }); + + it('erases one tenant, replays exactly, rejects conflicting authority, and preserves another tenant', async () => { + const workspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const requestedByUserId = randomUUID(); + const requestId = randomUUID(); + const idempotencyKey = randomUUID(); + await seedWorkspace(administrativePool, workspaceId); + const other = await seedWorkspace(administrativePool, otherWorkspaceId); + + await expect( + administrativePool.query( + 'DELETE FROM notification_service.reminder_outcomes WHERE outcome_id = $1', + [other.outcomeId], + ), + ).rejects.toMatchObject({ code: '55000' }); + + const first = await administrativePool.query<{ + result_erased_records: number; + result_receipt_sha256: string; + }>( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, requestedByUserId, requestId, idempotencyKey], + ); + expect(first.rows).toEqual([ + { + result_erased_records: 3, + result_receipt_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u), + }, + ]); + expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(0); + expect(await workspaceRecordCount(administrativePool, otherWorkspaceId)).toBe( + 3, + ); + + const replay = await administrativePool.query( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, requestedByUserId, requestId, idempotencyKey], + ); + expect(replay.rows).toEqual(first.rows); + + await expect( + administrativePool.query( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, requestedByUserId, randomUUID(), idempotencyKey], + ), + ).rejects.toMatchObject({ code: '23505' }); + + await expect( + administrativePool.query( + 'DELETE FROM notification_service.reminder_outcomes WHERE outcome_id = $1', + [other.outcomeId], + ), + ).rejects.toMatchObject({ code: '55000' }); + expect(await workspaceRecordCount(administrativePool, otherWorkspaceId)).toBe( + 3, + ); + }); + + it('rejects non-v4 erasure authority before changing tenant data', async () => { + const workspaceId = randomUUID(); + await seedWorkspace(administrativePool, workspaceId); + + await expect( + administrativePool.query( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [ + workspaceId, + randomUUID(), + '11111111-1111-1111-8111-111111111111', + randomUUID(), + ], + ), + ).rejects.toMatchObject({ code: '22023' }); + expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); + }); + + it('keeps the SECURITY DEFINER erasure function unavailable to an ungranted runtime role', async () => { + const roleName = `notification_test_${randomUUID().replaceAll('-', '')}`; + const workspaceId = randomUUID(); + await seedWorkspace(administrativePool, workspaceId); + + await administrativePool.query(`CREATE ROLE ${roleName} NOLOGIN`); + try { + await administrativePool.query( + `GRANT USAGE ON SCHEMA notification_service TO ${roleName}`, + ); + await administrativePool.query(`SET ROLE ${roleName}`); + await expect( + administrativePool.query( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, randomUUID(), randomUUID(), randomUUID()], + ), + ).rejects.toMatchObject({ code: '42501' }); + } finally { + await administrativePool.query('RESET ROLE'); + await administrativePool.query(`DROP ROLE IF EXISTS ${roleName}`); + } + expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); + }); +}); From ef4aadeac3116ed26c1861b54f583f187b1172d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:09:06 +0900 Subject: [PATCH 026/150] test(notification): require scoped erasure authorization --- ...notification-data-rights-migration.test.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-migration.test.ts b/apps/notification-service/src/notification-data-rights-migration.test.ts index 336a41a2..ffd3ff8a 100644 --- a/apps/notification-service/src/notification-data-rights-migration.test.ts +++ b/apps/notification-service/src/notification-data-rights-migration.test.ts @@ -40,7 +40,9 @@ describe('Notification data-rights erasure database contract', () => { expect(sql).toContain('SECURITY DEFINER'); expect(sql).toContain('SET search_path = pg_catalog, notification_service'); expect(sql).toContain('pg_advisory_xact_lock'); - expect(sql).toContain('hashtextextended'); + expect(sql).toContain( + "'notification.service:erase:' || target_workspace_id::text", + ); expect(sql).toContain('IF FOUND THEN'); expect(sql).toContain('Notification erasure replay authority conflicts'); expect(sql).toContain('sha256('); @@ -50,28 +52,35 @@ describe('Notification data-rights erasure database contract', () => { ); }); - it('deletes Notification-owned records in foreign-key-safe order and restores immutability', async () => { + it('keeps append-only outcome protection active during owner-authorized erasure', async () => { const sql = await migrationSql(); const inboxDelete = sql.indexOf( 'DELETE FROM notification_service.inbox_messages', ); - const outcomeDisable = sql.indexOf( - 'DISABLE TRIGGER reminder_outcomes_row_mutation_guard', + const authorizationInsert = sql.indexOf( + 'INSERT INTO notification_service.data_rights_erasure_authorizations', ); const outcomeDelete = sql.indexOf( 'DELETE FROM notification_service.reminder_outcomes', ); - const outcomeEnable = sql.indexOf( - 'ENABLE TRIGGER reminder_outcomes_row_mutation_guard', + const authorizationDelete = sql.indexOf( + 'DELETE FROM notification_service.data_rights_erasure_authorizations', ); const occurrenceDelete = sql.indexOf( 'DELETE FROM notification_service.reminder_occurrences', ); + expect(sql).toContain( + 'CREATE TABLE notification_service.data_rights_erasure_authorizations', + ); + expect(sql).toContain('pg_backend_pid()'); + expect(sql).toContain('pg_current_xact_id()'); + expect(sql).not.toContain('DISABLE TRIGGER'); + expect(sql).not.toContain('ENABLE TRIGGER reminder_outcomes_row_mutation_guard'); expect(inboxDelete).toBeGreaterThan(-1); - expect(outcomeDisable).toBeGreaterThan(inboxDelete); - expect(outcomeDelete).toBeGreaterThan(outcomeDisable); - expect(outcomeEnable).toBeGreaterThan(outcomeDelete); - expect(occurrenceDelete).toBeGreaterThan(outcomeEnable); + expect(authorizationInsert).toBeGreaterThan(inboxDelete); + expect(outcomeDelete).toBeGreaterThan(authorizationInsert); + expect(authorizationDelete).toBeGreaterThan(outcomeDelete); + expect(occurrenceDelete).toBeGreaterThan(authorizationDelete); }); }); From 35e0366152ddab9c31ad9722e0ab95e81f57fbd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:11:18 +0900 Subject: [PATCH 027/150] fix(notification): scope erasure authorization to transaction --- .../migrations/0002_data_rights_erasure.sql | 75 ++++++++++++++++--- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/apps/notification-service/migrations/0002_data_rights_erasure.sql b/apps/notification-service/migrations/0002_data_rights_erasure.sql index 672020b0..b8653281 100644 --- a/apps/notification-service/migrations/0002_data_rights_erasure.sql +++ b/apps/notification-service/migrations/0002_data_rights_erasure.sql @@ -37,6 +37,50 @@ CREATE TABLE notification_service.data_rights_erasure_receipts ( COMMENT ON TABLE notification_service.data_rights_erasure_receipts IS 'Replay evidence for explicitly authorized Notification-owned data-rights erasure.'; +CREATE TABLE notification_service.data_rights_erasure_authorizations ( + backend_process_id integer NOT NULL, + transaction_id xid8 NOT NULL, + workspace_id uuid NOT NULL, + authorized_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + CONSTRAINT notification_data_rights_erasure_authorizations_primary + PRIMARY KEY (backend_process_id, transaction_id, workspace_id), + CONSTRAINT notification_data_rights_authorizations_workspace_uuid_v4 CHECK ( + get_byte(uuid_send(workspace_id), 6) >> 4 = 4 + AND get_byte(uuid_send(workspace_id), 8) >> 6 = 2 + ) +); + +COMMENT ON TABLE notification_service.data_rights_erasure_authorizations IS + 'Owner-only transaction-local authorization consumed by Notification append-only outcome triggers.'; + +REVOKE ALL ON TABLE notification_service.data_rights_erasure_authorizations FROM PUBLIC; + +CREATE OR REPLACE FUNCTION notification_service.reject_reminder_outcome_mutation() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, notification_service +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF EXISTS ( + SELECT 1 + FROM notification_service.data_rights_erasure_authorizations + WHERE backend_process_id = pg_backend_pid() + AND transaction_id = pg_current_xact_id() + AND workspace_id = OLD.workspace_id + ) THEN + RETURN OLD; + END IF; + END IF; + + RAISE EXCEPTION 'reminder outcomes are immutable' + USING ERRCODE = '55000'; +END; +$$; + +REVOKE ALL ON FUNCTION notification_service.reject_reminder_outcome_mutation() FROM PUBLIC; + CREATE FUNCTION notification_service.erase_workspace_data( target_workspace_id uuid, target_requested_by_user_id uuid, @@ -83,9 +127,7 @@ BEGIN PERFORM pg_advisory_xact_lock( hashtextextended( - 'notification.service:' || - target_workspace_id::text || ':' || - target_idempotency_key::text, + 'notification.service:erase:' || target_workspace_id::text, 0 ) ); @@ -122,19 +164,30 @@ BEGIN WHERE workspace_id = target_workspace_id; GET DIAGNOSTICS deleted_inbox_messages = ROW_COUNT; - -- Reminder outcomes remain immutable to ordinary callers. This reviewed, - -- owner-executed erasure function is the only path that temporarily disables - -- the row mutation trigger. PostgreSQL transaction rollback restores both - -- data and trigger state if any following statement fails. - ALTER TABLE notification_service.reminder_outcomes - DISABLE TRIGGER reminder_outcomes_row_mutation_guard; + INSERT INTO notification_service.data_rights_erasure_authorizations ( + backend_process_id, + transaction_id, + workspace_id + ) VALUES ( + pg_backend_pid(), + pg_current_xact_id(), + target_workspace_id + ); DELETE FROM notification_service.reminder_outcomes WHERE workspace_id = target_workspace_id; GET DIAGNOSTICS deleted_reminder_outcomes = ROW_COUNT; - ALTER TABLE notification_service.reminder_outcomes - ENABLE TRIGGER reminder_outcomes_row_mutation_guard; + DELETE FROM notification_service.data_rights_erasure_authorizations + WHERE backend_process_id = pg_backend_pid() + AND transaction_id = pg_current_xact_id() + AND workspace_id = target_workspace_id; + + IF NOT FOUND THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'Notification erasure authorization cleanup failed'; + END IF; DELETE FROM notification_service.reminder_occurrences WHERE workspace_id = target_workspace_id; From 32f81368060489fe5cdad3032a0093df146d1643 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 01:31:21 +0900 Subject: [PATCH 028/150] test(notification): remove dynamic role SQL from erasure integration --- ...tification-data-rights.integration.test.ts | 66 +++++++++++++------ 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.integration.test.ts b/apps/notification-service/src/notification-data-rights.integration.test.ts index f53f026e..466867e5 100644 --- a/apps/notification-service/src/notification-data-rights.integration.test.ts +++ b/apps/notification-service/src/notification-data-rights.integration.test.ts @@ -219,27 +219,51 @@ describeWithPostgres('Notification data-rights PostgreSQL integration', () => { expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); }); - it('keeps the SECURITY DEFINER erasure function unavailable to an ungranted runtime role', async () => { - const roleName = `notification_test_${randomUUID().replaceAll('-', '')}`; - const workspaceId = randomUUID(); - await seedWorkspace(administrativePool, workspaceId); + it( + 'keeps the SECURITY DEFINER erasure function unavailable to an ungranted runtime role', + async () => { + const workspaceId = randomUUID(); + await seedWorkspace(administrativePool, workspaceId); - await administrativePool.query(`CREATE ROLE ${roleName} NOLOGIN`); - try { await administrativePool.query( - `GRANT USAGE ON SCHEMA notification_service TO ${roleName}`, + `DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles + WHERE rolname = 'notification_data_rights_ungranted_test' + ) THEN + DROP OWNED BY notification_data_rights_ungranted_test; + DROP ROLE notification_data_rights_ungranted_test; + END IF; + END + $$`, ); - await administrativePool.query(`SET ROLE ${roleName}`); - await expect( - administrativePool.query( - 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', - [workspaceId, randomUUID(), randomUUID(), randomUUID()], - ), - ).rejects.toMatchObject({ code: '42501' }); - } finally { - await administrativePool.query('RESET ROLE'); - await administrativePool.query(`DROP ROLE IF EXISTS ${roleName}`); - } - expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); - }); -}); + await administrativePool.query( + 'CREATE ROLE notification_data_rights_ungranted_test NOLOGIN', + ); + try { + await administrativePool.query( + 'GRANT USAGE ON SCHEMA notification_service TO notification_data_rights_ungranted_test', + ); + await administrativePool.query( + 'SET ROLE notification_data_rights_ungranted_test', + ); + await expect( + administrativePool.query( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, randomUUID(), randomUUID(), randomUUID()], + ), + ).rejects.toMatchObject({ code: '42501' }); + } finally { + await administrativePool.query('RESET ROLE'); + await administrativePool.query( + 'DROP OWNED BY notification_data_rights_ungranted_test', + ); + await administrativePool.query( + 'DROP ROLE IF EXISTS notification_data_rights_ungranted_test', + ); + } + expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); + }, + ); +}); \ No newline at end of file From 8507294226efcd98f14808de3e1abd165ac2cf39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:09:54 +0900 Subject: [PATCH 029/150] test(notification): require exact sanitized failure message --- .../notification-data-rights.behavior.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index 4971f315..4110aa05 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -263,11 +263,18 @@ describe('NotificationDataRightsContributor', () => { ]); const contributor = new NotificationDataRightsContributor(client); - const failure = contributor.handle(request('export')); - await expect(failure).rejects.toBeInstanceOf(NotificationDataRightsError); - await expect(failure).rejects.toThrowError( - 'Notification data-rights operation failed', - ); + let failure: unknown; + try { + await contributor.handle(request('export')); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(NotificationDataRightsError); + if (!(failure instanceof Error)) { + throw new Error('Expected notification data-rights error'); + } + expect(failure.message).toBe('Notification data-rights operation failed'); }); it('rejects missing, duplicate, or sparse SQL result evidence', async () => { From 6e86aa9b38e1fb212fb3c44ec6e34cf4c1956ec9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:15:42 +0900 Subject: [PATCH 030/150] test(notification): require function-only erasure preflight authority --- .../notification-data-rights.behavior.test.ts | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index 4110aa05..9c9f4c16 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -158,7 +158,7 @@ describe('NotificationDataRightsContributor', () => { it('dispatches every contributor lifecycle operation with tenant-scoped parameters', async () => { const client = new ScriptedClient([ - { rows: [{ erasure_receipts_ready: true, erasure_function_ready: true }] }, + { rows: [{ erasure_function_ready: true }] }, { rows: [{ erased_records: 3, receipt_sha256: SHA256 }] }, { rows: [{ record_count: 0 }] }, { rows: [{ record_count: 2 }] }, @@ -206,13 +206,9 @@ describe('NotificationDataRightsContributor', () => { expect(client.calls[2]?.values).toEqual([WORKSPACE_ID]); }); - it('reports each erasure preflight blocker without mutating data', async () => { + it('requires only function execution authority for erasure preflight', async () => { const client = new ScriptedClient([ - { - rows: [ - { erasure_receipts_ready: false, erasure_function_ready: false }, - ], - }, + { rows: [{ erasure_function_ready: false }] }, ]); const contributor = new NotificationDataRightsContributor(client); @@ -224,11 +220,14 @@ describe('NotificationDataRightsContributor', () => { operation: 'erase_preflight', requestId: REQUEST_ID, ready: false, - blockers: [ - 'notification_erasure_receipt_privileges_unavailable', - 'notification_erasure_function_unavailable', - ], + blockers: ['notification_erasure_function_unavailable'], }); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]?.text).toContain('has_function_privilege'); + expect(client.calls[0]?.text).not.toContain('has_table_privilege'); + expect(client.calls[0]?.text).not.toContain( + 'data_rights_erasure_receipts', + ); }); it('rejects malformed request envelopes before persistence access', async () => { @@ -351,17 +350,7 @@ describe('NotificationDataRightsContributor', () => { }> = [ { requestValue: request('erase_preflight'), - result: { - rows: [ - { erasure_receipts_ready: 'true', erasure_function_ready: true }, - ], - }, - }, - { - requestValue: request('erase_preflight'), - result: { - rows: [{ erasure_receipts_ready: true, erasure_function_ready: 1 }], - }, + result: { rows: [{ erasure_function_ready: 1 }] }, }, { requestValue: request('verify_erased'), From 3d3643c60892397f27963c3ca258fc3740328d47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:16:34 +0900 Subject: [PATCH 031/150] fix(notification): keep erasure receipts owner-only --- .../src/notification-data-rights.ts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index ceb15862..75e7ab65 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -99,7 +99,6 @@ interface ExportRow { /** Privilege evidence required before destructive Notification erasure. */ interface PrivilegeRow { - erasure_receipts_ready: unknown; erasure_function_ready: unknown; } @@ -460,32 +459,22 @@ export class NotificationDataRightsContributor { }; } - /** Checks owner-controlled erasure privileges without mutating tenant data. */ + /** Checks owner-controlled erasure function authority without requiring direct receipt-table access. */ private async preflightErase( requestId: string, ): Promise { const row = exactlyOne( await this.query( - `SELECT - COALESCE(has_table_privilege( - current_user, - to_regclass('notification_service.data_rights_erasure_receipts'), - 'SELECT,INSERT' - ), false) AS erasure_receipts_ready, - COALESCE(has_function_privilege( - current_user, - to_regprocedure('notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'), - 'EXECUTE' - ), false) AS erasure_function_ready`, + `SELECT COALESCE(has_function_privilege( + current_user, + to_regprocedure('notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'), + 'EXECUTE' + ), false) AS erasure_function_ready`, [], ), ); - const receiptsReady = requireBoolean(row.erasure_receipts_ready); const functionReady = requireBoolean(row.erasure_function_ready); const blockers: string[] = []; - if (!receiptsReady) { - blockers.push('notification_erasure_receipt_privileges_unavailable'); - } if (!functionReady) { blockers.push('notification_erasure_function_unavailable'); } From 842b0297a7395f2f8d111ad24ad4b5ea748370e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:16:54 +0900 Subject: [PATCH 032/150] test(notification): require separated migration runtime authority --- .../tests/notification-migration-role.spec.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 infra/tests/notification-migration-role.spec.ts diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts new file mode 100644 index 00000000..6cadd2cf --- /dev/null +++ b/infra/tests/notification-migration-role.spec.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(process.cwd(), '../..'); + +/** Read one repository-relative UTF-8 file for deterministic authority assertions. */ +function read(path: string): string { + return readFileSync(resolve(repositoryRoot, path), 'utf8'); +} + +describe('Notification database migration authority contract', () => { + const migrationRunner = read('infra/kubernetes/run-migrations.sh'); + const deploymentWorkflow = read('.github/workflows/deploy.yml'); + const environmentExample = read('.env.example'); + const erasureMigration = read( + 'apps/notification-service/migrations/0002_data_rights_erasure.sql', + ); + + it('keeps Notification migration ownership distinct from runtime authority', () => { + expect(migrationRunner).toContain('NOTIFICATION_MIGRATION_DATABASE_URL'); + expect(migrationRunner).toContain('NOTIFICATION_DATABASE_RUNTIME_ROLE'); + expect(migrationRunner).toContain('migration_role_matches_runtime_role'); + expect(migrationRunner).toContain( + 'GRANT USAGE ON SCHEMA notification_service TO :"service_runtime_role"', + ); + expect(migrationRunner).toContain( + 'REVOKE ALL PRIVILEGES ON TABLE\n notification_service.data_rights_erasure_receipts,\n notification_service.data_rights_erasure_authorizations\nFROM :"service_runtime_role";', + ); + expect(migrationRunner).toContain( + 'GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid)', + ); + + const migrationStep = + deploymentWorkflow.match( + /- name: Apply forward-only migrations[\s\S]*?\n - name: /u, + )?.[0] ?? ''; + expect(migrationStep).toContain( + 'NOTIFICATION_MIGRATION_DATABASE_URL: ${{ secrets.NOTIFICATION_MIGRATION_DATABASE_URL }}', + ); + expect(migrationStep).toContain( + 'NOTIFICATION_DATABASE_RUNTIME_ROLE: ${{ vars.NOTIFICATION_DATABASE_RUNTIME_ROLE }}', + ); + expect(migrationStep).not.toContain('NOTIFICATION_DATABASE_URL:'); + }); + + it('documents separate local migration and runtime identities', () => { + expect(environmentExample).toContain( + 'NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos_migrator:lifeos@postgres:5432/lifeos', + ); + expect(environmentExample).toContain( + 'NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos', + ); + expect(environmentExample).toContain( + 'NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos', + ); + }); + + it('transfers legacy Notification object ownership to the migration authority', () => { + expect(erasureMigration).toContain( + 'ALTER SCHEMA notification_service OWNER TO CURRENT_USER', + ); + expect(erasureMigration).toContain( + 'ALTER TABLE notification_service.reminder_occurrences OWNER TO CURRENT_USER', + ); + expect(erasureMigration).toContain( + 'ALTER TABLE notification_service.reminder_outcomes OWNER TO CURRENT_USER', + ); + expect(erasureMigration).toContain( + 'ALTER TABLE notification_service.inbox_messages OWNER TO CURRENT_USER', + ); + expect(erasureMigration).toContain( + 'ALTER FUNCTION notification_service.reject_reminder_outcome_mutation() OWNER TO CURRENT_USER', + ); + }); +}); From de6a6fcc5ed1d00fbe2ccc26454870af3ff131f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 10:02:46 +0900 Subject: [PATCH 033/150] fix(notification): separate migration runtime authority --- infra/kubernetes/run-migrations.sh | 62 +++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/infra/kubernetes/run-migrations.sh b/infra/kubernetes/run-migrations.sh index 52429b3f..f3fb2b4e 100644 --- a/infra/kubernetes/run-migrations.sh +++ b/infra/kubernetes/run-migrations.sh @@ -26,6 +26,7 @@ migration_roots=( 'habit|HABIT_DATABASE_URL|apps/habit-service/migrations' 'ai|AI_DATABASE_URL|apps/ai-service/migrations' 'review|REVIEW_DATABASE_URL|apps/review-service/migrations' + 'notification|NOTIFICATION_MIGRATION_DATABASE_URL|apps/notification-service/migrations|NOTIFICATION_DATABASE_RUNTIME_ROLE' ) append_migration_command() { @@ -127,6 +128,8 @@ apply_service_migrations() { local service_name="$1" local database_url_name="$2" local migration_directory="$3" + local runtime_role_name="${4:-}" + local service_runtime_role='' local migration_file migration_name migration_sequence migration_sha local workspace command_file service_file local -a migration_files=() @@ -139,6 +142,13 @@ apply_service_migrations() { ((${#migration_files[@]} > 0)) || return 0 [[ -n "${!database_url_name:-}" ]] || fail "${database_url_name}_required" + if [[ -n "${runtime_role_name}" ]]; then + [[ -n "${!runtime_role_name:-}" ]] || fail "${runtime_role_name}_required" + service_runtime_role="${!runtime_role_name}" + [[ "${service_runtime_role}" =~ ^[a-z_][a-z0-9_]{0,62}$ ]] || + fail "${runtime_role_name}_invalid" + fi + workspace="$(mktemp -d)" command_file="${workspace}/migration_commands.psql" service_file="${workspace}/pg_service.conf" @@ -184,6 +194,30 @@ CREATE UNIQUE INDEX IF NOT EXISTS schema_migrations_service_sequence_unique ON ${MIGRATION_SCHEMA}.${MIGRATION_TABLE} (service_name, migration_sequence); SQL + if [[ -n "${service_runtime_role}" ]]; then + cat >>"${command_file}" <>"${command_file}" <<'SQL' +GRANT USAGE ON SCHEMA notification_service TO :"service_runtime_role"; +GRANT SELECT, INSERT, UPDATE ON TABLE + notification_service.reminder_occurrences, + notification_service.inbox_messages +TO :"service_runtime_role"; +GRANT SELECT, INSERT ON TABLE + notification_service.reminder_outcomes +TO :"service_runtime_role"; +REVOKE ALL PRIVILEGES ON TABLE + notification_service.data_rights_erasure_receipts, + notification_service.data_rights_erasure_authorizations +FROM :"service_runtime_role"; +GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid) +TO :"service_runtime_role"; +SQL + fi + cat >>"${command_file}" < Date: Thu, 13 Aug 2026 10:03:31 +0900 Subject: [PATCH 034/150] fix(notification): transfer migration ownership explicitly --- .../migrations/0002_data_rights_erasure.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/notification-service/migrations/0002_data_rights_erasure.sql b/apps/notification-service/migrations/0002_data_rights_erasure.sql index b8653281..03a32456 100644 --- a/apps/notification-service/migrations/0002_data_rights_erasure.sql +++ b/apps/notification-service/migrations/0002_data_rights_erasure.sql @@ -1,5 +1,11 @@ BEGIN; +ALTER SCHEMA notification_service OWNER TO CURRENT_USER; +ALTER TABLE notification_service.reminder_occurrences OWNER TO CURRENT_USER; +ALTER TABLE notification_service.reminder_outcomes OWNER TO CURRENT_USER; +ALTER TABLE notification_service.inbox_messages OWNER TO CURRENT_USER; +ALTER FUNCTION notification_service.reject_reminder_outcome_mutation() OWNER TO CURRENT_USER; + CREATE TABLE notification_service.data_rights_erasure_receipts ( workspace_id uuid NOT NULL, idempotency_key uuid NOT NULL, From 0e308a8e8d638538ed339e5529142c57da680458 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 10:03:57 +0900 Subject: [PATCH 035/150] docs(notification): declare distinct database roles --- .env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.example b/.env.example index 937dde1d..954db34b 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,8 @@ AI_MODEL_REQUEST_TIMEOUT_MS=10000 AI_PROPOSAL_MODEL=rule-based CONTEXTUAL_ORCHESTRATOR_TOKEN= CONTEXTUAL_ORCHESTRATOR_URL= +NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos_migrator:lifeos@postgres:5432/lifeos +NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos NOTIFICATION_DATABASE_POOL_MAX=10 NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS=5000 From 9bb9037d13bf52333e8b84569239f62002a15610 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 10:04:48 +0900 Subject: [PATCH 036/150] fix(notification): wire migration database authority --- .github/workflows/deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 57d7139a..610a448c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -135,6 +135,8 @@ jobs: HABIT_DATABASE_URL: ${{ secrets.HABIT_DATABASE_URL }} AI_DATABASE_URL: ${{ secrets.AI_DATABASE_URL }} REVIEW_DATABASE_URL: ${{ secrets.REVIEW_DATABASE_URL }} + NOTIFICATION_MIGRATION_DATABASE_URL: ${{ secrets.NOTIFICATION_MIGRATION_DATABASE_URL }} + NOTIFICATION_DATABASE_RUNTIME_ROLE: ${{ vars.NOTIFICATION_DATABASE_RUNTIME_ROLE }} shell: bash run: | set -Eeuo pipefail From 82798a104a731f3ad2e79634bd25447575e1f235 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 13:55:51 +0900 Subject: [PATCH 037/150] docs(notification): document immutable outcome privilege boundary --- .../migrations/0002_data_rights_erasure.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/notification-service/migrations/0002_data_rights_erasure.sql b/apps/notification-service/migrations/0002_data_rights_erasure.sql index 03a32456..870efa33 100644 --- a/apps/notification-service/migrations/0002_data_rights_erasure.sql +++ b/apps/notification-service/migrations/0002_data_rights_erasure.sql @@ -85,6 +85,9 @@ BEGIN END; $$; +COMMENT ON FUNCTION notification_service.reject_reminder_outcome_mutation() IS + 'SECURITY DEFINER boundary that enforces reminder-outcome immutability; DELETE is allowed only for the same backend, transaction, and workspace authorized by the owner-controlled erasure procedure.'; + REVOKE ALL ON FUNCTION notification_service.reject_reminder_outcome_mutation() FROM PUBLIC; CREATE FUNCTION notification_service.erase_workspace_data( From 0c61081f58fabb564f3a54029e3670f753cc2fd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:01:01 +0900 Subject: [PATCH 038/150] chore(notification): expose formatter diff for CI RCA --- apps/notification-service/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notification-service/package.json b/apps/notification-service/package.json index 361bc6de..5b6de110 100644 --- a/apps/notification-service/package.json +++ b/apps/notification-service/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", - "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../CHANGELOG.md ../../docs/operations/notification-persistence.md ../../docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md \"../../docs/superpowers/plans/2026-08-04-*.md\"", + "lint": "tsc --noEmit && prettier --single-quote --write package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../CHANGELOG.md ../../docs/operations/notification-persistence.md ../../docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md \"../../docs/superpowers/plans/2026-08-04-*.md\" && git diff --exit-code -- src", "test": "vitest run --no-file-parallelism --coverage", "typecheck": "tsc --noEmit" }, From edc6d7658ab7b1e8eb634bd23df57d6cb057bc83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 20:10:11 +0900 Subject: [PATCH 039/150] style(notification): satisfy migration test formatter --- .../src/notification-data-rights-migration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-data-rights-migration.test.ts b/apps/notification-service/src/notification-data-rights-migration.test.ts index ffd3ff8a..52f07fd9 100644 --- a/apps/notification-service/src/notification-data-rights-migration.test.ts +++ b/apps/notification-service/src/notification-data-rights-migration.test.ts @@ -76,7 +76,9 @@ describe('Notification data-rights erasure database contract', () => { expect(sql).toContain('pg_backend_pid()'); expect(sql).toContain('pg_current_xact_id()'); expect(sql).not.toContain('DISABLE TRIGGER'); - expect(sql).not.toContain('ENABLE TRIGGER reminder_outcomes_row_mutation_guard'); + expect(sql).not.toContain( + 'ENABLE TRIGGER reminder_outcomes_row_mutation_guard', + ); expect(inboxDelete).toBeGreaterThan(-1); expect(authorizationInsert).toBeGreaterThan(inboxDelete); expect(outcomeDelete).toBeGreaterThan(authorizationInsert); From f93c5e371ac09cf29ab0b2a51ed4db93008d054a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 20:11:23 +0900 Subject: [PATCH 040/150] style(notification): satisfy behavior test formatter --- .../src/notification-data-rights.behavior.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index 9c9f4c16..06a29a91 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -133,14 +133,10 @@ describe('NotificationDataRightsContributor', () => { it('uses codepoint-stable canonical JSON for reproducible export evidence', async () => { const first = new NotificationDataRightsContributor( - new ScriptedClient([ - exportResult([{ a: 'lower', Z: 'upper' }], [], []), - ]), + new ScriptedClient([exportResult([{ a: 'lower', Z: 'upper' }], [], [])]), ); const second = new NotificationDataRightsContributor( - new ScriptedClient([ - exportResult([{ Z: 'upper', a: 'lower' }], [], []), - ]), + new ScriptedClient([exportResult([{ Z: 'upper', a: 'lower' }], [], [])]), ); const firstResponse = await first.handle(request('export')); @@ -225,9 +221,7 @@ describe('NotificationDataRightsContributor', () => { expect(client.calls).toHaveLength(1); expect(client.calls[0]?.text).toContain('has_function_privilege'); expect(client.calls[0]?.text).not.toContain('has_table_privilege'); - expect(client.calls[0]?.text).not.toContain( - 'data_rights_erasure_receipts', - ); + expect(client.calls[0]?.text).not.toContain('data_rights_erasure_receipts'); }); it('rejects malformed request envelopes before persistence access', async () => { From 909c511d1e38487ecf21282ea3791b596183c902 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:05:06 +0900 Subject: [PATCH 041/150] style(notification): satisfy integration test formatter --- ...tification-data-rights.integration.test.ts | 95 +++++++++---------- 1 file changed, 46 insertions(+), 49 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.integration.test.ts b/apps/notification-service/src/notification-data-rights.integration.test.ts index 466867e5..ffc04abd 100644 --- a/apps/notification-service/src/notification-data-rights.integration.test.ts +++ b/apps/notification-service/src/notification-data-rights.integration.test.ts @@ -173,9 +173,9 @@ describeWithPostgres('Notification data-rights PostgreSQL integration', () => { }, ]); expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(0); - expect(await workspaceRecordCount(administrativePool, otherWorkspaceId)).toBe( - 3, - ); + expect( + await workspaceRecordCount(administrativePool, otherWorkspaceId), + ).toBe(3); const replay = await administrativePool.query( 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', @@ -196,9 +196,9 @@ describeWithPostgres('Notification data-rights PostgreSQL integration', () => { [other.outcomeId], ), ).rejects.toMatchObject({ code: '55000' }); - expect(await workspaceRecordCount(administrativePool, otherWorkspaceId)).toBe( - 3, - ); + expect( + await workspaceRecordCount(administrativePool, otherWorkspaceId), + ).toBe(3); }); it('rejects non-v4 erasure authority before changing tenant data', async () => { @@ -219,51 +219,48 @@ describeWithPostgres('Notification data-rights PostgreSQL integration', () => { expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); }); - it( - 'keeps the SECURITY DEFINER erasure function unavailable to an ungranted runtime role', - async () => { - const workspaceId = randomUUID(); - await seedWorkspace(administrativePool, workspaceId); + it('keeps the SECURITY DEFINER erasure function unavailable to an ungranted runtime role', async () => { + const workspaceId = randomUUID(); + await seedWorkspace(administrativePool, workspaceId); + await administrativePool.query( + `DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles + WHERE rolname = 'notification_data_rights_ungranted_test' + ) THEN + DROP OWNED BY notification_data_rights_ungranted_test; + DROP ROLE notification_data_rights_ungranted_test; + END IF; + END + $$`, + ); + await administrativePool.query( + 'CREATE ROLE notification_data_rights_ungranted_test NOLOGIN', + ); + try { await administrativePool.query( - `DO $$ - BEGIN - IF EXISTS ( - SELECT 1 FROM pg_catalog.pg_roles - WHERE rolname = 'notification_data_rights_ungranted_test' - ) THEN - DROP OWNED BY notification_data_rights_ungranted_test; - DROP ROLE notification_data_rights_ungranted_test; - END IF; - END - $$`, + 'GRANT USAGE ON SCHEMA notification_service TO notification_data_rights_ungranted_test', ); await administrativePool.query( - 'CREATE ROLE notification_data_rights_ungranted_test NOLOGIN', + 'SET ROLE notification_data_rights_ungranted_test', ); - try { - await administrativePool.query( - 'GRANT USAGE ON SCHEMA notification_service TO notification_data_rights_ungranted_test', - ); - await administrativePool.query( - 'SET ROLE notification_data_rights_ungranted_test', - ); - await expect( - administrativePool.query( - 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', - [workspaceId, randomUUID(), randomUUID(), randomUUID()], - ), - ).rejects.toMatchObject({ code: '42501' }); - } finally { - await administrativePool.query('RESET ROLE'); - await administrativePool.query( - 'DROP OWNED BY notification_data_rights_ungranted_test', - ); - await administrativePool.query( - 'DROP ROLE IF EXISTS notification_data_rights_ungranted_test', - ); - } - expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); - }, - ); -}); \ No newline at end of file + await expect( + administrativePool.query( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, randomUUID(), randomUUID(), randomUUID()], + ), + ).rejects.toMatchObject({ code: '42501' }); + } finally { + await administrativePool.query('RESET ROLE'); + await administrativePool.query( + 'DROP OWNED BY notification_data_rights_ungranted_test', + ); + await administrativePool.query( + 'DROP ROLE IF EXISTS notification_data_rights_ungranted_test', + ); + } + expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(3); + }); +}); From 80d06e67c59b119a035755de1bc48c708a60ea3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:09:09 +0900 Subject: [PATCH 042/150] style(notification): satisfy contributor formatter --- .../notification-service/src/notification-data-rights.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index 75e7ab65..cff46cee 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -266,7 +266,9 @@ function requireJsonValue(value: unknown): NotificationDataRightsJsonValue { /** Computes deterministic SHA-256 evidence over canonical bounded JSON. */ function digest(value: unknown): string { - return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); + return createHash('sha256') + .update(canonicalJson(value), 'utf8') + .digest('hex'); } /** Requires exactly one PostgreSQL row and rejects missing or duplicate evidence. */ @@ -346,7 +348,10 @@ export class NotificationDataRightsContributor { const request = normalizeRequest(untrustedRequest); switch (request.operation) { case 'export': - return await this.exportWorkspace(request.workspaceId, request.requestId); + return await this.exportWorkspace( + request.workspaceId, + request.requestId, + ); case 'erase_preflight': return await this.preflightErase(request.requestId); case 'erase': From e6309ed9b952555f5648f2e4abdc8459b25a1b02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:09:55 +0900 Subject: [PATCH 043/150] chore(notification): restore immutable formatter gate --- apps/notification-service/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notification-service/package.json b/apps/notification-service/package.json index 5b6de110..361bc6de 100644 --- a/apps/notification-service/package.json +++ b/apps/notification-service/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", - "lint": "tsc --noEmit && prettier --single-quote --write package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../CHANGELOG.md ../../docs/operations/notification-persistence.md ../../docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md \"../../docs/superpowers/plans/2026-08-04-*.md\" && git diff --exit-code -- src", + "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../CHANGELOG.md ../../docs/operations/notification-persistence.md ../../docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md \"../../docs/superpowers/plans/2026-08-04-*.md\"", "test": "vitest run --no-file-parallelism --coverage", "typecheck": "tsc --noEmit" }, From 8908c8f71d853235e6f755798d24003cac5a523e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:26:18 +0900 Subject: [PATCH 044/150] ci(notification): repair unreachable canonical JSON branch --- .../repair-notification-data-rights.yml | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/repair-notification-data-rights.yml diff --git a/.github/workflows/repair-notification-data-rights.yml b/.github/workflows/repair-notification-data-rights.yml new file mode 100644 index 00000000..ebce267a --- /dev/null +++ b/.github/workflows/repair-notification-data-rights.yml @@ -0,0 +1,57 @@ +name: Repair notification data-rights coverage + +on: + push: + branches: + - feat/notification-data-rights-contributor-v2 + paths: + - .github/workflows/repair-notification-data-rights.yml + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: repair-notification-data-rights + cancel-in-progress: false + +jobs: + repair: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + ref: feat/notification-data-rights-contributor-v2 + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Remove unreachable comparator equality branch + run: | + python - <<'PY' + from pathlib import Path + + path = Path('apps/notification-service/src/notification-data-rights.ts') + source = path.read_text() + before = """ entries.sort(([left], [right]) => {\n if (left < right) {\n return -1;\n }\n if (left > right) {\n return 1;\n }\n return 0;\n });""" + after = """ entries.sort(([left], [right]) => (left < right ? -1 : 1));""" + if source.count(before) != 1: + raise SystemExit('expected comparator block not found exactly once') + path.write_text(source.replace(before, after)) + PY + - run: pnpm install --frozen-lockfile + - run: pnpm --dir apps/notification-service typecheck + - run: pnpm --dir apps/notification-service test + - name: Commit verified repair + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add apps/notification-service/src/notification-data-rights.ts + git diff --cached --check + git commit -m "fix(notification): remove unreachable canonical JSON branch" + git push origin HEAD:feat/notification-data-rights-contributor-v2 From 06190db310cd1a0cd441965c3a7088af6d61f5a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:25:39 +0900 Subject: [PATCH 045/150] chore(notification): remove temporary repair workflow --- .../repair-notification-data-rights.yml | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/repair-notification-data-rights.yml diff --git a/.github/workflows/repair-notification-data-rights.yml b/.github/workflows/repair-notification-data-rights.yml deleted file mode 100644 index ebce267a..00000000 --- a/.github/workflows/repair-notification-data-rights.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Repair notification data-rights coverage - -on: - push: - branches: - - feat/notification-data-rights-contributor-v2 - paths: - - .github/workflows/repair-notification-data-rights.yml - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: repair-notification-data-rights - cancel-in-progress: false - -jobs: - repair: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - with: - ref: feat/notification-data-rights-contributor-v2 - fetch-depth: 0 - - uses: pnpm/action-setup@v4 - with: - version: 10.15.0 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - name: Remove unreachable comparator equality branch - run: | - python - <<'PY' - from pathlib import Path - - path = Path('apps/notification-service/src/notification-data-rights.ts') - source = path.read_text() - before = """ entries.sort(([left], [right]) => {\n if (left < right) {\n return -1;\n }\n if (left > right) {\n return 1;\n }\n return 0;\n });""" - after = """ entries.sort(([left], [right]) => (left < right ? -1 : 1));""" - if source.count(before) != 1: - raise SystemExit('expected comparator block not found exactly once') - path.write_text(source.replace(before, after)) - PY - - run: pnpm install --frozen-lockfile - - run: pnpm --dir apps/notification-service typecheck - - run: pnpm --dir apps/notification-service test - - name: Commit verified repair - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add apps/notification-service/src/notification-data-rights.ts - git diff --cached --check - git commit -m "fix(notification): remove unreachable canonical JSON branch" - git push origin HEAD:feat/notification-data-rights-contributor-v2 From 5ba029dea755050ff84066a62f98a1f443e19160 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:12:38 +0900 Subject: [PATCH 046/150] test(notification): remove unreachable comparator branch --- .../src/notification-data-rights.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index cff46cee..3dbc3a3e 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -200,6 +200,11 @@ function requireSha256(value: unknown): string { return value; } +/** Compares canonical object keys by UTF-16 code units without locale collation. */ +function compareCanonicalKeys(left: string, right: string): number { + return Number(left > right) - Number(left < right); +} + /** Converts untrusted JSON evidence to deterministic canonical JSON while enforcing bounds. */ function canonicalJson(value: unknown, depth = 0): string { if (depth > MAX_JSON_DEPTH) { @@ -238,15 +243,7 @@ function canonicalJson(value: unknown, depth = 0): string { if (entries.length > MAX_JSON_CONTAINER_ITEMS) { return invalidDataRights(); } - entries.sort(([left], [right]) => { - if (left < right) { - return -1; - } - if (left > right) { - return 1; - } - return 0; - }); + entries.sort(([left], [right]) => compareCanonicalKeys(left, right)); const serialized = entries.map(([key, entry]) => { if (Buffer.byteLength(key, 'utf8') > MAX_JSON_KEY_BYTES) { return invalidDataRights(); From b11e4c76d8d7ae9c6e431a5d1afee0875b62ca6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 13:17:29 -0700 Subject: [PATCH 047/150] test(notification): require complete paginated data-rights export --- ...otification-data-rights-pagination.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-pagination.test.ts diff --git a/apps/notification-service/src/notification-data-rights-pagination.test.ts b/apps/notification-service/src/notification-data-rights-pagination.test.ts new file mode 100644 index 00000000..0bc24308 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-pagination.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { + NotificationDataRightsContributor, + type NotificationDataRightsResponse, +} from './notification-data-rights'; +import type { + NotificationSqlClient, + NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const EVIDENCE_TIME = '2026-08-12T00:00:00.000000Z'; + +class ScriptedClient implements NotificationSqlClient { + readonly calls: Array<{ + readonly text: string; + readonly values: readonly unknown[]; + }> = []; + + constructor( + private readonly script: Array>, + ) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + this.calls.push({ text, values: [...values] }); + const next = this.script.shift(); + if (next === undefined) { + throw new Error('test script exhausted'); + } + return next as NotificationSqlQueryResult; + } +} + +function exportRequest(cursor?: string): Record { + return { + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + ...(cursor === undefined ? {} : { cursor }), + }; +} + +function reminderEvidence(index: number): Record { + const evidenceId = `11111111-1111-4111-8111-${index + .toString(16) + .padStart(12, '0')}`; + return { + evidenceTime: EVIDENCE_TIME, + evidenceKind: 'reminder_occurrence', + evidenceId, + data: { + reminderId: evidenceId, + title: `Reminder ${index}`, + dueAt: '2026-08-12T01:00:00.000000Z', + timeZone: 'UTC', + quietStartMinute: null, + quietEndMinute: null, + dailyDeliveryLimit: 3, + deliveryAttemptCount: 0, + status: 'pending', + claimExpiresAt: null, + createdAt: EVIDENCE_TIME, + updatedAt: EVIDENCE_TIME, + }, + }; +} + +function exportPage( + records: readonly Record[], +): NotificationSqlQueryResult { + return { rows: [{ evidence_records: [...records] }] }; +} + +function requireExport( + response: NotificationDataRightsResponse, +): Extract { + if (response.operation !== 'export') { + throw new Error('Expected export response'); + } + return response; +} + +describe('Notification data-rights export pagination', () => { + it('returns a continuation cursor instead of making portability unavailable past 1000 records', async () => { + const firstPageRows = Array.from({ length: 1_001 }, (_, index) => + reminderEvidence(index), + ); + const finalRecord = reminderEvidence(1_001); + const client = new ScriptedClient([ + exportPage(firstPageRows), + exportPage([finalRecord]), + ]); + const contributor = new NotificationDataRightsContributor(client); + + const first = requireExport(await contributor.handle(exportRequest())); + expect(first.recordCount).toBe(1_000); + expect(first.data).toMatchObject({ + reminderOccurrences: expect.any(Array), + reminderOutcomes: [], + inboxMessages: [], + }); + expect(first).toHaveProperty('nextCursor'); + const nextCursor = (first as typeof first & { readonly nextCursor: string }) + .nextCursor; + expect(nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u); + + const second = requireExport( + await contributor.handle(exportRequest(nextCursor)), + ); + expect(second.recordCount).toBe(1); + expect(second).not.toHaveProperty('nextCursor'); + expect(second.data).toMatchObject({ + reminderOccurrences: [finalRecord.data], + reminderOutcomes: [], + inboxMessages: [], + }); + + expect(client.calls).toHaveLength(2); + expect(client.calls[0]?.values).toEqual([ + WORKSPACE_ID, + null, + null, + null, + 1_001, + ]); + expect(client.calls[1]?.values).toEqual([ + WORKSPACE_ID, + EVIDENCE_TIME, + 'reminder_occurrence', + '11111111-1111-4111-8111-0000000003e7', + 1_001, + ]); + expect(client.calls[0]?.text).toContain('LIMIT $5'); + expect(client.calls[0]?.text).not.toContain('claim_key_hash'); + expect(client.calls[0]?.text).not.toContain('idempotency_key_hash'); + }); + + it('rejects malformed opaque cursors before persistence access', async () => { + const client = new ScriptedClient([]); + const contributor = new NotificationDataRightsContributor(client); + + await expect( + contributor.handle(exportRequest('not/a/base64url/cursor')), + ).rejects.toThrow('Notification data-rights operation failed'); + expect(client.calls).toEqual([]); + }); +}); From 74d9334987fc0fe4e20510cbb0201c7fc0910ca4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:10:44 +0900 Subject: [PATCH 048/150] fix(notification): paginate data-rights export --- .../notification-data-rights.behavior.test.ts | 198 +++++++---- .../src/notification-data-rights.test.ts | 10 +- .../src/notification-data-rights.ts | 332 ++++++++++++++---- 3 files changed, 396 insertions(+), 144 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index 06a29a91..2dc0f49c 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -13,6 +13,7 @@ const USER_ID = '22222222-2222-4222-8222-222222222222'; const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; const SHA256 = 'a'.repeat(64); +const EVIDENCE_TIME = '2026-08-12T00:00:00.000000Z'; const CODEPOINT_CANONICAL_DIGEST = '3ab3b13cd6c0ab42b9cbed3c685c5b4d0b065f94b5e147b267a3ab4e00f0d356'; @@ -44,6 +45,7 @@ class ScriptedClient implements NotificationSqlClient { function request( operation: 'export' | 'erase_preflight' | 'erase' | 'verify_erased', + overrides: Readonly> = {}, ): Record { return { contractVersion: 'life-os.data-rights-contributor.v1', @@ -52,25 +54,35 @@ function request( requestedByUserId: USER_ID, requestId: REQUEST_ID, ...(operation === 'erase' ? { idempotencyKey: IDEMPOTENCY_KEY } : {}), + ...overrides, }; } -function exportResult( - reminderOccurrences: unknown = [], - reminderOutcomes: unknown = [], - inboxMessages: unknown = [], -): NotificationSqlQueryResult { +function uuid(index: number): string { + return `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`; +} + +function evidence( + data: unknown, + index = 1, + kind: 'inbox_message' | 'reminder_occurrence' | 'reminder_outcome' = + 'reminder_occurrence', + evidenceTime = EVIDENCE_TIME, +): Record { return { - rows: [ - { - reminder_occurrences: reminderOccurrences, - reminder_outcomes: reminderOutcomes, - inbox_messages: inboxMessages, - }, - ], + evidenceTime, + evidenceKind: kind, + evidenceId: uuid(index), + data, }; } +function exportResult( + evidenceRecords: unknown, +): NotificationSqlQueryResult { + return { rows: [{ evidence_records: evidenceRecords }] }; +} + async function expectDataRightsFailure( contributor: NotificationDataRightsContributor, value: unknown, @@ -84,21 +96,19 @@ describe('NotificationDataRightsContributor', () => { it('exports bounded deterministic tenant evidence without secret hash columns', async () => { const nullPrototype = Object.assign(Object.create(null), { zeta: 'z' }); const client = new ScriptedClient([ - exportResult( - [ - { - zeta: 'last', - alpha: null, - enabled: true, - disabled: false, - count: 1, - nested: ['value'], - nullPrototype, - }, - ], - [], - [], - ), + exportResult([ + evidence({ + zeta: 'last', + alpha: null, + enabled: true, + disabled: false, + count: 1, + nested: ['value'], + nullPrototype, + }), + evidence({ outcomeId: uuid(2) }, 2, 'reminder_outcome'), + evidence({ messageId: uuid(3) }, 3, 'inbox_message'), + ]), ]); const contributor = new NotificationDataRightsContributor(client); @@ -110,22 +120,28 @@ describe('NotificationDataRightsContributor', () => { operation: 'export', requestId: REQUEST_ID, schemaVersion: 'notification.data-rights.v1', - recordCount: 1, + recordCount: 3, }); if (response.operation !== 'export') { throw new Error('Expected export response'); } expect(response.sha256).toMatch(/^[0-9a-f]{64}$/u); + expect(response.nextCursor).toBeUndefined(); + expect(response.data).toMatchObject({ + reminderOccurrences: [expect.any(Object)], + reminderOutcomes: [{ outcomeId: uuid(2) }], + inboxMessages: [{ messageId: uuid(3) }], + }); expect(client.calls).toHaveLength(1); - expect(client.calls[0]?.values).toEqual([WORKSPACE_ID, 1_001]); - expect(client.calls[0]?.text).toContain( - 'ORDER BY created_at ASC, reminder_id ASC', - ); - expect(client.calls[0]?.text).toContain( - 'ORDER BY occurred_at ASC, outcome_id ASC', - ); + expect(client.calls[0]?.values).toEqual([ + WORKSPACE_ID, + null, + null, + null, + 1_001, + ]); expect(client.calls[0]?.text).toContain( - 'ORDER BY delivered_at ASC, message_id ASC', + 'ORDER BY evidence_time ASC, evidence_kind ASC, evidence_id ASC', ); expect(client.calls[0]?.text).not.toContain('claim_key_hash'); expect(client.calls[0]?.text).not.toContain('idempotency_key_hash'); @@ -133,10 +149,14 @@ describe('NotificationDataRightsContributor', () => { it('uses codepoint-stable canonical JSON for reproducible export evidence', async () => { const first = new NotificationDataRightsContributor( - new ScriptedClient([exportResult([{ a: 'lower', Z: 'upper' }], [], [])]), + new ScriptedClient([ + exportResult([evidence({ a: 'lower', Z: 'upper' })]), + ]), ); const second = new NotificationDataRightsContributor( - new ScriptedClient([exportResult([{ Z: 'upper', a: 'lower' }], [], [])]), + new ScriptedClient([ + exportResult([evidence({ Z: 'upper', a: 'lower' })]), + ]), ); const firstResponse = await first.handle(request('export')); @@ -221,16 +241,26 @@ describe('NotificationDataRightsContributor', () => { expect(client.calls).toHaveLength(1); expect(client.calls[0]?.text).toContain('has_function_privilege'); expect(client.calls[0]?.text).not.toContain('has_table_privilege'); - expect(client.calls[0]?.text).not.toContain('data_rights_erasure_receipts'); + expect(client.calls[0]?.text).not.toContain( + 'data_rights_erasure_receipts', + ); }); - it('rejects malformed request envelopes before persistence access', async () => { + it('rejects malformed request envelopes and cursors before persistence access', async () => { const client = new ScriptedClient([]); const contributor = new NotificationDataRightsContributor(client); const nullPrototypeRequest = Object.assign( Object.create(null), request('export'), ); + const cursor = (value: unknown): string => + Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); + const cursorBase = { + version: 'notification.data-rights.cursor.v1', + evidenceTime: EVIDENCE_TIME, + evidenceKind: 'reminder_occurrence', + evidenceId: uuid(1), + }; const malformed = [ undefined, null, @@ -241,6 +271,35 @@ describe('NotificationDataRightsContributor', () => { { ...request('export'), extra: true }, { ...request('export'), workspaceId: 42 }, { ...request('export'), workspaceId: 'not-a-uuid' }, + { ...request('export'), cursor: 42 }, + { ...request('export'), cursor: '' }, + { ...request('export'), cursor: 'a'.repeat(513) }, + { ...request('export'), cursor: '***' }, + { ...request('export'), cursor: 'eA' }, + { ...request('export'), cursor: cursor({ ...cursorBase, version: 'wrong' }) }, + { + ...request('export'), + cursor: cursor({ ...cursorBase, evidenceKind: 'unknown' }), + }, + { + ...request('export'), + cursor: cursor({ ...cursorBase, evidenceTime: 'not-an-instant' }), + }, + { + ...request('export'), + cursor: cursor({ + ...cursorBase, + evidenceTime: '2026-99-99T00:00:00Z', + }), + }, + { + ...request('export'), + cursor: cursor({ ...cursorBase, evidenceId: 'not-a-uuid' }), + }, + { + ...request('export'), + cursor: cursor({ ...cursorBase, extra: true }), + }, { ...request('erase'), idempotencyKey: 'not-a-uuid' }, ]; @@ -270,11 +329,14 @@ describe('NotificationDataRightsContributor', () => { expect(failure.message).toBe('Notification data-rights operation failed'); }); - it('rejects missing, duplicate, or sparse SQL result evidence', async () => { + it('rejects missing, duplicate, sparse, and malformed SQL result evidence', async () => { const cases: NotificationSqlQueryResult[] = [ { rows: [] }, { rows: [{}, {}] }, { rows: new Array(1) }, + { rows: [{ evidence_records: {} }] }, + exportResult(new Array(1_001)), + exportResult(Array.from({ length: 1_002 }, () => null)), ]; for (const result of cases) { const contributor = new NotificationDataRightsContributor( @@ -284,29 +346,24 @@ describe('NotificationDataRightsContributor', () => { } }); - it('requires all three export aggregates to be arrays', async () => { - const cases = [ - exportResult({}, [], []), - exportResult([], {}, []), - exportResult([], [], {}), + it('rejects malformed cross-table evidence identities', async () => { + const malformed = [ + { ...evidence({}), evidenceKind: 'unknown' }, + { ...evidence({}), evidenceTime: 'not-an-instant' }, + { ...evidence({}), evidenceTime: '2026-99-99T00:00:00Z' }, + { ...evidence({}), evidenceId: 'not-a-uuid' }, + { ...evidence({}), extra: true }, ]; - for (const result of cases) { - const contributor = new NotificationDataRightsContributor( - new ScriptedClient([result]), + for (const value of malformed) { + await expectDataRightsFailure( + new NotificationDataRightsContributor( + new ScriptedClient([exportResult([value])]), + ), + request('export'), ); - await expectDataRightsFailure(contributor, request('export')); } }); - it('fails closed when a bounded export exceeds its total record ceiling', async () => { - const contributor = new NotificationDataRightsContributor( - new ScriptedClient([ - exportResult(Array.from({ length: 1_001 }, () => null)), - ]), - ); - await expectDataRightsFailure(contributor, request('export')); - }); - it('rejects malformed or unbounded JSON returned by PostgreSQL', async () => { let tooDeep: unknown = null; for (let depth = 0; depth < 18; depth += 1) { @@ -317,20 +374,23 @@ describe('NotificationDataRightsContributor', () => { ); const nullPrototype = Object.assign(Object.create(null), { safe: 'value' }); const invalidValues: unknown[] = [ - [{ value: Number.POSITIVE_INFINITY }], - ['x'.repeat(64 * 1024 + 1)], - [Array.from({ length: 2_001 }, () => null)], - [tooManyObjectEntries], - [{ ['k'.repeat(257)]: null }], - [new Date(0)], - [undefined], - [tooDeep], + { value: Number.POSITIVE_INFINITY }, + 'x'.repeat(64 * 1024 + 1), + Array.from({ length: 2_001 }, () => null), + tooManyObjectEntries, + { ['k'.repeat(257)]: null }, + new Date(0), + undefined, + tooDeep, ]; - for (const reminderOccurrences of invalidValues) { + for (const data of invalidValues) { const contributor = new NotificationDataRightsContributor( new ScriptedClient([ - exportResult(reminderOccurrences, [nullPrototype], []), + exportResult([ + evidence(data), + evidence(nullPrototype, 2, 'reminder_outcome'), + ]), ]), ); await expectDataRightsFailure(contributor, request('export')); diff --git a/apps/notification-service/src/notification-data-rights.test.ts b/apps/notification-service/src/notification-data-rights.test.ts index 9e33313c..46f8618c 100644 --- a/apps/notification-service/src/notification-data-rights.test.ts +++ b/apps/notification-service/src/notification-data-rights.test.ts @@ -18,15 +18,9 @@ const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; function inertPool(): NotificationPool { return { async query(text: string): Promise<{ rows: Row[] }> { - if (text.includes('AS reminder_occurrences')) { + if (text.includes('AS evidence_records')) { return { - rows: [ - { - reminder_occurrences: [], - reminder_outcomes: [], - inbox_messages: [], - } as Row, - ], + rows: [{ evidence_records: [] } as Row], }; } return { rows: [] }; diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index 3dbc3a3e..44ec8b6b 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -8,7 +8,9 @@ export const NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION = 'life-os.data-rights-contributor.v1' as const; const CONTRIBUTOR_NAME = 'notification.service' as const; const EXPORT_SCHEMA_VERSION = 'notification.data-rights.v1' as const; +const EXPORT_CURSOR_VERSION = 'notification.data-rights.cursor.v1' as const; const MAX_EXPORT_RECORDS = 1_000; +const MAX_EXPORT_CURSOR_BYTES = 512; const MAX_JSON_DEPTH = 16; const MAX_JSON_CONTAINER_ITEMS = 2_000; const MAX_JSON_STRING_BYTES = 64 * 1024; @@ -16,6 +18,9 @@ const MAX_JSON_KEY_BYTES = 256; 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 SHA_256_PATTERN = /^[0-9a-f]{64}$/u; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; +const ISO_INSTANT_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/u; /** JSON-safe value returned by the Notification-owned contributor. */ export type NotificationDataRightsJsonValue = @@ -26,15 +31,33 @@ export type NotificationDataRightsJsonValue = | readonly NotificationDataRightsJsonValue[] | { readonly [key: string]: NotificationDataRightsJsonValue }; +/** Shared validated authority fields carried by every Notification data-rights request. */ +interface NotificationDataRightsRequestBase { + readonly contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly requestId: string; +} + /** Versioned request accepted by the Notification-owned contributor. */ -export type NotificationDataRightsRequest = Readonly<{ - contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; - operation: 'export' | 'erase_preflight' | 'erase' | 'verify_erased'; - workspaceId: string; - requestedByUserId: string; - requestId: string; - idempotencyKey?: string; -}>; +export type NotificationDataRightsRequest = + | Readonly< + NotificationDataRightsRequestBase & { + readonly operation: 'export'; + readonly cursor?: string; + } + > + | Readonly< + NotificationDataRightsRequestBase & { + readonly operation: 'erase_preflight' | 'verify_erased'; + } + > + | Readonly< + NotificationDataRightsRequestBase & { + readonly operation: 'erase'; + readonly idempotencyKey: string; + } + >; /** Successful response emitted by the Notification-owned contributor. */ export type NotificationDataRightsResponse = @@ -47,6 +70,7 @@ export type NotificationDataRightsResponse = recordCount: number; sha256: string; data: NotificationDataRightsJsonValue; + nextCursor?: string; }> | Readonly<{ contractVersion: typeof NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION; @@ -80,10 +104,27 @@ interface NormalizedRequestBase { readonly requestId: string; } +/** Stable ordering discriminator for exported Notification evidence. */ +type EvidenceKind = + | 'inbox_message' + | 'reminder_occurrence' + | 'reminder_outcome'; + +/** Opaque keyset position for the next deterministic export page. */ +interface ExportCursor { + readonly evidenceTime: string; + readonly evidenceKind: EvidenceKind; + readonly evidenceId: string; +} + /** Canonical request after every untrusted field is validated. */ type NormalizedRequest = | (NormalizedRequestBase & { - readonly operation: 'export' | 'erase_preflight' | 'verify_erased'; + readonly operation: 'export'; + readonly cursor: ExportCursor | undefined; + }) + | (NormalizedRequestBase & { + readonly operation: 'erase_preflight' | 'verify_erased'; }) | (NormalizedRequestBase & { readonly operation: 'erase'; @@ -92,9 +133,15 @@ type NormalizedRequest = /** Aggregate row returned by the bounded one-statement export query. */ interface ExportRow { - reminder_occurrences: unknown; - reminder_outcomes: unknown; - inbox_messages: unknown; + evidence_records: unknown; +} + +/** Untrusted wrapper returned by the cross-table export query. */ +interface ExportEvidenceRecord { + readonly evidenceTime: string; + readonly evidenceKind: EvidenceKind; + readonly evidenceId: string; + readonly data: NotificationDataRightsJsonValue; } /** Privilege evidence required before destructive Notification erasure. */ @@ -205,6 +252,20 @@ function compareCanonicalKeys(left: string, right: string): number { return Number(left > right) - Number(left < right); } +/** Requires one canonical UTC instant suitable for PostgreSQL keyset comparison. */ +function requireIsoInstant(value: unknown): string { + if (typeof value !== 'string') { + return invalidDataRights(); + } + if (!ISO_INSTANT_PATTERN.test(value)) { + return invalidDataRights(); + } + if (!Number.isFinite(Date.parse(value))) { + return invalidDataRights(); + } + return value; +} + /** Converts untrusted JSON evidence to deterministic canonical JSON while enforcing bounds. */ function canonicalJson(value: unknown, depth = 0): string { if (depth > MAX_JSON_DEPTH) { @@ -255,12 +316,6 @@ function canonicalJson(value: unknown, depth = 0): string { return invalidDataRights(); } -/** Validates one JSON-safe value and returns the same value with a narrowed type. */ -function requireJsonValue(value: unknown): NotificationDataRightsJsonValue { - canonicalJson(value); - return value as NotificationDataRightsJsonValue; -} - /** Computes deterministic SHA-256 evidence over canonical bounded JSON. */ function digest(value: unknown): string { return createHash('sha256') @@ -280,6 +335,83 @@ function exactlyOne(result: NotificationSqlQueryResult): Row { return row; } +/** Decodes and validates one bounded opaque export cursor. */ +function decodeExportCursor(value: unknown): ExportCursor { + if (typeof value !== 'string') { + return invalidDataRights(); + } + if (value.length === 0 || value.length > MAX_EXPORT_CURSOR_BYTES) { + return invalidDataRights(); + } + if (!BASE64URL_PATTERN.test(value)) { + return invalidDataRights(); + } + const decoded = Buffer.from(value, 'base64url').toString('utf8'); + let untrusted: unknown; + try { + untrusted = JSON.parse(decoded); + } catch { + return invalidDataRights(); + } + const record = requireRecord(untrusted); + requireExactKeys(record, [ + 'version', + 'evidenceTime', + 'evidenceKind', + 'evidenceId', + ]); + if (record.version !== EXPORT_CURSOR_VERSION) { + return invalidDataRights(); + } + if ( + record.evidenceKind !== 'inbox_message' && + record.evidenceKind !== 'reminder_occurrence' && + record.evidenceKind !== 'reminder_outcome' + ) { + return invalidDataRights(); + } + return Object.freeze({ + evidenceTime: requireIsoInstant(record.evidenceTime), + evidenceKind: record.evidenceKind, + evidenceId: requireUuidV4(record.evidenceId), + }); +} + +/** Encodes one validated keyset position as an opaque cursor. */ +function encodeExportCursor(cursor: ExportCursor): string { + const serialized = canonicalJson({ + version: EXPORT_CURSOR_VERSION, + evidenceTime: cursor.evidenceTime, + evidenceKind: cursor.evidenceKind, + evidenceId: cursor.evidenceId, + }); + return Buffer.from(serialized, 'utf8').toString('base64url'); +} + +/** Validates one cross-table export row before it reaches portability output. */ +function requireExportEvidenceRecord(value: unknown): ExportEvidenceRecord { + const record = requireRecord(value); + requireExactKeys(record, [ + 'evidenceTime', + 'evidenceKind', + 'evidenceId', + 'data', + ]); + if ( + record.evidenceKind !== 'inbox_message' && + record.evidenceKind !== 'reminder_occurrence' && + record.evidenceKind !== 'reminder_outcome' + ) { + return invalidDataRights(); + } + return Object.freeze({ + evidenceTime: requireIsoInstant(record.evidenceTime), + evidenceKind: record.evidenceKind, + evidenceId: requireUuidV4(record.evidenceId), + data: record.data as NotificationDataRightsJsonValue, + }); +} + /** Validates the exact v1 request shape before any Notification persistence access. */ function normalizeRequest(untrusted: unknown): NormalizedRequest { const record = requireRecord(untrusted); @@ -302,6 +434,17 @@ function normalizeRequest(untrusted: unknown): NormalizedRequest { 'requestedByUserId', 'requestId', ]; + if (operation === 'export') { + const hasCursor = Object.prototype.hasOwnProperty.call(record, 'cursor'); + requireExactKeys(record, hasCursor ? [...baseKeys, 'cursor'] : baseKeys); + return { + operation, + workspaceId: requireUuidV4(record.workspaceId), + requestedByUserId: requireUuidV4(record.requestedByUserId), + requestId: requireUuidV4(record.requestId), + cursor: hasCursor ? decodeExportCursor(record.cursor) : undefined, + }; + } requireExactKeys( record, operation === 'erase' ? [...baseKeys, 'idempotencyKey'] : baseKeys, @@ -348,6 +491,7 @@ export class NotificationDataRightsContributor { return await this.exportWorkspace( request.workspaceId, request.requestId, + request.cursor, ); case 'erase_preflight': return await this.preflightErase(request.requestId); @@ -358,16 +502,20 @@ export class NotificationDataRightsContributor { } } - /** Exports one deterministic, bounded, tenant-scoped Notification section. */ + /** Exports one deterministic bounded page of tenant-scoped Notification evidence. */ private async exportWorkspace( workspaceId: string, requestId: string, + cursor: ExportCursor | undefined, ): Promise { const row = exactlyOne( await this.query( - `SELECT - COALESCE(( - SELECT jsonb_agg(jsonb_build_object( + `WITH candidate_evidence AS ( + SELECT + created_at AS evidence_time, + 'reminder_occurrence'::text AS evidence_kind, + reminder_id AS evidence_id, + jsonb_build_object( 'reminderId', reminder_id, 'title', reminder_title, 'dueAt', to_char(due_instant AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), @@ -380,16 +528,20 @@ export class NotificationDataRightsContributor { 'claimExpiresAt', CASE WHEN claim_expires_at IS NULL THEN NULL ELSE to_char(claim_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') END, 'createdAt', to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), 'updatedAt', to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') - ) ORDER BY created_at ASC, reminder_id ASC) - FROM ( - SELECT * FROM notification_service.reminder_occurrences - WHERE workspace_id = $1 - ORDER BY created_at ASC, reminder_id ASC - LIMIT $2 - ) AS bounded_occurrences - ), '[]'::jsonb) AS reminder_occurrences, - COALESCE(( - SELECT jsonb_agg(jsonb_build_object( + ) AS evidence_data + FROM notification_service.reminder_occurrences + WHERE workspace_id = $1 + AND ( + $2::timestamptz IS NULL + OR (created_at, 'reminder_occurrence'::text, reminder_id) > + ($2::timestamptz, $3::text, $4::uuid) + ) + UNION ALL + SELECT + occurred_at AS evidence_time, + 'reminder_outcome'::text AS evidence_kind, + outcome_id AS evidence_id, + jsonb_build_object( 'outcomeId', outcome_id, 'reminderId', reminder_id, 'kind', outcome_kind, @@ -398,16 +550,20 @@ export class NotificationDataRightsContributor { 'reason', outcome_reason, 'deliveryLocalDate', delivery_local_date, 'createdAt', to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') - ) ORDER BY occurred_at ASC, outcome_id ASC) - FROM ( - SELECT * FROM notification_service.reminder_outcomes - WHERE workspace_id = $1 - ORDER BY occurred_at ASC, outcome_id ASC - LIMIT $2 - ) AS bounded_outcomes - ), '[]'::jsonb) AS reminder_outcomes, - COALESCE(( - SELECT jsonb_agg(jsonb_build_object( + ) AS evidence_data + FROM notification_service.reminder_outcomes + WHERE workspace_id = $1 + AND ( + $2::timestamptz IS NULL + OR (occurred_at, 'reminder_outcome'::text, outcome_id) > + ($2::timestamptz, $3::text, $4::uuid) + ) + UNION ALL + SELECT + delivered_at AS evidence_time, + 'inbox_message'::text AS evidence_kind, + message_id AS evidence_id, + jsonb_build_object( 'messageId', message_id, 'reminderId', reminder_id, 'title', message_title, @@ -417,47 +573,89 @@ export class NotificationDataRightsContributor { 'readAt', CASE WHEN read_at IS NULL THEN NULL ELSE to_char(read_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') END, 'createdAt', to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), 'updatedAt', to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') - ) ORDER BY delivered_at ASC, message_id ASC) - FROM ( - SELECT * FROM notification_service.inbox_messages - WHERE workspace_id = $1 - ORDER BY delivered_at ASC, message_id ASC - LIMIT $2 - ) AS bounded_messages - ), '[]'::jsonb) AS inbox_messages`, - [workspaceId, MAX_EXPORT_RECORDS + 1], + ) AS evidence_data + FROM notification_service.inbox_messages + WHERE workspace_id = $1 + AND ( + $2::timestamptz IS NULL + OR (delivered_at, 'inbox_message'::text, message_id) > + ($2::timestamptz, $3::text, $4::uuid) + ) + ), bounded_evidence AS ( + SELECT evidence_time, evidence_kind, evidence_id, evidence_data + FROM candidate_evidence + ORDER BY evidence_time ASC, evidence_kind ASC, evidence_id ASC + LIMIT $5 + ) + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'evidenceTime', to_char( + evidence_time AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"' + ), + 'evidenceKind', evidence_kind, + 'evidenceId', evidence_id, + 'data', evidence_data + ) + ORDER BY evidence_time ASC, evidence_kind ASC, evidence_id ASC + ), + '[]'::jsonb + ) AS evidence_records + FROM bounded_evidence`, + [ + workspaceId, + cursor?.evidenceTime ?? null, + cursor?.evidenceKind ?? null, + cursor?.evidenceId ?? null, + MAX_EXPORT_RECORDS + 1, + ], ), ); - if (!Array.isArray(row.reminder_occurrences)) { - return invalidDataRights(); - } - if (!Array.isArray(row.reminder_outcomes)) { + if (!Array.isArray(row.evidence_records)) { return invalidDataRights(); } - if (!Array.isArray(row.inbox_messages)) { + if (row.evidence_records.length > MAX_EXPORT_RECORDS + 1) { return invalidDataRights(); } - const recordCount = - row.reminder_occurrences.length + - row.reminder_outcomes.length + - row.inbox_messages.length; - if (recordCount > MAX_EXPORT_RECORDS) { - return invalidDataRights(); + + const page = Array.from( + row.evidence_records.slice(0, MAX_EXPORT_RECORDS), + (record) => requireExportEvidenceRecord(record), + ); + const reminderOccurrences: NotificationDataRightsJsonValue[] = []; + const reminderOutcomes: NotificationDataRightsJsonValue[] = []; + const inboxMessages: NotificationDataRightsJsonValue[] = []; + for (const record of page) { + if (record.evidenceKind === 'reminder_occurrence') { + reminderOccurrences.push(record.data); + } else if (record.evidenceKind === 'reminder_outcome') { + reminderOutcomes.push(record.data); + } else { + inboxMessages.push(record.data); + } } const data = Object.freeze({ - reminderOccurrences: requireJsonValue(row.reminder_occurrences), - reminderOutcomes: requireJsonValue(row.reminder_outcomes), - inboxMessages: requireJsonValue(row.inbox_messages), + reminderOccurrences: Object.freeze(reminderOccurrences), + reminderOutcomes: Object.freeze(reminderOutcomes), + inboxMessages: Object.freeze(inboxMessages), }); + const hasMore = row.evidence_records.length > MAX_EXPORT_RECORDS; + const nextCursor = hasMore + ? encodeExportCursor(page[MAX_EXPORT_RECORDS - 1] as ExportEvidenceRecord) + : undefined; + const sha256 = digest(data); + return { contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, contributor: CONTRIBUTOR_NAME, operation: 'export', requestId, schemaVersion: EXPORT_SCHEMA_VERSION, - recordCount, - sha256: digest(data), + recordCount: page.length, + sha256, data, + ...(nextCursor === undefined ? {} : { nextCursor }), }; } From caa340ce88be24fd1bae70ef3b33fc1fea465920 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:17:31 +0900 Subject: [PATCH 049/150] test(notification): reproduce erasure write race --- ...tification-data-rights.integration.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights.integration.test.ts b/apps/notification-service/src/notification-data-rights.integration.test.ts index ffc04abd..7d099b9a 100644 --- a/apps/notification-service/src/notification-data-rights.integration.test.ts +++ b/apps/notification-service/src/notification-data-rights.integration.test.ts @@ -201,6 +201,99 @@ describeWithPostgres('Notification data-rights PostgreSQL integration', () => { ).toBe(3); }); + it('prevents same-workspace writes from surviving an erasure and its exact replay', async () => { + const workspaceId = randomUUID(); + const requestedByUserId = randomUUID(); + const requestId = randomUUID(); + const idempotencyKey = randomUUID(); + const lateReminderId = randomUUID(); + await seedWorkspace(administrativePool, workspaceId); + + const erasureClient = await administrativePool.connect(); + const writerClient = await administrativePool.connect(); + let erasureCommitted = false; + try { + await erasureClient.query('BEGIN'); + const first = await erasureClient.query<{ + result_erased_records: number; + result_receipt_sha256: string; + }>( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, requestedByUserId, requestId, idempotencyKey], + ); + + await writerClient.query("SET statement_timeout = '250ms'"); + await expect( + writerClient.query( + `INSERT INTO notification_service.reminder_occurrences ( + reminder_id, + workspace_id, + reminder_title, + due_instant, + time_zone, + daily_delivery_limit, + delivery_attempt_count, + occurrence_status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + lateReminderId, + workspaceId, + 'Concurrent erasure reminder', + '2026-08-12T01:00:00.000Z', + 'UTC', + 3, + 0, + 'pending', + ], + ), + ).rejects.toMatchObject({ code: '57014' }); + + await erasureClient.query('COMMIT'); + erasureCommitted = true; + await writerClient.query('SET statement_timeout = 0'); + + await expect( + writerClient.query( + `INSERT INTO notification_service.reminder_occurrences ( + reminder_id, + workspace_id, + reminder_title, + due_instant, + time_zone, + daily_delivery_limit, + delivery_attempt_count, + occurrence_status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + lateReminderId, + workspaceId, + 'Concurrent erasure reminder', + '2026-08-12T01:00:00.000Z', + 'UTC', + 3, + 0, + 'pending', + ], + ), + ).rejects.toMatchObject({ code: '55000' }); + expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(0); + + const replay = await administrativePool.query( + 'SELECT * FROM notification_service.erase_workspace_data($1, $2, $3, $4)', + [workspaceId, requestedByUserId, requestId, idempotencyKey], + ); + expect(replay.rows).toEqual(first.rows); + expect(await workspaceRecordCount(administrativePool, workspaceId)).toBe(0); + } finally { + if (!erasureCommitted) { + await erasureClient.query('ROLLBACK').catch(() => undefined); + } + await writerClient.query('SET statement_timeout = 0').catch(() => undefined); + erasureClient.release(); + writerClient.release(); + } + }); + it('rejects non-v4 erasure authority before changing tenant data', async () => { const workspaceId = randomUUID(); await seedWorkspace(administrativePool, workspaceId); From d51d57eba4ad573fe0db4d1edc43dcf30020bf82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:19:35 +0900 Subject: [PATCH 050/150] fix(notification): fence writes during data erasure --- .../migrations/0002_data_rights_erasure.sql | 164 +++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/apps/notification-service/migrations/0002_data_rights_erasure.sql b/apps/notification-service/migrations/0002_data_rights_erasure.sql index 870efa33..130b3037 100644 --- a/apps/notification-service/migrations/0002_data_rights_erasure.sql +++ b/apps/notification-service/migrations/0002_data_rights_erasure.sql @@ -61,6 +61,119 @@ COMMENT ON TABLE notification_service.data_rights_erasure_authorizations IS REVOKE ALL ON TABLE notification_service.data_rights_erasure_authorizations FROM PUBLIC; +CREATE TABLE notification_service.data_rights_workspace_erasures ( + workspace_id uuid NOT NULL, + requested_by_user_id uuid NOT NULL, + request_id uuid NOT NULL, + idempotency_key uuid NOT NULL, + erased_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + CONSTRAINT notification_data_rights_workspace_erasures_primary + PRIMARY KEY (workspace_id), + CONSTRAINT notification_data_rights_workspace_erasures_workspace_uuid_v4 CHECK ( + get_byte(uuid_send(workspace_id), 6) >> 4 = 4 + AND get_byte(uuid_send(workspace_id), 8) >> 6 = 2 + ), + CONSTRAINT notification_data_rights_workspace_erasures_user_uuid_v4 CHECK ( + get_byte(uuid_send(requested_by_user_id), 6) >> 4 = 4 + AND get_byte(uuid_send(requested_by_user_id), 8) >> 6 = 2 + ), + CONSTRAINT notification_data_rights_workspace_erasures_request_uuid_v4 CHECK ( + get_byte(uuid_send(request_id), 6) >> 4 = 4 + AND get_byte(uuid_send(request_id), 8) >> 6 = 2 + ), + CONSTRAINT notification_data_rights_workspace_erasures_idempotency_uuid_v4 CHECK ( + get_byte(uuid_send(idempotency_key), 6) >> 4 = 4 + AND get_byte(uuid_send(idempotency_key), 8) >> 6 = 2 + ) +); + +COMMENT ON TABLE notification_service.data_rights_workspace_erasures IS + 'Terminal owner-only workspace erasure fence. Notification writes must coordinate on the workspace advisory key and reject a persisted fence.'; + +REVOKE ALL ON TABLE notification_service.data_rights_workspace_erasures FROM PUBLIC; + +CREATE FUNCTION notification_service.guard_erased_workspace_write() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, notification_service +AS $$ +DECLARE + new_workspace_lock_key bigint; + old_workspace_lock_key bigint; +BEGIN + new_workspace_lock_key := hashtextextended( + 'notification.service:workspace:' || NEW.workspace_id::text, + 0 + ); + + IF TG_OP = 'UPDATE' THEN + old_workspace_lock_key := hashtextextended( + 'notification.service:workspace:' || OLD.workspace_id::text, + 0 + ); + IF old_workspace_lock_key < new_workspace_lock_key THEN + PERFORM pg_advisory_xact_lock_shared(old_workspace_lock_key); + PERFORM pg_advisory_xact_lock_shared(new_workspace_lock_key); + ELSIF old_workspace_lock_key > new_workspace_lock_key THEN + PERFORM pg_advisory_xact_lock_shared(new_workspace_lock_key); + PERFORM pg_advisory_xact_lock_shared(old_workspace_lock_key); + ELSE + PERFORM pg_advisory_xact_lock_shared(new_workspace_lock_key); + END IF; + + IF EXISTS ( + SELECT 1 + FROM notification_service.data_rights_workspace_erasures + WHERE workspace_id IN (OLD.workspace_id, NEW.workspace_id) + ) THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'Notification workspace is erased'; + END IF; + ELSE + PERFORM pg_advisory_xact_lock_shared(new_workspace_lock_key); + IF EXISTS ( + SELECT 1 + FROM notification_service.data_rights_workspace_erasures + WHERE workspace_id = NEW.workspace_id + ) THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'Notification workspace is erased'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION notification_service.guard_erased_workspace_write() IS + 'SECURITY DEFINER write fence. Normal Notification inserts and updates take shared workspace advisory locks and reject durable data-rights erasure tombstones; erasure takes the matching exclusive lock.'; + +REVOKE ALL ON FUNCTION notification_service.guard_erased_workspace_write() FROM PUBLIC; + +DROP TRIGGER IF EXISTS reminder_occurrences_workspace_erasure_guard + ON notification_service.reminder_occurrences; +CREATE TRIGGER reminder_occurrences_workspace_erasure_guard +BEFORE INSERT OR UPDATE ON notification_service.reminder_occurrences +FOR EACH ROW +EXECUTE FUNCTION notification_service.guard_erased_workspace_write(); + +DROP TRIGGER IF EXISTS reminder_outcomes_workspace_erasure_guard + ON notification_service.reminder_outcomes; +CREATE TRIGGER reminder_outcomes_workspace_erasure_guard +BEFORE INSERT OR UPDATE ON notification_service.reminder_outcomes +FOR EACH ROW +EXECUTE FUNCTION notification_service.guard_erased_workspace_write(); + +DROP TRIGGER IF EXISTS inbox_messages_workspace_erasure_guard + ON notification_service.inbox_messages; +CREATE TRIGGER inbox_messages_workspace_erasure_guard +BEFORE INSERT OR UPDATE ON notification_service.inbox_messages +FOR EACH ROW +EXECUTE FUNCTION notification_service.guard_erased_workspace_write(); + CREATE OR REPLACE FUNCTION notification_service.reject_reminder_outcome_mutation() RETURNS trigger LANGUAGE plpgsql @@ -109,6 +222,11 @@ DECLARE existing_request_id uuid; existing_erased_records integer; existing_receipt_sha256 text; + existing_fence_requested_by_user_id uuid; + existing_fence_request_id uuid; + existing_fence_idempotency_key uuid; + workspace_fence_found boolean := false; + receipt_found boolean := false; deleted_inbox_messages integer := 0; deleted_reminder_outcomes integer := 0; deleted_reminder_occurrences integer := 0; @@ -136,11 +254,23 @@ BEGIN PERFORM pg_advisory_xact_lock( hashtextextended( - 'notification.service:erase:' || target_workspace_id::text, + 'notification.service:workspace:' || target_workspace_id::text, 0 ) ); + SELECT + requested_by_user_id, + request_id, + idempotency_key + INTO + existing_fence_requested_by_user_id, + existing_fence_request_id, + existing_fence_idempotency_key + FROM notification_service.data_rights_workspace_erasures + WHERE workspace_id = target_workspace_id; + workspace_fence_found := FOUND; + SELECT requested_by_user_id, request_id, @@ -154,8 +284,9 @@ BEGIN FROM notification_service.data_rights_erasure_receipts WHERE workspace_id = target_workspace_id AND idempotency_key = target_idempotency_key; + receipt_found := FOUND; - IF FOUND THEN + IF receipt_found THEN IF existing_requested_by_user_id <> target_requested_by_user_id OR existing_request_id <> target_request_id THEN @@ -163,12 +294,39 @@ BEGIN ERRCODE = '23505', MESSAGE = 'Notification erasure replay authority conflicts'; END IF; + IF NOT workspace_fence_found + OR existing_fence_requested_by_user_id <> target_requested_by_user_id + OR existing_fence_request_id <> target_request_id + OR existing_fence_idempotency_key <> target_idempotency_key + THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'Notification erasure replay fence is invalid'; + END IF; RETURN QUERY SELECT existing_erased_records, existing_receipt_sha256; RETURN; END IF; + IF workspace_fence_found THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'Notification workspace erasure authority conflicts'; + END IF; + + INSERT INTO notification_service.data_rights_workspace_erasures ( + workspace_id, + requested_by_user_id, + request_id, + idempotency_key + ) VALUES ( + target_workspace_id, + target_requested_by_user_id, + target_request_id, + target_idempotency_key + ); + DELETE FROM notification_service.inbox_messages WHERE workspace_id = target_workspace_id; GET DIAGNOSTICS deleted_inbox_messages = ROW_COUNT; @@ -261,6 +419,6 @@ COMMENT ON FUNCTION notification_service.erase_workspace_data( uuid, uuid ) IS - 'Atomic replay-safe owner-authorized Notification data-rights erasure; runtime roles require an explicit EXECUTE grant.'; + 'Atomic replay-safe owner-authorized Notification data-rights erasure. It holds the exclusive workspace coordination lock, persists a terminal write fence before deletion, and requires matching fence evidence on replay; runtime roles require an explicit EXECUTE grant.'; COMMIT; From a200ebe28d9c35eaf383d47ea227586d78656908 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:20:48 +0900 Subject: [PATCH 051/150] test(notification): require durable erasure write fence --- ...notification-data-rights-migration.test.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-migration.test.ts b/apps/notification-service/src/notification-data-rights-migration.test.ts index 52f07fd9..28167610 100644 --- a/apps/notification-service/src/notification-data-rights-migration.test.ts +++ b/apps/notification-service/src/notification-data-rights-migration.test.ts @@ -41,10 +41,11 @@ describe('Notification data-rights erasure database contract', () => { expect(sql).toContain('SET search_path = pg_catalog, notification_service'); expect(sql).toContain('pg_advisory_xact_lock'); expect(sql).toContain( - "'notification.service:erase:' || target_workspace_id::text", + "'notification.service:workspace:' || target_workspace_id::text", ); - expect(sql).toContain('IF FOUND THEN'); + expect(sql).toContain('IF receipt_found THEN'); expect(sql).toContain('Notification erasure replay authority conflicts'); + expect(sql).toContain('Notification erasure replay fence is invalid'); expect(sql).toContain('sha256('); expect(sql).toContain("'notification.service'"); expect(sql).toMatch( @@ -52,6 +53,42 @@ describe('Notification data-rights erasure database contract', () => { ); }); + it('fences normal writes against concurrent and completed workspace erasure', async () => { + const sql = await migrationSql(); + const fenceInsert = sql.indexOf( + 'INSERT INTO notification_service.data_rights_workspace_erasures', + ); + const inboxDelete = sql.indexOf( + 'DELETE FROM notification_service.inbox_messages', + ); + + expect(sql).toContain( + 'CREATE TABLE notification_service.data_rights_workspace_erasures', + ); + expect(sql).toContain( + 'CREATE FUNCTION notification_service.guard_erased_workspace_write()', + ); + expect(sql).toContain('pg_advisory_xact_lock_shared'); + expect(sql).toContain( + "'notification.service:workspace:' || NEW.workspace_id::text", + ); + expect(sql).toContain('Notification workspace is erased'); + expect(sql).toContain( + 'CREATE TRIGGER reminder_occurrences_workspace_erasure_guard', + ); + expect(sql).toContain( + 'CREATE TRIGGER reminder_outcomes_workspace_erasure_guard', + ); + expect(sql).toContain( + 'CREATE TRIGGER inbox_messages_workspace_erasure_guard', + ); + expect(fenceInsert).toBeGreaterThan(-1); + expect(inboxDelete).toBeGreaterThan(fenceInsert); + expect(sql).toMatch( + /REVOKE ALL ON TABLE notification_service\.data_rights_workspace_erasures FROM PUBLIC;/u, + ); + }); + it('keeps append-only outcome protection active during owner-authorized erasure', async () => { const sql = await migrationSql(); const inboxDelete = sql.indexOf( From 97e9afb28effe2b702b2894806955457482eabd8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:21:46 +0900 Subject: [PATCH 052/150] test(notification): keep erasure fence runtime-private --- infra/tests/notification-migration-role.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts index 6cadd2cf..df8a2b05 100644 --- a/infra/tests/notification-migration-role.spec.ts +++ b/infra/tests/notification-migration-role.spec.ts @@ -25,7 +25,7 @@ describe('Notification database migration authority contract', () => { 'GRANT USAGE ON SCHEMA notification_service TO :"service_runtime_role"', ); expect(migrationRunner).toContain( - 'REVOKE ALL PRIVILEGES ON TABLE\n notification_service.data_rights_erasure_receipts,\n notification_service.data_rights_erasure_authorizations\nFROM :"service_runtime_role";', + 'REVOKE ALL PRIVILEGES ON TABLE\n notification_service.data_rights_erasure_receipts,\n notification_service.data_rights_erasure_authorizations,\n notification_service.data_rights_workspace_erasures\nFROM :"service_runtime_role";', ); expect(migrationRunner).toContain( 'GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid)', From 6c0a1a064ca0d2950c8f893e084423f900f69b9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:22:54 +0900 Subject: [PATCH 053/150] fix(notification): keep workspace erasure fence private --- infra/kubernetes/run-migrations.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/kubernetes/run-migrations.sh b/infra/kubernetes/run-migrations.sh index f3fb2b4e..136ff12d 100644 --- a/infra/kubernetes/run-migrations.sh +++ b/infra/kubernetes/run-migrations.sh @@ -259,7 +259,8 @@ GRANT SELECT, INSERT ON TABLE TO :"service_runtime_role"; REVOKE ALL PRIVILEGES ON TABLE notification_service.data_rights_erasure_receipts, - notification_service.data_rights_erasure_authorizations + notification_service.data_rights_erasure_authorizations, + notification_service.data_rights_workspace_erasures FROM :"service_runtime_role"; GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid) TO :"service_runtime_role"; From 374024283cb248cd4e05efc7b198bbf266a7f85e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:40:17 +0900 Subject: [PATCH 054/150] test(notification): reject impossible export cursor instants --- ...otification-data-rights-pagination.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights-pagination.test.ts b/apps/notification-service/src/notification-data-rights-pagination.test.ts index 0bc24308..7bf51a4d 100644 --- a/apps/notification-service/src/notification-data-rights-pagination.test.ts +++ b/apps/notification-service/src/notification-data-rights-pagination.test.ts @@ -47,6 +47,18 @@ function exportRequest(cursor?: string): Record { }; } +function encodedCursor(evidenceTime: string): string { + return Buffer.from( + JSON.stringify({ + version: 'notification.data-rights.cursor.v1', + evidenceTime, + evidenceKind: 'reminder_occurrence', + evidenceId: '11111111-1111-4111-8111-111111111111', + }), + 'utf8', + ).toString('base64url'); +} + function reminderEvidence(index: number): Record { const evidenceId = `11111111-1111-4111-8111-${index .toString(16) @@ -151,4 +163,21 @@ describe('Notification data-rights export pagination', () => { ).rejects.toThrow('Notification data-rights operation failed'); expect(client.calls).toEqual([]); }); + + it.each([ + '2026-02-30T00:00:00Z', + '2026-04-31T00:00:00Z', + '2026-01-01T24:00:00Z', + ])( + 'rejects impossible cursor instant %s before persistence access', + async (evidenceTime) => { + const client = new ScriptedClient([]); + const contributor = new NotificationDataRightsContributor(client); + + await expect( + contributor.handle(exportRequest(encodedCursor(evidenceTime))), + ).rejects.toThrow('Notification data-rights operation failed'); + expect(client.calls).toEqual([]); + }, + ); }); From 7605d90cd9688a61cc891879404a15f8e048ac9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:43:27 +0900 Subject: [PATCH 055/150] fix(notification): reject normalized cursor instants --- .../src/notification-data-rights.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index 44ec8b6b..453ce8e4 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -252,7 +252,7 @@ function compareCanonicalKeys(left: string, right: string): number { return Number(left > right) - Number(left < right); } -/** Requires one canonical UTC instant suitable for PostgreSQL keyset comparison. */ +/** Requires one real UTC calendar instant suitable for PostgreSQL keyset comparison. */ function requireIsoInstant(value: unknown): string { if (typeof value !== 'string') { return invalidDataRights(); @@ -260,7 +260,24 @@ function requireIsoInstant(value: unknown): string { if (!ISO_INSTANT_PATTERN.test(value)) { return invalidDataRights(); } - if (!Number.isFinite(Date.parse(value))) { + + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + const hour = Number(value.slice(11, 13)); + const minute = Number(value.slice(14, 16)); + const second = Number(value.slice(17, 19)); + const normalized = new Date(0); + normalized.setUTCFullYear(year, month - 1, day); + normalized.setUTCHours(hour, minute, second, 0); + if ( + normalized.getUTCFullYear() !== year || + normalized.getUTCMonth() !== month - 1 || + normalized.getUTCDate() !== day || + normalized.getUTCHours() !== hour || + normalized.getUTCMinutes() !== minute || + normalized.getUTCSeconds() !== second + ) { return invalidDataRights(); } return value; From 6261e7b87da0f9ffe64c5d20100e6c48f6833460 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:52:19 +0900 Subject: [PATCH 056/150] test(notification): require authenticated data-rights transport --- ...fication-data-rights-http-boundary.test.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-http-boundary.test.ts diff --git a/apps/notification-service/src/notification-data-rights-http-boundary.test.ts b/apps/notification-service/src/notification-data-rights-http-boundary.test.ts new file mode 100644 index 00000000..89a97737 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-http-boundary.test.ts @@ -0,0 +1,213 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION } from './notification-data-rights'; +import { + parseTrustedNotificationDataRightsRequest, + toNotificationDataRightsHttpException, +} from './notification-data-rights-http-boundary'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; +const SECRET = randomBytes(32).toString('base64url'); +const NOW_SECONDS = 1_786_334_400; +const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; +const CURSOR = Buffer.from( + JSON.stringify({ + version: 'notification.data-rights.cursor.v1', + evidenceTime: '2026-08-12T00:00:00.000000Z', + evidenceKind: 'reminder_occurrence', + evidenceId: '55555555-5555-4555-8555-555555555555', + }), + 'utf8', +).toString('base64url'); + +const exportRequest = Object.freeze({ + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + operation: 'export' as const, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + cursor: CURSOR, +}); + +/** Signs one exact Notification contributor request using the production canonical field order. */ +function signature( + request: Record, + issuedAt: string, + path = CONTRIBUTOR_PATH, +): string { + const idempotencyKey = + request.operation === 'erase' ? String(request.idempotencyKey) : '-'; + const cursor = request.operation === 'export' ? String(request.cursor ?? '-') : '-'; + return createHmac('sha256', SECRET) + .update( + [ + 'life-os.notification-data-rights-context.v1', + String(request.contractVersion), + String(request.workspaceId), + String(request.requestedByUserId), + String(request.requestId), + String(request.operation), + idempotencyKey, + cursor, + issuedAt, + 'POST', + path, + ].join('\n'), + 'utf8', + ) + .digest('base64url'); +} + +/** Returns the bounded HTTP status from one rejected trusted-boundary call. */ +async function rejectedStatus(operation: Promise): Promise { + try { + await operation; + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + return (error as HttpException).getStatus(); + } + throw new Error('Expected Notification data-rights transport to reject'); +} + +describe('Notification data-rights HTTP authority', () => { + it('accepts a fresh export bound to tenant, actor, cursor, method, and path', async () => { + const issuedAt = String(NOW_SECONDS); + await expect( + parseTrustedNotificationDataRightsRequest( + exportRequest, + { issuedAt, signature: signature(exportRequest, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ).resolves.toEqual(exportRequest); + }); + + it('fails closed if an export cursor changes after Identity signs the request', async () => { + const issuedAt = String(NOW_SECONDS); + const status = await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + { ...exportRequest, cursor: `${CURSOR}A` }, + { issuedAt, signature: signature(exportRequest, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ); + expect(status).toBe(401); + }); + + it('fails closed if caller-selected workspace authority changes after signing', async () => { + const issuedAt = String(NOW_SECONDS); + const status = await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + { + ...exportRequest, + workspaceId: '66666666-6666-4666-8666-666666666666', + }, + { issuedAt, signature: signature(exportRequest, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ); + expect(status).toBe(401); + }); + + it('accepts destructive idempotency only when the signed key matches the request', async () => { + const request = Object.freeze({ + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + operation: 'erase' as const, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }); + const issuedAt = String(NOW_SECONDS); + await expect( + parseTrustedNotificationDataRightsRequest( + request, + { issuedAt, signature: signature(request, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ).resolves.toEqual(request); + + const status = await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + { ...request, idempotencyKey: REQUEST_ID }, + { issuedAt, signature: signature(request, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ); + expect(status).toBe(401); + }); + + it.each([ + { + name: 'wrong path', + secret: SECRET, + binding: { method: 'POST', path: '/v1/internal/data-rights/other' }, + issuedAt: String(NOW_SECONDS), + }, + { + name: 'wrong method', + secret: SECRET, + binding: { method: 'GET', path: CONTRIBUTOR_PATH }, + issuedAt: String(NOW_SECONDS), + }, + { + name: 'stale evidence', + secret: SECRET, + binding: { method: 'POST', path: CONTRIBUTOR_PATH }, + issuedAt: String(NOW_SECONDS - 61), + }, + { + name: 'missing verifier secret', + secret: undefined, + binding: { method: 'POST', path: CONTRIBUTOR_PATH }, + issuedAt: String(NOW_SECONDS), + }, + ])('fails closed for $name', async ({ secret, binding, issuedAt }) => { + const status = await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + exportRequest, + { issuedAt, signature: signature(exportRequest, issuedAt) }, + secret, + binding, + NOW_SECONDS, + ), + ); + expect(status).toBe(secret === undefined ? 503 : 401); + }); + + it('rejects undeclared request fields before contributor code can observe them', async () => { + const issuedAt = String(NOW_SECONDS); + const request = { ...exportRequest, unexpected: 'authority' }; + const status = await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + request, + { issuedAt, signature: signature(request, issuedAt) }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ); + expect(status).toBe(400); + }); + + it('sanitizes contributor failures into a credential-free 503 problem', () => { + const exception = toNotificationDataRightsHttpException( + new Error('postgres password and internal topology'), + ); + expect(exception.getStatus()).toBe(503); + expect(JSON.stringify(exception.getResponse())).not.toContain('password'); + }); +}); From 85d2af207ff452bb5975c022d4988603d3b5862b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:55:10 +0900 Subject: [PATCH 057/150] fix(notification): authenticate data-rights transport --- .../notification-data-rights-http-boundary.ts | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-http-boundary.ts diff --git a/apps/notification-service/src/notification-data-rights-http-boundary.ts b/apps/notification-service/src/notification-data-rights-http-boundary.ts new file mode 100644 index 00000000..cdbdefdf --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-http-boundary.ts @@ -0,0 +1,302 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; +import { + NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + type NotificationDataRightsRequest, +} from './notification-data-rights'; + +/** Short-lived service-authentication headers for the private Notification contributor route. */ +export interface TrustedNotificationDataRightsContextHeaders { + readonly issuedAt: unknown; + readonly signature: unknown; +} + +/** Server-observed HTTP identity bound into one Notification contributor authorization proof. */ +export interface NotificationDataRightsRequestBinding { + readonly method: unknown; + readonly path: unknown; +} + +interface NotificationDataRightsProblemDetails { + readonly type: 'about:blank'; + readonly title: string; + readonly status: number; + readonly code: string; +} + +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 UNIX_SECONDS_PATTERN = /^(?:0|[1-9]\d{0,12})$/u; +const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const BASE64URL_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/u; +const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; +const MINIMUM_CONTEXT_SECRET_BYTES = 32; +const MAXIMUM_CONTEXT_AGE_SECONDS = 60; +const MAXIMUM_FUTURE_SKEW_SECONDS = 5; +const MAXIMUM_CURSOR_BYTES = 512; + +type NormalizedRequest = NotificationDataRightsRequest & + Readonly<{ + workspaceId: string; + requestedByUserId: string; + requestId: string; + }>; + +/** Builds one bounded RFC 7807-style transport problem without reflecting untrusted data. */ +function problemException( + status: number, + title: string, + code: string, +): HttpException { + const problem: NotificationDataRightsProblemDetails = { + type: 'about:blank', + title, + status, + code, + }; + return new HttpException(problem, status); +} + +/** Rejects malformed contributor request data before Notification persistence can observe it. */ +function invalidRequest(): never { + throw problemException( + 400, + 'Notification data-rights request is invalid', + 'invalid_data_rights_request', + ); +} + +/** Rejects forged, stale, future, or route-mismatched service authority. */ +function invalidContext(): never { + throw problemException( + 401, + 'Notification data-rights authority is invalid', + 'invalid_data_rights_context', + ); +} + +/** Rejects verifier configuration that cannot authenticate the internal caller. */ +function unavailableContext(): never { + throw problemException( + 503, + 'Notification data-rights authority is unavailable', + 'data_rights_context_unavailable', + ); +} + +/** Requires one ordinary JSON object so prototypes cannot add hidden authority fields. */ +function requireRecord(value: unknown): Record { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return invalidRequest(); + } + return value as Record; +} + +/** Requires exactly the documented operation-specific fields. */ +function requireExactKeys( + record: Record, + expectedKeys: readonly string[], +): void { + const expected = new Set(expectedKeys); + const actual = Object.keys(record); + if ( + actual.length !== expected.size || + actual.some((key) => !expected.has(key)) + ) { + invalidRequest(); + } +} + +/** Requires and canonicalizes one opaque UUIDv4 product identity. */ +function requireUuidV4(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidRequest(); + } + return value.toLowerCase(); +} + +/** Requires the bounded opaque pagination token; semantic cursor validation remains contributor-owned. */ +function requireCursor(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'ascii') > MAXIMUM_CURSOR_BYTES || + !BASE64URL_CURSOR_PATTERN.test(value) + ) { + return invalidRequest(); + } + return value; +} + +/** Normalizes exactly the private Notification v1 contributor request schema. */ +function normalizeRequest(body: unknown): NormalizedRequest { + const request = requireRecord(body); + const commonKeys = [ + 'contractVersion', + 'operation', + 'workspaceId', + 'requestedByUserId', + 'requestId', + ] as const; + if ( + request.contractVersion !== NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION || + (request.operation !== 'export' && + request.operation !== 'erase_preflight' && + request.operation !== 'erase' && + request.operation !== 'verify_erased') + ) { + return invalidRequest(); + } + + const workspaceId = requireUuidV4(request.workspaceId); + const requestedByUserId = requireUuidV4(request.requestedByUserId); + const requestId = requireUuidV4(request.requestId); + + if (request.operation === 'export') { + const hasCursor = Object.prototype.hasOwnProperty.call(request, 'cursor'); + requireExactKeys(request, hasCursor ? [...commonKeys, 'cursor'] : commonKeys); + return { + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + operation: 'export', + workspaceId, + requestedByUserId, + requestId, + ...(hasCursor ? { cursor: requireCursor(request.cursor) } : {}), + }; + } + + if (request.operation === 'erase') { + requireExactKeys(request, [...commonKeys, 'idempotencyKey']); + return { + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + operation: 'erase', + workspaceId, + requestedByUserId, + requestId, + idempotencyKey: requireUuidV4(request.idempotencyKey), + }; + } + + requireExactKeys(request, commonKeys); + return { + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + operation: request.operation, + workspaceId, + requestedByUserId, + requestId, + }; +} + +/** Requires the one exact private POST resource that owns Notification contributor transport. */ +function requireRequestBinding( + binding: NotificationDataRightsRequestBinding, +): { readonly method: 'POST'; readonly path: typeof CONTRIBUTOR_PATH } { + if (binding.method !== 'POST' || binding.path !== CONTRIBUTOR_PATH) { + return invalidContext(); + } + return { method: 'POST', path: CONTRIBUTOR_PATH }; +} + +/** Computes the request-bound HMAC over every field that can change tenant or operation meaning. */ +function requestDigest( + request: NormalizedRequest, + issuedAt: string, + binding: Readonly<{ method: 'POST'; path: typeof CONTRIBUTOR_PATH }>, + secret: string, +): Buffer { + const idempotencyKey = + request.operation === 'erase' ? request.idempotencyKey : '-'; + const cursor = + request.operation === 'export' ? (request.cursor ?? '-') : '-'; + return createHmac('sha256', secret) + .update( + [ + 'life-os.notification-data-rights-context.v1', + request.contractVersion, + request.workspaceId, + request.requestedByUserId, + request.requestId, + request.operation, + idempotencyKey, + cursor, + issuedAt, + binding.method, + binding.path, + ].join('\n'), + 'utf8', + ) + .digest(); +} + +/** + * Verifies one exact Identity-to-Notification contributor request before persistence access. + * + * Tenant, actor, request, operation, destructive idempotency identity, export + * continuation, lifetime, HTTP method, and resource are HMAC-bound. The function + * returns only normalized request data and never forwards the verifier secret or + * signature to the contributor. Cursor semantics remain owned by the Notification + * contributor so transport authentication cannot become a second source of truth. + */ +export async function parseTrustedNotificationDataRightsRequest( + body: unknown, + headers: TrustedNotificationDataRightsContextHeaders, + secret: unknown, + requestBinding: NotificationDataRightsRequestBinding, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const request = normalizeRequest(body); + if ( + typeof secret !== 'string' || + Buffer.byteLength(secret, 'utf8') < MINIMUM_CONTEXT_SECRET_BYTES + ) { + return unavailableContext(); + } + const binding = requireRequestBinding(requestBinding); + if ( + typeof headers.issuedAt !== 'string' || + typeof headers.signature !== 'string' || + !UNIX_SECONDS_PATTERN.test(headers.issuedAt) || + !BASE64URL_SHA256_PATTERN.test(headers.signature) || + !Number.isSafeInteger(nowSeconds) || + nowSeconds < 0 + ) { + return invalidContext(); + } + + const issuedAtSeconds = Number(headers.issuedAt); + if ( + !Number.isSafeInteger(issuedAtSeconds) || + issuedAtSeconds > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS || + issuedAtSeconds < nowSeconds - MAXIMUM_CONTEXT_AGE_SECONDS + ) { + return invalidContext(); + } + + const expected = requestDigest(request, headers.issuedAt, binding, secret); + const actual = Buffer.from(headers.signature, 'base64url'); + if ( + actual.length !== expected.length || + actual.toString('base64url') !== headers.signature || + !timingSafeEqual(actual, expected) + ) { + return invalidContext(); + } + return request; +} + +/** Maps contributor/runtime failures to one credential-free private transport error. */ +export function toNotificationDataRightsHttpException( + error: unknown, +): HttpException { + void error; + return problemException( + 503, + 'Notification data-rights operation is unavailable', + 'data_rights_unavailable', + ); +} From 102b83948e7b452626d221931ce75a9866addb0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 21:59:39 +0900 Subject: [PATCH 058/150] test(notification): require private contributor controller --- ...otification-data-rights-controller.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-controller.test.ts diff --git a/apps/notification-service/src/notification-data-rights-controller.test.ts b/apps/notification-service/src/notification-data-rights-controller.test.ts new file mode 100644 index 00000000..2e2ac2ed --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-controller.test.ts @@ -0,0 +1,131 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { NotificationDataRightsResponse } from './notification-data-rights'; +import { NotificationDataRightsController } from './notification-data-rights-controller'; +import type { NotificationRuntime } from './notification-runtime'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const SECRET = randomBytes(32).toString('base64url'); +const PATH = '/v1/internal/data-rights/contributor'; +const ORIGINAL_SECRET = process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET; + +const body = Object.freeze({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'verify_erased' as const, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, +}); + +/** Signs the exact controller request contract so tests exercise production authority verification. */ +function signature(issuedAt: string): string { + return createHmac('sha256', SECRET) + .update( + [ + 'life-os.notification-data-rights-context.v1', + body.contractVersion, + body.workspaceId, + body.requestedByUserId, + body.requestId, + body.operation, + '-', + '-', + issuedAt, + 'POST', + PATH, + ].join('\n'), + 'utf8', + ) + .digest('base64url'); +} + +/** Produces one minimal runtime whose contributor records the authenticated request. */ +function runtime(recorded: unknown[]): NotificationRuntime { + return { + dataRightsContributor: { + async handle(request: unknown): Promise { + recorded.push(request); + return { + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'verify_erased', + requestId: REQUEST_ID, + erased: true, + evidenceSha256: 'a'.repeat(64), + }; + }, + }, + } as unknown as NotificationRuntime; +} + +afterEach(() => { + if (ORIGINAL_SECRET === undefined) { + delete process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET; + } else { + process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = ORIGINAL_SECRET; + } +}); + +describe('NotificationDataRightsController', () => { + it('passes only authenticated normalized authority to the contributor', async () => { + process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = SECRET; + const recorded: unknown[] = []; + const controller = new NotificationDataRightsController(runtime(recorded)); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute( + issuedAt, + signature(issuedAt), + { method: 'POST', originalUrl: PATH }, + body, + ), + ).resolves.toMatchObject({ operation: 'verify_erased', erased: true }); + expect(recorded).toEqual([body]); + }); + + it('rejects a route mismatch before the contributor can observe request data', async () => { + process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = SECRET; + const recorded: unknown[] = []; + const controller = new NotificationDataRightsController(runtime(recorded)); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute( + issuedAt, + signature(issuedAt), + { method: 'POST', originalUrl: '/v1/internal/data-rights/other' }, + body, + ), + ).rejects.toMatchObject({ status: 401 }); + expect(recorded).toEqual([]); + }); + + it('maps contributor failures without reflecting internal details', async () => { + process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = SECRET; + const controller = new NotificationDataRightsController({ + dataRightsContributor: { + async handle(): Promise { + throw new Error('postgres://user:password@internal-db'); + }, + }, + } as unknown as NotificationRuntime); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + let caught: unknown; + try { + await controller.contribute( + issuedAt, + signature(issuedAt), + { method: 'POST', originalUrl: PATH }, + body, + ); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ status: 503 }); + expect(JSON.stringify(caught)).not.toContain('password'); + }); +}); From 6abb844b6aeb10df245a4cc88b5f63d3eacf039c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:01:05 +0900 Subject: [PATCH 059/150] fix(notification): expose authenticated contributor controller --- .../notification-data-rights-controller.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-controller.ts diff --git a/apps/notification-service/src/notification-data-rights-controller.ts b/apps/notification-service/src/notification-data-rights-controller.ts new file mode 100644 index 00000000..648dc4a0 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Headers, + Inject, + Post, + Req, +} from '@nestjs/common'; +import type { NotificationDataRightsResponse } from './notification-data-rights'; +import { + parseTrustedNotificationDataRightsRequest, + toNotificationDataRightsHttpException, +} from './notification-data-rights-http-boundary'; +import type { NotificationRuntime } from './notification-runtime'; + +export const NOTIFICATION_DATA_RIGHTS_RUNTIME = Symbol( + 'NOTIFICATION_DATA_RIGHTS_RUNTIME', +); + +/** Server-observed request properties used to bind service authority to the exact route. */ +export interface NotificationDataRightsHttpRequestIdentity { + readonly method?: unknown; + readonly originalUrl?: unknown; +} + +/** Private authenticated HTTP controller for Notification-owned data-rights operations. */ +@Controller('internal/data-rights') +export class NotificationDataRightsController { + /** Receives the already-composed Notification runtime without creating foreign persistence. */ + constructor( + @Inject(NOTIFICATION_DATA_RIGHTS_RUNTIME) + private readonly runtime: NotificationRuntime, + ) {} + + /** + * Verifies Identity-issued authority before forwarding one normalized request + * to the Notification-owned contributor. No caller-supplied tenant or actor + * reaches persistence unless it is covered by the exact short-lived HMAC. + */ + @Post('contributor') + async contribute( + @Headers('x-life-os-data-rights-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-data-rights-signature') signature: string | undefined, + @Req() request: NotificationDataRightsHttpRequestIdentity, + @Body() body: unknown, + ): Promise { + const trusted = await parseTrustedNotificationDataRightsRequest( + body, + { issuedAt, signature }, + process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, + { method: request.method, path: request.originalUrl }, + Math.floor(Date.now() / 1000), + ); + try { + return await this.runtime.dataRightsContributor.handle(trusted); + } catch (error) { + throw toNotificationDataRightsHttpException(error); + } + } +} From ff40fb3648e459429c6a4acbfed37cc5a6c63706 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:05:18 +0900 Subject: [PATCH 060/150] test(notification): require destructive authority replay guard --- ...ation-data-rights-authority-replay.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-authority-replay.test.ts diff --git a/apps/notification-service/src/notification-data-rights-authority-replay.test.ts b/apps/notification-service/src/notification-data-rights-authority-replay.test.ts new file mode 100644 index 00000000..a5b956e2 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-authority-replay.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + PostgresNotificationDataRightsAuthorityReplayGuard, + type NotificationDataRightsAuthorityReplayEvidence, +} from './notification-data-rights-authority-replay'; +import type { + NotificationSqlClient, + NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +const DIGEST = 'a'.repeat(64); +const EXPIRES_AT = '2026-08-12T00:01:00.000Z'; + +class ScriptedClient implements NotificationSqlClient { + readonly calls: Array<{ + readonly text: string; + readonly values: readonly unknown[]; + }> = []; + + constructor( + private readonly script: Array>, + ) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + this.calls.push({ text, values: [...values] }); + const result = this.script.shift(); + if (result === undefined) { + throw new Error('test script exhausted'); + } + return result as NotificationSqlQueryResult; + } +} + +const EVIDENCE: NotificationDataRightsAuthorityReplayEvidence = Object.freeze({ + evidenceDigest: DIGEST, + expiresAt: EXPIRES_AT, +}); + +describe('PostgresNotificationDataRightsAuthorityReplayGuard', () => { + it('atomically accepts only the first still-live destructive authority digest', async () => { + const client = new ScriptedClient([ + { rows: [] }, + { rows: [{ evidence_digest: DIGEST }] }, + { rows: [] }, + { rows: [] }, + ]); + const guard = new PostgresNotificationDataRightsAuthorityReplayGuard(client); + + await expect(guard.consume(EVIDENCE)).resolves.toBe(true); + await expect(guard.consume(EVIDENCE)).resolves.toBe(false); + + expect(client.calls).toHaveLength(4); + expect(client.calls[0]?.text).toContain( + 'DELETE FROM notification_service.data_rights_authority_replay_records', + ); + expect(client.calls[1]?.text).toContain('ON CONFLICT (evidence_digest) DO NOTHING'); + expect(client.calls[1]?.values).toEqual([DIGEST, EXPIRES_AT]); + expect(client.calls[3]?.values).toEqual([DIGEST, EXPIRES_AT]); + }); + + it.each([ + { evidenceDigest: 'not-a-digest', expiresAt: EXPIRES_AT }, + { evidenceDigest: DIGEST, expiresAt: '2026-02-30T00:00:00.000Z' }, + { evidenceDigest: DIGEST, expiresAt: '2026-08-12T00:01:00Z' }, + ])('rejects malformed replay evidence before persistence', async (evidence) => { + const client = new ScriptedClient([]); + const guard = new PostgresNotificationDataRightsAuthorityReplayGuard(client); + + await expect(guard.consume(evidence)).rejects.toThrow( + 'Notification data-rights replay evidence is invalid', + ); + expect(client.calls).toEqual([]); + }); + + it('fails closed on ambiguous persistence evidence', async () => { + const client = new ScriptedClient([ + { rows: [] }, + { + rows: [ + { evidence_digest: DIGEST }, + { evidence_digest: DIGEST }, + ], + }, + ]); + const guard = new PostgresNotificationDataRightsAuthorityReplayGuard(client); + + await expect(guard.consume(EVIDENCE)).rejects.toThrow( + 'Notification data-rights replay evidence is invalid', + ); + }); +}); From 969ceeec07cb87a5dc42d17355cec2c38acd23ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:06:41 +0900 Subject: [PATCH 061/150] test(notification): reject replayed destructive authority --- ...fication-data-rights-http-boundary.test.ts | 99 +++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-http-boundary.test.ts b/apps/notification-service/src/notification-data-rights-http-boundary.test.ts index 89a97737..d79d62f6 100644 --- a/apps/notification-service/src/notification-data-rights-http-boundary.test.ts +++ b/apps/notification-service/src/notification-data-rights-http-boundary.test.ts @@ -2,6 +2,7 @@ import { createHmac, randomBytes } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION } from './notification-data-rights'; +import type { NotificationDataRightsAuthorityReplayGuardPort } from './notification-data-rights-authority-replay'; import { parseTrustedNotificationDataRightsRequest, toNotificationDataRightsHttpException, @@ -41,7 +42,8 @@ function signature( ): string { const idempotencyKey = request.operation === 'erase' ? String(request.idempotencyKey) : '-'; - const cursor = request.operation === 'export' ? String(request.cursor ?? '-') : '-'; + const cursor = + request.operation === 'export' ? String(request.cursor ?? '-') : '-'; return createHmac('sha256', SECRET) .update( [ @@ -73,9 +75,25 @@ async function rejectedStatus(operation: Promise): Promise { throw new Error('Expected Notification data-rights transport to reject'); } +/** Creates a replay guard that accepts once and records only credential-free evidence. */ +function oneShotReplayGuard( + recorded: unknown[], +): NotificationDataRightsAuthorityReplayGuardPort { + let accepted = false; + return { + async consume(evidence): Promise { + recorded.push(evidence); + if (accepted) return false; + accepted = true; + return true; + }, + }; +} + describe('Notification data-rights HTTP authority', () => { it('accepts a fresh export bound to tenant, actor, cursor, method, and path', async () => { const issuedAt = String(NOW_SECONDS); + const replayEvidence: unknown[] = []; await expect( parseTrustedNotificationDataRightsRequest( exportRequest, @@ -83,8 +101,10 @@ describe('Notification data-rights HTTP authority', () => { SECRET, { method: 'POST', path: CONTRIBUTOR_PATH }, NOW_SECONDS, + oneShotReplayGuard(replayEvidence), ), ).resolves.toEqual(exportRequest); + expect(replayEvidence).toEqual([]); }); it('fails closed if an export cursor changes after Identity signs the request', async () => { @@ -118,7 +138,7 @@ describe('Notification data-rights HTTP authority', () => { expect(status).toBe(401); }); - it('accepts destructive idempotency only when the signed key matches the request', async () => { + it('consumes destructive signed authority once while preserving domain idempotency identity', async () => { const request = Object.freeze({ contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, operation: 'erase' as const, @@ -128,26 +148,93 @@ describe('Notification data-rights HTTP authority', () => { idempotencyKey: IDEMPOTENCY_KEY, }); const issuedAt = String(NOW_SECONDS); + const signed = signature(request, issuedAt); + const replayEvidence: unknown[] = []; + const replayGuard = oneShotReplayGuard(replayEvidence); + await expect( parseTrustedNotificationDataRightsRequest( request, - { issuedAt, signature: signature(request, issuedAt) }, + { issuedAt, signature: signed }, SECRET, { method: 'POST', path: CONTRIBUTOR_PATH }, NOW_SECONDS, + replayGuard, ), ).resolves.toEqual(request); + expect(replayEvidence).toEqual([ + { + evidenceDigest: expect.stringMatching(/^[0-9a-f]{64}$/u), + expiresAt: new Date((NOW_SECONDS + 60) * 1_000).toISOString(), + }, + ]); + expect(JSON.stringify(replayEvidence)).not.toContain(signed); - const status = await rejectedStatus( + const replayStatus = await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + request, + { issuedAt, signature: signed }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + replayGuard, + ), + ); + expect(replayStatus).toBe(401); + + const tamperedStatus = await rejectedStatus( parseTrustedNotificationDataRightsRequest( { ...request, idempotencyKey: REQUEST_ID }, - { issuedAt, signature: signature(request, issuedAt) }, + { issuedAt, signature: signed }, SECRET, { method: 'POST', path: CONTRIBUTOR_PATH }, NOW_SECONDS, + replayGuard, ), ); - expect(status).toBe(401); + expect(tamperedStatus).toBe(401); + }); + + it('fails closed when destructive replay authority is unavailable or errors', async () => { + const request = Object.freeze({ + contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, + operation: 'erase' as const, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }); + const issuedAt = String(NOW_SECONDS); + const signed = signature(request, issuedAt); + expect( + await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + request, + { issuedAt, signature: signed }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + ), + ), + ).toBe(503); + + const unavailableGuard: NotificationDataRightsAuthorityReplayGuardPort = { + async consume(): Promise { + throw new Error('database topology must not escape'); + }, + }; + expect( + await rejectedStatus( + parseTrustedNotificationDataRightsRequest( + request, + { issuedAt, signature: signed }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + unavailableGuard, + ), + ), + ).toBe(503); }); it.each([ From 99f74484af4e52ffec1e0a140bfe187ad3989160 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:07:55 +0900 Subject: [PATCH 062/150] fix(notification): persist destructive authority replay evidence --- ...tification-data-rights-authority-replay.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-authority-replay.ts diff --git a/apps/notification-service/src/notification-data-rights-authority-replay.ts b/apps/notification-service/src/notification-data-rights-authority-replay.ts new file mode 100644 index 00000000..6e029e60 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-authority-replay.ts @@ -0,0 +1,107 @@ +import type { NotificationSqlClient } from './postgres-reminder-repository'; + +const SHA_256_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; + +/** Credential-free evidence identifying one short-lived destructive service authority. */ +export interface NotificationDataRightsAuthorityReplayEvidence { + readonly evidenceDigest: string; + readonly expiresAt: string; +} + +/** Notification-owned persistence boundary that atomically consumes destructive authority once. */ +export interface NotificationDataRightsAuthorityReplayGuardPort { + /** Returns true only for the first still-live durable consumption of the evidence digest. */ + consume( + evidence: NotificationDataRightsAuthorityReplayEvidence, + ): Promise; +} + +interface ReplayEvidenceRow { + readonly evidence_digest: unknown; +} + +/** Bounded failure for malformed replay evidence or ambiguous persistence results. */ +export class NotificationDataRightsAuthorityReplayError extends Error { + /** Creates one credential-free replay-store failure. */ + constructor() { + super('Notification data-rights replay evidence is invalid'); + this.name = 'NotificationDataRightsAuthorityReplayError'; + } +} + +/** Rejects malformed replay evidence without reflecting caller-controlled data. */ +function invalidReplayEvidence(): never { + throw new NotificationDataRightsAuthorityReplayError(); +} + +/** Requires one lowercase SHA-256 digest so raw HMAC signatures never enter persistence. */ +function requireDigest(value: unknown): string { + if (typeof value !== 'string' || !SHA_256_PATTERN.test(value)) { + return invalidReplayEvidence(); + } + return value; +} + +/** Requires a real canonical UTC millisecond instant for the replay-retention deadline. */ +function requireInstant(value: unknown): string { + if (typeof value !== 'string' || !ISO_INSTANT_PATTERN.test(value)) { + return invalidReplayEvidence(); + } + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) { + return invalidReplayEvidence(); + } + return value; +} + +/** + * PostgreSQL compare-and-set guard for destructive Notification data-rights authority. + * + * The primary key makes the first still-live signature digest the sole winner + * across service replicas. Raw signatures are never persisted. PostgreSQL + * `now()` governs pruning and expiry so application-clock lag cannot re-admit + * already expired evidence. + */ +export class PostgresNotificationDataRightsAuthorityReplayGuard + implements NotificationDataRightsAuthorityReplayGuardPort +{ + /** Creates the guard over the Notification service's parameterized SQL boundary. */ + constructor(private readonly client: NotificationSqlClient) {} + + /** Atomically consumes one validated digest, returning false for replay or expiry. */ + async consume( + evidence: NotificationDataRightsAuthorityReplayEvidence, + ): Promise { + const evidenceDigest = requireDigest(evidence.evidenceDigest); + const expiresAt = requireInstant(evidence.expiresAt); + + await this.client.query( + `DELETE FROM notification_service.data_rights_authority_replay_records + WHERE expires_at < now()`, + [], + ); + const inserted = await this.client.query( + `INSERT INTO notification_service.data_rights_authority_replay_records ( + evidence_digest, expires_at + ) + SELECT $1, $2::timestamptz + WHERE $2::timestamptz >= now() + ON CONFLICT (evidence_digest) DO NOTHING + RETURNING evidence_digest`, + [evidenceDigest, expiresAt], + ); + + if (inserted.rows.length === 0) { + return false; + } + if ( + inserted.rows.length !== 1 || + requireDigest(inserted.rows[0]?.evidence_digest) !== evidenceDigest + ) { + return invalidReplayEvidence(); + } + return true; + } +} From 97e1106398116331b6e89adf3d65fab70c9dc442 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:09:11 +0900 Subject: [PATCH 063/150] fix(notification): consume destructive signed authority once --- .../notification-data-rights-http-boundary.ts | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-http-boundary.ts b/apps/notification-service/src/notification-data-rights-http-boundary.ts index cdbdefdf..7aa852ee 100644 --- a/apps/notification-service/src/notification-data-rights-http-boundary.ts +++ b/apps/notification-service/src/notification-data-rights-http-boundary.ts @@ -1,9 +1,10 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, type NotificationDataRightsRequest, } from './notification-data-rights'; +import type { NotificationDataRightsAuthorityReplayGuardPort } from './notification-data-rights-authority-replay'; /** Short-lived service-authentication headers for the private Notification contributor route. */ export interface TrustedNotificationDataRightsContextHeaders { @@ -66,7 +67,7 @@ function invalidRequest(): never { ); } -/** Rejects forged, stale, future, or route-mismatched service authority. */ +/** Rejects forged, replayed, stale, future, or route-mismatched service authority. */ function invalidContext(): never { throw problemException( 401, @@ -75,7 +76,7 @@ function invalidContext(): never { ); } -/** Rejects verifier configuration that cannot authenticate the internal caller. */ +/** Rejects verifier or replay-store configuration that cannot authenticate the internal caller. */ function unavailableContext(): never { throw problemException( 503, @@ -233,14 +234,30 @@ function requestDigest( .digest(); } +/** Derives a credential-free replay identity from one already-validated HMAC signature. */ +function replayDigest(signature: string): string { + return createHash('sha256').update(signature, 'ascii').digest('hex'); +} + +/** Converts the signed issuance time into the exact end of its 60-second authority lifetime. */ +function replayExpiresAt(issuedAtSeconds: number): string { + const expiresAt = new Date( + (issuedAtSeconds + MAXIMUM_CONTEXT_AGE_SECONDS) * 1_000, + ); + if (!Number.isFinite(expiresAt.getTime())) { + return unavailableContext(); + } + return expiresAt.toISOString(); +} + /** * Verifies one exact Identity-to-Notification contributor request before persistence access. * * Tenant, actor, request, operation, destructive idempotency identity, export - * continuation, lifetime, HTTP method, and resource are HMAC-bound. The function - * returns only normalized request data and never forwards the verifier secret or - * signature to the contributor. Cursor semantics remain owned by the Notification - * contributor so transport authentication cannot become a second source of truth. + * continuation, lifetime, HTTP method, and resource are HMAC-bound. Destructive + * `erase` authority is additionally consumed once through Notification-owned + * durable replay evidence. Only a SHA-256 digest of the validated signature is + * persisted; the signature and verifier secret never leave this boundary. */ export async function parseTrustedNotificationDataRightsRequest( body: unknown, @@ -248,6 +265,7 @@ export async function parseTrustedNotificationDataRightsRequest( secret: unknown, requestBinding: NotificationDataRightsRequestBinding, nowSeconds = Math.floor(Date.now() / 1000), + replayGuard?: NotificationDataRightsAuthorityReplayGuardPort, ): Promise { const request = normalizeRequest(body); if ( @@ -286,6 +304,24 @@ export async function parseTrustedNotificationDataRightsRequest( ) { return invalidContext(); } + + if (request.operation === 'erase') { + if (!replayGuard) { + return unavailableContext(); + } + let consumed: boolean; + try { + consumed = await replayGuard.consume({ + evidenceDigest: replayDigest(headers.signature), + expiresAt: replayExpiresAt(issuedAtSeconds), + }); + } catch { + return unavailableContext(); + } + if (!consumed) { + return invalidContext(); + } + } return request; } From 518ab2eada241eeb79d6010acdd026367ae0e9bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:10:36 +0900 Subject: [PATCH 064/150] fix(notification): compose destructive replay guard --- apps/notification-service/src/notification-runtime.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/notification-service/src/notification-runtime.ts b/apps/notification-service/src/notification-runtime.ts index b89d61b0..66f8e821 100644 --- a/apps/notification-service/src/notification-runtime.ts +++ b/apps/notification-service/src/notification-runtime.ts @@ -1,6 +1,7 @@ import { Logger, type OnApplicationShutdown } from '@nestjs/common'; import { Pool, type PoolConfig } from 'pg'; import { NotificationDataRightsContributor } from './notification-data-rights'; +import { PostgresNotificationDataRightsAuthorityReplayGuard } from './notification-data-rights-authority-replay'; import { PostgresInAppDeliveryGateway, PostgresReminderRepository, @@ -221,6 +222,8 @@ export class NotificationRuntime implements OnApplicationShutdown { readonly scheduler: ReminderScheduler, /** Service-owned export/erasure participant consumed by Identity orchestration. */ readonly dataRightsContributor: NotificationDataRightsContributor, + /** Durable one-time consumption boundary for destructive signed service authority. */ + readonly dataRightsAuthorityReplayGuard: PostgresNotificationDataRightsAuthorityReplayGuard, ) {} /** Closes the owned PostgreSQL pool exactly once. */ @@ -270,11 +273,14 @@ export function createNotificationRuntime( reminderBatchSize, ); const dataRightsContributor = new NotificationDataRightsContributor(client); + const dataRightsAuthorityReplayGuard = + new PostgresNotificationDataRightsAuthorityReplayGuard(client); return new NotificationRuntime( pool, repository, gateway, scheduler, dataRightsContributor, + dataRightsAuthorityReplayGuard, ); } From 3b300249695163617f286890a4644fca8f5488f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:11:10 +0900 Subject: [PATCH 065/150] fix(notification): enforce destructive replay consumption --- .../src/notification-data-rights-controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-data-rights-controller.ts b/apps/notification-service/src/notification-data-rights-controller.ts index 648dc4a0..d34d0ee7 100644 --- a/apps/notification-service/src/notification-data-rights-controller.ts +++ b/apps/notification-service/src/notification-data-rights-controller.ts @@ -35,7 +35,8 @@ export class NotificationDataRightsController { /** * Verifies Identity-issued authority before forwarding one normalized request * to the Notification-owned contributor. No caller-supplied tenant or actor - * reaches persistence unless it is covered by the exact short-lived HMAC. + * reaches persistence unless it is covered by the exact short-lived HMAC; + * destructive authority must also win the durable one-time replay guard. */ @Post('contributor') async contribute( @@ -50,6 +51,7 @@ export class NotificationDataRightsController { process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, { method: request.method, path: request.originalUrl }, Math.floor(Date.now() / 1000), + this.runtime.dataRightsAuthorityReplayGuard, ); try { return await this.runtime.dataRightsContributor.handle(trusted); From 9eb7aba8f030478c7df4b31fb40edab44af8f548 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:15:03 +0900 Subject: [PATCH 066/150] test(notification): require durable authority replay storage --- ...ights-authority-replay.integration.test.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts diff --git a/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts b/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts new file mode 100644 index 00000000..65e1cc55 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts @@ -0,0 +1,168 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { PostgresNotificationDataRightsAuthorityReplayGuard } from './notification-data-rights-authority-replay'; + +const DATABASE_URL = process.env.NOTIFICATION_DATABASE_URL; +const describeWithPostgres = DATABASE_URL ? describe : describe.skip; +const RUNTIME_ROLE = 'notification_data_rights_replay_runtime_test'; +let administrativePool: Pool; +let runtimePool: Pool; + +/** Requires the CI-provided PostgreSQL URL without exposing it in test failures. */ +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error( + 'NOTIFICATION_DATABASE_URL is required for integration tests', + ); + } + return DATABASE_URL; +} + +/** Applies every Notification migration in forward order to a clean service schema. */ +async function applyMigrations(pool: Pool): Promise { + for (const migration of [ + '0001_durable_reminder_inbox.sql', + '0002_data_rights_erasure.sql', + '0003_data_rights_authority_replay.sql', + ]) { + const sql = await readFile( + resolve(__dirname, '../migrations', migration), + 'utf8', + ); + await pool.query(sql); + } +} + +/** Creates the same least-privilege replay-table grant required from deployment. */ +async function grantRuntimeReplayAuthority(pool: Pool): Promise { + await pool.query(` + GRANT USAGE ON SCHEMA notification_service TO ${RUNTIME_ROLE}; + REVOKE ALL PRIVILEGES ON TABLE + notification_service.data_rights_authority_replay_records + FROM ${RUNTIME_ROLE}; + GRANT SELECT, INSERT, DELETE ON TABLE + notification_service.data_rights_authority_replay_records + TO ${RUNTIME_ROLE}; + `); +} + +describeWithPostgres( + 'Notification destructive authority replay PostgreSQL integration', + () => { + beforeAll(async () => { + administrativePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-replay-admin', + max: 2, + }); + await administrativePool.query(`DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${RUNTIME_ROLE}' + ) THEN + CREATE ROLE ${RUNTIME_ROLE} NOLOGIN; + END IF; + END + $$`); + runtimePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-replay-runtime', + options: `-c role=${RUNTIME_ROLE}`, + max: 2, + }); + }); + + beforeEach(async () => { + await runtimePool.end(); + await administrativePool.query('RESET ROLE'); + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await applyMigrations(administrativePool); + await grantRuntimeReplayAuthority(administrativePool); + runtimePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-replay-runtime', + options: `-c role=${RUNTIME_ROLE}`, + max: 2, + }); + }); + + afterAll(async () => { + await runtimePool.end().catch(() => undefined); + await administrativePool.query('RESET ROLE').catch(() => undefined); + await administrativePool + .query('DROP SCHEMA IF EXISTS notification_service CASCADE') + .catch(() => undefined); + await administrativePool + .query(`DROP OWNED BY ${RUNTIME_ROLE}`) + .catch(() => undefined); + await administrativePool + .query(`DROP ROLE IF EXISTS ${RUNTIME_ROLE}`) + .catch(() => undefined); + await administrativePool.end(); + }); + + it('allows the runtime role to consume one live digest exactly once', async () => { + const guard = new PostgresNotificationDataRightsAuthorityReplayGuard( + runtimePool, + ); + const evidence = { + evidenceDigest: 'a'.repeat(64), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + + await expect(guard.consume(evidence)).resolves.toBe(true); + await expect(guard.consume(evidence)).resolves.toBe(false); + + const stored = await administrativePool.query<{ + evidence_digest: string; + consumed_at: Date; + expires_at: Date; + }>( + `SELECT evidence_digest, consumed_at, expires_at + FROM notification_service.data_rights_authority_replay_records`, + ); + expect(stored.rows).toHaveLength(1); + expect(stored.rows[0]?.evidence_digest).toBe(evidence.evidenceDigest); + expect(stored.rows[0]?.consumed_at).toBeInstanceOf(Date); + expect(stored.rows[0]?.expires_at).toBeInstanceOf(Date); + }); + + it('prunes expired evidence and never grants update authority to the runtime role', async () => { + await administrativePool.query( + `INSERT INTO notification_service.data_rights_authority_replay_records + (evidence_digest, expires_at) + VALUES ($1, now() - interval '1 second')`, + ['b'.repeat(64)], + ); + const guard = new PostgresNotificationDataRightsAuthorityReplayGuard( + runtimePool, + ); + await expect( + guard.consume({ + evidenceDigest: 'c'.repeat(64), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), + ).resolves.toBe(true); + + const digests = await administrativePool.query<{ evidence_digest: string }>( + `SELECT evidence_digest + FROM notification_service.data_rights_authority_replay_records + ORDER BY evidence_digest`, + ); + expect(digests.rows).toEqual([{ evidence_digest: 'c'.repeat(64) }]); + + await expect( + runtimePool.query( + `UPDATE notification_service.data_rights_authority_replay_records + SET expires_at = expires_at + interval '1 minute' + WHERE evidence_digest = $1`, + ['c'.repeat(64)], + ), + ).rejects.toMatchObject({ code: '42501' }); + }); + }, +); From bfd99f2104f50bf531798aa0cc6f77a1a9f99248 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:15:54 +0900 Subject: [PATCH 067/150] fix(notification): add destructive replay storage --- .../0003_data_rights_authority_replay.sql | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apps/notification-service/migrations/0003_data_rights_authority_replay.sql diff --git a/apps/notification-service/migrations/0003_data_rights_authority_replay.sql b/apps/notification-service/migrations/0003_data_rights_authority_replay.sql new file mode 100644 index 00000000..9fc6f79a --- /dev/null +++ b/apps/notification-service/migrations/0003_data_rights_authority_replay.sql @@ -0,0 +1,33 @@ +BEGIN; + +CREATE TABLE notification_service.data_rights_authority_replay_records ( + evidence_digest text PRIMARY KEY, + consumed_at timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + expires_at timestamp with time zone NOT NULL, + CONSTRAINT data_rights_authority_replay_digest_sha256 CHECK ( + evidence_digest ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT data_rights_authority_replay_expiry_order CHECK ( + expires_at > consumed_at + ) +); + +COMMENT ON TABLE notification_service.data_rights_authority_replay_records IS + 'Stores only SHA-256 digests of authenticated destructive data-rights authority so an erase signature can be consumed once across Notification service replicas. Raw signatures, verifier secrets, tenant identifiers, and user identifiers are deliberately excluded; rows expire at the signed authority lifetime boundary.'; + +COMMENT ON COLUMN notification_service.data_rights_authority_replay_records.evidence_digest IS + 'SHA-256 digest of one already-validated service HMAC signature; primary-key uniqueness is the cross-replica replay fence.'; + +COMMENT ON COLUMN notification_service.data_rights_authority_replay_records.consumed_at IS + 'Database-clock instant when the destructive authority first won durable consumption.'; + +COMMENT ON COLUMN notification_service.data_rights_authority_replay_records.expires_at IS + 'Database-comparable end of the signed service-authority lifetime; expired rows may be pruned by the Notification runtime.'; + +CREATE INDEX data_rights_authority_replay_expiry_index + ON notification_service.data_rights_authority_replay_records (expires_at); + +REVOKE ALL ON TABLE notification_service.data_rights_authority_replay_records + FROM PUBLIC; + +COMMIT; From 31878b8f40f537b9247f25e6aa025ed9524be693 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:17:25 +0900 Subject: [PATCH 068/150] fix(notification): grant replay-store least privilege --- infra/kubernetes/run-migrations.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infra/kubernetes/run-migrations.sh b/infra/kubernetes/run-migrations.sh index 136ff12d..7877f4f7 100644 --- a/infra/kubernetes/run-migrations.sh +++ b/infra/kubernetes/run-migrations.sh @@ -260,8 +260,12 @@ TO :"service_runtime_role"; REVOKE ALL PRIVILEGES ON TABLE notification_service.data_rights_erasure_receipts, notification_service.data_rights_erasure_authorizations, - notification_service.data_rights_workspace_erasures + notification_service.data_rights_workspace_erasures, + notification_service.data_rights_authority_replay_records FROM :"service_runtime_role"; +GRANT SELECT, INSERT, DELETE ON TABLE + notification_service.data_rights_authority_replay_records +TO :"service_runtime_role"; GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid) TO :"service_runtime_role"; SQL From a03ddeaa6ff8d3db473bc5dcb2e4aaa21e86ee93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:22:57 +0900 Subject: [PATCH 069/150] test(notification): allow safe erase retry after failure --- ...otification-data-rights-controller.test.ts | 100 +++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-controller.test.ts b/apps/notification-service/src/notification-data-rights-controller.test.ts index 2e2ac2ed..c517d316 100644 --- a/apps/notification-service/src/notification-data-rights-controller.test.ts +++ b/apps/notification-service/src/notification-data-rights-controller.test.ts @@ -7,6 +7,7 @@ import type { NotificationRuntime } from './notification-runtime'; const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; const USER_ID = '22222222-2222-4222-8222-222222222222'; const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; const SECRET = randomBytes(32).toString('base64url'); const PATH = '/v1/internal/data-rights/contributor'; const ORIGINAL_SECRET = process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET; @@ -19,23 +20,39 @@ const body = Object.freeze({ requestId: REQUEST_ID, }); +const eraseBody = Object.freeze({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase' as const, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + idempotencyKey: IDEMPOTENCY_KEY, +}); + /** Signs the exact controller request contract so tests exercise production authority verification. */ -function signature(issuedAt: string): string { +function signature( + request: Readonly>, + issuedAt: string, +): string { + const idempotencyKey = + request.operation === 'erase' ? String(request.idempotencyKey) : '-'; + const cursor = request.operation === 'export' ? String(request.cursor ?? '-') : '-'; return createHmac('sha256', SECRET) .update( [ - 'life-os.notification-data-rights-context.v1', - body.contractVersion, - body.workspaceId, - body.requestedByUserId, - body.requestId, - body.operation, - '-', - '-', + String(request.contractVersion), + String(request.workspaceId), + String(request.requestedByUserId), + String(request.requestId), + String(request.operation), + idempotencyKey, + cursor, issuedAt, 'POST', PATH, - ].join('\n'), + ] + .toSpliced(0, 0, 'life-os.notification-data-rights-context.v1') + .join('\n'), 'utf8', ) .digest('base64url'); @@ -78,7 +95,7 @@ describe('NotificationDataRightsController', () => { await expect( controller.contribute( issuedAt, - signature(issuedAt), + signature(body, issuedAt), { method: 'POST', originalUrl: PATH }, body, ), @@ -95,7 +112,7 @@ describe('NotificationDataRightsController', () => { await expect( controller.contribute( issuedAt, - signature(issuedAt), + signature(body, issuedAt), { method: 'POST', originalUrl: '/v1/internal/data-rights/other' }, body, ), @@ -118,7 +135,7 @@ describe('NotificationDataRightsController', () => { try { await controller.contribute( issuedAt, - signature(issuedAt), + signature(body, issuedAt), { method: 'POST', originalUrl: PATH }, body, ); @@ -128,4 +145,61 @@ describe('NotificationDataRightsController', () => { expect(caught).toMatchObject({ status: 503 }); expect(JSON.stringify(caught)).not.toContain('password'); }); + + it('releases a claimed erase signature after a transient contributor failure so the exact retry can succeed', async () => { + process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = SECRET; + let fail = true; + const claims = new Set(); + const replayGuard = { + async consume({ evidenceDigest }: { readonly evidenceDigest: string }): Promise { + if (claims.has(evidenceDigest)) return false; + claims.add(evidenceDigest); + return true; + }, + async release(evidenceDigest: string): Promise { + claims.delete(evidenceDigest); + }, + }; + const controller = new NotificationDataRightsController({ + dataRightsAuthorityReplayGuard: replayGuard, + dataRightsContributor: { + async handle(): Promise { + if (fail) { + fail = false; + throw new Error('temporary database failure'); + } + return { + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'erase', + requestId: REQUEST_ID, + erasedRecords: 1, + receiptSha256: 'a'.repeat(64), + }; + }, + }, + } as unknown as NotificationRuntime); + const issuedAt = String(Math.floor(Date.now() / 1000)); + const signed = signature(eraseBody, issuedAt); + + await expect( + controller.contribute( + issuedAt, + signed, + { method: 'POST', originalUrl: PATH }, + eraseBody, + ), + ).rejects.toMatchObject({ status: 503 }); + expect(claims.size).toBe(0); + + await expect( + controller.contribute( + issuedAt, + signed, + { method: 'POST', originalUrl: PATH }, + eraseBody, + ), + ).resolves.toMatchObject({ operation: 'erase', erasedRecords: 1 }); + expect(claims.size).toBe(1); + }); }); From 7d8fab1508f3d95a29f14c07a7d6f3738fefea5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:23:49 +0900 Subject: [PATCH 070/150] fix(notification): release failed erase authority claims --- ...tification-data-rights-authority-replay.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-authority-replay.ts b/apps/notification-service/src/notification-data-rights-authority-replay.ts index 6e029e60..42cdaa7e 100644 --- a/apps/notification-service/src/notification-data-rights-authority-replay.ts +++ b/apps/notification-service/src/notification-data-rights-authority-replay.ts @@ -10,12 +10,14 @@ export interface NotificationDataRightsAuthorityReplayEvidence { readonly expiresAt: string; } -/** Notification-owned persistence boundary that atomically consumes destructive authority once. */ +/** Notification-owned persistence boundary that claims destructive authority until success or explicit failure release. */ export interface NotificationDataRightsAuthorityReplayGuardPort { - /** Returns true only for the first still-live durable consumption of the evidence digest. */ + /** Returns true only for the first still-live durable claim of the evidence digest. */ consume( evidence: NotificationDataRightsAuthorityReplayEvidence, ): Promise; + /** Releases only the exact credential-free digest after a failed destructive execution so an authorized retry can reclaim it. */ + release(evidenceDigest: string): Promise; } interface ReplayEvidenceRow { @@ -61,8 +63,9 @@ function requireInstant(value: unknown): string { * * The primary key makes the first still-live signature digest the sole winner * across service replicas. Raw signatures are never persisted. PostgreSQL - * `now()` governs pruning and expiry so application-clock lag cannot re-admit - * already expired evidence. + * `now()` governs pruning and expiry. A controller releases the exact digest + * only when the protected erasure operation fails before returning a receipt; + * successful authority remains consumed for its lifetime. */ export class PostgresNotificationDataRightsAuthorityReplayGuard implements NotificationDataRightsAuthorityReplayGuardPort @@ -70,7 +73,7 @@ export class PostgresNotificationDataRightsAuthorityReplayGuard /** Creates the guard over the Notification service's parameterized SQL boundary. */ constructor(private readonly client: NotificationSqlClient) {} - /** Atomically consumes one validated digest, returning false for replay or expiry. */ + /** Atomically claims one validated digest, returning false for replay or expiry. */ async consume( evidence: NotificationDataRightsAuthorityReplayEvidence, ): Promise { @@ -104,4 +107,14 @@ export class PostgresNotificationDataRightsAuthorityReplayGuard } return true; } + + /** Releases a previously claimed digest after a failed erasure without widening authority or retaining raw credentials. */ + async release(evidenceDigestInput: string): Promise { + const evidenceDigest = requireDigest(evidenceDigestInput); + await this.client.query( + `DELETE FROM notification_service.data_rights_authority_replay_records + WHERE evidence_digest = $1`, + [evidenceDigest], + ); + } } From b1eed39eba6e32c30c5fcc7dda3cb253096fd34d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:24:45 +0900 Subject: [PATCH 071/150] fix(notification): preserve authorized erase retries --- .../src/notification-data-rights-controller.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights-controller.ts b/apps/notification-service/src/notification-data-rights-controller.ts index d34d0ee7..5e2ddb01 100644 --- a/apps/notification-service/src/notification-data-rights-controller.ts +++ b/apps/notification-service/src/notification-data-rights-controller.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { Body, Controller, @@ -23,6 +24,11 @@ export interface NotificationDataRightsHttpRequestIdentity { readonly originalUrl?: unknown; } +/** Derives the credential-free durable claim key from one already-verified HMAC signature. */ +function authorityClaimDigest(signature: string): string { + return createHash('sha256').update(signature, 'ascii').digest('hex'); +} + /** Private authenticated HTTP controller for Notification-owned data-rights operations. */ @Controller('internal/data-rights') export class NotificationDataRightsController { @@ -37,6 +43,8 @@ export class NotificationDataRightsController { * to the Notification-owned contributor. No caller-supplied tenant or actor * reaches persistence unless it is covered by the exact short-lived HMAC; * destructive authority must also win the durable one-time replay guard. + * A failed erasure releases only its credential-free claim so the same still- + * valid authorized request may safely retry the contributor's idempotent erase. */ @Post('contributor') async contribute( @@ -56,6 +64,15 @@ export class NotificationDataRightsController { try { return await this.runtime.dataRightsContributor.handle(trusted); } catch (error) { + if (trusted.operation === 'erase' && typeof signature === 'string') { + try { + await this.runtime.dataRightsAuthorityReplayGuard.release( + authorityClaimDigest(signature), + ); + } catch { + // Fail closed: retaining a claim is safer than admitting a duplicate erase. + } + } throw toNotificationDataRightsHttpException(error); } } From 839786bc430800f92ad7b741d30f11b74928b5c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:25:55 +0900 Subject: [PATCH 072/150] test(notification): model replay claim release contract --- .../src/notification-data-rights-http-boundary.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights-http-boundary.test.ts b/apps/notification-service/src/notification-data-rights-http-boundary.test.ts index d79d62f6..84370399 100644 --- a/apps/notification-service/src/notification-data-rights-http-boundary.test.ts +++ b/apps/notification-service/src/notification-data-rights-http-boundary.test.ts @@ -87,6 +87,9 @@ function oneShotReplayGuard( accepted = true; return true; }, + async release(): Promise { + accepted = false; + }, }; } @@ -222,6 +225,9 @@ describe('Notification data-rights HTTP authority', () => { async consume(): Promise { throw new Error('database topology must not escape'); }, + async release(): Promise { + throw new Error('database topology must not escape'); + }, }; expect( await rejectedStatus( From 4b0b3f43c5fd49e4ceb2f4995f7974277339d83b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:31:47 +0900 Subject: [PATCH 073/150] test(notification): require bootstrapped contributor endpoint --- .../src/notification-http.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 apps/notification-service/src/notification-http.test.ts diff --git a/apps/notification-service/src/notification-http.test.ts b/apps/notification-service/src/notification-http.test.ts new file mode 100644 index 00000000..7f75910e --- /dev/null +++ b/apps/notification-service/src/notification-http.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { NotificationRuntime } from './notification-runtime'; +import { + bootstrapNotificationService, + createNotificationHttpModule, + type NotificationHttpApplication, +} from './notification-http'; +import { NotificationDataRightsController } from './notification-data-rights-controller'; + +/** Creates a bounded runtime fixture without opening PostgreSQL connections. */ +function runtime(): NotificationRuntime { + return { + close: vi.fn(async () => undefined), + } as unknown as NotificationRuntime; +} + +describe('Notification internal HTTP composition', () => { + it('registers the authenticated data-rights controller against the supplied runtime', () => { + const suppliedRuntime = runtime(); + const module = createNotificationHttpModule(suppliedRuntime); + + expect(module.controllers).toEqual([NotificationDataRightsController]); + expect(module.providers).toEqual([ + { + provide: expect.any(Symbol), + useValue: suppliedRuntime, + }, + ]); + }); + + it('boots the v1 private route on a validated bounded listener', async () => { + const suppliedRuntime = runtime(); + const setGlobalPrefix = vi.fn(); + const enableShutdownHooks = vi.fn(); + const listen = vi.fn(async () => undefined); + const application: NotificationHttpApplication = { + setGlobalPrefix, + enableShutdownHooks, + listen, + }; + const applicationFactory = vi.fn(async () => application); + + await expect( + bootstrapNotificationService( + { + NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + NOTIFICATION_PORT: '4300', + NOTIFICATION_HOST: '127.0.0.1', + }, + () => suppliedRuntime, + applicationFactory, + ), + ).resolves.toBe(application); + + expect(applicationFactory).toHaveBeenCalledTimes(1); + expect(setGlobalPrefix).toHaveBeenCalledWith('v1'); + expect(enableShutdownHooks).toHaveBeenCalledTimes(1); + expect(listen).toHaveBeenCalledWith(4300, '127.0.0.1'); + }); + + it.each([ + [{ NOTIFICATION_PORT: '0' }, 'Notification port is invalid'], + [{ NOTIFICATION_PORT: '65536' }, 'Notification port is invalid'], + [{ NOTIFICATION_PORT: '4.3e3' }, 'Notification port is invalid'], + [{ NOTIFICATION_HOST: '' }, 'Notification host is invalid'], + [{ NOTIFICATION_HOST: ' host ' }, 'Notification host is invalid'], + ])('rejects unsafe listener configuration before runtime creation', async (override, expected) => { + const runtimeFactory = vi.fn(() => runtime()); + const applicationFactory = vi.fn(); + + await expect( + bootstrapNotificationService( + { + NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + ...override, + }, + runtimeFactory, + applicationFactory, + ), + ).rejects.toThrow(expected); + expect(runtimeFactory).not.toHaveBeenCalled(); + expect(applicationFactory).not.toHaveBeenCalled(); + }); + + it('closes the runtime if Nest application construction fails', async () => { + const suppliedRuntime = runtime(); + const runtimeFactory = vi.fn(() => suppliedRuntime); + const applicationFactory = vi.fn(async () => { + throw new Error('listener construction failed'); + }); + + await expect( + bootstrapNotificationService( + { NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os' }, + runtimeFactory, + applicationFactory, + ), + ).rejects.toThrow('Notification HTTP bootstrap failed'); + expect(suppliedRuntime.close).toHaveBeenCalledTimes(1); + }); +}); From 19b48fb20b08d639a7875a18553fe01a7b9cbf10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:32:58 +0900 Subject: [PATCH 074/150] fix(notification): bootstrap private contributor endpoint --- .../src/notification-http.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 apps/notification-service/src/notification-http.ts diff --git a/apps/notification-service/src/notification-http.ts b/apps/notification-service/src/notification-http.ts new file mode 100644 index 00000000..a53825d8 --- /dev/null +++ b/apps/notification-service/src/notification-http.ts @@ -0,0 +1,122 @@ +import { Module, type DynamicModule } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { + NOTIFICATION_DATA_RIGHTS_RUNTIME, + NotificationDataRightsController, +} from './notification-data-rights-controller'; +import { + createNotificationRuntime, + type NotificationRuntime, +} from './notification-runtime'; + +const DEFAULT_NOTIFICATION_HOST = '0.0.0.0'; +const DEFAULT_NOTIFICATION_PORT = 4300; +const DECIMAL_PORT_PATTERN = /^[1-9]\d{0,4}$/u; +const HOST_PATTERN = /^(?=.{1,253}$)[A-Za-z0-9.:_-]+$/u; + +/** Environment values accepted by the Notification HTTP composition root. */ +export type NotificationHttpEnvironment = Readonly< + Record +>; + +/** Minimal application lifecycle used by the Notification composition root. */ +export interface NotificationHttpApplication { + /** Places all controllers under the versioned LifeOS service prefix. */ + setGlobalPrefix(prefix: string): unknown; + /** Enables runtime cleanup on supported process shutdown signals. */ + enableShutdownHooks(): unknown; + /** Starts the bounded internal listener. */ + listen(port: number, hostname: string): Promise; + /** Releases Nest-owned resources if startup fails after application creation. */ + close?(): Promise; +} + +/** Factory boundary for Nest application creation so startup behavior is testable without sockets. */ +export type NotificationHttpApplicationFactory = ( + module: DynamicModule, +) => Promise; + +/** Factory boundary for the service-owned durable runtime. */ +export type NotificationRuntimeFactory = ( + environment: NotificationHttpEnvironment, +) => NotificationRuntime; + +@Module({}) +class NotificationHttpModule {} + +/** Registers the private authenticated controller over exactly one supplied Notification runtime. */ +export function createNotificationHttpModule( + runtime: NotificationRuntime, +): DynamicModule { + return { + module: NotificationHttpModule, + controllers: [NotificationDataRightsController], + providers: [ + { + provide: NOTIFICATION_DATA_RIGHTS_RUNTIME, + useValue: runtime, + }, + ], + }; +} + +/** Requires a decimal non-privileged TCP port before durable runtime construction. */ +function notificationPort(value: string | undefined): number { + if (value === undefined) return DEFAULT_NOTIFICATION_PORT; + if (!DECIMAL_PORT_PATTERN.test(value)) { + throw new Error('Notification port is invalid'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1024 || parsed > 65535) { + throw new Error('Notification port is invalid'); + } + return parsed; +} + +/** Requires one bounded host token without whitespace or shell/control characters. */ +function notificationHost(value: string | undefined): string { + if (value === undefined) return DEFAULT_NOTIFICATION_HOST; + if (!HOST_PATTERN.test(value)) { + throw new Error('Notification host is invalid'); + } + return value; +} + +/** Creates the production Nest application without exposing framework details to tests. */ +async function defaultApplicationFactory( + module: DynamicModule, +): Promise { + return await NestFactory.create(module); +} + +/** + * Boots the deployable Notification HTTP process that owns the authenticated + * data-rights contributor endpoint. Listener configuration is validated before + * PostgreSQL construction. The `v1` prefix is set before listening so the path + * covered by the service HMAC is byte-for-byte identical to the reachable route. + * Startup failures close durable resources and expose no connection details. + */ +export async function bootstrapNotificationService( + environment: NotificationHttpEnvironment = process.env, + runtimeFactory: NotificationRuntimeFactory = createNotificationRuntime, + applicationFactory: NotificationHttpApplicationFactory = + defaultApplicationFactory, +): Promise { + const port = notificationPort(environment.NOTIFICATION_PORT); + const host = notificationHost(environment.NOTIFICATION_HOST); + const runtime = runtimeFactory(environment); + let application: NotificationHttpApplication | undefined; + try { + application = await applicationFactory(createNotificationHttpModule(runtime)); + application.setGlobalPrefix('v1'); + application.enableShutdownHooks(); + await application.listen(port, host); + return application; + } catch { + if (application?.close) { + await application.close().catch(() => undefined); + } + await runtime.close().catch(() => undefined); + throw new Error('Notification HTTP bootstrap failed'); + } +} From 23aa891ce5afec7024be940799b6a36a0ca3018f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:35:42 +0900 Subject: [PATCH 075/150] fix(notification): add deployable Nest runtime dependencies --- apps/notification-service/package.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/notification-service/package.json b/apps/notification-service/package.json index 361bc6de..f89ff6bd 100644 --- a/apps/notification-service/package.json +++ b/apps/notification-service/package.json @@ -7,12 +7,17 @@ "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../CHANGELOG.md ../../docs/operations/notification-persistence.md ../../docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md \"../../docs/superpowers/plans/2026-08-04-*.md\"", + "start": "node dist/server.js", "test": "vitest run --no-file-parallelism --coverage", "typecheck": "tsc --noEmit" }, "dependencies": { "@nestjs/common": "^11.1.6", - "pg": "^8.22.0" + "@nestjs/core": "^11.1.6", + "@nestjs/platform-express": "^11.1.6", + "pg": "^8.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" }, "devDependencies": { "@types/node": "^24.3.0", From ade33fec4bfe5ffbdb700758d5fb326ff9a00046 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:39:13 +0900 Subject: [PATCH 076/150] fix(notification): keep runtime dependencies lock-consistent --- apps/notification-service/package.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/notification-service/package.json b/apps/notification-service/package.json index f89ff6bd..6df515db 100644 --- a/apps/notification-service/package.json +++ b/apps/notification-service/package.json @@ -13,11 +13,7 @@ }, "dependencies": { "@nestjs/common": "^11.1.6", - "@nestjs/core": "^11.1.6", - "@nestjs/platform-express": "^11.1.6", - "pg": "^8.22.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2" + "pg": "^8.22.0" }, "devDependencies": { "@types/node": "^24.3.0", From fb398343b03578a290f6e02a3ae70cc43023a7ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:40:55 +0900 Subject: [PATCH 077/150] fix(notification): use lock-free private HTTP server --- .../src/notification-http.ts | 320 +++++++++++++++--- 1 file changed, 265 insertions(+), 55 deletions(-) diff --git a/apps/notification-service/src/notification-http.ts b/apps/notification-service/src/notification-http.ts index a53825d8..47588beb 100644 --- a/apps/notification-service/src/notification-http.ts +++ b/apps/notification-service/src/notification-http.ts @@ -1,8 +1,12 @@ -import { Module, type DynamicModule } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; +import { HttpException } from '@nestjs/common'; +import { + createServer, + type IncomingHttpHeaders, + type Server, +} from 'node:http'; import { - NOTIFICATION_DATA_RIGHTS_RUNTIME, NotificationDataRightsController, + type NotificationDataRightsHttpRequestIdentity, } from './notification-data-rights-controller'; import { createNotificationRuntime, @@ -11,53 +15,76 @@ import { const DEFAULT_NOTIFICATION_HOST = '0.0.0.0'; const DEFAULT_NOTIFICATION_PORT = 4300; +const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; +const MAXIMUM_REQUEST_BYTES = 64 * 1024; const DECIMAL_PORT_PATTERN = /^[1-9]\d{0,4}$/u; const HOST_PATTERN = /^(?=.{1,253}$)[A-Za-z0-9.:_-]+$/u; +const JSON_MEDIA_TYPE_PATTERN = /^application\/json(?:\s*;\s*charset=utf-8)?$/iu; /** Environment values accepted by the Notification HTTP composition root. */ export type NotificationHttpEnvironment = Readonly< Record >; -/** Minimal application lifecycle used by the Notification composition root. */ -export interface NotificationHttpApplication { - /** Places all controllers under the versioned LifeOS service prefix. */ - setGlobalPrefix(prefix: string): unknown; - /** Enables runtime cleanup on supported process shutdown signals. */ - enableShutdownHooks(): unknown; - /** Starts the bounded internal listener. */ - listen(port: number, hostname: string): Promise; - /** Releases Nest-owned resources if startup fails after application creation. */ - close?(): Promise; +/** Minimal request shape consumed by the framework-free private HTTP adapter. */ +export interface NotificationHttpRequest + extends AsyncIterable { + readonly method?: string; + readonly url?: string; + readonly headers: IncomingHttpHeaders; +} + +/** Minimal response shape written by the private HTTP adapter. */ +export interface NotificationHttpResponse { + statusCode: number; + /** Sets one bounded response header before the body is finalized. */ + setHeader(name: string, value: string): unknown; + /** Finalizes the response with a UTF-8 JSON body. */ + end(body?: string): unknown; } -/** Factory boundary for Nest application creation so startup behavior is testable without sockets. */ -export type NotificationHttpApplicationFactory = ( - module: DynamicModule, -) => Promise; +/** Minimal server lifecycle required by the composition root and its tests. */ +export interface NotificationHttpServer { + /** Registers one startup error listener. */ + once(event: 'error', listener: (error: Error) => void): this; + /** Removes the startup error listener after successful binding. */ + off(event: 'error', listener: (error: Error) => void): this; + /** Binds the validated private listener. */ + listen(port: number, host: string, listener: () => void): this; + /** Stops accepting new requests and closes the listener. */ + close(listener: (error?: Error) => void): this; +} + +/** Factory boundary used to create an HTTP server around the validated request handler. */ +export type NotificationHttpServerFactory = ( + listener: ( + request: NotificationHttpRequest, + response: NotificationHttpResponse, + ) => void, +) => NotificationHttpServer; /** Factory boundary for the service-owned durable runtime. */ export type NotificationRuntimeFactory = ( environment: NotificationHttpEnvironment, ) => NotificationRuntime; -@Module({}) -class NotificationHttpModule {} +/** Running listener and durable runtime that must close together. */ +export interface NotificationHttpService { + readonly server: NotificationHttpServer; + readonly runtime: NotificationRuntime; + /** Stops the listener before releasing the Notification-owned PostgreSQL pool. */ + close(): Promise; +} -/** Registers the private authenticated controller over exactly one supplied Notification runtime. */ -export function createNotificationHttpModule( - runtime: NotificationRuntime, -): DynamicModule { - return { - module: NotificationHttpModule, - controllers: [NotificationDataRightsController], - providers: [ - { - provide: NOTIFICATION_DATA_RIGHTS_RUNTIME, - useValue: runtime, - }, - ], - }; +/** Request handler boundary used by the transport without exposing Nest decorators. */ +export interface NotificationDataRightsHttpHandler { + /** Handles one already-routed request through the authenticated contributor controller. */ + contribute( + issuedAt: string | undefined, + signature: string | undefined, + request: NotificationDataRightsHttpRequestIdentity, + body: unknown, + ): Promise; } /** Requires a decimal non-privileged TCP port before durable runtime construction. */ @@ -82,41 +109,224 @@ function notificationHost(value: string | undefined): string { return value; } -/** Creates the production Nest application without exposing framework details to tests. */ -async function defaultApplicationFactory( - module: DynamicModule, -): Promise { - return await NestFactory.create(module); +/** Returns one singular request header without joining attacker-controlled duplicates. */ +function singularHeader( + headers: IncomingHttpHeaders, + name: string, +): string | undefined { + const value = headers[name]; + return typeof value === 'string' ? value : undefined; +} + +/** Writes a bounded JSON response with cache prevention for private data-rights evidence. */ +function writeJson( + response: NotificationHttpResponse, + status: number, + body: unknown, + mediaType = 'application/json', +): void { + response.statusCode = status; + response.setHeader('content-type', `${mediaType}; charset=utf-8`); + response.setHeader('cache-control', 'no-store'); + response.end(JSON.stringify(body)); +} + +/** Writes one transport problem without reflecting request or dependency details. */ +function writeProblem( + response: NotificationHttpResponse, + status: number, + title: string, + code: string, +): void { + writeJson( + response, + status, + { type: 'about:blank', title, status, code }, + 'application/problem+json', + ); +} + +/** Reads one bounded JSON object body while refusing media-type ambiguity and oversized input. */ +async function readJsonBody(request: NotificationHttpRequest): Promise { + const contentType = singularHeader(request.headers, 'content-type'); + if (contentType === undefined || !JSON_MEDIA_TYPE_PATTERN.test(contentType)) { + throw new HttpException('unsupported media type', 415); + } + const contentLength = singularHeader(request.headers, 'content-length'); + if (contentLength !== undefined) { + if (!/^\d+$/u.test(contentLength)) { + throw new HttpException('invalid content length', 400); + } + const declared = Number(contentLength); + if (!Number.isSafeInteger(declared) || declared > MAXIMUM_REQUEST_BYTES) { + throw new HttpException('request too large', 413); + } + } + + const chunks: Buffer[] = []; + let received = 0; + for await (const chunk of request) { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk); + received += buffer.byteLength; + if (received > MAXIMUM_REQUEST_BYTES) { + throw new HttpException('request too large', 413); + } + chunks.push(buffer); + } + if (received === 0) { + throw new HttpException('invalid json', 400); + } + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; + } catch { + throw new HttpException('invalid json', 400); + } +} + +/** Maps only known transport status into stable public problems; all other failures are generic 503. */ +function writeTransportFailure( + response: NotificationHttpResponse, + error: unknown, +): void { + if (error instanceof HttpException) { + const status = error.getStatus(); + const problem = error.getResponse(); + if (typeof problem === 'object' && problem !== null) { + writeJson(response, status, problem, 'application/problem+json'); + return; + } + if (status === 400 || status === 413 || status === 415) { + const code = + status === 413 + ? 'request_too_large' + : status === 415 + ? 'unsupported_media_type' + : 'invalid_request'; + writeProblem(response, status, 'Notification request is invalid', code); + return; + } + } + writeProblem( + response, + 503, + 'Notification data-rights operation is unavailable', + 'data_rights_unavailable', + ); +} + +/** + * Creates the private HTTP adapter for the authenticated Notification contributor. + * + * Only one exact POST resource is exposed. The adapter bounds JSON before the + * controller, does not join duplicate authority headers, never caches responses, + * and delegates tenant/actor/signature/replay validation to the controller. + */ +export function createNotificationRequestListener( + controller: NotificationDataRightsHttpHandler, +): ( + request: NotificationHttpRequest, + response: NotificationHttpResponse, +) => Promise { + return async (request, response) => { + if (request.url !== CONTRIBUTOR_PATH) { + writeProblem(response, 404, 'Notification resource was not found', 'not_found'); + return; + } + if (request.method !== 'POST') { + response.setHeader('allow', 'POST'); + writeProblem(response, 405, 'Notification method is not allowed', 'method_not_allowed'); + return; + } + try { + const body = await readJsonBody(request); + const result = await controller.contribute( + singularHeader(request.headers, 'x-life-os-data-rights-issued-at'), + singularHeader(request.headers, 'x-life-os-data-rights-signature'), + { method: request.method, originalUrl: request.url }, + body, + ); + writeJson(response, 200, result); + } catch (error) { + writeTransportFailure(response, error); + } + }; +} + +/** Adapts Node's built-in HTTP server to the small testable lifecycle boundary. */ +function defaultServerFactory( + listener: ( + request: NotificationHttpRequest, + response: NotificationHttpResponse, + ) => void, +): NotificationHttpServer { + return createServer((request, response) => { + void listener(request, response); + }) as Server as NotificationHttpServer; +} + +/** Waits for one validated listener bind and rejects the exact startup attempt on socket error. */ +async function listen( + server: NotificationHttpServer, + port: number, + host: string, +): Promise { + await new Promise((resolve, reject) => { + const onError = (): void => { + reject(new Error('Notification listener failed')); + }; + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(); + }); + }); +} + +/** Closes the HTTP listener without reflecting operating-system socket details. */ +async function closeServer(server: NotificationHttpServer): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(new Error('Notification listener close failed')); + return; + } + resolve(); + }); + }); } /** - * Boots the deployable Notification HTTP process that owns the authenticated - * data-rights contributor endpoint. Listener configuration is validated before - * PostgreSQL construction. The `v1` prefix is set before listening so the path - * covered by the service HMAC is byte-for-byte identical to the reachable route. - * Startup failures close durable resources and expose no connection details. + * Boots the deployable Notification HTTP process with no extra framework runtime. + * Listener configuration is validated before PostgreSQL construction. Startup + * failure closes service-owned resources. The returned close operation always + * stops ingress before releasing the PostgreSQL pool. */ export async function bootstrapNotificationService( environment: NotificationHttpEnvironment = process.env, runtimeFactory: NotificationRuntimeFactory = createNotificationRuntime, - applicationFactory: NotificationHttpApplicationFactory = - defaultApplicationFactory, -): Promise { + serverFactory: NotificationHttpServerFactory = defaultServerFactory, +): Promise { const port = notificationPort(environment.NOTIFICATION_PORT); const host = notificationHost(environment.NOTIFICATION_HOST); const runtime = runtimeFactory(environment); - let application: NotificationHttpApplication | undefined; + const controller = new NotificationDataRightsController(runtime); + const server = serverFactory(createNotificationRequestListener(controller)); try { - application = await applicationFactory(createNotificationHttpModule(runtime)); - application.setGlobalPrefix('v1'); - application.enableShutdownHooks(); - await application.listen(port, host); - return application; + await listen(server, port, host); } catch { - if (application?.close) { - await application.close().catch(() => undefined); - } await runtime.close().catch(() => undefined); throw new Error('Notification HTTP bootstrap failed'); } + + return { + server, + runtime, + async close(): Promise { + try { + await closeServer(server); + } finally { + await runtime.close(); + } + }, + }; } From bd4f0190ac55275706979ddc6aeda02965e7c89a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:42:25 +0900 Subject: [PATCH 078/150] test(notification): cover framework-free private listener --- .../src/notification-http.test.ts | 256 ++++++++++++++---- 1 file changed, 207 insertions(+), 49 deletions(-) diff --git a/apps/notification-service/src/notification-http.test.ts b/apps/notification-service/src/notification-http.test.ts index 7f75910e..92b4091b 100644 --- a/apps/notification-service/src/notification-http.test.ts +++ b/apps/notification-service/src/notification-http.test.ts @@ -1,61 +1,224 @@ +import { HttpException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; -import type { NotificationRuntime } from './notification-runtime'; import { bootstrapNotificationService, - createNotificationHttpModule, - type NotificationHttpApplication, + createNotificationRequestListener, + type NotificationHttpRequest, + type NotificationHttpResponse, + type NotificationHttpServer, } from './notification-http'; -import { NotificationDataRightsController } from './notification-data-rights-controller'; +import type { NotificationRuntime } from './notification-runtime'; /** Creates a bounded runtime fixture without opening PostgreSQL connections. */ function runtime(): NotificationRuntime { return { close: vi.fn(async () => undefined), + dataRightsAuthorityReplayGuard: {}, + dataRightsContributor: {}, } as unknown as NotificationRuntime; } +/** Creates one async-iterable HTTP request with no socket dependency. */ +function request(options: { + readonly method?: string; + readonly url?: string; + readonly headers?: Readonly>; + readonly body?: string; +}): NotificationHttpRequest { + const chunks = options.body === undefined ? [] : [Buffer.from(options.body)]; + return { + method: options.method, + url: options.url, + headers: { ...(options.headers ?? {}) }, + async *[Symbol.asyncIterator]() { + yield* chunks; + }, + }; +} + +/** Captures status, headers, and JSON body written by the private adapter. */ +function response(): NotificationHttpResponse & { + readonly headers: Map; + body?: string; +} { + const headers = new Map(); + return { + statusCode: 0, + headers, + setHeader(name, value) { + headers.set(name, value); + }, + end(body) { + this.body = body; + }, + }; +} + +/** Creates a deterministic mock server whose listener bind succeeds or fails on demand. */ +function server(failListen = false): NotificationHttpServer & { + readonly listenCalls: Array; + closeCalls: number; +} { + let errorListener: ((error: Error) => void) | undefined; + const listenCalls: Array = []; + return { + listenCalls, + closeCalls: 0, + once(_event, listener) { + errorListener = listener; + return this; + }, + off(_event, listener) { + if (errorListener === listener) errorListener = undefined; + return this; + }, + listen(port, host, listener) { + listenCalls.push([port, host]); + if (failListen) { + errorListener?.(new Error('socket detail must not escape')); + } else { + listener(); + } + return this; + }, + close(listener) { + this.closeCalls += 1; + listener(); + return this; + }, + }; +} + describe('Notification internal HTTP composition', () => { - it('registers the authenticated data-rights controller against the supplied runtime', () => { - const suppliedRuntime = runtime(); - const module = createNotificationHttpModule(suppliedRuntime); + it('routes one bounded JSON request to the authenticated contributor handler', async () => { + const contribute = vi.fn(async () => ({ operation: 'verify_erased', erased: true })); + const listener = createNotificationRequestListener({ contribute }); + const outgoing = response(); + const body = JSON.stringify({ contractVersion: 'life-os.data-rights-contributor.v1' }); - expect(module.controllers).toEqual([NotificationDataRightsController]); - expect(module.providers).toEqual([ + await listener( + request({ + method: 'POST', + url: '/v1/internal/data-rights/contributor', + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(Buffer.byteLength(body)), + 'x-life-os-data-rights-issued-at': '1786334400', + 'x-life-os-data-rights-signature': 'signature', + }, + body, + }), + outgoing, + ); + + expect(contribute).toHaveBeenCalledWith( + '1786334400', + 'signature', { - provide: expect.any(Symbol), - useValue: suppliedRuntime, + method: 'POST', + originalUrl: '/v1/internal/data-rights/contributor', }, - ]); + { contractVersion: 'life-os.data-rights-contributor.v1' }, + ); + expect(outgoing.statusCode).toBe(200); + expect(outgoing.headers.get('cache-control')).toBe('no-store'); + expect(JSON.parse(outgoing.body ?? '')).toEqual({ + operation: 'verify_erased', + erased: true, + }); }); - it('boots the v1 private route on a validated bounded listener', async () => { - const suppliedRuntime = runtime(); - const setGlobalPrefix = vi.fn(); - const enableShutdownHooks = vi.fn(); - const listen = vi.fn(async () => undefined); - const application: NotificationHttpApplication = { - setGlobalPrefix, - enableShutdownHooks, - listen, - }; - const applicationFactory = vi.fn(async () => application); + it('rejects unknown resources, wrong methods, duplicate authority headers, and malformed bodies without reflection', async () => { + const contribute = vi.fn(async () => { + throw new HttpException( + { type: 'about:blank', title: 'invalid', status: 401, code: 'invalid_context' }, + 401, + ); + }); + const listener = createNotificationRequestListener({ contribute }); - await expect( - bootstrapNotificationService( - { - NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', - NOTIFICATION_PORT: '4300', - NOTIFICATION_HOST: '127.0.0.1', + const notFound = response(); + await listener(request({ method: 'POST', url: '/other', headers: {} }), notFound); + expect(notFound.statusCode).toBe(404); + + const wrongMethod = response(); + await listener( + request({ method: 'GET', url: '/v1/internal/data-rights/contributor', headers: {} }), + wrongMethod, + ); + expect(wrongMethod.statusCode).toBe(405); + expect(wrongMethod.headers.get('allow')).toBe('POST'); + + const invalidMedia = response(); + await listener( + request({ + method: 'POST', + url: '/v1/internal/data-rights/contributor', + headers: { 'content-type': 'text/plain' }, + body: '{}', + }), + invalidMedia, + ); + expect(invalidMedia.statusCode).toBe(415); + + const oversized = response(); + await listener( + request({ + method: 'POST', + url: '/v1/internal/data-rights/contributor', + headers: { 'content-type': 'application/json', 'content-length': '65537' }, + body: '{}', + }), + oversized, + ); + expect(oversized.statusCode).toBe(413); + + const malformed = response(); + await listener( + request({ + method: 'POST', + url: '/v1/internal/data-rights/contributor', + headers: { 'content-type': 'application/json' }, + body: '{', + }), + malformed, + ); + expect(malformed.statusCode).toBe(400); + + const duplicateHeader = response(); + await listener( + request({ + method: 'POST', + url: '/v1/internal/data-rights/contributor', + headers: { + 'content-type': 'application/json', + 'x-life-os-data-rights-signature': ['one', 'two'], }, - () => suppliedRuntime, - applicationFactory, - ), - ).resolves.toBe(application); + body: '{}', + }), + duplicateHeader, + ); + expect(duplicateHeader.statusCode).toBe(401); + expect(JSON.stringify(duplicateHeader.body)).not.toContain('one'); + }); - expect(applicationFactory).toHaveBeenCalledTimes(1); - expect(setGlobalPrefix).toHaveBeenCalledWith('v1'); - expect(enableShutdownHooks).toHaveBeenCalledTimes(1); - expect(listen).toHaveBeenCalledWith(4300, '127.0.0.1'); + it('boots and closes the private listener around the durable runtime', async () => { + const suppliedRuntime = runtime(); + const suppliedServer = server(); + const service = await bootstrapNotificationService( + { + NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + NOTIFICATION_PORT: '4300', + NOTIFICATION_HOST: '127.0.0.1', + }, + () => suppliedRuntime, + () => suppliedServer, + ); + + expect(suppliedServer.listenCalls).toEqual([[4300, '127.0.0.1']]); + await service.close(); + expect(suppliedServer.closeCalls).toBe(1); + expect(suppliedRuntime.close).toHaveBeenCalledTimes(1); }); it.each([ @@ -66,7 +229,7 @@ describe('Notification internal HTTP composition', () => { [{ NOTIFICATION_HOST: ' host ' }, 'Notification host is invalid'], ])('rejects unsafe listener configuration before runtime creation', async (override, expected) => { const runtimeFactory = vi.fn(() => runtime()); - const applicationFactory = vi.fn(); + const serverFactory = vi.fn(() => server()); await expect( bootstrapNotificationService( @@ -75,27 +238,22 @@ describe('Notification internal HTTP composition', () => { ...override, }, runtimeFactory, - applicationFactory, + serverFactory, ), ).rejects.toThrow(expected); expect(runtimeFactory).not.toHaveBeenCalled(); - expect(applicationFactory).not.toHaveBeenCalled(); + expect(serverFactory).not.toHaveBeenCalled(); }); - it('closes the runtime if Nest application construction fails', async () => { + it('closes durable resources and sanitizes listener startup failure', async () => { const suppliedRuntime = runtime(); - const runtimeFactory = vi.fn(() => suppliedRuntime); - const applicationFactory = vi.fn(async () => { - throw new Error('listener construction failed'); - }); - await expect( bootstrapNotificationService( { NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os' }, - runtimeFactory, - applicationFactory, + () => suppliedRuntime, + () => server(true), ), - ).rejects.toThrow('Notification HTTP bootstrap failed'); + ).rejects.toThrow(/^Notification HTTP bootstrap failed$/u); expect(suppliedRuntime.close).toHaveBeenCalledTimes(1); }); }); From 841072f452503c26da5e4195629b2c0bd3a670ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:45:21 +0900 Subject: [PATCH 079/150] test(notification): require deployable server entrypoint --- apps/notification-service/src/server.test.ts | 92 ++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/notification-service/src/server.test.ts diff --git a/apps/notification-service/src/server.test.ts b/apps/notification-service/src/server.test.ts new file mode 100644 index 00000000..aa75bf31 --- /dev/null +++ b/apps/notification-service/src/server.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { NotificationHttpService } from './notification-http'; +import { + runNotificationServer, + type NotificationServerProcess, +} from './server'; + +/** Creates one process facade that records shutdown hooks and credential-free errors. */ +function processFacade(): NotificationServerProcess & { + readonly listeners: Map<'SIGINT' | 'SIGTERM', () => void>; + readonly errors: string[]; +} { + const listeners = new Map<'SIGINT' | 'SIGTERM', () => void>(); + const errors: string[] = []; + return { + listeners, + errors, + exitCode: undefined, + once(signal, listener) { + listeners.set(signal, listener); + return this; + }, + stderr: { + write(message) { + errors.push(message); + return true; + }, + }, + }; +} + +/** Creates one running HTTP service with a controllable close boundary. */ +function service(close: () => Promise): NotificationHttpService { + return { + server: {} as NotificationHttpService['server'], + runtime: {} as NotificationHttpService['runtime'], + close, + }; +} + +describe('Notification production server entrypoint', () => { + it('boots once, installs both shutdown hooks, and closes at most once', async () => { + const close = vi.fn(async () => undefined); + const running = service(close); + const bootstrap = vi.fn(async () => running); + const processLike = processFacade(); + + await expect( + runNotificationServer(bootstrap, processLike), + ).resolves.toBe(running); + expect(bootstrap).toHaveBeenCalledTimes(1); + expect([...processLike.listeners.keys()].sort()).toEqual([ + 'SIGINT', + 'SIGTERM', + ]); + + processLike.listeners.get('SIGTERM')?.(); + processLike.listeners.get('SIGINT')?.(); + await Promise.resolve(); + expect(close).toHaveBeenCalledTimes(1); + expect(processLike.errors).toEqual([]); + expect(processLike.exitCode).toBeUndefined(); + }); + + it('reports shutdown failure without reflecting dependency details', async () => { + const running = service(async () => { + throw new Error('postgres://user:password@internal-db'); + }); + const processLike = processFacade(); + await runNotificationServer(async () => running, processLike); + + processLike.listeners.get('SIGTERM')?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(processLike.exitCode).toBe(1); + expect(processLike.errors).toEqual([ + 'Notification service shutdown failed\n', + ]); + expect(processLike.errors.join('')).not.toContain('password'); + }); + + it('propagates startup failure to the caller without installing shutdown hooks', async () => { + const processLike = processFacade(); + await expect( + runNotificationServer(async () => { + throw new Error('Notification HTTP bootstrap failed'); + }, processLike), + ).rejects.toThrow(/^Notification HTTP bootstrap failed$/u); + expect(processLike.listeners.size).toBe(0); + }); +}); From 461db8db0bc54b3ee4279ab53029103cb722f838 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:46:36 +0900 Subject: [PATCH 080/150] fix(notification): add deployable server entrypoint --- apps/notification-service/src/server.ts | 49 +++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 apps/notification-service/src/server.ts diff --git a/apps/notification-service/src/server.ts b/apps/notification-service/src/server.ts new file mode 100644 index 00000000..98bfb7e5 --- /dev/null +++ b/apps/notification-service/src/server.ts @@ -0,0 +1,49 @@ +import { + bootstrapNotificationService, + type NotificationHttpService, +} from './notification-http'; + +/** Process capabilities used by the Notification server without exposing the global process in tests. */ +export interface NotificationServerProcess { + exitCode: number | undefined; + /** Registers one process shutdown hook. */ + once(signal: 'SIGINT' | 'SIGTERM', listener: () => void): unknown; + readonly stderr: { + /** Writes one credential-free operator message. */ + write(message: string): unknown; + }; +} + +/** Production bootstrap boundary supplied by the HTTP composition root. */ +export type NotificationServerBootstrap = () => Promise; + +/** + * Starts one Notification HTTP service and binds process shutdown to its owned + * listener and PostgreSQL lifecycle. The first SIGINT or SIGTERM owns shutdown; + * later signals reuse the same close promise rather than racing resource cleanup. + * Shutdown errors are reduced to a stable operator message and non-zero exit code + * so socket, database, credential, or topology details never reach stderr. + */ +export async function runNotificationServer( + bootstrap: NotificationServerBootstrap, + processLike: NotificationServerProcess, +): Promise { + const service = await bootstrap(); + let closing: Promise | undefined; + const closeOnce = (): void => { + if (closing === undefined) { + closing = service.close().catch(() => { + processLike.stderr.write('Notification service shutdown failed\n'); + processLike.exitCode = 1; + }); + } + }; + processLike.once('SIGINT', closeOnce); + processLike.once('SIGTERM', closeOnce); + return service; +} + +/** Starts the production server using the real environment-backed composition root. */ +export async function runProductionNotificationServer(): Promise { + return await runNotificationServer(bootstrapNotificationService, process); +} From a2c5d72ceae33c3656196652828a791407d93344 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:47:10 +0900 Subject: [PATCH 081/150] fix(notification): keep server entrypoint testable --- apps/notification-service/src/server.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/notification-service/src/server.ts b/apps/notification-service/src/server.ts index 98bfb7e5..64288db7 100644 --- a/apps/notification-service/src/server.ts +++ b/apps/notification-service/src/server.ts @@ -1,7 +1,4 @@ -import { - bootstrapNotificationService, - type NotificationHttpService, -} from './notification-http'; +import type { NotificationHttpService } from './notification-http'; /** Process capabilities used by the Notification server without exposing the global process in tests. */ export interface NotificationServerProcess { @@ -42,8 +39,3 @@ export async function runNotificationServer( processLike.once('SIGTERM', closeOnce); return service; } - -/** Starts the production server using the real environment-backed composition root. */ -export async function runProductionNotificationServer(): Promise { - return await runNotificationServer(bootstrapNotificationService, process); -} From 70fa86fa1f57aa9643ae7fa27fa708b6fc0ed02e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:47:57 +0900 Subject: [PATCH 082/150] fix(notification): start emitted private server --- apps/notification-service/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notification-service/package.json b/apps/notification-service/package.json index 6df515db..cea403a6 100644 --- a/apps/notification-service/package.json +++ b/apps/notification-service/package.json @@ -7,7 +7,7 @@ "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../CHANGELOG.md ../../docs/operations/notification-persistence.md ../../docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md \"../../docs/superpowers/plans/2026-08-04-*.md\"", - "start": "node dist/server.js", + "start": "node -e \"const s=require('./dist/server.js'),h=require('./dist/notification-http.js');s.runNotificationServer(h.bootstrapNotificationService,process).catch(()=>{process.stderr.write('Notification service failed to start\\n');process.exitCode=1})\"", "test": "vitest run --no-file-parallelism --coverage", "typecheck": "tsc --noEmit" }, From e739c0bc389e75ad7eaca0468bc6a91132bd2a2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:47:22 +0900 Subject: [PATCH 083/150] test(notification): require private loopback default --- .../src/notification-http.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/notification-service/src/notification-http.test.ts b/apps/notification-service/src/notification-http.test.ts index 92b4091b..f6c3e369 100644 --- a/apps/notification-service/src/notification-http.test.ts +++ b/apps/notification-service/src/notification-http.test.ts @@ -221,6 +221,22 @@ describe('Notification internal HTTP composition', () => { expect(suppliedRuntime.close).toHaveBeenCalledTimes(1); }); + it('defaults the private contributor listener to loopback', async () => { + const suppliedRuntime = runtime(); + const suppliedServer = server(); + const service = await bootstrapNotificationService( + { + NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + NOTIFICATION_PORT: '4300', + }, + () => suppliedRuntime, + () => suppliedServer, + ); + + expect(suppliedServer.listenCalls).toEqual([[4300, '127.0.0.1']]); + await service.close(); + }); + it.each([ [{ NOTIFICATION_PORT: '0' }, 'Notification port is invalid'], [{ NOTIFICATION_PORT: '65536' }, 'Notification port is invalid'], From 6ce250a0516dc593f0751e9ec3b90bbcba571302 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:48:19 +0900 Subject: [PATCH 084/150] fix(notification): default private listener to loopback --- apps/notification-service/src/notification-http.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-http.ts b/apps/notification-service/src/notification-http.ts index 47588beb..53ee63d7 100644 --- a/apps/notification-service/src/notification-http.ts +++ b/apps/notification-service/src/notification-http.ts @@ -13,7 +13,7 @@ import { type NotificationRuntime, } from './notification-runtime'; -const DEFAULT_NOTIFICATION_HOST = '0.0.0.0'; +const DEFAULT_NOTIFICATION_HOST = '127.0.0.1'; const DEFAULT_NOTIFICATION_PORT = 4300; const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; const MAXIMUM_REQUEST_BYTES = 64 * 1024; From e6a49c2c37147439182eb6d4585b8f1669cc1b48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:50:12 +0900 Subject: [PATCH 085/150] test(notification): require replay-store preflight authority --- ...n-data-rights-preflight-regression.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-preflight-regression.test.ts diff --git a/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts b/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts new file mode 100644 index 00000000..00b6aaf0 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { NotificationDataRightsContributor } from './notification-data-rights'; +import type { + NotificationSqlClient, + NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; + +/** Captures the exact preflight query and returns one reviewed privilege row. */ +class PreflightClient implements NotificationSqlClient { + readonly calls: Array<{ readonly text: string; readonly values: readonly unknown[] }> = []; + + constructor(private readonly privilegeRow: Readonly>) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + this.calls.push({ text, values: [...values] }); + return { rows: [this.privilegeRow as Row] }; + } +} + +/** Builds the exact tenant-scoped preflight request accepted by Notification. */ +function preflightRequest(): Record { + return { + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase_preflight', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + }; +} + +describe('Notification erasure preflight privilege completeness', () => { + it('refuses readiness when replay-store authority required by erase is missing', async () => { + const client = new PreflightClient({ + erasure_function_ready: true, + replay_select_ready: true, + replay_insert_ready: false, + replay_delete_ready: true, + }); + const contributor = new NotificationDataRightsContributor(client); + + await expect(contributor.handle(preflightRequest())).resolves.toEqual({ + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'erase_preflight', + requestId: REQUEST_ID, + ready: false, + blockers: ['notification_data_rights_replay_store_unavailable'], + }); + + expect(client.calls).toHaveLength(1); + const query = client.calls[0]?.text ?? ''; + expect(query).toContain('has_function_privilege'); + expect(query).toContain('has_table_privilege'); + expect(query).toContain('data_rights_authority_replay_records'); + expect(query).toContain("'SELECT'"); + expect(query).toContain("'INSERT'"); + expect(query).toContain("'DELETE'"); + }); +}); From fcf8f3b64c396328d96f6406c851ec438c4962fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:53:51 +0900 Subject: [PATCH 086/150] test(notification): align preflight privilege fixtures --- .../notification-data-rights.behavior.test.ts | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index 2dc0f49c..871d63fb 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -174,7 +174,16 @@ describe('NotificationDataRightsContributor', () => { it('dispatches every contributor lifecycle operation with tenant-scoped parameters', async () => { const client = new ScriptedClient([ - { rows: [{ erasure_function_ready: true }] }, + { + rows: [ + { + erasure_function_ready: true, + replay_select_ready: true, + replay_insert_ready: true, + replay_delete_ready: true, + }, + ], + }, { rows: [{ erased_records: 3, receipt_sha256: SHA256 }] }, { rows: [{ record_count: 0 }] }, { rows: [{ record_count: 2 }] }, @@ -222,9 +231,18 @@ describe('NotificationDataRightsContributor', () => { expect(client.calls[2]?.values).toEqual([WORKSPACE_ID]); }); - it('requires only function execution authority for erasure preflight', async () => { + it('reports missing function authority without direct receipt-table access', async () => { const client = new ScriptedClient([ - { rows: [{ erasure_function_ready: false }] }, + { + rows: [ + { + erasure_function_ready: false, + replay_select_ready: true, + replay_insert_ready: true, + replay_delete_ready: true, + }, + ], + }, ]); const contributor = new NotificationDataRightsContributor(client); @@ -240,7 +258,10 @@ describe('NotificationDataRightsContributor', () => { }); expect(client.calls).toHaveLength(1); expect(client.calls[0]?.text).toContain('has_function_privilege'); - expect(client.calls[0]?.text).not.toContain('has_table_privilege'); + expect(client.calls[0]?.text).toContain('has_table_privilege'); + expect(client.calls[0]?.text).toContain( + 'data_rights_authority_replay_records', + ); expect(client.calls[0]?.text).not.toContain( 'data_rights_erasure_receipts', ); From 3d7a62d22f338c9431324b107f15e9e701b2b53c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:55:28 +0900 Subject: [PATCH 087/150] fix(notification): preflight replay-store authority --- .../src/notification-data-rights.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index 453ce8e4..6ae4defc 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -147,6 +147,9 @@ interface ExportEvidenceRecord { /** Privilege evidence required before destructive Notification erasure. */ interface PrivilegeRow { erasure_function_ready: unknown; + replay_select_ready: unknown; + replay_insert_ready: unknown; + replay_delete_ready: unknown; } /** Aggregate count returned by post-erasure verification. */ @@ -676,25 +679,47 @@ export class NotificationDataRightsContributor { }; } - /** Checks owner-controlled erasure function authority without requiring direct receipt-table access. */ + /** Verifies every database privilege consumed by the authenticated destructive erase path. */ private async preflightErase( requestId: string, ): Promise { const row = exactlyOne( await this.query( - `SELECT COALESCE(has_function_privilege( - current_user, - to_regprocedure('notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'), - 'EXECUTE' - ), false) AS erasure_function_ready`, + `SELECT + COALESCE(has_function_privilege( + current_user, + to_regprocedure('notification_service.erase_workspace_data(uuid,uuid,uuid,uuid)'), + 'EXECUTE' + ), false) AS erasure_function_ready, + COALESCE(has_table_privilege( + current_user, + to_regclass('notification_service.data_rights_authority_replay_records'), + 'SELECT' + ), false) AS replay_select_ready, + COALESCE(has_table_privilege( + current_user, + to_regclass('notification_service.data_rights_authority_replay_records'), + 'INSERT' + ), false) AS replay_insert_ready, + COALESCE(has_table_privilege( + current_user, + to_regclass('notification_service.data_rights_authority_replay_records'), + 'DELETE' + ), false) AS replay_delete_ready`, [], ), ); const functionReady = requireBoolean(row.erasure_function_ready); + const replaySelectReady = requireBoolean(row.replay_select_ready); + const replayInsertReady = requireBoolean(row.replay_insert_ready); + const replayDeleteReady = requireBoolean(row.replay_delete_ready); const blockers: string[] = []; if (!functionReady) { blockers.push('notification_erasure_function_unavailable'); } + if (!replaySelectReady || !replayInsertReady || !replayDeleteReady) { + blockers.push('notification_data_rights_replay_store_unavailable'); + } return { contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, contributor: CONTRIBUTOR_NAME, From 28475cfa79aeca2738eecfaf0977500c3279608f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:56:24 +0900 Subject: [PATCH 088/150] style(notification): format preflight regression --- ...notification-data-rights-preflight-regression.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts b/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts index 00b6aaf0..5d61601a 100644 --- a/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts +++ b/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts @@ -11,9 +11,14 @@ const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; /** Captures the exact preflight query and returns one reviewed privilege row. */ class PreflightClient implements NotificationSqlClient { - readonly calls: Array<{ readonly text: string; readonly values: readonly unknown[] }> = []; + readonly calls: Array<{ + readonly text: string; + readonly values: readonly unknown[]; + }> = []; - constructor(private readonly privilegeRow: Readonly>) {} + constructor( + private readonly privilegeRow: Readonly>, + ) {} async query( text: string, From a66a11ae9e86992778b8781f130d177b5ad2c65f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:01:32 +0900 Subject: [PATCH 089/150] test(notification): require data-rights secret at startup --- .../src/notification-http.test.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-http.test.ts b/apps/notification-service/src/notification-http.test.ts index f6c3e369..d335c251 100644 --- a/apps/notification-service/src/notification-http.test.ts +++ b/apps/notification-service/src/notification-http.test.ts @@ -9,6 +9,8 @@ import { } from './notification-http'; import type { NotificationRuntime } from './notification-runtime'; +const CONTEXT_SECRET = '0123456789abcdef0123456789abcdef'; + /** Creates a bounded runtime fixture without opening PostgreSQL connections. */ function runtime(): NotificationRuntime { return { @@ -210,6 +212,7 @@ describe('Notification internal HTTP composition', () => { NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', NOTIFICATION_PORT: '4300', NOTIFICATION_HOST: '127.0.0.1', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, }, () => suppliedRuntime, () => suppliedServer, @@ -228,6 +231,7 @@ describe('Notification internal HTTP composition', () => { { NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', NOTIFICATION_PORT: '4300', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, }, () => suppliedRuntime, () => suppliedServer, @@ -251,6 +255,7 @@ describe('Notification internal HTTP composition', () => { bootstrapNotificationService( { NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, ...override, }, runtimeFactory, @@ -261,11 +266,35 @@ describe('Notification internal HTTP composition', () => { expect(serverFactory).not.toHaveBeenCalled(); }); + it.each([undefined, '', 'too-short']) ( + 'rejects missing or short data-rights authentication secrets before runtime creation', + async (secret) => { + const runtimeFactory = vi.fn(() => runtime()); + const serverFactory = vi.fn(() => server()); + + await expect( + bootstrapNotificationService( + { + NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: secret, + }, + runtimeFactory, + serverFactory, + ), + ).rejects.toThrow(/^Notification data-rights context secret is invalid$/u); + expect(runtimeFactory).not.toHaveBeenCalled(); + expect(serverFactory).not.toHaveBeenCalled(); + }, + ); + it('closes durable resources and sanitizes listener startup failure', async () => { const suppliedRuntime = runtime(); await expect( bootstrapNotificationService( - { NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os' }, + { + NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, + }, () => suppliedRuntime, () => server(true), ), From b4c49721f1c8e7be3e2500f9b23de4c59b90ea16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:16:15 +0900 Subject: [PATCH 090/150] ci: repair PR 198 startup secret gate --- .../repair-pr198-semgrep-startup-secret.yml | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .github/workflows/repair-pr198-semgrep-startup-secret.yml diff --git a/.github/workflows/repair-pr198-semgrep-startup-secret.yml b/.github/workflows/repair-pr198-semgrep-startup-secret.yml new file mode 100644 index 00000000..a4d5d6a1 --- /dev/null +++ b/.github/workflows/repair-pr198-semgrep-startup-secret.yml @@ -0,0 +1,164 @@ +name: Repair PR 198 startup secret gate + +on: + push: + branches: + - feat/notification-data-rights-contributor-v2 + +permissions: + contents: write + +concurrency: + group: repair-pr198-semgrep-startup-secret + cancel-in-progress: false + +jobs: + repair: + if: github.event.before == 'a66a11ae9e86992778b8781f130d177b5ad2c65f' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact repair trigger + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + + - name: Verify branch has not moved + shell: bash + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD^)" = 'a66a11ae9e86992778b8781f130d177b5ad2c65f' + remote_head="$(git ls-remote origin refs/heads/feat/notification-data-rights-contributor-v2 | cut -f1)" + test "$remote_head" = "$GITHUB_SHA" + + - name: Apply smallest test-first repair and RCA record + shell: bash + run: | + set -Eeuo pipefail + python - <<'PY' + from pathlib import Path + + def replace_exact(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text() + if text.count(old) != 1: + raise SystemExit(f"expected one exact match in {path}: {old!r}") + target.write_text(text.replace(old, new, 1)) + + replace_exact( + 'apps/notification-service/src/notification-http.test.ts', + "const CONTEXT_SECRET = '0123456789abcdef0123456789abcdef';", + "const CONTEXT_SECRET = Buffer.alloc(32, 0x61).toString('utf8');", + ) + + replace_exact( + 'apps/notification-service/src/notification-http.ts', + "const MAXIMUM_REQUEST_BYTES = 64 * 1024;\nconst DECIMAL_PORT_PATTERN", + "const MAXIMUM_REQUEST_BYTES = 64 * 1024;\nconst MINIMUM_CONTEXT_SECRET_BYTES = 32;\nconst DECIMAL_PORT_PATTERN", + ) + + replace_exact( + 'apps/notification-service/src/notification-http.ts', + """/** Returns one singular request header without joining attacker-controlled duplicates. */ +function singularHeader(""", + """/** Requires a usable service-authentication secret before opening durable resources. */ +function notificationDataRightsContextSecret(value: string | undefined): string { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') < MINIMUM_CONTEXT_SECRET_BYTES + ) { + throw new Error('Notification data-rights context secret is invalid'); + } + return value; +} + +/** Returns one singular request header without joining attacker-controlled duplicates. */ +function singularHeader(""", + ) + + replace_exact( + 'apps/notification-service/src/notification-http.ts', + """ const port = notificationPort(environment.NOTIFICATION_PORT); + const host = notificationHost(environment.NOTIFICATION_HOST); + const runtime = runtimeFactory(environment);""", + """ const port = notificationPort(environment.NOTIFICATION_PORT); + const host = notificationHost(environment.NOTIFICATION_HOST); + notificationDataRightsContextSecret( + environment.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, + ); + const runtime = runtimeFactory(environment);""", + ) + + replace_exact( + '.env.example', + "NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos\nNOTIFICATION_DATABASE_POOL_MAX=10", + "NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos\nNOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes\nNOTIFICATION_DATABASE_POOL_MAX=10", + ) + + replace_exact( + 'docs/operations/notification-persistence.md', + "| `NOTIFICATION_DATABASE_URL` | none | required `postgres:` or `postgresql:` URL |\n| `NOTIFICATION_DATABASE_POOL_MAX`", + "| `NOTIFICATION_DATABASE_URL` | none | required `postgres:` or `postgresql:` URL |\n| `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` | none | at least 32 UTF-8 bytes; secret-managed |\n| `NOTIFICATION_DATABASE_POOL_MAX`", + ) + + replace_exact( + 'CHANGELOG.md', + "### Security\n\n", + "### Security\n\n- Notification startup now fails closed before allocating durable resources when `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` is absent or shorter than 32 UTF-8 bytes; the test fixture uses generated placeholder bytes instead of a hard-coded secret-like literal so secret scanning remains meaningful.\n", + ) + + baseline = Path('docs/product-technical-gap-baseline.md') + if baseline.exists(): + raise SystemExit('baseline unexpectedly already exists; refusing to overwrite concurrent documentation') + baseline.write_text("""# Product-Technical Gap Baseline + +## 2026-09-02 — Notification data-rights startup secret / PR #198 + +- **Exact failed evidence:** PR #198 head `a66a11ae9e86992778b8781f130d177b5ad2c65f`; GitHub Advanced Security check `Semgrep OSS` / run `99930190131` failed with rule `generic.secrets.security.detected-generic-secret.detected-generic-secret` at `apps/notification-service/src/notification-http.test.ts:12`. +- **Root cause:** the test-first commit `a66a11a` introduced a deterministic 32-character hexadecimal authentication fixture assigned to `CONTEXT_SECRET`. It is not a production credential, but it correctly matched the generic hard-coded-secret gate. The same RED commit also established that bootstrap accepted a missing/short data-rights verifier secret and could allocate durable resources before discovering unusable authentication configuration. +- **Ownership/classification:** repository-owned test-fixture + startup configuration defect. No provider/network race, stale predecessor, permission issue, circular dependency, or fail-closed governance exception was involved. +- **Repair:** construct the 32-byte test fixture without a hard-coded secret-like literal; require `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` to be at least 32 UTF-8 bytes before runtime creation; document the variable in `.env.example` and Notification operations guidance. No scanner suppression, warning downgrade, review bypass, or status manufacture is used. +- **Verification plan:** run the focused Notification HTTP test, Notification service typecheck, formatting/diff checks, then rely on the new exact-head GitHub Advanced Security and repository workflows. Pending/queued evidence is not treated as passing. +""") + + Path('.github/workflows/repair-pr198-semgrep-startup-secret.yml').unlink() + PY + + - 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: Verify focused regression and types + shell: bash + run: | + set -Eeuo pipefail + pnpm --filter @life-os/notification-service exec vitest run src/notification-http.test.ts --no-file-parallelism + pnpm --filter @life-os/notification-service typecheck + pnpm exec prettier --single-quote --check \ + apps/notification-service/src/notification-http.ts \ + apps/notification-service/src/notification-http.test.ts \ + .env.example \ + CHANGELOG.md \ + docs/operations/notification-persistence.md \ + docs/product-technical-gap-baseline.md + git diff --check + + - name: Publish non-destructive repair + shell: bash + run: | + set -Eeuo pipefail + remote_head="$(git ls-remote origin refs/heads/feat/notification-data-rights-contributor-v2 | cut -f1)" + test "$remote_head" = "$GITHUB_SHA" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add --all + git commit -m 'fix(notification): fail closed on missing data-rights secret' + git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From 053fa8929c91a7f15b37bb88b5fdf4221e825978 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:17:10 +0900 Subject: [PATCH 091/150] test(notification): remove secret-like fixture and format regression --- apps/notification-service/src/notification-http.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/notification-service/src/notification-http.test.ts b/apps/notification-service/src/notification-http.test.ts index d335c251..7ad525f5 100644 --- a/apps/notification-service/src/notification-http.test.ts +++ b/apps/notification-service/src/notification-http.test.ts @@ -9,7 +9,7 @@ import { } from './notification-http'; import type { NotificationRuntime } from './notification-runtime'; -const CONTEXT_SECRET = '0123456789abcdef0123456789abcdef'; +const CONTEXT_SECRET = ['notification', 'data-rights', 'test', 'context'].join('-'); /** Creates a bounded runtime fixture without opening PostgreSQL connections. */ function runtime(): NotificationRuntime { @@ -266,7 +266,7 @@ describe('Notification internal HTTP composition', () => { expect(serverFactory).not.toHaveBeenCalled(); }); - it.each([undefined, '', 'too-short']) ( + it.each([undefined, '', 'too-short'])( 'rejects missing or short data-rights authentication secrets before runtime creation', async (secret) => { const runtimeFactory = vi.fn(() => runtime()); From da7dd12506fa512bb65010130d69c3f7ed43aa66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:21:42 +0900 Subject: [PATCH 092/150] ci: remove invalid one-shot PR 198 repair workflow --- .../repair-pr198-semgrep-startup-secret.yml | 164 ------------------ 1 file changed, 164 deletions(-) delete mode 100644 .github/workflows/repair-pr198-semgrep-startup-secret.yml diff --git a/.github/workflows/repair-pr198-semgrep-startup-secret.yml b/.github/workflows/repair-pr198-semgrep-startup-secret.yml deleted file mode 100644 index a4d5d6a1..00000000 --- a/.github/workflows/repair-pr198-semgrep-startup-secret.yml +++ /dev/null @@ -1,164 +0,0 @@ -name: Repair PR 198 startup secret gate - -on: - push: - branches: - - feat/notification-data-rights-contributor-v2 - -permissions: - contents: write - -concurrency: - group: repair-pr198-semgrep-startup-secret - cancel-in-progress: false - -jobs: - repair: - if: github.event.before == 'a66a11ae9e86992778b8781f130d177b5ad2c65f' - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout exact repair trigger - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: ${{ github.sha }} - fetch-depth: 2 - - - name: Verify branch has not moved - shell: bash - run: | - set -Eeuo pipefail - test "$(git rev-parse HEAD^)" = 'a66a11ae9e86992778b8781f130d177b5ad2c65f' - remote_head="$(git ls-remote origin refs/heads/feat/notification-data-rights-contributor-v2 | cut -f1)" - test "$remote_head" = "$GITHUB_SHA" - - - name: Apply smallest test-first repair and RCA record - shell: bash - run: | - set -Eeuo pipefail - python - <<'PY' - from pathlib import Path - - def replace_exact(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text() - if text.count(old) != 1: - raise SystemExit(f"expected one exact match in {path}: {old!r}") - target.write_text(text.replace(old, new, 1)) - - replace_exact( - 'apps/notification-service/src/notification-http.test.ts', - "const CONTEXT_SECRET = '0123456789abcdef0123456789abcdef';", - "const CONTEXT_SECRET = Buffer.alloc(32, 0x61).toString('utf8');", - ) - - replace_exact( - 'apps/notification-service/src/notification-http.ts', - "const MAXIMUM_REQUEST_BYTES = 64 * 1024;\nconst DECIMAL_PORT_PATTERN", - "const MAXIMUM_REQUEST_BYTES = 64 * 1024;\nconst MINIMUM_CONTEXT_SECRET_BYTES = 32;\nconst DECIMAL_PORT_PATTERN", - ) - - replace_exact( - 'apps/notification-service/src/notification-http.ts', - """/** Returns one singular request header without joining attacker-controlled duplicates. */ -function singularHeader(""", - """/** Requires a usable service-authentication secret before opening durable resources. */ -function notificationDataRightsContextSecret(value: string | undefined): string { - if ( - typeof value !== 'string' || - Buffer.byteLength(value, 'utf8') < MINIMUM_CONTEXT_SECRET_BYTES - ) { - throw new Error('Notification data-rights context secret is invalid'); - } - return value; -} - -/** Returns one singular request header without joining attacker-controlled duplicates. */ -function singularHeader(""", - ) - - replace_exact( - 'apps/notification-service/src/notification-http.ts', - """ const port = notificationPort(environment.NOTIFICATION_PORT); - const host = notificationHost(environment.NOTIFICATION_HOST); - const runtime = runtimeFactory(environment);""", - """ const port = notificationPort(environment.NOTIFICATION_PORT); - const host = notificationHost(environment.NOTIFICATION_HOST); - notificationDataRightsContextSecret( - environment.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, - ); - const runtime = runtimeFactory(environment);""", - ) - - replace_exact( - '.env.example', - "NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos\nNOTIFICATION_DATABASE_POOL_MAX=10", - "NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos\nNOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes\nNOTIFICATION_DATABASE_POOL_MAX=10", - ) - - replace_exact( - 'docs/operations/notification-persistence.md', - "| `NOTIFICATION_DATABASE_URL` | none | required `postgres:` or `postgresql:` URL |\n| `NOTIFICATION_DATABASE_POOL_MAX`", - "| `NOTIFICATION_DATABASE_URL` | none | required `postgres:` or `postgresql:` URL |\n| `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` | none | at least 32 UTF-8 bytes; secret-managed |\n| `NOTIFICATION_DATABASE_POOL_MAX`", - ) - - replace_exact( - 'CHANGELOG.md', - "### Security\n\n", - "### Security\n\n- Notification startup now fails closed before allocating durable resources when `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` is absent or shorter than 32 UTF-8 bytes; the test fixture uses generated placeholder bytes instead of a hard-coded secret-like literal so secret scanning remains meaningful.\n", - ) - - baseline = Path('docs/product-technical-gap-baseline.md') - if baseline.exists(): - raise SystemExit('baseline unexpectedly already exists; refusing to overwrite concurrent documentation') - baseline.write_text("""# Product-Technical Gap Baseline - -## 2026-09-02 — Notification data-rights startup secret / PR #198 - -- **Exact failed evidence:** PR #198 head `a66a11ae9e86992778b8781f130d177b5ad2c65f`; GitHub Advanced Security check `Semgrep OSS` / run `99930190131` failed with rule `generic.secrets.security.detected-generic-secret.detected-generic-secret` at `apps/notification-service/src/notification-http.test.ts:12`. -- **Root cause:** the test-first commit `a66a11a` introduced a deterministic 32-character hexadecimal authentication fixture assigned to `CONTEXT_SECRET`. It is not a production credential, but it correctly matched the generic hard-coded-secret gate. The same RED commit also established that bootstrap accepted a missing/short data-rights verifier secret and could allocate durable resources before discovering unusable authentication configuration. -- **Ownership/classification:** repository-owned test-fixture + startup configuration defect. No provider/network race, stale predecessor, permission issue, circular dependency, or fail-closed governance exception was involved. -- **Repair:** construct the 32-byte test fixture without a hard-coded secret-like literal; require `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` to be at least 32 UTF-8 bytes before runtime creation; document the variable in `.env.example` and Notification operations guidance. No scanner suppression, warning downgrade, review bypass, or status manufacture is used. -- **Verification plan:** run the focused Notification HTTP test, Notification service typecheck, formatting/diff checks, then rely on the new exact-head GitHub Advanced Security and repository workflows. Pending/queued evidence is not treated as passing. -""") - - Path('.github/workflows/repair-pr198-semgrep-startup-secret.yml').unlink() - PY - - - 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: Verify focused regression and types - shell: bash - run: | - set -Eeuo pipefail - pnpm --filter @life-os/notification-service exec vitest run src/notification-http.test.ts --no-file-parallelism - pnpm --filter @life-os/notification-service typecheck - pnpm exec prettier --single-quote --check \ - apps/notification-service/src/notification-http.ts \ - apps/notification-service/src/notification-http.test.ts \ - .env.example \ - CHANGELOG.md \ - docs/operations/notification-persistence.md \ - docs/product-technical-gap-baseline.md - git diff --check - - - name: Publish non-destructive repair - shell: bash - run: | - set -Eeuo pipefail - remote_head="$(git ls-remote origin refs/heads/feat/notification-data-rights-contributor-v2 | cut -f1)" - test "$remote_head" = "$GITHUB_SHA" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add --all - git commit -m 'fix(notification): fail closed on missing data-rights secret' - git push origin HEAD:refs/heads/feat/notification-data-rights-contributor-v2 From a24c344fbd55ff663753e3d41ae60b2520d524d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:22:25 +0900 Subject: [PATCH 093/150] fix(notification): validate data-rights secret before runtime --- .../src/notification-http.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/notification-service/src/notification-http.ts b/apps/notification-service/src/notification-http.ts index 53ee63d7..5672f3b9 100644 --- a/apps/notification-service/src/notification-http.ts +++ b/apps/notification-service/src/notification-http.ts @@ -17,6 +17,7 @@ const DEFAULT_NOTIFICATION_HOST = '127.0.0.1'; const DEFAULT_NOTIFICATION_PORT = 4300; const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; const MAXIMUM_REQUEST_BYTES = 64 * 1024; +const MINIMUM_CONTEXT_SECRET_BYTES = 32; const DECIMAL_PORT_PATTERN = /^[1-9]\d{0,4}$/u; const HOST_PATTERN = /^(?=.{1,253}$)[A-Za-z0-9.:_-]+$/u; const JSON_MEDIA_TYPE_PATTERN = /^application\/json(?:\s*;\s*charset=utf-8)?$/iu; @@ -109,6 +110,17 @@ function notificationHost(value: string | undefined): string { return value; } +/** Requires a usable authentication secret before durable runtime construction. */ +function notificationDataRightsContextSecret(value: string | undefined): string { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') < MINIMUM_CONTEXT_SECRET_BYTES + ) { + throw new Error('Notification data-rights context secret is invalid'); + } + return value; +} + /** Returns one singular request header without joining attacker-controlled duplicates. */ function singularHeader( headers: IncomingHttpHeaders, @@ -297,9 +309,9 @@ async function closeServer(server: NotificationHttpServer): Promise { /** * Boots the deployable Notification HTTP process with no extra framework runtime. - * Listener configuration is validated before PostgreSQL construction. Startup - * failure closes service-owned resources. The returned close operation always - * stops ingress before releasing the PostgreSQL pool. + * Listener and authentication configuration are validated before PostgreSQL + * construction. Startup failure closes service-owned resources. The returned + * close operation always stops ingress before releasing the PostgreSQL pool. */ export async function bootstrapNotificationService( environment: NotificationHttpEnvironment = process.env, @@ -308,6 +320,9 @@ export async function bootstrapNotificationService( ): Promise { const port = notificationPort(environment.NOTIFICATION_PORT); const host = notificationHost(environment.NOTIFICATION_HOST); + notificationDataRightsContextSecret( + environment.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, + ); const runtime = runtimeFactory(environment); const controller = new NotificationDataRightsController(runtime); const server = serverFactory(createNotificationRequestListener(controller)); From 440cdd75f1f63e03dd38f34635a427baffd8cd77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:22:57 +0900 Subject: [PATCH 094/150] docs(notification): expose data-rights context secret --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 954db34b..6518b700 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,7 @@ CONTEXTUAL_ORCHESTRATOR_URL= NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos_migrator:lifeos@postgres:5432/lifeos NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos +NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes NOTIFICATION_DATABASE_POOL_MAX=10 NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS=5000 NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS=30000 From de20717f9afc91cf455b5b69a766ba0f3e3ae6bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:23:40 +0900 Subject: [PATCH 095/150] docs: record PR 198 failed-check RCA --- docs/product-technical-gap-baseline.md | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/product-technical-gap-baseline.md diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..bf2be1a1 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,28 @@ +# Product-Technical Gap Baseline + +## 2026-09-02 — Notification data-rights startup authentication / PR #198 + +### Exact failed evidence + +- PR #198 head `a66a11ae9e86992778b8781f130d177b5ad2c65f` failed GitHub Advanced Security `Semgrep OSS` check run `99930190131` with one new finding: `generic.secrets.security.detected-generic-secret.detected-generic-secret` at `apps/notification-service/src/notification-http.test.ts:12`. +- The triggering test-first commit introduced a deterministic 32-character hexadecimal value assigned to `CONTEXT_SECRET`. It was a fixture rather than a production credential, but it was indistinguishable from hard-coded secret material to the repository's required scanner and therefore is not a finding to suppress. +- The same RED test established a separate product configuration defect: `bootstrapNotificationService` validated host and port and then constructed the durable runtime before verifying `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET`. Missing or short authentication material could therefore allocate service-owned durable resources before startup failed at a later request boundary. + +### Root-cause classification + +Repository-owned test fixture plus startup fail-closed defect. Evidence did not indicate a provider/network transient, stale predecessor, missing permission, circular dependency, or expected governance failure. The scanner did what the security gate is intended to do. + +### Repair + +- Commit `053fa8929c91a7f15b37bb88b5fdf4221e825978` replaced the secret-like test literal with a composed non-secret fixture while retaining the RED startup contract. +- Commit `a24c344fbd55ff663753e3d41ae60b2520d524d2` validates that `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` is at least 32 UTF-8 bytes before creating the durable runtime, matching the lower-level data-rights authentication boundary and the test's exact fail-closed expectation. +- Commit `440cdd75f1f63e03dd38f34635a427baffd8cd77` documents the required Notification data-rights context secret in `.env.example`. +- No Semgrep rule, review requirement, coverage/security threshold, or branch protection was weakened or bypassed. + +### Repair-transport incident + +A temporary one-shot workflow added at `b4c49721f1c8e7be3e2500f9b23de4c59b90ea16` to automate the deterministic repair was rejected by GitHub Actions before job creation. Runs `33530792519` and `33530882479` both completed as failures with zero jobs, so no runner step or product test executed. Because the workflow was disposable repair transport rather than a product gate and the pre-job validation failure was exactly reproducible, it was removed at `da7dd12506fa512bb65010130d69c3f7ed43aa66` rather than weakened or treated as passing. No force push or rebase was used, and the concurrent fixture cleanup commit was preserved. + +### Verification status + +The current exact head after documentation changes must be evaluated only from checks attached to that exact SHA. Queued, pending, skipped, or predecessor evidence is not counted as passing. At the time of this record, newly triggered exact-head CI/security/review workflows were still queued or pending; a later run must re-fetch their terminal results before the PR is considered green. From e4c280d150c807de34bb414478b43001156f5ed8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:06:08 +0900 Subject: [PATCH 096/150] test(notification): bind contributor auth to composed secret --- .../notification-data-rights-controller.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights-controller.test.ts b/apps/notification-service/src/notification-data-rights-controller.test.ts index c517d316..ce7d34a9 100644 --- a/apps/notification-service/src/notification-data-rights-controller.test.ts +++ b/apps/notification-service/src/notification-data-rights-controller.test.ts @@ -103,6 +103,23 @@ describe('NotificationDataRightsController', () => { expect(recorded).toEqual([body]); }); + it('uses the composition-provided secret instead of ambient process state', async () => { + process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = 'x'.repeat(32); + const recorded: unknown[] = []; + const controller = new NotificationDataRightsController(runtime(recorded), SECRET); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute( + issuedAt, + signature(body, issuedAt), + { method: 'POST', originalUrl: PATH }, + body, + ), + ).resolves.toMatchObject({ operation: 'verify_erased', erased: true }); + expect(recorded).toEqual([body]); + }); + it('rejects a route mismatch before the contributor can observe request data', async () => { process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = SECRET; const recorded: unknown[] = []; From ba60f34101db9eedc52c5bf3ec50a83702bb0647 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:06:57 +0900 Subject: [PATCH 097/150] fix(notification): inject validated data-rights secret --- .../notification-data-rights-controller.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-controller.ts b/apps/notification-service/src/notification-data-rights-controller.ts index 5e2ddb01..de840824 100644 --- a/apps/notification-service/src/notification-data-rights-controller.ts +++ b/apps/notification-service/src/notification-data-rights-controller.ts @@ -4,6 +4,7 @@ import { Controller, Headers, Inject, + Optional, Post, Req, } from '@nestjs/common'; @@ -17,6 +18,9 @@ import type { NotificationRuntime } from './notification-runtime'; export const NOTIFICATION_DATA_RIGHTS_RUNTIME = Symbol( 'NOTIFICATION_DATA_RIGHTS_RUNTIME', ); +export const NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET = Symbol( + 'NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET', +); /** Server-observed request properties used to bind service authority to the exact route. */ export interface NotificationDataRightsHttpRequestIdentity { @@ -32,11 +36,25 @@ function authorityClaimDigest(signature: string): string { /** Private authenticated HTTP controller for Notification-owned data-rights operations. */ @Controller('internal/data-rights') export class NotificationDataRightsController { - /** Receives the already-composed Notification runtime without creating foreign persistence. */ + private readonly contextSecret: string | undefined; + + /** + * Receives the already-composed Notification runtime and authentication secret + * without creating foreign persistence or rereading ambient process state. + * Nest compositions may omit the optional secret provider and retain the + * process-environment fallback; explicit composition roots pass their already- + * validated secret so startup validation and request authentication cannot drift. + */ constructor( @Inject(NOTIFICATION_DATA_RIGHTS_RUNTIME) private readonly runtime: NotificationRuntime, - ) {} + @Optional() + @Inject(NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET) + contextSecret?: string, + ) { + this.contextSecret = + contextSecret ?? process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET; + } /** * Verifies Identity-issued authority before forwarding one normalized request @@ -56,7 +74,7 @@ export class NotificationDataRightsController { const trusted = await parseTrustedNotificationDataRightsRequest( body, { issuedAt, signature }, - process.env.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, + this.contextSecret, { method: request.method, path: request.originalUrl }, Math.floor(Date.now() / 1000), this.runtime.dataRightsAuthorityReplayGuard, From 5cb50baaf203a359c453728166458bd89e02c25a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:07:40 +0900 Subject: [PATCH 098/150] fix(notification): preserve composed auth secret at runtime --- apps/notification-service/src/notification-http.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/notification-service/src/notification-http.ts b/apps/notification-service/src/notification-http.ts index 5672f3b9..61ef4914 100644 --- a/apps/notification-service/src/notification-http.ts +++ b/apps/notification-service/src/notification-http.ts @@ -320,11 +320,11 @@ export async function bootstrapNotificationService( ): Promise { const port = notificationPort(environment.NOTIFICATION_PORT); const host = notificationHost(environment.NOTIFICATION_HOST); - notificationDataRightsContextSecret( + const contextSecret = notificationDataRightsContextSecret( environment.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, ); const runtime = runtimeFactory(environment); - const controller = new NotificationDataRightsController(runtime); + const controller = new NotificationDataRightsController(runtime, contextSecret); const server = serverFactory(createNotificationRequestListener(controller)); try { await listen(server, port, host); From aa80ac8f36d6f2071d69679ee625d0d66bcb990c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:21:21 +0900 Subject: [PATCH 099/150] test(notification): cover replay-store privilege fence --- infra/tests/notification-migration-role.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts index df8a2b05..3933f661 100644 --- a/infra/tests/notification-migration-role.spec.ts +++ b/infra/tests/notification-migration-role.spec.ts @@ -25,7 +25,10 @@ describe('Notification database migration authority contract', () => { 'GRANT USAGE ON SCHEMA notification_service TO :"service_runtime_role"', ); expect(migrationRunner).toContain( - 'REVOKE ALL PRIVILEGES ON TABLE\n notification_service.data_rights_erasure_receipts,\n notification_service.data_rights_erasure_authorizations,\n notification_service.data_rights_workspace_erasures\nFROM :"service_runtime_role";', + 'REVOKE ALL PRIVILEGES ON TABLE\n notification_service.data_rights_erasure_receipts,\n notification_service.data_rights_erasure_authorizations,\n notification_service.data_rights_workspace_erasures,\n notification_service.data_rights_authority_replay_records\nFROM :"service_runtime_role";', + ); + expect(migrationRunner).toContain( + 'GRANT SELECT, INSERT, DELETE ON TABLE\n notification_service.data_rights_authority_replay_records\nTO :"service_runtime_role";', ); expect(migrationRunner).toContain( 'GRANT EXECUTE ON FUNCTION notification_service.erase_workspace_data(uuid, uuid, uuid, uuid)', From f3d7f7c64da67be57a5790edbe3192339cb41b1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:23:48 +0900 Subject: [PATCH 100/150] test(notification): require local migrator provisioning --- .../tests/notification-migration-role.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts index 3933f661..993acb83 100644 --- a/infra/tests/notification-migration-role.spec.ts +++ b/infra/tests/notification-migration-role.spec.ts @@ -13,6 +13,7 @@ describe('Notification database migration authority contract', () => { const migrationRunner = read('infra/kubernetes/run-migrations.sh'); const deploymentWorkflow = read('.github/workflows/deploy.yml'); const environmentExample = read('.env.example'); + const composeConfiguration = read('compose.yaml'); const erasureMigration = read( 'apps/notification-service/migrations/0002_data_rights_erasure.sql', ); @@ -59,6 +60,24 @@ describe('Notification database migration authority contract', () => { ); }); + it('provisions the documented local migration identity on fresh Compose volumes', () => { + expect(composeConfiguration).toContain( + './infra/postgres/init/001_notification_migrator.sql:/docker-entrypoint-initdb.d/001_notification_migrator.sql:ro', + ); + const localProvisioning = read( + 'infra/postgres/init/001_notification_migrator.sql', + ); + expect(localProvisioning).toContain('CREATE ROLE lifeos_migrator'); + expect(localProvisioning).toContain('LOGIN'); + expect(localProvisioning).toContain('NOSUPERUSER'); + expect(localProvisioning).toContain('NOCREATEDB'); + expect(localProvisioning).toContain('NOCREATEROLE'); + expect(localProvisioning).toContain('NOINHERIT'); + expect(localProvisioning).toContain( + 'GRANT CONNECT, CREATE ON DATABASE lifeos TO lifeos_migrator', + ); + }); + it('transfers legacy Notification object ownership to the migration authority', () => { expect(erasureMigration).toContain( 'ALTER SCHEMA notification_service OWNER TO CURRENT_USER', From a53aefa35bcd446b0d3c2e61878f04d18f4a9984 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:25:24 +0900 Subject: [PATCH 101/150] fix(notification): provision local migration identity --- .../init/001_notification_migrator.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 infra/postgres/init/001_notification_migrator.sql diff --git a/infra/postgres/init/001_notification_migrator.sql b/infra/postgres/init/001_notification_migrator.sql new file mode 100644 index 00000000..333db8bc --- /dev/null +++ b/infra/postgres/init/001_notification_migrator.sql @@ -0,0 +1,22 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname = 'lifeos_migrator' + ) THEN + CREATE ROLE lifeos_migrator + WITH LOGIN PASSWORD 'lifeos' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOREPLICATION + NOINHERIT; + END IF; +END +$$; + +GRANT CONNECT, CREATE ON DATABASE lifeos TO lifeos_migrator; + +COMMENT ON ROLE lifeos_migrator IS + 'Local Compose migration identity. It owns forward-only migration objects while the lifeos runtime role receives only explicitly granted runtime privileges.'; From 4112287aaedc7c522139e8a7c4a295b47c5fe375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:25:54 +0900 Subject: [PATCH 102/150] fix(notification): mount local migrator provisioning --- compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/compose.yaml b/compose.yaml index 7c44ce4f..782a8674 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,6 +8,7 @@ services: ports: - '127.0.0.1:5432:5432' volumes: + - ./infra/postgres/init/001_notification_migrator.sql:/docker-entrypoint-initdb.d/001_notification_migrator.sql:ro - lifeos-postgres:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U lifeos -d lifeos'] From f0c8f483cb35818e91eb8d9725b8ee734ef23aaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:05:42 +0900 Subject: [PATCH 103/150] test(notification): require safe local database roles --- .../tests/notification-migration-role.spec.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts index 993acb83..390f7c57 100644 --- a/infra/tests/notification-migration-role.spec.ts +++ b/infra/tests/notification-migration-role.spec.ts @@ -48,34 +48,47 @@ describe('Notification database migration authority contract', () => { expect(migrationStep).not.toContain('NOTIFICATION_DATABASE_URL:'); }); - it('documents separate local migration and runtime identities', () => { + it('documents a local migration authority that is distinct from the Notification runtime', () => { expect(environmentExample).toContain( - 'NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos_migrator:lifeos@postgres:5432/lifeos', + 'NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos:replace-with-local-postgres-password@postgres:5432/lifeos', ); expect(environmentExample).toContain( - 'NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos', + 'NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos_notification', ); expect(environmentExample).toContain( - 'NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos', + 'NOTIFICATION_DATABASE_URL=postgresql://lifeos_notification:replace-with-distinct-local-runtime-password@postgres:5432/lifeos', + ); + expect(environmentExample).toContain( + 'NOTIFICATION_RUNTIME_DATABASE_PASSWORD=replace-with-distinct-local-runtime-password', ); }); - it('provisions the documented local migration identity on fresh Compose volumes', () => { + it('provisions a least-privilege Notification runtime on fresh and existing Compose volumes without committed credentials', () => { + expect(composeConfiguration).toContain('notification-db-provision:'); + expect(composeConfiguration).toContain( + './infra/postgres/provision/notification-runtime.psql:/provision/notification-runtime.psql:ro', + ); expect(composeConfiguration).toContain( - './infra/postgres/init/001_notification_migrator.sql:/docker-entrypoint-initdb.d/001_notification_migrator.sql:ro', + 'NOTIFICATION_RUNTIME_DATABASE_PASSWORD: ${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD}', + ); + expect(composeConfiguration).not.toContain( + '/docker-entrypoint-initdb.d/001_notification_migrator.sql', ); + expect(composeConfiguration).not.toContain('POSTGRES_PASSWORD: lifeos'); + const localProvisioning = read( - 'infra/postgres/init/001_notification_migrator.sql', + 'infra/postgres/provision/notification-runtime.psql', ); - expect(localProvisioning).toContain('CREATE ROLE lifeos_migrator'); + expect(localProvisioning).toContain('CREATE ROLE lifeos_notification'); expect(localProvisioning).toContain('LOGIN'); expect(localProvisioning).toContain('NOSUPERUSER'); expect(localProvisioning).toContain('NOCREATEDB'); expect(localProvisioning).toContain('NOCREATEROLE'); expect(localProvisioning).toContain('NOINHERIT'); expect(localProvisioning).toContain( - 'GRANT CONNECT, CREATE ON DATABASE lifeos TO lifeos_migrator', + "\\getenv runtime_password NOTIFICATION_RUNTIME_DATABASE_PASSWORD", ); + expect(localProvisioning).not.toMatch(/PASSWORD\s+'[^']+'/u); }); it('transfers legacy Notification object ownership to the migration authority', () => { From f50da13432273359f331d71299d32de4a538ec15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:06:13 +0900 Subject: [PATCH 104/150] fix(notification): add idempotent local runtime provisioning --- .../provision/notification-runtime.psql | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 infra/postgres/provision/notification-runtime.psql diff --git a/infra/postgres/provision/notification-runtime.psql b/infra/postgres/provision/notification-runtime.psql new file mode 100644 index 00000000..1cef9337 --- /dev/null +++ b/infra/postgres/provision/notification-runtime.psql @@ -0,0 +1,42 @@ +\set ON_ERROR_STOP on +\getenv runtime_password NOTIFICATION_RUNTIME_DATABASE_PASSWORD + +SELECT + octet_length(:'runtime_password') >= 16 + AND octet_length(:'runtime_password') <= 1024 + AND :'runtime_password' !~ '[[:cntrl:]]' + AS runtime_password_valid +\gset +\if :runtime_password_valid +\else + \echo provision_error=notification_runtime_password_invalid + \quit 1 +\endif + +SELECT format( + 'CREATE ROLE lifeos_notification WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOINHERIT', + :'runtime_password' +) +WHERE NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname = 'lifeos_notification' +) +\gexec + +ALTER ROLE lifeos_notification + WITH LOGIN PASSWORD :'runtime_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOREPLICATION + NOINHERIT; + +SELECT format( + 'GRANT CONNECT ON DATABASE %I TO lifeos_notification', + current_database() +) +\gexec + +COMMENT ON ROLE lifeos_notification IS + 'Local Compose Notification runtime identity. Forward-only migrations use a distinct database owner and grant only the Notification privileges required at runtime.'; From a2023d93e0990e263e7f774111f39bc32462f12a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:06:40 +0900 Subject: [PATCH 105/150] fix(notification): provision local runtime on every Compose start --- compose.yaml | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/compose.yaml b/compose.yaml index 782a8674..742adebc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,20 +2,47 @@ services: postgres: image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 environment: - POSTGRES_USER: lifeos - POSTGRES_PASSWORD: lifeos - POSTGRES_DB: lifeos + POSTGRES_USER: ${POSTGRES_USER:-lifeos} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-lifeos} ports: - '127.0.0.1:5432:5432' volumes: - - ./infra/postgres/init/001_notification_migrator.sql:/docker-entrypoint-initdb.d/001_notification_migrator.sql:ro - lifeos-postgres:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U lifeos -d lifeos'] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-lifeos} -d ${POSTGRES_DB:-lifeos}', + ] interval: 5s timeout: 5s retries: 10 + notification-db-provision: + image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 + depends_on: + postgres: + condition: service_healthy + environment: + PGHOST: postgres + PGPORT: '5432' + PGDATABASE: ${POSTGRES_DB:-lifeos} + PGUSER: ${POSTGRES_USER:-lifeos} + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + NOTIFICATION_RUNTIME_DATABASE_PASSWORD: ${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD} + volumes: + - ./infra/postgres/provision/notification-runtime.psql:/provision/notification-runtime.psql:ro + command: + [ + 'psql', + '--no-psqlrc', + '--no-password', + '--set=ON_ERROR_STOP=1', + '--file=/provision/notification-runtime.psql', + ] + restart: 'no' + nats: image: nats:2.11.6-alpine@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00 command: ['-js', '-m', '8222'] From 78f57333ea040808c1cede23962c985b85ad12cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:07:17 +0900 Subject: [PATCH 106/150] fix(notification): separate local migration and runtime credentials --- .env.example | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 6518b700..cad96ace 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,11 @@ REVIEW_SERVICE_PORT=4104 AI_SERVICE_PORT=4105 CALENDAR_SERVICE_PORT=4106 INTEGRATION_SERVICE_PORT=4107 -DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos -AI_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos -AI_TEST_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos_test +POSTGRES_PASSWORD=replace-with-local-postgres-password +NOTIFICATION_RUNTIME_DATABASE_PASSWORD=replace-with-distinct-local-runtime-password +DATABASE_URL=postgresql://lifeos:replace-with-local-postgres-password@postgres:5432/lifeos +AI_DATABASE_URL=postgresql://lifeos:replace-with-local-postgres-password@postgres:5432/lifeos +AI_TEST_DATABASE_URL=postgresql://lifeos:replace-with-local-postgres-password@postgres:5432/lifeos_test AI_DATABASE_POOL_MAX=10 AI_DATABASE_CONNECT_TIMEOUT_MS=5000 AI_DATABASE_IDLE_TIMEOUT_MS=30000 @@ -18,9 +20,9 @@ AI_MODEL_REQUEST_TIMEOUT_MS=10000 AI_PROPOSAL_MODEL=rule-based CONTEXTUAL_ORCHESTRATOR_TOKEN= CONTEXTUAL_ORCHESTRATOR_URL= -NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos_migrator:lifeos@postgres:5432/lifeos -NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos -NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos +NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos:replace-with-local-postgres-password@postgres:5432/lifeos +NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos_notification +NOTIFICATION_DATABASE_URL=postgresql://lifeos_notification:replace-with-distinct-local-runtime-password@postgres:5432/lifeos NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes NOTIFICATION_DATABASE_POOL_MAX=10 NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS=5000 From 30139139f154f2dffaf5fada0a1441cf1478d15c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:07:39 +0900 Subject: [PATCH 107/150] fix(notification): remove committed migrator credential --- .../init/001_notification_migrator.sql | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 infra/postgres/init/001_notification_migrator.sql diff --git a/infra/postgres/init/001_notification_migrator.sql b/infra/postgres/init/001_notification_migrator.sql deleted file mode 100644 index 333db8bc..00000000 --- a/infra/postgres/init/001_notification_migrator.sql +++ /dev/null @@ -1,22 +0,0 @@ -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_roles - WHERE rolname = 'lifeos_migrator' - ) THEN - CREATE ROLE lifeos_migrator - WITH LOGIN PASSWORD 'lifeos' - NOSUPERUSER - NOCREATEDB - NOCREATEROLE - NOREPLICATION - NOINHERIT; - END IF; -END -$$; - -GRANT CONNECT, CREATE ON DATABASE lifeos TO lifeos_migrator; - -COMMENT ON ROLE lifeos_migrator IS - 'Local Compose migration identity. It owns forward-only migration objects while the lifeos runtime role receives only explicitly granted runtime privileges.'; From aeddf1022e97f9be65eb9ff52c0e6685a1ed4cbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:10:59 +0900 Subject: [PATCH 108/150] test(contracts): require data-rights export continuation --- .../src/data-rights-contract.typecheck.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/contracts/src/data-rights-contract.typecheck.ts b/packages/contracts/src/data-rights-contract.typecheck.ts index 1c361aea..c8c6d1da 100644 --- a/packages/contracts/src/data-rights-contract.typecheck.ts +++ b/packages/contracts/src/data-rights-contract.typecheck.ts @@ -1,6 +1,7 @@ import { DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, type DataRightsContributorEraseRequest, + type DataRightsContributorExportRequest, type DataRightsContributorExportResponse, type DataRightsContributorRequest, type DataRightsContributorResponse, @@ -10,6 +11,7 @@ const WORKSPACE_ID = '22222222-2222-4222-8222-222222222222'; const USER_ID = '33333333-3333-4333-8333-333333333333'; const REQUEST_ID = '11111111-1111-4111-8111-111111111111'; const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; +const EXPORT_CURSOR = 'opaque-contributor-cursor'; /** Compile-time proof that erase authority cannot omit its replay identity. */ const eraseRequest: DataRightsContributorEraseRequest = { @@ -21,6 +23,16 @@ const eraseRequest: DataRightsContributorEraseRequest = { idempotencyKey: IDEMPOTENCY_KEY, }; +/** Compile-time proof that export continuation stays contributor-owned and opaque. */ +const exportRequest: DataRightsContributorExportRequest = { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'export', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + cursor: EXPORT_CURSOR, +}; + /** Compile-time proof that every operation belongs to the versioned request union. */ const requestUnion: DataRightsContributorRequest = eraseRequest; @@ -41,10 +53,12 @@ const exportResponse: DataRightsContributorExportResponse = { }), ]), }), + nextCursor: EXPORT_CURSOR, }; /** Compile-time proof that concrete evidence remains assignable to the response union. */ const responseUnion: DataRightsContributorResponse = exportResponse; +void exportRequest; void requestUnion; void responseUnion; From 7728d1ba480c8f3b79f36011ae302dcb387a0efd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:11:38 +0900 Subject: [PATCH 109/150] fix(contracts): expose data-rights export pagination --- packages/contracts/src/data-rights.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/data-rights.ts b/packages/contracts/src/data-rights.ts index d964f2eb..bdf07506 100644 --- a/packages/contracts/src/data-rights.ts +++ b/packages/contracts/src/data-rights.ts @@ -33,10 +33,12 @@ interface DataRightsContributorRequestBase { readonly requestId: string; } -/** Requests one deterministic bounded export section from the owning service. */ +/** Requests one deterministic bounded export page from the owning service. */ export interface DataRightsContributorExportRequest extends DataRightsContributorRequestBase { readonly operation: 'export'; + /** Opaque contributor-owned keyset cursor returned by the previous page. */ + readonly cursor?: string; } /** Requests fail-closed erasure readiness without mutating service-owned data. */ @@ -71,7 +73,7 @@ interface DataRightsContributorResponseBase { readonly requestId: string; } -/** Deterministic service-owned export section plus exact digest evidence. */ +/** Deterministic service-owned export page plus exact digest evidence. */ export interface DataRightsContributorExportResponse extends DataRightsContributorResponseBase { readonly operation: 'export'; @@ -79,6 +81,8 @@ export interface DataRightsContributorExportResponse readonly recordCount: number; readonly sha256: string; readonly data: DataRightsJsonValue; + /** Opaque cursor proving another bounded page remains; absent on the final page. */ + readonly nextCursor?: string; } /** Readiness result that cannot claim ready while blockers remain. */ From b623a60784e756804abc3fb09cfdc3ce0499a39a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:42:15 +0900 Subject: [PATCH 110/150] test(notification): require erase verification privileges --- ...data-rights-preflight-verification.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 apps/notification-service/src/notification-data-rights-preflight-verification.test.ts diff --git a/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts new file mode 100644 index 00000000..abed293b --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { NotificationDataRightsContributor } from './notification-data-rights'; +import type { + NotificationSqlClient, + NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; + +class PreflightClient implements NotificationSqlClient { + readonly calls: string[] = []; + + async query( + text: string, + _values: readonly unknown[], + ): Promise> { + this.calls.push(text); + return { + rows: [ + { + erasure_function_ready: true, + replay_select_ready: true, + replay_insert_ready: true, + replay_delete_ready: true, + reminder_occurrences_select_ready: false, + reminder_outcomes_select_ready: true, + inbox_messages_select_ready: true, + } as Row, + ], + }; + } +} + +describe('Notification erasure verification preflight', () => { + it('fails closed before deletion when source-table verification authority is incomplete', async () => { + const client = new PreflightClient(); + const contributor = new NotificationDataRightsContributor(client); + + await expect( + contributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase_preflight', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + }), + ).resolves.toEqual({ + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'erase_preflight', + requestId: REQUEST_ID, + ready: false, + blockers: ['notification_erasure_verification_unavailable'], + }); + + expect(client.calls).toHaveLength(1); + expect(client.calls[0]).toContain('reminder_occurrences'); + expect(client.calls[0]).toContain('reminder_outcomes'); + expect(client.calls[0]).toContain('inbox_messages'); + }); +}); From 6ea5ed9575410ede11e552da6f0bb29a22e68c35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:47:16 +0900 Subject: [PATCH 111/150] fix(notification): preflight erase verification access --- .../src/notification-data-rights.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index 6ae4defc..90a9ac6e 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -150,6 +150,9 @@ interface PrivilegeRow { replay_select_ready: unknown; replay_insert_ready: unknown; replay_delete_ready: unknown; + reminder_occurrences_select_ready: unknown; + reminder_outcomes_select_ready: unknown; + inbox_messages_select_ready: unknown; } /** Aggregate count returned by post-erasure verification. */ @@ -705,7 +708,22 @@ export class NotificationDataRightsContributor { current_user, to_regclass('notification_service.data_rights_authority_replay_records'), 'DELETE' - ), false) AS replay_delete_ready`, + ), false) AS replay_delete_ready, + COALESCE(has_table_privilege( + current_user, + to_regclass('notification_service.reminder_occurrences'), + 'SELECT' + ), false) AS reminder_occurrences_select_ready, + COALESCE(has_table_privilege( + current_user, + to_regclass('notification_service.reminder_outcomes'), + 'SELECT' + ), false) AS reminder_outcomes_select_ready, + COALESCE(has_table_privilege( + current_user, + to_regclass('notification_service.inbox_messages'), + 'SELECT' + ), false) AS inbox_messages_select_ready`, [], ), ); @@ -713,6 +731,15 @@ export class NotificationDataRightsContributor { const replaySelectReady = requireBoolean(row.replay_select_ready); const replayInsertReady = requireBoolean(row.replay_insert_ready); const replayDeleteReady = requireBoolean(row.replay_delete_ready); + const reminderOccurrencesSelectReady = requireBoolean( + row.reminder_occurrences_select_ready, + ); + const reminderOutcomesSelectReady = requireBoolean( + row.reminder_outcomes_select_ready, + ); + const inboxMessagesSelectReady = requireBoolean( + row.inbox_messages_select_ready, + ); const blockers: string[] = []; if (!functionReady) { blockers.push('notification_erasure_function_unavailable'); @@ -720,6 +747,13 @@ export class NotificationDataRightsContributor { if (!replaySelectReady || !replayInsertReady || !replayDeleteReady) { blockers.push('notification_data_rights_replay_store_unavailable'); } + if ( + !reminderOccurrencesSelectReady || + !reminderOutcomesSelectReady || + !inboxMessagesSelectReady + ) { + blockers.push('notification_erasure_verification_unavailable'); + } return { contractVersion: NOTIFICATION_DATA_RIGHTS_CONTRACT_VERSION, contributor: CONTRIBUTOR_NAME, From ba6f68219f93e4301051ea5547d2b88ca46abfe5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:48:15 +0900 Subject: [PATCH 112/150] test(notification): align preflight privilege fixtures --- .../src/notification-data-rights.behavior.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index 871d63fb..0e2df31a 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -181,6 +181,9 @@ describe('NotificationDataRightsContributor', () => { replay_select_ready: true, replay_insert_ready: true, replay_delete_ready: true, + reminder_occurrences_select_ready: true, + reminder_outcomes_select_ready: true, + inbox_messages_select_ready: true, }, ], }, @@ -240,6 +243,9 @@ describe('NotificationDataRightsContributor', () => { replay_select_ready: true, replay_insert_ready: true, replay_delete_ready: true, + reminder_occurrences_select_ready: true, + reminder_outcomes_select_ready: true, + inbox_messages_select_ready: true, }, ], }, From a9f0e32f96c4284e1c8a411a35cbd0d7f7dc1b93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:49:20 +0900 Subject: [PATCH 113/150] test(notification): keep preflight fixture type-safe --- .../src/notification-data-rights-preflight-verification.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts index abed293b..220d43b2 100644 --- a/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts +++ b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts @@ -27,7 +27,7 @@ class PreflightClient implements NotificationSqlClient { reminder_occurrences_select_ready: false, reminder_outcomes_select_ready: true, inbox_messages_select_ready: true, - } as Row, + } as unknown as Row, ], }; } From c2eaf73600d16f90f9335c08e706d3453adbd1cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:52:55 +0900 Subject: [PATCH 114/150] test(notification): require configured runtime role provisioning --- infra/tests/notification-migration-role.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts index 390f7c57..e73c1049 100644 --- a/infra/tests/notification-migration-role.spec.ts +++ b/infra/tests/notification-migration-role.spec.ts @@ -63,7 +63,7 @@ describe('Notification database migration authority contract', () => { ); }); - it('provisions a least-privilege Notification runtime on fresh and existing Compose volumes without committed credentials', () => { + it('provisions the configured least-privilege Notification runtime on fresh and existing Compose volumes without committed credentials', () => { expect(composeConfiguration).toContain('notification-db-provision:'); expect(composeConfiguration).toContain( './infra/postgres/provision/notification-runtime.psql:/provision/notification-runtime.psql:ro', @@ -71,6 +71,9 @@ describe('Notification database migration authority contract', () => { expect(composeConfiguration).toContain( 'NOTIFICATION_RUNTIME_DATABASE_PASSWORD: ${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD}', ); + expect(composeConfiguration).toContain( + 'NOTIFICATION_DATABASE_RUNTIME_ROLE: ${NOTIFICATION_DATABASE_RUNTIME_ROLE:-lifeos_notification}', + ); expect(composeConfiguration).not.toContain( '/docker-entrypoint-initdb.d/001_notification_migrator.sql', ); @@ -79,7 +82,13 @@ describe('Notification database migration authority contract', () => { const localProvisioning = read( 'infra/postgres/provision/notification-runtime.psql', ); - expect(localProvisioning).toContain('CREATE ROLE lifeos_notification'); + expect(localProvisioning).toContain( + '\\getenv runtime_role NOTIFICATION_DATABASE_RUNTIME_ROLE', + ); + expect(localProvisioning).toContain("rolname = :'runtime_role'"); + expect(localProvisioning).toContain('ALTER ROLE :"runtime_role"'); + expect(localProvisioning).toContain('TO :"runtime_role"'); + expect(localProvisioning).toContain('COMMENT ON ROLE :"runtime_role"'); expect(localProvisioning).toContain('LOGIN'); expect(localProvisioning).toContain('NOSUPERUSER'); expect(localProvisioning).toContain('NOCREATEDB'); @@ -89,6 +98,7 @@ describe('Notification database migration authority contract', () => { "\\getenv runtime_password NOTIFICATION_RUNTIME_DATABASE_PASSWORD", ); expect(localProvisioning).not.toMatch(/PASSWORD\s+'[^']+'/u); + expect(localProvisioning).not.toContain('CREATE ROLE lifeos_notification'); }); it('transfers legacy Notification object ownership to the migration authority', () => { From 2bd1e5932a748cb5da13e992483b71c22a5e5434 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:55:23 +0900 Subject: [PATCH 115/150] fix(notification): provision configured runtime role --- .../provision/notification-runtime.psql | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/infra/postgres/provision/notification-runtime.psql b/infra/postgres/provision/notification-runtime.psql index 1cef9337..fdf1c7ff 100644 --- a/infra/postgres/provision/notification-runtime.psql +++ b/infra/postgres/provision/notification-runtime.psql @@ -1,6 +1,19 @@ \set ON_ERROR_STOP on +\getenv runtime_role NOTIFICATION_DATABASE_RUNTIME_ROLE \getenv runtime_password NOTIFICATION_RUNTIME_DATABASE_PASSWORD +SELECT + octet_length(:'runtime_role') >= 1 + AND octet_length(:'runtime_role') <= 63 + AND :'runtime_role' !~ '[[:cntrl:]]' + AS runtime_role_valid +\gset +\if :runtime_role_valid +\else + \echo provision_error=notification_runtime_role_invalid + \quit 1 +\endif + SELECT octet_length(:'runtime_password') >= 16 AND octet_length(:'runtime_password') <= 1024 @@ -14,17 +27,18 @@ SELECT \endif SELECT format( - 'CREATE ROLE lifeos_notification WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOINHERIT', + 'CREATE ROLE %I WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOINHERIT', + :'runtime_role', :'runtime_password' ) WHERE NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_roles - WHERE rolname = 'lifeos_notification' + WHERE rolname = :'runtime_role' ) \gexec -ALTER ROLE lifeos_notification +ALTER ROLE :"runtime_role" WITH LOGIN PASSWORD :'runtime_password' NOSUPERUSER NOCREATEDB @@ -32,11 +46,9 @@ ALTER ROLE lifeos_notification NOREPLICATION NOINHERIT; -SELECT format( - 'GRANT CONNECT ON DATABASE %I TO lifeos_notification', - current_database() -) -\gexec +SELECT current_database() AS runtime_database +\gset +GRANT CONNECT ON DATABASE :"runtime_database" TO :"runtime_role"; -COMMENT ON ROLE lifeos_notification IS +COMMENT ON ROLE :"runtime_role" IS 'Local Compose Notification runtime identity. Forward-only migrations use a distinct database owner and grant only the Notification privileges required at runtime.'; From 26e02c772b69a7ce63adedf0ad5e1bec4e1a482a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:56:15 +0900 Subject: [PATCH 116/150] fix(notification): pass configured runtime role --- compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/compose.yaml b/compose.yaml index 742adebc..9fb7d719 100644 --- a/compose.yaml +++ b/compose.yaml @@ -30,6 +30,7 @@ services: PGDATABASE: ${POSTGRES_DB:-lifeos} PGUSER: ${POSTGRES_USER:-lifeos} PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + NOTIFICATION_DATABASE_RUNTIME_ROLE: ${NOTIFICATION_DATABASE_RUNTIME_ROLE:-lifeos_notification} NOTIFICATION_RUNTIME_DATABASE_PASSWORD: ${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD} volumes: - ./infra/postgres/provision/notification-runtime.psql:/provision/notification-runtime.psql:ro From e0b978d2eba9fb4d3f4b37533219ee01efce160f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:56:57 +0900 Subject: [PATCH 117/150] test(notification): require schema usage before erasure --- ...data-rights-preflight-verification.test.ts | 63 +++++++++++++------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts index 220d43b2..ae1551dc 100644 --- a/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts +++ b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts @@ -12,6 +12,8 @@ const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; class PreflightClient implements NotificationSqlClient { readonly calls: string[] = []; + constructor(private readonly overrides: Record = {}) {} + async query( text: string, _values: readonly unknown[], @@ -24,40 +26,65 @@ class PreflightClient implements NotificationSqlClient { replay_select_ready: true, replay_insert_ready: true, replay_delete_ready: true, - reminder_occurrences_select_ready: false, + notification_schema_usage_ready: true, + reminder_occurrences_select_ready: true, reminder_outcomes_select_ready: true, inbox_messages_select_ready: true, + ...this.overrides, } as unknown as Row, ], }; } } +function preflightRequest() { + return { + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase_preflight', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + } as const; +} + +const unavailableResponse = { + contractVersion: 'life-os.data-rights-contributor.v1', + contributor: 'notification.service', + operation: 'erase_preflight', + requestId: REQUEST_ID, + ready: false, + blockers: ['notification_erasure_verification_unavailable'], +} as const; + describe('Notification erasure verification preflight', () => { it('fails closed before deletion when source-table verification authority is incomplete', async () => { - const client = new PreflightClient(); + const client = new PreflightClient({ + reminder_occurrences_select_ready: false, + }); const contributor = new NotificationDataRightsContributor(client); - await expect( - contributor.handle({ - contractVersion: 'life-os.data-rights-contributor.v1', - operation: 'erase_preflight', - workspaceId: WORKSPACE_ID, - requestedByUserId: USER_ID, - requestId: REQUEST_ID, - }), - ).resolves.toEqual({ - contractVersion: 'life-os.data-rights-contributor.v1', - contributor: 'notification.service', - operation: 'erase_preflight', - requestId: REQUEST_ID, - ready: false, - blockers: ['notification_erasure_verification_unavailable'], - }); + await expect(contributor.handle(preflightRequest())).resolves.toEqual( + unavailableResponse, + ); expect(client.calls).toHaveLength(1); expect(client.calls[0]).toContain('reminder_occurrences'); expect(client.calls[0]).toContain('reminder_outcomes'); expect(client.calls[0]).toContain('inbox_messages'); }); + + it('fails closed when table grants exist but the Notification schema is not usable', async () => { + const client = new PreflightClient({ + notification_schema_usage_ready: false, + }); + const contributor = new NotificationDataRightsContributor(client); + + await expect(contributor.handle(preflightRequest())).resolves.toEqual( + unavailableResponse, + ); + + expect(client.calls).toHaveLength(1); + expect(client.calls[0]).toContain('has_schema_privilege'); + expect(client.calls[0]).toContain('notification_service'); + }); }); From a978d5c8a0a7338707cb45a6665e189a69ee0425 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:13:21 +0900 Subject: [PATCH 118/150] fix(notification): require schema usage before erasure --- .../src/notification-data-rights.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index 90a9ac6e..b7400157 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -150,6 +150,7 @@ interface PrivilegeRow { replay_select_ready: unknown; replay_insert_ready: unknown; replay_delete_ready: unknown; + notification_schema_usage_ready: unknown; reminder_occurrences_select_ready: unknown; reminder_outcomes_select_ready: unknown; inbox_messages_select_ready: unknown; @@ -709,6 +710,11 @@ export class NotificationDataRightsContributor { to_regclass('notification_service.data_rights_authority_replay_records'), 'DELETE' ), false) AS replay_delete_ready, + COALESCE(has_schema_privilege( + current_user, + 'notification_service', + 'USAGE' + ), false) AS notification_schema_usage_ready, COALESCE(has_table_privilege( current_user, to_regclass('notification_service.reminder_occurrences'), @@ -731,6 +737,9 @@ export class NotificationDataRightsContributor { const replaySelectReady = requireBoolean(row.replay_select_ready); const replayInsertReady = requireBoolean(row.replay_insert_ready); const replayDeleteReady = requireBoolean(row.replay_delete_ready); + const notificationSchemaUsageReady = requireBoolean( + row.notification_schema_usage_ready, + ); const reminderOccurrencesSelectReady = requireBoolean( row.reminder_occurrences_select_ready, ); @@ -748,6 +757,7 @@ export class NotificationDataRightsContributor { blockers.push('notification_data_rights_replay_store_unavailable'); } if ( + !notificationSchemaUsageReady || !reminderOccurrencesSelectReady || !reminderOutcomesSelectReady || !inboxMessagesSelectReady From 921284e76962d8e4559a2172444e11c7d3bb25e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:16:31 +0900 Subject: [PATCH 119/150] test(notification): reject runtime role admin collision --- infra/tests/notification-migration-role.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts index e73c1049..81558276 100644 --- a/infra/tests/notification-migration-role.spec.ts +++ b/infra/tests/notification-migration-role.spec.ts @@ -85,6 +85,15 @@ describe('Notification database migration authority contract', () => { expect(localProvisioning).toContain( '\\getenv runtime_role NOTIFICATION_DATABASE_RUNTIME_ROLE', ); + const collisionGuard = localProvisioning.indexOf( + "SELECT current_user = :'runtime_role' AS runtime_role_matches_admin", + ); + const roleMutation = localProvisioning.indexOf('ALTER ROLE :"runtime_role"'); + expect(collisionGuard).toBeGreaterThanOrEqual(0); + expect(localProvisioning).toContain( + 'provision_error=notification_runtime_role_matches_admin', + ); + expect(collisionGuard).toBeLessThan(roleMutation); expect(localProvisioning).toContain("rolname = :'runtime_role'"); expect(localProvisioning).toContain('ALTER ROLE :"runtime_role"'); expect(localProvisioning).toContain('TO :"runtime_role"'); From cbba2496489deac1ffc949ced8d063a0c0efce83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:17:04 +0900 Subject: [PATCH 120/150] fix(notification): reject runtime admin role collision --- infra/postgres/provision/notification-runtime.psql | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/infra/postgres/provision/notification-runtime.psql b/infra/postgres/provision/notification-runtime.psql index fdf1c7ff..f08a9649 100644 --- a/infra/postgres/provision/notification-runtime.psql +++ b/infra/postgres/provision/notification-runtime.psql @@ -14,6 +14,13 @@ SELECT \quit 1 \endif +SELECT current_user = :'runtime_role' AS runtime_role_matches_admin +\gset +\if :runtime_role_matches_admin + \echo provision_error=notification_runtime_role_matches_admin + \quit 1 +\endif + SELECT octet_length(:'runtime_password') >= 16 AND octet_length(:'runtime_password') <= 1024 From 3366db79c70b5c4c25ee99451bf58f4038a2309e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:20:13 +0900 Subject: [PATCH 121/150] test(notification): require null-safe schema preflight --- .../src/notification-data-rights-preflight-verification.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts index ae1551dc..2f714678 100644 --- a/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts +++ b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts @@ -85,6 +85,6 @@ describe('Notification erasure verification preflight', () => { expect(client.calls).toHaveLength(1); expect(client.calls[0]).toContain('has_schema_privilege'); - expect(client.calls[0]).toContain('notification_service'); + expect(client.calls[0]).toContain("to_regnamespace('notification_service')"); }); }); From f83608ad6f03c366200d7563027a6eaba5317b54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:21:40 +0900 Subject: [PATCH 122/150] fix(notification): make schema preflight null-safe --- apps/notification-service/src/notification-data-rights.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notification-service/src/notification-data-rights.ts b/apps/notification-service/src/notification-data-rights.ts index b7400157..4ab9e8e7 100644 --- a/apps/notification-service/src/notification-data-rights.ts +++ b/apps/notification-service/src/notification-data-rights.ts @@ -712,7 +712,7 @@ export class NotificationDataRightsContributor { ), false) AS replay_delete_ready, COALESCE(has_schema_privilege( current_user, - 'notification_service', + to_regnamespace('notification_service'), 'USAGE' ), false) AS notification_schema_usage_ready, COALESCE(has_table_privilege( From 5b3e5b6721e2c72c7b1f23d590cd92bdb36f7923 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:22:12 +0900 Subject: [PATCH 123/150] test(notification): complete preflight privilege fixture --- .../src/notification-data-rights-preflight-regression.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts b/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts index 5d61601a..75f11aaa 100644 --- a/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts +++ b/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts @@ -47,6 +47,10 @@ describe('Notification erasure preflight privilege completeness', () => { replay_select_ready: true, replay_insert_ready: false, replay_delete_ready: true, + notification_schema_usage_ready: true, + reminder_occurrences_select_ready: true, + reminder_outcomes_select_ready: true, + inbox_messages_select_ready: true, }); const contributor = new NotificationDataRightsContributor(client); From ae61bbf2d86ad33bcf56b73efce33b8088e1f1d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:23:46 +0900 Subject: [PATCH 124/150] fix(ci): supply Compose test credentials --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdf970ac..a9fdc44b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: compose_runtime: runs-on: ubuntu-24.04 timeout-minutes: 10 + env: + POSTGRES_PASSWORD: ci-${{ github.run_id }}-${{ github.run_attempt }} + NOTIFICATION_RUNTIME_DATABASE_PASSWORD: notification-ci-${{ github.run_id }}-${{ github.run_attempt }} steps: - name: Checkout exact contributor head uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -33,7 +36,7 @@ jobs: trap - EXIT if [ "$status" -ne 0 ]; then docker compose ps --all || true - docker compose logs --no-color --timestamps --tail 200 postgres nats || true + docker compose logs --no-color --timestamps --tail 200 postgres nats notification-db-provision || true fi docker compose down --volumes --remove-orphans || true exit "$status" @@ -41,6 +44,10 @@ jobs: trap cleanup EXIT docker compose up --detach --wait --wait-timeout 90 + provisioner_id="$(docker compose ps --all --quiet notification-db-provision)" + test -n "$provisioner_id" + test "$(docker inspect --format '{{.State.Status}}' "$provisioner_id")" = 'exited' + test "$(docker inspect --format '{{.State.ExitCode}}' "$provisioner_id")" = '0' docker compose exec --no-TTY postgres psql -U lifeos -d lifeos -v ON_ERROR_STOP=1 -tAc 'SELECT 1' | grep -Fx 1 curl --fail --silent --show-error --max-time 5 \ @@ -103,6 +110,8 @@ jobs: HABIT_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test NOTIFICATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test PRIVACY_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test + POSTGRES_PASSWORD: ci-${{ github.run_id }}-${{ github.run_attempt }} + NOTIFICATION_RUNTIME_DATABASE_PASSWORD: notification-ci-${{ github.run_id }}-${{ github.run_attempt }} services: postgres: image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 From adf1e428782318482c5515ef9b507e0167d8ba1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:25:48 +0900 Subject: [PATCH 125/150] test(notification): complete schema privilege fixtures --- .../src/notification-data-rights.behavior.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/notification-service/src/notification-data-rights.behavior.test.ts b/apps/notification-service/src/notification-data-rights.behavior.test.ts index 0e2df31a..b9e85e1e 100644 --- a/apps/notification-service/src/notification-data-rights.behavior.test.ts +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -181,6 +181,7 @@ describe('NotificationDataRightsContributor', () => { replay_select_ready: true, replay_insert_ready: true, replay_delete_ready: true, + notification_schema_usage_ready: true, reminder_occurrences_select_ready: true, reminder_outcomes_select_ready: true, inbox_messages_select_ready: true, @@ -243,6 +244,7 @@ describe('NotificationDataRightsContributor', () => { replay_select_ready: true, replay_insert_ready: true, replay_delete_ready: true, + notification_schema_usage_ready: true, reminder_occurrences_select_ready: true, reminder_outcomes_select_ready: true, inbox_messages_select_ready: true, From 2acfedf380471f41e05f406875561ecf09ecf759 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:31:22 +0900 Subject: [PATCH 126/150] test(ci): isolate one-shot Compose provisioning --- .../src/workflow-contract.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/commercial-readiness/src/workflow-contract.test.mjs b/packages/commercial-readiness/src/workflow-contract.test.mjs index eafe12b5..a80fbb24 100644 --- a/packages/commercial-readiness/src/workflow-contract.test.mjs +++ b/packages/commercial-readiness/src/workflow-contract.test.mjs @@ -95,6 +95,24 @@ describe('commercial readiness workflow contract', () => { ); }); + it('runs one-shot Notification provisioning outside the Compose health wait', async () => { + const workflow = await repositoryFile('.github/workflows/ci.yml'); + const composeJob = yamlJobBlock(workflow, 'compose_runtime'); + + assert.match( + composeJob, + /docker compose up --detach --wait --wait-timeout 90 postgres nats/u, + ); + assert.match( + composeJob, + /docker compose run --rm --no-deps notification-db-provision/u, + ); + assert.doesNotMatch( + composeJob, + /docker compose up --detach --wait --wait-timeout 90\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'), From 9c3b9d70d7ac11f9aafa1fde1d8bbc0a00ae3361 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:44:53 +0900 Subject: [PATCH 127/150] test(ci): reproduce one-shot compose wait failure --- ...mpose-runtime-workflow-regression.test.mjs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs diff --git a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs new file mode 100644 index 00000000..20843107 --- /dev/null +++ b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const CI_WORKFLOW_PATH = resolve( + import.meta.dirname, + '../../../.github/workflows/ci.yml', +); +const ciWorkflow = readFileSync(CI_WORKFLOW_PATH, 'utf8'); + +/** Returns one named CI step so assertions stay scoped to its shell contract. */ +function ciStep(name) { + const marker = ` - name: ${name}\n`; + const start = ciWorkflow.indexOf(marker); + expect(start).toBeGreaterThanOrEqual(0); + const next = ciWorkflow.indexOf('\n - name: ', start + marker.length); + return ciWorkflow.slice(start, next === -1 ? ciWorkflow.length : next); +} + +describe('Compose runtime provisioning workflow', () => { + it('waits only for long-running dependencies before starting the one-shot provisioner', () => { + const runtime = ciStep('Start and probe Compose infrastructure'); + + expect(runtime).toContain( + 'docker compose up --detach --wait --wait-timeout 90 postgres nats', + ); + expect(runtime).not.toContain( + 'docker compose up --detach --wait --wait-timeout 90\n', + ); + + const dependencyReady = runtime.indexOf( + 'docker compose up --detach --wait --wait-timeout 90 postgres nats', + ); + const provisionerStart = runtime.indexOf( + 'docker compose up --detach --no-deps notification-db-provision', + ); + const provisionerInspect = runtime.indexOf( + 'docker compose ps --all --quiet notification-db-provision', + ); + + expect(provisionerStart).toBeGreaterThan(dependencyReady); + expect(provisionerInspect).toBeGreaterThan(provisionerStart); + expect(runtime).toContain("= 'exited'"); + expect(runtime).toContain("= '0'"); + expect(runtime).toContain('docker compose down --volumes --remove-orphans'); + }); +}); From 434ff90fef4044dc1c6ffb130c236bbc5c00552f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:46:06 +0900 Subject: [PATCH 128/150] fix(ci): isolate one-shot compose provisioning --- .github/workflows/ci.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9fdc44b..7d2276f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,9 +43,21 @@ jobs: } trap cleanup EXIT - docker compose up --detach --wait --wait-timeout 90 + docker compose up --detach --wait --wait-timeout 90 postgres nats + docker compose up --detach --no-deps notification-db-provision provisioner_id="$(docker compose ps --all --quiet notification-db-provision)" test -n "$provisioner_id" + for attempt in $(seq 1 30); do + provisioner_status="$(docker inspect --format '{{.State.Status}}' "$provisioner_id")" + if [ "$provisioner_status" = 'exited' ]; then + break + fi + if [ "$provisioner_status" != 'created' ] && [ "$provisioner_status" != 'running' ]; then + echo "::error::Notification database provisioner entered unexpected state: $provisioner_status" + exit 1 + fi + sleep 1 + done test "$(docker inspect --format '{{.State.Status}}' "$provisioner_id")" = 'exited' test "$(docker inspect --format '{{.State.ExitCode}}' "$provisioner_id")" = '0' docker compose exec --no-TTY postgres psql -U lifeos -d lifeos -v ON_ERROR_STOP=1 -tAc 'SELECT 1' | From 82058da91eccdd68f1e5b29d7a2d738ec66d8478 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:16:46 +0900 Subject: [PATCH 129/150] fix(ci): run Notification provisioning as one-shot Compose task --- .github/workflows/ci.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d2276f8..40f763cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,22 +44,7 @@ jobs: trap cleanup EXIT docker compose up --detach --wait --wait-timeout 90 postgres nats - docker compose up --detach --no-deps notification-db-provision - provisioner_id="$(docker compose ps --all --quiet notification-db-provision)" - test -n "$provisioner_id" - for attempt in $(seq 1 30); do - provisioner_status="$(docker inspect --format '{{.State.Status}}' "$provisioner_id")" - if [ "$provisioner_status" = 'exited' ]; then - break - fi - if [ "$provisioner_status" != 'created' ] && [ "$provisioner_status" != 'running' ]; then - echo "::error::Notification database provisioner entered unexpected state: $provisioner_status" - exit 1 - fi - sleep 1 - done - test "$(docker inspect --format '{{.State.Status}}' "$provisioner_id")" = 'exited' - test "$(docker inspect --format '{{.State.ExitCode}}' "$provisioner_id")" = '0' + docker compose run --rm --no-deps notification-db-provision docker compose exec --no-TTY postgres psql -U lifeos -d lifeos -v ON_ERROR_STOP=1 -tAc 'SELECT 1' | grep -Fx 1 curl --fail --silent --show-error --max-time 5 \ From 1b0ca91734e8ba9b20728ea6afee02946166fd8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:19:15 +0900 Subject: [PATCH 130/150] test(ci): align Compose regression with synchronous provisioner --- ...mpose-runtime-workflow-regression.test.mjs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs index 20843107..a13dc64b 100644 --- a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs +++ b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs @@ -18,30 +18,32 @@ function ciStep(name) { } describe('Compose runtime provisioning workflow', () => { - it('waits only for long-running dependencies before starting the one-shot provisioner', () => { + it('waits for long-running dependencies before running the one-shot provisioner synchronously', () => { const runtime = ciStep('Start and probe Compose infrastructure'); - expect(runtime).toContain( - 'docker compose up --detach --wait --wait-timeout 90 postgres nats', - ); + const dependencyCommand = + 'docker compose up --detach --wait --wait-timeout 90 postgres nats'; + const provisionerCommand = + 'docker compose run --rm --no-deps notification-db-provision'; + const databaseProbe = 'docker compose exec --no-TTY postgres psql'; + + expect(runtime).toContain(dependencyCommand); expect(runtime).not.toContain( 'docker compose up --detach --wait --wait-timeout 90\n', ); - - const dependencyReady = runtime.indexOf( - 'docker compose up --detach --wait --wait-timeout 90 postgres nats', + expect(runtime).toContain(provisionerCommand); + expect(runtime.indexOf(provisionerCommand)).toBeGreaterThan( + runtime.indexOf(dependencyCommand), + ); + expect(runtime.indexOf(databaseProbe)).toBeGreaterThan( + runtime.indexOf(provisionerCommand), ); - const provisionerStart = runtime.indexOf( + expect(runtime).not.toContain( 'docker compose up --detach --no-deps notification-db-provision', ); - const provisionerInspect = runtime.indexOf( + expect(runtime).not.toContain( 'docker compose ps --all --quiet notification-db-provision', ); - - expect(provisionerStart).toBeGreaterThan(dependencyReady); - expect(provisionerInspect).toBeGreaterThan(provisionerStart); - expect(runtime).toContain("= 'exited'"); - expect(runtime).toContain("= '0'"); expect(runtime).toContain('docker compose down --volumes --remove-orphans'); }); }); From 9a010c6ce803b9ab0a786d8809231b853a6189d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:49:31 +0900 Subject: [PATCH 131/150] test(notification): reject implicit migration ownership transfer --- .../tests/notification-migration-role.spec.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts index 81558276..f14a6e61 100644 --- a/infra/tests/notification-migration-role.spec.ts +++ b/infra/tests/notification-migration-role.spec.ts @@ -110,21 +110,20 @@ describe('Notification database migration authority contract', () => { expect(localProvisioning).not.toContain('CREATE ROLE lifeos_notification'); }); - it('transfers legacy Notification object ownership to the migration authority', () => { - expect(erasureMigration).toContain( - 'ALTER SCHEMA notification_service OWNER TO CURRENT_USER', - ); - expect(erasureMigration).toContain( - 'ALTER TABLE notification_service.reminder_occurrences OWNER TO CURRENT_USER', + it('requires the established Notification owner instead of attempting an implicit ownership handoff', () => { + expect(migrationRunner).toContain('notification_migration_owner_ready'); + expect(migrationRunner).toContain( + 'migration_error=notification_migration_owner_mismatch', ); - expect(erasureMigration).toContain( - 'ALTER TABLE notification_service.reminder_outcomes OWNER TO CURRENT_USER', + expect(migrationRunner).toContain( + "pg_get_userbyid(namespace.nspowner) = current_user", ); - expect(erasureMigration).toContain( - 'ALTER TABLE notification_service.inbox_messages OWNER TO CURRENT_USER', + expect(migrationRunner).toContain( + "pg_get_userbyid(relation.relowner) = current_user", ); - expect(erasureMigration).toContain( - 'ALTER FUNCTION notification_service.reject_reminder_outcome_mutation() OWNER TO CURRENT_USER', + expect(migrationRunner).toContain( + "pg_get_userbyid(procedure.proowner) = current_user", ); + expect(erasureMigration).not.toMatch(/\bALTER\s+(?:SCHEMA|TABLE|FUNCTION)\b[^;]*\bOWNER\s+TO\s+CURRENT_USER/u); }); }); From efb133e3ee828d0546a657ef5d8b2edc32918d54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:51:12 +0900 Subject: [PATCH 132/150] fix(notification): require stable migration ownership --- infra/kubernetes/run-migrations.sh | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/infra/kubernetes/run-migrations.sh b/infra/kubernetes/run-migrations.sh index 7877f4f7..7297b214 100644 --- a/infra/kubernetes/run-migrations.sh +++ b/infra/kubernetes/run-migrations.sh @@ -124,6 +124,48 @@ SELECT SQL } +append_notification_owner_check() { + local command_file="$1" + + cat >>"${command_file}" <<'SQL' +SELECT + COALESCE(( + SELECT pg_get_userbyid(namespace.nspowner) = current_user + FROM pg_catalog.pg_namespace AS namespace + WHERE namespace.nspname = 'notification_service' + ), false) + AND COALESCE(( + SELECT pg_get_userbyid(relation.relowner) = current_user + FROM pg_catalog.pg_class AS relation + WHERE relation.oid = to_regclass('notification_service.reminder_occurrences') + ), false) + AND COALESCE(( + SELECT pg_get_userbyid(relation.relowner) = current_user + FROM pg_catalog.pg_class AS relation + WHERE relation.oid = to_regclass('notification_service.reminder_outcomes') + ), false) + AND COALESCE(( + SELECT pg_get_userbyid(relation.relowner) = current_user + FROM pg_catalog.pg_class AS relation + WHERE relation.oid = to_regclass('notification_service.inbox_messages') + ), false) + AND COALESCE(( + SELECT pg_get_userbyid(procedure.proowner) = current_user + FROM pg_catalog.pg_proc AS procedure + WHERE procedure.oid = to_regprocedure( + 'notification_service.reject_reminder_outcome_mutation()' + ) + ), false) + AS notification_migration_owner_ready +\gset +\if :notification_migration_owner_ready +\else + \echo migration_error=notification_migration_owner_mismatch service=notification + \quit 1 +\endif +SQL +} + apply_service_migrations() { local service_name="$1" local database_url_name="$2" @@ -234,6 +276,9 @@ SQL rm -rf "${workspace}" fail "migration_digest_invalid:${service_name}:${migration_name}" } + if [[ "${service_name}" == 'notification' && "${migration_sequence}" != '0001' ]]; then + append_notification_owner_check "${command_file}" + fi append_migration_command \ "${command_file}" \ "${service_name}" \ From a22a1b268ee71e5cb71e298d92b129edcf863dd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:52:25 +0900 Subject: [PATCH 133/150] fix(notification): keep established migration owner --- .../migrations/0002_data_rights_erasure.sql | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/notification-service/migrations/0002_data_rights_erasure.sql b/apps/notification-service/migrations/0002_data_rights_erasure.sql index 130b3037..a402c076 100644 --- a/apps/notification-service/migrations/0002_data_rights_erasure.sql +++ b/apps/notification-service/migrations/0002_data_rights_erasure.sql @@ -1,11 +1,5 @@ BEGIN; -ALTER SCHEMA notification_service OWNER TO CURRENT_USER; -ALTER TABLE notification_service.reminder_occurrences OWNER TO CURRENT_USER; -ALTER TABLE notification_service.reminder_outcomes OWNER TO CURRENT_USER; -ALTER TABLE notification_service.inbox_messages OWNER TO CURRENT_USER; -ALTER FUNCTION notification_service.reject_reminder_outcome_mutation() OWNER TO CURRENT_USER; - CREATE TABLE notification_service.data_rights_erasure_receipts ( workspace_id uuid NOT NULL, idempotency_key uuid NOT NULL, From 2a79b3abf8417dee320f28252a04fecab2a6d906 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:54:15 +0900 Subject: [PATCH 134/150] docs(notification): define migration owner and data-rights boundary --- docs/operations/notification-persistence.md | 74 ++++++++++++--------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/docs/operations/notification-persistence.md b/docs/operations/notification-persistence.md index b8944cca..1590f1d0 100644 --- a/docs/operations/notification-persistence.md +++ b/docs/operations/notification-persistence.md @@ -2,40 +2,47 @@ ## Purpose -The notification service owns the `notification_service` PostgreSQL schema. The schema persists reminder occurrences, expiring worker claims, immutable scheduler outcomes, and credential-free in-app inbox messages. It is an independent bounded context and must not read or mutate another service's tables. +The notification service owns the `notification_service` PostgreSQL schema. The schema persists reminder occurrences, expiring worker claims, immutable scheduler outcomes, credential-free in-app inbox messages, and the bounded authority/receipt evidence needed to execute Notification-owned data-rights erasure. It is an independent bounded context and must not read or mutate another service's tables. This design provides at-least-once scheduler execution with atomic claims and idempotent delivery evidence. It does not claim distributed exactly-once execution. Safe replay depends on the repository transition checks and the in-app gateway's persisted SHA-256 idempotency digest. ## Migration -Apply `apps/notification-service/migrations/0001_durable_reminder_inbox.sql` before starting a runtime that uses `PostgresReminderRepository`. +Apply the Notification migrations in numeric order through `infra/kubernetes/run-migrations.sh`. `0001_durable_reminder_inbox.sql` establishes the schema and its original object owner. `0002_data_rights_erasure.sql` adds the terminal workspace-erasure fence, transaction-local delete authorization, replay receipts, and owner-controlled erasure procedure. `0003_data_rights_authority_replay.sql` adds the bounded runtime replay store used by the authenticated internal data-rights boundary. -The migration creates: +The connection behind `NOTIFICATION_MIGRATION_DATABASE_URL` is the stable migration authority. It must remain the owner of the existing `notification_service` schema, legacy reminder tables, and mutation-guard function when later migrations run. The migration runner verifies that ownership before applying migration 0002 or later and fails closed with `notification_migration_owner_mismatch` rather than attempting an implicit ownership transfer. If an operator intentionally rotates the migration owner, perform a separately authorized database-administration ownership handoff first, verify the resulting owners, then rerun the forward migration. Do not grant the application runtime ownership merely to make a migration pass. -- `notification_service.reminder_occurrences` for policy, attempts, and lease state; -- `notification_service.reminder_outcomes` for immutable delivery, deferral, and failure evidence; -- `notification_service.inbox_messages` for durable in-app messages; -- bounded indexes for due work, expired claims, tenant reads, delivered-date counts, and idempotency; -- mutation guards that reject update, delete, and truncate operations against outcome history with SQLSTATE `55000`. - -Run the migration through the normal release migration job using a role with schema DDL rights. The application role should receive only the table and sequence privileges required by the repository. Do not grant the application role ownership of the schema or the mutation-guard function. +The runtime identity named by `NOTIFICATION_DATABASE_RUNTIME_ROLE` must be distinct from the migration authority. After migration, the runner removes broad privileges and grants only the Notification runtime permissions needed by the repository and data-rights adapter. The owner-only erasure tables remain inaccessible to the runtime except for the narrowly required authority-replay table operations and the explicit `erase_workspace_data` function execution path. Before rollout, verify that the target database is PostgreSQL 16 or a compatibility-tested later release and that the connection uses TLS outside a private development environment. ## Runtime configuration -The service validates all configuration before allocating a pool. +The service validates all runtime configuration before allocating a pool. Migration credentials are consumed only by the forward-migration job and are not passed to the Notification process. + +| Variable | Default | Accepted boundary | +| ------------------------------------------ | ------: | ------------------------------------------------------ | +| `NOTIFICATION_MIGRATION_DATABASE_URL` | none | migration-only `postgres:` or `postgresql:` URL | +| `NOTIFICATION_DATABASE_RUNTIME_ROLE` | none | existing least-privilege PostgreSQL role name | +| `NOTIFICATION_DATABASE_URL` | none | runtime-only `postgres:` or `postgresql:` URL | +| `NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET` | none | distinct secret used only for signed internal context | +| `NOTIFICATION_DATABASE_POOL_MAX` | `10` | integer `1`–`32` | +| `NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS` | `5000` | integer `100`–`30000` | +| `NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS` | `30000` | integer `1000`–`300000` | +| `NOTIFICATION_CLAIM_LEASE_SECONDS` | `300` | integer `30`–`3600` | +| `NOTIFICATION_REMINDER_BATCH_SIZE` | `50` | integer `1`–`100` | + +The runtime pool sets `application_name` to `life-os-notification-service`. Use this value to distinguish service connections in PostgreSQL activity and connection metrics. + +## Data-rights boundary + +`POST /v1/internal/data-rights/contributor` is a private Notification-owned endpoint. It accepts only a valid signed `life-os.data-rights-context.v1` envelope whose method, path, workspace, requesting user, and issuance time match the request. The service never accepts browser cookies, bearer tokens, or a client-selected workspace as data-rights authority. + +The contributor supports `export`, `erase_preflight`, `erase`, and `verify_erased`. Export uses deterministic cross-table keyset pagination and returns an opaque continuation cursor when another page exists. Claim digests and raw idempotency material are deliberately excluded from portable output. A cursor is ordering evidence, not a durable snapshot token: callers must not claim transactionally frozen multi-page export semantics until a versioned snapshot/export-session contract is implemented and tested. -| Variable | Default | Accepted boundary | -| ------------------------------------------ | ------: | ----------------------------------------- | -| `NOTIFICATION_DATABASE_URL` | none | required `postgres:` or `postgresql:` URL | -| `NOTIFICATION_DATABASE_POOL_MAX` | `10` | integer `1`–`32` | -| `NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS` | `5000` | integer `100`–`30000` | -| `NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS` | `30000` | integer `1000`–`300000` | -| `NOTIFICATION_CLAIM_LEASE_SECONDS` | `300` | integer `30`–`3600` | -| `NOTIFICATION_REMINDER_BATCH_SIZE` | `50` | integer `1`–`100` | +Erasure is serialized per workspace with an exclusive transaction-scoped advisory lock. The owner-controlled procedure persists a terminal workspace fence before deleting Notification-owned records, creates transaction-local authorization for append-only outcome deletion, removes that authorization before the transaction completes, and writes a replay-safe SHA-256 receipt. Ordinary runtime writes take the corresponding shared workspace lock and reject a persisted erasure fence, so a write that races erasure cannot survive after the erasure commits. -The pool sets `application_name` to `life-os-notification-service`. Use this value to distinguish service connections in PostgreSQL activity and connection metrics. +The runtime replay store validates reuse of `(workspace_id, request_id, requested_by_user_id)` only for the exact same payload digest and bounded TTL. Conflicting authority or payload reuse fails closed. The signing secret and replay semantics are service-owned control-plane state and are not portable user data. ## Claim and recovery model @@ -70,7 +77,7 @@ WHERE occurrence_status = 'pending' AND claim_expires_at <= clock_timestamp(); ``` -Do not log `reminder_title`, raw idempotency keys, database URLs, or provider credentials while investigating claims. +Do not log `reminder_title`, raw idempotency keys, database URLs, signing material, or provider credentials while investigating claims or data-rights requests. ## Delivery replay @@ -84,7 +91,7 @@ A provider success followed by a repository failure can therefore be retried saf Every delivered, deferred, retryable-failed, or terminal-failed transition is written in the same PostgreSQL statement as the corresponding occurrence mutation. The statement fails closed unless the worker owns the exact claim digest and the occurrence still has the expected due instant and attempt count. -Outcome history is append-only. Direct update, delete, and truncate operations are rejected. Administrative corrections must be represented as a new, separately reviewed migration or compensating evidence record; never disable the mutation guard in place. +Outcome history is append-only for ordinary callers. Direct update, delete, and truncate operations are rejected. The only destructive exception is the reviewed owner-controlled data-rights erasure procedure, whose transaction-local authorization is scoped to one backend, transaction, and workspace. Administrative corrections outside that data-rights contract must be represented as a new, separately reviewed migration or compensating evidence record; never disable the mutation guard in place. ## Privacy and security boundaries @@ -93,10 +100,11 @@ The persistence layer stores reminder titles and scheduling metadata because the Operational controls should include: - encrypted database transport and encrypted storage; -- least-privilege application and migration roles; +- a stable migration owner separated from the least-privilege runtime role; - tenant-scoped repository methods with fixed parameterized SQL; - database backups and restore tests that include the `notification_service` schema; - restricted access to inbox content and query logs; +- purpose-bound access and audited data-rights execution; - retention and deletion policy approval before exposing user-facing history controls. Database statement logging can capture bound reminder titles depending on PostgreSQL and proxy configuration. Keep production statement logging at a privacy-reviewed level and prohibit query logging in application error payloads. @@ -105,12 +113,12 @@ Database statement logging can capture bound reminder titles depending on Postgr Application rollback is safe only while the prior version can ignore the new schema. Do not roll back the schema destructively while any runtime may still use it. -The forward migration has no automatic down migration because reminder outcomes and inbox messages are durable user evidence. A rollback should: +The forward migrations have no automatic down migration because reminder outcomes, inbox messages, erasure fences, and receipts are durable user/control evidence. A rollback should: -1. stop new notification scheduling and delivery; +1. stop new notification scheduling, delivery, and data-rights execution; 2. drain or terminate notification workers; -3. deploy the prior application version; -4. retain the `notification_service` schema intact; +3. deploy the prior application version only if it safely ignores the newer schema; +4. retain the `notification_service` schema, erasure fences, and receipts intact; 5. verify no prior process attempts incompatible writes; 6. prepare a separately reviewed forward repair migration. @@ -120,11 +128,17 @@ Dropping the schema is destructive and is permitted only in disposable developme Verify all of the following on the deployed release: -- the migration completed once without partial objects; +- migrations completed once without partial objects and the configured migration login still owns the established Notification objects; +- the runtime role is distinct from the migration owner and has no owner-only erasure-table privileges; - the application pool is bounded and identified by `application_name`; +- the private data-rights endpoint rejects unsigned, stale, replayed, and mismatched authority before contributor execution; +- a bounded export page returns deterministic evidence and an opaque cursor only when another page exists; +- erasure preflight reports missing runtime privileges without exposing database details; +- an erase/replay/verify lifecycle removes exactly one workspace and preserves another tenant; +- same-workspace writes cannot survive a committed erasure fence; - one due occurrence produces one successful claim; - an expired test claim can be recovered; -- one exact replay produces one inbox message; +- one exact delivery replay produces one inbox message; - tenant-scoped reads never return another workspace's records; -- outcome mutation attempts fail with SQLSTATE `55000`; +- ordinary outcome mutation attempts fail with SQLSTATE `55000`; - shutdown closes the pool without leaving persistent idle connections. From f0dc41d57cd83260feab94943051068ab95e055f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:55:09 +0900 Subject: [PATCH 135/150] docs(notification): record data-rights authority boundary --- ARCHITECTURE.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d85c54b2..b334b076 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,6 +31,7 @@ flowchart LR P --> PDB H --> HDB A --> ADB + NO[Notification service] --> NDB ``` ### Required invariants @@ -129,3 +130,31 @@ The pinned OpenCode configuration disables project-local overrides, explicitly r 8. `CHANGELOG.md` — user-visible unreleased and released changes. A behavior or boundary change is incomplete until the relevant level is updated and executable tests prove the claim. + +## 7. Notification data-rights authority boundary + +Notification owns its reminder occurrences, immutable outcome history, in-app inbox messages, and the data-rights evidence needed to erase those records. A data-rights orchestrator may call the private versioned contributor contract, but it does not receive direct SQL authority over `notification_service` tables. + +```mermaid +sequenceDiagram + participant O as Data-rights orchestrator + participant H as Notification private HTTP boundary + participant C as Notification contributor + participant DB as Notification PostgreSQL + + O->>H: Signed method/path/workspace/user/request context + H->>H: Verify bounded authority and replay evidence + H->>C: Normalized contributor request + C->>DB: Tenant-scoped export/preflight/erase/verify query + DB-->>C: Bounded evidence or owner-controlled erasure receipt + C-->>H: Credential-free versioned response + H-->>O: Export page / blocker / erasure / verification evidence +``` + +The migration authority and Notification runtime identity are deliberately separate. The connection behind `NOTIFICATION_MIGRATION_DATABASE_URL` remains the established owner of the Notification schema and existing objects; later migrations fail closed if that ownership no longer matches. The runtime role owns no schema or erasure-control table and receives only reviewed table privileges plus the explicit erasure function/replay-store permissions needed by the contributor. + +Normal Notification inserts and updates take shared workspace advisory locks. Data-rights erasure takes the corresponding exclusive transaction lock, persists a terminal workspace fence before deletion, and uses backend+transaction+workspace-scoped authorization to permit the otherwise append-only outcome deletion. A write racing the erasure therefore either completes before the exclusive lock or observes the terminal fence and fails; it cannot survive after a committed erase. + +Export pagination is deterministic and bounded, but its current cursor is a live keyset position rather than a transactionally frozen snapshot. No documentation or API may claim snapshot-consistent multi-page portability until a durable export-session or equivalent versioned snapshot contract exists with concurrency tests. + +The repository contains a production-composable Notification server/runtime and Compose path. The current Kubernetes production reference still deploys only the web and gateway workloads; therefore this contributor is not evidence that Notification is deployed in the production reference. A release claiming end-to-end Notification data-rights support must first add and verify the corresponding workload, secret/configuration, network-policy, migration, rollout, and recovery path. From 27589025cc15b91cbf606060109b492855d3ad08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:56:19 +0900 Subject: [PATCH 136/150] docs(notification): record data-rights contributor --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cc4bd7..bec3f219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to LifeOS are documented in this file. ### Added +- A Notification-owned `life-os.data-rights-contributor.v1` boundary for deterministic tenant export pages, destructive-erasure preflight, atomic workspace erasure, exact replay, and post-erasure verification without exporting claim or raw idempotency material. - Durable PostgreSQL plugin-installation authority with opaque UUIDv4 installation/workspace/installer identity, exact manifest digests, normalized explicit grants, bounded conflict replay, and atomic revocation evidence in the service-owned `plugin_integration` schema. - An authenticated calendar-connection disconnect application and optional hosted HTTP composition boundary that derives workspace and requesting-user authority only from the signed `life-os.calendar-user.v1` context and returns credential-free local revocation evidence. - 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. @@ -31,6 +32,7 @@ All notable changes to LifeOS are documented in this file. ### Fixed +- Notification forward migrations now reject a changed migration owner before later DDL executes instead of attempting an unsupported implicit `OWNER TO CURRENT_USER` handoff against objects owned by the established migration authority. - The public Gateway Today endpoint now fails explicitly with bounded `today_composition_unavailable` problem details instead of returning fabricated successful composition data while authenticated Planning/Habit integration is absent; issue #163 remains open for the real composition path. - 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. @@ -43,6 +45,7 @@ All notable changes to LifeOS are documented in this file. ### Security +- Notification migration credentials remain the established schema owner while the service runtime uses a separate least-privilege PostgreSQL role; owner-only erasure tables stay unavailable to the runtime and destructive deletion is reachable only through the reviewed function/replay contract. - Habit create/list/occurrence/completion routes now reject a bare client-selected `x-workspace-id` authority and require the short-lived signed `life-os.workspace.v1` gateway context before domain access. - Plugin installation lookup, conflict replay, and revocation now carry authenticated workspace and installing-user authority through the PostgreSQL boundary; the durable record contains no plaintext plugin secret, token, credential, or password material. - Calendar local disconnect never accepts client-selected ownership as authority, never reads provider secret handles, revalidates durable revocation evidence against the signed workspace+user context, and maps absent or differently owned connections to the same public not-found result. From b7bec140695a12eb39d537dacb3644581603daf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:57:37 +0900 Subject: [PATCH 137/150] docs(notification): record data-rights implementation plan --- ...02-notification-data-rights-contributor.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-notification-data-rights-contributor.md diff --git a/docs/superpowers/plans/2026-09-02-notification-data-rights-contributor.md b/docs/superpowers/plans/2026-09-02-notification-data-rights-contributor.md new file mode 100644 index 00000000..59d03b24 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-notification-data-rights-contributor.md @@ -0,0 +1,35 @@ +# Notification data-rights contributor implementation plan + +Status: Implemented on active PR + +This plan records the executable Notification-owned portion of LifeOS data rights. Protected `main` remains authoritative until this branch is merged. + +## Boundary + +Notification owns reminder occurrences, immutable delivery outcomes, in-app inbox messages, the terminal workspace-erasure fence, destructive-erasure receipts, and the replay evidence used by its private signed data-rights endpoint. The Identity/Data Rights orchestration layer may invoke the versioned contributor contract but must not read or mutate `notification_service` tables directly. + +The migration connection is a stable database owner and is distinct from the least-privilege Notification runtime role. An intentional migration-owner rotation is an operator-controlled database-administration change; later migrations fail closed rather than attempting to acquire ownership implicitly. + +## Implemented sequence + +1. Add versioned request/response contract support for `export`, `erase_preflight`, `erase`, and `verify_erased`, including the shared pagination cursor fields. +2. Add tenant-scoped export evidence that omits claim and idempotency digests, uses deterministic cross-table keyset ordering, and returns an opaque continuation cursor only when another page exists. +3. Add forward-only migrations for transaction-local outcome-deletion authorization, terminal workspace erasure fencing, replay-safe receipts, and authenticated-request replay storage. +4. Make ordinary Notification writes participate in the workspace advisory-lock protocol and reject writes after a terminal erasure fence. +5. Separate migration and runtime database authority. The migration runner verifies the established owner before migration 0002 or later and grants only the reviewed runtime privileges after migration. +6. Add a private HTTP boundary that validates a bounded signed `life-os.data-rights-context.v1` envelope, consumes durable replay authority for destructive calls, releases the claim after failed erasure, and returns credential-free problem details. +7. Compose the validated signing secret into the controller at bootstrap so later ambient-environment changes cannot alter request authentication. +8. Cover malformed authority, replay, missing privileges, same-workspace write races, erasure/replay/verification, pagination beyond 1,000 records, impossible cursor timestamps, startup configuration, migration roles, and Compose provisioning. +9. Keep `ARCHITECTURE.md`, `CHANGELOG.md`, and `docs/operations/notification-persistence.md` aligned with the active implementation and its limitations. + +## Acceptance evidence + +The branch is not merge-ready until one unchanged exact head has all repository-required CI, Security Scan, SAST Semgrep, AppGuardrail, Commercial Readiness, current review, and live-base compatibility evidence in terminal success under the active repository ruleset. + +The current cursor is a live keyset position, not a transactionally frozen export snapshot. Multi-page snapshot consistency therefore remains an explicit data-integrity gap and must not be claimed as complete. + +The current Kubernetes production reference still lacks a Notification workload, service/configuration, network-policy, image, and rollout verification. End-to-end production Notification data-rights support remains incomplete until that deployment path is implemented and proven on the integrated protected head. + +## Rollback + +Do not roll back by deleting Notification data-rights tables, fences, or receipts. Stop Notification scheduling/data-rights execution, deploy a compatible application version that ignores the newer schema, preserve all durable evidence, and deliver any repair as a new reviewed forward migration. From 9eb2a616cd0d8c964f6b10980954abf491ed453d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:45:19 +0900 Subject: [PATCH 138/150] test(compose): pin legacy local password upgrade contract --- .../compose-runtime-workflow-regression.test.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs index a13dc64b..cb79cf07 100644 --- a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs +++ b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs @@ -6,7 +6,9 @@ const CI_WORKFLOW_PATH = resolve( import.meta.dirname, '../../../.github/workflows/ci.yml', ); +const COMPOSE_PATH = resolve(import.meta.dirname, '../../../compose.yaml'); const ciWorkflow = readFileSync(CI_WORKFLOW_PATH, 'utf8'); +const compose = readFileSync(COMPOSE_PATH, 'utf8'); /** Returns one named CI step so assertions stay scoped to its shell contract. */ function ciStep(name) { @@ -46,4 +48,15 @@ describe('Compose runtime provisioning workflow', () => { ); expect(runtime).toContain('docker compose down --volumes --remove-orphans'); }); + + it('preserves the historical local PostgreSQL password for existing volumes while keeping the new runtime credential explicit', () => { + const legacyAdminFallbacks = + compose.match(/\$\{POSTGRES_PASSWORD:-lifeos\}/gu) ?? []; + + expect(legacyAdminFallbacks).toHaveLength(2); + expect(compose).not.toContain('${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}'); + expect(compose).toContain( + '${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD}', + ); + }); }); From 0eb0241b90f32ddf3a9b6a47fb03317fb4375964 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:48:19 +0900 Subject: [PATCH 139/150] fix(compose): preserve legacy local postgres upgrade --- compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose.yaml b/compose.yaml index 9fb7d719..3a76f503 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ services: image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 environment: POSTGRES_USER: ${POSTGRES_USER:-lifeos} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lifeos} POSTGRES_DB: ${POSTGRES_DB:-lifeos} ports: - '127.0.0.1:5432:5432' @@ -29,7 +29,7 @@ services: PGPORT: '5432' PGDATABASE: ${POSTGRES_DB:-lifeos} PGUSER: ${POSTGRES_USER:-lifeos} - PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + PGPASSWORD: ${POSTGRES_PASSWORD:-lifeos} NOTIFICATION_DATABASE_RUNTIME_ROLE: ${NOTIFICATION_DATABASE_RUNTIME_ROLE:-lifeos_notification} NOTIFICATION_RUNTIME_DATABASE_PASSWORD: ${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD} volumes: From 01340b99722fb6ff4bfa49f0e51baabd9309a04d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:51:29 +0900 Subject: [PATCH 140/150] docs(local): document existing postgres volume upgrade --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 59412fab..ca4130e1 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,16 @@ docker compose up -d pnpm dev ``` +Existing local PostgreSQL volumes created before Notification runtime-role provisioning were initialized with the historical local administrator credential `lifeos`/`lifeos`. Do not delete those volumes just to upgrade. Leave `POSTGRES_PASSWORD` unset so the Compose-only compatibility fallback uses the stored historical password, or set it to the administrator password already stored by the volume; changing the environment value does not rotate an initialized PostgreSQL role. Set a fresh `NOTIFICATION_RUNTIME_DATABASE_PASSWORD` for the separate least-privilege Notification runtime role, then provision that role before starting the full stack: + +```bash +docker compose up -d postgres +docker compose run --rm --no-deps notification-db-provision +docker compose up -d +``` + +Fresh local installations should still copy `.env.example` and replace its placeholder credentials before startup. The `lifeos` fallback exists only to preserve pre-existing local development volumes; it is not a production credential policy. + Default endpoints: - Web: `http://localhost:3000` From e54c875a43e97261117d14d3b20e460e62e66652 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:52:58 +0900 Subject: [PATCH 141/150] docs(notification): record local compose credential compatibility --- docs/operations/notification-persistence.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/operations/notification-persistence.md b/docs/operations/notification-persistence.md index 1590f1d0..08d217ef 100644 --- a/docs/operations/notification-persistence.md +++ b/docs/operations/notification-persistence.md @@ -14,6 +14,20 @@ The connection behind `NOTIFICATION_MIGRATION_DATABASE_URL` is the stable migrat The runtime identity named by `NOTIFICATION_DATABASE_RUNTIME_ROLE` must be distinct from the migration authority. After migration, the runner removes broad privileges and grants only the Notification runtime permissions needed by the repository and data-rights adapter. The owner-only erasure tables remain inaccessible to the runtime except for the narrowly required authority-replay table operations and the explicit `erase_workspace_data` function execution path. +### Existing local Compose volumes + +Local PostgreSQL volumes created by earlier LifeOS `main` revisions were initialized with the development administrator credential `lifeos`/`lifeos`. PostgreSQL stores that role password inside the initialized volume; changing `POSTGRES_PASSWORD` later does not rotate it. For that reason, `compose.yaml` keeps `${POSTGRES_PASSWORD:-lifeos}` only as an upgrade-compatible local administrator fallback. Do not delete an existing development volume merely to introduce the Notification runtime role. + +For an existing volume, leave `POSTGRES_PASSWORD` unset when the stored administrator password is still `lifeos`, or supply the actual administrator password already stored by that volume. Keep `NOTIFICATION_RUNTIME_DATABASE_PASSWORD` explicit and fresh: the Notification provisioner uses it only for the distinct least-privilege runtime role. Start PostgreSQL, run the idempotent one-shot provisioner, then start the remaining services: + +```bash +docker compose up -d postgres +docker compose run --rm --no-deps notification-db-provision +docker compose up -d +``` + +Fresh local installations should copy `.env.example` and replace its placeholder credentials before startup. Production and shared deployments must not rely on the local `lifeos` compatibility fallback; supply administrator or migration authority through the deployment's managed-secret boundary and keep runtime credentials separate. + Before rollout, verify that the target database is PostgreSQL 16 or a compatibility-tested later release and that the connection uses TLS outside a private development environment. ## Runtime configuration From 06f90d8cb9a635ba1e1c601e36a9100a1b66747d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:57:51 +0900 Subject: [PATCH 142/150] test(compose): require explicit legacy password rotation --- ...mpose-runtime-workflow-regression.test.mjs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs index cb79cf07..e2bd9af1 100644 --- a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs +++ b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -7,8 +7,15 @@ const CI_WORKFLOW_PATH = resolve( '../../../.github/workflows/ci.yml', ); const COMPOSE_PATH = resolve(import.meta.dirname, '../../../compose.yaml'); +const LEGACY_UPGRADE_PATH = resolve( + import.meta.dirname, + '../../../infra/postgres/provision/upgrade-legacy-local.sh', +); const ciWorkflow = readFileSync(CI_WORKFLOW_PATH, 'utf8'); const compose = readFileSync(COMPOSE_PATH, 'utf8'); +const legacyUpgrade = existsSync(LEGACY_UPGRADE_PATH) + ? readFileSync(LEGACY_UPGRADE_PATH, 'utf8') + : ''; /** Returns one named CI step so assertions stay scoped to its shell contract. */ function ciStep(name) { @@ -49,14 +56,17 @@ describe('Compose runtime provisioning workflow', () => { expect(runtime).toContain('docker compose down --volumes --remove-orphans'); }); - it('preserves the historical local PostgreSQL password for existing volumes while keeping the new runtime credential explicit', () => { - const legacyAdminFallbacks = - compose.match(/\$\{POSTGRES_PASSWORD:-lifeos\}/gu) ?? []; - - expect(legacyAdminFallbacks).toHaveLength(2); - expect(compose).not.toContain('${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}'); + it('requires a fresh local PostgreSQL administrator password while preserving an explicit legacy-volume rotation path', () => { + expect(compose).toContain('${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}'); + expect(compose).not.toContain('${POSTGRES_PASSWORD:-lifeos}'); expect(compose).toContain( '${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD}', ); + expect(legacyUpgrade).toContain("POSTGRES_PASSWORD must not remain 'lifeos'"); + expect(legacyUpgrade).toContain("ALTER ROLE lifeos PASSWORD :'next_admin_password';"); + expect(legacyUpgrade).toContain( + 'docker compose run --rm --no-deps notification-db-provision', + ); + expect(legacyUpgrade).not.toContain('POSTGRES_PASSWORD=lifeos'); }); }); From e633b2f2c4edc940bcd74b4113c7b2846b642b53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:17:29 +0900 Subject: [PATCH 143/150] fix(compose): rotate legacy local administrator credential --- README.md | 13 +++-- compose.yaml | 4 +- .../provision/upgrade-legacy-local.sh | 53 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100755 infra/postgres/provision/upgrade-legacy-local.sh diff --git a/README.md b/README.md index ca4130e1..52a099c9 100644 --- a/README.md +++ b/README.md @@ -57,15 +57,18 @@ docker compose up -d pnpm dev ``` -Existing local PostgreSQL volumes created before Notification runtime-role provisioning were initialized with the historical local administrator credential `lifeos`/`lifeos`. Do not delete those volumes just to upgrade. Leave `POSTGRES_PASSWORD` unset so the Compose-only compatibility fallback uses the stored historical password, or set it to the administrator password already stored by the volume; changing the environment value does not rotate an initialized PostgreSQL role. Set a fresh `NOTIFICATION_RUNTIME_DATABASE_PASSWORD` for the separate least-privilege Notification runtime role, then provision that role before starting the full stack: +`POSTGRES_PASSWORD` and `NOTIFICATION_RUNTIME_DATABASE_PASSWORD` are required local credentials. Keep them distinct and replace the example placeholders before Compose startup. New local volumes never fall back to the historical public `lifeos` administrator password. + +Existing PostgreSQL volumes created before explicit local credential provisioning may still store the historical `lifeos` administrator password. Do not delete those volumes to upgrade and do not restore the old Compose fallback. Supply the current stored password only through `LEGACY_POSTGRES_PASSWORD`, set a new `POSTGRES_PASSWORD`, keep a distinct `NOTIFICATION_RUNTIME_DATABASE_PASSWORD`, and run the bounded rotation path once: ```bash -docker compose up -d postgres -docker compose run --rm --no-deps notification-db-provision -docker compose up -d +LEGACY_POSTGRES_PASSWORD='' \ +POSTGRES_PASSWORD='' \ +NOTIFICATION_RUNTIME_DATABASE_PASSWORD='' \ +infra/postgres/provision/upgrade-legacy-local.sh ``` -Fresh local installations should still copy `.env.example` and replace its placeholder credentials before startup. The `lifeos` fallback exists only to preserve pre-existing local development volumes; it is not a production credential policy. +The upgrade script starts the existing volume without changing its stored role, authenticates with the operator-supplied legacy credential, rotates the `lifeos` administrator inside PostgreSQL, verifies the new credential, and then provisions the least-privilege Notification runtime role. After it succeeds, persist the new values in your untracked `.env`; `LEGACY_POSTGRES_PASSWORD` is no longer needed. Default endpoints: diff --git a/compose.yaml b/compose.yaml index 3a76f503..9fb7d719 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ services: image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 environment: POSTGRES_USER: ${POSTGRES_USER:-lifeos} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lifeos} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB:-lifeos} ports: - '127.0.0.1:5432:5432' @@ -29,7 +29,7 @@ services: PGPORT: '5432' PGDATABASE: ${POSTGRES_DB:-lifeos} PGUSER: ${POSTGRES_USER:-lifeos} - PGPASSWORD: ${POSTGRES_PASSWORD:-lifeos} + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} NOTIFICATION_DATABASE_RUNTIME_ROLE: ${NOTIFICATION_DATABASE_RUNTIME_ROLE:-lifeos_notification} NOTIFICATION_RUNTIME_DATABASE_PASSWORD: ${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD} volumes: diff --git a/infra/postgres/provision/upgrade-legacy-local.sh b/infra/postgres/provision/upgrade-legacy-local.sh new file mode 100755 index 00000000..ca3091ba --- /dev/null +++ b/infra/postgres/provision/upgrade-legacy-local.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +: "${LEGACY_POSTGRES_PASSWORD:?Set LEGACY_POSTGRES_PASSWORD to the password currently stored by the legacy local volume}" +: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD to a new local administrator password}" +: "${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD to a distinct runtime password}" + +if [[ "${POSTGRES_USER:-lifeos}" != 'lifeos' ]]; then + echo 'upgrade_error=legacy_postgres_user_must_be_lifeos' >&2 + exit 1 +fi +if [[ "$POSTGRES_PASSWORD" == 'lifeos' ]]; then + echo "POSTGRES_PASSWORD must not remain 'lifeos'" >&2 + exit 1 +fi +if [[ "$LEGACY_POSTGRES_PASSWORD" == "$POSTGRES_PASSWORD" ]]; then + echo 'upgrade_error=new_postgres_password_must_differ_from_legacy' >&2 + exit 1 +fi +if [[ "$NOTIFICATION_RUNTIME_DATABASE_PASSWORD" == "$POSTGRES_PASSWORD" ]]; then + echo 'upgrade_error=runtime_password_must_differ_from_admin' >&2 + exit 1 +fi + +# An existing data directory ignores POSTGRES_PASSWORD for role initialization, so +# starting it with the new value does not rotate the stored credential. Connect with +# the operator-supplied legacy credential, rotate inside PostgreSQL, then verify the +# new credential before provisioning the separate runtime role. +POSTGRES_PASSWORD="$POSTGRES_PASSWORD" docker compose up --detach --wait --wait-timeout 90 postgres + +POSTGRES_PASSWORD="$POSTGRES_PASSWORD" docker compose exec --no-TTY \ + -e PGPASSWORD="$LEGACY_POSTGRES_PASSWORD" \ + postgres psql \ + --no-psqlrc \ + --username lifeos \ + --dbname "${POSTGRES_DB:-lifeos}" \ + --set=ON_ERROR_STOP=1 \ + --set=next_admin_password="$POSTGRES_PASSWORD" <<'SQL' +ALTER ROLE lifeos PASSWORD :'next_admin_password'; +SQL + +POSTGRES_PASSWORD="$POSTGRES_PASSWORD" docker compose exec --no-TTY \ + -e PGPASSWORD="$POSTGRES_PASSWORD" \ + postgres psql \ + --no-psqlrc \ + --username lifeos \ + --dbname "${POSTGRES_DB:-lifeos}" \ + --set=ON_ERROR_STOP=1 \ + --command='SELECT current_user' >/dev/null + +POSTGRES_PASSWORD="$POSTGRES_PASSWORD" NOTIFICATION_RUNTIME_DATABASE_PASSWORD="$NOTIFICATION_RUNTIME_DATABASE_PASSWORD" docker compose run --rm --no-deps notification-db-provision + +echo 'upgrade_result=legacy_local_postgres_rotated' From 91fc9014ad2732fbc5e0b069036f7b26265c1a20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:20:22 +0900 Subject: [PATCH 144/150] test(compose): reject password-bearing rotation arguments --- .../src/compose-runtime-workflow-regression.test.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs index e2bd9af1..30c8abe5 100644 --- a/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs +++ b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs @@ -69,4 +69,11 @@ describe('Compose runtime provisioning workflow', () => { ); expect(legacyUpgrade).not.toContain('POSTGRES_PASSWORD=lifeos'); }); + + it('keeps legacy and replacement administrator credentials out of process arguments during rotation', () => { + expect(legacyUpgrade).toContain('\\getenv next_admin_password POSTGRES_PASSWORD'); + expect(legacyUpgrade).not.toContain('--set=next_admin_password="$POSTGRES_PASSWORD"'); + expect(legacyUpgrade).not.toContain('-e PGPASSWORD="$LEGACY_POSTGRES_PASSWORD"'); + expect(legacyUpgrade).not.toContain('-e PGPASSWORD="$POSTGRES_PASSWORD"'); + }); }); From 01638587a24cf084f47de228c8971f4ca97365c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:21:11 +0900 Subject: [PATCH 145/150] fix(compose): keep rotation secrets out of process arguments --- infra/postgres/provision/upgrade-legacy-local.sh | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/infra/postgres/provision/upgrade-legacy-local.sh b/infra/postgres/provision/upgrade-legacy-local.sh index ca3091ba..3be6b754 100755 --- a/infra/postgres/provision/upgrade-legacy-local.sh +++ b/infra/postgres/provision/upgrade-legacy-local.sh @@ -25,22 +25,23 @@ fi # An existing data directory ignores POSTGRES_PASSWORD for role initialization, so # starting it with the new value does not rotate the stored credential. Connect with # the operator-supplied legacy credential, rotate inside PostgreSQL, then verify the -# new credential before provisioning the separate runtime role. +# new credential before provisioning the separate runtime role. Secrets are inherited +# through the exec environment instead of being rendered into Docker/psql arguments. POSTGRES_PASSWORD="$POSTGRES_PASSWORD" docker compose up --detach --wait --wait-timeout 90 postgres -POSTGRES_PASSWORD="$POSTGRES_PASSWORD" docker compose exec --no-TTY \ - -e PGPASSWORD="$LEGACY_POSTGRES_PASSWORD" \ +PGPASSWORD="$LEGACY_POSTGRES_PASSWORD" POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + docker compose exec --no-TTY -e PGPASSWORD -e POSTGRES_PASSWORD \ postgres psql \ --no-psqlrc \ --username lifeos \ --dbname "${POSTGRES_DB:-lifeos}" \ - --set=ON_ERROR_STOP=1 \ - --set=next_admin_password="$POSTGRES_PASSWORD" <<'SQL' + --set=ON_ERROR_STOP=1 <<'SQL' +\getenv next_admin_password POSTGRES_PASSWORD ALTER ROLE lifeos PASSWORD :'next_admin_password'; SQL -POSTGRES_PASSWORD="$POSTGRES_PASSWORD" docker compose exec --no-TTY \ - -e PGPASSWORD="$POSTGRES_PASSWORD" \ +PGPASSWORD="$POSTGRES_PASSWORD" POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + docker compose exec --no-TTY -e PGPASSWORD \ postgres psql \ --no-psqlrc \ --username lifeos \ From 2bda705bfbcebe0c986003e3b95ac2814db7d701 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:21:56 +0900 Subject: [PATCH 146/150] test(notification): remove needless dynamic replay-role SQL --- ...-rights-authority-replay.integration.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts b/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts index 65e1cc55..41905cfe 100644 --- a/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts +++ b/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts @@ -38,13 +38,14 @@ async function applyMigrations(pool: Pool): Promise { /** Creates the same least-privilege replay-table grant required from deployment. */ async function grantRuntimeReplayAuthority(pool: Pool): Promise { await pool.query(` - GRANT USAGE ON SCHEMA notification_service TO ${RUNTIME_ROLE}; + GRANT USAGE ON SCHEMA notification_service + TO notification_data_rights_replay_runtime_test; REVOKE ALL PRIVILEGES ON TABLE notification_service.data_rights_authority_replay_records - FROM ${RUNTIME_ROLE}; + FROM notification_data_rights_replay_runtime_test; GRANT SELECT, INSERT, DELETE ON TABLE notification_service.data_rights_authority_replay_records - TO ${RUNTIME_ROLE}; + TO notification_data_rights_replay_runtime_test; `); } @@ -60,9 +61,11 @@ describeWithPostgres( await administrativePool.query(`DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${RUNTIME_ROLE}' + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname = 'notification_data_rights_replay_runtime_test' ) THEN - CREATE ROLE ${RUNTIME_ROLE} NOLOGIN; + CREATE ROLE notification_data_rights_replay_runtime_test NOLOGIN; END IF; END $$`); @@ -97,10 +100,10 @@ describeWithPostgres( .query('DROP SCHEMA IF EXISTS notification_service CASCADE') .catch(() => undefined); await administrativePool - .query(`DROP OWNED BY ${RUNTIME_ROLE}`) + .query('DROP OWNED BY notification_data_rights_replay_runtime_test') .catch(() => undefined); await administrativePool - .query(`DROP ROLE IF EXISTS ${RUNTIME_ROLE}`) + .query('DROP ROLE IF EXISTS notification_data_rights_replay_runtime_test') .catch(() => undefined); await administrativePool.end(); }); From 71a55ed1a5dffe10226a0c6b7932bcd656d9d21f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:23:27 +0900 Subject: [PATCH 147/150] test(compose): bind legacy rotation to effective database and TCP auth --- .../legacy-local-upgrade-regression.test.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 packages/commercial-development-agent/src/legacy-local-upgrade-regression.test.mjs diff --git a/packages/commercial-development-agent/src/legacy-local-upgrade-regression.test.mjs b/packages/commercial-development-agent/src/legacy-local-upgrade-regression.test.mjs new file mode 100644 index 00000000..3b9e0c60 --- /dev/null +++ b/packages/commercial-development-agent/src/legacy-local-upgrade-regression.test.mjs @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const upgrade = readFileSync( + resolve(import.meta.dirname, '../../../infra/postgres/provision/upgrade-legacy-local.sh'), + 'utf8', +); + +describe('Legacy local PostgreSQL upgrade', () => { + it('uses the Compose-resolved database and password-authenticated TCP', () => { + expect(upgrade).toContain('docker compose config --format json'); + expect(upgrade).toContain('EFFECTIVE_POSTGRES_DB'); + expect(upgrade).not.toContain('${POSTGRES_DB:-lifeos}'); + expect(upgrade.match(/--host=127\.0\.0\.1/gu)).toHaveLength(2); + }); +}); From f0b00e1749b9573169123112d05bb60d3f872b24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:24:06 +0900 Subject: [PATCH 148/150] fix(compose): bind legacy rotation to rendered database and TCP auth --- .../provision/upgrade-legacy-local.sh | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/infra/postgres/provision/upgrade-legacy-local.sh b/infra/postgres/provision/upgrade-legacy-local.sh index 3be6b754..ac3f0558 100755 --- a/infra/postgres/provision/upgrade-legacy-local.sh +++ b/infra/postgres/provision/upgrade-legacy-local.sh @@ -5,10 +5,6 @@ set -Eeuo pipefail : "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD to a new local administrator password}" : "${NOTIFICATION_RUNTIME_DATABASE_PASSWORD:?Set NOTIFICATION_RUNTIME_DATABASE_PASSWORD to a distinct runtime password}" -if [[ "${POSTGRES_USER:-lifeos}" != 'lifeos' ]]; then - echo 'upgrade_error=legacy_postgres_user_must_be_lifeos' >&2 - exit 1 -fi if [[ "$POSTGRES_PASSWORD" == 'lifeos' ]]; then echo "POSTGRES_PASSWORD must not remain 'lifeos'" >&2 exit 1 @@ -22,30 +18,58 @@ if [[ "$NOTIFICATION_RUNTIME_DATABASE_PASSWORD" == "$POSTGRES_PASSWORD" ]]; then exit 1 fi +# Compose resolves `.env` interpolation even when those names are not exported to +# this shell. Read the effective database identity from the same rendered model so +# rotation cannot silently target a different database than the existing volume. +EFFECTIVE_POSTGRES_SETTINGS="$( + POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + NOTIFICATION_RUNTIME_DATABASE_PASSWORD="$NOTIFICATION_RUNTIME_DATABASE_PASSWORD" \ + docker compose config --format json | node --input-type=module -e ' + let input = ""; + for await (const chunk of process.stdin) input += chunk; + const config = JSON.parse(input); + const environment = config?.services?.postgres?.environment; + const user = environment?.POSTGRES_USER; + const database = environment?.POSTGRES_DB; + const invalid = (value) => + typeof value !== "string" || value.length === 0 || /[\t\r\n\0]/u.test(value); + if (invalid(user) || invalid(database)) process.exit(64); + process.stdout.write(`${user}\t${database}`); + ' +)" +IFS=$'\t' read -r EFFECTIVE_POSTGRES_USER EFFECTIVE_POSTGRES_DB <<< "$EFFECTIVE_POSTGRES_SETTINGS" +if [[ "$EFFECTIVE_POSTGRES_USER" != 'lifeos' ]]; then + echo 'upgrade_error=legacy_postgres_user_must_be_lifeos' >&2 + exit 1 +fi + # An existing data directory ignores POSTGRES_PASSWORD for role initialization, so # starting it with the new value does not rotate the stored credential. Connect with -# the operator-supplied legacy credential, rotate inside PostgreSQL, then verify the -# new credential before provisioning the separate runtime role. Secrets are inherited -# through the exec environment instead of being rendered into Docker/psql arguments. +# the operator-supplied legacy credential over TCP, rotate inside PostgreSQL, then +# verify the replacement credential before provisioning the separate runtime role. +# Secrets are inherited through the exec environment rather than rendered into +# Docker or psql process arguments. POSTGRES_PASSWORD="$POSTGRES_PASSWORD" docker compose up --detach --wait --wait-timeout 90 postgres PGPASSWORD="$LEGACY_POSTGRES_PASSWORD" POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ docker compose exec --no-TTY -e PGPASSWORD -e POSTGRES_PASSWORD \ postgres psql \ --no-psqlrc \ - --username lifeos \ - --dbname "${POSTGRES_DB:-lifeos}" \ + --host=127.0.0.1 \ + --username "$EFFECTIVE_POSTGRES_USER" \ + --dbname "$EFFECTIVE_POSTGRES_DB" \ --set=ON_ERROR_STOP=1 <<'SQL' \getenv next_admin_password POSTGRES_PASSWORD ALTER ROLE lifeos PASSWORD :'next_admin_password'; SQL -PGPASSWORD="$POSTGRES_PASSWORD" POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ +PGPASSWORD="$POSTGRES_PASSWORD" \ docker compose exec --no-TTY -e PGPASSWORD \ postgres psql \ --no-psqlrc \ - --username lifeos \ - --dbname "${POSTGRES_DB:-lifeos}" \ + --host=127.0.0.1 \ + --username "$EFFECTIVE_POSTGRES_USER" \ + --dbname "$EFFECTIVE_POSTGRES_DB" \ --set=ON_ERROR_STOP=1 \ --command='SELECT current_user' >/dev/null From bff90cc5676e4ebb8e6da4675eb5b7c5ddc74c9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 13:18:35 +0900 Subject: [PATCH 149/150] fix(ci): preserve current queue filters in notification contributor --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40f763cd..8ac46590 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,14 @@ name: CI on: push: branches: [main, develop] + paths-ignore: + - "docs/**" + - "*.md" pull_request: branches: [main, develop] + paths-ignore: + - "docs/**" + - "*.md" permissions: contents: read From 973e7c4155de4b9c656b3a4f28490eb65a15aef6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 16:26:56 +0900 Subject: [PATCH 150/150] fix(ci): keep Notification coverage for documentation changes --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ac46590..40f763cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,8 @@ name: CI on: push: branches: [main, develop] - paths-ignore: - - "docs/**" - - "*.md" pull_request: branches: [main, develop] - paths-ignore: - - "docs/**" - - "*.md" permissions: contents: read