diff --git a/.env.example b/.env.example index 937dde1d8..cad96ace9 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,7 +20,10 @@ AI_MODEL_REQUEST_TIMEOUT_MS=10000 AI_PROPOSAL_MODEL=rule-based CONTEXTUAL_ORCHESTRATOR_TOKEN= CONTEXTUAL_ORCHESTRATOR_URL= -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 NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS=30000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c86718012..6aba73ad8 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,14 +36,15 @@ 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" } trap cleanup EXIT - docker compose up --detach --wait --wait-timeout 90 + docker compose up --detach --wait --wait-timeout 90 postgres nats + 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 \ @@ -103,6 +107,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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f763ec9c4..1cad4ffab 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d85c54b25..b334b0768 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cc4bd7e..bec3f2195 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. diff --git a/README.md b/README.md index 59412fabb..52a099c9f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,19 @@ docker compose up -d pnpm dev ``` +`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 +LEGACY_POSTGRES_PASSWORD='' \ +POSTGRES_PASSWORD='' \ +NOTIFICATION_RUNTIME_DATABASE_PASSWORD='' \ +infra/postgres/provision/upgrade-legacy-local.sh +``` + +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: - Web: `http://localhost:3000` 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 000000000..a402c0760 --- /dev/null +++ b/apps/notification-service/migrations/0002_data_rights_erasure.sql @@ -0,0 +1,418 @@ +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 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 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 +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; +$$; + +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( + 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; + 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; + 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: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, + 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; + receipt_found := FOUND; + + IF receipt_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; + 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; + + 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; + + 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; + 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. 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; 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 000000000..9fc6f79ab --- /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; diff --git a/apps/notification-service/package.json b/apps/notification-service/package.json index 361bc6de9..cea403a64 100644 --- a/apps/notification-service/package.json +++ b/apps/notification-service/package.json @@ -7,6 +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 -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" }, 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 000000000..41905cfe9 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-authority-replay.integration.test.ts @@ -0,0 +1,171 @@ +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 notification_data_rights_replay_runtime_test; + REVOKE ALL PRIVILEGES ON TABLE + notification_service.data_rights_authority_replay_records + FROM notification_data_rights_replay_runtime_test; + GRANT SELECT, INSERT, DELETE ON TABLE + notification_service.data_rights_authority_replay_records + TO notification_data_rights_replay_runtime_test; + `); +} + +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 = 'notification_data_rights_replay_runtime_test' + ) THEN + CREATE ROLE notification_data_rights_replay_runtime_test 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 notification_data_rights_replay_runtime_test') + .catch(() => undefined); + await administrativePool + .query('DROP ROLE IF EXISTS notification_data_rights_replay_runtime_test') + .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' }); + }); + }, +); 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 000000000..a5b956e2d --- /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', + ); + }); +}); 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 000000000..42cdaa7e3 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-authority-replay.ts @@ -0,0 +1,120 @@ +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 claims destructive authority until success or explicit failure release. */ +export interface NotificationDataRightsAuthorityReplayGuardPort { + /** 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 { + 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. 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 +{ + /** Creates the guard over the Notification service's parameterized SQL boundary. */ + constructor(private readonly client: NotificationSqlClient) {} + + /** Atomically claims 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; + } + + /** 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], + ); + } +} 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 000000000..9d7ac0bf2 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-controller.test.ts @@ -0,0 +1,221 @@ +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 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; + +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, +}); + +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( + 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', + 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'); +} + +/** 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(body, issuedAt), + { method: 'POST', originalUrl: PATH }, + body, + ), + ).resolves.toMatchObject({ operation: 'verify_erased', erased: true }); + 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[] = []; + const controller = new NotificationDataRightsController(runtime(recorded)); + const issuedAt = String(Math.floor(Date.now() / 1000)); + + await expect( + controller.contribute( + issuedAt, + signature(body, 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(body, issuedAt), + { method: 'POST', originalUrl: PATH }, + body, + ); + } catch (error) { + caught = error; + } + 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); + }); +}); 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 000000000..de8408249 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-controller.ts @@ -0,0 +1,97 @@ +import { createHash } from 'node:crypto'; +import { + Body, + Controller, + Headers, + Inject, + Optional, + 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', +); +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 { + readonly method?: unknown; + 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 { + 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 + * 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( + @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 }, + this.contextSecret, + { method: request.method, path: request.originalUrl }, + Math.floor(Date.now() / 1000), + this.runtime.dataRightsAuthorityReplayGuard, + ); + 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); + } + } +} 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 000000000..84370399b --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-http-boundary.test.ts @@ -0,0 +1,306 @@ +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, +} 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'); +} + +/** 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; + }, + async release(): Promise { + accepted = false; + }, + }; +} + +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, + { issuedAt, signature: signature(exportRequest, issuedAt) }, + 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 () => { + 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('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, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + 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: 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 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: signed }, + SECRET, + { method: 'POST', path: CONTRIBUTOR_PATH }, + NOW_SECONDS, + replayGuard, + ), + ); + 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'); + }, + async release(): 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([ + { + 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'); + }); +}); 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 000000000..7aa852eee --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-http-boundary.ts @@ -0,0 +1,338 @@ +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 { + 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, replayed, 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 or replay-store 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(); +} + +/** 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. 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, + headers: TrustedNotificationDataRightsContextHeaders, + secret: unknown, + requestBinding: NotificationDataRightsRequestBinding, + nowSeconds = Math.floor(Date.now() / 1000), + replayGuard?: NotificationDataRightsAuthorityReplayGuardPort, +): 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(); + } + + 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; +} + +/** 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', + ); +} 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 000000000..281676105 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-migration.test.ts @@ -0,0 +1,125 @@ +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( + "'notification.service:workspace:' || target_workspace_id::text", + ); + 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( + /REVOKE ALL ON FUNCTION notification_service\.erase_workspace_data\([\s\S]*?\) FROM PUBLIC;/u, + ); + }); + + 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( + 'DELETE FROM notification_service.inbox_messages', + ); + const authorizationInsert = sql.indexOf( + 'INSERT INTO notification_service.data_rights_erasure_authorizations', + ); + const outcomeDelete = sql.indexOf( + 'DELETE FROM notification_service.reminder_outcomes', + ); + 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(authorizationInsert).toBeGreaterThan(inboxDelete); + expect(outcomeDelete).toBeGreaterThan(authorizationInsert); + expect(authorizationDelete).toBeGreaterThan(outcomeDelete); + expect(occurrenceDelete).toBeGreaterThan(authorizationDelete); + }); +}); 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 000000000..7bf51a4db --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-pagination.test.ts @@ -0,0 +1,183 @@ +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 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) + .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([]); + }); + + 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([]); + }, + ); +}); 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 000000000..75f11aaad --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-preflight-regression.test.ts @@ -0,0 +1,75 @@ +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, + 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); + + 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'"); + }); +}); 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 000000000..2f714678b --- /dev/null +++ b/apps/notification-service/src/notification-data-rights-preflight-verification.test.ts @@ -0,0 +1,90 @@ +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[] = []; + + constructor(private readonly overrides: Record = {}) {} + + 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, + 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({ + reminder_occurrences_select_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('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("to_regnamespace('notification_service')"); + }); +}); 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 000000000..b9e85e1e2 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.behavior.test.ts @@ -0,0 +1,469 @@ +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); +const EVIDENCE_TIME = '2026-08-12T00:00:00.000000Z'; +const CODEPOINT_CANONICAL_DIGEST = + '3ab3b13cd6c0ab42b9cbed3c685c5b4d0b065f94b5e147b267a3ab4e00f0d356'; + +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', + overrides: Readonly> = {}, +): Record { + return { + contractVersion: 'life-os.data-rights-contributor.v1', + operation, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + ...(operation === 'erase' ? { idempotencyKey: IDEMPOTENCY_KEY } : {}), + ...overrides, + }; +} + +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 { + evidenceTime, + evidenceKind: kind, + evidenceId: uuid(index), + data, + }; +} + +function exportResult( + evidenceRecords: unknown, +): NotificationSqlQueryResult { + return { rows: [{ evidence_records: evidenceRecords }] }; +} + +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([ + 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); + + 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: 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, + null, + null, + null, + 1_001, + ]); + expect(client.calls[0]?.text).toContain( + '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'); + }); + + it('uses codepoint-stable canonical JSON for reproducible export evidence', async () => { + const first = new NotificationDataRightsContributor( + new ScriptedClient([ + exportResult([evidence({ a: 'lower', Z: 'upper' })]), + ]), + ); + const second = new NotificationDataRightsContributor( + new ScriptedClient([ + exportResult([evidence({ 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_function_ready: true, + 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, + }, + ], + }, + { 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 missing function authority without direct receipt-table access', async () => { + const client = new ScriptedClient([ + { + rows: [ + { + erasure_function_ready: false, + 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, + }, + ], + }, + ]); + 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_function_unavailable'], + }); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]?.text).toContain('has_function_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', + ); + }); + + 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, + [], + nullPrototypeRequest, + { ...request('export'), contractVersion: 'wrong' }, + { ...request('export'), operation: 'unknown' }, + { ...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' }, + ]; + + 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); + + 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, 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( + new ScriptedClient([result]), + ); + await expectDataRightsFailure(contributor, request('export')); + } + }); + + 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 value of malformed) { + await expectDataRightsFailure( + new NotificationDataRightsContributor( + new ScriptedClient([exportResult([value])]), + ), + 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 data of invalidValues) { + const contributor = new NotificationDataRightsContributor( + new ScriptedClient([ + exportResult([ + evidence(data), + evidence(nullPrototype, 2, 'reminder_outcome'), + ]), + ]), + ); + 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_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.integration.test.ts b/apps/notification-service/src/notification-data-rights.integration.test.ts new file mode 100644 index 000000000..7d099b9a6 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.integration.test.ts @@ -0,0 +1,359 @@ +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('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); + + 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 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( + '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); + }); +}); 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 000000000..46f8618ca --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + createNotificationRuntime, + type NotificationPool, +} 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(text: string): Promise<{ rows: Row[] }> { + if (text.includes('AS evidence_records')) { + return { + rows: [{ evidence_records: [] } as 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(), + ); + + try { + const response = await runtime.dataRightsContributor.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(); + } + }); +}); 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 000000000..4ab9e8e70 --- /dev/null +++ b/apps/notification-service/src/notification-data-rights.ts @@ -0,0 +1,834 @@ +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 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; +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 = + | boolean + | number + | string + | null + | 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< + 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 = + | 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; + nextCursor?: string; + }> + | 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; +} + +/** 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'; + readonly cursor: ExportCursor | undefined; + }) + | (NormalizedRequestBase & { + readonly operation: 'erase_preflight' | 'verify_erased'; + }) + | (NormalizedRequestBase & { + readonly operation: 'erase'; + readonly idempotencyKey: string; + }); + +/** Aggregate row returned by the bounded one-statement export query. */ +interface ExportRow { + 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. */ +interface PrivilegeRow { + erasure_function_ready: unknown; + 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; +} + +/** 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; +} + +/** 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); +} + +/** Requires one real UTC calendar 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(); + } + + 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; +} + +/** 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]) => compareCanonicalKeys(left, 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(); +} + +/** 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; +} + +/** 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); + 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', + ]; + 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, + ); + 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, + request.cursor, + ); + 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 page of tenant-scoped Notification evidence. */ + private async exportWorkspace( + workspaceId: string, + requestId: string, + cursor: ExportCursor | undefined, + ): Promise { + const row = exactlyOne( + await this.query( + `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"'), + '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"') + ) 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, + '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"') + ) 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, + '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"') + ) 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.evidence_records)) { + return invalidDataRights(); + } + if (row.evidence_records.length > MAX_EXPORT_RECORDS + 1) { + 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: 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: page.length, + sha256, + data, + ...(nextCursor === undefined ? {} : { nextCursor }), + }; + } + + /** 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, + 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, + COALESCE(has_schema_privilege( + current_user, + to_regnamespace('notification_service'), + 'USAGE' + ), false) AS notification_schema_usage_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`, + [], + ), + ); + 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 notificationSchemaUsageReady = requireBoolean( + row.notification_schema_usage_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'); + } + if (!replaySelectReady || !replayInsertReady || !replayDeleteReady) { + blockers.push('notification_data_rights_replay_store_unavailable'); + } + if ( + !notificationSchemaUsageReady || + !reminderOccurrencesSelectReady || + !reminderOutcomesSelectReady || + !inboxMessagesSelectReady + ) { + blockers.push('notification_erasure_verification_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-http.test.ts b/apps/notification-service/src/notification-http.test.ts new file mode 100644 index 000000000..c8d4263e5 --- /dev/null +++ b/apps/notification-service/src/notification-http.test.ts @@ -0,0 +1,305 @@ +import { HttpException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { + bootstrapNotificationService, + createNotificationRequestListener, + type NotificationHttpRequest, + type NotificationHttpResponse, + type NotificationHttpServer, +} from './notification-http'; +import type { NotificationRuntime } from './notification-runtime'; + +const CONTEXT_SECRET = ['notification', 'data-rights', 'test', 'context'].join('-'); + +/** 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 | undefined; +} { + const headers = new Map(); + return { + statusCode: 0, + headers, + body: undefined, + 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('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' }); + + 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', + { + 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('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 }); + + 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'], + }, + body: '{}', + }), + duplicateHeader, + ); + expect(duplicateHeader.statusCode).toBe(401); + expect(JSON.stringify(duplicateHeader.body)).not.toContain('one'); + }); + + 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', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, + }, + () => 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('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', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, + }, + () => 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'], + [{ 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 serverFactory = vi.fn(() => server()); + + await expect( + bootstrapNotificationService( + { + NOTIFICATION_DATABASE_URL: 'postgresql://runtime.invalid/life_os', + NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, + ...override, + }, + runtimeFactory, + serverFactory, + ), + ).rejects.toThrow(expected); + expect(runtimeFactory).not.toHaveBeenCalled(); + 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_DATA_RIGHTS_CONTEXT_SECRET: CONTEXT_SECRET, + }, + () => suppliedRuntime, + () => server(true), + ), + ).rejects.toThrow(/^Notification HTTP bootstrap failed$/u); + expect(suppliedRuntime.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/notification-service/src/notification-http.ts b/apps/notification-service/src/notification-http.ts new file mode 100644 index 000000000..f518badce --- /dev/null +++ b/apps/notification-service/src/notification-http.ts @@ -0,0 +1,347 @@ +import { HttpException } from '@nestjs/common'; +import { + createServer, + type IncomingHttpHeaders, + type Server, +} from 'node:http'; +import { + NotificationDataRightsController, + type NotificationDataRightsHttpRequestIdentity, +} from './notification-data-rights-controller'; +import { + createNotificationRuntime, + type NotificationRuntime, +} from './notification-runtime'; + +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; + +/** Environment values accepted by the Notification HTTP composition root. */ +export type NotificationHttpEnvironment = Readonly< + Record +>; + +/** Minimal request shape consumed by the framework-free private HTTP adapter. */ +export interface NotificationHttpRequest + extends AsyncIterable { + readonly method: string | undefined; + readonly url: string | undefined; + 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; +} + +/** 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; + +/** 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; +} + +/** 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. */ +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; +} + +/** 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, + 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 with no extra framework runtime. + * 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, + runtimeFactory: NotificationRuntimeFactory = createNotificationRuntime, + serverFactory: NotificationHttpServerFactory = defaultServerFactory, +): Promise { + const port = notificationPort(environment.NOTIFICATION_PORT); + const host = notificationHost(environment.NOTIFICATION_HOST); + const contextSecret = notificationDataRightsContextSecret( + environment.NOTIFICATION_DATA_RIGHTS_CONTEXT_SECRET, + ); + const runtime = runtimeFactory(environment); + const controller = new NotificationDataRightsController(runtime, contextSecret); + const server = serverFactory(createNotificationRequestListener(controller)); + try { + await listen(server, port, host); + } catch { + 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(); + } + }, + }; +} diff --git a/apps/notification-service/src/notification-runtime.ts b/apps/notification-service/src/notification-runtime.ts index 3040aac19..66f8e821e 100644 --- a/apps/notification-service/src/notification-runtime.ts +++ b/apps/notification-service/src/notification-runtime.ts @@ -1,5 +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, @@ -218,6 +220,10 @@ 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, + /** Durable one-time consumption boundary for destructive signed service authority. */ + readonly dataRightsAuthorityReplayGuard: PostgresNotificationDataRightsAuthorityReplayGuard, ) {} /** Closes the owned PostgreSQL pool exactly once. */ @@ -266,5 +272,15 @@ export function createNotificationRuntime( gateway, reminderBatchSize, ); - return new NotificationRuntime(pool, repository, gateway, scheduler); + const dataRightsContributor = new NotificationDataRightsContributor(client); + const dataRightsAuthorityReplayGuard = + new PostgresNotificationDataRightsAuthorityReplayGuard(client); + return new NotificationRuntime( + pool, + repository, + gateway, + scheduler, + dataRightsContributor, + dataRightsAuthorityReplayGuard, + ); } diff --git a/apps/notification-service/src/server.test.ts b/apps/notification-service/src/server.test.ts new file mode 100644 index 000000000..aa75bf317 --- /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); + }); +}); diff --git a/apps/notification-service/src/server.ts b/apps/notification-service/src/server.ts new file mode 100644 index 000000000..64288db73 --- /dev/null +++ b/apps/notification-service/src/server.ts @@ -0,0 +1,41 @@ +import 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; +} diff --git a/apps/notification-service/tsconfig.json b/apps/notification-service/tsconfig.json index 7a9b07d3d..624dc7436 100644 --- a/apps/notification-service/tsconfig.json +++ b/apps/notification-service/tsconfig.json @@ -5,6 +5,8 @@ "moduleResolution": "Node", "rootDir": "src", "outDir": "dist", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "declaration": true }, "include": ["src/**/*.ts"] diff --git a/compose.yaml b/compose.yaml index 7c44ce4fb..9fb7d7194 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,19 +2,48 @@ 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: - 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_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 + 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'] diff --git a/docs/operations/notification-persistence.md b/docs/operations/notification-persistence.md index b8944cca8..08d217efb 100644 --- a/docs/operations/notification-persistence.md +++ b/docs/operations/notification-persistence.md @@ -2,40 +2,61 @@ ## 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`. +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. -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. +### 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 -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 +91,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 +105,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 +114,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 +127,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 +142,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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 000000000..bf2be1a18 --- /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. 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 000000000..59d03b24c --- /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. diff --git a/infra/kubernetes/run-migrations.sh b/infra/kubernetes/run-migrations.sh index 52429b3fb..7297b214b 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() { @@ -123,10 +124,54 @@ 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" 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 +184,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 +236,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, + 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 + fi + cat >>"${command_file}" <= 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 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 + 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 %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 = :'runtime_role' +) +\gexec + +ALTER ROLE :"runtime_role" + WITH LOGIN PASSWORD :'runtime_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOREPLICATION + NOINHERIT; + +SELECT current_database() AS runtime_database +\gset +GRANT CONNECT ON DATABASE :"runtime_database" TO :"runtime_role"; + +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.'; diff --git a/infra/postgres/provision/upgrade-legacy-local.sh b/infra/postgres/provision/upgrade-legacy-local.sh new file mode 100755 index 000000000..ac3f0558c --- /dev/null +++ b/infra/postgres/provision/upgrade-legacy-local.sh @@ -0,0 +1,78 @@ +#!/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_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 + +# 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 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 \ + --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" \ + docker compose exec --no-TTY -e PGPASSWORD \ + postgres psql \ + --no-psqlrc \ + --host=127.0.0.1 \ + --username "$EFFECTIVE_POSTGRES_USER" \ + --dbname "$EFFECTIVE_POSTGRES_DB" \ + --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' diff --git a/infra/tests/notification-migration-role.spec.ts b/infra/tests/notification-migration-role.spec.ts new file mode 100644 index 000000000..f14a6e61f --- /dev/null +++ b/infra/tests/notification-migration-role.spec.ts @@ -0,0 +1,129 @@ +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 composeConfiguration = read('compose.yaml'); + 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,\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)', + ); + + 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 a local migration authority that is distinct from the Notification runtime', () => { + expect(environmentExample).toContain( + 'NOTIFICATION_MIGRATION_DATABASE_URL=postgresql://lifeos:replace-with-local-postgres-password@postgres:5432/lifeos', + ); + expect(environmentExample).toContain( + 'NOTIFICATION_DATABASE_RUNTIME_ROLE=lifeos_notification', + ); + expect(environmentExample).toContain( + '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 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', + ); + 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', + ); + expect(composeConfiguration).not.toContain('POSTGRES_PASSWORD: lifeos'); + + const localProvisioning = read( + 'infra/postgres/provision/notification-runtime.psql', + ); + 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"'); + expect(localProvisioning).toContain('COMMENT ON ROLE :"runtime_role"'); + expect(localProvisioning).toContain('LOGIN'); + expect(localProvisioning).toContain('NOSUPERUSER'); + expect(localProvisioning).toContain('NOCREATEDB'); + expect(localProvisioning).toContain('NOCREATEROLE'); + expect(localProvisioning).toContain('NOINHERIT'); + expect(localProvisioning).toContain( + "\\getenv runtime_password NOTIFICATION_RUNTIME_DATABASE_PASSWORD", + ); + expect(localProvisioning).not.toMatch(/PASSWORD\s+'[^']+'/u); + expect(localProvisioning).not.toContain('CREATE ROLE lifeos_notification'); + }); + + 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(migrationRunner).toContain( + "pg_get_userbyid(namespace.nspowner) = current_user", + ); + expect(migrationRunner).toContain( + "pg_get_userbyid(relation.relowner) = 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); + }); +}); 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 000000000..30c8abe50 --- /dev/null +++ b/packages/commercial-development-agent/src/compose-runtime-workflow-regression.test.mjs @@ -0,0 +1,79 @@ +import { existsSync, 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 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) { + 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 for long-running dependencies before running the one-shot provisioner synchronously', () => { + const runtime = ciStep('Start and probe Compose infrastructure'); + + 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', + ); + expect(runtime).toContain(provisionerCommand); + expect(runtime.indexOf(provisionerCommand)).toBeGreaterThan( + runtime.indexOf(dependencyCommand), + ); + expect(runtime.indexOf(databaseProbe)).toBeGreaterThan( + runtime.indexOf(provisionerCommand), + ); + expect(runtime).not.toContain( + 'docker compose up --detach --no-deps notification-db-provision', + ); + expect(runtime).not.toContain( + 'docker compose ps --all --quiet notification-db-provision', + ); + expect(runtime).toContain('docker compose down --volumes --remove-orphans'); + }); + + 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'); + }); + + 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"'); + }); +}); 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 000000000..3b9e0c608 --- /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); + }); +}); diff --git a/packages/commercial-readiness/src/workflow-contract.test.mjs b/packages/commercial-readiness/src/workflow-contract.test.mjs index 364cb1adf..f1838f76e 100644 --- a/packages/commercial-readiness/src/workflow-contract.test.mjs +++ b/packages/commercial-readiness/src/workflow-contract.test.mjs @@ -105,6 +105,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('pins every LifeOS-owned hosted-runner workflow to the explicit supported Ubuntu image', async () => { const ciWorkflow = await repositoryFile('.github/workflows/ci.yml'); const ciJobs = [ diff --git a/packages/contracts/src/data-rights-contract.typecheck.ts b/packages/contracts/src/data-rights-contract.typecheck.ts index 1c361aea3..c8c6d1da3 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; diff --git a/packages/contracts/src/data-rights.ts b/packages/contracts/src/data-rights.ts index d964f2ebf..bdf075067 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. */