diff --git a/.env.example b/.env.example index 4a9cb05..2cd05c2 100644 --- a/.env.example +++ b/.env.example @@ -107,3 +107,11 @@ GULOGULO_MAIL_MAX_CONNECTIONS_PER_IP=20 GULOGULO_MAIL_MAX_MESSAGES_PER_USER_PER_MINUTE=60 GULOGULO_MAIL_QUEUE_MAX_ATTEMPTS=5 GULOGULO_MAIL_QUEUE_RETRY_BASE_MS=60000 + +# LP6 is a private, synthetic backup/restore/retention/DR proof. The smoke +# harness generates LP6_TEST_KEY_B64 at runtime; never commit a real key. +GULOGULO_LP6_VOLUMES_EXTERNAL=false +GULOGULO_LP6_NETWORK=gulogulo-lp6-runtime +GULOGULO_LP6_COMPOSE_PLATFORM=linux/amd64 +LP6_TEST_KEY_B64= +LP6_KEY_REFERENCE=kms/local-synthetic/lp6-v1 diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 5cb6895..f6470b4 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -94,11 +94,13 @@ jobs: test -f doc/lp3-local-mail.md test -f doc/lp4-local-web.md test -f doc/lp5-local-operations-capacity.md + test -f doc/lp6-local-backup-dr.md test -f release/v1-release-evidence.template.json test -f release/lp2-local-services.json test -f release/lp3-local-mail.json test -f release/lp4-local-web.json test -f release/lp5-local-operations-capacity.json + test -f release/lp6-local-backup-dr.json test -f scripts/m10-release-audit.mjs test -f scripts/lp2-compose-audit.mjs test -f scripts/lp2-compose-smoke.mjs @@ -115,6 +117,22 @@ jobs: test -f scripts/lp5-capacity-smoke.ts test -f scripts/lp5-proof-check.ts test -f tsconfig.lp5.json + test -f scripts/lp6-source-fixture.ts + test -f scripts/lp6-backup-worker.ts + test -f scripts/lp6-restore-worker.ts + test -f scripts/lp6-compose-audit.ts + test -f scripts/lp6-compose-smoke.ts + test -f tsconfig.lp6.json + test -f src/backup/backup-contract.ts + test -f src/backup/backup-contract.test.ts + test -f src/backup/index.ts + test -f src/backup/backup-contract.mjs + test -f src/lifecycle/retention.ts + test -f src/lifecycle/retention.test.ts + test -f src/lifecycle/retention.mjs + test -f src/lifecycle/account-lifecycle.ts + test -f src/lifecycle/account-lifecycle.test.ts + test -f src/lifecycle/account-lifecycle.mjs test -f src/capacity/capacity-contract.ts test -f src/capacity/capacity-contract.test.ts test -f src/ops/patch/status.ts @@ -332,6 +350,11 @@ jobs: scripts/lp5-compose-smoke.ts \ scripts/lp5-capacity-smoke.ts \ scripts/lp5-proof-check.ts \ + scripts/lp6-source-fixture.ts \ + scripts/lp6-backup-worker.ts \ + scripts/lp6-restore-worker.ts \ + scripts/lp6-compose-audit.ts \ + scripts/lp6-compose-smoke.ts \ scripts/m10-release-audit.mjs \ .gitignore \ .github/workflows/commit-tests.yml \ @@ -447,9 +470,13 @@ jobs: grep -Fq 'REPLAYED_CODE' src/auth/totp.ts grep -Fq 'challenge' src/auth/webauthn.ts grep -Fq 'One-time recovery' src/auth/recovery-codes.ts - grep -Fq 'DEFAULT_TRASH_RETENTION_DAYS = 28' src/lifecycle/retention.mjs - grep -Fq "BACKUP_ENCRYPTION_ALGORITHM = 'aes-256-gcm'" src/backup/backup-contract.mjs - grep -Fq 'PURGE_REJECTED' src/lifecycle/retention.mjs + # LP6 canonicals live in TypeScript; the MJS files are compatibility + # bridges and must only re-export the typed implementation. + grep -Fq 'DEFAULT_TRASH_RETENTION_DAYS = 28' src/lifecycle/retention.ts + grep -Fq "BACKUP_ENCRYPTION_ALGORITHM = 'aes-256-gcm'" src/backup/backup-contract.ts + grep -Fq 'PURGE_REJECTED' src/lifecycle/retention.ts + grep -Fq "export * from './retention.ts'" src/lifecycle/retention.mjs + grep -Fq "export * from './backup-contract.ts'" src/backup/backup-contract.mjs grep -Fq 'createLogRotationPolicy' src/observability/log-policy.ts grep -Fq 'createStructuredEvent' src/observability/structured-event.ts grep -Fq 'createAlertPolicy' src/observability/alert-policy.ts @@ -468,6 +495,14 @@ jobs: grep -Fq 'profiles: ["lp5"]' compose.yaml grep -Fq 'profiles: ["lp5-check"]' compose.yaml grep -Fq 'lp5-patch-state:/var/lib/gulogulo/patch:ro' compose.yaml + grep -Fq 'gulogulo-lp6-backup' compose.yaml + grep -Fq 'profiles: ["lp6"]' compose.yaml + grep -Fq 'profiles: ["lp6-check"]' compose.yaml + grep -Fq 'lp6-backup-data:/var/lib/gulogulo/lp6-backup:ro' compose.yaml + grep -Fq "BACKUP_ENCRYPTION_ALGORITHM = 'aes-256-gcm'" src/backup/backup-contract.ts + grep -Fq "export * from './backup-contract.ts'" src/backup/backup-contract.mjs + grep -Fq 'DEFAULT_TRASH_RETENTION_DAYS = 28' src/lifecycle/retention.ts + grep -Fq "export * from './retention.ts'" src/lifecycle/retention.mjs grep -Fq 'MIGRATION_PHASES' src/upgrade/compatibility.mjs grep -Fq 'createSchemaMigrationPlan' src/upgrade/compatibility.mjs grep -Fq 'createUpgradeController' src/upgrade/control-plane.mjs @@ -515,6 +550,7 @@ jobs: node -e "const t=require('./release/lp3-local-mail.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP3' || t.proofType !== 'local_synthetic_mail' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.externalDeliveryEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.policy?.catchAll !== false || t.policy?.automaticForwarding !== false || t.policy?.scanFailureMode !== 'fail_closed' || t.policy?.trashRetentionDays !== 28 || t.protocols?.imap?.idle !== true || t.protocols?.lmtp?.quotaReservationBeforeAck !== true || t.protocols?.sieve?.redirect !== false) process.exit(1)" node -e "const t=require('./release/lp4-local-web.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP4' || t.proofType !== 'local_synthetic_web_dav' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.publicDnsRequired !== false || t.publicAcmeEnabled !== false || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.credentialsCommitted !== false || t.web?.sameOriginOnly !== true || t.session?.csrfOnAuthenticatedMutations !== true || t.dav?.tenantBoundEtags !== true || t.dav?.tenantBoundSyncTokens !== true || t.dav?.masterContentAccess !== false || t.discovery?.tenantBound !== true || t.architectureValidation?.defaultWorkflowMode !== 'amd64' || t.architectureValidation?.finalWorkflowMode !== 'multiarch' || t.architectureValidation?.finalModePlatforms?.join(',') !== 'linux/arm64' || t.architectureValidation?.arm64RequiredBeforeMergeOrRelease !== true || t.architectureValidation?.composeProofPlatform !== 'linux/amd64') process.exit(1)" node -e "const t=require('./release/lp5-local-operations-capacity.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP5' || t.proofType !== 'local_synthetic_operations_capacity' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.capacity?.claim !== 'bounded_local_proof_only' || t.capacity?.amd64Budget?.activeIdleConnections !== 8 || t.architectureValidation?.defaultWorkflowMode !== 'amd64' || t.architectureValidation?.finalWorkflowMode !== 'multiarch' || t.architectureValidation?.finalModePlatforms?.join(',') !== 'linux/arm64' || t.architectureValidation?.arm64RequiredBeforeMergeOrRelease !== true) process.exit(1)" + node -e "const t=require('./release/lp6-local-backup-dr.json'); if (t.spdxLicenseIdentifier !== 'MIT' || t.author !== 'Sythos (https://www.sythos.net)' || t.milestone !== 'LP6' || t.proofType !== 'local_synthetic_backup_restore_retention_dr' || t.networkPolicy !== 'offline_dependencies' || t.internalNetwork !== true || t.enableIpv6 !== true || t.ipFamilies?.join(',') !== 'ipv4,ipv6' || t.syntheticDataOnly !== true || t.hostPortsPublished !== false || t.dockerSocketMounted !== false || t.recovery?.metadataEncryption !== 'aes-256-gcm' || t.recovery?.checksumAlgorithm !== 'sha256' || t.recovery?.retentionDays !== 28 || t.recovery?.rpoMinutes !== 15 || t.recovery?.rtoMinutes !== 60 || t.recovery?.failedRestorePreservesSource !== true || t.recovery?.holdsPreventPurge !== true || t.recovery?.idempotentPurge !== true || t.architectureValidation?.defaultWorkflowMode !== 'amd64' || t.architectureValidation?.composeProofPlatform !== 'linux/amd64' || t.architectureValidation?.finalWorkflowMode !== 'multiarch' || t.architectureValidation?.finalModePlatforms?.join(',') !== 'linux/arm64' || t.architectureValidation?.arm64RequiredBeforeMergeOrRelease !== true) process.exit(1)" bash -n docker/lp1-network/entrypoint-ca.sh bash -n docker/lp1-network/entrypoint-dns.sh bash -n docker/lp2-tls/entrypoint-tls.sh @@ -690,6 +726,11 @@ jobs: shell: bash run: npm run test:lp5 + - name: Run LP6 static and typed backup/restore/DR gates + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp6-compose-audit.ts') != '' && hashFiles('src/backup/backup-contract.ts') != '' && hashFiles('src/lifecycle/retention.ts') != '' + shell: bash + run: npm run test:lp6 + # In the default amd64 mode, the Compose proofs run on the amd64 GitHub # runner before any architecture image work that could be expensive. - name: Run LP3 local mail Compose proof (amd64) @@ -709,6 +750,11 @@ jobs: shell: bash run: npm run test:lp5:docker + - name: Run LP6 local backup/restore/DR Compose proof (amd64) + if: inputs.architecture_mode != 'multiarch' && hashFiles('scripts/lp6-compose-smoke.ts') != '' && hashFiles('compose.yaml') != '' && hashFiles('src/backup/backup-contract.ts') != '' && hashFiles('src/lifecycle/retention.ts') != '' + shell: bash + run: npm run test:lp6:docker + - name: Validate LP3 mail images on amd64 if: inputs.architecture_mode != 'multiarch' && hashFiles('docker/lp3-tls/Dockerfile') != '' && hashFiles('docker/lp3-postfix/Dockerfile') != '' && hashFiles('docker/lp3-dovecot/Dockerfile') != '' && hashFiles('docker/lp3-rspamd/Dockerfile') != '' && hashFiles('docker/lp3-clamav/Dockerfile') != '' && hashFiles('docker/lp3-proof/Dockerfile') != '' shell: bash diff --git a/README.md b/README.md index b6390b0..80b9d44 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ passed the integrated GitHub AMD64 Compose proof, followed by the final ARM64 artifact and attestation gate. This remains local-proof evidence, not a claim of production capacity or external service interoperability. +LP6 is implemented at the bounded local backup, restore, retention, and +disaster-recovery boundary. Its synthetic proof is now being validated through +the AMD64-first GitHub gate, followed by the explicit ARM64 artifact gate; +the smoke harness checks the internal dual-stack network IPAM before starting +the one-shot workers, and the source fixture declares its container-internal +source path explicitly. Until both gates are green, LP6 is not marked as a +completed release milestone. This is still local synthetic evidence, not a +production backup, storage, or RPO/RTO claim. + ### Security - [x] no open relay; @@ -203,6 +212,7 @@ gulogulo/ │ ├── lp3-local-mail.md │ ├── lp4-local-web.md │ ├── lp5-local-operations-capacity.md +│ ├── lp6-local-backup-dr.md │ ├── mail-core.md │ ├── rbac-admin-mfa.md │ ├── release-readiness.md @@ -232,6 +242,11 @@ gulogulo/ │ ├── lp5-compose-audit.ts │ ├── lp5-compose-smoke.ts │ ├── lp5-proof-check.ts +│ ├── lp6-source-fixture.ts +│ ├── lp6-backup-worker.ts +│ ├── lp6-restore-worker.ts +│ ├── lp6-compose-audit.ts +│ ├── lp6-compose-smoke.ts │ ├── m10-release-audit.mjs │ ├── container-patch.sh │ └── runtime, fixture, and patch utilities @@ -242,15 +257,31 @@ gulogulo/ │ ├── lp3-local-mail.json │ ├── lp4-local-web.json │ ├── lp5-local-operations-capacity.json +│ ├── lp6-local-backup-dr.json │ └── v1-release-evidence.template.json ├── src/ │ ├── admin/ (TypeScript RBAC, delegation, quota, and admin tools) │ ├── auth/ (TypeScript password, TOTP, WebAuthn, and recovery contracts) │ ├── backup/ +│ │ ├── backup-contract.mjs +│ │ ├── backup-contract.test.mjs +│ │ ├── backup-contract.ts +│ │ ├── backup-contract.test.ts +│ │ ├── index.mjs +│ │ └── index.ts │ ├── db/migrations/ │ ├── foundation/ │ ├── integrations/ (TypeScript LDAP, PostgreSQL, tenant, and migration adapters) │ ├── lifecycle/ +│ │ ├── account-lifecycle.mjs +│ │ ├── account-lifecycle.test.mjs +│ │ ├── account-lifecycle.ts +│ │ ├── account-lifecycle.test.ts +│ │ ├── index.mjs +│ │ ├── retention.mjs +│ │ ├── retention.test.mjs +│ │ ├── retention.ts +│ │ └── retention.test.ts │ ├── mail/ │ │ ├── imap-idle.mjs │ │ ├── imap-idle.test.ts @@ -316,6 +347,7 @@ gulogulo/ ├── tsconfig.json ├── tsconfig.lp4.json ├── tsconfig.lp5.json +├── tsconfig.lp6.json └── tsconfig.server.json ~~~ diff --git a/compose.yaml b/compose.yaml index 07c343e..ff46d51 100644 --- a/compose.yaml +++ b/compose.yaml @@ -989,6 +989,108 @@ services: com.sythos.gulogulo.network-policy: offline_dependencies com.sythos.gulogulo.protocols: patch_status_only + # LP6 is an offline, synthetic backup/restore/retention rehearsal. The + # source data volume is intentionally absent from the isolated restore + # service; restore can consume only a read-only backup volume. + gulogulo-lp6-source-fixture: + build: + context: . + dockerfile: Dockerfile + args: + INSTALL_DEV: "true" + platform: ${GULOGULO_LP6_COMPOSE_PLATFORM:-linux/amd64} + profiles: ["lp6-check"] + command: ["node", "--experimental-strip-types", "scripts/lp6-source-fixture.ts"] + # Named Docker volumes are initially root-owned. These disposable local + # writers need no capabilities and have no host or socket access. + user: "0:0" + environment: + LP6_SOURCE_DIR: /var/lib/gulogulo/lp6-source + volumes: + - lp6-source-data:/var/lib/gulogulo/lp6-source + networks: + - lp6-runtime + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + init: true + restart: "no" + labels: + com.sythos.gulogulo.milestone: LP6 + com.sythos.gulogulo.proof: local_synthetic_backup_source + com.sythos.gulogulo.network-policy: offline_dependencies + + gulogulo-lp6-backup: + build: + context: . + dockerfile: Dockerfile + args: + INSTALL_DEV: "true" + platform: ${GULOGULO_LP6_COMPOSE_PLATFORM:-linux/amd64} + profiles: ["lp6"] + command: ["node", "--experimental-strip-types", "scripts/lp6-backup-worker.ts"] + user: "0:0" + environment: + LP6_SOURCE_DIR: /var/lib/gulogulo/lp6-source + LP6_BACKUP_DIR: /var/lib/gulogulo/lp6-backup + LP6_TEST_KEY_B64: ${LP6_TEST_KEY_B64} + LP6_KEY_REFERENCE: ${LP6_KEY_REFERENCE:-kms/local-synthetic/lp6-v1} + volumes: + - lp6-source-data:/var/lib/gulogulo/lp6-source:ro + - lp6-backup-data:/var/lib/gulogulo/lp6-backup + networks: + - lp6-runtime + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + init: true + restart: "no" + labels: + com.sythos.gulogulo.milestone: LP6 + com.sythos.gulogulo.proof: local_synthetic_encrypted_backup + com.sythos.gulogulo.network-policy: offline_dependencies + + gulogulo-lp6-restore: + build: + context: . + dockerfile: Dockerfile + args: + INSTALL_DEV: "true" + platform: ${GULOGULO_LP6_COMPOSE_PLATFORM:-linux/amd64} + profiles: ["lp6-check"] + command: ["node", "--experimental-strip-types", "scripts/lp6-restore-worker.ts"] + user: "0:0" + environment: + LP6_BACKUP_DIR: /var/lib/gulogulo/lp6-backup + LP6_RESTORE_DIR: /var/lib/gulogulo/lp6-restore + LP6_TEST_KEY_B64: ${LP6_TEST_KEY_B64} + volumes: + - lp6-backup-data:/var/lib/gulogulo/lp6-backup:ro + - lp6-restore-data:/var/lib/gulogulo/lp6-restore + networks: + - lp6-runtime + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + init: true + restart: "no" + labels: + com.sythos.gulogulo.milestone: LP6 + com.sythos.gulogulo.proof: local_synthetic_isolated_restore + com.sythos.gulogulo.network-policy: offline_dependencies + volumes: runtime-state: name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-runtime-state @@ -1071,6 +1173,15 @@ volumes: lp5-patch-state: name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp5-patch-state external: ${GULOGULO_LP5_VOLUMES_EXTERNAL:-false} + lp6-source-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp6-source-data + external: ${GULOGULO_LP6_VOLUMES_EXTERNAL:-false} + lp6-backup-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp6-backup-data + external: ${GULOGULO_LP6_VOLUMES_EXTERNAL:-false} + lp6-restore-data: + name: ${GULOGULO_VOLUME_PREFIX:-gulogulo}-lp6-restore-data + external: ${GULOGULO_LP6_VOLUMES_EXTERNAL:-false} networks: proof-runtime: @@ -1111,3 +1222,11 @@ networks: config: - subnet: 172.29.5.0/24 - subnet: fd42:4755:756c:7035::/64 + lp6-runtime: + name: ${GULOGULO_LP6_NETWORK:-gulogulo-lp6-runtime} + internal: true + enable_ipv6: true + ipam: + config: + - subnet: 172.29.6.0/24 + - subnet: fd42:4755:756c:7036::/64 diff --git a/doc/lp6-local-backup-dr.md b/doc/lp6-local-backup-dr.md new file mode 100644 index 0000000..8282160 --- /dev/null +++ b/doc/lp6-local-backup-dr.md @@ -0,0 +1,56 @@ + + +# LP6 local backup, restore, retention, and DR proof + +LP6 is an offline, dual-stack synthetic proof for the backup and recovery +contracts. It is not a production backup service, an external-storage +integration, an Internet reachability check, or evidence of a recovery claim +for customer data. The machine-readable boundary is +`release/lp6-local-backup-dr.json`. + +## Topology and storage boundary + +All LP6 services run on the private, internal `lp6-runtime` network with IPv4 +and IPv6 enabled. No service publishes a host port, uses host networking, +mounts the Docker socket, or needs public DNS. The Compose default platform is +`linux/amd64`; the final `multiarch` workflow remains the explicit ARM64 +artifact and provenance gate before merge or release. + +The source fixture, backup archive, and restore destination use separate named +volumes: `lp6-source-data`, `lp6-backup-data`, and `lp6-restore-data`. Set +`GULOGULO_LP6_VOLUMES_EXTERNAL=true` only after an operator has created all +three named volumes. The backup worker sees the source read-only; the restore +worker sees the backup volume read-only and never mounts the source volume. + +## What the rehearsal verifies + +The disposable source fixture contains only three deterministic synthetic +records. The backup worker creates an archive manifest with SHA-256 member +checksums and encrypts metadata with AES-256-GCM using an ephemeral runtime +test key. The key is required through `LP6_TEST_KEY_B64`, is generated by the +smoke harness, and is neither committed nor persisted in the archive. + +The restore worker decrypts the metadata, verifies every checksum, validates a +user-scoped non-overwriting restore plan, and records a metadata-only restore +result in the isolated destination. A deliberately tampered restore must fail +integrity validation; the manifest and encrypted metadata digests are compared +before and after it so a failed restore cannot alter the source archive. + +Retention uses the canonical 28-day policy. A hold blocks the first purge; +after releasing it, a single item purges at the boundary and replaying the +same operation identifier returns the identical result. The DR rehearsal +records an RPO objective of 15 minutes and RTO objective of 60 minutes, with +synthetic observations of 10 and 20 minutes respectively. + +## Commands and limits + +Run `npm run test:lp6` for the typed/static audit and `npm run test:lp6:docker` +for the amd64 Compose rehearsal. The Docker proof is intended for the GitHub +amd64 runner; it must not be described as production readiness. Actual tenant +data, key management, encrypted archive body storage, external replication, +operator recovery authorization, and a timed recovery exercise remain required +before any production RPO/RTO statement. diff --git a/package.json b/package.json index 25ec1dd..049323c 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "test:lp5": "npm run typecheck:server && npx tsc --project tsconfig.lp5.json && node --experimental-strip-types src/capacity/capacity-contract.test.ts && node --experimental-strip-types src/ops/patch/status.test.ts && node --experimental-strip-types scripts/lp5-compose-audit.ts", "test:lp5:docker": "node --experimental-strip-types scripts/lp5-compose-smoke.ts", "test:lp5:capacity": "node --experimental-strip-types scripts/lp5-capacity-smoke.ts", + "test:lp6": "npx tsc --project tsconfig.lp6.json && node --experimental-strip-types src/backup/backup-contract.test.ts && node --experimental-strip-types src/lifecycle/retention.test.ts && node --experimental-strip-types src/lifecycle/account-lifecycle.test.ts && node --experimental-strip-types scripts/lp6-compose-audit.ts", + "test:lp6:docker": "node --experimental-strip-types scripts/lp6-compose-smoke.ts", "test:m2:postgres": "node --experimental-strip-types src/integrations/postgres.integration.test.ts" }, "dependencies": { diff --git a/release/lp6-local-backup-dr.json b/release/lp6-local-backup-dr.json new file mode 100644 index 0000000..1836f5d --- /dev/null +++ b/release/lp6-local-backup-dr.json @@ -0,0 +1,64 @@ +{ + "spdxLicenseIdentifier": "MIT", + "spdxFileCopyrightText": "2026 Sythos (https://www.sythos.net)", + "author": "Sythos (https://www.sythos.net)", + "schemaVersion": 1, + "milestone": "LP6", + "proofType": "local_synthetic_backup_restore_retention_dr", + "networkPolicy": "offline_dependencies", + "networkName": "gulogulo-lp6-runtime", + "internalNetwork": true, + "enableIpv6": true, + "ipFamilies": ["ipv4", "ipv6"], + "syntheticDataOnly": true, + "hostNetwork": false, + "hostPortsPublished": false, + "dockerSocketMounted": false, + "targetPlatforms": ["linux/amd64", "linux/arm64"], + "architectureValidation": { + "defaultWorkflowMode": "amd64", + "composeProofPlatform": "linux/amd64", + "finalWorkflowMode": "multiarch", + "finalModePlatforms": ["linux/arm64"], + "arm64RequiredBeforeMergeOrRelease": true + }, + "recovery": { + "metadataEncryption": "aes-256-gcm", + "checksumAlgorithm": "sha256", + "retentionDays": 28, + "rpoMinutes": 15, + "rtoMinutes": 60, + "holdsPreventPurge": true, + "idempotentPurge": true, + "failedRestorePreservesSource": true, + "isolatedRestore": true + }, + "canonicalTypedModules": [ + "src/backup/backup-contract.ts", + "src/backup/index.ts", + "src/lifecycle/retention.ts", + "src/lifecycle/account-lifecycle.ts" + ], + "compatibilityBridges": [ + "src/backup/backup-contract.mjs", + "src/backup/index.mjs", + "src/lifecycle/retention.mjs", + "src/lifecycle/account-lifecycle.mjs" + ], + "scripts": [ + "scripts/lp6-source-fixture.ts", + "scripts/lp6-backup-worker.ts", + "scripts/lp6-restore-worker.ts", + "scripts/lp6-compose-audit.ts", + "scripts/lp6-compose-smoke.ts" + ], + "services": [ + { "name": "gulogulo-lp6-source-fixture", "role": "synthetic persistent source writer" }, + { "name": "gulogulo-lp6-backup", "role": "encrypted metadata and checksum backup worker" }, + { "name": "gulogulo-lp6-restore", "role": "isolated restore and DR rehearsal worker" } + ], + "volumes": ["lp6-source-data", "lp6-backup-data", "lp6-restore-data"], + "externalPersistentVolumes": true, + "liveDockerEvidence": "github_actions_required", + "status": "implementation_ready" +} diff --git a/scripts/lp6-backup-worker.ts b/scripts/lp6-backup-worker.ts new file mode 100644 index 0000000..b8f4ff3 --- /dev/null +++ b/scripts/lp6-backup-worker.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + createArchiveManifest, + createRecoveryObjectives, + createUserBackupScope, + encryptArchiveMetadata, + sha256Hex, + verifyArchiveManifest, +} from '../src/backup/backup-contract.ts'; + +type SyntheticSource = { + readonly schemaVersion: number; + readonly tenantId: string; + readonly userId: string; + readonly entries: Readonly>; +}; + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`LP6 backup worker requires ${name}.`); + return value; +} + +function recoveryKey(): Buffer { + const key = Buffer.from(requiredEnvironment('LP6_TEST_KEY_B64'), 'base64url'); + if (key.byteLength !== 32) throw new Error('LP6 backup worker requires a 32-byte LP6_TEST_KEY_B64.'); + return key; +} + +const sourceDirectory = requiredEnvironment('LP6_SOURCE_DIR'); +const backupDirectory = requiredEnvironment('LP6_BACKUP_DIR'); +const source = JSON.parse(await readFile(resolve(sourceDirectory, 'synthetic-source.json'), 'utf8')) as SyntheticSource; +if (source.schemaVersion !== 1 || source.tenantId !== 'acme' || source.userId !== 'alice') { + throw new Error('LP6 source fixture is not the declared synthetic tenant/user scope.'); +} + +await mkdir(backupDirectory, { recursive: true }); +const manifestPath = resolve(backupDirectory, 'archive-manifest.json'); +const envelopePath = resolve(backupDirectory, 'metadata-envelope.json'); +try { + const existing = JSON.parse(await readFile(manifestPath, 'utf8')); + const verification = verifyArchiveManifest(existing, source.entries); + if (!verification.complete) throw new Error('existing LP6 archive is incomplete'); + console.log(JSON.stringify({ milestone: 'LP6', archiveId: existing.archiveId, idempotent: true, status: 'verified' }, null, 2)); +} catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const scope = createUserBackupScope({ + session: { tenantId: source.tenantId, userId: source.userId, role: 'user' }, + resources: ['mail', 'ics', 'vcard'], + issuedAt: '2026-08-26T00:00:00.000Z', + }); + const resourceByPath = Object.freeze({ + 'mail/INBOX/0001.eml': 'mail', + 'calendar/home.ics': 'ics', + 'contacts/alice.vcf': 'vcard', + } as const); + const manifest = createArchiveManifest({ + scope, + archiveId: 'lp6-synthetic-archive-001', + createdAt: '2026-08-26T00:00:00.000Z', + entries: Object.entries(source.entries).map(([path, content]) => ({ + resource: resourceByPath[path as keyof typeof resourceByPath], + path, + bytes: Buffer.byteLength(content), + sha256: sha256Hex(content), + mediaType: 'application/octet-stream', + })), + }); + const verification = verifyArchiveManifest(manifest, source.entries); + if (!verification.complete) throw new Error('new LP6 archive verification did not complete.'); + const objectives = createRecoveryObjectives({ rpoMinutes: 15, rtoMinutes: 60, retentionDays: 28 }); + const envelope = encryptArchiveMetadata({ + archiveId: manifest.archiveId, + tenantId: manifest.tenantId, + userId: manifest.userId, + manifestSha256: manifest.manifestSha256, + recoveryObjectives: objectives, + syntheticDataOnly: true, + }, { + key: recoveryKey(), + keyReference: process.env.LP6_KEY_REFERENCE || 'kms/local-synthetic/lp6-v1', + }); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + await writeFile(envelopePath, `${JSON.stringify(envelope, null, 2)}\n`, 'utf8'); + console.log(JSON.stringify({ + milestone: 'LP6', + archiveId: manifest.archiveId, + manifestSha256: manifest.manifestSha256, + encryptedMetadata: true, + idempotent: false, + status: 'created', + }, null, 2)); +} diff --git a/scripts/lp6-compose-audit.ts b/scripts/lp6-compose-audit.ts new file mode 100644 index 0000000..e46d66a --- /dev/null +++ b/scripts/lp6-compose-audit.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +type JsonRecord = Record; + +const root = resolve(process.cwd()); +const compose = await readFile(resolve(root, 'compose.yaml'), 'utf8'); +const manifest = JSON.parse(await readFile(resolve(root, 'release/lp6-local-backup-dr.json'), 'utf8')) as JsonRecord; + +function fail(message: string): never { + throw new Error(`LP6 static audit failed: ${message}`); +} + +function equal(actual: unknown, expected: unknown, description: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(`${description}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +function requireText(haystack: string, marker: string, description = marker): void { + if (!haystack.includes(marker)) fail(`missing ${description}: ${marker}`); +} + +equal(manifest.milestone, 'LP6', 'manifest milestone'); +equal(manifest.proofType, 'local_synthetic_backup_restore_retention_dr', 'manifest proof type'); +equal(manifest.networkPolicy, 'offline_dependencies', 'manifest network policy'); +equal(manifest.internalNetwork, true, 'manifest internal network'); +equal(manifest.enableIpv6, true, 'manifest IPv6 flag'); +equal(manifest.ipFamilies, ['ipv4', 'ipv6'], 'manifest IP families'); +equal(manifest.syntheticDataOnly, true, 'manifest synthetic-data flag'); +equal(manifest.hostNetwork, false, 'manifest host-network flag'); +equal(manifest.hostPortsPublished, false, 'manifest host-port flag'); +equal(manifest.dockerSocketMounted, false, 'manifest Docker-socket flag'); +equal(manifest.targetPlatforms, ['linux/amd64', 'linux/arm64'], 'manifest target platforms'); + +const recovery = manifest.recovery as JsonRecord; +equal(recovery.retentionDays, 28, 'retention period'); +equal(recovery.rpoMinutes, 15, 'RPO'); +equal(recovery.rtoMinutes, 60, 'RTO'); +equal(recovery.failedRestorePreservesSource, true, 'failed restore preservation'); +equal(recovery.holdsPreventPurge, true, 'hold policy'); +equal(recovery.idempotentPurge, true, 'purge idempotency'); +const architecture = manifest.architectureValidation as JsonRecord; +equal(architecture.defaultWorkflowMode, 'amd64', 'default architecture mode'); +equal(architecture.composeProofPlatform, 'linux/amd64', 'Compose proof platform'); +equal(architecture.finalWorkflowMode, 'multiarch', 'final architecture mode'); +equal(architecture.finalModePlatforms, ['linux/arm64'], 'final architecture platforms'); +equal(architecture.arm64RequiredBeforeMergeOrRelease, true, 'final arm64 gate'); + +const servicesStart = compose.indexOf('\nservices:'); +const volumesStart = compose.indexOf('\nvolumes:', servicesStart); +if (servicesStart < 0 || volumesStart < 0) fail('Compose services or volumes section is missing'); +const services = compose.slice(servicesStart, volumesStart); +const lp6Start = services.search(/^ gulogulo-lp6-source-fixture:$/mu); +if (lp6Start < 0) fail('LP6 service section is missing'); +const lp6 = services.slice(lp6Start); +const sourceFixtureEnd = lp6.indexOf('\n gulogulo-lp6-backup:'); +if (sourceFixtureEnd < 0) fail('LP6 source fixture service boundary is missing'); +const sourceFixture = lp6.slice(0, sourceFixtureEnd); +for (const marker of [ + 'gulogulo-lp6-source-fixture:', 'gulogulo-lp6-backup:', 'gulogulo-lp6-restore:', + 'profiles: ["lp6"]', 'profiles: ["lp6-check"]', + 'platform: ${GULOGULO_LP6_COMPOSE_PLATFORM:-linux/amd64}', + 'lp6-source-data:/var/lib/gulogulo/lp6-source:ro', + 'lp6-backup-data:/var/lib/gulogulo/lp6-backup:ro', + 'lp6-restore-data:/var/lib/gulogulo/lp6-restore', + 'com.sythos.gulogulo.milestone: LP6', + 'com.sythos.gulogulo.network-policy: offline_dependencies', +]) requireText(lp6, marker); +requireText(sourceFixture, 'LP6_SOURCE_DIR: /var/lib/gulogulo/lp6-source', 'LP6 source fixture source directory'); +if (/\n\s+ports:/mu.test(lp6)) fail('LP6 services must not publish host ports'); +if (/docker\.sock|network_mode:\s*host|privileged:\s*true/mu.test(lp6)) fail('LP6 topology contains a Docker socket, host network, or privileged service'); + +for (const marker of ['lp6-source-data:', 'lp6-backup-data:', 'lp6-restore-data:', 'external: ${GULOGULO_LP6_VOLUMES_EXTERNAL:-false}', 'lp6-runtime:', '172.29.6.0/24', 'fd42:4755:756c:7036::/64']) { + requireText(compose, marker, `LP6 Compose marker ${marker}`); +} +for (const path of [ + ...(manifest.canonicalTypedModules as string[]), + ...(manifest.scripts as string[]), +]) { + const source = await readFile(resolve(root, path), 'utf8'); + requireText(source, 'SPDX-License-Identifier: MIT', `${path} SPDX marker`); + if (/^\s*\/\/\s*@ts-nocheck/mu.test(source)) fail(`${path} disables TypeScript checking`); +} + +console.log(JSON.stringify({ + milestone: manifest.milestone, + proofType: manifest.proofType, + volumes: manifest.volumes, + recovery, + architectureValidation: architecture, + status: manifest.status, +}, null, 2)); diff --git a/scripts/lp6-compose-smoke.ts b/scripts/lp6-compose-smoke.ts new file mode 100644 index 0000000..6cb0ee5 --- /dev/null +++ b/scripts/lp6-compose-smoke.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { randomBytes } from 'node:crypto'; +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { isIP } from 'node:net'; +import { inspect } from 'node:util'; + +type JsonRecord = Record; + +const runId = String(process.env.GITHUB_RUN_ID || Date.now()).replace(/[^0-9]/gu, '') || 'local'; +const project = `gulogulo-lp6-${runId}`; +const network = `gulogulo-lp6-network-${runId}`; +const volumePrefix = `gulogulo-lp6-${runId}`; +const composeBase = ['compose', '--project-name', project, '--file', 'compose.yaml']; +const environment: NodeJS.ProcessEnv = { + ...process.env, + GULOGULO_VOLUME_PREFIX: volumePrefix, + GULOGULO_LP6_VOLUMES_EXTERNAL: 'false', + GULOGULO_LP6_NETWORK: network, + GULOGULO_LP6_COMPOSE_PLATFORM: 'linux/amd64', + LP6_TEST_KEY_B64: randomBytes(32).toString('base64url'), +}; + +function execute(args: string[], { capture = false, allowFailure = false } = {}): SpawnSyncReturns { + const result = spawnSync('docker', args, { + cwd: process.cwd(), env: environment, encoding: 'utf8', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0 && !allowFailure) throw new Error(`Docker command failed (${result.status}): docker ${args.join(' ')}`); + return result; +} + +function compose(args: string[], options?: { capture?: boolean; allowFailure?: boolean }): SpawnSyncReturns { + return execute([...composeBase, ...args], options); +} + +function serviceContainer(service: string): string { + return compose(['ps', '-aq', service], { capture: true }).stdout.trim().split(/\r?\n/gu).filter(Boolean)[0] || ''; +} + +function inspectContainer(container: string): JsonRecord { + return JSON.parse(execute(['inspect', container], { capture: true }).stdout)[0] as JsonRecord; +} + +function assertSafeContainer(container: JsonRecord, service: string): void { + const host = (container.HostConfig || {}) as JsonRecord; + if (host.NetworkMode === 'host' || host.Privileged === true) throw new Error(`${service} uses unsafe host or privileged execution.`); + if (Object.values(host.PortBindings as JsonRecord || {}).flat().filter(Boolean).length !== 0) throw new Error(`${service} publishes a host port.`); + for (const mount of (container.Mounts || []) as JsonRecord[]) { + if (/docker\.sock/iu.test(`${mount.Source || ''} ${mount.Destination || ''}`)) throw new Error(`${service} mounts the Docker socket.`); + } +} + +function assertInternalDualStackNetwork(networkDetails: JsonRecord): void { + if (networkDetails.Internal !== true || networkDetails.EnableIPv6 !== true) { + throw new Error('LP6 network is not internal with IPv6 enabled.'); + } + const ipam = (networkDetails.IPAM || {}) as JsonRecord; + const configurations = Array.isArray(ipam.Config) ? ipam.Config as JsonRecord[] : []; + const addressFamilies = configurations + .map((configuration) => configuration.Subnet) + .filter((subnet): subnet is string => typeof subnet === 'string') + .map((subnet) => isIP(subnet.split('/', 1)[0])); + if (!addressFamilies.includes(4) || !addressFamilies.includes(6)) { + throw new Error('LP6 network IPAM does not provide both IPv4 and IPv6 subnets.'); + } +} + +function assertVolume(container: JsonRecord, destination: string, writable: boolean): void { + const mount = ((container.Mounts || []) as JsonRecord[]).find((candidate) => candidate.Destination === destination); + if (!mount || mount.Type !== 'volume' || mount.RW !== writable) throw new Error(`LP6 expected ${writable ? 'writable' : 'read-only'} volume at ${destination}.`); +} + +let created = false; +try { + compose(['--profile', 'lp6', '--profile', 'lp6-check', 'config', '--quiet']); + compose(['--profile', 'lp6', '--profile', 'lp6-check', 'build', '--pull', 'gulogulo-lp6-source-fixture', 'gulogulo-lp6-backup', 'gulogulo-lp6-restore']); + compose(['--profile', 'lp6', '--profile', 'lp6-check', 'create']); + created = true; + const networkDetails = JSON.parse(execute(['network', 'inspect', network], { capture: true }).stdout)[0] as JsonRecord; + assertInternalDualStackNetwork(networkDetails); + for (const service of ['gulogulo-lp6-source-fixture', 'gulogulo-lp6-backup', 'gulogulo-lp6-restore']) { + assertSafeContainer(inspectContainer(serviceContainer(service)), service); + } + const backup = inspectContainer(serviceContainer('gulogulo-lp6-backup')); + const restore = inspectContainer(serviceContainer('gulogulo-lp6-restore')); + assertVolume(backup, '/var/lib/gulogulo/lp6-source', false); + assertVolume(backup, '/var/lib/gulogulo/lp6-backup', true); + assertVolume(restore, '/var/lib/gulogulo/lp6-backup', false); + assertVolume(restore, '/var/lib/gulogulo/lp6-restore', true); + if (((restore.Mounts || []) as JsonRecord[]).some((mount) => mount.Destination === '/var/lib/gulogulo/lp6-source')) { + throw new Error('LP6 isolated restore can access the source volume.'); + } + + compose(['--profile', 'lp6-check', 'run', '--rm', '--no-deps', 'gulogulo-lp6-source-fixture']); + compose(['--profile', 'lp6', 'run', '--rm', '--no-deps', 'gulogulo-lp6-backup']); + compose(['--profile', 'lp6-check', 'run', '--rm', '--no-deps', 'gulogulo-lp6-restore']); + // Re-running the source-independent backup proves the persisted archive + // path is idempotent before a second isolated restore rehearsal. + compose(['--profile', 'lp6', 'run', '--rm', '--no-deps', 'gulogulo-lp6-backup']); + compose(['--profile', 'lp6-check', 'run', '--rm', '--no-deps', 'gulogulo-lp6-restore']); + + console.log(JSON.stringify({ + milestone: 'LP6', project, network, networkInternal: true, networkIpv6: true, + backupIdempotent: true, encryptedMetadataVerified: true, checksumsVerified: true, + isolatedRestore: true, failedRestoreSourcePreserved: true, retentionDays: 28, + holdsPreventPurge: true, purgeIdempotent: true, rpoMinutes: 15, rtoMinutes: 60, + hostPortsPublished: false, dockerSocketMounted: false, composeProofPlatform: 'linux/amd64', status: 'pass', + }, null, 2)); +} catch (error) { + compose(['logs', '--no-color', '--tail', '160', 'gulogulo-lp6-source-fixture', 'gulogulo-lp6-backup', 'gulogulo-lp6-restore'], { allowFailure: true }); + throw new Error(`${(error as Error).message}\n${inspect(error, { depth: 2 })}`); +} finally { + if (created) compose(['--profile', 'lp6', '--profile', 'lp6-check', 'down', '--volumes', '--remove-orphans'], { allowFailure: true }); +} diff --git a/scripts/lp6-restore-worker.ts b/scripts/lp6-restore-worker.ts new file mode 100644 index 0000000..dea0adc --- /dev/null +++ b/scripts/lp6-restore-worker.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + createDrRehearsalRecord, + createRecoveryObjectives, + createRestorePlan, + createUserBackupScope, + decryptArchiveMetadata, + sha256Hex, + validateRestorePlan, + verifyArchiveManifest, +} from '../src/backup/backup-contract.ts'; +import { createRetentionStore } from '../src/lifecycle/retention.ts'; + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`LP6 restore worker requires ${name}.`); + return value; +} + +function recoveryKey(): Buffer { + const key = Buffer.from(requiredEnvironment('LP6_TEST_KEY_B64'), 'base64url'); + if (key.byteLength !== 32) throw new Error('LP6 restore worker requires a 32-byte LP6_TEST_KEY_B64.'); + return key; +} + +const sourceEntries = Object.freeze({ + 'mail/INBOX/0001.eml': 'From: alice@example.test\nSubject: LP6 synthetic backup\n\nSynthetic only.\n', + 'calendar/home.ics': 'BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n', + 'contacts/alice.vcf': 'BEGIN:VCARD\nVERSION:4.0\nFN:Alice\nEND:VCARD\n', +}); +const backupDirectory = requiredEnvironment('LP6_BACKUP_DIR'); +const restoreDirectory = requiredEnvironment('LP6_RESTORE_DIR'); +const manifestRaw = await readFile(resolve(backupDirectory, 'archive-manifest.json'), 'utf8'); +const envelopeRaw = await readFile(resolve(backupDirectory, 'metadata-envelope.json'), 'utf8'); +const manifest = JSON.parse(manifestRaw); +const envelope = JSON.parse(envelopeRaw); +const sourceArchiveDigest = sha256Hex({ manifestRaw, envelopeRaw }); +const metadata = decryptArchiveMetadata(envelope, { key: recoveryKey() }); +const scope = createUserBackupScope({ + session: { tenantId: 'acme', userId: 'alice', role: 'user' }, + resources: ['mail', 'ics', 'vcard'], + issuedAt: '2026-08-26T00:00:00.000Z', +}); +const integrity = verifyArchiveManifest(manifest, sourceEntries); +if (!integrity.complete || metadata.archiveId !== manifest.archiveId || metadata.manifestSha256 !== manifest.manifestSha256) { + throw new Error('LP6 encrypted metadata or archive checksum verification failed.'); +} +const plan = createRestorePlan({ + manifest, + scope, + target: { tenantId: 'acme', userId: 'alice', role: 'user' }, + requestedResources: ['mail', 'ics', 'vcard'], +}); +const restored = validateRestorePlan(plan, { + manifest, + scope, + target: { tenantId: 'acme', userId: 'alice', role: 'user' }, + contentByPath: sourceEntries, +}); + +let failedRestoreRejected = false; +try { + validateRestorePlan(plan, { + manifest, + scope, + target: { tenantId: 'acme', userId: 'alice', role: 'user' }, + contentByPath: { ...sourceEntries, 'mail/INBOX/0001.eml': 'tampered synthetic content' }, + }); +} catch (error) { + failedRestoreRejected = (error as { code?: string }).code === 'INTEGRITY_FAILED'; +} +if (!failedRestoreRejected) throw new Error('LP6 tampered restore was not rejected.'); +const sourcePreserved = sourceArchiveDigest === sha256Hex({ + manifestRaw: await readFile(resolve(backupDirectory, 'archive-manifest.json'), 'utf8'), + envelopeRaw: await readFile(resolve(backupDirectory, 'metadata-envelope.json'), 'utf8'), +}); +if (!sourcePreserved) throw new Error('LP6 failed restore changed the source archive.'); + +let current = new Date('2026-08-26T00:00:00.000Z'); +const retention = createRetentionStore({ now: () => current }); +retention.markDeleted({ + tenantId: 'acme', userId: 'alice', itemId: 'lp6-retention-object', resourceType: 'backup', + deletedAt: '2026-07-29T00:00:00.000Z', idempotencyKey: 'lp6-delete-001', +}); +retention.addHold({ tenantId: 'acme', userId: 'alice', itemId: 'lp6-retention-object', holdId: 'lp6-hold-001', reasonCode: 'dr_rehearsal' }); +const held = retention.runPurgeBatch({ workerId: 'lp6-worker', operationId: 'lp6-purge-held' }); +if (held.purged !== 0) throw new Error('LP6 retention hold did not prevent the 28-day purge.'); +retention.releaseHold({ tenantId: 'acme', userId: 'alice', itemId: 'lp6-retention-object', holdId: 'lp6-hold-001' }); +const purged = retention.runPurgeBatch({ workerId: 'lp6-worker', operationId: 'lp6-purge-001' }); +const repeatedPurge = retention.runPurgeBatch({ workerId: 'lp6-worker', operationId: 'lp6-purge-001' }); +if (purged.purged !== 1 || JSON.stringify(purged) !== JSON.stringify(repeatedPurge)) { + throw new Error('LP6 28-day purge is not held and idempotent as required.'); +} +const objectives = createRecoveryObjectives({ rpoMinutes: 15, rtoMinutes: 60, retentionDays: 28 }); +const rehearsal = createDrRehearsalRecord({ + rehearsalId: 'lp6-rehearsal-001', tenantId: 'acme', archiveId: manifest.archiveId, objectives, + startedAt: '2026-08-26T00:00:00.000Z', endedAt: '2026-08-26T00:20:00.000Z', outcome: 'passed', + observedRpoMinutes: 10, observedRtoMinutes: 20, integrityVerified: true, privacyVerified: true, + evidenceSha256: sourceArchiveDigest, +}); + +await mkdir(restoreDirectory, { recursive: true }); +await writeFile(resolve(restoreDirectory, 'lp6-restore-result.json'), `${JSON.stringify({ + milestone: 'LP6', archiveId: manifest.archiveId, restoreStatus: restored.status, + encryptedMetadataVerified: true, checksumsVerified: integrity.complete, isolatedRestore: true, + failedRestoreRejected, sourcePreserved, retentionDays: 28, heldPurgePrevented: true, + purgeIdempotent: true, rpoMinutes: objectives.rpoMinutes, rtoMinutes: objectives.rtoMinutes, + observedRpoMinutes: rehearsal.observedRpoMinutes, observedRtoMinutes: rehearsal.observedRtoMinutes, + syntheticDataOnly: true, status: 'pass', +}, null, 2)}\n`, 'utf8'); +console.log(JSON.stringify({ milestone: 'LP6', archiveId: manifest.archiveId, status: 'pass' }, null, 2)); diff --git a/scripts/lp6-source-fixture.ts b/scripts/lp6-source-fixture.ts new file mode 100644 index 0000000..f5227a1 --- /dev/null +++ b/scripts/lp6-source-fixture.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const sourceDirectory = process.env.LP6_SOURCE_DIR; +if (!sourceDirectory) throw new Error('LP6 source fixture requires LP6_SOURCE_DIR.'); + +const fixture = Object.freeze({ + schemaVersion: 1, + tenantId: 'acme', + userId: 'alice', + entries: Object.freeze({ + 'mail/INBOX/0001.eml': 'From: alice@example.test\nSubject: LP6 synthetic backup\n\nSynthetic only.\n', + 'calendar/home.ics': 'BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n', + 'contacts/alice.vcf': 'BEGIN:VCARD\nVERSION:4.0\nFN:Alice\nEND:VCARD\n', + }), +}); + +await mkdir(sourceDirectory, { recursive: true }); +await writeFile(resolve(sourceDirectory, 'synthetic-source.json'), `${JSON.stringify(fixture)}\n`, 'utf8'); +console.log(JSON.stringify({ milestone: 'LP6', fixture: 'synthetic-source', status: 'written' }, null, 2)); diff --git a/src/backup/backup-contract.mjs b/src/backup/backup-contract.mjs index 7e91276..a004d5c 100644 --- a/src/backup/backup-contract.mjs +++ b/src/backup/backup-contract.mjs @@ -2,715 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -import { - createCipheriv, - createDecipheriv, - createHash, - randomBytes, - randomUUID, - timingSafeEqual, -} from 'node:crypto'; - -export const BACKUP_SCHEMA_VERSION = 1; -export const BACKUP_ENVELOPE_VERSION = 1; -export const BACKUP_LINK_TTL_MAX_MS = 24 * 60 * 60 * 1000; -export const BACKUP_RESOURCE_TYPES = Object.freeze([ - 'mail', - 'folders', - 'ics', - 'vcard', - 'preferences', -]); -export const BACKUP_PROVIDER_OPERATIONS = Object.freeze(['snapshot', 'restore']); -export const BACKUP_ENCRYPTION_ALGORITHM = 'aes-256-gcm'; - -const RESOURCE_TYPE_SET = new Set(BACKUP_RESOURCE_TYPES); -const PROVIDER_OPERATION_SET = new Set(BACKUP_PROVIDER_OPERATIONS); -const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; -const SAFE_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u; -const SAFE_ARCHIVE_PATH_PATTERN = /^(?!\/)(?!.*(?:^|\/)(?:\.|\.\.)\/?)[A-Za-z0-9._@+()\-/]{1,512}$/u; -const SHA256_PATTERN = /^[a-f0-9]{64}$/u; -const LINK_ID_PATTERN = /^[0-9a-f-]{36}$/u; -const SENSITIVE_FIELD_PATTERN = /^(?:session(?:id|token|secret)?|session[_-].*|access[_-]?token|refresh[_-]?token|cookie|authorization|password|passphrase|private[_-]?key|credential(?:s)?|secret)$/iu; -const DEFAULT_LINK_BASE_URL = 'https://download.invalid'; -const MIN_AES_KEY_BYTES = 32; -const AES_IV_BYTES = 12; - -function backupError(message, code = 'BACKUP_CONTRACT_ERROR') { - const error = new Error(`Backup contract error: ${message}`); - error.code = code; - return error; -} - -function isPlainObject(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function assertPlainObject(value, name) { - if (!isPlainObject(value)) throw backupError(`${name} must be an object`, 'INVALID_CONTRACT'); -} - -function assertId(value, name) { - if (typeof value !== 'string' || !ID_PATTERN.test(value)) { - throw backupError(`${name} is invalid`, 'INVALID_IDENTITY'); - } - return value; -} - -function assertReference(value, name) { - if (typeof value !== 'string' || !SAFE_REFERENCE_PATTERN.test(value)) { - throw backupError(`${name} is invalid`, 'INVALID_REFERENCE'); - } - return value; -} - -function assertDate(value, name) { - const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); - if (Number.isNaN(date.getTime())) throw backupError(`${name} is invalid`, 'INVALID_TIMESTAMP'); - return date; -} - -function assertInteger(value, name, minimum, maximum) { - if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { - throw backupError(`${name} must be an integer between ${minimum} and ${maximum}`, 'INVALID_NUMBER'); - } - return value; -} - -function assertResourceList(resources, name = 'resources') { - if (!Array.isArray(resources) || resources.length === 0) { - throw backupError(`${name} must be a non-empty array`, 'INVALID_RESOURCES'); - } - const unique = [...new Set(resources)]; - if (unique.some((resource) => typeof resource !== 'string' || !RESOURCE_TYPE_SET.has(resource))) { - throw backupError(`${name} contains an unsupported resource`, 'INVALID_RESOURCES'); - } - return Object.freeze(unique); -} - -function assertSafeMetadata(value, path = 'metadata') { - if (Array.isArray(value)) { - value.forEach((item, index) => assertSafeMetadata(item, `${path}[${index}]`)); - return value; - } - if (isPlainObject(value)) { - for (const [key, nestedValue] of Object.entries(value)) { - if (SENSITIVE_FIELD_PATTERN.test(key)) { - throw backupError(`${path}.${key} is not allowed in backup metadata`, 'SESSION_SECRET_FORBIDDEN'); - } - assertSafeMetadata(nestedValue, `${path}.${key}`); - } - return value; - } - if ( - value !== null - && typeof value !== 'string' - && typeof value !== 'number' - && typeof value !== 'boolean' - ) { - throw backupError(`${path} contains an unsupported value`, 'INVALID_METADATA'); - } - return value; -} - -function canonicalize(value) { - if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - if (Array.isArray(value)) return value.map((item) => canonicalize(item)); - if (isPlainObject(value)) { - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, canonicalize(value[key])]), - ); - } - throw backupError('value cannot be represented canonically', 'INVALID_METADATA'); -} - -export function canonicalJson(value) { - assertSafeMetadata(value); - return JSON.stringify(canonicalize(value)); -} - -export function sha256Hex(value) { - const input = Buffer.isBuffer(value) || value instanceof Uint8Array - ? value - : Buffer.from(typeof value === 'string' ? value : canonicalJson(value), 'utf8'); - return createHash('sha256').update(input).digest('hex'); -} - -function assertChecksum(value, name = 'checksum') { - if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { - throw backupError(`${name} must be a lowercase SHA-256 digest`, 'INVALID_CHECKSUM'); - } - return value; -} - -function normalizeIdentity(identity, name = 'identity') { - assertPlainObject(identity, name); - const role = identity.role ?? 'user'; - if (typeof role !== 'string' || !['user', 'provider', 'tenant_master', 'monitor'].includes(role)) { - throw backupError(`${name}.role is invalid`, 'INVALID_IDENTITY'); - } - return Object.freeze({ - tenantId: assertId(identity.tenantId, `${name}.tenantId`), - userId: identity.userId === null || identity.userId === undefined - ? null - : assertId(identity.userId, `${name}.userId`), - role, - }); -} - -function assertNoSessionSecrets(value) { - try { - assertSafeMetadata(value); - } catch (error) { - if (error.code === 'SESSION_SECRET_FORBIDDEN') throw error; - throw error; - } -} - -/** - * Authorize a user backup without copying a session or its secrets into the - * durable backup scope. A normal user can only target that same user. - */ -export function createUserBackupScope({ - session, - targetUserId = session?.userId, - resources = BACKUP_RESOURCE_TYPES, - issuedAt = new Date(), - expiresAt = null, -} = {}) { - const actor = normalizeIdentity(session, 'session'); - if (actor.role !== 'user') { - throw backupError('user self-service requires a user session', 'USER_SCOPE_DENIED'); - } - const target = assertId(targetUserId, 'targetUserId'); - if (target !== actor.userId) { - throw backupError('a user backup can target only the authenticated user', 'USER_SCOPE_DENIED'); - } - const issued = assertDate(issuedAt, 'issuedAt'); - const expiry = expiresAt === null ? null : assertDate(expiresAt, 'expiresAt'); - if (expiry !== null && expiry <= issued) { - throw backupError('expiresAt must be later than issuedAt', 'INVALID_TIMESTAMP'); - } - const scope = { - schemaVersion: BACKUP_SCHEMA_VERSION, - scopeType: 'user-self-service', - tenantId: actor.tenantId, - userId: actor.userId, - resources: assertResourceList(resources), - issuedAt: issued.toISOString(), - expiresAt: expiry?.toISOString() ?? null, - sessionBinding: 'request-only', - sessionSecrets: false, - }; - assertNoSessionSecrets(scope); - return Object.freeze(scope); -} - -function normalizeEntry(entry, index) { - assertPlainObject(entry, `entries[${index}]`); - if (typeof entry.resource !== 'string' || !RESOURCE_TYPE_SET.has(entry.resource)) { - throw backupError(`entries[${index}].resource is unsupported`, 'INVALID_ARCHIVE'); - } - if (typeof entry.path !== 'string' || !SAFE_ARCHIVE_PATH_PATTERN.test(entry.path)) { - throw backupError(`entries[${index}].path is unsafe`, 'INVALID_ARCHIVE_PATH'); - } - const bytes = assertInteger(entry.bytes, `entries[${index}].bytes`, 0, Number.MAX_SAFE_INTEGER); - const checksum = assertChecksum(entry.sha256, `entries[${index}].sha256`); - const normalized = { - resource: entry.resource, - path: entry.path, - bytes, - sha256: checksum, - }; - if (entry.mediaType !== undefined) { - if (typeof entry.mediaType !== 'string' || entry.mediaType.length === 0 || entry.mediaType.length > 255) { - throw backupError(`entries[${index}].mediaType is invalid`, 'INVALID_ARCHIVE'); - } - normalized.mediaType = entry.mediaType; - } - return Object.freeze(normalized); -} - -/** - * Build a metadata-only archive manifest. The caller supplies checksums for - * the encrypted archive members; member bodies never enter this object. - */ -export function createArchiveManifest({ - scope, - archiveId = randomUUID(), - entries, - createdAt = new Date(), - format = 'gulogulo-backup-tar', -} = {}) { - assertPlainObject(scope, 'scope'); - assertId(scope.tenantId, 'scope.tenantId'); - if (scope.userId !== null) assertId(scope.userId, 'scope.userId'); - assertResourceList(scope.resources, 'scope.resources'); - assertId(archiveId, 'archiveId'); - if (!Array.isArray(entries) || entries.length === 0) { - throw backupError('entries must be a non-empty array', 'INVALID_ARCHIVE'); - } - const timestamp = assertDate(createdAt, 'createdAt'); - if (typeof format !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(format)) { - throw backupError('format is invalid', 'INVALID_ARCHIVE'); - } - const normalizedEntries = Object.freeze(entries.map(normalizeEntry)); - const manifest = { - schemaVersion: BACKUP_SCHEMA_VERSION, - archiveId, - format, - tenantId: scope.tenantId, - userId: scope.userId, - resources: Object.freeze([...scope.resources]), - createdAt: timestamp.toISOString(), - entries: normalizedEntries, - encryptedContent: true, - }; - assertNoSessionSecrets(manifest); - const withDigest = { - ...manifest, - manifestSha256: sha256Hex(manifest), - }; - return Object.freeze(withDigest); -} - -export function verifyArchiveManifest(manifest, contentByPath = {}) { - assertPlainObject(manifest, 'manifest'); - if (manifest.schemaVersion !== BACKUP_SCHEMA_VERSION) { - throw backupError('manifest schemaVersion is unsupported', 'INVALID_ARCHIVE'); - } - if (typeof manifest.manifestSha256 !== 'string') { - throw backupError('manifestSha256 is required', 'INVALID_ARCHIVE'); - } - const { manifestSha256, ...withoutDigest } = manifest; - if (sha256Hex(withoutDigest) !== manifestSha256) { - throw backupError('manifest digest does not match', 'INTEGRITY_FAILED'); - } - const results = []; - for (const entry of manifest.entries) { - const data = contentByPath[entry.path]; - if (data === undefined) { - results.push(Object.freeze({ path: entry.path, status: 'not-present' })); - continue; - } - const actual = sha256Hex(data); - if (actual !== entry.sha256) { - throw backupError(`checksum mismatch for ${entry.path}`, 'INTEGRITY_FAILED'); - } - const bytes = Buffer.byteLength(data); - if (bytes !== entry.bytes) { - throw backupError(`size mismatch for ${entry.path}`, 'INTEGRITY_FAILED'); - } - results.push(Object.freeze({ path: entry.path, status: 'verified', sha256: actual, bytes })); - } - return Object.freeze({ - archiveId: manifest.archiveId, - manifestSha256, - entries: Object.freeze(results), - complete: results.every((result) => result.status === 'verified'), - }); -} - -function assertEncryptionKey(key) { - if (!(Buffer.isBuffer(key) || key instanceof Uint8Array) || key.byteLength !== MIN_AES_KEY_BYTES) { - throw backupError('encryption key must contain exactly 32 bytes', 'INVALID_ENCRYPTION_KEY'); - } - return Buffer.from(key); -} - -function assertKeyReference(value) { - return assertReference(value, 'keyReference'); -} - -/** - * Encrypt metadata in-process with an externally managed 256-bit key. The - * key is never returned, serialized, or placed in the envelope. Production - * callers should resolve it from a KMS/secret provider using keyReference. - */ -export function encryptArchiveMetadata(metadata, { - key, - keyReference, - aad = 'gulogulo-backup-metadata-v1', - random = randomBytes, -} = {}) { - assertPlainObject(metadata, 'metadata'); - assertNoSessionSecrets(metadata); - const encryptionKey = assertEncryptionKey(key); - const reference = assertKeyReference(keyReference); - if (typeof aad !== 'string' || aad.length === 0 || aad.length > 128) { - throw backupError('aad is invalid', 'INVALID_ENCRYPTION'); - } - const iv = random(AES_IV_BYTES); - if (!(iv instanceof Uint8Array) || iv.byteLength !== AES_IV_BYTES) { - throw backupError('random source returned an invalid IV', 'INVALID_ENCRYPTION'); - } - const cipher = createCipheriv(BACKUP_ENCRYPTION_ALGORITHM, encryptionKey, iv); - cipher.setAAD(Buffer.from(aad, 'utf8')); - const plaintext = Buffer.from(canonicalJson(metadata), 'utf8'); - const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); - const envelope = { - envelopeVersion: BACKUP_ENVELOPE_VERSION, - algorithm: BACKUP_ENCRYPTION_ALGORITHM, - keyReference: reference, - aad, - iv: Buffer.from(iv).toString('base64url'), - authTag: cipher.getAuthTag().toString('base64url'), - ciphertext: ciphertext.toString('base64url'), - plaintextSha256: sha256Hex(plaintext), - }; - assertNoSessionSecrets(envelope); - return Object.freeze(envelope); -} - -export function decryptArchiveMetadata(envelope, { key } = {}) { - assertPlainObject(envelope, 'envelope'); - if (envelope.envelopeVersion !== BACKUP_ENVELOPE_VERSION || envelope.algorithm !== BACKUP_ENCRYPTION_ALGORITHM) { - throw backupError('unsupported archive envelope', 'INVALID_ENCRYPTION'); - } - const encryptionKey = assertEncryptionKey(key); - assertKeyReference(envelope.keyReference); - if (typeof envelope.aad !== 'string' || typeof envelope.iv !== 'string' || typeof envelope.authTag !== 'string' || typeof envelope.ciphertext !== 'string') { - throw backupError('archive envelope is incomplete', 'INVALID_ENCRYPTION'); - } - let plaintext; - try { - const decipher = createDecipheriv( - BACKUP_ENCRYPTION_ALGORITHM, - encryptionKey, - Buffer.from(envelope.iv, 'base64url'), - ); - decipher.setAAD(Buffer.from(envelope.aad, 'utf8')); - decipher.setAuthTag(Buffer.from(envelope.authTag, 'base64url')); - plaintext = Buffer.concat([ - decipher.update(Buffer.from(envelope.ciphertext, 'base64url')), - decipher.final(), - ]); - } catch { - throw backupError('archive metadata authentication failed', 'INTEGRITY_FAILED'); - } - if (sha256Hex(plaintext) !== envelope.plaintextSha256) { - throw backupError('archive metadata checksum mismatch', 'INTEGRITY_FAILED'); - } - let metadata; - try { - metadata = JSON.parse(plaintext.toString('utf8')); - } catch { - throw backupError('archive metadata is not valid JSON', 'INVALID_ENCRYPTION'); - } - assertNoSessionSecrets(metadata); - return Object.freeze(metadata); -} - -function normalizeLinkBaseUrl(value) { - const baseUrl = value ?? DEFAULT_LINK_BASE_URL; - if (typeof baseUrl !== 'string') throw backupError('baseUrl is invalid', 'INVALID_LINK'); - let parsed; - try { - parsed = new URL(baseUrl); - } catch { - throw backupError('baseUrl is invalid', 'INVALID_LINK'); - } - if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash) { - throw backupError('backup links require an HTTPS base URL without credentials', 'INVALID_LINK'); - } - return parsed; -} - -/** Create an opaque, expiring link record; only the caller receives the token. */ -export function createBackupLink({ - archiveId, - scope, - baseUrl = DEFAULT_LINK_BASE_URL, - issuedAt = new Date(), - expiresAt = null, - ttlMs = 15 * 60 * 1000, - token = randomBytes(32).toString('base64url'), -} = {}) { - assertId(archiveId, 'archiveId'); - assertPlainObject(scope, 'scope'); - const tenantId = assertId(scope.tenantId, 'scope.tenantId'); - const userId = scope.userId === null ? null : assertId(scope.userId, 'scope.userId'); - const resources = assertResourceList(scope.resources, 'scope.resources'); - const issued = assertDate(issuedAt, 'issuedAt'); - const expiry = expiresAt === null - ? new Date(issued.getTime() + assertInteger(ttlMs, 'ttlMs', 1_000, BACKUP_LINK_TTL_MAX_MS)) - : assertDate(expiresAt, 'expiresAt'); - if (expiry <= issued || expiry.getTime() - issued.getTime() > BACKUP_LINK_TTL_MAX_MS) { - throw backupError('backup link expiry is outside the allowed window', 'INVALID_LINK'); - } - if (typeof token !== 'string' || token.length < 32 || token.length > 512) { - throw backupError('token is invalid', 'INVALID_LINK'); - } - const linkId = randomUUID(); - const url = normalizeLinkBaseUrl(baseUrl); - url.pathname = `${url.pathname.replace(/\/$/u, '')}/backup/${linkId}`; - url.searchParams.set('token', token); - const record = { - schemaVersion: BACKUP_SCHEMA_VERSION, - linkId, - archiveId, - tenantId, - userId, - resources, - issuedAt: issued.toISOString(), - expiresAt: expiry.toISOString(), - revokedAt: null, - tokenSha256: sha256Hex(token), - href: url.toString(), - oneTime: false, - }; - // The token is intentionally returned separately and must not be persisted - // in archive metadata or audit records. - const result = { - record: Object.freeze(record), - token, - }; - assertNoSessionSecrets(result.record); - return Object.freeze(result); -} - -export function revokeBackupLink(link, { revokedAt = new Date(), reason = 'operator-revoked' } = {}) { - assertPlainObject(link, 'link'); - if (typeof link.linkId !== 'string' || !LINK_ID_PATTERN.test(link.linkId)) throw backupError('linkId is invalid', 'INVALID_LINK'); - const revoked = assertDate(revokedAt, 'revokedAt'); - if (typeof reason !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(reason)) { - throw backupError('reason is invalid', 'INVALID_LINK'); - } - return Object.freeze({ ...link, revokedAt: revoked.toISOString(), revokeReason: reason }); -} - -export function assertBackupLinkUsable(link, { token, now = new Date() } = {}) { - assertPlainObject(link, 'link'); - const current = assertDate(now, 'now'); - if (link.revokedAt !== null) throw backupError('backup link has been revoked', 'LINK_REVOKED'); - if (current >= assertDate(link.expiresAt, 'link.expiresAt')) throw backupError('backup link has expired', 'LINK_EXPIRED'); - if (typeof token !== 'string') throw backupError('link token is required', 'LINK_TOKEN_INVALID'); - const expected = Buffer.from(assertChecksum(link.tokenSha256, 'link.tokenSha256'), 'hex'); - const actual = Buffer.from(sha256Hex(token), 'hex'); - if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { - throw backupError('link token is invalid', 'LINK_TOKEN_INVALID'); - } - return true; -} - -/** - * Create a provider scope for encrypted tenant-level copies. Providers may - * move ciphertext and metadata for DR, but receive no user session, cookie, - * bearer token, or plaintext decryption capability. - */ -export function createProviderBackupScope({ - providerId, - tenantId, - resources = BACKUP_RESOURCE_TYPES, - operations = BACKUP_PROVIDER_OPERATIONS, - encryptionKeyReference, - issuedAt = new Date(), - expiresAt, -} = {}) { - const normalizedProviderId = assertId(providerId, 'providerId'); - const normalizedTenantId = assertId(tenantId, 'tenantId'); - const normalizedResources = assertResourceList(resources); - if (!Array.isArray(operations) || operations.length === 0 || operations.some((operation) => !PROVIDER_OPERATION_SET.has(operation))) { - throw backupError('operations are invalid', 'INVALID_PROVIDER_SCOPE'); - } - const normalizedOperations = Object.freeze([...new Set(operations)]); - const reference = assertKeyReference(encryptionKeyReference); - const issued = assertDate(issuedAt, 'issuedAt'); - const expiry = assertDate(expiresAt, 'expiresAt'); - if (expiry <= issued) throw backupError('expiresAt must be later than issuedAt', 'INVALID_TIMESTAMP'); - const scope = { - schemaVersion: BACKUP_SCHEMA_VERSION, - scopeType: 'provider-encrypted-tenant', - providerId: normalizedProviderId, - tenantId: normalizedTenantId, - userId: null, - resources: normalizedResources, - operations: normalizedOperations, - issuedAt: issued.toISOString(), - expiresAt: expiry.toISOString(), - encryptedContentOnly: true, - plaintextAccess: false, - sessionAccess: false, - sessionSecrets: false, - encryptionKeyReference: reference, - }; - assertNoSessionSecrets(scope); - return Object.freeze(scope); -} - -function assertScopeTarget(scope, target) { - assertPlainObject(scope, 'scope'); - const normalizedTarget = normalizeIdentity(target, 'target'); - if (scope.tenantId !== normalizedTarget.tenantId) { - throw backupError('restore target crosses tenant boundary', 'TENANT_SCOPE_DENIED'); - } - if (scope.scopeType === 'user-self-service' && scope.userId !== normalizedTarget.userId) { - throw backupError('restore target crosses user boundary', 'USER_SCOPE_DENIED'); - } - if (scope.scopeType !== 'user-self-service' && scope.scopeType !== 'provider-encrypted-tenant') { - throw backupError('scope type is not restorable', 'INVALID_SCOPE'); - } - if (scope.scopeType === 'provider-encrypted-tenant' && normalizedTarget.role !== 'provider') { - throw backupError('provider scope requires a provider restore actor', 'PROVIDER_SCOPE_DENIED'); - } - return normalizedTarget; -} - -/** Build a privacy-checked restore plan without opening archive member bodies. */ -export function createRestorePlan({ - manifest, - scope, - target, - requestedResources = scope?.resources, - overwrite = false, - plannedAt = new Date(), -} = {}) { - assertPlainObject(manifest, 'manifest'); - assertPlainObject(scope, 'scope'); - assertScopeTarget(scope, target); - if (manifest.tenantId !== scope.tenantId || manifest.userId !== scope.userId) { - throw backupError('manifest scope does not match restore scope', 'SCOPE_MISMATCH'); - } - const resources = assertResourceList(requestedResources, 'requestedResources'); - if (resources.some((resource) => !scope.resources.includes(resource))) { - throw backupError('restore requests a resource outside the authorized scope', 'RESOURCE_SCOPE_DENIED'); - } - if (typeof overwrite !== 'boolean') throw backupError('overwrite must be boolean', 'INVALID_RESTORE_PLAN'); - const timestamp = assertDate(plannedAt, 'plannedAt'); - const plan = { - schemaVersion: BACKUP_SCHEMA_VERSION, - planType: 'restore', - archiveId: manifest.archiveId, - tenantId: scope.tenantId, - userId: scope.userId, - resources, - overwrite, - integrityRequired: true, - privacyValidated: true, - plannedAt: timestamp.toISOString(), - status: 'ready-for-integrity-check', - }; - assertNoSessionSecrets(plan); - return Object.freeze(plan); -} - -export function validateRestorePlan(plan, { - manifest, - scope, - target, - contentByPath = {}, -} = {}) { - assertPlainObject(plan, 'plan'); - assertPlainObject(manifest, 'manifest'); - const actor = assertScopeTarget(scope, target); - if (plan.archiveId !== manifest.archiveId || plan.tenantId !== scope.tenantId || plan.userId !== scope.userId) { - throw backupError('restore plan identity does not match its archive scope', 'SCOPE_MISMATCH'); - } - if (actor.role === 'user' && plan.overwrite) { - throw backupError('user restores cannot overwrite existing data', 'RESTORE_POLICY_DENIED'); - } - const integrity = verifyArchiveManifest(manifest, contentByPath); - if (!integrity.complete) { - throw backupError('all archive members must be present before restore', 'INTEGRITY_INCOMPLETE'); - } - return Object.freeze({ - ...plan, - status: 'validated', - integrity, - privacyValidated: true, - validatedAt: new Date().toISOString(), - }); -} - -export function createRecoveryObjectives({ - rpoMinutes = 15, - rtoMinutes = 60, - retentionDays = 28, - measuredFrom = 'last-successful-backup', -} = {}) { - assertInteger(rpoMinutes, 'rpoMinutes', 1, 525_600); - assertInteger(rtoMinutes, 'rtoMinutes', 1, 525_600); - if (rtoMinutes < rpoMinutes) throw backupError('rtoMinutes cannot be lower than rpoMinutes', 'INVALID_RECOVERY_OBJECTIVES'); - assertInteger(retentionDays, 'retentionDays', 28, 36_500); - if (!['last-successful-backup', 'last-replicated-checkpoint'].includes(measuredFrom)) { - throw backupError('measuredFrom is invalid', 'INVALID_RECOVERY_OBJECTIVES'); - } - return Object.freeze({ - schemaVersion: BACKUP_SCHEMA_VERSION, - objectiveType: 'tenant-disaster-recovery', - rpoMinutes, - rtoMinutes, - retentionDays, - measuredFrom, - }); -} - -/** Record metadata from a DR rehearsal; it contains no mailbox/DAV bodies. */ -export function createDrRehearsalRecord({ - rehearsalId = randomUUID(), - tenantId, - archiveId, - objectives, - startedAt, - endedAt, - outcome, - observedRpoMinutes, - observedRtoMinutes, - integrityVerified, - privacyVerified, - evidenceSha256, - runbookVersion = '1', -} = {}) { - assertId(rehearsalId, 'rehearsalId'); - assertId(tenantId, 'tenantId'); - assertId(archiveId, 'archiveId'); - assertPlainObject(objectives, 'objectives'); - assertInteger(objectives.rpoMinutes, 'objectives.rpoMinutes', 1, 525_600); - assertInteger(objectives.rtoMinutes, 'objectives.rtoMinutes', 1, 525_600); - if (objectives.rtoMinutes < objectives.rpoMinutes) { - throw backupError('objectives.rtoMinutes cannot be lower than objectives.rpoMinutes', 'INVALID_REHEARSAL'); - } - const started = assertDate(startedAt, 'startedAt'); - const ended = assertDate(endedAt, 'endedAt'); - if (ended < started) throw backupError('endedAt must not precede startedAt', 'INVALID_REHEARSAL'); - if (!['passed', 'failed', 'inconclusive'].includes(outcome)) throw backupError('outcome is invalid', 'INVALID_REHEARSAL'); - assertInteger(observedRpoMinutes, 'observedRpoMinutes', 0, 525_600); - assertInteger(observedRtoMinutes, 'observedRtoMinutes', 0, 525_600); - if (typeof integrityVerified !== 'boolean' || typeof privacyVerified !== 'boolean') { - throw backupError('integrityVerified and privacyVerified must be boolean', 'INVALID_REHEARSAL'); - } - if (evidenceSha256 !== undefined) assertChecksum(evidenceSha256, 'evidenceSha256'); - if (typeof runbookVersion !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/u.test(runbookVersion)) { - throw backupError('runbookVersion is invalid', 'INVALID_REHEARSAL'); - } - const passed = outcome === 'passed'; - if (passed && (!integrityVerified || !privacyVerified || observedRpoMinutes > objectives.rpoMinutes || observedRtoMinutes > objectives.rtoMinutes)) { - throw backupError('a passed rehearsal must satisfy integrity, privacy, RPO, and RTO objectives', 'INVALID_REHEARSAL'); - } - const record = { - schemaVersion: BACKUP_SCHEMA_VERSION, - rehearsalId, - tenantId, - archiveId, - startedAt: started.toISOString(), - endedAt: ended.toISOString(), - durationMinutes: Math.ceil((ended.getTime() - started.getTime()) / 60_000), - outcome, - observedRpoMinutes, - observedRtoMinutes, - integrityVerified, - privacyVerified, - evidenceSha256: evidenceSha256 ?? null, - runbookVersion, - }; - assertNoSessionSecrets(record); - return Object.freeze(record); -} - -export { backupError, assertNoSessionSecrets }; +// Temporary compatibility bridge. Backup contract behavior lives in TypeScript. +export * from './backup-contract.ts'; diff --git a/src/backup/backup-contract.test.mjs b/src/backup/backup-contract.test.mjs index 220479b..7cc62f9 100644 --- a/src/backup/backup-contract.test.mjs +++ b/src/backup/backup-contract.test.mjs @@ -2,197 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - assertBackupLinkUsable, - createArchiveManifest, - createBackupLink, - createDrRehearsalRecord, - createProviderBackupScope, - createRecoveryObjectives, - createRestorePlan, - createUserBackupScope, - decryptArchiveMetadata, - encryptArchiveMetadata, - revokeBackupLink, - sha256Hex, - validateRestorePlan, - verifyArchiveManifest, -} from './backup-contract.mjs'; - -const session = Object.freeze({ - tenantId: 'acme', - userId: 'alice', - role: 'user', - sessionId: 'must-never-be-copied', - accessToken: 'must-never-be-copied', -}); -const key = Buffer.alloc(32, 7); -const manifestEntries = [ - { resource: 'mail', path: 'mail/INBOX/0001.eml', bytes: 10, sha256: sha256Hex('hello mail') }, - { resource: 'ics', path: 'calendar/home.ics', bytes: 11, sha256: sha256Hex('hello event') }, -]; - -function createScope() { - return createUserBackupScope({ - session, - resources: ['mail', 'folders', 'ics', 'vcard', 'preferences'], - issuedAt: '2026-08-22T10:00:00Z', - }); -} - -test('user scope is self-scoped and excludes session secrets', () => { - const scope = createScope(); - assert.equal(scope.tenantId, 'acme'); - assert.equal(scope.userId, 'alice'); - assert.deepEqual(scope.resources, ['mail', 'folders', 'ics', 'vcard', 'preferences']); - assert.equal(Object.hasOwn(scope, 'sessionId'), false); - assert.equal(Object.hasOwn(scope, 'accessToken'), false); - assert.throws( - () => createUserBackupScope({ session, targetUserId: 'bob' }), - (error) => error.code === 'USER_SCOPE_DENIED', - ); -}); - -test('archive manifest has member checksums and rejects unsafe paths', () => { - const manifest = createArchiveManifest({ scope: createScope(), entries: manifestEntries, archiveId: 'archive-001' }); - assert.equal(manifest.manifestSha256.length, 64); - assert.equal(verifyArchiveManifest(manifest, { - 'mail/INBOX/0001.eml': 'hello mail', - 'calendar/home.ics': 'hello event', - }).complete, true); - assert.throws( - () => createArchiveManifest({ scope: createScope(), entries: [{ ...manifestEntries[0], path: '../escape.eml' }] }), - (error) => error.code === 'INVALID_ARCHIVE_PATH', - ); - assert.throws( - () => verifyArchiveManifest(manifest, { 'mail/INBOX/0001.eml': 'tampered', 'calendar/home.ics': 'hello event' }), - (error) => error.code === 'INTEGRITY_FAILED', - ); -}); - -test('archive metadata is encrypted and authenticated without serializing the key', () => { - const metadata = Object.freeze({ archiveId: 'archive-001', tenantId: 'acme', userId: 'alice', resources: ['mail'] }); - const envelope = encryptArchiveMetadata(metadata, { - key, - keyReference: 'kms/gulogulo/backup-key-v1', - random: () => Buffer.alloc(12, 3), - }); - assert.equal(envelope.algorithm, 'aes-256-gcm'); - assert.equal(Object.hasOwn(envelope, 'key'), false); - assert.deepEqual(decryptArchiveMetadata(envelope, { key }), metadata); - assert.throws( - () => decryptArchiveMetadata({ ...envelope, ciphertext: `${envelope.ciphertext}x` }, { key }), - (error) => error.code === 'INTEGRITY_FAILED', - ); - assert.throws( - () => encryptArchiveMetadata({ accessToken: 'not allowed' }, { key, keyReference: 'kms/key' }), - (error) => error.code === 'SESSION_SECRET_FORBIDDEN', - ); -}); - -test('backup links expire and can be revoked', () => { - const created = createBackupLink({ - archiveId: 'archive-001', - scope: createScope(), - baseUrl: 'https://backup.example.test/download', - issuedAt: '2026-08-22T10:00:00Z', - ttlMs: 60_000, - token: 'opaque-token-that-is-long-enough-for-tests', - }); - assert.match(created.record.href, /^https:\/\/backup\.example\.test\/download\/backup\//u); - assert.equal(assertBackupLinkUsable(created.record, { token: created.token, now: '2026-08-22T10:00:30Z' }), true); - assert.throws( - () => assertBackupLinkUsable(created.record, { token: created.token, now: '2026-08-22T10:01:00Z' }), - (error) => error.code === 'LINK_EXPIRED', - ); - const revoked = revokeBackupLink(created.record, { revokedAt: '2026-08-22T10:00:10Z' }); - assert.throws( - () => assertBackupLinkUsable(revoked, { token: created.token, now: '2026-08-22T10:00:20Z' }), - (error) => error.code === 'LINK_REVOKED', - ); -}); - -test('provider scope moves encrypted tenant data without sessions or plaintext access', () => { - const scope = createProviderBackupScope({ - providerId: 'provider-a', - tenantId: 'acme', - encryptionKeyReference: 'kms/gulogulo/tenant-acme', - issuedAt: '2026-08-22T10:00:00Z', - expiresAt: '2026-08-23T10:00:00Z', - }); - assert.equal(scope.userId, null); - assert.equal(scope.encryptedContentOnly, true); - assert.equal(scope.plaintextAccess, false); - assert.equal(scope.sessionAccess, false); - assert.equal(Object.hasOwn(scope, 'sessionId'), false); - assert.throws( - () => createProviderBackupScope({ providerId: 'provider-a', tenantId: 'acme', encryptionKeyReference: 'kms/key', expiresAt: '2026-08-21T10:00:00Z' }), - (error) => error.code === 'INVALID_TIMESTAMP', - ); -}); - -test('restore plan validates scope, integrity, and user overwrite policy', () => { - const scope = createScope(); - const manifest = createArchiveManifest({ scope, entries: manifestEntries, archiveId: 'archive-001' }); - const plan = createRestorePlan({ manifest, scope, target: { tenantId: 'acme', userId: 'alice', role: 'user' }, requestedResources: ['mail', 'ics'] }); - const result = validateRestorePlan(plan, { - manifest, - scope, - target: { tenantId: 'acme', userId: 'alice', role: 'user' }, - contentByPath: { 'mail/INBOX/0001.eml': 'hello mail', 'calendar/home.ics': 'hello event' }, - }); - assert.equal(result.status, 'validated'); - assert.throws( - () => createRestorePlan({ manifest, scope, target: { tenantId: 'acme', userId: 'bob', role: 'user' } }), - (error) => error.code === 'USER_SCOPE_DENIED', - ); - assert.throws( - () => validateRestorePlan({ ...plan, overwrite: true }, { - manifest, - scope, - target: { tenantId: 'acme', userId: 'alice', role: 'user' }, - contentByPath: { 'mail/INBOX/0001.eml': 'hello mail', 'calendar/home.ics': 'hello event' }, - }), - (error) => error.code === 'RESTORE_POLICY_DENIED', - ); -}); - -test('DR objectives and rehearsal record enforce RPO/RTO and privacy evidence', () => { - const objectives = createRecoveryObjectives({ rpoMinutes: 15, rtoMinutes: 60, retentionDays: 28 }); - const record = createDrRehearsalRecord({ - tenantId: 'acme', - archiveId: 'archive-001', - objectives, - startedAt: '2026-08-22T10:00:00Z', - endedAt: '2026-08-22T10:20:00Z', - outcome: 'passed', - observedRpoMinutes: 10, - observedRtoMinutes: 20, - integrityVerified: true, - privacyVerified: true, - evidenceSha256: sha256Hex('rehearsal-evidence'), - }); - assert.equal(record.durationMinutes, 20); - assert.throws( - () => createRecoveryObjectives({ rpoMinutes: 60, rtoMinutes: 15 }), - (error) => error.code === 'INVALID_RECOVERY_OBJECTIVES', - ); - assert.throws( - () => createDrRehearsalRecord({ - tenantId: 'acme', - archiveId: 'archive-001', - objectives, - startedAt: '2026-08-22T10:00:00Z', - endedAt: '2026-08-22T10:20:00Z', - outcome: 'passed', - observedRpoMinutes: 20, - observedRtoMinutes: 20, - integrityVerified: true, - privacyVerified: true, - }), - (error) => error.code === 'INVALID_REHEARSAL', - ); -}); +// Temporary compatibility bridge. Backup contract tests are TypeScript. +import './backup-contract.test.ts'; diff --git a/src/backup/backup-contract.test.ts b/src/backup/backup-contract.test.ts new file mode 100644 index 0000000..ffa2a91 --- /dev/null +++ b/src/backup/backup-contract.test.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertBackupLinkUsable, + createArchiveManifest, + createBackupLink, + createDrRehearsalRecord, + createProviderBackupScope, + createRecoveryObjectives, + createRestorePlan, + createUserBackupScope, + decryptArchiveMetadata, + encryptArchiveMetadata, + revokeBackupLink, + sha256Hex, + validateRestorePlan, + verifyArchiveManifest, +} from './backup-contract.ts'; + +const session = Object.freeze({ tenantId: 'acme', userId: 'alice', role: 'user' as const, sessionId: 'never-persist', accessToken: 'never-persist' }); +const key = Buffer.alloc(32, 7); +const members = Object.freeze([ + { resource: 'mail' as const, path: 'mail/INBOX/0001.eml', bytes: 10, sha256: sha256Hex('hello mail') }, + { resource: 'ics' as const, path: 'calendar/home.ics', bytes: 11, sha256: sha256Hex('hello event') }, +]); +const hasCode = (code: string) => (error: unknown): boolean => typeof error === 'object' && error !== null && 'code' in error && error.code === code; +function userScope() { return createUserBackupScope({ session, issuedAt: '2026-08-22T10:00:00Z' }); } +function manifest() { return createArchiveManifest({ scope: userScope(), entries: members, archiveId: 'archive-001' }); } +function content() { return { 'mail/INBOX/0001.eml': 'hello mail', 'calendar/home.ics': 'hello event' }; } + +test('LP6 user backup scope is self-scoped and never persists request credentials', () => { + const scope = userScope(); + assert.deepEqual(scope.resources, ['mail', 'folders', 'ics', 'vcard', 'preferences']); + assert.equal(Object.hasOwn(scope, 'sessionId'), false); + assert.equal(Object.hasOwn(scope, 'accessToken'), false); + assert.throws(() => createUserBackupScope({ session, targetUserId: 'bob' }), hasCode('USER_SCOPE_DENIED')); +}); + +test('LP6 provider scope moves tenant ciphertext only and requires a bounded authority', () => { + const scope = createProviderBackupScope({ providerId: 'provider-a', tenantId: 'acme', encryptionKeyReference: 'kms/acme', issuedAt: '2026-08-22T10:00:00Z', expiresAt: '2026-08-23T10:00:00Z' }); + assert.deepEqual({ userId: scope.userId, encrypted: scope.encryptedContentOnly, plaintext: scope.plaintextAccess, sessions: scope.sessionAccess }, { userId: null, encrypted: true, plaintext: false, sessions: false }); + assert.throws(() => createProviderBackupScope({ providerId: 'provider-a', tenantId: 'acme', encryptionKeyReference: 'kms/acme', expiresAt: '2026-08-21T10:00:00Z' }), hasCode('INVALID_TIMESTAMP')); +}); + +test('LP6 manifests bind all members to checksums and fail closed on traversal or tampering', () => { + const archive = manifest(); + assert.equal(verifyArchiveManifest(archive, content()).complete, true); + assert.throws(() => createArchiveManifest({ scope: userScope(), entries: [{ ...members[0], path: '../escape.eml' }] }), hasCode('INVALID_ARCHIVE_PATH')); + assert.throws(() => verifyArchiveManifest(archive, { ...content(), 'mail/INBOX/0001.eml': 'tampered' }), hasCode('INTEGRITY_FAILED')); +}); + +test('LP6 metadata encryption authenticates encrypted metadata without serializing a key', () => { + const metadata = { archiveId: 'archive-001', tenantId: 'acme', userId: 'alice', resources: ['mail'] }; + const envelope = encryptArchiveMetadata(metadata, { key, keyReference: 'kms/acme', random: () => Buffer.alloc(12, 3) }); + assert.equal(envelope.algorithm, 'aes-256-gcm'); + assert.equal(Object.hasOwn(envelope, 'key'), false); + assert.deepEqual(decryptArchiveMetadata(envelope, { key }), metadata); + assert.throws(() => decryptArchiveMetadata({ ...envelope, ciphertext: `${envelope.ciphertext}x` }, { key }), hasCode('INTEGRITY_FAILED')); + assert.throws(() => encryptArchiveMetadata({ accessToken: 'forbidden' }, { key, keyReference: 'kms/acme' }), hasCode('SESSION_SECRET_FORBIDDEN')); +}); + +test('LP6 download links have short expiry, opaque-token comparison, and revocation', () => { + const created = createBackupLink({ archiveId: 'archive-001', scope: userScope(), baseUrl: 'https://backup.example.test/download', issuedAt: '2026-08-22T10:00:00Z', ttlMs: 60_000, token: 'opaque-token-that-is-long-enough-for-tests' }); + assert.match(created.record.href, /^https:\/\/backup\.example\.test\/download\/backup\//u); + assert.equal(assertBackupLinkUsable(created.record, { token: created.token, now: '2026-08-22T10:00:30Z' }), true); + assert.throws(() => assertBackupLinkUsable(created.record, { token: created.token, now: '2026-08-22T10:01:00Z' }), hasCode('LINK_EXPIRED')); + assert.throws(() => assertBackupLinkUsable(revokeBackupLink(created.record, { revokedAt: '2026-08-22T10:00:10Z' }), { token: created.token, now: '2026-08-22T10:00:20Z' }), hasCode('LINK_REVOKED')); +}); + +test('LP6 restore validates archive scope and protects users from overwrite', () => { + const scope = userScope(); const archive = manifest(); const actor = { tenantId: 'acme', userId: 'alice', role: 'user' as const }; + const plan = createRestorePlan({ manifest: archive, scope, target: actor, requestedResources: ['mail', 'ics'] }); + assert.equal(validateRestorePlan(plan, { manifest: archive, scope, target: actor, contentByPath: content() }).status, 'validated'); + assert.throws(() => createRestorePlan({ manifest: archive, scope, target: { ...actor, userId: 'bob' } }), hasCode('USER_SCOPE_DENIED')); + assert.throws(() => validateRestorePlan({ ...plan, overwrite: true }, { manifest: archive, scope, target: actor, contentByPath: content() }), hasCode('RESTORE_POLICY_DENIED')); +}); + +test('LP6 recovery objectives and rehearsal records only mark measured, private recovery passed', () => { + const objectives = createRecoveryObjectives({ rpoMinutes: 15, rtoMinutes: 60, retentionDays: 28 }); + const record = createDrRehearsalRecord({ tenantId: 'acme', archiveId: 'archive-001', objectives, startedAt: '2026-08-22T10:00:00Z', endedAt: '2026-08-22T10:20:00Z', outcome: 'passed', observedRpoMinutes: 10, observedRtoMinutes: 20, integrityVerified: true, privacyVerified: true, evidenceSha256: sha256Hex('rehearsal-evidence') }); + assert.equal(record.durationMinutes, 20); + assert.throws(() => createRecoveryObjectives({ rpoMinutes: 60, rtoMinutes: 15 }), hasCode('INVALID_RECOVERY_OBJECTIVES')); + assert.throws(() => createDrRehearsalRecord({ tenantId: 'acme', archiveId: 'archive-001', objectives, startedAt: '2026-08-22T10:00:00Z', endedAt: '2026-08-22T10:20:00Z', outcome: 'passed', observedRpoMinutes: 20, observedRtoMinutes: 20, integrityVerified: true, privacyVerified: true }), hasCode('INVALID_REHEARSAL')); +}); diff --git a/src/backup/backup-contract.ts b/src/backup/backup-contract.ts new file mode 100644 index 0000000..287dae6 --- /dev/null +++ b/src/backup/backup-contract.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; + +export const BACKUP_SCHEMA_VERSION = 1 as const; +export const BACKUP_ENVELOPE_VERSION = 1 as const; +export const BACKUP_LINK_TTL_MAX_MS = 86_400_000; +export const BACKUP_RESOURCE_TYPES = Object.freeze(['mail', 'folders', 'ics', 'vcard', 'preferences'] as const); +export const BACKUP_PROVIDER_OPERATIONS = Object.freeze(['snapshot', 'restore'] as const); +export const BACKUP_ENCRYPTION_ALGORITHM = 'aes-256-gcm' as const; +export type BackupResource = typeof BACKUP_RESOURCE_TYPES[number]; +export type ProviderBackupOperation = typeof BACKUP_PROVIDER_OPERATIONS[number]; +export type BackupRole = 'user' | 'provider' | 'tenant_master' | 'monitor'; +export type BackupDate = Date | string | number; +export interface BackupMetadataObject { readonly [key: string]: BackupMetadata; } +export type BackupMetadata = null | string | number | boolean | readonly BackupMetadata[] | BackupMetadataObject; +export interface BackupIdentity { readonly tenantId: string; readonly userId: string | null; readonly role: BackupRole } +export interface UserBackupScope { readonly schemaVersion: 1; readonly scopeType: 'user-self-service'; readonly tenantId: string; readonly userId: string; readonly resources: readonly BackupResource[]; readonly issuedAt: string; readonly expiresAt: string | null; readonly sessionBinding: 'request-only'; readonly sessionSecrets: false } +export interface ProviderBackupScope { readonly schemaVersion: 1; readonly scopeType: 'provider-encrypted-tenant'; readonly providerId: string; readonly tenantId: string; readonly userId: null; readonly resources: readonly BackupResource[]; readonly operations: readonly ProviderBackupOperation[]; readonly issuedAt: string; readonly expiresAt: string; readonly encryptedContentOnly: true; readonly plaintextAccess: false; readonly sessionAccess: false; readonly sessionSecrets: false; readonly encryptionKeyReference: string } +export type BackupScope = UserBackupScope | ProviderBackupScope; +export interface ArchiveEntry { readonly resource: BackupResource; readonly path: string; readonly bytes: number; readonly sha256: string; readonly mediaType?: string } +export interface BackupManifest { readonly schemaVersion: 1; readonly archiveId: string; readonly format: string; readonly tenantId: string; readonly userId: string | null; readonly resources: readonly BackupResource[]; readonly createdAt: string; readonly entries: readonly ArchiveEntry[]; readonly encryptedContent: true; readonly manifestSha256: string } +export interface EncryptedArchiveMetadata { readonly envelopeVersion: 1; readonly algorithm: 'aes-256-gcm'; readonly keyReference: string; readonly aad: string; readonly iv: string; readonly authTag: string; readonly ciphertext: string; readonly plaintextSha256: string } +export interface BackupLinkRecord { readonly schemaVersion: 1; readonly linkId: string; readonly archiveId: string; readonly tenantId: string; readonly userId: string | null; readonly resources: readonly BackupResource[]; readonly issuedAt: string; readonly expiresAt: string; readonly revokedAt: string | null; readonly tokenSha256: string; readonly href: string; readonly oneTime: false; readonly revokeReason?: string } +export interface RecoveryObjectives { readonly schemaVersion: 1; readonly objectiveType: 'tenant-disaster-recovery'; readonly rpoMinutes: number; readonly rtoMinutes: number; readonly retentionDays: number; readonly measuredFrom: 'last-successful-backup' | 'last-replicated-checkpoint' } +export interface RestorePlan { readonly schemaVersion: 1; readonly planType: 'restore'; readonly archiveId: string; readonly tenantId: string; readonly userId: string | null; readonly resources: readonly BackupResource[]; readonly overwrite: boolean; readonly integrityRequired: true; readonly privacyValidated: true; readonly plannedAt: string; readonly status: 'ready-for-integrity-check' | 'validated' } +type RecordValue = Record; +type BackupContractError = Error & { code: string }; +const resources = new Set(BACKUP_RESOURCE_TYPES); const operations = new Set(BACKUP_PROVIDER_OPERATIONS); +const idPattern = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; const refPattern = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u; const pathPattern = /^(?!\/)(?!.*(?:^|\/)(?:\.|\.\.)\/?)[A-Za-z0-9._@+()\-/]{1,512}$/u; const checksumPattern = /^[a-f0-9]{64}$/u; const linkIdPattern = /^[0-9a-f-]{36}$/u; const sensitivePattern = /^(?:session(?:id|token|secret)?|session[_-].*|access[_-]?token|refresh[_-]?token|cookie|authorization|password|passphrase|private[_-]?key|credential(?:s)?|secret)$/iu; +export function backupError(message: string, code = 'BACKUP_CONTRACT_ERROR'): BackupContractError { const error = new Error(`Backup contract error: ${message}`) as BackupContractError; error.code = code; return error; } +function plain(value: unknown): value is RecordValue { return value !== null && typeof value === 'object' && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); } +function object(value: unknown, name: string): asserts value is RecordValue { if (!plain(value)) throw backupError(`${name} must be an object`, 'INVALID_CONTRACT'); } +function id(value: unknown, name: string): string { if (typeof value !== 'string' || !idPattern.test(value)) throw backupError(`${name} is invalid`, 'INVALID_IDENTITY'); return value; } +function reference(value: unknown, name: string): string { if (typeof value !== 'string' || !refPattern.test(value)) throw backupError(`${name} is invalid`, 'INVALID_REFERENCE'); return value; } +function date(value: unknown, name: string): Date { const result = value instanceof Date ? new Date(value.getTime()) : new Date(value as string | number); if (Number.isNaN(result.getTime())) throw backupError(`${name} is invalid`, 'INVALID_TIMESTAMP'); return result; } +function integer(value: unknown, name: string, min: number, max: number): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max) throw backupError(`${name} must be an integer between ${min} and ${max}`, 'INVALID_NUMBER'); return value; } +function checksum(value: unknown, name = 'checksum'): string { if (typeof value !== 'string' || !checksumPattern.test(value)) throw backupError(`${name} must be a lowercase SHA-256 digest`, 'INVALID_CHECKSUM'); return value; } +function resourceList(value: unknown, name = 'resources'): readonly BackupResource[] { if (!Array.isArray(value) || value.length === 0) throw backupError(`${name} must be a non-empty array`, 'INVALID_RESOURCES'); const result = [...new Set(value)]; if (result.some((item) => typeof item !== 'string' || !resources.has(item))) throw backupError(`${name} contains an unsupported resource`, 'INVALID_RESOURCES'); return Object.freeze(result as BackupResource[]); } +export function assertNoSessionSecrets(value: unknown): void { safe(value); } +function safe(value: unknown, path = 'metadata'): void { if (Array.isArray(value)) { value.forEach((item, index) => safe(item, `${path}[${index}]`)); return; } if (plain(value)) { for (const [key, nested] of Object.entries(value)) { if (sensitivePattern.test(key)) throw backupError(`${path}.${key} is not allowed in backup metadata`, 'SESSION_SECRET_FORBIDDEN'); safe(nested, `${path}.${key}`); } return; } if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) throw backupError(`${path} contains an unsupported value`, 'INVALID_METADATA'); } +function canonical(value: unknown): BackupMetadata { if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return value as null | string | number | boolean; if (Array.isArray(value)) return value.map(canonical); if (plain(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) as Readonly>; throw backupError('value cannot be represented canonically', 'INVALID_METADATA'); } +export function canonicalJson(value: unknown): string { safe(value); return JSON.stringify(canonical(value)); } +export function sha256Hex(value: unknown): string { const input = Buffer.isBuffer(value) || value instanceof Uint8Array ? value : Buffer.from(typeof value === 'string' ? value : canonicalJson(value), 'utf8'); return createHash('sha256').update(input).digest('hex'); } +function identity(value: unknown, name = 'identity'): Readonly { object(value, name); const role = value.role ?? 'user'; if (role !== 'user' && role !== 'provider' && role !== 'tenant_master' && role !== 'monitor') throw backupError(`${name}.role is invalid`, 'INVALID_IDENTITY'); return Object.freeze({ tenantId: id(value.tenantId, `${name}.tenantId`), userId: value.userId === null || value.userId === undefined ? null : id(value.userId, `${name}.userId`), role }); } + +export interface UserScopeOptions { readonly session?: BackupIdentity | RecordValue | null; readonly targetUserId?: string; readonly resources?: readonly BackupResource[]; readonly issuedAt?: BackupDate; readonly expiresAt?: BackupDate | null } +export function createUserBackupScope({ session, targetUserId = plain(session) ? session.userId as string : undefined, resources: requested = BACKUP_RESOURCE_TYPES, issuedAt = new Date(), expiresAt = null }: UserScopeOptions = {}): Readonly { const actor = identity(session, 'session'); if (actor.role !== 'user') throw backupError('user self-service requires a user session', 'USER_SCOPE_DENIED'); const target = id(targetUserId, 'targetUserId'); if (target !== actor.userId) throw backupError('a user backup can target only the authenticated user', 'USER_SCOPE_DENIED'); const issued = date(issuedAt, 'issuedAt'); const expiry = expiresAt === null ? null : date(expiresAt, 'expiresAt'); if (expiry !== null && expiry <= issued) throw backupError('expiresAt must be later than issuedAt', 'INVALID_TIMESTAMP'); const scope: UserBackupScope = { schemaVersion: 1, scopeType: 'user-self-service', tenantId: actor.tenantId, userId: actor.userId as string, resources: resourceList(requested), issuedAt: issued.toISOString(), expiresAt: expiry?.toISOString() ?? null, sessionBinding: 'request-only', sessionSecrets: false }; safe(scope); return Object.freeze(scope); } +function entry(value: unknown, index: number): Readonly { object(value, `entries[${index}]`); if (typeof value.resource !== 'string' || !resources.has(value.resource)) throw backupError(`entries[${index}].resource is unsupported`, 'INVALID_ARCHIVE'); if (typeof value.path !== 'string' || !pathPattern.test(value.path)) throw backupError(`entries[${index}].path is unsafe`, 'INVALID_ARCHIVE_PATH'); const result: { resource: BackupResource; path: string; bytes: number; sha256: string; mediaType?: string } = { resource: value.resource as BackupResource, path: value.path, bytes: integer(value.bytes, `entries[${index}].bytes`, 0, Number.MAX_SAFE_INTEGER), sha256: checksum(value.sha256, `entries[${index}].sha256`) }; if (value.mediaType !== undefined) { if (typeof value.mediaType !== 'string' || value.mediaType.length === 0 || value.mediaType.length > 255) throw backupError(`entries[${index}].mediaType is invalid`, 'INVALID_ARCHIVE'); result.mediaType = value.mediaType; } return Object.freeze(result); } +export function createArchiveManifest({ scope, archiveId = randomUUID(), entries, createdAt = new Date(), format = 'gulogulo-backup-tar' }: { readonly scope?: BackupScope | RecordValue; readonly archiveId?: string; readonly entries?: readonly ArchiveEntry[]; readonly createdAt?: BackupDate; readonly format?: string } = {}): Readonly { object(scope, 'scope'); const tenantId = id(scope.tenantId, 'scope.tenantId'); const userId = scope.userId === null ? null : id(scope.userId, 'scope.userId'); const scopeResources = resourceList(scope.resources, 'scope.resources'); id(archiveId, 'archiveId'); if (!Array.isArray(entries) || entries.length === 0) throw backupError('entries must be a non-empty array', 'INVALID_ARCHIVE'); const created = date(createdAt, 'createdAt'); if (typeof format !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(format)) throw backupError('format is invalid', 'INVALID_ARCHIVE'); const base = { schemaVersion: 1 as const, archiveId, format, tenantId, userId, resources: Object.freeze([...scopeResources]), createdAt: created.toISOString(), entries: Object.freeze(entries.map(entry)), encryptedContent: true as const }; safe(base); return Object.freeze({ ...base, manifestSha256: sha256Hex(base) }); } +export function verifyArchiveManifest(manifest: BackupManifest, contentByPath: Readonly> = {}): Readonly<{ archiveId: string; manifestSha256: string; entries: readonly Readonly<{ path: string; status: 'not-present' | 'verified'; sha256?: string; bytes?: number }>[]; complete: boolean }> { object(manifest, 'manifest'); if (manifest.schemaVersion !== 1) throw backupError('manifest schemaVersion is unsupported', 'INVALID_ARCHIVE'); if (typeof manifest.manifestSha256 !== 'string') throw backupError('manifestSha256 is required', 'INVALID_ARCHIVE'); const { manifestSha256, ...base } = manifest; if (sha256Hex(base) !== manifestSha256) throw backupError('manifest digest does not match', 'INTEGRITY_FAILED'); const found: Readonly<{ path: string; status: 'not-present' | 'verified'; sha256?: string; bytes?: number }>[] = []; for (const member of manifest.entries) { const content = contentByPath[member.path]; if (content === undefined) { found.push(Object.freeze({ path: member.path, status: 'not-present' as const })); continue; } const actual = sha256Hex(content); if (actual !== member.sha256) throw backupError(`checksum mismatch for ${member.path}`, 'INTEGRITY_FAILED'); const bytes = Buffer.byteLength(content); if (bytes !== member.bytes) throw backupError(`size mismatch for ${member.path}`, 'INTEGRITY_FAILED'); found.push(Object.freeze({ path: member.path, status: 'verified' as const, sha256: actual, bytes })); } return Object.freeze({ archiveId: manifest.archiveId, manifestSha256, entries: Object.freeze(found), complete: found.every((member) => member.status === 'verified') }); } +function key(value: unknown): Buffer { if (!(Buffer.isBuffer(value) || value instanceof Uint8Array) || value.byteLength !== 32) throw backupError('encryption key must contain exactly 32 bytes', 'INVALID_ENCRYPTION_KEY'); return Buffer.from(value); } +export function encryptArchiveMetadata(metadata: Readonly>, { key: input, keyReference, aad = 'gulogulo-backup-metadata-v1', random = randomBytes }: { readonly key?: Uint8Array; readonly keyReference?: string; readonly aad?: string; readonly random?: (size: number) => Uint8Array } = {}): Readonly { object(metadata, 'metadata'); safe(metadata); const encryptionKey = key(input); const reference = referenceKey(keyReference); if (typeof aad !== 'string' || aad.length === 0 || aad.length > 128) throw backupError('aad is invalid', 'INVALID_ENCRYPTION'); const iv = random(12); if (!(iv instanceof Uint8Array) || iv.byteLength !== 12) throw backupError('random source returned an invalid IV', 'INVALID_ENCRYPTION'); const cipher = createCipheriv(BACKUP_ENCRYPTION_ALGORITHM, encryptionKey, iv); cipher.setAAD(Buffer.from(aad)); const plaintext = Buffer.from(canonicalJson(metadata)); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); const envelope: EncryptedArchiveMetadata = { envelopeVersion: 1, algorithm: BACKUP_ENCRYPTION_ALGORITHM, keyReference: reference, aad, iv: Buffer.from(iv).toString('base64url'), authTag: cipher.getAuthTag().toString('base64url'), ciphertext: ciphertext.toString('base64url'), plaintextSha256: sha256Hex(plaintext) }; safe(envelope); return Object.freeze(envelope); } +function referenceKey(value: unknown): string { return reference(value, 'keyReference'); } +export function decryptArchiveMetadata(envelope: EncryptedArchiveMetadata, { key: input }: { readonly key?: Uint8Array } = {}): Readonly> { object(envelope, 'envelope'); if (envelope.envelopeVersion !== 1 || envelope.algorithm !== BACKUP_ENCRYPTION_ALGORITHM) throw backupError('unsupported archive envelope', 'INVALID_ENCRYPTION'); const encryptionKey = key(input); referenceKey(envelope.keyReference); if (typeof envelope.aad !== 'string' || typeof envelope.iv !== 'string' || typeof envelope.authTag !== 'string' || typeof envelope.ciphertext !== 'string') throw backupError('archive envelope is incomplete', 'INVALID_ENCRYPTION'); let plaintext: Buffer; try { const decipher = createDecipheriv(BACKUP_ENCRYPTION_ALGORITHM, encryptionKey, Buffer.from(envelope.iv, 'base64url')); decipher.setAAD(Buffer.from(envelope.aad)); decipher.setAuthTag(Buffer.from(envelope.authTag, 'base64url')); plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64url')), decipher.final()]); } catch { throw backupError('archive metadata authentication failed', 'INTEGRITY_FAILED'); } if (sha256Hex(plaintext) !== envelope.plaintextSha256) throw backupError('archive metadata checksum mismatch', 'INTEGRITY_FAILED'); let result: unknown; try { result = JSON.parse(plaintext.toString()) as unknown; } catch { throw backupError('archive metadata is not valid JSON', 'INVALID_ENCRYPTION'); } object(result, 'metadata'); safe(result); return Object.freeze(result as Record); } +export function createBackupLink({ archiveId, scope, baseUrl = 'https://download.invalid', issuedAt = new Date(), expiresAt = null, ttlMs = 900_000, token = randomBytes(32).toString('base64url') }: { readonly archiveId?: string; readonly scope?: BackupScope | RecordValue; readonly baseUrl?: string; readonly issuedAt?: BackupDate; readonly expiresAt?: BackupDate | null; readonly ttlMs?: number; readonly token?: string } = {}): Readonly<{ record: Readonly; token: string }> { id(archiveId, 'archiveId'); object(scope, 'scope'); const tenantId = id(scope.tenantId, 'scope.tenantId'); const userId = scope.userId === null ? null : id(scope.userId, 'scope.userId'); const scopeResources = resourceList(scope.resources, 'scope.resources'); const issued = date(issuedAt, 'issuedAt'); const expires = expiresAt === null ? new Date(issued.getTime() + integer(ttlMs, 'ttlMs', 1_000, BACKUP_LINK_TTL_MAX_MS)) : date(expiresAt, 'expiresAt'); if (expires <= issued || expires.getTime() - issued.getTime() > BACKUP_LINK_TTL_MAX_MS) throw backupError('backup link expiry is outside the allowed window', 'INVALID_LINK'); if (typeof token !== 'string' || token.length < 32 || token.length > 512) throw backupError('token is invalid', 'INVALID_LINK'); let url: URL; try { url = new URL(baseUrl); } catch { throw backupError('baseUrl is invalid', 'INVALID_LINK'); } if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw backupError('backup links require an HTTPS base URL without credentials', 'INVALID_LINK'); const linkId = randomUUID(); url.pathname = `${url.pathname.replace(/\/$/u, '')}/backup/${linkId}`; url.searchParams.set('token', token); const record: BackupLinkRecord = { schemaVersion: 1, linkId, archiveId: archiveId as string, tenantId, userId, resources: scopeResources, issuedAt: issued.toISOString(), expiresAt: expires.toISOString(), revokedAt: null, tokenSha256: sha256Hex(token), href: url.toString(), oneTime: false }; safe(record); return Object.freeze({ record: Object.freeze(record), token }); } +export function revokeBackupLink(link: BackupLinkRecord, { revokedAt = new Date(), reason = 'operator-revoked' }: { readonly revokedAt?: BackupDate; readonly reason?: string } = {}): Readonly { object(link, 'link'); if (typeof link.linkId !== 'string' || !linkIdPattern.test(link.linkId)) throw backupError('linkId is invalid', 'INVALID_LINK'); if (typeof reason !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(reason)) throw backupError('reason is invalid', 'INVALID_LINK'); return Object.freeze({ ...link, revokedAt: date(revokedAt, 'revokedAt').toISOString(), revokeReason: reason }); } +export function assertBackupLinkUsable(link: BackupLinkRecord, { token, now = new Date() }: { readonly token?: string; readonly now?: BackupDate } = {}): true { object(link, 'link'); if (link.revokedAt !== null) throw backupError('backup link has been revoked', 'LINK_REVOKED'); if (date(now, 'now') >= date(link.expiresAt, 'link.expiresAt')) throw backupError('backup link has expired', 'LINK_EXPIRED'); if (typeof token !== 'string') throw backupError('link token is required', 'LINK_TOKEN_INVALID'); const expected = Buffer.from(checksum(link.tokenSha256, 'link.tokenSha256'), 'hex'); const actual = Buffer.from(sha256Hex(token), 'hex'); if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw backupError('link token is invalid', 'LINK_TOKEN_INVALID'); return true; } +export function createProviderBackupScope({ providerId, tenantId, resources: requested = BACKUP_RESOURCE_TYPES, operations: requestedOperations = BACKUP_PROVIDER_OPERATIONS, encryptionKeyReference, issuedAt = new Date(), expiresAt }: { readonly providerId?: string; readonly tenantId?: string; readonly resources?: readonly BackupResource[]; readonly operations?: readonly ProviderBackupOperation[]; readonly encryptionKeyReference?: string; readonly issuedAt?: BackupDate; readonly expiresAt?: BackupDate } = {}): Readonly { const provider = id(providerId, 'providerId'); const tenant = id(tenantId, 'tenantId'); const scopeResources = resourceList(requested); if (!Array.isArray(requestedOperations) || requestedOperations.length === 0 || requestedOperations.some((operation) => !operations.has(operation))) throw backupError('operations are invalid', 'INVALID_PROVIDER_SCOPE'); const issued = date(issuedAt, 'issuedAt'); const expires = date(expiresAt, 'expiresAt'); if (expires <= issued) throw backupError('expiresAt must be later than issuedAt', 'INVALID_TIMESTAMP'); const scope: ProviderBackupScope = { schemaVersion: 1, scopeType: 'provider-encrypted-tenant', providerId: provider, tenantId: tenant, userId: null, resources: scopeResources, operations: Object.freeze([...new Set(requestedOperations)]), issuedAt: issued.toISOString(), expiresAt: expires.toISOString(), encryptedContentOnly: true, plaintextAccess: false, sessionAccess: false, sessionSecrets: false, encryptionKeyReference: referenceKey(encryptionKeyReference) }; safe(scope); return Object.freeze(scope); } +function target(scope: BackupScope, value: BackupIdentity | RecordValue): Readonly { const actor = identity(value, 'target'); if (scope.tenantId !== actor.tenantId) throw backupError('restore target crosses tenant boundary', 'TENANT_SCOPE_DENIED'); if (scope.scopeType === 'user-self-service' && scope.userId !== actor.userId) throw backupError('restore target crosses user boundary', 'USER_SCOPE_DENIED'); if (scope.scopeType === 'provider-encrypted-tenant' && actor.role !== 'provider') throw backupError('provider scope requires a provider restore actor', 'PROVIDER_SCOPE_DENIED'); return actor; } +export function createRestorePlan({ manifest, scope, target: actor, requestedResources = scope?.resources, overwrite = false, plannedAt = new Date() }: { readonly manifest?: BackupManifest; readonly scope?: BackupScope; readonly target?: BackupIdentity | RecordValue; readonly requestedResources?: readonly BackupResource[]; readonly overwrite?: boolean; readonly plannedAt?: BackupDate } = {}): Readonly { if (manifest === undefined || scope === undefined || actor === undefined) throw backupError('manifest, scope, and target are required', 'INVALID_CONTRACT'); target(scope, actor); if (manifest.tenantId !== scope.tenantId || manifest.userId !== scope.userId) throw backupError('manifest scope does not match restore scope', 'SCOPE_MISMATCH'); const selected = resourceList(requestedResources, 'requestedResources'); if (selected.some((item) => !scope.resources.includes(item))) throw backupError('restore requests a resource outside the authorized scope', 'RESOURCE_SCOPE_DENIED'); if (typeof overwrite !== 'boolean') throw backupError('overwrite must be boolean', 'INVALID_RESTORE_PLAN'); const plan: RestorePlan = { schemaVersion: 1, planType: 'restore', archiveId: manifest.archiveId, tenantId: scope.tenantId, userId: scope.userId, resources: selected, overwrite, integrityRequired: true, privacyValidated: true, plannedAt: date(plannedAt, 'plannedAt').toISOString(), status: 'ready-for-integrity-check' }; safe(plan); return Object.freeze(plan); } +export function validateRestorePlan(plan: RestorePlan, { manifest, scope, target: actor, contentByPath = {} }: { readonly manifest?: BackupManifest; readonly scope?: BackupScope; readonly target?: BackupIdentity | RecordValue; readonly contentByPath?: Readonly> } = {}): Readonly; readonly validatedAt: string }> { if (manifest === undefined || scope === undefined || actor === undefined) throw backupError('manifest, scope, and target are required', 'INVALID_CONTRACT'); const restoreActor = target(scope, actor); if (plan.archiveId !== manifest.archiveId || plan.tenantId !== scope.tenantId || plan.userId !== scope.userId) throw backupError('restore plan identity does not match its archive scope', 'SCOPE_MISMATCH'); if (restoreActor.role === 'user' && plan.overwrite) throw backupError('user restores cannot overwrite existing data', 'RESTORE_POLICY_DENIED'); const integrity = verifyArchiveManifest(manifest, contentByPath); if (!integrity.complete) throw backupError('all archive members must be present before restore', 'INTEGRITY_INCOMPLETE'); return Object.freeze({ ...plan, status: 'validated' as const, integrity, privacyValidated: true as const, validatedAt: new Date().toISOString() }); } +export function createRecoveryObjectives({ rpoMinutes = 15, rtoMinutes = 60, retentionDays = 28, measuredFrom = 'last-successful-backup' }: { readonly rpoMinutes?: number; readonly rtoMinutes?: number; readonly retentionDays?: number; readonly measuredFrom?: RecoveryObjectives['measuredFrom'] } = {}): Readonly { integer(rpoMinutes, 'rpoMinutes', 1, 525_600); integer(rtoMinutes, 'rtoMinutes', 1, 525_600); if (rtoMinutes < rpoMinutes) throw backupError('rtoMinutes cannot be lower than rpoMinutes', 'INVALID_RECOVERY_OBJECTIVES'); integer(retentionDays, 'retentionDays', 28, 36_500); if (measuredFrom !== 'last-successful-backup' && measuredFrom !== 'last-replicated-checkpoint') throw backupError('measuredFrom is invalid', 'INVALID_RECOVERY_OBJECTIVES'); return Object.freeze({ schemaVersion: 1, objectiveType: 'tenant-disaster-recovery', rpoMinutes, rtoMinutes, retentionDays, measuredFrom }); } +export function createDrRehearsalRecord({ rehearsalId = randomUUID(), tenantId, archiveId, objectives, startedAt, endedAt, outcome, observedRpoMinutes, observedRtoMinutes, integrityVerified, privacyVerified, evidenceSha256, runbookVersion = '1' }: { readonly rehearsalId?: string; readonly tenantId?: string; readonly archiveId?: string; readonly objectives?: RecoveryObjectives; readonly startedAt?: BackupDate; readonly endedAt?: BackupDate; readonly outcome?: 'passed' | 'failed' | 'inconclusive'; readonly observedRpoMinutes?: number; readonly observedRtoMinutes?: number; readonly integrityVerified?: boolean; readonly privacyVerified?: boolean; readonly evidenceSha256?: string; readonly runbookVersion?: string } = {}): Readonly> { id(rehearsalId, 'rehearsalId'); id(tenantId, 'tenantId'); id(archiveId, 'archiveId'); object(objectives, 'objectives'); const rpo = integer(objectives.rpoMinutes, 'objectives.rpoMinutes', 1, 525_600); const rto = integer(objectives.rtoMinutes, 'objectives.rtoMinutes', 1, 525_600); if (rto < rpo) throw backupError('objectives.rtoMinutes cannot be lower than objectives.rpoMinutes', 'INVALID_REHEARSAL'); const started = date(startedAt, 'startedAt'); const ended = date(endedAt, 'endedAt'); if (ended < started) throw backupError('endedAt must not precede startedAt', 'INVALID_REHEARSAL'); if (outcome !== 'passed' && outcome !== 'failed' && outcome !== 'inconclusive') throw backupError('outcome is invalid', 'INVALID_REHEARSAL'); const observedRpo = integer(observedRpoMinutes, 'observedRpoMinutes', 0, 525_600); const observedRto = integer(observedRtoMinutes, 'observedRtoMinutes', 0, 525_600); if (typeof integrityVerified !== 'boolean' || typeof privacyVerified !== 'boolean') throw backupError('integrityVerified and privacyVerified must be boolean', 'INVALID_REHEARSAL'); if (evidenceSha256 !== undefined) checksum(evidenceSha256, 'evidenceSha256'); if (typeof runbookVersion !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/u.test(runbookVersion)) throw backupError('runbookVersion is invalid', 'INVALID_REHEARSAL'); if (outcome === 'passed' && (!integrityVerified || !privacyVerified || observedRpo > rpo || observedRto > rto)) throw backupError('a passed rehearsal must satisfy integrity, privacy, RPO, and RTO objectives', 'INVALID_REHEARSAL'); const record = { schemaVersion: 1, rehearsalId, tenantId: tenantId as string, archiveId: archiveId as string, startedAt: started.toISOString(), endedAt: ended.toISOString(), durationMinutes: Math.ceil((ended.getTime() - started.getTime()) / 60_000), outcome, observedRpoMinutes: observedRpo, observedRtoMinutes: observedRto, integrityVerified, privacyVerified, evidenceSha256: evidenceSha256 ?? null, runbookVersion }; safe(record); return Object.freeze(record); } diff --git a/src/backup/index.mjs b/src/backup/index.mjs index 977a46c..2f55716 100644 --- a/src/backup/index.mjs +++ b/src/backup/index.mjs @@ -2,4 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -export * from './backup-contract.mjs'; +// Temporary compatibility bridge. Backup contract behavior lives in TypeScript. +export * from './index.ts'; diff --git a/src/backup/index.ts b/src/backup/index.ts new file mode 100644 index 0000000..c473087 --- /dev/null +++ b/src/backup/index.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +export * from './backup-contract.ts'; diff --git a/src/lifecycle/account-lifecycle.mjs b/src/lifecycle/account-lifecycle.mjs index 0b14f17..d95a22c 100644 --- a/src/lifecycle/account-lifecycle.mjs +++ b/src/lifecycle/account-lifecycle.mjs @@ -2,605 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -import { randomUUID } from 'node:crypto'; - -/** - * Account deletion lifecycle contract. - * - * This is an auditable state machine, not a destructive account deleter. It - * records the authorization and recovery window that an adapter must enforce - * across aliases, delegations, factors, backups, mailbox data, and DAV data. - * Permanent resource deletion is deliberately a separate operation. - */ - -export const ACCOUNT_LIFECYCLE_SCHEMA_VERSION = 1; -export const DEFAULT_ACCOUNT_RECOVERY_DAYS = 28; -export const ACCOUNT_STATES = Object.freeze({ - ACTIVE: 'active', - DELETION_REQUESTED: 'deletion_requested', - SOFT_DELETED: 'soft_deleted', - PURGE_PENDING: 'purge_pending', - PURGED: 'purged', -}); - -export const ACCOUNT_RESOURCE_TYPES = Object.freeze([ - 'aliases', - 'delegations', - 'factors', - 'backups', - 'mailbox', - 'dav_collections', - 'preferences', -]); - -const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; -const OPERATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; -const REASON_PATTERN = /^[^\r\n]{1,256}$/u; -const DAY_MS = 24 * 60 * 60 * 1000; - -function accountError(message, code = 'ACCOUNT_LIFECYCLE_ERROR') { - const error = new Error(`Account lifecycle error: ${message}`); - error.code = code; - return error; -} - -function assertObject(value, field) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw accountError(`${field} must be an object`, 'INVALID_INPUT'); - } - return value; -} - -function assertId(value, field) { - if (typeof value !== 'string' || !ID_PATTERN.test(value)) { - throw accountError(`${field} is invalid`, 'INVALID_IDENTITY'); - } - return value; -} - -function assertOperationId(value, field = 'operationId') { - if (typeof value !== 'string' || !OPERATION_PATTERN.test(value)) { - throw accountError(`${field} is invalid`, 'INVALID_OPERATION_ID'); - } - return value; -} - -function assertReason(value, field = 'reason') { - if (typeof value !== 'string' || !REASON_PATTERN.test(value)) { - throw accountError(`${field} must be a single-line value of 1-256 characters`, 'INVALID_REASON'); - } - return value; -} - -function parseDate(value, field, fallback) { - const candidate = value === undefined ? fallback : value; - const date = candidate instanceof Date ? new Date(candidate.getTime()) : new Date(candidate); - if (Number.isNaN(date.getTime())) throw accountError(`${field} is invalid`, 'INVALID_TIMESTAMP'); - return date; -} - -function iso(value, field, fallback) { - return parseDate(value, field, fallback).toISOString(); -} - -function assertRecoveryDays(value) { - if (!Number.isInteger(value) || value < 1 || value > 3650) { - throw accountError('recoveryDays must be an integer between 1 and 3650', 'INVALID_RECOVERY_POLICY'); - } - return value; -} - -function deepFreeze(value) { - if (Array.isArray(value)) return Object.freeze(value.map((item) => deepFreeze(item))); - if (value && typeof value === 'object') { - const copy = {}; - for (const [key, item] of Object.entries(value)) copy[key] = deepFreeze(item); - return Object.freeze(copy); - } - return value; -} - -function normalizeActor(actor, fallbackId = undefined) { - if (actor === undefined) { - return Object.freeze({ actorId: fallbackId ?? 'account-lifecycle', role: fallbackId ? 'user' : 'system' }); - } - assertObject(actor, 'actor'); - return Object.freeze({ - actorId: assertId(actor.actorId, 'actor.actorId'), - role: assertId(actor.role ?? 'system', 'actor.role'), - }); -} - -function accountKey(tenantId, userId) { - return `${tenantId}\u0000${userId}`; -} - -function confirmationFor(userId) { - return `DELETE:${userId}`; -} - -function publicAccount(account) { - return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - tenantId: account.tenantId, - userId: account.userId, - accountType: account.accountType, - state: account.state, - createdAt: account.createdAt, - deletionRequestedAt: account.deletionRequestedAt, - softDeletedAt: account.softDeletedAt, - recoveryUntil: account.recoveryUntil, - purgeQueuedAt: account.purgeQueuedAt, - purgedAt: account.purgedAt, - cleanupPlan: [...account.cleanupPlan], - activeHoldIds: [...account.holds.keys()], - deletionRequestId: account.deletionRequestId, - }); -} - -function createAuditEvent({ event, account, actor, occurredAt, operationId, metadata = {} }) { - return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - event, - occurredAt: iso(occurredAt, 'occurredAt'), - tenantId: account.tenantId, - userId: account.userId, - actorId: actor.actorId, - actorRole: actor.role, - ...(operationId ? { operationId } : {}), - metadata, - }); -} - -function normalizeCleanupPlan(plan) { - const selected = plan === undefined ? ACCOUNT_RESOURCE_TYPES : plan; - if (!Array.isArray(selected) || selected.length === 0) { - throw accountError('cleanupPlan must be a non-empty list', 'INVALID_CLEANUP_PLAN'); - } - const unique = [...new Set(selected)]; - if (unique.some((item) => !ACCOUNT_RESOURCE_TYPES.includes(item))) { - throw accountError('cleanupPlan contains an unsupported resource type', 'INVALID_CLEANUP_PLAN'); - } - return unique; -} - -function normalizeResourceResults(account, value) { - assertObject(value, 'resourceResults'); - const result = {}; - for (const resource of account.cleanupPlan) { - if (value[resource] !== 'purged') { - throw accountError(`resource purge is incomplete: ${resource}`, 'PURGE_INCOMPLETE'); - } - result[resource] = 'purged'; - } - return result; -} - -function requireState(account, expected) { - if (account.state !== expected) { - throw accountError(`account must be ${expected}, not ${account.state}`, 'INVALID_STATE_TRANSITION'); - } -} - -function assertAccountScope(input) { - assertObject(input, 'scope'); - const { tenantId, userId } = input; - return { - tenantId: assertId(tenantId, 'tenantId'), - userId: assertId(userId, 'userId'), - }; -} - -/** - * Create an in-memory account lifecycle state machine. Adapters should persist - * the same immutable state transitions transactionally in PostgreSQL. - */ -export function createAccountLifecycleStore({ - now = () => new Date(), - recoveryDays = DEFAULT_ACCOUNT_RECOVERY_DAYS, -} = {}) { - if (typeof now !== 'function') throw accountError('now must be a function', 'INVALID_CLOCK'); - const defaultRecoveryDays = assertRecoveryDays(recoveryDays); - const accounts = new Map(); - const operationResults = new Map(); - const auditEvents = []; - - function currentDate() { - return parseDate(now(), 'clock'); - } - - function requireAccount(scope) { - const normalized = assertAccountScope(scope); - const account = accounts.get(accountKey(normalized.tenantId, normalized.userId)); - if (!account) throw accountError('account does not exist', 'ACCOUNT_NOT_FOUND'); - return { normalized, account }; - } - - function appendAudit(event) { - auditEvents.push(event); - return event; - } - - function registerAccount({ - tenantId, - userId, - accountType = 'user', - createdAt = currentDate(), - cleanupPlan = undefined, - actor = undefined, - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const type = assertId(accountType, 'accountType'); - const key = accountKey(scope.tenantId, scope.userId); - if (accounts.has(key)) throw accountError('account already exists', 'ACCOUNT_EXISTS'); - const account = { - ...scope, - accountType: type, - state: ACCOUNT_STATES.ACTIVE, - createdAt: iso(createdAt, 'createdAt'), - deletionRequestedAt: null, - softDeletedAt: null, - recoveryUntil: null, - purgeQueuedAt: null, - purgedAt: null, - cleanupPlan: normalizeCleanupPlan(cleanupPlan), - holds: new Map(), - deletionRequestId: null, - }; - accounts.set(key, account); - const audit = appendAudit(createAuditEvent({ - event: 'account.registered', - account, - actor: normalizeActor(actor, scope.userId), - occurredAt: createdAt, - metadata: { accountType: type, cleanupPlan: account.cleanupPlan }, - })); - return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'account_registered', - account: publicAccount(account), - audit, - }); - } - - function requestDeletion({ - tenantId, - userId, - actor, - confirmation, - reason, - requestId = randomUUID(), - requestedAt = currentDate(), - recoveryDays = defaultRecoveryDays, - cleanupPlan = undefined, - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const request = assertOperationId(requestId, 'requestId'); - const key = `request\u0000${scope.tenantId}\u0000${scope.userId}\u0000${request}`; - const priorResult = operationResults.get(key); - if (priorResult) return priorResult; - if (confirmation !== confirmationFor(scope.userId)) { - throw accountError('strong deletion confirmation is required', 'STRONG_CONFIRMATION_REQUIRED'); - } - const actorInfo = normalizeActor(actor, scope.userId); - const deletionReason = assertReason(reason ?? 'user_requested'); - const days = assertRecoveryDays(recoveryDays); - const date = parseDate(requestedAt, 'requestedAt'); - const { account } = requireAccount(scope); - requireState(account, ACCOUNT_STATES.ACTIVE); - if (account.holds.size > 0) throw accountError('account has an active hold', 'ACCOUNT_ON_HOLD'); - account.state = ACCOUNT_STATES.DELETION_REQUESTED; - account.deletionRequestedAt = date.toISOString(); - account.recoveryUntil = new Date(date.getTime() + days * DAY_MS).toISOString(); - account.cleanupPlan = normalizeCleanupPlan(cleanupPlan ?? account.cleanupPlan); - account.deletionRequestId = request; - const audit = appendAudit(createAuditEvent({ - event: 'account.deletion_requested', - account, - actor: actorInfo, - occurredAt: date, - operationId: request, - metadata: { - reason: deletionReason, - recoveryUntil: account.recoveryUntil, - recoveryDays: days, - cleanupPlan: account.cleanupPlan, - }, - })); - const result = deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'deletion_requested', - requestId: request, - account: publicAccount(account), - audit, - }); - operationResults.set(key, result); - return result; - } - - function softDeleteAccount({ - tenantId, - userId, - actor, - confirmation, - requestId, - deletedAt = currentDate(), - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const { account } = requireAccount(scope); - requireState(account, ACCOUNT_STATES.DELETION_REQUESTED); - if (confirmation !== confirmationFor(scope.userId)) { - throw accountError('strong deletion confirmation is required', 'STRONG_CONFIRMATION_REQUIRED'); - } - if (requestId !== undefined && requestId !== account.deletionRequestId) { - throw accountError('requestId does not match the pending deletion', 'DELETION_REQUEST_MISMATCH'); - } - const actorInfo = normalizeActor(actor, scope.userId); - const date = parseDate(deletedAt, 'deletedAt'); - account.state = ACCOUNT_STATES.SOFT_DELETED; - account.softDeletedAt = date.toISOString(); - const audit = appendAudit(createAuditEvent({ - event: 'account.soft_deleted', - account, - actor: actorInfo, - occurredAt: date, - operationId: account.deletionRequestId, - metadata: { recoveryUntil: account.recoveryUntil, cleanupPlan: account.cleanupPlan }, - })); - return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'soft_deleted', - account: publicAccount(account), - audit, - }); - } - - function restoreAccount({ - tenantId, - userId, - actor, - restoredAt = currentDate(), - reason = 'account_recovery', - operationId = randomUUID(), - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const operation = assertOperationId(operationId); - const key = `restore\u0000${scope.tenantId}\u0000${scope.userId}\u0000${operation}`; - const priorResult = operationResults.get(key); - if (priorResult) return priorResult; - const { account } = requireAccount(scope); - if (![ACCOUNT_STATES.DELETION_REQUESTED, ACCOUNT_STATES.SOFT_DELETED, ACCOUNT_STATES.PURGE_PENDING].includes(account.state)) { - throw accountError('account is not recoverable in its current state', 'ACCOUNT_NOT_RECOVERABLE'); - } - const date = parseDate(restoredAt, 'restoredAt'); - if (account.recoveryUntil && date.getTime() > new Date(account.recoveryUntil).getTime()) { - throw accountError('the account recovery window has elapsed', 'RECOVERY_WINDOW_EXPIRED'); - } - const actorInfo = normalizeActor(actor, scope.userId); - account.state = ACCOUNT_STATES.ACTIVE; - account.recoveryUntil = null; - account.purgeQueuedAt = null; - account.deletionRequestedAt = null; - account.softDeletedAt = null; - account.deletionRequestId = null; - const audit = appendAudit(createAuditEvent({ - event: 'account.restored', - account, - actor: actorInfo, - occurredAt: date, - operationId: operation, - metadata: { reason: assertReason(reason) }, - })); - const result = deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'account_restored', - operationId: operation, - account: publicAccount(account), - audit, - }); - operationResults.set(key, result); - return result; - } - - function addAccountHold({ - tenantId, - userId, - holdId, - reasonCode, - actor, - addedAt = currentDate(), - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const hold = assertOperationId(holdId, 'holdId'); - const reason = assertReason(reasonCode ?? 'administrative_hold', 'reasonCode'); - const { account } = requireAccount(scope); - const actorInfo = normalizeActor(actor); - if (account.holds.has(hold)) return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'account_hold_added', - holdId: hold, - account: publicAccount(account), - idempotent: true, - }); - account.holds.set(hold, { holdId: hold, reasonCode: reason, addedAt: iso(addedAt, 'addedAt') }); - const audit = appendAudit(createAuditEvent({ - event: 'account.hold_added', - account, - actor: actorInfo, - occurredAt: addedAt, - metadata: { holdId: hold, reasonCode: reason }, - })); - return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'account_hold_added', - holdId: hold, - account: publicAccount(account), - audit, - }); - } - - function releaseAccountHold({ - tenantId, - userId, - holdId, - actor, - releasedAt = currentDate(), - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const hold = assertOperationId(holdId, 'holdId'); - const { account } = requireAccount(scope); - const actorInfo = normalizeActor(actor); - if (!account.holds.delete(hold)) return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'account_hold_released', - holdId: hold, - account: publicAccount(account), - idempotent: true, - }); - const audit = appendAudit(createAuditEvent({ - event: 'account.hold_released', - account, - actor: actorInfo, - occurredAt: releasedAt, - metadata: { holdId: hold }, - })); - return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'account_hold_released', - holdId: hold, - account: publicAccount(account), - audit, - }); - } - - function queuePurge({ - tenantId, - userId, - actor, - queuedAt = currentDate(), - operationId = randomUUID(), - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const operation = assertOperationId(operationId); - const key = `queue\u0000${scope.tenantId}\u0000${scope.userId}\u0000${operation}`; - const priorResult = operationResults.get(key); - if (priorResult) return priorResult; - const { account } = requireAccount(scope); - requireState(account, ACCOUNT_STATES.SOFT_DELETED); - const date = parseDate(queuedAt, 'queuedAt'); - if (!account.recoveryUntil || date.getTime() < new Date(account.recoveryUntil).getTime()) { - throw accountError('the account recovery window has not elapsed', 'RECOVERY_WINDOW_ACTIVE'); - } - if (account.holds.size > 0) throw accountError('account has an active hold', 'ACCOUNT_ON_HOLD'); - const actorInfo = normalizeActor(actor); - account.state = ACCOUNT_STATES.PURGE_PENDING; - account.purgeQueuedAt = date.toISOString(); - const audit = appendAudit(createAuditEvent({ - event: 'account.purge_queued', - account, - actor: actorInfo, - occurredAt: date, - operationId: operation, - metadata: { cleanupPlan: account.cleanupPlan }, - })); - const result = deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'purge_queued', - operationId: operation, - account: publicAccount(account), - audit, - }); - operationResults.set(key, result); - return result; - } - - function completePurge({ - tenantId, - userId, - actor, - confirmation, - completedAt = currentDate(), - operationId = randomUUID(), - resourceResults = undefined, - } = {}) { - const scope = assertAccountScope({ tenantId, userId }); - const operation = assertOperationId(operationId); - const key = `complete\u0000${scope.tenantId}\u0000${scope.userId}\u0000${operation}`; - const priorResult = operationResults.get(key); - if (priorResult) return priorResult; - const { account } = requireAccount(scope); - requireState(account, ACCOUNT_STATES.PURGE_PENDING); - if (confirmation !== `PURGE:${scope.userId}`) { - throw accountError('strong purge confirmation is required', 'STRONG_CONFIRMATION_REQUIRED'); - } - if (account.holds.size > 0) throw accountError('account has an active hold', 'ACCOUNT_ON_HOLD'); - const adapterResults = normalizeResourceResults(account, resourceResults === undefined ? {} : resourceResults); - const date = parseDate(completedAt, 'completedAt'); - const actorInfo = normalizeActor(actor); - account.state = ACCOUNT_STATES.PURGED; - account.purgedAt = date.toISOString(); - const audit = appendAudit(createAuditEvent({ - event: 'account.purged', - account, - actor: actorInfo, - occurredAt: date, - operationId: operation, - metadata: { cleanupPlan: account.cleanupPlan, resourceResults: adapterResults }, - })); - const result = deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - operation: 'account_purged', - operationId: operation, - account: publicAccount(account), - audit, - }); - operationResults.set(key, result); - return result; - } - - function getAccount(scope) { - return publicAccount(requireAccount(scope).account); - } - - function getAuditEvents({ tenantId = undefined, userId = undefined, event = undefined } = {}) { - const tenant = tenantId === undefined ? undefined : assertId(tenantId, 'tenantId'); - const user = userId === undefined ? undefined : assertId(userId, 'userId'); - if (user && !tenant) throw accountError('tenantId is required with userId', 'INVALID_SCOPE'); - if (event !== undefined && (typeof event !== 'string' || !/^account\.[a-z_]+$/u.test(event))) { - throw accountError('event is invalid', 'INVALID_AUDIT_FILTER'); - } - return deepFreeze(auditEvents.filter((entry) => - (tenant === undefined || entry.tenantId === tenant) && - (user === undefined || entry.userId === user) && - (event === undefined || entry.event === event))); - } - - function exportState() { - return deepFreeze({ - schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, - recoveryDays: defaultRecoveryDays, - accounts: [...accounts.values()].map((account) => publicAccount(account)), - }); - } - - return Object.freeze({ - recoveryDays: defaultRecoveryDays, - registerAccount, - requestDeletion, - softDeleteAccount, - restoreAccount, - addAccountHold, - releaseAccountHold, - queuePurge, - completePurge, - getAccount, - getAuditEvents, - exportState, - confirmationFor, - }); -} - -export { - accountError, - confirmationFor, -}; +// Temporary compatibility bridge. Account-lifecycle behavior lives in TypeScript. +export * from './account-lifecycle.ts'; diff --git a/src/lifecycle/account-lifecycle.test.ts b/src/lifecycle/account-lifecycle.test.ts new file mode 100644 index 0000000..deca70c --- /dev/null +++ b/src/lifecycle/account-lifecycle.test.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ACCOUNT_STATES, createAccountLifecycleStore } from './account-lifecycle.ts'; + +const scope = { tenantId: 'acme', userId: 'alice' }; + +test('account deletion has a recoverable 28-day window and purge requires every planned resource', () => { + let clock = new Date('2026-08-01T00:00:00Z'); + const store = createAccountLifecycleStore({ now: () => clock }); + store.registerAccount({ ...scope, cleanupPlan: ['aliases', 'mailbox'] }); + store.requestDeletion({ ...scope, confirmation: 'DELETE:alice', requestId: 'request-001' }); + store.softDeleteAccount({ ...scope, confirmation: 'DELETE:alice', requestId: 'request-001' }); + const restored = store.restoreAccount({ ...scope, operationId: 'restore-001' }); + assert.equal(restored.account.state, ACCOUNT_STATES.ACTIVE); + store.requestDeletion({ ...scope, confirmation: 'DELETE:alice', requestId: 'request-002' }); + store.softDeleteAccount({ ...scope, confirmation: 'DELETE:alice', requestId: 'request-002' }); + clock = new Date('2026-08-29T00:00:00Z'); + store.queuePurge({ ...scope, operationId: 'queue-001' }); + assert.throws(() => store.completePurge({ ...scope, confirmation: 'PURGE:alice', operationId: 'purge-incomplete', resourceResults: { aliases: 'purged' } }), (error: unknown) => (error as { code?: string }).code === 'PURGE_INCOMPLETE'); + assert.equal(store.completePurge({ ...scope, confirmation: 'PURGE:alice', operationId: 'purge-001', resourceResults: { aliases: 'purged', mailbox: 'purged' } }).account.state, ACCOUNT_STATES.PURGED); +}); + +test('account holds, tenant scope and audit do not expose content', () => { + const store = createAccountLifecycleStore(); + store.registerAccount(scope); + store.addAccountHold({ ...scope, holdId: 'hold-001', reasonCode: 'legal_hold' }); + assert.throws(() => store.requestDeletion({ ...scope, confirmation: 'DELETE:alice', requestId: 'held-request' }), (error: unknown) => (error as { code?: string }).code === 'ACCOUNT_ON_HOLD'); + assert.equal(store.getAuditEvents({ tenantId: 'other-tenant' }).length, 0); + for (const event of store.getAuditEvents(scope)) assert.equal(Object.hasOwn(event, 'content'), false); +}); diff --git a/src/lifecycle/account-lifecycle.ts b/src/lifecycle/account-lifecycle.ts new file mode 100644 index 0000000..51d58b0 --- /dev/null +++ b/src/lifecycle/account-lifecycle.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { randomUUID } from 'node:crypto'; + +/** + * Account deletion lifecycle contract. This is an auditable state machine, not + * a destructive account deleter; adapters own permanent resource deletion. + */ +export const ACCOUNT_LIFECYCLE_SCHEMA_VERSION = 1 as const; +export const DEFAULT_ACCOUNT_RECOVERY_DAYS = 28; +export const ACCOUNT_STATES = Object.freeze({ + ACTIVE: 'active', + DELETION_REQUESTED: 'deletion_requested', + SOFT_DELETED: 'soft_deleted', + PURGE_PENDING: 'purge_pending', + PURGED: 'purged', +} as const); +export const ACCOUNT_RESOURCE_TYPES = Object.freeze([ + 'aliases', 'delegations', 'factors', 'backups', 'mailbox', 'dav_collections', 'preferences', +] as const); + +export type AccountState = typeof ACCOUNT_STATES[keyof typeof ACCOUNT_STATES]; +export type AccountResourceType = typeof ACCOUNT_RESOURCE_TYPES[number]; +export type DateInput = Date | string | number; +export interface AccountScope { readonly tenantId: string; readonly userId: string; } +export interface AccountAuditEvent extends AccountScope { + readonly schemaVersion: typeof ACCOUNT_LIFECYCLE_SCHEMA_VERSION; + readonly event: string; readonly occurredAt: string; readonly actorId: string; readonly actorRole: string; + readonly operationId?: string; readonly metadata: Readonly>; +} +export interface PublicAccount extends AccountScope { + readonly schemaVersion: typeof ACCOUNT_LIFECYCLE_SCHEMA_VERSION; readonly accountType: string; readonly state: AccountState; + readonly createdAt: string; readonly deletionRequestedAt: string | null; readonly softDeletedAt: string | null; + readonly recoveryUntil: string | null; readonly purgeQueuedAt: string | null; readonly purgedAt: string | null; + readonly cleanupPlan: readonly AccountResourceType[]; readonly activeHoldIds: readonly string[]; readonly deletionRequestId: string | null; +} +export interface AccountOperation { + readonly schemaVersion: typeof ACCOUNT_LIFECYCLE_SCHEMA_VERSION; readonly operation: string; readonly account: PublicAccount; + readonly audit?: AccountAuditEvent; readonly requestId?: string; readonly operationId?: string; readonly holdId?: string; readonly idempotent?: boolean; +} + +type UnknownRecord = Record; +type AccountHold = { readonly holdId: string; readonly reasonCode: string; readonly addedAt: string }; +type AccountRecord = { + tenantId: string; userId: string; accountType: string; state: AccountState; createdAt: string; + deletionRequestedAt: string | null; softDeletedAt: string | null; recoveryUntil: string | null; + purgeQueuedAt: string | null; purgedAt: string | null; cleanupPlan: AccountResourceType[]; + holds: Map; deletionRequestId: string | null; +}; + +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; +const OPERATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const REASON_PATTERN = /^[^\r\n]{1,256}$/u; +const DAY_MS = 24 * 60 * 60 * 1000; + +export function accountError(message: string, code = 'ACCOUNT_LIFECYCLE_ERROR'): Error & { code: string } { + const error = new Error(`Account lifecycle error: ${message}`) as Error & { code: string }; + error.code = code; + return error; +} +function isObject(value: unknown): value is UnknownRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); } +function assertObject(value: unknown, field: string): UnknownRecord { if (!isObject(value)) throw accountError(`${field} must be an object`, 'INVALID_INPUT'); return value; } +function assertId(value: unknown, field: string): string { if (typeof value !== 'string' || !ID_PATTERN.test(value)) throw accountError(`${field} is invalid`, 'INVALID_IDENTITY'); return value; } +function assertOperationId(value: unknown, field = 'operationId'): string { if (typeof value !== 'string' || !OPERATION_PATTERN.test(value)) throw accountError(`${field} is invalid`, 'INVALID_OPERATION_ID'); return value; } +function assertReason(value: unknown, field = 'reason'): string { if (typeof value !== 'string' || !REASON_PATTERN.test(value)) throw accountError(`${field} must be a single-line value of 1-256 characters`, 'INVALID_REASON'); return value; } +function parseDate(value: unknown, field: string, fallback?: DateInput): Date { + const candidate = value === undefined ? fallback : value; + const date = candidate instanceof Date ? new Date(candidate.getTime()) : new Date(candidate as string | number); + if (Number.isNaN(date.getTime())) throw accountError(`${field} is invalid`, 'INVALID_TIMESTAMP'); + return date; +} +function iso(value: unknown, field: string, fallback?: DateInput): string { return parseDate(value, field, fallback).toISOString(); } +function assertRecoveryDays(value: unknown): number { + if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 3650) throw accountError('recoveryDays must be an integer between 1 and 3650', 'INVALID_RECOVERY_POLICY'); + return value as number; +} +function deepFreeze(value: T): Readonly { + if (Array.isArray(value)) return Object.freeze(value.map((item) => deepFreeze(item))) as unknown as Readonly; + if (isObject(value)) { const copy: UnknownRecord = {}; for (const [key, item] of Object.entries(value)) copy[key] = deepFreeze(item); return Object.freeze(copy) as Readonly; } + return value as Readonly; +} +function normalizeActor(actor: unknown, fallbackId?: string): Readonly<{ actorId: string; role: string }> { + if (actor === undefined) return Object.freeze({ actorId: fallbackId ?? 'account-lifecycle', role: fallbackId ? 'user' : 'system' }); + const input = assertObject(actor, 'actor'); + return Object.freeze({ actorId: assertId(input.actorId, 'actor.actorId'), role: assertId(input.role ?? 'system', 'actor.role') }); +} +function accountKey(tenantId: string, userId: string): string { return `${tenantId}\u0000${userId}`; } +export function confirmationFor(userId: string): string { return `DELETE:${userId}`; } +function publicAccount(account: AccountRecord): PublicAccount { + return deepFreeze({ schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, tenantId: account.tenantId, userId: account.userId, accountType: account.accountType, state: account.state, createdAt: account.createdAt, deletionRequestedAt: account.deletionRequestedAt, softDeletedAt: account.softDeletedAt, recoveryUntil: account.recoveryUntil, purgeQueuedAt: account.purgeQueuedAt, purgedAt: account.purgedAt, cleanupPlan: [...account.cleanupPlan], activeHoldIds: [...account.holds.keys()], deletionRequestId: account.deletionRequestId }) as PublicAccount; +} +function createAuditEvent({ event, account, actor, occurredAt, operationId, metadata = {} }: { event: string; account: AccountRecord; actor: { actorId: string; role: string }; occurredAt: unknown; operationId?: string; metadata?: UnknownRecord }): AccountAuditEvent { + return deepFreeze({ schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, event, occurredAt: iso(occurredAt, 'occurredAt'), tenantId: account.tenantId, userId: account.userId, actorId: actor.actorId, actorRole: actor.role, ...(operationId ? { operationId } : {}), metadata }) as AccountAuditEvent; +} +function normalizeCleanupPlan(plan: unknown): AccountResourceType[] { + const selected: unknown[] = plan === undefined ? [...ACCOUNT_RESOURCE_TYPES] : Array.isArray(plan) ? plan : []; + if (selected.length === 0) throw accountError('cleanupPlan must be a non-empty list', 'INVALID_CLEANUP_PLAN'); + const unique = [...new Set(selected)]; + if (unique.some((item) => typeof item !== 'string' || !(ACCOUNT_RESOURCE_TYPES as readonly string[]).includes(item))) throw accountError('cleanupPlan contains an unsupported resource type', 'INVALID_CLEANUP_PLAN'); + return unique as AccountResourceType[]; +} +function normalizeResourceResults(account: AccountRecord, value: unknown): Readonly> { + const input = assertObject(value, 'resourceResults'); const result = {} as Record; + for (const resource of account.cleanupPlan) { if (input[resource] !== 'purged') throw accountError(`resource purge is incomplete: ${resource}`, 'PURGE_INCOMPLETE'); result[resource] = 'purged'; } + return result; +} +function requireState(account: AccountRecord, expected: AccountState): void { if (account.state !== expected) throw accountError(`account must be ${expected}, not ${account.state}`, 'INVALID_STATE_TRANSITION'); } +function assertAccountScope(input: unknown): AccountScope { const scope = assertObject(input, 'scope'); return { tenantId: assertId(scope.tenantId, 'tenantId'), userId: assertId(scope.userId, 'userId') }; } + +export interface AccountLifecycleStore { + readonly recoveryDays: number; registerAccount(input?: UnknownRecord): AccountOperation; requestDeletion(input?: UnknownRecord): AccountOperation; softDeleteAccount(input?: UnknownRecord): AccountOperation; restoreAccount(input?: UnknownRecord): AccountOperation; addAccountHold(input?: UnknownRecord): AccountOperation; releaseAccountHold(input?: UnknownRecord): AccountOperation; queuePurge(input?: UnknownRecord): AccountOperation; completePurge(input?: UnknownRecord): AccountOperation; getAccount(scope: AccountScope): PublicAccount; getAuditEvents(input?: UnknownRecord): readonly AccountAuditEvent[]; exportState(): Readonly<{ schemaVersion: typeof ACCOUNT_LIFECYCLE_SCHEMA_VERSION; recoveryDays: number; accounts: readonly PublicAccount[] }>; confirmationFor(userId: string): string; +} +export function createAccountLifecycleStore({ now = () => new Date(), recoveryDays = DEFAULT_ACCOUNT_RECOVERY_DAYS }: { now?: () => DateInput; recoveryDays?: number } = {}): AccountLifecycleStore { + if (typeof now !== 'function') throw accountError('now must be a function', 'INVALID_CLOCK'); + const defaultRecoveryDays = assertRecoveryDays(recoveryDays); const accounts = new Map(); const operationResults = new Map(); const auditEvents: AccountAuditEvent[] = []; + const currentDate = (): Date => parseDate(now(), 'clock'); + const requireAccount = (scope: unknown): { normalized: AccountScope; account: AccountRecord } => { const normalized = assertAccountScope(scope); const account = accounts.get(accountKey(normalized.tenantId, normalized.userId)); if (!account) throw accountError('account does not exist', 'ACCOUNT_NOT_FOUND'); return { normalized, account }; }; + const appendAudit = (event: AccountAuditEvent): AccountAuditEvent => { auditEvents.push(event); return event; }; + const result = (operation: string, account: AccountRecord, audit?: AccountAuditEvent, extra: UnknownRecord = {}): AccountOperation => deepFreeze({ schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, operation, account: publicAccount(account), ...(audit ? { audit } : {}), ...extra }) as AccountOperation; + const registerAccount = (input: UnknownRecord = {}): AccountOperation => { + const scope = assertAccountScope(input); const type = assertId(input.accountType ?? 'user', 'accountType'); const key = accountKey(scope.tenantId, scope.userId); if (accounts.has(key)) throw accountError('account already exists', 'ACCOUNT_EXISTS'); + const account: AccountRecord = { ...scope, accountType: type, state: ACCOUNT_STATES.ACTIVE, createdAt: iso(input.createdAt, 'createdAt', currentDate()), deletionRequestedAt: null, softDeletedAt: null, recoveryUntil: null, purgeQueuedAt: null, purgedAt: null, cleanupPlan: normalizeCleanupPlan(input.cleanupPlan), holds: new Map(), deletionRequestId: null }; accounts.set(key, account); + const audit = appendAudit(createAuditEvent({ event: 'account.registered', account, actor: normalizeActor(input.actor, scope.userId), occurredAt: input.createdAt ?? currentDate(), metadata: { accountType: type, cleanupPlan: account.cleanupPlan } })); return result('account_registered', account, audit); + }; + const requestDeletion = (input: UnknownRecord = {}): AccountOperation => { + const scope = assertAccountScope(input); const request = assertOperationId(input.requestId ?? randomUUID(), 'requestId'); const key = `request\u0000${scope.tenantId}\u0000${scope.userId}\u0000${request}`; const prior = operationResults.get(key); if (prior) return prior; if (input.confirmation !== confirmationFor(scope.userId)) throw accountError('strong deletion confirmation is required', 'STRONG_CONFIRMATION_REQUIRED'); + const actor = normalizeActor(input.actor, scope.userId); const reason = assertReason(input.reason ?? 'user_requested'); const days = assertRecoveryDays(input.recoveryDays ?? defaultRecoveryDays); const date = parseDate(input.requestedAt, 'requestedAt', currentDate()); const { account } = requireAccount(scope); requireState(account, ACCOUNT_STATES.ACTIVE); if (account.holds.size > 0) throw accountError('account has an active hold', 'ACCOUNT_ON_HOLD'); account.state = ACCOUNT_STATES.DELETION_REQUESTED; account.deletionRequestedAt = date.toISOString(); account.recoveryUntil = new Date(date.getTime() + days * DAY_MS).toISOString(); account.cleanupPlan = normalizeCleanupPlan(input.cleanupPlan ?? account.cleanupPlan); account.deletionRequestId = request; + const audit = appendAudit(createAuditEvent({ event: 'account.deletion_requested', account, actor, occurredAt: date, operationId: request, metadata: { reason, recoveryUntil: account.recoveryUntil, recoveryDays: days, cleanupPlan: account.cleanupPlan } })); const output = result('deletion_requested', account, audit, { requestId: request }); operationResults.set(key, output); return output; + }; + const softDeleteAccount = (input: UnknownRecord = {}): AccountOperation => { + const scope = assertAccountScope(input); const { account } = requireAccount(scope); requireState(account, ACCOUNT_STATES.DELETION_REQUESTED); if (input.confirmation !== confirmationFor(scope.userId)) throw accountError('strong deletion confirmation is required', 'STRONG_CONFIRMATION_REQUIRED'); if (input.requestId !== undefined && input.requestId !== account.deletionRequestId) throw accountError('requestId does not match the pending deletion', 'DELETION_REQUEST_MISMATCH'); const date = parseDate(input.deletedAt, 'deletedAt', currentDate()); account.state = ACCOUNT_STATES.SOFT_DELETED; account.softDeletedAt = date.toISOString(); + const audit = appendAudit(createAuditEvent({ event: 'account.soft_deleted', account, actor: normalizeActor(input.actor, scope.userId), occurredAt: date, operationId: account.deletionRequestId ?? undefined, metadata: { recoveryUntil: account.recoveryUntil, cleanupPlan: account.cleanupPlan } })); return result('soft_deleted', account, audit); + }; + const restoreAccount = (input: UnknownRecord = {}): AccountOperation => { + const scope = assertAccountScope(input); const id = assertOperationId(input.operationId ?? randomUUID()); const key = `restore\u0000${scope.tenantId}\u0000${scope.userId}\u0000${id}`; const prior = operationResults.get(key); if (prior) return prior; const { account } = requireAccount(scope); if (account.state !== ACCOUNT_STATES.DELETION_REQUESTED && account.state !== ACCOUNT_STATES.SOFT_DELETED && account.state !== ACCOUNT_STATES.PURGE_PENDING) throw accountError('account is not recoverable in its current state', 'ACCOUNT_NOT_RECOVERABLE'); const date = parseDate(input.restoredAt, 'restoredAt', currentDate()); if (account.recoveryUntil && date.getTime() > new Date(account.recoveryUntil).getTime()) throw accountError('the account recovery window has elapsed', 'RECOVERY_WINDOW_EXPIRED'); account.state = ACCOUNT_STATES.ACTIVE; account.recoveryUntil = null; account.purgeQueuedAt = null; account.deletionRequestedAt = null; account.softDeletedAt = null; account.deletionRequestId = null; + const audit = appendAudit(createAuditEvent({ event: 'account.restored', account, actor: normalizeActor(input.actor, scope.userId), occurredAt: date, operationId: id, metadata: { reason: assertReason(input.reason ?? 'account_recovery') } })); const output = result('account_restored', account, audit, { operationId: id }); operationResults.set(key, output); return output; + }; + const addAccountHold = (input: UnknownRecord = {}): AccountOperation => { const scope = assertAccountScope(input); const holdId = assertOperationId(input.holdId, 'holdId'); const reason = assertReason(input.reasonCode ?? 'administrative_hold', 'reasonCode'); const { account } = requireAccount(scope); if (account.holds.has(holdId)) return result('account_hold_added', account, undefined, { holdId, idempotent: true }); account.holds.set(holdId, { holdId, reasonCode: reason, addedAt: iso(input.addedAt, 'addedAt', currentDate()) }); const audit = appendAudit(createAuditEvent({ event: 'account.hold_added', account, actor: normalizeActor(input.actor), occurredAt: input.addedAt ?? currentDate(), metadata: { holdId, reasonCode: reason } })); return result('account_hold_added', account, audit, { holdId }); }; + const releaseAccountHold = (input: UnknownRecord = {}): AccountOperation => { const scope = assertAccountScope(input); const holdId = assertOperationId(input.holdId, 'holdId'); const { account } = requireAccount(scope); if (!account.holds.delete(holdId)) return result('account_hold_released', account, undefined, { holdId, idempotent: true }); const audit = appendAudit(createAuditEvent({ event: 'account.hold_released', account, actor: normalizeActor(input.actor), occurredAt: input.releasedAt ?? currentDate(), metadata: { holdId } })); return result('account_hold_released', account, audit, { holdId }); }; + const queuePurge = (input: UnknownRecord = {}): AccountOperation => { + const scope = assertAccountScope(input); const id = assertOperationId(input.operationId ?? randomUUID()); const key = `queue\u0000${scope.tenantId}\u0000${scope.userId}\u0000${id}`; const prior = operationResults.get(key); if (prior) return prior; const { account } = requireAccount(scope); requireState(account, ACCOUNT_STATES.SOFT_DELETED); const date = parseDate(input.queuedAt, 'queuedAt', currentDate()); if (!account.recoveryUntil || date.getTime() < new Date(account.recoveryUntil).getTime()) throw accountError('the account recovery window has not elapsed', 'RECOVERY_WINDOW_ACTIVE'); if (account.holds.size > 0) throw accountError('account has an active hold', 'ACCOUNT_ON_HOLD'); account.state = ACCOUNT_STATES.PURGE_PENDING; account.purgeQueuedAt = date.toISOString(); const audit = appendAudit(createAuditEvent({ event: 'account.purge_queued', account, actor: normalizeActor(input.actor), occurredAt: date, operationId: id, metadata: { cleanupPlan: account.cleanupPlan } })); const output = result('purge_queued', account, audit, { operationId: id }); operationResults.set(key, output); return output; + }; + const completePurge = (input: UnknownRecord = {}): AccountOperation => { + const scope = assertAccountScope(input); const id = assertOperationId(input.operationId ?? randomUUID()); const key = `complete\u0000${scope.tenantId}\u0000${scope.userId}\u0000${id}`; const prior = operationResults.get(key); if (prior) return prior; const { account } = requireAccount(scope); requireState(account, ACCOUNT_STATES.PURGE_PENDING); if (input.confirmation !== `PURGE:${scope.userId}`) throw accountError('strong purge confirmation is required', 'STRONG_CONFIRMATION_REQUIRED'); if (account.holds.size > 0) throw accountError('account has an active hold', 'ACCOUNT_ON_HOLD'); const resourceResults = normalizeResourceResults(account, input.resourceResults ?? {}); const date = parseDate(input.completedAt, 'completedAt', currentDate()); account.state = ACCOUNT_STATES.PURGED; account.purgedAt = date.toISOString(); const audit = appendAudit(createAuditEvent({ event: 'account.purged', account, actor: normalizeActor(input.actor), occurredAt: date, operationId: id, metadata: { cleanupPlan: account.cleanupPlan, resourceResults } })); const output = result('account_purged', account, audit, { operationId: id }); operationResults.set(key, output); return output; + }; + const getAccount = (scope: AccountScope): PublicAccount => publicAccount(requireAccount(scope).account); + const getAuditEvents = (input: UnknownRecord = {}): readonly AccountAuditEvent[] => { const tenant = input.tenantId === undefined ? undefined : assertId(input.tenantId, 'tenantId'); const user = input.userId === undefined ? undefined : assertId(input.userId, 'userId'); if (user && !tenant) throw accountError('tenantId is required with userId', 'INVALID_SCOPE'); if (input.event !== undefined && (typeof input.event !== 'string' || !/^account\.[a-z_]+$/u.test(input.event))) throw accountError('event is invalid', 'INVALID_AUDIT_FILTER'); return deepFreeze(auditEvents.filter((entry) => (tenant === undefined || entry.tenantId === tenant) && (user === undefined || entry.userId === user) && (input.event === undefined || entry.event === input.event))) as readonly AccountAuditEvent[]; }; + const exportState = (): Readonly<{ schemaVersion: typeof ACCOUNT_LIFECYCLE_SCHEMA_VERSION; recoveryDays: number; accounts: readonly PublicAccount[] }> => deepFreeze({ schemaVersion: ACCOUNT_LIFECYCLE_SCHEMA_VERSION, recoveryDays: defaultRecoveryDays, accounts: [...accounts.values()].map(publicAccount) }); + return Object.freeze({ recoveryDays: defaultRecoveryDays, registerAccount, requestDeletion, softDeleteAccount, restoreAccount, addAccountHold, releaseAccountHold, queuePurge, completePurge, getAccount, getAuditEvents, exportState, confirmationFor }); +} diff --git a/src/lifecycle/retention.mjs b/src/lifecycle/retention.mjs index edc19d0..fd6be9a 100644 --- a/src/lifecycle/retention.mjs +++ b/src/lifecycle/retention.mjs @@ -2,757 +2,5 @@ // SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) // Author: Sythos (https://www.sythos.net) -import { randomUUID } from 'node:crypto'; - -/** - * Server-side trash retention contract. - * - * This module stores metadata only. The mailbox, DAV, and backup stores remain - * the sources of truth for user content and can be connected through the - * optional `purgeItem` callback. A record is marked purged only after that - * callback succeeds, which makes a retry safe after a worker or container - * restart. - */ - -export const RETENTION_SCHEMA_VERSION = 1; -export const DEFAULT_TRASH_RETENTION_DAYS = 28; -export const DEFAULT_PURGE_BATCH_SIZE = 100; -export const DEFAULT_LOCK_LEASE_MS = 30_000; - -const DAY_MS = 24 * 60 * 60 * 1000; -const USER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; -const OPERATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; -const RESOURCE_TYPE_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/u; -const ROLE_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/u; -const MAX_REASON_LENGTH = 256; -const DEFAULT_RESOURCE_TYPES = new Set([ - 'mail', - 'calendar', - 'contacts', - 'preferences', - 'backup', -]); - -export const RETENTION_ITEM_STATES = Object.freeze({ - TRASHED: 'trashed', - RESTORED: 'restored', - PURGED: 'purged', -}); - -export const RETENTION_METRIC_NAMES = Object.freeze({ - ITEMS_TRASHED: 'gulogulo_retention_items_trashed_total', - ITEMS_RESTORED: 'gulogulo_retention_items_restored_total', - ITEMS_HELD: 'gulogulo_retention_items_held_total', - ITEMS_LOCKED: 'gulogulo_retention_items_locked_total', - PURGE_BATCHES: 'gulogulo_retention_purge_batches_total', - PURGE_CANDIDATES: 'gulogulo_retention_purge_candidates_total', - ITEMS_PURGED: 'gulogulo_retention_items_purged_total', - ITEMS_SKIPPED: 'gulogulo_retention_items_skipped_total', - PURGE_FAILURES: 'gulogulo_retention_purge_failures_total', - HOLDS_ADDED: 'gulogulo_retention_holds_added_total', - HOLDS_RELEASED: 'gulogulo_retention_holds_released_total', -}); - -function retentionError(message, code = 'RETENTION_ERROR') { - const error = new Error(`Retention error: ${message}`); - error.code = code; - return error; -} - -function assertObject(value, field) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw retentionError(`${field} must be an object`, 'INVALID_INPUT'); - } - return value; -} - -function assertId(value, field) { - if (typeof value !== 'string' || !USER_ID_PATTERN.test(value)) { - throw retentionError(`${field} is invalid`, 'INVALID_IDENTITY'); - } - return value; -} - -function assertOperationId(value, field = 'operationId') { - if (typeof value !== 'string' || !OPERATION_ID_PATTERN.test(value)) { - throw retentionError(`${field} is invalid`, 'INVALID_OPERATION_ID'); - } - return value; -} - -function assertRole(value, field = 'role') { - if (typeof value !== 'string' || !ROLE_PATTERN.test(value)) { - throw retentionError(`${field} is invalid`, 'INVALID_ROLE'); - } - return value; -} - -function toDate(value, field, fallback = undefined) { - const candidate = value === undefined ? fallback : value; - const date = candidate instanceof Date ? new Date(candidate.getTime()) : new Date(candidate); - if (Number.isNaN(date.getTime())) { - throw retentionError(`${field} is invalid`, 'INVALID_TIMESTAMP'); - } - return date; -} - -function toIso(value, field, fallback = undefined) { - return toDate(value, field, fallback).toISOString(); -} - -function assertSafeResourceType(value, field = 'resourceType') { - if (typeof value !== 'string' || !RESOURCE_TYPE_PATTERN.test(value)) { - throw retentionError(`${field} is invalid`, 'INVALID_RESOURCE_TYPE'); - } - return value; -} - -function assertRetentionDays(value) { - if (!Number.isInteger(value) || value < 1 || value > 3650) { - throw retentionError('retentionDays must be an integer between 1 and 3650', 'INVALID_RETENTION_POLICY'); - } - return value; -} - -function assertBatchSize(value, maxBatchSize) { - if (!Number.isInteger(value) || value < 1 || value > maxBatchSize) { - throw retentionError(`limit must be an integer between 1 and ${maxBatchSize}`, 'INVALID_BATCH_SIZE'); - } - return value; -} - -function cloneAndFreeze(value) { - if (Array.isArray(value)) return Object.freeze(value.map((item) => cloneAndFreeze(item))); - if (value && typeof value === 'object') { - const copy = {}; - for (const [key, item] of Object.entries(value)) copy[key] = cloneAndFreeze(item); - return Object.freeze(copy); - } - return value; -} - -function normalizeActor(actor, fallbackUserId = undefined) { - if (actor === undefined) { - return Object.freeze({ - actorId: fallbackUserId ?? 'retention-worker', - role: fallbackUserId ? 'user' : 'system', - }); - } - assertObject(actor, 'actor'); - const actorId = assertId(actor.actorId, 'actor.actorId'); - const role = assertRole(actor.role ?? 'user', 'actor.role'); - return Object.freeze({ actorId, role }); -} - -function normalizeScope(input, { requireItem = true } = {}) { - assertObject(input, 'scope'); - const tenantId = assertId(input.tenantId, 'scope.tenantId'); - const userId = assertId(input.userId, 'scope.userId'); - const result = { tenantId, userId }; - if (requireItem) result.itemId = assertId(input.itemId, 'scope.itemId'); - return result; -} - -function scopeKey({ tenantId, userId, itemId }) { - return `${tenantId}\u0000${userId}\u0000${itemId}`; -} - -function operationKey(scope, idempotencyKey) { - return `${scopeKey(scope)}\u0000${idempotencyKey}`; -} - -function normalizeHoldId(value) { - return assertOperationId(value, 'holdId'); -} - -function normalizeReasonCode(value) { - if (typeof value !== 'string' || value.length < 1 || value.length > MAX_REASON_LENGTH || /[\r\n]/u.test(value)) { - throw retentionError(`reasonCode must contain 1-${MAX_REASON_LENGTH} single-line characters`, 'INVALID_HOLD_REASON'); - } - return value; -} - -function assertLeaseMs(value) { - if (!Number.isInteger(value) || value < 1000 || value > 24 * 60 * 60 * 1000) { - throw retentionError('leaseMs must be between 1000 and 86400000 milliseconds', 'INVALID_LOCK_LEASE'); - } - return value; -} - -function createMetrics() { - return Object.fromEntries(Object.values(RETENTION_METRIC_NAMES).map((name) => [name, 0])); -} - -function increment(metrics, name, amount = 1) { - metrics[name] = (metrics[name] ?? 0) + amount; -} - -function createAuditEvent({ event, scope, actor, occurredAt, operationId, metadata = {} }) { - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - event, - occurredAt: toIso(occurredAt, 'occurredAt'), - tenantId: scope.tenantId, - userId: scope.userId, - ...(scope.itemId ? { itemId: scope.itemId } : {}), - actorId: actor.actorId, - actorRole: actor.role, - ...(operationId ? { operationId } : {}), - metadata, - }); -} - -function publicItem(record) { - if (!record) return null; - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - tenantId: record.tenantId, - userId: record.userId, - itemId: record.itemId, - resourceType: record.resourceType, - generation: record.generation, - state: record.state, - deletedAt: record.deletedAt, - retentionUntil: record.retentionUntil, - restoredAt: record.restoredAt, - purgedAt: record.purgedAt, - holdIds: [...record.holds.keys()], - lock: record.lock - ? { - owner: record.lock.owner, - acquiredAt: record.lock.acquiredAt, - leaseExpiresAt: record.lock.leaseExpiresAt, - } - : null, - }); -} - -function publicOperationResult(result) { - return cloneAndFreeze(result); -} - -function activeHolds(record, now) { - const nowMs = now.getTime(); - return [...record.holds.values()].filter((hold) => hold.expiresAt === null || new Date(hold.expiresAt).getTime() > nowMs); -} - -function isLockActive(record, now) { - return record.lock !== null && new Date(record.lock.leaseExpiresAt).getTime() > now.getTime(); -} - -function assertPurgeWorker(actor) { - if (actor.role !== 'system' && actor.role !== 'operations' && actor.role !== 'provider') { - throw retentionError('only an operational worker may purge content', 'PURGE_ACTOR_DENIED'); - } -} - -/** - * Create an in-memory retention contract suitable for deterministic tests and - * for an adapter-backed implementation. The contract deliberately returns - * metadata envelopes, never message bodies, calendar data, or contact data. - */ -export function createRetentionStore({ - now = () => new Date(), - retentionDays = DEFAULT_TRASH_RETENTION_DAYS, - maxBatchSize = DEFAULT_PURGE_BATCH_SIZE, - resourceTypes = DEFAULT_RESOURCE_TYPES, - purgeItem = undefined, -} = {}) { - if (typeof now !== 'function') throw retentionError('now must be a function', 'INVALID_CLOCK'); - const policyDays = assertRetentionDays(retentionDays); - if (!Number.isInteger(maxBatchSize) || maxBatchSize < 1 || maxBatchSize > 10_000) { - throw retentionError('maxBatchSize must be between 1 and 10000', 'INVALID_BATCH_SIZE'); - } - if (typeof purgeItem !== 'undefined' && typeof purgeItem !== 'function') { - throw retentionError('purgeItem must be a function when provided', 'INVALID_PURGE_ADAPTER'); - } - const allowedResourceTypes = new Set(resourceTypes); - for (const type of allowedResourceTypes) assertSafeResourceType(type, 'resourceTypes item'); - - const records = new Map(); - const operationResults = new Map(); - const auditEvents = []; - const metrics = createMetrics(); - - function currentDate() { - return toDate(now(), 'clock'); - } - - function requireItem(input) { - const scope = normalizeScope(input); - const record = records.get(scopeKey(scope)); - if (!record) throw retentionError('item does not exist', 'ITEM_NOT_FOUND'); - return { scope, record }; - } - - function appendAudit(event) { - auditEvents.push(event); - return event; - } - - function checkResourceType(resourceType) { - const type = assertSafeResourceType(resourceType); - if (!allowedResourceTypes.has(type)) { - throw retentionError(`resourceType ${type} is not enabled`, 'RESOURCE_TYPE_DISABLED'); - } - return type; - } - - function markDeleted({ - tenantId, - userId, - itemId, - resourceType, - deletedAt = currentDate(), - idempotencyKey = randomUUID(), - actor = undefined, - } = {}) { - const scope = normalizeScope({ tenantId, userId, itemId }); - const type = checkResourceType(resourceType); - const key = assertOperationId(idempotencyKey, 'idempotencyKey'); - const opKey = operationKey(scope, key); - const priorResult = operationResults.get(`delete\u0000${opKey}`); - if (priorResult) return priorResult; - const actorInfo = normalizeActor(actor, userId); - const deletionDate = toDate(deletedAt, 'deletedAt'); - const old = records.get(scopeKey(scope)); - const generation = old ? old.generation + 1 : 1; - const record = { - ...scope, - resourceType: type, - generation, - state: RETENTION_ITEM_STATES.TRASHED, - deletedAt: deletionDate.toISOString(), - retentionUntil: new Date(deletionDate.getTime() + policyDays * DAY_MS).toISOString(), - restoredAt: null, - purgedAt: null, - holds: old?.holds ?? new Map(), - lock: null, - }; - records.set(scopeKey(scope), record); - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_TRASHED); - const audit = appendAudit(createAuditEvent({ - event: 'retention.item_trashed', - scope, - actor: actorInfo, - occurredAt: deletionDate, - operationId: key, - metadata: { - resourceType: type, - generation, - retentionUntil: record.retentionUntil, - }, - })); - const result = publicOperationResult({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'item_trashed', - idempotencyKey: key, - item: publicItem(record), - audit, - }); - operationResults.set(`delete\u0000${opKey}`, result); - return result; - } - - function restoreItem({ - tenantId, - userId, - itemId, - restoredAt = currentDate(), - idempotencyKey = randomUUID(), - actor = undefined, - } = {}) { - const scope = normalizeScope({ tenantId, userId, itemId }); - const key = assertOperationId(idempotencyKey, 'idempotencyKey'); - const opKey = operationKey(scope, key); - const priorResult = operationResults.get(`restore\u0000${opKey}`); - if (priorResult) return priorResult; - const actorInfo = normalizeActor(actor, userId); - const { record } = requireItem(scope); - if (record.state === RETENTION_ITEM_STATES.PURGED) { - throw retentionError('purged content cannot be restored by this contract', 'ITEM_ALREADY_PURGED'); - } - const restoreDate = toDate(restoredAt, 'restoredAt'); - record.state = RETENTION_ITEM_STATES.RESTORED; - record.restoredAt = restoreDate.toISOString(); - record.retentionUntil = null; - record.lock = null; - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_RESTORED); - const audit = appendAudit(createAuditEvent({ - event: 'retention.item_restored', - scope, - actor: actorInfo, - occurredAt: restoreDate, - operationId: key, - metadata: { generation: record.generation }, - })); - const result = publicOperationResult({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'item_restored', - idempotencyKey: key, - item: publicItem(record), - audit, - }); - operationResults.set(`restore\u0000${opKey}`, result); - return result; - } - - function addHold({ - tenantId, - userId, - itemId, - holdId, - reasonCode, - expiresAt = null, - actor, - occurredAt = currentDate(), - } = {}) { - const scope = normalizeScope({ tenantId, userId, itemId }); - const hold = normalizeHoldId(holdId); - const reason = normalizeReasonCode(reasonCode); - const actorInfo = normalizeActor(actor, userId); - const { record } = requireItem(scope); - const expiry = expiresAt === null ? null : toIso(expiresAt, 'expiresAt'); - if (expiry && new Date(expiry).getTime() <= currentDate().getTime()) { - throw retentionError('expiresAt must be in the future', 'INVALID_HOLD_EXPIRY'); - } - if (record.holds.has(hold)) return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'hold_added', - holdId: hold, - item: publicItem(record), - idempotent: true, - }); - record.holds.set(hold, { - holdId: hold, - reasonCode: reason, - createdAt: toIso(occurredAt, 'occurredAt'), - expiresAt: expiry, - }); - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_HELD); - increment(metrics, RETENTION_METRIC_NAMES.HOLDS_ADDED); - const audit = appendAudit(createAuditEvent({ - event: 'retention.hold_added', - scope, - actor: actorInfo, - occurredAt, - metadata: { holdId: hold, reasonCode: reason, expiresAt: expiry }, - })); - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'hold_added', - holdId: hold, - item: publicItem(record), - audit, - }); - } - - function releaseHold({ - tenantId, - userId, - itemId, - holdId, - actor, - occurredAt = currentDate(), - } = {}) { - const scope = normalizeScope({ tenantId, userId, itemId }); - const hold = normalizeHoldId(holdId); - const actorInfo = normalizeActor(actor, userId); - const { record } = requireItem(scope); - if (!record.holds.has(hold)) { - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'hold_released', - holdId: hold, - item: publicItem(record), - idempotent: true, - }); - } - record.holds.delete(hold); - increment(metrics, RETENTION_METRIC_NAMES.HOLDS_RELEASED); - const audit = appendAudit(createAuditEvent({ - event: 'retention.hold_released', - scope, - actor: actorInfo, - occurredAt, - metadata: { holdId: hold }, - })); - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'hold_released', - holdId: hold, - item: publicItem(record), - audit, - }); - } - - function acquireLock({ - tenantId, - userId, - itemId, - lockId = randomUUID(), - owner, - leaseMs = DEFAULT_LOCK_LEASE_MS, - acquiredAt = currentDate(), - } = {}) { - const scope = normalizeScope({ tenantId, userId, itemId }); - const lock = assertOperationId(lockId, 'lockId'); - const lockOwner = assertId(owner, 'owner'); - const duration = assertLeaseMs(leaseMs); - const date = toDate(acquiredAt, 'acquiredAt'); - const { record } = requireItem(scope); - if (record.lock && isLockActive(record, date) && record.lock.owner !== lockOwner) { - throw retentionError('item is locked by another worker', 'ITEM_LOCKED'); - } - record.lock = { - lockId: lock, - owner: lockOwner, - acquiredAt: date.toISOString(), - leaseExpiresAt: new Date(date.getTime() + duration).toISOString(), - }; - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_LOCKED); - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'item_locked', - lockId: lock, - item: publicItem(record), - }); - } - - function releaseLock({ tenantId, userId, itemId, lockId, owner } = {}) { - const scope = normalizeScope({ tenantId, userId, itemId }); - const lock = assertOperationId(lockId, 'lockId'); - const lockOwner = assertId(owner, 'owner'); - const { record } = requireItem(scope); - if (!record.lock || record.lock.lockId !== lock || record.lock.owner !== lockOwner) { - throw retentionError('lock does not belong to the owner', 'LOCK_NOT_OWNED'); - } - record.lock = null; - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'item_unlocked', - item: publicItem(record), - }); - } - - function listCandidates({ tenantId, userId, asOf = currentDate(), limit = maxBatchSize } = {}) { - const date = toDate(asOf, 'asOf'); - const batch = assertBatchSize(limit, maxBatchSize); - const tenant = tenantId === undefined ? undefined : assertId(tenantId, 'tenantId'); - const user = userId === undefined ? undefined : assertId(userId, 'userId'); - if (user && !tenant) throw retentionError('tenantId is required with userId', 'INVALID_SCOPE'); - return [...records.values()] - .filter((record) => record.state === RETENTION_ITEM_STATES.TRASHED) - .filter((record) => tenant === undefined || record.tenantId === tenant) - .filter((record) => user === undefined || record.userId === user) - .filter((record) => new Date(record.retentionUntil).getTime() <= date.getTime()) - .filter((record) => activeHolds(record, date).length === 0) - .filter((record) => !isLockActive(record, date)) - .sort((left, right) => left.retentionUntil.localeCompare(right.retentionUntil) || scopeKey(left).localeCompare(scopeKey(right))) - .slice(0, batch) - .map((record) => publicItem(record)); - } - - function runPurgeBatch({ - workerId, - operationId = randomUUID(), - tenantId = undefined, - userId = undefined, - limit = maxBatchSize, - startedAt = currentDate(), - purgeItem: purgeCallback = purgeItem, - } = {}) { - const worker = assertId(workerId, 'workerId'); - const operation = assertOperationId(operationId); - const batch = assertBatchSize(limit, maxBatchSize); - const actor = normalizeActor({ actorId: worker, role: 'system' }); - const tenant = tenantId === undefined ? '*' : assertId(tenantId, 'tenantId'); - const user = userId === undefined ? '*' : assertId(userId, 'userId'); - if (user !== '*' && tenant === '*') throw retentionError('tenantId is required with userId', 'INVALID_SCOPE'); - const resultKey = `purge\u0000${worker}\u0000${tenant}\u0000${user}\u0000${operation}`; - const priorResult = operationResults.get(resultKey); - if (priorResult) return priorResult; - assertPurgeWorker(actor); - const start = toDate(startedAt, 'startedAt'); - const candidates = listCandidates({ - tenantId: tenant === '*' ? undefined : tenant, - userId: user === '*' ? undefined : user, - asOf: start, - limit: batch, - }); - increment(metrics, RETENTION_METRIC_NAMES.PURGE_BATCHES); - increment(metrics, RETENTION_METRIC_NAMES.PURGE_CANDIDATES, candidates.length); - const purged = []; - const skipped = []; - const failures = []; - const events = []; - - for (const candidate of candidates) { - const key = scopeKey(candidate); - const record = records.get(key); - if (!record || record.state !== RETENTION_ITEM_STATES.TRASHED) { - skipped.push({ itemId: candidate.itemId, reason: 'state_changed' }); - continue; - } - if (new Date(record.retentionUntil).getTime() > start.getTime()) { - skipped.push({ itemId: candidate.itemId, reason: 'retention_not_elapsed' }); - continue; - } - if (activeHolds(record, start).length > 0) { - skipped.push({ itemId: candidate.itemId, reason: 'retention_hold' }); - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_HELD); - continue; - } - if (isLockActive(record, start)) { - skipped.push({ itemId: candidate.itemId, reason: 'item_locked' }); - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_SKIPPED); - continue; - } - record.lock = { - lockId: operation, - owner: worker, - acquiredAt: start.toISOString(), - leaseExpiresAt: new Date(start.getTime() + DEFAULT_LOCK_LEASE_MS).toISOString(), - }; - let purgeSucceeded = true; - try { - const adapterResult = purgeCallback - ? purgeCallback(publicItem(record)) - : undefined; - if (adapterResult === false) { - purgeSucceeded = false; - throw retentionError('purge adapter rejected the item', 'PURGE_REJECTED'); - } - if (adapterResult && typeof adapterResult.then === 'function') { - throw retentionError('purgeItem must be synchronous in this contract', 'ASYNC_PURGE_ADAPTER'); - } - } catch (error) { - purgeSucceeded = false; - failures.push({ itemId: record.itemId, code: error.code ?? 'PURGE_ADAPTER_ERROR' }); - increment(metrics, RETENTION_METRIC_NAMES.PURGE_FAILURES); - const failureAudit = appendAudit(createAuditEvent({ - event: 'retention.purge_failed', - scope: record, - actor, - occurredAt: start, - operationId: operation, - metadata: { resourceType: record.resourceType, generation: record.generation, code: error.code ?? 'PURGE_ADAPTER_ERROR' }, - })); - events.push(failureAudit); - } - if (!purgeSucceeded) { - record.lock = null; - skipped.push({ itemId: record.itemId, reason: 'purge_failed' }); - continue; - } - // Re-read the metadata after the adapter callback. A restore or hold - // added by a concurrent adapter must win over a stale purge decision. - if ( - record.state !== RETENTION_ITEM_STATES.TRASHED || - new Date(record.retentionUntil).getTime() > start.getTime() || - activeHolds(record, start).length > 0 - ) { - record.lock = null; - skipped.push({ itemId: record.itemId, reason: 'state_changed' }); - continue; - } - record.state = RETENTION_ITEM_STATES.PURGED; - record.purgedAt = start.toISOString(); - record.lock = null; - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_PURGED); - const purgeAudit = appendAudit(createAuditEvent({ - event: 'retention.purge', - scope: record, - actor, - occurredAt: start, - operationId: operation, - metadata: { resourceType: record.resourceType, generation: record.generation }, - })); - events.push(purgeAudit); - purged.push(publicItem(record)); - } - - increment(metrics, RETENTION_METRIC_NAMES.ITEMS_SKIPPED, skipped.length); - const completedAt = currentDate(); - const remaining = listCandidates({ - tenantId: tenant === '*' ? undefined : tenant, - userId: user === '*' ? undefined : user, - asOf: completedAt, - limit: maxBatchSize, - }).length; - const result = publicOperationResult({ - schemaVersion: RETENTION_SCHEMA_VERSION, - operation: 'purge_batch', - operationId: operation, - workerId: worker, - startedAt: start.toISOString(), - completedAt: completedAt.toISOString(), - batchSize: batch, - scanned: candidates.length, - purged: purged.length, - skipped: skipped.length, - failed: failures.length, - remaining, - items: purged, - skippedItems: skipped, - failures, - auditEvents: events, - metrics: { ...metrics }, - }); - operationResults.set(resultKey, result); - return result; - } - - function getItem(input) { - return publicItem(requireItem(input).record); - } - - function getAuditEvents({ tenantId = undefined, userId = undefined, event = undefined } = {}) { - const tenant = tenantId === undefined ? undefined : assertId(tenantId, 'tenantId'); - const user = userId === undefined ? undefined : assertId(userId, 'userId'); - if (user && !tenant) throw retentionError('tenantId is required with userId', 'INVALID_SCOPE'); - if (event !== undefined && (typeof event !== 'string' || !/^retention\.[a-z_]+$/u.test(event))) { - throw retentionError('event is invalid', 'INVALID_AUDIT_FILTER'); - } - return cloneAndFreeze(auditEvents.filter((entry) => - (tenant === undefined || entry.tenantId === tenant) && - (user === undefined || entry.userId === user) && - (event === undefined || entry.event === event))); - } - - function getMetrics() { - return cloneAndFreeze({ ...metrics }); - } - - function exportState() { - return cloneAndFreeze({ - schemaVersion: RETENTION_SCHEMA_VERSION, - retentionDays: policyDays, - items: [...records.values()].map((record) => publicItem(record)), - metrics: { ...metrics }, - }); - } - - return Object.freeze({ - retentionDays: policyDays, - maxBatchSize, - markDeleted, - restoreItem, - addHold, - releaseHold, - acquireLock, - releaseLock, - listCandidates, - runPurgeBatch, - getItem, - getAuditEvents, - getMetrics, - exportState, - }); -} - -export { - assertId, - assertOperationId, - retentionError, -}; +// Temporary compatibility bridge. Retention behavior lives in TypeScript. +export * from './retention.ts'; diff --git a/src/lifecycle/retention.test.ts b/src/lifecycle/retention.test.ts new file mode 100644 index 0000000..d2022a3 --- /dev/null +++ b/src/lifecycle/retention.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRetentionStore } from './retention.ts'; + +const scope = { tenantId: 'acme', userId: 'alice', itemId: 'message-001' }; + +test('retention enforces the 28-day boundary and resumes idempotently after an adapter failure', () => { + let clock = new Date('2026-08-01T00:00:00Z'); + let attempts = 0; + const store = createRetentionStore({ now: () => clock, purgeItem: () => { attempts += 1; return attempts > 1; } }); + store.markDeleted({ ...scope, resourceType: 'mail', idempotencyKey: 'delete-001' }); + clock = new Date('2026-08-28T23:59:59.999Z'); + assert.equal(store.runPurgeBatch({ workerId: 'worker-001', operationId: 'before-boundary' }).purged, 0); + clock = new Date('2026-08-29T00:00:00Z'); + const failed = store.runPurgeBatch({ workerId: 'worker-001', operationId: 'failed-batch' }); + assert.equal(failed.failed, 1); + const completed = store.runPurgeBatch({ workerId: 'worker-001', operationId: 'resumed-batch' }); + assert.equal(completed.purged, 1); + assert.deepEqual(store.runPurgeBatch({ workerId: 'worker-001', operationId: 'resumed-batch' }), completed); +}); + +test('holds, locks, restores, tenant scope and audit stay safe and metadata-only', () => { + let clock = new Date('2026-08-01T00:00:00Z'); + const store = createRetentionStore({ now: () => clock }); + store.markDeleted({ ...scope, resourceType: 'mail', idempotencyKey: 'delete-002' }); + store.addHold({ ...scope, holdId: 'hold-001', reasonCode: 'legal_hold' }); + clock = new Date('2026-08-29T00:00:00Z'); + assert.equal(store.listCandidates().length, 0); + store.releaseHold({ ...scope, holdId: 'hold-001' }); + store.acquireLock({ ...scope, lockId: 'lock-001', owner: 'worker-lock' }); + assert.equal(store.listCandidates().length, 0); + store.releaseLock({ ...scope, lockId: 'lock-001', owner: 'worker-lock' }); + store.restoreItem({ ...scope, idempotencyKey: 'restore-001' }); + assert.equal(store.listCandidates().length, 0); + assert.throws(() => store.getItem({ ...scope, tenantId: 'other-tenant' }), (error: unknown) => (error as { code?: string }).code === 'ITEM_NOT_FOUND'); + for (const event of store.getAuditEvents({ tenantId: scope.tenantId, userId: scope.userId })) { + assert.equal(Object.hasOwn(event, 'content'), false); + assert.equal(Object.hasOwn(event.metadata, 'content'), false); + } +}); diff --git a/src/lifecycle/retention.ts b/src/lifecycle/retention.ts new file mode 100644 index 0000000..2c014e4 --- /dev/null +++ b/src/lifecycle/retention.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) + +import { randomUUID } from 'node:crypto'; + +/** Metadata-only trash retention contract. Content remains in adapter stores. */ +export const RETENTION_SCHEMA_VERSION = 1 as const; +export const DEFAULT_TRASH_RETENTION_DAYS = 28; +export const DEFAULT_PURGE_BATCH_SIZE = 100; +export const DEFAULT_LOCK_LEASE_MS = 30_000; +export const RETENTION_ITEM_STATES = Object.freeze({ TRASHED: 'trashed', RESTORED: 'restored', PURGED: 'purged' } as const); +export const RETENTION_METRIC_NAMES = Object.freeze({ + ITEMS_TRASHED: 'gulogulo_retention_items_trashed_total', ITEMS_RESTORED: 'gulogulo_retention_items_restored_total', ITEMS_HELD: 'gulogulo_retention_items_held_total', ITEMS_LOCKED: 'gulogulo_retention_items_locked_total', PURGE_BATCHES: 'gulogulo_retention_purge_batches_total', PURGE_CANDIDATES: 'gulogulo_retention_purge_candidates_total', ITEMS_PURGED: 'gulogulo_retention_items_purged_total', ITEMS_SKIPPED: 'gulogulo_retention_items_skipped_total', PURGE_FAILURES: 'gulogulo_retention_purge_failures_total', HOLDS_ADDED: 'gulogulo_retention_holds_added_total', HOLDS_RELEASED: 'gulogulo_retention_holds_released_total', +} as const); + +export type RetentionItemState = typeof RETENTION_ITEM_STATES[keyof typeof RETENTION_ITEM_STATES]; +export interface RetentionScope { readonly tenantId: string; readonly userId: string; readonly itemId: string; } +export interface RetentionItem extends RetentionScope { + readonly schemaVersion: typeof RETENTION_SCHEMA_VERSION; readonly resourceType: string; readonly generation: number; readonly state: RetentionItemState; + readonly deletedAt: string; readonly retentionUntil: string | null; readonly restoredAt: string | null; readonly purgedAt: string | null; + readonly holdIds: readonly string[]; readonly lock: Readonly<{ owner: string; acquiredAt: string; leaseExpiresAt: string }> | null; +} +export interface RetentionAuditEvent extends RetentionScope { readonly schemaVersion: typeof RETENTION_SCHEMA_VERSION; readonly event: string; readonly occurredAt: string; readonly actorId: string; readonly actorRole: string; readonly operationId?: string; readonly metadata: Readonly>; } +export interface RetentionItemOperation { readonly schemaVersion: typeof RETENTION_SCHEMA_VERSION; readonly operation: string; readonly item: RetentionItem; readonly audit?: RetentionAuditEvent; readonly idempotencyKey?: string; readonly holdId?: string; readonly lockId?: string; readonly idempotent?: boolean; } +export interface PurgeBatchResult { readonly schemaVersion: typeof RETENTION_SCHEMA_VERSION; readonly operation: 'purge_batch'; readonly operationId: string; readonly workerId: string; readonly startedAt: string; readonly completedAt: string; readonly batchSize: number; readonly scanned: number; readonly purged: number; readonly skipped: number; readonly failed: number; readonly remaining: number; readonly items: readonly RetentionItem[]; readonly skippedItems: readonly Readonly<{ itemId: string; reason: string }>[]; readonly failures: readonly Readonly<{ itemId: string; code: string }>[]; readonly auditEvents: readonly RetentionAuditEvent[]; readonly metrics: Readonly>; } + +type UnknownRecord = Record; +type DateInput = Date | string | number; +type Actor = { readonly actorId: string; readonly role: string }; +type Hold = { readonly holdId: string; readonly reasonCode: string; readonly createdAt: string; readonly expiresAt: string | null }; +type Lock = { readonly lockId: string; readonly owner: string; readonly acquiredAt: string; readonly leaseExpiresAt: string }; +type RecordItem = { tenantId: string; userId: string; itemId: string; resourceType: string; generation: number; state: RetentionItemState; deletedAt: string; retentionUntil: string | null; restoredAt: string | null; purgedAt: string | null; holds: Map; lock: Lock | null }; + +const DAY_MS = 86_400_000; +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/u; +const OPERATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const RESOURCE_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/u; +const ROLE_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/u; +const DEFAULT_RESOURCE_TYPES = new Set(['mail', 'calendar', 'contacts', 'preferences', 'backup']); + +export function retentionError(message: string, code = 'RETENTION_ERROR'): Error & { code: string } { const error = new Error(`Retention error: ${message}`) as Error & { code: string }; error.code = code; return error; } +function isObject(value: unknown): value is UnknownRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); } +function object(value: unknown, field: string): UnknownRecord { if (!isObject(value)) throw retentionError(`${field} must be an object`, 'INVALID_INPUT'); return value; } +export function assertId(value: unknown, field: string): string { if (typeof value !== 'string' || !ID_PATTERN.test(value)) throw retentionError(`${field} is invalid`, 'INVALID_IDENTITY'); return value; } +export function assertOperationId(value: unknown, field = 'operationId'): string { if (typeof value !== 'string' || !OPERATION_PATTERN.test(value)) throw retentionError(`${field} is invalid`, 'INVALID_OPERATION_ID'); return value; } +function date(value: unknown, field: string, fallback?: DateInput): Date { const selected = value === undefined ? fallback : value; const parsed = selected instanceof Date ? new Date(selected.getTime()) : new Date(selected as string | number); if (Number.isNaN(parsed.getTime())) throw retentionError(`${field} is invalid`, 'INVALID_TIMESTAMP'); return parsed; } +function iso(value: unknown, field: string, fallback?: DateInput): string { return date(value, field, fallback).toISOString(); } +function retentionDays(value: unknown): number { if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 3650) throw retentionError('retentionDays must be an integer between 1 and 3650', 'INVALID_RETENTION_POLICY'); return value as number; } +function batchSize(value: unknown, max: number): number { if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > max) throw retentionError(`limit must be an integer between 1 and ${max}`, 'INVALID_BATCH_SIZE'); return value as number; } +function freeze(value: T): Readonly { if (Array.isArray(value)) return Object.freeze(value.map(freeze)) as unknown as Readonly; if (isObject(value)) { const copy: UnknownRecord = {}; for (const [key, item] of Object.entries(value)) copy[key] = freeze(item); return Object.freeze(copy) as Readonly; } return value as Readonly; } +function actor(value: unknown, fallback?: string): Actor { if (value === undefined) return Object.freeze({ actorId: fallback ?? 'retention-worker', role: fallback ? 'user' : 'system' }); const input = object(value, 'actor'); const role = input.role ?? 'user'; if (typeof role !== 'string' || !ROLE_PATTERN.test(role)) throw retentionError('actor.role is invalid', 'INVALID_ROLE'); return Object.freeze({ actorId: assertId(input.actorId, 'actor.actorId'), role }); } +function scope(value: unknown): RetentionScope { const input = object(value, 'scope'); return { tenantId: assertId(input.tenantId, 'scope.tenantId'), userId: assertId(input.userId, 'scope.userId'), itemId: assertId(input.itemId, 'scope.itemId') }; } +function key(value: RetentionScope): string { return `${value.tenantId}\u0000${value.userId}\u0000${value.itemId}`; } +function activeHolds(item: RecordItem, now: Date): Hold[] { return [...item.holds.values()].filter((hold) => hold.expiresAt === null || new Date(hold.expiresAt).getTime() > now.getTime()); } +function locked(item: RecordItem, now: Date): boolean { return item.lock !== null && new Date(item.lock.leaseExpiresAt).getTime() > now.getTime(); } +function publicItem(item: RecordItem): RetentionItem { return freeze({ schemaVersion: RETENTION_SCHEMA_VERSION, tenantId: item.tenantId, userId: item.userId, itemId: item.itemId, resourceType: item.resourceType, generation: item.generation, state: item.state, deletedAt: item.deletedAt, retentionUntil: item.retentionUntil, restoredAt: item.restoredAt, purgedAt: item.purgedAt, holdIds: [...item.holds.keys()], lock: item.lock ? { owner: item.lock.owner, acquiredAt: item.lock.acquiredAt, leaseExpiresAt: item.lock.leaseExpiresAt } : null }) as RetentionItem; } +function audit(input: { event: string; item: RetentionScope; actor: Actor; occurredAt: unknown; operationId?: string; metadata?: UnknownRecord }): RetentionAuditEvent { return freeze({ schemaVersion: RETENTION_SCHEMA_VERSION, event: input.event, occurredAt: iso(input.occurredAt, 'occurredAt'), tenantId: input.item.tenantId, userId: input.item.userId, itemId: input.item.itemId, actorId: input.actor.actorId, actorRole: input.actor.role, ...(input.operationId ? { operationId: input.operationId } : {}), metadata: input.metadata ?? {} }) as RetentionAuditEvent; } +function resource(value: unknown): string { if (typeof value !== 'string' || !RESOURCE_PATTERN.test(value)) throw retentionError('resourceType is invalid', 'INVALID_RESOURCE_TYPE'); return value; } + +export interface RetentionStore { + readonly retentionDays: number; readonly maxBatchSize: number; + markDeleted(input?: UnknownRecord): RetentionItemOperation; restoreItem(input?: UnknownRecord): RetentionItemOperation; addHold(input?: UnknownRecord): RetentionItemOperation; releaseHold(input?: UnknownRecord): RetentionItemOperation; acquireLock(input?: UnknownRecord): RetentionItemOperation; releaseLock(input?: UnknownRecord): RetentionItemOperation; listCandidates(input?: UnknownRecord): readonly RetentionItem[]; runPurgeBatch(input?: UnknownRecord): PurgeBatchResult; getItem(input: RetentionScope): RetentionItem; getAuditEvents(input?: UnknownRecord): readonly RetentionAuditEvent[]; getMetrics(): Readonly>; exportState(): Readonly<{ schemaVersion: typeof RETENTION_SCHEMA_VERSION; retentionDays: number; items: readonly RetentionItem[]; metrics: Readonly> }>; +} +export function createRetentionStore({ now = () => new Date(), retentionDays: configuredDays = DEFAULT_TRASH_RETENTION_DAYS, maxBatchSize = DEFAULT_PURGE_BATCH_SIZE, resourceTypes = DEFAULT_RESOURCE_TYPES, purgeItem }: { now?: () => DateInput; retentionDays?: number; maxBatchSize?: number; resourceTypes?: Iterable; purgeItem?: (item: RetentionItem) => unknown } = {}): RetentionStore { + if (typeof now !== 'function') throw retentionError('now must be a function', 'INVALID_CLOCK'); const policyDays = retentionDays(configuredDays); if (!Number.isInteger(maxBatchSize) || maxBatchSize < 1 || maxBatchSize > 10_000) throw retentionError('maxBatchSize must be between 1 and 10000', 'INVALID_BATCH_SIZE'); if (purgeItem !== undefined && typeof purgeItem !== 'function') throw retentionError('purgeItem must be a function when provided', 'INVALID_PURGE_ADAPTER'); + const allowed = new Set(resourceTypes); for (const type of allowed) resource(type); const records = new Map(); const results = new Map(); const events: RetentionAuditEvent[] = []; const metrics: Record = Object.fromEntries(Object.values(RETENTION_METRIC_NAMES).map((name) => [name, 0])); + const current = (): Date => date(now(), 'clock'); const increment = (name: string, amount = 1): void => { metrics[name] = (metrics[name] ?? 0) + amount; }; const requireItem = (input: unknown): { itemScope: RetentionScope; item: RecordItem } => { const itemScope = scope(input); const item = records.get(key(itemScope)); if (!item) throw retentionError('item does not exist', 'ITEM_NOT_FOUND'); return { itemScope, item }; }; + const itemOperation = (operation: string, item: RecordItem, value: UnknownRecord = {}): RetentionItemOperation => freeze({ schemaVersion: RETENTION_SCHEMA_VERSION, operation, item: publicItem(item), ...value }) as RetentionItemOperation; + const listCandidates = (input: UnknownRecord = {}): readonly RetentionItem[] => { const asOf = date(input.asOf, 'asOf', current()); const limit = batchSize(input.limit ?? maxBatchSize, maxBatchSize); const tenant = input.tenantId === undefined ? undefined : assertId(input.tenantId, 'tenantId'); const user = input.userId === undefined ? undefined : assertId(input.userId, 'userId'); if (user && !tenant) throw retentionError('tenantId is required with userId', 'INVALID_SCOPE'); return [...records.values()].filter((item) => item.state === RETENTION_ITEM_STATES.TRASHED && (tenant === undefined || item.tenantId === tenant) && (user === undefined || item.userId === user) && item.retentionUntil !== null && new Date(item.retentionUntil).getTime() <= asOf.getTime() && activeHolds(item, asOf).length === 0 && !locked(item, asOf)).sort((left, right) => (left.retentionUntil ?? '').localeCompare(right.retentionUntil ?? '') || key(left).localeCompare(key(right))).slice(0, limit).map(publicItem); }; + const markDeleted = (input: UnknownRecord = {}): RetentionItemOperation => { const itemScope = scope(input); const type = resource(input.resourceType); if (!allowed.has(type)) throw retentionError(`resourceType ${type} is not enabled`, 'RESOURCE_TYPE_DISABLED'); const id = assertOperationId(input.idempotencyKey ?? randomUUID(), 'idempotencyKey'); const resultKey = `delete\u0000${key(itemScope)}\u0000${id}`; const oldResult = results.get(resultKey); if (oldResult) return oldResult as RetentionItemOperation; const deletedAt = date(input.deletedAt, 'deletedAt', current()); const prior = records.get(key(itemScope)); const item: RecordItem = { ...itemScope, resourceType: type, generation: prior ? prior.generation + 1 : 1, state: RETENTION_ITEM_STATES.TRASHED, deletedAt: deletedAt.toISOString(), retentionUntil: new Date(deletedAt.getTime() + policyDays * DAY_MS).toISOString(), restoredAt: null, purgedAt: null, holds: prior?.holds ?? new Map(), lock: null }; records.set(key(itemScope), item); increment(RETENTION_METRIC_NAMES.ITEMS_TRASHED); const entry = audit({ event: 'retention.item_trashed', item: itemScope, actor: actor(input.actor, itemScope.userId), occurredAt: deletedAt, operationId: id, metadata: { resourceType: type, generation: item.generation, retentionUntil: item.retentionUntil } }); events.push(entry); const output = itemOperation('item_trashed', item, { idempotencyKey: id, audit: entry }); results.set(resultKey, output); return output; }; + const restoreItem = (input: UnknownRecord = {}): RetentionItemOperation => { const itemScope = scope(input); const id = assertOperationId(input.idempotencyKey ?? randomUUID(), 'idempotencyKey'); const resultKey = `restore\u0000${key(itemScope)}\u0000${id}`; const oldResult = results.get(resultKey); if (oldResult) return oldResult as RetentionItemOperation; const { item } = requireItem(itemScope); if (item.state === RETENTION_ITEM_STATES.PURGED) throw retentionError('purged content cannot be restored by this contract', 'ITEM_ALREADY_PURGED'); const restoredAt = date(input.restoredAt, 'restoredAt', current()); item.state = RETENTION_ITEM_STATES.RESTORED; item.restoredAt = restoredAt.toISOString(); item.retentionUntil = null; item.lock = null; increment(RETENTION_METRIC_NAMES.ITEMS_RESTORED); const entry = audit({ event: 'retention.item_restored', item: itemScope, actor: actor(input.actor, itemScope.userId), occurredAt: restoredAt, operationId: id, metadata: { generation: item.generation } }); events.push(entry); const output = itemOperation('item_restored', item, { idempotencyKey: id, audit: entry }); results.set(resultKey, output); return output; }; + const addHold = (input: UnknownRecord = {}): RetentionItemOperation => { const itemScope = scope(input); const holdId = assertOperationId(input.holdId, 'holdId'); if (typeof input.reasonCode !== 'string' || input.reasonCode.length < 1 || input.reasonCode.length > 256 || /[\r\n]/u.test(input.reasonCode)) throw retentionError('reasonCode must contain 1-256 single-line characters', 'INVALID_HOLD_REASON'); const { item } = requireItem(itemScope); const expiresAt = input.expiresAt === null || input.expiresAt === undefined ? null : iso(input.expiresAt, 'expiresAt'); if (expiresAt && new Date(expiresAt).getTime() <= current().getTime()) throw retentionError('expiresAt must be in the future', 'INVALID_HOLD_EXPIRY'); if (item.holds.has(holdId)) return itemOperation('hold_added', item, { holdId, idempotent: true }); item.holds.set(holdId, { holdId, reasonCode: input.reasonCode, createdAt: iso(input.occurredAt, 'occurredAt', current()), expiresAt }); increment(RETENTION_METRIC_NAMES.ITEMS_HELD); increment(RETENTION_METRIC_NAMES.HOLDS_ADDED); const entry = audit({ event: 'retention.hold_added', item: itemScope, actor: actor(input.actor, itemScope.userId), occurredAt: input.occurredAt ?? current(), metadata: { holdId, reasonCode: input.reasonCode, expiresAt } }); events.push(entry); return itemOperation('hold_added', item, { holdId, audit: entry }); }; + const releaseHold = (input: UnknownRecord = {}): RetentionItemOperation => { const itemScope = scope(input); const holdId = assertOperationId(input.holdId, 'holdId'); const { item } = requireItem(itemScope); if (!item.holds.has(holdId)) return itemOperation('hold_released', item, { holdId, idempotent: true }); item.holds.delete(holdId); increment(RETENTION_METRIC_NAMES.HOLDS_RELEASED); const entry = audit({ event: 'retention.hold_released', item: itemScope, actor: actor(input.actor, itemScope.userId), occurredAt: input.occurredAt ?? current(), metadata: { holdId } }); events.push(entry); return itemOperation('hold_released', item, { holdId, audit: entry }); }; + const acquireLock = (input: UnknownRecord = {}): RetentionItemOperation => { const itemScope = scope(input); const lockId = assertOperationId(input.lockId ?? randomUUID(), 'lockId'); const owner = assertId(input.owner, 'owner'); const leaseMs = input.leaseMs ?? DEFAULT_LOCK_LEASE_MS; if (!Number.isInteger(leaseMs) || (leaseMs as number) < 1000 || (leaseMs as number) > DAY_MS) throw retentionError('leaseMs must be between 1000 and 86400000 milliseconds', 'INVALID_LOCK_LEASE'); const acquiredAt = date(input.acquiredAt, 'acquiredAt', current()); const { item } = requireItem(itemScope); if (item.lock && locked(item, acquiredAt) && item.lock.owner !== owner) throw retentionError('item is locked by another worker', 'ITEM_LOCKED'); item.lock = { lockId, owner, acquiredAt: acquiredAt.toISOString(), leaseExpiresAt: new Date(acquiredAt.getTime() + (leaseMs as number)).toISOString() }; increment(RETENTION_METRIC_NAMES.ITEMS_LOCKED); return itemOperation('item_locked', item, { lockId }); }; + const releaseLock = (input: UnknownRecord = {}): RetentionItemOperation => { const itemScope = scope(input); const lockId = assertOperationId(input.lockId, 'lockId'); const owner = assertId(input.owner, 'owner'); const { item } = requireItem(itemScope); if (!item.lock || item.lock.lockId !== lockId || item.lock.owner !== owner) throw retentionError('lock does not belong to the owner', 'LOCK_NOT_OWNED'); item.lock = null; return itemOperation('item_unlocked', item); }; + const runPurgeBatch = (input: UnknownRecord = {}): PurgeBatchResult => { const workerId = assertId(input.workerId, 'workerId'); const operationId = assertOperationId(input.operationId ?? randomUUID()); const tenant = input.tenantId === undefined ? '*' : assertId(input.tenantId, 'tenantId'); const user = input.userId === undefined ? '*' : assertId(input.userId, 'userId'); if (user !== '*' && tenant === '*') throw retentionError('tenantId is required with userId', 'INVALID_SCOPE'); const limit = batchSize(input.limit ?? maxBatchSize, maxBatchSize); const resultKey = `purge\u0000${workerId}\u0000${tenant}\u0000${user}\u0000${operationId}`; const prior = results.get(resultKey); if (prior) return prior as PurgeBatchResult; const startedAt = date(input.startedAt, 'startedAt', current()); const callback = input.purgeItem === undefined ? purgeItem : input.purgeItem; if (callback !== undefined && typeof callback !== 'function') throw retentionError('purgeItem must be a function when provided', 'INVALID_PURGE_ADAPTER'); const candidates = listCandidates({ tenantId: tenant === '*' ? undefined : tenant, userId: user === '*' ? undefined : user, asOf: startedAt, limit }); increment(RETENTION_METRIC_NAMES.PURGE_BATCHES); increment(RETENTION_METRIC_NAMES.PURGE_CANDIDATES, candidates.length); const items: RetentionItem[] = []; const skippedItems: { itemId: string; reason: string }[] = []; const failures: { itemId: string; code: string }[] = []; const batchEvents: RetentionAuditEvent[] = []; const worker: Actor = { actorId: workerId, role: 'system' }; + for (const candidate of candidates) { const item = records.get(key(candidate)); if (!item || item.state !== RETENTION_ITEM_STATES.TRASHED || item.retentionUntil === null || new Date(item.retentionUntil).getTime() > startedAt.getTime() || activeHolds(item, startedAt).length > 0 || locked(item, startedAt)) { skippedItems.push({ itemId: candidate.itemId, reason: 'state_changed' }); continue; } item.lock = { lockId: operationId, owner: workerId, acquiredAt: startedAt.toISOString(), leaseExpiresAt: new Date(startedAt.getTime() + DEFAULT_LOCK_LEASE_MS).toISOString() }; try { const adapterResult = callback ? (callback as (value: RetentionItem) => unknown)(publicItem(item)) : undefined; if (adapterResult === false) throw retentionError('purge adapter rejected the item', 'PURGE_REJECTED'); if (isObject(adapterResult) && typeof adapterResult.then === 'function') throw retentionError('purgeItem must be synchronous in this contract', 'ASYNC_PURGE_ADAPTER'); } catch (error) { const code = isObject(error) && typeof error.code === 'string' ? error.code : 'PURGE_ADAPTER_ERROR'; failures.push({ itemId: item.itemId, code }); increment(RETENTION_METRIC_NAMES.PURGE_FAILURES); const entry = audit({ event: 'retention.purge_failed', item, actor: worker, occurredAt: startedAt, operationId, metadata: { resourceType: item.resourceType, generation: item.generation, code } }); events.push(entry); batchEvents.push(entry); item.lock = null; skippedItems.push({ itemId: item.itemId, reason: 'purge_failed' }); continue; } + if (item.state !== RETENTION_ITEM_STATES.TRASHED || item.retentionUntil === null || new Date(item.retentionUntil).getTime() > startedAt.getTime() || activeHolds(item, startedAt).length > 0) { item.lock = null; skippedItems.push({ itemId: item.itemId, reason: 'state_changed' }); continue; } item.state = RETENTION_ITEM_STATES.PURGED; item.purgedAt = startedAt.toISOString(); item.lock = null; increment(RETENTION_METRIC_NAMES.ITEMS_PURGED); const entry = audit({ event: 'retention.purge', item, actor: worker, occurredAt: startedAt, operationId, metadata: { resourceType: item.resourceType, generation: item.generation } }); events.push(entry); batchEvents.push(entry); items.push(publicItem(item)); } + increment(RETENTION_METRIC_NAMES.ITEMS_SKIPPED, skippedItems.length); const completedAt = current(); const remaining = listCandidates({ tenantId: tenant === '*' ? undefined : tenant, userId: user === '*' ? undefined : user, asOf: completedAt, limit: maxBatchSize }).length; const output = freeze({ schemaVersion: RETENTION_SCHEMA_VERSION, operation: 'purge_batch' as const, operationId, workerId, startedAt: startedAt.toISOString(), completedAt: completedAt.toISOString(), batchSize: limit, scanned: candidates.length, purged: items.length, skipped: skippedItems.length, failed: failures.length, remaining, items, skippedItems, failures, auditEvents: batchEvents, metrics: { ...metrics } }) as PurgeBatchResult; results.set(resultKey, output); return output; + }; + const getAuditEvents = (input: UnknownRecord = {}): readonly RetentionAuditEvent[] => { const tenant = input.tenantId === undefined ? undefined : assertId(input.tenantId, 'tenantId'); const user = input.userId === undefined ? undefined : assertId(input.userId, 'userId'); if (user && !tenant) throw retentionError('tenantId is required with userId', 'INVALID_SCOPE'); if (input.event !== undefined && (typeof input.event !== 'string' || !/^retention\.[a-z_]+$/u.test(input.event))) throw retentionError('event is invalid', 'INVALID_AUDIT_FILTER'); return freeze(events.filter((entry) => (tenant === undefined || entry.tenantId === tenant) && (user === undefined || entry.userId === user) && (input.event === undefined || entry.event === input.event))) as readonly RetentionAuditEvent[]; }; + return Object.freeze({ retentionDays: policyDays, maxBatchSize, markDeleted, restoreItem, addHold, releaseHold, acquireLock, releaseLock, listCandidates, runPurgeBatch, getItem: (input: RetentionScope): RetentionItem => publicItem(requireItem(input).item), getAuditEvents, getMetrics: (): Readonly> => freeze({ ...metrics }) as Readonly>, exportState: () => freeze({ schemaVersion: RETENTION_SCHEMA_VERSION, retentionDays: policyDays, items: [...records.values()].map(publicItem), metrics: { ...metrics } }) as Readonly<{ schemaVersion: typeof RETENTION_SCHEMA_VERSION; retentionDays: number; items: readonly RetentionItem[]; metrics: Readonly> }> }); +} diff --git a/tsconfig.lp6.json b/tsconfig.lp6.json new file mode 100644 index 0000000..df95cdb --- /dev/null +++ b/tsconfig.lp6.json @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) +// Author: Sythos (https://www.sythos.net) +{ + "extends": "./tsconfig.server.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": [ + "src/backup/backup-contract.ts", + "src/backup/index.ts", + "src/backup/backup-contract.test.ts", + "src/lifecycle/retention.ts", + "src/lifecycle/retention.test.ts", + "src/lifecycle/account-lifecycle.ts", + "src/lifecycle/account-lifecycle.test.ts", + "scripts/lp6-*.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +}