@cleocode/core 2026.9.24 → 2026.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/import-tasks.d.ts.map +1 -1
- package/dist/admin/import-tasks.js +5 -2
- package/dist/admin/import-tasks.js.map +1 -1
- package/dist/admin/import.d.ts.map +1 -1
- package/dist/admin/import.js +7 -2
- package/dist/admin/import.js.map +1 -1
- package/dist/audit-prune.d.ts +32 -0
- package/dist/audit-prune.d.ts.map +1 -1
- package/dist/audit-prune.js +37 -3
- package/dist/audit-prune.js.map +1 -1
- package/dist/build-provenance.json +2 -2
- package/dist/cloud/crypto.d.ts +1 -2
- package/dist/cloud/crypto.d.ts.map +1 -1
- package/dist/cloud/crypto.js +1 -11
- package/dist/cloud/crypto.js.map +1 -1
- package/dist/cloud/index.d.ts +1 -0
- package/dist/cloud/index.d.ts.map +1 -1
- package/dist/cloud/index.js +1 -0
- package/dist/cloud/index.js.map +1 -1
- package/dist/cloud/nexus-auth.d.ts +26 -1
- package/dist/cloud/nexus-auth.d.ts.map +1 -1
- package/dist/cloud/nexus-auth.js +61 -32
- package/dist/cloud/nexus-auth.js.map +1 -1
- package/dist/cloud/nexus-credentials.js +2 -2
- package/dist/cloud/nexus-credentials.js.map +1 -1
- package/dist/cloud/nexus-device.d.ts +809 -0
- package/dist/cloud/nexus-device.d.ts.map +1 -0
- package/dist/cloud/nexus-device.js +1680 -0
- package/dist/cloud/nexus-device.js.map +1 -0
- package/dist/cloud/nexus-enrol.d.ts +210 -0
- package/dist/cloud/nexus-enrol.d.ts.map +1 -0
- package/dist/cloud/nexus-enrol.js +1023 -0
- package/dist/cloud/nexus-enrol.js.map +1 -0
- package/dist/cloud/nexus-link.d.ts +3 -0
- package/dist/cloud/nexus-link.d.ts.map +1 -1
- package/dist/cloud/nexus-link.js +38 -4
- package/dist/cloud/nexus-link.js.map +1 -1
- package/dist/cloud/signing.d.ts +20 -0
- package/dist/cloud/signing.d.ts.map +1 -1
- package/dist/cloud/signing.js +14 -0
- package/dist/cloud/signing.js.map +1 -1
- package/dist/cloud/uuidv7.d.ts +9 -0
- package/dist/cloud/uuidv7.d.ts.map +1 -0
- package/dist/cloud/uuidv7.js +19 -0
- package/dist/cloud/uuidv7.js.map +1 -0
- package/dist/crypto/credentials.d.ts +61 -0
- package/dist/crypto/credentials.d.ts.map +1 -1
- package/dist/crypto/credentials.js +154 -58
- package/dist/crypto/credentials.js.map +1 -1
- package/dist/decide/bench/index.d.ts +1 -1
- package/dist/decide/bench/index.d.ts.map +1 -1
- package/dist/decide/bench/index.js +1 -1
- package/dist/decide/bench/index.js.map +1 -1
- package/dist/decide/bench/operation.d.ts +1 -1
- package/dist/decide/bench/operation.d.ts.map +1 -1
- package/dist/decide/bench/operation.js +1 -1
- package/dist/decide/bench/operation.js.map +1 -1
- package/dist/decide/bench/profiles.d.ts +50 -22
- package/dist/decide/bench/profiles.d.ts.map +1 -1
- package/dist/decide/bench/profiles.js +103 -59
- package/dist/decide/bench/profiles.js.map +1 -1
- package/dist/decide/bench/report.d.ts.map +1 -1
- package/dist/decide/bench/report.js +6 -1
- package/dist/decide/bench/report.js.map +1 -1
- package/dist/decide/bench/runner.d.ts +6 -1
- package/dist/decide/bench/runner.d.ts.map +1 -1
- package/dist/decide/bench/runner.js +1 -0
- package/dist/decide/bench/runner.js.map +1 -1
- package/dist/decide/client.d.ts.map +1 -1
- package/dist/decide/client.js +11 -5
- package/dist/decide/client.js.map +1 -1
- package/dist/decide/jev-wire.d.ts +31 -1
- package/dist/decide/jev-wire.d.ts.map +1 -1
- package/dist/decide/jev-wire.js +61 -7
- package/dist/decide/jev-wire.js.map +1 -1
- package/dist/docs/export-document.js +12907 -10622
- package/dist/docs/export-document.js.map +3 -3
- package/dist/doctor/dependency-cycles.d.ts +41 -0
- package/dist/doctor/dependency-cycles.d.ts.map +1 -0
- package/dist/doctor/dependency-cycles.js +39 -0
- package/dist/doctor/dependency-cycles.js.map +1 -0
- package/dist/doctor/index.d.ts +2 -0
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +1 -0
- package/dist/doctor/index.js.map +1 -1
- package/dist/doctor/orphan-ac-bindings.d.ts +64 -0
- package/dist/doctor/orphan-ac-bindings.d.ts.map +1 -0
- package/dist/doctor/orphan-ac-bindings.js +59 -0
- package/dist/doctor/orphan-ac-bindings.js.map +1 -0
- package/dist/doctor/projects.d.ts.map +1 -1
- package/dist/doctor/projects.js +43 -29
- package/dist/doctor/projects.js.map +1 -1
- package/dist/doctor/row-identity.d.ts +42 -0
- package/dist/doctor/row-identity.d.ts.map +1 -0
- package/dist/doctor/row-identity.js +151 -0
- package/dist/doctor/row-identity.js.map +1 -0
- package/dist/doctor/twin-collapse.d.ts +58 -2
- package/dist/doctor/twin-collapse.d.ts.map +1 -1
- package/dist/doctor/twin-collapse.js +212 -14
- package/dist/doctor/twin-collapse.js.map +1 -1
- package/dist/error-catalog.d.ts.map +1 -1
- package/dist/error-catalog.js +4 -0
- package/dist/error-catalog.js.map +1 -1
- package/dist/error-registry.d.ts.map +1 -1
- package/dist/error-registry.js +8 -0
- package/dist/error-registry.js.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.js +10 -0
- package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
- package/dist/llm/plugin-facade.js +17767 -15482
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +19 -10
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/manifest-identity.d.ts +171 -0
- package/dist/memory/manifest-identity.d.ts.map +1 -0
- package/dist/memory/manifest-identity.js +345 -0
- package/dist/memory/manifest-identity.js.map +1 -0
- package/dist/memory/mental-model-queue.d.ts +14 -4
- package/dist/memory/mental-model-queue.d.ts.map +1 -1
- package/dist/memory/mental-model-queue.js +64 -35
- package/dist/memory/mental-model-queue.js.map +1 -1
- package/dist/memory/pipeline-manifest-sqlite.d.ts +47 -5
- package/dist/memory/pipeline-manifest-sqlite.d.ts.map +1 -1
- package/dist/memory/pipeline-manifest-sqlite.js +130 -14
- package/dist/memory/pipeline-manifest-sqlite.js.map +1 -1
- package/dist/memory/retrieval/observe.d.ts.map +1 -1
- package/dist/memory/retrieval/observe.js +2 -0
- package/dist/memory/retrieval/observe.js.map +1 -1
- package/dist/memory/transcript-scanner.js +1 -1
- package/dist/nexus/deps.d.ts.map +1 -1
- package/dist/nexus/deps.js +7 -14
- package/dist/nexus/deps.js.map +1 -1
- package/dist/nexus/discover.d.ts.map +1 -1
- package/dist/nexus/discover.js +6 -9
- package/dist/nexus/discover.js.map +1 -1
- package/dist/nexus/git-state.d.ts.map +1 -1
- package/dist/nexus/git-state.js +10 -1
- package/dist/nexus/git-state.js.map +1 -1
- package/dist/nexus/projects-clean.d.ts.map +1 -1
- package/dist/nexus/projects-clean.js +22 -11
- package/dist/nexus/projects-clean.js.map +1 -1
- package/dist/nexus/projects-scan.d.ts.map +1 -1
- package/dist/nexus/projects-scan.js +8 -13
- package/dist/nexus/projects-scan.js.map +1 -1
- package/dist/nexus/query.d.ts.map +1 -1
- package/dist/nexus/query.js +4 -12
- package/dist/nexus/query.js.map +1 -1
- package/dist/nexus/registry-roots.d.ts +5 -2
- package/dist/nexus/registry-roots.d.ts.map +1 -1
- package/dist/nexus/registry-roots.js +8 -10
- package/dist/nexus/registry-roots.js.map +1 -1
- package/dist/nexus/registry.d.ts +18 -0
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +37 -16
- package/dist/nexus/registry.js.map +1 -1
- package/dist/nexus/workspace.d.ts.map +1 -1
- package/dist/nexus/workspace.js +54 -4
- package/dist/nexus/workspace.js.map +1 -1
- package/dist/orchestrate/lifecycle-ops.js +1 -1
- package/dist/orchestrate/lifecycle-ops.js.map +1 -1
- package/dist/orchestrate/pivot.d.ts.map +1 -1
- package/dist/orchestrate/pivot.js +4 -2
- package/dist/orchestrate/pivot.js.map +1 -1
- package/dist/orchestrate/query-ops.d.ts.map +1 -1
- package/dist/orchestrate/query-ops.js +3 -3
- package/dist/orchestrate/query-ops.js.map +1 -1
- package/dist/orchestrate/spawn-ops.js +1 -1
- package/dist/orchestrate/spawn-ops.js.map +1 -1
- package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
- package/dist/orchestrate/worktree-complete.js +40 -2
- package/dist/orchestrate/worktree-complete.js.map +1 -1
- package/dist/orchestration/index.js +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/lead-rollup.d.ts.map +1 -1
- package/dist/orchestration/lead-rollup.js +3 -3
- package/dist/orchestration/lead-rollup.js.map +1 -1
- package/dist/orchestration/parallel.js +1 -1
- package/dist/orchestration/parallel.js.map +1 -1
- package/dist/orchestration/spawn-prompt.d.ts +26 -0
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +62 -91
- package/dist/orchestration/spawn-prompt.js.map +1 -1
- package/dist/orchestration/wave-topic.d.ts +4 -2
- package/dist/orchestration/wave-topic.d.ts.map +1 -1
- package/dist/orchestration/wave-topic.js +6 -4
- package/dist/orchestration/wave-topic.js.map +1 -1
- package/dist/orchestration/waves.d.ts +2 -1
- package/dist/orchestration/waves.d.ts.map +1 -1
- package/dist/orchestration/waves.js +4 -3
- package/dist/orchestration/waves.js.map +1 -1
- package/dist/phases/index.d.ts.map +1 -1
- package/dist/phases/index.js +4 -2
- package/dist/phases/index.js.map +1 -1
- package/dist/release/ci-evidence.d.ts +190 -0
- package/dist/release/ci-evidence.d.ts.map +1 -1
- package/dist/release/ci-evidence.js +436 -4
- package/dist/release/ci-evidence.js.map +1 -1
- package/dist/sdk/validator-ac-pull.d.ts.map +1 -1
- package/dist/sdk/validator-ac-pull.js +2 -1
- package/dist/sdk/validator-ac-pull.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +12807 -10522
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/sentient/cross-project-hygiene.d.ts +5 -0
- package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
- package/dist/sentient/cross-project-hygiene.js +7 -1
- package/dist/sentient/cross-project-hygiene.js.map +1 -1
- package/dist/sentient/propose-tick.d.ts.map +1 -1
- package/dist/sentient/propose-tick.js +2 -3
- package/dist/sentient/propose-tick.js.map +1 -1
- package/dist/sentient/stage-drift-tick.d.ts.map +1 -1
- package/dist/sentient/stage-drift-tick.js +2 -3
- package/dist/sentient/stage-drift-tick.js.map +1 -1
- package/dist/session/engine-ops.d.ts.map +1 -1
- package/dist/session/engine-ops.js +77 -49
- package/dist/session/engine-ops.js.map +1 -1
- package/dist/sessions/focus-state-store.d.ts +69 -23
- package/dist/sessions/focus-state-store.d.ts.map +1 -1
- package/dist/sessions/focus-state-store.js +113 -27
- package/dist/sessions/focus-state-store.js.map +1 -1
- package/dist/sessions/handoff.js +1 -1
- package/dist/sessions/handoff.js.map +1 -1
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/index.js +24 -13
- package/dist/sessions/index.js.map +1 -1
- package/dist/sessions/terminal-identity.d.ts +165 -17
- package/dist/sessions/terminal-identity.d.ts.map +1 -1
- package/dist/sessions/terminal-identity.js +341 -32
- package/dist/sessions/terminal-identity.js.map +1 -1
- package/dist/skills/manifests/research.js +2 -0
- package/dist/skills/manifests/research.js.map +1 -1
- package/dist/skills/orchestrator/startup.js +2 -2
- package/dist/skills/orchestrator/startup.js.map +1 -1
- package/dist/skills/orchestrator/validator.d.ts.map +1 -1
- package/dist/skills/orchestrator/validator.js +9 -2
- package/dist/skills/orchestrator/validator.js.map +1 -1
- package/dist/skills/validation.d.ts +4 -1
- package/dist/skills/validation.d.ts.map +1 -1
- package/dist/skills/validation.js +11 -3
- package/dist/skills/validation.js.map +1 -1
- package/dist/snapshot/index.d.ts +4 -0
- package/dist/snapshot/index.d.ts.map +1 -1
- package/dist/snapshot/index.js +50 -3
- package/dist/snapshot/index.js.map +1 -1
- package/dist/spawn/branch-lock.d.ts +111 -7
- package/dist/spawn/branch-lock.d.ts.map +1 -1
- package/dist/spawn/branch-lock.js +296 -8
- package/dist/spawn/branch-lock.js.map +1 -1
- package/dist/store/ac-binding-prune.d.ts +144 -0
- package/dist/store/ac-binding-prune.d.ts.map +1 -0
- package/dist/store/ac-binding-prune.js +293 -0
- package/dist/store/ac-binding-prune.js.map +1 -0
- package/dist/store/agent-registry-store.d.ts.map +1 -1
- package/dist/store/agent-registry-store.js +3 -2
- package/dist/store/agent-registry-store.js.map +1 -1
- package/dist/store/backup-sidecar.d.ts +85 -2
- package/dist/store/backup-sidecar.d.ts.map +1 -1
- package/dist/store/backup-sidecar.js +173 -1
- package/dist/store/backup-sidecar.js.map +1 -1
- package/dist/store/conduit-sqlite.d.ts.map +1 -1
- package/dist/store/conduit-sqlite.js +3 -2
- package/dist/store/conduit-sqlite.js.map +1 -1
- package/dist/store/credential-transfer.d.ts.map +1 -1
- package/dist/store/credential-transfer.js +3 -0
- package/dist/store/credential-transfer.js.map +1 -1
- package/dist/store/db-helpers.d.ts +32 -13
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/db-helpers.js +144 -40
- package/dist/store/db-helpers.js.map +1 -1
- package/dist/store/dependency-cycles.d.ts +164 -0
- package/dist/store/dependency-cycles.d.ts.map +1 -0
- package/dist/store/dependency-cycles.js +360 -0
- package/dist/store/dependency-cycles.js.map +1 -0
- package/dist/store/display-id-alias.d.ts +585 -0
- package/dist/store/display-id-alias.d.ts.map +1 -0
- package/dist/store/display-id-alias.js +1538 -0
- package/dist/store/display-id-alias.js.map +1 -0
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +48 -0
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/exodus/migrate.d.ts.map +1 -1
- package/dist/store/exodus/migrate.js +8 -1
- package/dist/store/exodus/migrate.js.map +1 -1
- package/dist/store/exodus/reconcile.d.ts.map +1 -1
- package/dist/store/exodus/reconcile.js +7 -2
- package/dist/store/exodus/reconcile.js.map +1 -1
- package/dist/store/exodus/recovery.d.ts.map +1 -1
- package/dist/store/exodus/recovery.js +20 -1
- package/dist/store/exodus/recovery.js.map +1 -1
- package/dist/store/global-salt.d.ts +35 -0
- package/dist/store/global-salt.d.ts.map +1 -1
- package/dist/store/global-salt.js +136 -16
- package/dist/store/global-salt.js.map +1 -1
- package/dist/store/legacy-tasks-lineage.d.ts.map +1 -1
- package/dist/store/legacy-tasks-lineage.js +19 -2
- package/dist/store/legacy-tasks-lineage.js.map +1 -1
- package/dist/store/migration-sqlite.d.ts.map +1 -1
- package/dist/store/migration-sqlite.js +19 -11
- package/dist/store/migration-sqlite.js.map +1 -1
- package/dist/store/portable-bundle-scan.d.ts.map +1 -1
- package/dist/store/portable-bundle-scan.js +6 -0
- package/dist/store/portable-bundle-scan.js.map +1 -1
- package/dist/store/row-identity-flag.d.ts +26 -0
- package/dist/store/row-identity-flag.d.ts.map +1 -0
- package/dist/store/row-identity-flag.js +31 -0
- package/dist/store/row-identity-flag.js.map +1 -0
- package/dist/store/row-identity-registry.d.ts +61 -0
- package/dist/store/row-identity-registry.d.ts.map +1 -0
- package/dist/store/row-identity-registry.js +206 -0
- package/dist/store/row-identity-registry.js.map +1 -0
- package/dist/store/row-identity.d.ts +480 -0
- package/dist/store/row-identity.d.ts.map +1 -0
- package/dist/store/row-identity.js +1509 -0
- package/dist/store/row-identity.js.map +1 -0
- package/dist/store/safety-data-accessor.d.ts +5 -2
- package/dist/store/safety-data-accessor.d.ts.map +1 -1
- package/dist/store/safety-data-accessor.js +9 -2
- package/dist/store/safety-data-accessor.js.map +1 -1
- package/dist/store/schema/attachments.d.ts +1 -1
- package/dist/store/schema/audit.d.ts +4 -4
- package/dist/store/schema/background-jobs.d.ts +1 -1
- package/dist/store/schema/cleo-global/nexus.d.ts +1 -1
- package/dist/store/schema/cleo-global/skills.d.ts +2 -2
- package/dist/store/schema/cleo-project/audit.d.ts +4 -4
- package/dist/store/schema/cleo-project/docs.d.ts +1 -1
- package/dist/store/schema/cleo-project/lifecycle.d.ts +4 -4
- package/dist/store/schema/cleo-project/nexus-graph.d.ts +3 -3
- package/dist/store/schema/cleo-project/provenance-commits.d.ts +2 -2
- package/dist/store/schema/cleo-project/provenance-rest.d.ts +3 -3
- package/dist/store/schema/cleo-project/runtime.d.ts +3 -3
- package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts +87 -1
- package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core-batch2.js +35 -0
- package/dist/store/schema/cleo-project/tasks-core-batch2.js.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core.d.ts +183 -7
- package/dist/store/schema/cleo-project/tasks-core.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core.js +52 -1
- package/dist/store/schema/cleo-project/tasks-core.js.map +1 -1
- package/dist/store/schema/cleo-shared/brain.d.ts +6 -6
- package/dist/store/schema/lifecycle.d.ts +4 -4
- package/dist/store/schema/manifest.d.ts +1 -1
- package/dist/store/schema/memory-schema.d.ts +5 -5
- package/dist/store/schema/nexus-schema.d.ts +3 -3
- package/dist/store/schema/provenance/releases.d.ts +1 -1
- package/dist/store/schema/skills-schema.d.ts +2 -2
- package/dist/store/schema/tasks.d.ts +6 -6
- package/dist/store/session-store.d.ts +63 -3
- package/dist/store/session-store.d.ts.map +1 -1
- package/dist/store/session-store.js +121 -8
- package/dist/store/session-store.js.map +1 -1
- package/dist/store/split-brain-import.d.ts +2 -14
- package/dist/store/split-brain-import.d.ts.map +1 -1
- package/dist/store/split-brain-import.js +40 -19
- package/dist/store/split-brain-import.js.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts +326 -2
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.js +782 -49
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/sync/clock-store.d.ts +67 -0
- package/dist/store/sync/clock-store.d.ts.map +1 -0
- package/dist/store/sync/clock-store.js +117 -0
- package/dist/store/sync/clock-store.js.map +1 -0
- package/dist/store/sync/flags.d.ts +51 -0
- package/dist/store/sync/flags.d.ts.map +1 -0
- package/dist/store/sync/flags.js +81 -0
- package/dist/store/sync/flags.js.map +1 -0
- package/dist/store/sync/hlc.d.ts +115 -0
- package/dist/store/sync/hlc.d.ts.map +1 -0
- package/dist/store/sync/hlc.js +172 -0
- package/dist/store/sync/hlc.js.map +1 -0
- package/dist/store/sync/index.d.ts +20 -0
- package/dist/store/sync/index.d.ts.map +1 -0
- package/dist/store/sync/index.js +20 -0
- package/dist/store/sync/index.js.map +1 -0
- package/dist/store/sync/replica-registry.d.ts +85 -0
- package/dist/store/sync/replica-registry.d.ts.map +1 -0
- package/dist/store/sync/replica-registry.js +147 -0
- package/dist/store/sync/replica-registry.js.map +1 -0
- package/dist/store/sync/replica.d.ts +181 -0
- package/dist/store/sync/replica.d.ts.map +1 -0
- package/dist/store/sync/replica.js +305 -0
- package/dist/store/sync/replica.js.map +1 -0
- package/dist/store/sync/schema.d.ts +68 -0
- package/dist/store/sync/schema.d.ts.map +1 -0
- package/dist/store/sync/schema.js +130 -0
- package/dist/store/sync/schema.js.map +1 -0
- package/dist/store/sync/skew-hold.d.ts +71 -0
- package/dist/store/sync/skew-hold.d.ts.map +1 -0
- package/dist/store/sync/skew-hold.js +89 -0
- package/dist/store/sync/skew-hold.js.map +1 -0
- package/dist/store/table-classification.d.ts +10 -8
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +93 -25
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/tasks-sqlite.d.ts.map +1 -1
- package/dist/store/tasks-sqlite.js +23 -3
- package/dist/store/tasks-sqlite.js.map +1 -1
- package/dist/store/twin-collapse.d.ts +113 -2
- package/dist/store/twin-collapse.d.ts.map +1 -1
- package/dist/store/twin-collapse.js +247 -19
- package/dist/store/twin-collapse.js.map +1 -1
- package/dist/store/umbrella-data-accessor.d.ts +5 -2
- package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
- package/dist/store/umbrella-data-accessor.js +9 -2
- package/dist/store/umbrella-data-accessor.js.map +1 -1
- package/dist/store/validation-schemas.d.ts +241 -49
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/system/health.d.ts +2 -0
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +57 -0
- package/dist/system/health.js.map +1 -1
- package/dist/system/project-health.d.ts +9 -2
- package/dist/system/project-health.d.ts.map +1 -1
- package/dist/system/project-health.js +8 -3
- package/dist/system/project-health.js.map +1 -1
- package/dist/task-work/index.d.ts.map +1 -1
- package/dist/task-work/index.js +9 -6
- package/dist/task-work/index.js.map +1 -1
- package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
- package/dist/tasks/ac-coverage-gate.js +3 -0
- package/dist/tasks/ac-coverage-gate.js.map +1 -1
- package/dist/tasks/ac-table.d.ts +35 -7
- package/dist/tasks/ac-table.d.ts.map +1 -1
- package/dist/tasks/ac-table.js +119 -14
- package/dist/tasks/ac-table.js.map +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +48 -2
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/analyze.d.ts.map +1 -1
- package/dist/tasks/analyze.js +3 -2
- package/dist/tasks/analyze.js.map +1 -1
- package/dist/tasks/backfill-child-projections.js +47 -6
- package/dist/tasks/backfill-child-projections.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +6 -1
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/completion-evaluation.d.ts.map +1 -1
- package/dist/tasks/completion-evaluation.js +3 -0
- package/dist/tasks/completion-evaluation.js.map +1 -1
- package/dist/tasks/evidence.d.ts +19 -1
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +93 -21
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/infer-add-params.js +1 -1
- package/dist/tasks/infer-add-params.js.map +1 -1
- package/dist/tasks/task-context.d.ts.map +1 -1
- package/dist/tasks/task-context.js +3 -1
- package/dist/tasks/task-context.js.map +1 -1
- package/dist/tasks/task-import.d.ts.map +1 -1
- package/dist/tasks/task-import.js +11 -3
- package/dist/tasks/task-import.js.map +1 -1
- package/dist/tasks/task-next.d.ts +5 -1
- package/dist/tasks/task-next.d.ts.map +1 -1
- package/dist/tasks/task-next.js +7 -2
- package/dist/tasks/task-next.js.map +1 -1
- package/dist/validation/compliance.d.ts +5 -1
- package/dist/validation/compliance.d.ts.map +1 -1
- package/dist/validation/compliance.js +24 -3
- package/dist/validation/compliance.js.map +1 -1
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +3 -2
- package/dist/validation/engine-ops.js.map +1 -1
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.d.ts.map +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/validation/protocol-common.d.ts +47 -2
- package/dist/validation/protocol-common.d.ts.map +1 -1
- package/dist/validation/protocol-common.js +86 -21
- package/dist/validation/protocol-common.js.map +1 -1
- package/dist/workgraph/scaffold-apply.d.ts.map +1 -1
- package/dist/workgraph/scaffold-apply.js +14 -5
- package/dist/workgraph/scaffold-apply.js.map +1 -1
- package/migrations/README.md +12 -0
- package/migrations/drizzle-cleo-project/20260928120000_t12341-row-uids/migration.sql +169 -0
- package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/migration.sql +76 -0
- package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/revert.sql +7 -0
- package/migrations/sync-journal/20260929140000_t12342-sync-clock/migration.sql +42 -0
- package/package.json +13 -13
- package/templates/CLEO-INJECTION.md +2 -2
- package/templates/CLEO-REFERENCE.md +5 -1
|
@@ -0,0 +1,1680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleo Nexus device store: `<cleoHome>/nexus-device.json`.
|
|
3
|
+
*
|
|
4
|
+
* One file per CLEO home holds every Nexus device identity of that home, keyed
|
|
5
|
+
* by API origin and then by user id (cleo-nexus device contract §2.2, §2.4,
|
|
6
|
+
* §2.5, §3.1, §3.5). Each entry carries:
|
|
7
|
+
*
|
|
8
|
+
* - `deviceId`, the client-minted UUIDv7 (§3.1);
|
|
9
|
+
* - `keys`, the device's X25519 and Ed25519 key pairs, local-only (§2.4);
|
|
10
|
+
* - `current`, the device credential in use (`cnx_d1_…`, §2.2);
|
|
11
|
+
* - `pending`, a client-minted rotation credential not yet confirmed (§2.5);
|
|
12
|
+
* - `pendingSignOut` and `pendingRevoke`, credentials kept only to retry an
|
|
13
|
+
* unconfirmed sign-out (E9) or revoke (E10) (§3.5, M3);
|
|
14
|
+
* - `retired`, the unsettled sign-out or revoke of a device this entry
|
|
15
|
+
* replaced, so a live credential is never dropped on a device change.
|
|
16
|
+
*
|
|
17
|
+
* The file is separate from `nexus-credentials.json`, which stays format
|
|
18
|
+
* version 1 so a downgraded CLI can read it and never touches this file
|
|
19
|
+
* (finding 8).
|
|
20
|
+
*
|
|
21
|
+
* Safety rules, all enforced here:
|
|
22
|
+
*
|
|
23
|
+
* - **Sealed at rest.** Every token and private key is encrypted with
|
|
24
|
+
* AES-256-GCM under the machine key (`crypto/credentials.ts`,
|
|
25
|
+
* KDF id `nexus-device:` + `JSON.stringify([origin, userId])`, which no
|
|
26
|
+
* choice of origin and user id can make collide). Each value carries its
|
|
27
|
+
* field path (for example `current.token`) as GCM associated data, so
|
|
28
|
+
* values cannot be swapped between fields either. A copy of the file on
|
|
29
|
+
* another machine, or under another (origin, user), cannot be opened. The
|
|
30
|
+
* file is also excluded from every backup bundle. Read paths (`get`,
|
|
31
|
+
* `list`) never create the machine key or salt: without them an entry
|
|
32
|
+
* reads as unreadable. A lost key never blocks re-login: the login discards
|
|
33
|
+
* the unreadable entry ({@link NexusDeviceTransaction.discardUnreadable}).
|
|
34
|
+
* - **Owner-only.** The file is created 0600 (`O_EXCL | O_NOFOLLOW`). Every
|
|
35
|
+
* read opens it without following links and checks the open descriptor:
|
|
36
|
+
* a regular file, one link, owned by this user, no group or other bits.
|
|
37
|
+
* The CLEO home must be owned by this user, inside a parent no other user
|
|
38
|
+
* can rewrite; a home the user owns that is group- or world-writable is
|
|
39
|
+
* tightened with `chmod go-w` on the first write, with a warning in
|
|
40
|
+
* {@link NexusDeviceTransaction.warnings}. Each refusal names the fix.
|
|
41
|
+
* - **Locked, re-read, atomic.** Every mutation runs through
|
|
42
|
+
* {@link NexusDeviceStore.update}: it takes this store's own cross-process
|
|
43
|
+
* lock (about a minute of waiting, see {@link NexusDeviceStoreOptions}),
|
|
44
|
+
* re-reads the file **after** acquiring it (M6, N1), and writes a temp file
|
|
45
|
+
* that is fsynced and renamed over the original, then fsyncs the directory.
|
|
46
|
+
* On macOS, Node's fsync is `F_FULLFSYNC` (libuv), so the data reaches the
|
|
47
|
+
* disk, not only the drive cache. No rotated backups are made; any found are
|
|
48
|
+
* purged (L3), and stale temp files are swept under the lock.
|
|
49
|
+
* - **Never held across a network call (contract v2.9).** The lock covers
|
|
50
|
+
* only a local read-modify-write. Every change that involves the server
|
|
51
|
+
* follows one order: under the lock, re-read and persist the intent (a
|
|
52
|
+
* `pending` credential before E8, §2.5 step 1; the E1 intent before E1,
|
|
53
|
+
* §3.3 step 5); **return from `update`, releasing the lock**; make the call
|
|
54
|
+
* (E1, E8, E9, E10) with a plain `AbortSignal.timeout(...)`; then call
|
|
55
|
+
* `update` again and re-check with a compare-and-swap (is the entry still
|
|
56
|
+
* the one this process wrote?) before promoting or clearing. `update`
|
|
57
|
+
* writes when `fn` returns, so the intent is durable before the request.
|
|
58
|
+
* - **Lock loss aborts.** If the lock is taken over (a stale-lock takeover
|
|
59
|
+
* after the machine slept, say), {@link NexusDeviceTransaction.signal} is
|
|
60
|
+
* aborted, no further write happens, and `update` rejects with
|
|
61
|
+
* `E_NEXUS_DEVICE_LOCK_COMPROMISED`.
|
|
62
|
+
* - **No downgrade.** A file whose `version` is newer than this CLI knows is
|
|
63
|
+
* refused, never read as empty and never rewritten. A malformed file is
|
|
64
|
+
* refused the same way. Unknown fields of a known version, at any depth,
|
|
65
|
+
* are kept on rewrite.
|
|
66
|
+
* - **Unreadable entries are carried, not dropped.** An entry whose secrets do
|
|
67
|
+
* not open under this machine's key (copied in from another machine) is
|
|
68
|
+
* written back byte-identical and never decrypted, rewritten or deleted.
|
|
69
|
+
* Updates to other entries go ahead; only an operation on that entry fails
|
|
70
|
+
* (`E_NEXUS_DEVICE_UNSEAL_FAILED`), and {@link NexusDeviceStore.list}
|
|
71
|
+
* reports it as an {@link UnreadableNexusDevice}.
|
|
72
|
+
* - **Fenced writes.** Before each rename the file is compared (inode, size,
|
|
73
|
+
* mtime in ns) with what this transaction last read or wrote; a change
|
|
74
|
+
* made by anyone else aborts with `E_NEXUS_DEVICE_LOCK_COMPROMISED`.
|
|
75
|
+
* - **Sealed handles.** Nothing handed out prints a secret: entries from
|
|
76
|
+
* {@link NexusDeviceTransaction.get}, {@link SealedNexusDevice.unseal} and
|
|
77
|
+
* the `apply*` transitions carry non-enumerable `toJSON` and `util.inspect`
|
|
78
|
+
* guards, and {@link NexusDeviceEnrolment} holds its secrets privately.
|
|
79
|
+
* {@link redactNexusDeviceSecrets} masks credentials and private keys in any
|
|
80
|
+
* diagnostic text. The guards are non-enumerable, so spreading a guarded
|
|
81
|
+
* entry (`{ ...entry }`) produces an unguarded copy: T12868–T12871 must
|
|
82
|
+
* never spread an entry, or any part of one, into a log line or envelope.
|
|
83
|
+
*
|
|
84
|
+
* Behaviour that uses it ships behind `CLEO_NEXUS_DEVICE=1`
|
|
85
|
+
* ({@link isNexusDeviceEnabled}): login enrolment and the 9.24 upgrade
|
|
86
|
+
* (`nexus-enrol.ts`, T12868), then rotation, logout and the cloud reads
|
|
87
|
+
* (T12869–T12871).
|
|
88
|
+
*
|
|
89
|
+
* @task T12867
|
|
90
|
+
* @epic T12323
|
|
91
|
+
*/
|
|
92
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
93
|
+
import { randomBytes } from 'node:crypto';
|
|
94
|
+
import { chmodSync, closeSync, constants as fsConstants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
|
|
95
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
96
|
+
import { inspect } from 'node:util';
|
|
97
|
+
import { resolveNexusDevicePath } from '@cleocode/paths';
|
|
98
|
+
import * as lockfile from 'proper-lockfile';
|
|
99
|
+
import { z } from 'zod';
|
|
100
|
+
import { deriveGlobalKeyFromMaterial, loadGlobalKeyMaterial, openWithGlobalKey, sealWithGlobalKey, } from '../crypto/credentials.js';
|
|
101
|
+
import { nexusOriginKey } from './nexus-credentials.js';
|
|
102
|
+
/** File name of the store, directly under the CLEO home. */
|
|
103
|
+
export const NEXUS_DEVICE_FILE = 'nexus-device.json';
|
|
104
|
+
/** The file format version this CLI reads and writes. A newer version is refused. */
|
|
105
|
+
export const NEXUS_DEVICE_FILE_VERSION = 1;
|
|
106
|
+
/** Environment switch that turns on the device-credential behaviour (T12867–T12871). */
|
|
107
|
+
export const NEXUS_DEVICE_ENV = 'CLEO_NEXUS_DEVICE';
|
|
108
|
+
/**
|
|
109
|
+
* The device credential format (contract §2.2, `DEVICE_CREDENTIAL` in §4.1):
|
|
110
|
+
* `cnx_d1_` followed by 43 base64url characters (32 random bytes).
|
|
111
|
+
*/
|
|
112
|
+
export const NEXUS_DEVICE_CREDENTIAL_PATTERN = /^cnx_d1_[A-Za-z0-9_-]{43}$/;
|
|
113
|
+
/** Every scope a device credential can carry (contract §2.3, `DeviceScope`). */
|
|
114
|
+
export const NEXUS_DEVICE_SCOPES = [
|
|
115
|
+
'account:read',
|
|
116
|
+
'devices:read',
|
|
117
|
+
'projects:read',
|
|
118
|
+
'projects:write',
|
|
119
|
+
'sync:read',
|
|
120
|
+
'sync:write',
|
|
121
|
+
'keys:read',
|
|
122
|
+
'keys:write',
|
|
123
|
+
];
|
|
124
|
+
/** Credential profiles (contract §2.3, `DeviceProfile`). */
|
|
125
|
+
export const NEXUS_DEVICE_PROFILES = ['device', 'read-only'];
|
|
126
|
+
/** Why an E1 call is in flight: an interactive login (§3.3) or the 9.24 auto-upgrade (§3.4). */
|
|
127
|
+
export const NEXUS_DEVICE_INTENT_KINDS = ['login', 'upgrade'];
|
|
128
|
+
/**
|
|
129
|
+
* Most credentials one sign-out or revoke slot may hold. A transition that
|
|
130
|
+
* would need more refuses (`E_NEXUS_DEVICE_SLOT_FULL`) instead of dropping a
|
|
131
|
+
* live credential.
|
|
132
|
+
*/
|
|
133
|
+
export const NEXUS_DEVICE_MAX_SLOT_CREDENTIALS = 8;
|
|
134
|
+
/** Most retired-device items one entry may hold; beyond it a device change refuses. */
|
|
135
|
+
export const NEXUS_DEVICE_MAX_RETIRED = 8;
|
|
136
|
+
/** Default time {@link NexusDeviceStore.update} waits for the lock. */
|
|
137
|
+
export const NEXUS_DEVICE_LOCK_WAIT_MS = 60_000;
|
|
138
|
+
/** Default age after which a lock whose holder stopped refreshing it may be taken over. */
|
|
139
|
+
export const NEXUS_DEVICE_LOCK_STALE_MS = 30_000;
|
|
140
|
+
/** `O_NOFOLLOW` where the platform has it (not on Windows). */
|
|
141
|
+
const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
|
142
|
+
/** `O_NONBLOCK`, so opening a planted FIFO cannot hang the read. */
|
|
143
|
+
const NON_BLOCK = fsConstants.O_NONBLOCK ?? 0;
|
|
144
|
+
/** Matches a device credential, or a truncated/suffixed fragment of one, anywhere in text. */
|
|
145
|
+
const CREDENTIAL_IN_TEXT = /cnx_d1_[A-Za-z0-9_-]+/g;
|
|
146
|
+
/** Matches a private-key field and its value in JSON or `util.inspect` output. */
|
|
147
|
+
const PRIVATE_KEY_IN_TEXT = /(["']?privateKey["']?\s*[:=]\s*["'])([^"']*)(["'])/g;
|
|
148
|
+
/** Temp files this store writes: `.nexus-device.json.<12 hex>.tmp`. */
|
|
149
|
+
const TEMP_FILE = /^\.nexus-device\.json\.[0-9a-f]{12}\.tmp$/;
|
|
150
|
+
const uuid = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
151
|
+
const isoTime = z.iso.datetime();
|
|
152
|
+
/** Plain (in-memory) secret leaves. */
|
|
153
|
+
const plainToken = z
|
|
154
|
+
.string()
|
|
155
|
+
.regex(NEXUS_DEVICE_CREDENTIAL_PATTERN, 'expected a cnx_d1_ device credential');
|
|
156
|
+
const plainPrivateKey = z.string().regex(/^[A-Za-z0-9+/]{43}=$/, 'expected a base64 32-byte key');
|
|
157
|
+
/** Sealed (on-disk) secret leaves: AES-GCM ciphertext, base64. */
|
|
158
|
+
const sealedLeaf = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/, 'expected sealed base64');
|
|
159
|
+
const publicKey = z.string().regex(/^[A-Za-z0-9+/]{43}=$/, 'expected a base64 32-byte key');
|
|
160
|
+
/**
|
|
161
|
+
* Build the entry schema over a token leaf and a private-key leaf, so the
|
|
162
|
+
* in-memory form (validated plaintext) and the on-disk form (ciphertext)
|
|
163
|
+
* share one shape. Every object is loose: unknown fields survive a rewrite.
|
|
164
|
+
*/
|
|
165
|
+
function makeEntrySchema(token, privateKey) {
|
|
166
|
+
const keyPair = z.looseObject({ publicKey, privateKey });
|
|
167
|
+
const slotCredential = z.looseObject({ credentialId: uuid.nullable(), token });
|
|
168
|
+
const credentials = z.array(slotCredential).min(1).max(NEXUS_DEVICE_MAX_SLOT_CREDENTIALS);
|
|
169
|
+
const pendingEnd = z.looseObject({ credentials, requestedAt: isoTime });
|
|
170
|
+
return z.looseObject({
|
|
171
|
+
deviceId: uuid,
|
|
172
|
+
createdAt: isoTime,
|
|
173
|
+
keys: z.looseObject({ encryption: keyPair, signing: keyPair }).nullable(),
|
|
174
|
+
current: z
|
|
175
|
+
.looseObject({
|
|
176
|
+
credentialId: uuid,
|
|
177
|
+
token,
|
|
178
|
+
profile: z.enum(NEXUS_DEVICE_PROFILES),
|
|
179
|
+
scopes: z.array(z.enum(NEXUS_DEVICE_SCOPES)),
|
|
180
|
+
createdAt: isoTime,
|
|
181
|
+
})
|
|
182
|
+
.nullable(),
|
|
183
|
+
pending: z
|
|
184
|
+
.looseObject({
|
|
185
|
+
/** Server id of the new credential; `null` until E8 answers (the client minted the token). */
|
|
186
|
+
credentialId: uuid.nullable(),
|
|
187
|
+
token,
|
|
188
|
+
createdAt: isoTime,
|
|
189
|
+
})
|
|
190
|
+
.nullable(),
|
|
191
|
+
pendingSignOut: pendingEnd.nullable(),
|
|
192
|
+
pendingRevoke: pendingEnd.nullable(),
|
|
193
|
+
retired: z
|
|
194
|
+
.array(z.looseObject({
|
|
195
|
+
deviceId: uuid,
|
|
196
|
+
kind: z.enum(['sign-out', 'revoke']),
|
|
197
|
+
credentials,
|
|
198
|
+
requestedAt: isoTime,
|
|
199
|
+
}))
|
|
200
|
+
.max(NEXUS_DEVICE_MAX_RETIRED)
|
|
201
|
+
.optional(),
|
|
202
|
+
/**
|
|
203
|
+
* An E1 call in flight (contract §3.3 step 5, §3.4 step 1): persisted under
|
|
204
|
+
* the lock before E1 is sent, cleared at step 7. Holds no secret.
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* A credential E1 issued to this process while the file already held
|
|
208
|
+
* another one for the same device, and the server could not yet say which
|
|
209
|
+
* is live (§3.3 step 7, ruling A). Kept, sealed, until E2 settles it, so
|
|
210
|
+
* the only live credential is never lost.
|
|
211
|
+
*/
|
|
212
|
+
raceCandidate: z
|
|
213
|
+
.looseObject({
|
|
214
|
+
credentialId: uuid,
|
|
215
|
+
token,
|
|
216
|
+
profile: z.enum(NEXUS_DEVICE_PROFILES),
|
|
217
|
+
scopes: z.array(z.enum(NEXUS_DEVICE_SCOPES)),
|
|
218
|
+
createdAt: isoTime,
|
|
219
|
+
})
|
|
220
|
+
.nullable()
|
|
221
|
+
.optional(),
|
|
222
|
+
enrolIntent: z
|
|
223
|
+
.looseObject({
|
|
224
|
+
deviceId: uuid,
|
|
225
|
+
kind: z.enum(NEXUS_DEVICE_INTENT_KINDS),
|
|
226
|
+
/** Random id of the process that wrote it (a CAS token). */
|
|
227
|
+
owner: z.string().regex(/^[0-9a-f]{32}$/),
|
|
228
|
+
startedAt: isoTime,
|
|
229
|
+
})
|
|
230
|
+
.nullable()
|
|
231
|
+
.optional(),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const entrySchema = makeEntrySchema(plainToken, plainPrivateKey);
|
|
235
|
+
const sealedEntrySchema = makeEntrySchema(sealedLeaf, sealedLeaf);
|
|
236
|
+
const sealedFileSchema = z.looseObject({
|
|
237
|
+
version: z.literal(NEXUS_DEVICE_FILE_VERSION),
|
|
238
|
+
devices: z.record(z.string(), z.record(z.string(), sealedEntrySchema)),
|
|
239
|
+
});
|
|
240
|
+
/** Thrown when the device file cannot be read or written safely. Never carries a secret. */
|
|
241
|
+
export class NexusDeviceStoreError extends Error {
|
|
242
|
+
/** Machine-readable reason. */
|
|
243
|
+
code;
|
|
244
|
+
/**
|
|
245
|
+
* @param code - Machine-readable reason.
|
|
246
|
+
* @param message - Human-readable message; passed through {@link redactNexusDeviceSecrets}.
|
|
247
|
+
*/
|
|
248
|
+
constructor(code, message) {
|
|
249
|
+
super(redactNexusDeviceSecrets(message));
|
|
250
|
+
this.name = 'NexusDeviceStoreError';
|
|
251
|
+
this.code = code;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Whether the device-credential behaviour is switched on (`CLEO_NEXUS_DEVICE=1`).
|
|
256
|
+
*
|
|
257
|
+
* @param env - Environment to read; defaults to `process.env`.
|
|
258
|
+
* @returns `true` only for the exact value `1`.
|
|
259
|
+
*/
|
|
260
|
+
export function isNexusDeviceEnabled(env = process.env) {
|
|
261
|
+
return env[NEXUS_DEVICE_ENV] === '1';
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Absolute path of the device store.
|
|
265
|
+
*
|
|
266
|
+
* @returns `<cleoHome>/nexus-device.json`, through `@cleocode/paths` (arch gate 2).
|
|
267
|
+
*/
|
|
268
|
+
export function nexusDevicePath() {
|
|
269
|
+
return resolveNexusDevicePath();
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Mask a device credential down to a safe preview.
|
|
273
|
+
*
|
|
274
|
+
* The preview keeps the last 4 characters (24 of the token's 256 random
|
|
275
|
+
* bits), enough to tell two credentials apart in a diagnostic and far too
|
|
276
|
+
* little to guess the rest. The same rule as `maskNexusToken`.
|
|
277
|
+
*
|
|
278
|
+
* @param value - Plaintext credential (read transiently, not retained).
|
|
279
|
+
* @returns `cnx_d1_…` plus the last 4 characters, or `…` for anything shorter.
|
|
280
|
+
*/
|
|
281
|
+
export function maskNexusDeviceCredential(value) {
|
|
282
|
+
return value.startsWith('cnx_d1_') && value.length > 16 ? `cnx_d1_…${value.slice(-4)}` : '…';
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Mask every device credential and every private-key field in `text`. Use on
|
|
286
|
+
* any diagnostic string (errors, logs, envelopes) that might hold one.
|
|
287
|
+
*
|
|
288
|
+
* @param text - Arbitrary text.
|
|
289
|
+
* @returns The text with every `cnx_d1_…` credential masked and every
|
|
290
|
+
* `privateKey` value replaced by `[redacted]`.
|
|
291
|
+
*/
|
|
292
|
+
export function redactNexusDeviceSecrets(text) {
|
|
293
|
+
return text
|
|
294
|
+
.replace(CREDENTIAL_IN_TEXT, (match) => maskNexusDeviceCredential(match))
|
|
295
|
+
.replace(PRIVATE_KEY_IN_TEXT, (_m, open, _value, close) => {
|
|
296
|
+
return `${open}[redacted]${close}`;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/** A plain JSON-like object (not an array). */
|
|
300
|
+
function isPlainObject(value) {
|
|
301
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* A copy of `value` with every `token` masked and every `privateKey` removed,
|
|
305
|
+
* at any depth. Public keys, ids and times are kept.
|
|
306
|
+
*/
|
|
307
|
+
function redactDeep(value) {
|
|
308
|
+
if (Array.isArray(value))
|
|
309
|
+
return value.map(redactDeep);
|
|
310
|
+
if (!isPlainObject(value))
|
|
311
|
+
return value;
|
|
312
|
+
const out = {};
|
|
313
|
+
for (const [key, child] of Object.entries(value)) {
|
|
314
|
+
if (key === 'token' && typeof child === 'string') {
|
|
315
|
+
out.tokenPreview = maskNexusDeviceCredential(child);
|
|
316
|
+
}
|
|
317
|
+
else if (key === 'privateKey') {
|
|
318
|
+
out.privateKey = '[redacted]';
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
out[key] = redactDeep(child);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Attach non-enumerable `toJSON` and `util.inspect` guards to `value` and to
|
|
328
|
+
* every object inside it, so printing or serialising it (or any part of it)
|
|
329
|
+
* shows only {@link redactDeep}'s view. Reading a field directly still returns
|
|
330
|
+
* the secret; that is the explicit path. The guards are non-enumerable, so a
|
|
331
|
+
* spread or a schema parse drops them and the store never writes a redacted
|
|
332
|
+
* value.
|
|
333
|
+
*/
|
|
334
|
+
function guard(value) {
|
|
335
|
+
if (Array.isArray(value)) {
|
|
336
|
+
for (const item of value)
|
|
337
|
+
guard(item);
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
if (!isPlainObject(value))
|
|
341
|
+
return value;
|
|
342
|
+
for (const child of Object.values(value))
|
|
343
|
+
guard(child);
|
|
344
|
+
const self = value;
|
|
345
|
+
Object.defineProperty(self, 'toJSON', {
|
|
346
|
+
value: () => redactDeep(self),
|
|
347
|
+
enumerable: false,
|
|
348
|
+
configurable: true,
|
|
349
|
+
});
|
|
350
|
+
Object.defineProperty(self, inspect.custom, {
|
|
351
|
+
value: () => redactDeep(self),
|
|
352
|
+
enumerable: false,
|
|
353
|
+
configurable: true,
|
|
354
|
+
});
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Attach the store's non-enumerable `toJSON` and `util.inspect` guards to any
|
|
359
|
+
* value that holds device secrets (tokens, private keys), at every depth, so
|
|
360
|
+
* printing or serialising it shows only the redacted view. Never spread the
|
|
361
|
+
* result: a spread drops the guards.
|
|
362
|
+
*
|
|
363
|
+
* @param value - An object holding `token` or `privateKey` fields.
|
|
364
|
+
* @returns The same object, guarded.
|
|
365
|
+
*/
|
|
366
|
+
export function guardNexusDeviceSecrets(value) {
|
|
367
|
+
return guard(value);
|
|
368
|
+
}
|
|
369
|
+
/** A deep copy of an entry, guarded. */
|
|
370
|
+
function guardedCopy(entry) {
|
|
371
|
+
return guard(entrySchema.parse(structuredClone(entry)));
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Secret-free diagnostic view of one entry. Public keys are shown; private
|
|
375
|
+
* keys and tokens never are.
|
|
376
|
+
*
|
|
377
|
+
* @param entry - A stored entry.
|
|
378
|
+
* @returns A view safe to print, log or put in an envelope.
|
|
379
|
+
*/
|
|
380
|
+
export function redactNexusDeviceEntry(entry) {
|
|
381
|
+
const view = redactDeep(entry);
|
|
382
|
+
return isPlainObject(view) ? view : {};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Opaque read handle to one entry. Tokens and private keys are held in a
|
|
386
|
+
* private field; serialising or inspecting the handle yields
|
|
387
|
+
* {@link redactNexusDeviceEntry} only. The accessors are for the code that
|
|
388
|
+
* builds an `Authorization` header or signs with the device key.
|
|
389
|
+
*/
|
|
390
|
+
export class SealedNexusDevice {
|
|
391
|
+
/** Always `true`: this entry opened under this machine's key. */
|
|
392
|
+
readable = true;
|
|
393
|
+
/** API origin. */
|
|
394
|
+
origin;
|
|
395
|
+
/** Nexus user id. */
|
|
396
|
+
userId;
|
|
397
|
+
/** Nexus device id (UUIDv7). */
|
|
398
|
+
deviceId;
|
|
399
|
+
#entry;
|
|
400
|
+
/**
|
|
401
|
+
* @param origin - API origin.
|
|
402
|
+
* @param userId - Nexus user id.
|
|
403
|
+
* @param entry - Stored entry; captured privately as a copy.
|
|
404
|
+
*/
|
|
405
|
+
constructor(origin, userId, entry) {
|
|
406
|
+
this.origin = origin;
|
|
407
|
+
this.userId = userId;
|
|
408
|
+
this.deviceId = entry.deviceId;
|
|
409
|
+
this.#entry = entrySchema.parse(structuredClone(entry));
|
|
410
|
+
}
|
|
411
|
+
/** The current credential's token, or `null`. Call only where the request is built. */
|
|
412
|
+
currentBearer() {
|
|
413
|
+
return this.#entry.current?.token ?? null;
|
|
414
|
+
}
|
|
415
|
+
/** The pending rotation credential's token, or `null` (§2.5: try it before `current`). */
|
|
416
|
+
pendingBearer() {
|
|
417
|
+
return this.#entry.pending?.token ?? null;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A guarded copy of the whole entry, secrets included, for the flows that
|
|
421
|
+
* must act on them. Printing or serialising it shows the redacted view.
|
|
422
|
+
*/
|
|
423
|
+
unseal() {
|
|
424
|
+
return guardedCopy(this.#entry);
|
|
425
|
+
}
|
|
426
|
+
/** Secret-free JSON form. */
|
|
427
|
+
toJSON() {
|
|
428
|
+
return { origin: this.origin, userId: this.userId, ...redactNexusDeviceEntry(this.#entry) };
|
|
429
|
+
}
|
|
430
|
+
/** Secret-free string form. */
|
|
431
|
+
toString() {
|
|
432
|
+
return `SealedNexusDevice(${this.origin}, user ${this.userId}, device ${this.deviceId})`;
|
|
433
|
+
}
|
|
434
|
+
/** Secret-free `util.inspect` form. */
|
|
435
|
+
[inspect.custom]() {
|
|
436
|
+
return this.toString();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* What E1 returned, as it is about to be stored (contract §3.3 step 7). The
|
|
441
|
+
* issued credential and any device keys are held in private fields, so the
|
|
442
|
+
* value never prints a secret.
|
|
443
|
+
*/
|
|
444
|
+
export class NexusDeviceEnrolment {
|
|
445
|
+
/** The device id that was enrolled. */
|
|
446
|
+
deviceId;
|
|
447
|
+
#keys;
|
|
448
|
+
#credential;
|
|
449
|
+
/**
|
|
450
|
+
* @param input - The enrolled device id, the keys when this enrolment
|
|
451
|
+
* created or confirmed them (else `null`), and the credential E1 issued.
|
|
452
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_ENTRY_INVALID` on a malformed credential or keys.
|
|
453
|
+
*/
|
|
454
|
+
constructor(input) {
|
|
455
|
+
const shape = entrySchema.shape;
|
|
456
|
+
const parsed = z
|
|
457
|
+
.object({ deviceId: shape.deviceId, keys: shape.keys, current: shape.current.unwrap() })
|
|
458
|
+
.safeParse({ deviceId: input.deviceId, keys: input.keys, current: input.credential });
|
|
459
|
+
if (!parsed.success) {
|
|
460
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_ENTRY_INVALID', `invalid enrolment: ${issuesText(parsed.error)}`);
|
|
461
|
+
}
|
|
462
|
+
this.deviceId = parsed.data.deviceId;
|
|
463
|
+
this.#keys = parsed.data.keys;
|
|
464
|
+
this.#credential = parsed.data.current;
|
|
465
|
+
}
|
|
466
|
+
/** A guarded copy of the keys, or `null`. */
|
|
467
|
+
keys() {
|
|
468
|
+
return this.#keys ? guard(structuredClone(this.#keys)) : null;
|
|
469
|
+
}
|
|
470
|
+
/** A guarded copy of the issued credential. */
|
|
471
|
+
credential() {
|
|
472
|
+
return guard(structuredClone(this.#credential));
|
|
473
|
+
}
|
|
474
|
+
/** Secret-free JSON form. */
|
|
475
|
+
toJSON() {
|
|
476
|
+
const view = redactDeep({
|
|
477
|
+
deviceId: this.deviceId,
|
|
478
|
+
keys: this.#keys,
|
|
479
|
+
credential: this.#credential,
|
|
480
|
+
});
|
|
481
|
+
return isPlainObject(view) ? view : {};
|
|
482
|
+
}
|
|
483
|
+
/** Secret-free string form. */
|
|
484
|
+
toString() {
|
|
485
|
+
return `NexusDeviceEnrolment(device ${this.deviceId}, credential ${this.#credential.credentialId})`;
|
|
486
|
+
}
|
|
487
|
+
/** Secret-free `util.inspect` form. */
|
|
488
|
+
[inspect.custom]() {
|
|
489
|
+
return this.toString();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Zod issues as one line (paths and messages only; never values). */
|
|
493
|
+
function issuesText(error) {
|
|
494
|
+
return error.issues
|
|
495
|
+
.slice(0, 3)
|
|
496
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
497
|
+
.join('; ');
|
|
498
|
+
}
|
|
499
|
+
// ---------- slot transitions (pure; run them inside NexusDeviceStore.update) ----------
|
|
500
|
+
/** The live credentials of an entry, newest first: `raceCandidate`, `pending`, then `current`. */
|
|
501
|
+
function liveCredentials(entry) {
|
|
502
|
+
const out = [];
|
|
503
|
+
if (entry.raceCandidate) {
|
|
504
|
+
out.push({ credentialId: entry.raceCandidate.credentialId, token: entry.raceCandidate.token });
|
|
505
|
+
}
|
|
506
|
+
if (entry.pending)
|
|
507
|
+
out.push({ credentialId: entry.pending.credentialId, token: entry.pending.token });
|
|
508
|
+
if (entry.current)
|
|
509
|
+
out.push({ credentialId: entry.current.credentialId, token: entry.current.token });
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Merge credential lists in the order given, dropping repeated tokens. Never
|
|
514
|
+
* truncates: a merge that exceeds the slot size refuses, because every
|
|
515
|
+
* credential in these lists may still be live on the server.
|
|
516
|
+
*/
|
|
517
|
+
function mergeCredentials(...lists) {
|
|
518
|
+
const seen = new Set();
|
|
519
|
+
const out = [];
|
|
520
|
+
for (const c of lists.flat()) {
|
|
521
|
+
if (seen.has(c.token))
|
|
522
|
+
continue;
|
|
523
|
+
seen.add(c.token);
|
|
524
|
+
out.push({ credentialId: c.credentialId, token: c.token });
|
|
525
|
+
}
|
|
526
|
+
if (out.length > NEXUS_DEVICE_MAX_SLOT_CREDENTIALS) {
|
|
527
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_SLOT_FULL', `refusing to hold more than ${NEXUS_DEVICE_MAX_SLOT_CREDENTIALS} unconfirmed credentials for one device; settle the pending sign-out or revoke first, or revoke this device on cleocode.dev`);
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Throw unless a login may enrol `deviceId` over `entry`. While a revoke of
|
|
533
|
+
* the same device is unconfirmed, enrolling would re-activate a device the
|
|
534
|
+
* user asked to revoke: retry E10 first (`cleo logout nexus --revoke`), and
|
|
535
|
+
* if it cannot be confirmed, revoke the device on cleocode.dev. Call this
|
|
536
|
+
* before E1 is sent; {@link applyEnrolment} checks it again.
|
|
537
|
+
*
|
|
538
|
+
* @param entry - The entry read under the lock, or `null`.
|
|
539
|
+
* @param deviceId - The device id the login would enrol.
|
|
540
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_REVOKE_PENDING`.
|
|
541
|
+
*/
|
|
542
|
+
export function assertEnrolmentAllowed(entry, deviceId) {
|
|
543
|
+
if (entry !== null && entry.deviceId === deviceId && entry.pendingRevoke !== null) {
|
|
544
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_REVOKE_PENDING', 'a revoke of this device is not yet confirmed by the server. Run `cleo logout nexus --revoke` to retry it; if it still cannot be confirmed, revoke this device on cleocode.dev, then log in again');
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Record a successful E1 (M6).
|
|
549
|
+
*
|
|
550
|
+
* - **Same device:** store the new credential and clear `pending` and
|
|
551
|
+
* `pendingSignOut`, because E1 revoked every older credential of the device
|
|
552
|
+
* on the server. Refused while `pendingRevoke` is set
|
|
553
|
+
* ({@link assertEnrolmentAllowed}).
|
|
554
|
+
* - **New device** (a fresh identity replaced the entry's device): the old
|
|
555
|
+
* device's unsettled revoke, and any credential it still holds, move to
|
|
556
|
+
* `retired`, so they are retried rather than dropped.
|
|
557
|
+
*
|
|
558
|
+
* @param entry - The entry read under the lock, or `null` for a new identity.
|
|
559
|
+
* @param enrolment - E1's result.
|
|
560
|
+
* @param now - Clock, for `createdAt` of a new entry and retired items.
|
|
561
|
+
* @returns The updated entry, guarded.
|
|
562
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_REVOKE_PENDING`, or
|
|
563
|
+
* `E_NEXUS_DEVICE_SLOT_FULL` when `retired` is full.
|
|
564
|
+
*/
|
|
565
|
+
export function applyEnrolment(entry, enrolment, now = new Date()) {
|
|
566
|
+
assertEnrolmentAllowed(entry, enrolment.deviceId);
|
|
567
|
+
const credential = enrolment.credential();
|
|
568
|
+
const keys = enrolment.keys();
|
|
569
|
+
if (entry !== null && entry.deviceId === enrolment.deviceId) {
|
|
570
|
+
return guard({
|
|
571
|
+
...entry,
|
|
572
|
+
keys: keys ?? entry.keys,
|
|
573
|
+
current: { ...credential },
|
|
574
|
+
pending: null,
|
|
575
|
+
pendingSignOut: null,
|
|
576
|
+
raceCandidate: null,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const retired = retireDevice(entry, now);
|
|
580
|
+
return guard({
|
|
581
|
+
deviceId: enrolment.deviceId,
|
|
582
|
+
createdAt: now.toISOString(),
|
|
583
|
+
keys,
|
|
584
|
+
current: { ...credential },
|
|
585
|
+
pending: null,
|
|
586
|
+
pendingSignOut: null,
|
|
587
|
+
pendingRevoke: null,
|
|
588
|
+
...(retired.length > 0 ? { retired } : {}),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* The `retired` list an entry leaves behind when a new identity replaces its
|
|
593
|
+
* device: every credential the old device may still hold (and its unsettled
|
|
594
|
+
* sign-out or revoke) is kept for a retry, never dropped.
|
|
595
|
+
*
|
|
596
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_SLOT_FULL` when `retired` is full.
|
|
597
|
+
*/
|
|
598
|
+
function retireDevice(entry, now) {
|
|
599
|
+
const retired = (entry?.retired ?? []).map((r) => ({
|
|
600
|
+
deviceId: r.deviceId,
|
|
601
|
+
kind: r.kind,
|
|
602
|
+
credentials: r.credentials.map((c) => ({ credentialId: c.credentialId, token: c.token })),
|
|
603
|
+
requestedAt: r.requestedAt,
|
|
604
|
+
}));
|
|
605
|
+
if (entry !== null) {
|
|
606
|
+
const live = mergeCredentials(liveCredentials(entry), entry.pendingSignOut?.credentials ?? []);
|
|
607
|
+
if (live.length > 0) {
|
|
608
|
+
retired.push({
|
|
609
|
+
deviceId: entry.deviceId,
|
|
610
|
+
kind: 'sign-out',
|
|
611
|
+
credentials: live,
|
|
612
|
+
requestedAt: entry.pendingSignOut?.requestedAt ?? now.toISOString(),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (entry.pendingRevoke) {
|
|
616
|
+
retired.push({
|
|
617
|
+
deviceId: entry.deviceId,
|
|
618
|
+
kind: 'revoke',
|
|
619
|
+
credentials: entry.pendingRevoke.credentials.map((c) => ({
|
|
620
|
+
credentialId: c.credentialId,
|
|
621
|
+
token: c.token,
|
|
622
|
+
})),
|
|
623
|
+
requestedAt: entry.pendingRevoke.requestedAt,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (retired.length > NEXUS_DEVICE_MAX_RETIRED) {
|
|
628
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_SLOT_FULL', `refusing to retire more than ${NEXUS_DEVICE_MAX_RETIRED} unsettled devices for one account; settle them first, or revoke them on cleocode.dev`);
|
|
629
|
+
}
|
|
630
|
+
return retired;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Persist the identity a login or upgrade is about to enrol, with its E1
|
|
634
|
+
* intent (§3.3 step 5, §3.4 step 1), before E1 is sent.
|
|
635
|
+
*
|
|
636
|
+
* - **No entry:** a new identity with `deviceId` and `keys`.
|
|
637
|
+
* - **Same device:** the intent is recorded; `keys` fill in only when the
|
|
638
|
+
* entry has none (lost keys, §3.3 step 6 `device-keys-changed`). Every
|
|
639
|
+
* credential and slot is kept: E1 has not run yet.
|
|
640
|
+
* - **Another device** (a fresh identity after 409 `device-revoked`,
|
|
641
|
+
* `device-other-account`, `device-id-taken` or `device-keys-changed`): the
|
|
642
|
+
* old device's credentials and unsettled slots move to `retired`, as in
|
|
643
|
+
* {@link applyEnrolment}, so nothing live is dropped.
|
|
644
|
+
*
|
|
645
|
+
* @param entry - The entry read under the lock, or `null`.
|
|
646
|
+
* @param identity - The device id and keys to enrol, and the intent.
|
|
647
|
+
* @param now - Clock.
|
|
648
|
+
* @returns The updated entry, guarded.
|
|
649
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_REVOKE_PENDING` for the same
|
|
650
|
+
* device while a revoke is unconfirmed, or `E_NEXUS_DEVICE_SLOT_FULL`.
|
|
651
|
+
*/
|
|
652
|
+
export function applyEnrolIntent(entry, identity, now = new Date()) {
|
|
653
|
+
assertEnrolmentAllowed(entry, identity.deviceId);
|
|
654
|
+
if (entry !== null && entry.deviceId === identity.deviceId) {
|
|
655
|
+
return guard({
|
|
656
|
+
...entry,
|
|
657
|
+
keys: entry.keys ?? structuredClone(identity.keys),
|
|
658
|
+
enrolIntent: { ...identity.intent },
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
const retired = retireDevice(entry, now);
|
|
662
|
+
return guard({
|
|
663
|
+
deviceId: identity.deviceId,
|
|
664
|
+
createdAt: now.toISOString(),
|
|
665
|
+
keys: structuredClone(identity.keys),
|
|
666
|
+
current: null,
|
|
667
|
+
pending: null,
|
|
668
|
+
pendingSignOut: null,
|
|
669
|
+
pendingRevoke: null,
|
|
670
|
+
...(retired.length > 0 ? { retired } : {}),
|
|
671
|
+
enrolIntent: { ...identity.intent },
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Clear an E1 intent, as a compare-and-swap on its owner: another process's
|
|
676
|
+
* intent is never cleared.
|
|
677
|
+
*
|
|
678
|
+
* @param entry - The entry read under the lock.
|
|
679
|
+
* @param owner - The intent owner this process wrote.
|
|
680
|
+
* @returns The updated entry (guarded), or the entry unchanged.
|
|
681
|
+
*/
|
|
682
|
+
export function applyClearEnrolIntent(entry, owner) {
|
|
683
|
+
if (entry.enrolIntent?.owner !== owner)
|
|
684
|
+
return entry;
|
|
685
|
+
return guard({ ...entry, enrolIntent: null });
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Drop `current`, as a compare-and-swap on its credential id: used when the
|
|
689
|
+
* server refused it and no other credential is live (§3.3 step 7, both
|
|
690
|
+
* racing credentials answered 401). Nothing is moved to a sign-out slot: a
|
|
691
|
+
* refused credential needs no E9.
|
|
692
|
+
*
|
|
693
|
+
* @param entry - The entry read under the lock.
|
|
694
|
+
* @param credentialId - The credential id the caller saw refused.
|
|
695
|
+
* @returns The updated entry (guarded), or the entry unchanged when `current` moved on.
|
|
696
|
+
*/
|
|
697
|
+
export function applyDropCurrent(entry, credentialId) {
|
|
698
|
+
if (entry.current?.credentialId !== credentialId)
|
|
699
|
+
return entry;
|
|
700
|
+
return guard({ ...entry, current: null });
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Refresh this process's E1 intent right before E1 is sent (compare-and-swap
|
|
704
|
+
* on the owner), so a live intent never looks stale while its owner is still
|
|
705
|
+
* working (§3.4 step 1).
|
|
706
|
+
*
|
|
707
|
+
* @param entry - The entry read under the lock.
|
|
708
|
+
* @param owner - The intent owner this process wrote.
|
|
709
|
+
* @param now - Clock.
|
|
710
|
+
* @returns The updated entry (guarded), or `null` when the intent is no
|
|
711
|
+
* longer this process's (another process replaced or took it over).
|
|
712
|
+
*/
|
|
713
|
+
export function applyRefreshEnrolIntent(entry, owner, now = new Date()) {
|
|
714
|
+
const intent = entry.enrolIntent;
|
|
715
|
+
if (!intent || intent.owner !== owner)
|
|
716
|
+
return null;
|
|
717
|
+
return guard({ ...entry, enrolIntent: { ...intent, startedAt: now.toISOString() } });
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Park a racing login's credential beside `current` until E2 settles which is
|
|
721
|
+
* live (§3.3 step 7). An existing candidate is replaced only by a newer one;
|
|
722
|
+
* the older is dropped, because E1 re-enrolment revoked it.
|
|
723
|
+
*
|
|
724
|
+
* @param entry - The entry read under the lock.
|
|
725
|
+
* @param candidate - The credential E1 issued to this process.
|
|
726
|
+
* @returns The updated entry, guarded.
|
|
727
|
+
*/
|
|
728
|
+
export function applySetRaceCandidate(entry, candidate) {
|
|
729
|
+
const held = entry.raceCandidate ?? null;
|
|
730
|
+
if (held !== null && Date.parse(held.createdAt) > Date.parse(candidate.createdAt))
|
|
731
|
+
return entry;
|
|
732
|
+
return guard({ ...entry, raceCandidate: structuredClone(candidate) });
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Drop the race candidate, as a compare-and-swap on its credential id (it
|
|
736
|
+
* lost: the server refused it, or `current` is newer).
|
|
737
|
+
*
|
|
738
|
+
* @param entry - The entry read under the lock.
|
|
739
|
+
* @param credentialId - The candidate the caller settled.
|
|
740
|
+
* @returns The updated entry (guarded), or the entry unchanged.
|
|
741
|
+
*/
|
|
742
|
+
export function applyDropRaceCandidate(entry, credentialId) {
|
|
743
|
+
if (entry.raceCandidate?.credentialId !== credentialId)
|
|
744
|
+
return entry;
|
|
745
|
+
return guard({ ...entry, raceCandidate: null });
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Store a freshly minted rotation credential as `pending` (§2.5 step 1). A
|
|
749
|
+
* second pending credential is never minted: when one exists the caller must
|
|
750
|
+
* replay it instead. The caller MUST let `update` write it (return from the
|
|
751
|
+
* transaction, releasing the lock) before sending E8, never send E8 inside it.
|
|
752
|
+
*
|
|
753
|
+
* @param entry - The entry read under the lock.
|
|
754
|
+
* @param newToken - The client-minted `cnx_d1_…` token.
|
|
755
|
+
* @param now - Clock.
|
|
756
|
+
* @returns The updated entry, guarded.
|
|
757
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_PENDING_EXISTS` when `pending` is already set.
|
|
758
|
+
*/
|
|
759
|
+
export function applyPendingRotation(entry, newToken, now = new Date()) {
|
|
760
|
+
if (entry.pending) {
|
|
761
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_PENDING_EXISTS', 'a pending rotation credential already exists; replay it instead of minting another');
|
|
762
|
+
}
|
|
763
|
+
const pending = entrySchema.shape.pending.unwrap().safeParse({
|
|
764
|
+
credentialId: null,
|
|
765
|
+
token: newToken,
|
|
766
|
+
createdAt: now.toISOString(),
|
|
767
|
+
});
|
|
768
|
+
if (!pending.success) {
|
|
769
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_ENTRY_INVALID', `invalid pending credential: ${issuesText(pending.error)}`);
|
|
770
|
+
}
|
|
771
|
+
return guard({ ...entry, pending: pending.data });
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Promote `pending` to `current` (§2.5 step 5, or its first successful use),
|
|
775
|
+
* as a compare-and-swap: only when `pending` still holds `expectedToken`.
|
|
776
|
+
* The profile and scopes carry over: E8 issues the new credential with the
|
|
777
|
+
* same profile and scopes as the one it replaced.
|
|
778
|
+
*
|
|
779
|
+
* @param entry - The entry read under the lock.
|
|
780
|
+
* @param expectedToken - The pending token the caller saw succeed.
|
|
781
|
+
* @param credentialId - The server id of the promoted credential.
|
|
782
|
+
* @returns The updated entry (guarded), or the entry unchanged when `pending`
|
|
783
|
+
* moved on or there is nothing to promote.
|
|
784
|
+
*/
|
|
785
|
+
export function applyPromotePending(entry, expectedToken, credentialId) {
|
|
786
|
+
if (!entry.pending || !entry.current || entry.pending.token !== expectedToken)
|
|
787
|
+
return entry;
|
|
788
|
+
return guard({
|
|
789
|
+
...entry,
|
|
790
|
+
current: {
|
|
791
|
+
...entry.current,
|
|
792
|
+
credentialId,
|
|
793
|
+
token: entry.pending.token,
|
|
794
|
+
createdAt: entry.pending.createdAt,
|
|
795
|
+
},
|
|
796
|
+
pending: null,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* CAS-drop `pending`: only when it still holds `expectedToken` (§2.5).
|
|
801
|
+
*
|
|
802
|
+
* @param entry - The entry read under the lock.
|
|
803
|
+
* @param expectedToken - The pending token the caller saw fail.
|
|
804
|
+
* @returns The updated entry (guarded), or the entry unchanged when `pending` moved on.
|
|
805
|
+
*/
|
|
806
|
+
export function applyDropPending(entry, expectedToken) {
|
|
807
|
+
if (!entry.pending || entry.pending.token !== expectedToken)
|
|
808
|
+
return entry;
|
|
809
|
+
return guard({ ...entry, pending: null });
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Begin `cleo logout nexus` (§3.5, M3): move the live credentials into
|
|
813
|
+
* `pendingSignOut`, newest first (`pending`, then `current`, then any
|
|
814
|
+
* credential an earlier unsettled sign-out holds), so they are used for
|
|
815
|
+
* nothing but the E9 retry. The device keys stay.
|
|
816
|
+
*
|
|
817
|
+
* @param entry - The entry read under the lock.
|
|
818
|
+
* @param now - Clock.
|
|
819
|
+
* @returns The updated entry (guarded), unchanged when it holds no credential at all.
|
|
820
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_SLOT_FULL` rather than drop a credential.
|
|
821
|
+
*/
|
|
822
|
+
export function applyBeginSignOut(entry, now = new Date()) {
|
|
823
|
+
const credentials = mergeCredentials(liveCredentials(entry), entry.pendingSignOut?.credentials ?? []);
|
|
824
|
+
if (credentials.length === 0)
|
|
825
|
+
return entry;
|
|
826
|
+
return guard({
|
|
827
|
+
...entry,
|
|
828
|
+
current: null,
|
|
829
|
+
pending: null,
|
|
830
|
+
raceCandidate: null,
|
|
831
|
+
pendingSignOut: {
|
|
832
|
+
credentials,
|
|
833
|
+
requestedAt: entry.pendingSignOut?.requestedAt ?? now.toISOString(),
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Begin `cleo logout nexus --revoke` (§3.5, M3): move every credential the
|
|
839
|
+
* entry holds into `pendingRevoke`, newest first: `pending`, `current`, an
|
|
840
|
+
* unsettled sign-out's, then an earlier unsettled revoke's. The entry, and its
|
|
841
|
+
* keys, stay until E10 is confirmed.
|
|
842
|
+
*
|
|
843
|
+
* @param entry - The entry read under the lock.
|
|
844
|
+
* @param now - Clock.
|
|
845
|
+
* @returns The updated entry (guarded), unchanged when it holds no credential at all.
|
|
846
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_SLOT_FULL` rather than drop a credential.
|
|
847
|
+
*/
|
|
848
|
+
export function applyBeginRevoke(entry, now = new Date()) {
|
|
849
|
+
const credentials = mergeCredentials(liveCredentials(entry), entry.pendingSignOut?.credentials ?? [], entry.pendingRevoke?.credentials ?? []);
|
|
850
|
+
if (credentials.length === 0)
|
|
851
|
+
return entry;
|
|
852
|
+
return guard({
|
|
853
|
+
...entry,
|
|
854
|
+
current: null,
|
|
855
|
+
pending: null,
|
|
856
|
+
raceCandidate: null,
|
|
857
|
+
pendingSignOut: null,
|
|
858
|
+
pendingRevoke: {
|
|
859
|
+
credentials,
|
|
860
|
+
requestedAt: entry.pendingRevoke?.requestedAt ?? now.toISOString(),
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Settle a confirmed sign-out (E9 answered 200, or 401 `device-signed-out` or
|
|
866
|
+
* `device-revoked`): clear `pendingSignOut`.
|
|
867
|
+
*
|
|
868
|
+
* @param entry - The entry read under the lock.
|
|
869
|
+
* @returns The updated entry, guarded.
|
|
870
|
+
*/
|
|
871
|
+
export function applySignOutConfirmed(entry) {
|
|
872
|
+
return guard({ ...entry, pendingSignOut: null });
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Settle a confirmed sign-out or revoke of a retired device.
|
|
876
|
+
*
|
|
877
|
+
* @param entry - The entry read under the lock.
|
|
878
|
+
* @param deviceId - The retired device the server confirmed.
|
|
879
|
+
* @param kind - Which request was confirmed.
|
|
880
|
+
* @returns The updated entry, guarded.
|
|
881
|
+
*/
|
|
882
|
+
export function applyRetiredSettled(entry, deviceId, kind) {
|
|
883
|
+
const retired = (entry.retired ?? []).filter((r) => !(r.deviceId === deviceId && r.kind === kind));
|
|
884
|
+
const { retired: _drop, ...rest } = entry;
|
|
885
|
+
return guard(retired.length > 0 ? { ...rest, retired } : rest);
|
|
886
|
+
}
|
|
887
|
+
// ---------- sealing ----------
|
|
888
|
+
/**
|
|
889
|
+
* The machine-key KDF id binding one entry's secrets. JSON-encoding the pair
|
|
890
|
+
* keeps it unambiguous: `("https://a.test:8443", "u")` and
|
|
891
|
+
* `("https://a.test", "8443:u")` give different ids (review N3).
|
|
892
|
+
*/
|
|
893
|
+
function sealId(origin, userId) {
|
|
894
|
+
return `nexus-device:${JSON.stringify([origin, userId])}`;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Apply `f` to every secret leaf of an entry, with the leaf's field path
|
|
898
|
+
* (used as GCM associated data), keeping every other field (known or not).
|
|
899
|
+
* The sealed and plain entry shapes differ only in what the secret strings
|
|
900
|
+
* hold, so one type serves both directions.
|
|
901
|
+
*/
|
|
902
|
+
function mapSecrets(entry, f) {
|
|
903
|
+
const creds = (list, at) => list.map((c, i) => ({ ...c, token: f(c.token, `${at}.credentials.${i}.token`) }));
|
|
904
|
+
const pair = (p, at) => ({
|
|
905
|
+
...p,
|
|
906
|
+
privateKey: f(p.privateKey, `${at}.privateKey`),
|
|
907
|
+
});
|
|
908
|
+
return {
|
|
909
|
+
...entry,
|
|
910
|
+
keys: entry.keys
|
|
911
|
+
? {
|
|
912
|
+
...entry.keys,
|
|
913
|
+
encryption: pair(entry.keys.encryption, 'keys.encryption'),
|
|
914
|
+
signing: pair(entry.keys.signing, 'keys.signing'),
|
|
915
|
+
}
|
|
916
|
+
: null,
|
|
917
|
+
current: entry.current
|
|
918
|
+
? { ...entry.current, token: f(entry.current.token, 'current.token') }
|
|
919
|
+
: null,
|
|
920
|
+
pending: entry.pending
|
|
921
|
+
? { ...entry.pending, token: f(entry.pending.token, 'pending.token') }
|
|
922
|
+
: null,
|
|
923
|
+
...(entry.raceCandidate
|
|
924
|
+
? {
|
|
925
|
+
raceCandidate: {
|
|
926
|
+
...entry.raceCandidate,
|
|
927
|
+
token: f(entry.raceCandidate.token, 'raceCandidate.token'),
|
|
928
|
+
},
|
|
929
|
+
}
|
|
930
|
+
: {}),
|
|
931
|
+
pendingSignOut: entry.pendingSignOut
|
|
932
|
+
? {
|
|
933
|
+
...entry.pendingSignOut,
|
|
934
|
+
credentials: creds(entry.pendingSignOut.credentials, 'pendingSignOut'),
|
|
935
|
+
}
|
|
936
|
+
: null,
|
|
937
|
+
pendingRevoke: entry.pendingRevoke
|
|
938
|
+
? {
|
|
939
|
+
...entry.pendingRevoke,
|
|
940
|
+
credentials: creds(entry.pendingRevoke.credentials, 'pendingRevoke'),
|
|
941
|
+
}
|
|
942
|
+
: null,
|
|
943
|
+
...(entry.retired
|
|
944
|
+
? {
|
|
945
|
+
retired: entry.retired.map((r, i) => ({
|
|
946
|
+
...r,
|
|
947
|
+
credentials: creds(r.credentials, `retired.${i}`),
|
|
948
|
+
})),
|
|
949
|
+
}
|
|
950
|
+
: {}),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** Thrown inside {@link mapSecrets} when one value fails to open. */
|
|
954
|
+
class UnsealLeafError extends Error {
|
|
955
|
+
}
|
|
956
|
+
/** A file's identity for fencing: inode, size and mtime in ns, or `absent`. */
|
|
957
|
+
function fingerprintOf(path) {
|
|
958
|
+
try {
|
|
959
|
+
const st = lstatSync(path, { bigint: true });
|
|
960
|
+
return `${st.ino}:${st.size}:${st.mtimeNs}`;
|
|
961
|
+
}
|
|
962
|
+
catch (err) {
|
|
963
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT')
|
|
964
|
+
return 'absent';
|
|
965
|
+
throw err;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/** An entry this machine's key cannot open, as {@link NexusDeviceStore.list} reports it. No secrets. */
|
|
969
|
+
export class UnreadableNexusDevice {
|
|
970
|
+
/** Always `false`: the entry could not be opened here. */
|
|
971
|
+
readable = false;
|
|
972
|
+
/** API origin. */
|
|
973
|
+
origin;
|
|
974
|
+
/** Nexus user id. */
|
|
975
|
+
userId;
|
|
976
|
+
/** The device id stored in clear, when present. */
|
|
977
|
+
deviceId;
|
|
978
|
+
/** Why it could not be opened. */
|
|
979
|
+
reason;
|
|
980
|
+
/**
|
|
981
|
+
* @param origin - API origin.
|
|
982
|
+
* @param userId - Nexus user id.
|
|
983
|
+
* @param deviceId - The device id stored in clear, when present.
|
|
984
|
+
* @param reason - Why it could not be opened.
|
|
985
|
+
*/
|
|
986
|
+
constructor(origin, userId, deviceId, reason) {
|
|
987
|
+
this.origin = origin;
|
|
988
|
+
this.userId = userId;
|
|
989
|
+
this.deviceId = deviceId;
|
|
990
|
+
this.reason = reason;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/** Store files locked by the current async context (re-entry detection). */
|
|
994
|
+
const heldLocks = new AsyncLocalStorage();
|
|
995
|
+
/**
|
|
996
|
+
* File-backed store for Nexus device identities and credentials. See the
|
|
997
|
+
* module comment for the safety rules.
|
|
998
|
+
*/
|
|
999
|
+
export class NexusDeviceStore {
|
|
1000
|
+
/** Absolute path of the store file. */
|
|
1001
|
+
location;
|
|
1002
|
+
#lockWaitMs;
|
|
1003
|
+
#lockStaleMs;
|
|
1004
|
+
#cleoHome;
|
|
1005
|
+
/**
|
|
1006
|
+
* @param path - Store path; defaults to {@link nexusDevicePath}.
|
|
1007
|
+
* @param options - Lock and sealing options.
|
|
1008
|
+
*/
|
|
1009
|
+
constructor(path = nexusDevicePath(), options = {}) {
|
|
1010
|
+
this.location = resolve(path);
|
|
1011
|
+
this.#lockWaitMs = options.lockWaitMs ?? NEXUS_DEVICE_LOCK_WAIT_MS;
|
|
1012
|
+
this.#lockStaleMs = Math.max(options.lockStaleMs ?? NEXUS_DEVICE_LOCK_STALE_MS, 2000);
|
|
1013
|
+
this.#cleoHome = options.cleoHome ?? dirname(this.location);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* A sealed snapshot of one entry, read without the lock. Use it to decide;
|
|
1017
|
+
* any change goes through {@link NexusDeviceStore.update}, which re-reads.
|
|
1018
|
+
*
|
|
1019
|
+
* @param apiUrl - Any URL on the API host.
|
|
1020
|
+
* @param userId - Nexus user id.
|
|
1021
|
+
* @returns The sealed entry, or `null`.
|
|
1022
|
+
* @throws {NexusDeviceStoreError} On an unsafe, newer, malformed or foreign-sealed file.
|
|
1023
|
+
*/
|
|
1024
|
+
async get(apiUrl, userId) {
|
|
1025
|
+
const origin = nexusOriginKey(apiUrl);
|
|
1026
|
+
const sealed = this.readSealed().file.devices[origin]?.[userId];
|
|
1027
|
+
if (!sealed)
|
|
1028
|
+
return null;
|
|
1029
|
+
const material = await this.material(false);
|
|
1030
|
+
return new SealedNexusDevice(origin, userId, this.open(origin, userId, sealed, material));
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Every entry, sorted by origin then user id. An entry this machine's key
|
|
1034
|
+
* cannot open is listed as an {@link UnreadableNexusDevice} (no secrets)
|
|
1035
|
+
* instead of failing the whole listing.
|
|
1036
|
+
*
|
|
1037
|
+
* @returns The entries.
|
|
1038
|
+
* @throws {NexusDeviceStoreError} On an unsafe, newer or malformed file.
|
|
1039
|
+
*/
|
|
1040
|
+
async list() {
|
|
1041
|
+
const out = [];
|
|
1042
|
+
const devices = this.readSealed().file.devices;
|
|
1043
|
+
const material = Object.keys(devices).length > 0 ? await this.material(false) : null;
|
|
1044
|
+
for (const origin of Object.keys(devices).sort()) {
|
|
1045
|
+
const users = devices[origin] ?? {};
|
|
1046
|
+
for (const userId of Object.keys(users).sort()) {
|
|
1047
|
+
const sealed = users[userId];
|
|
1048
|
+
if (!sealed)
|
|
1049
|
+
continue;
|
|
1050
|
+
try {
|
|
1051
|
+
out.push(new SealedNexusDevice(origin, userId, this.open(origin, userId, sealed, material)));
|
|
1052
|
+
}
|
|
1053
|
+
catch (err) {
|
|
1054
|
+
if (!(err instanceof NexusDeviceStoreError && err.code === 'E_NEXUS_DEVICE_UNSEAL_FAILED')) {
|
|
1055
|
+
throw err;
|
|
1056
|
+
}
|
|
1057
|
+
out.push(new UnreadableNexusDevice(origin, userId, sealed.deviceId, material === null
|
|
1058
|
+
? 'this CLEO home has no machine key or global salt (lost, or never created); kept untouched'
|
|
1059
|
+
: "sealed under another machine's key (copied from another machine or home); kept untouched"));
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Run `fn` under this store's cross-process lock (M6, N1).
|
|
1067
|
+
*
|
|
1068
|
+
* - The file is re-read **after** the lock is acquired, so `fn` always sees
|
|
1069
|
+
* the latest state, never a snapshot taken before another process wrote.
|
|
1070
|
+
* - `fn` may await local work (another store's lock, taken after this one),
|
|
1071
|
+
* but MUST NOT make a network call: the lock is never held across E1,
|
|
1072
|
+
* E8, E9 or E10 (v2.9). Persist the intent, return, make the call, then
|
|
1073
|
+
* call `update` again and re-check with a CAS.
|
|
1074
|
+
* - `tx.flush()` writes mid-transaction; whatever is still unflushed is
|
|
1075
|
+
* written when `fn` returns. When `fn` throws, unflushed changes are
|
|
1076
|
+
* dropped.
|
|
1077
|
+
* - Waiting for the lock gives up after `lockWaitMs` with
|
|
1078
|
+
* `E_NEXUS_DEVICE_BUSY`. Calling `update` on the same file from inside
|
|
1079
|
+
* `fn` fails at once with `E_NEXUS_DEVICE_REENTRANT` (it would otherwise
|
|
1080
|
+
* wait on itself).
|
|
1081
|
+
* - If the lock is lost while `fn` runs, `tx.signal` aborts, nothing more
|
|
1082
|
+
* is written, and `update` rejects with `E_NEXUS_DEVICE_LOCK_COMPROMISED`.
|
|
1083
|
+
*
|
|
1084
|
+
* @param fn - The read-modify-write step.
|
|
1085
|
+
* @returns What `fn` returned.
|
|
1086
|
+
* @throws {NexusDeviceStoreError} As above, or on an unsafe, newer, malformed or foreign-sealed file.
|
|
1087
|
+
*/
|
|
1088
|
+
async update(fn) {
|
|
1089
|
+
const held = heldLocks.getStore();
|
|
1090
|
+
if (held?.has(this.location)) {
|
|
1091
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_REENTRANT', `update() on ${this.location} was called from inside another update() on the same file; do the work in the outer transaction`);
|
|
1092
|
+
}
|
|
1093
|
+
const warnings = [];
|
|
1094
|
+
const tightened = this.prepareDirectory();
|
|
1095
|
+
if (tightened !== null)
|
|
1096
|
+
warnings.push(tightened);
|
|
1097
|
+
this.seed();
|
|
1098
|
+
const controller = new AbortController();
|
|
1099
|
+
let release;
|
|
1100
|
+
try {
|
|
1101
|
+
release = await lockfile.lock(this.location, {
|
|
1102
|
+
stale: this.#lockStaleMs,
|
|
1103
|
+
retries: {
|
|
1104
|
+
retries: Math.max(1, Math.ceil(this.#lockWaitMs / 250)),
|
|
1105
|
+
factor: 1,
|
|
1106
|
+
minTimeout: 250,
|
|
1107
|
+
maxTimeout: 250,
|
|
1108
|
+
},
|
|
1109
|
+
onCompromised: () => {
|
|
1110
|
+
controller.abort(new NexusDeviceStoreError('E_NEXUS_DEVICE_LOCK_COMPROMISED', `the lock on ${this.location} was lost while it was held (another process took it over as stale); nothing more was written. Retry the command`));
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
if (err instanceof Error && 'code' in err && err.code === 'ELOCKED') {
|
|
1116
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_BUSY', `another cleo process has held ${this.location} for more than ${Math.round(this.#lockWaitMs / 1000)} s; retry when it finishes`);
|
|
1117
|
+
}
|
|
1118
|
+
throw err;
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
return await heldLocks.run(new Set([...(held ?? []), this.location]), () => this.transact(fn, controller.signal, warnings));
|
|
1122
|
+
}
|
|
1123
|
+
finally {
|
|
1124
|
+
try {
|
|
1125
|
+
await release();
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
/* already released: the lock was compromised */
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/** The body of {@link NexusDeviceStore.update}, with the lock held. */
|
|
1133
|
+
async transact(fn, signal, warnings) {
|
|
1134
|
+
this.sweepTempFiles();
|
|
1135
|
+
const { file: sealedState, raw, fingerprint: readPrint } = this.readSealed();
|
|
1136
|
+
let fingerprint = readPrint;
|
|
1137
|
+
// Write paths may create the machine key and salt (review N1).
|
|
1138
|
+
let material = null;
|
|
1139
|
+
const ensureMaterial = async () => {
|
|
1140
|
+
if (material === null)
|
|
1141
|
+
material = await this.material(true);
|
|
1142
|
+
if (material === null) {
|
|
1143
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_MACHINE_KEY', `the machine key or global salt of ${this.#cleoHome} could not be created`);
|
|
1144
|
+
}
|
|
1145
|
+
return material;
|
|
1146
|
+
};
|
|
1147
|
+
if (Object.keys(sealedState.devices).length > 0)
|
|
1148
|
+
await ensureMaterial();
|
|
1149
|
+
const state = {};
|
|
1150
|
+
// Entries this machine's key cannot open: kept as their raw JSON and
|
|
1151
|
+
// written back unchanged, never decrypted, rewritten or deleted.
|
|
1152
|
+
const opaque = {};
|
|
1153
|
+
for (const [origin, users] of Object.entries(sealedState.devices)) {
|
|
1154
|
+
for (const [userId, sealed] of Object.entries(users)) {
|
|
1155
|
+
try {
|
|
1156
|
+
state[origin] = {
|
|
1157
|
+
...(state[origin] ?? {}),
|
|
1158
|
+
[userId]: this.open(origin, userId, sealed, material),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
catch (err) {
|
|
1162
|
+
if (!(err instanceof NexusDeviceStoreError && err.code === 'E_NEXUS_DEVICE_UNSEAL_FAILED')) {
|
|
1163
|
+
throw err;
|
|
1164
|
+
}
|
|
1165
|
+
opaque[origin] = { ...(opaque[origin] ?? {}), [userId]: rawEntry(raw, origin, userId) };
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const refuseOpaque = (origin, userId) => {
|
|
1170
|
+
if (opaque[origin]?.[userId] !== undefined) {
|
|
1171
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_UNSEAL_FAILED', `the Nexus device entry for ${origin} could not be opened with this machine's key and is kept untouched: ${this.location}. Run \`cleo login nexus\` against another origin or account, or remove the entry by hand`);
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
let dirty = false;
|
|
1175
|
+
const assertLive = () => {
|
|
1176
|
+
if (signal.aborted) {
|
|
1177
|
+
throw signal.reason instanceof Error
|
|
1178
|
+
? signal.reason
|
|
1179
|
+
: new NexusDeviceStoreError('E_NEXUS_DEVICE_LOCK_COMPROMISED', 'the lock was lost');
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
const flush = async () => {
|
|
1183
|
+
assertLive();
|
|
1184
|
+
if (!dirty)
|
|
1185
|
+
return;
|
|
1186
|
+
const sealed = this.seal(sealedState, state, opaque, await ensureMaterial());
|
|
1187
|
+
assertLive();
|
|
1188
|
+
fingerprint = this.writeAtomic(sealed, fingerprint);
|
|
1189
|
+
this.purgeBackups();
|
|
1190
|
+
dirty = false;
|
|
1191
|
+
};
|
|
1192
|
+
const tx = {
|
|
1193
|
+
signal,
|
|
1194
|
+
warnings,
|
|
1195
|
+
get: (apiUrl, userId) => {
|
|
1196
|
+
const origin = nexusOriginKey(apiUrl);
|
|
1197
|
+
refuseOpaque(origin, userId);
|
|
1198
|
+
const entry = state[origin]?.[userId];
|
|
1199
|
+
return entry ? guardedCopy(entry) : null;
|
|
1200
|
+
},
|
|
1201
|
+
set: (apiUrl, userId, entry) => {
|
|
1202
|
+
assertLive();
|
|
1203
|
+
refuseOpaque(nexusOriginKey(apiUrl), userId);
|
|
1204
|
+
const parsed = entrySchema.safeParse(entry);
|
|
1205
|
+
if (!parsed.success) {
|
|
1206
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_ENTRY_INVALID', `refusing to store an invalid Nexus device entry: ${issuesText(parsed.error)}`);
|
|
1207
|
+
}
|
|
1208
|
+
const origin = nexusOriginKey(apiUrl);
|
|
1209
|
+
state[origin] = { ...(state[origin] ?? {}), [userId]: parsed.data };
|
|
1210
|
+
dirty = true;
|
|
1211
|
+
},
|
|
1212
|
+
delete: (apiUrl, userId, expected) => {
|
|
1213
|
+
assertLive();
|
|
1214
|
+
const origin = nexusOriginKey(apiUrl);
|
|
1215
|
+
refuseOpaque(origin, userId);
|
|
1216
|
+
const users = state[origin];
|
|
1217
|
+
const entry = users?.[userId];
|
|
1218
|
+
if (!users || !entry)
|
|
1219
|
+
return false;
|
|
1220
|
+
if (entry.deviceId !== expected.deviceId)
|
|
1221
|
+
return false;
|
|
1222
|
+
if ((entry.current?.credentialId ?? null) !== expected.credentialId)
|
|
1223
|
+
return false;
|
|
1224
|
+
delete users[userId];
|
|
1225
|
+
if (Object.keys(users).length === 0)
|
|
1226
|
+
delete state[origin];
|
|
1227
|
+
dirty = true;
|
|
1228
|
+
return true;
|
|
1229
|
+
},
|
|
1230
|
+
flush,
|
|
1231
|
+
discardUnreadable: (apiUrl, userId, expected) => {
|
|
1232
|
+
assertLive();
|
|
1233
|
+
const origin = nexusOriginKey(apiUrl);
|
|
1234
|
+
const users = opaque[origin];
|
|
1235
|
+
const raw = users?.[userId];
|
|
1236
|
+
const rawDeviceId = isPlainObject(raw) ? raw.deviceId : undefined;
|
|
1237
|
+
if (!users || raw === undefined || rawDeviceId !== expected.deviceId) {
|
|
1238
|
+
return { discarded: false, warning: null };
|
|
1239
|
+
}
|
|
1240
|
+
delete users[userId];
|
|
1241
|
+
if (Object.keys(users).length === 0)
|
|
1242
|
+
delete opaque[origin];
|
|
1243
|
+
dirty = true;
|
|
1244
|
+
const warning = `W_NEXUS_DEVICE_DISCARDED_UNREADABLE: the Nexus device ${expected.deviceId} for ${origin} could not be opened on this machine and was removed locally. It is still active on the server: revoke device ${expected.deviceId} on cleocode.dev`;
|
|
1245
|
+
warnings.push(warning);
|
|
1246
|
+
return { discarded: true, warning };
|
|
1247
|
+
},
|
|
1248
|
+
keys: () => [
|
|
1249
|
+
...Object.entries(state).flatMap(([origin, users]) => Object.keys(users).map((userId) => ({ origin, userId, readable: true }))),
|
|
1250
|
+
...Object.entries(opaque).flatMap(([origin, users]) => Object.keys(users).map((userId) => ({ origin, userId, readable: false }))),
|
|
1251
|
+
],
|
|
1252
|
+
};
|
|
1253
|
+
const result = await fn(tx);
|
|
1254
|
+
await flush();
|
|
1255
|
+
return result;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Load this home's machine key and salt, mapping any failure other than
|
|
1259
|
+
* "missing" to `E_NEXUS_DEVICE_MACHINE_KEY` (review N5), so it is never
|
|
1260
|
+
* mistaken for a file copied from another machine.
|
|
1261
|
+
*
|
|
1262
|
+
* @param create - Whether missing material may be created (write paths only).
|
|
1263
|
+
* @returns The material, or `null` when it is missing and `create` is false.
|
|
1264
|
+
*/
|
|
1265
|
+
async material(create) {
|
|
1266
|
+
try {
|
|
1267
|
+
return await loadGlobalKeyMaterial({ cleoHome: this.#cleoHome, create });
|
|
1268
|
+
}
|
|
1269
|
+
catch (err) {
|
|
1270
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1271
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_MACHINE_KEY', `the machine key or global salt of ${this.#cleoHome} cannot be used, so the Nexus device file was left untouched: ${detail}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Decrypt one sealed entry and validate the plaintext.
|
|
1276
|
+
*
|
|
1277
|
+
* @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_UNSEAL_FAILED` when there
|
|
1278
|
+
* is no key material or a value fails authentication.
|
|
1279
|
+
*/
|
|
1280
|
+
open(origin, userId, sealed, material) {
|
|
1281
|
+
if (material === null) {
|
|
1282
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_UNSEAL_FAILED', `the Nexus device entry for ${origin} cannot be opened: this CLEO home has no machine key or global salt (lost, or never created). It was left untouched: ${this.location}. Run \`cleo login nexus\` to enrol this machine again`);
|
|
1283
|
+
}
|
|
1284
|
+
const key = deriveGlobalKeyFromMaterial(material, sealId(origin, userId));
|
|
1285
|
+
let plain;
|
|
1286
|
+
try {
|
|
1287
|
+
plain = mapSecrets(sealed, (value, path) => {
|
|
1288
|
+
const opened = openWithGlobalKey(value, key, path);
|
|
1289
|
+
if (opened === null)
|
|
1290
|
+
throw new UnsealLeafError(path);
|
|
1291
|
+
return opened;
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
catch (err) {
|
|
1295
|
+
if (!(err instanceof UnsealLeafError))
|
|
1296
|
+
throw err;
|
|
1297
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_UNSEAL_FAILED', `the Nexus device entry for ${origin} could not be opened with this machine's key (a file copied from another machine or home?). It was left untouched: ${this.location}. Run \`cleo login nexus\` to enrol this machine as its own device`);
|
|
1298
|
+
}
|
|
1299
|
+
const parsed = entrySchema.safeParse(plain);
|
|
1300
|
+
if (!parsed.success) {
|
|
1301
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_INVALID', `the Nexus device entry for ${origin} is malformed and was left untouched: ${this.location} (${issuesText(parsed.error)})`);
|
|
1302
|
+
}
|
|
1303
|
+
return parsed.data;
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Seal the in-memory state into the on-disk form, keeping unknown top-level
|
|
1307
|
+
* fields, and put every unopenable entry back exactly as it was read.
|
|
1308
|
+
*/
|
|
1309
|
+
seal(base, state, opaque, material) {
|
|
1310
|
+
const devices = {};
|
|
1311
|
+
for (const [origin, users] of Object.entries(opaque)) {
|
|
1312
|
+
devices[origin] = { ...users };
|
|
1313
|
+
}
|
|
1314
|
+
for (const [origin, users] of Object.entries(state)) {
|
|
1315
|
+
for (const [userId, entry] of Object.entries(users)) {
|
|
1316
|
+
const plain = entrySchema.parse(entry); // drops the non-enumerable guards
|
|
1317
|
+
const key = deriveGlobalKeyFromMaterial(material, sealId(origin, userId));
|
|
1318
|
+
devices[origin] = {
|
|
1319
|
+
...(devices[origin] ?? {}),
|
|
1320
|
+
[userId]: mapSecrets(plain, (value, path) => sealWithGlobalKey(value, key, path)),
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return { ...base, version: NEXUS_DEVICE_FILE_VERSION, devices };
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Read and validate the file. A missing or empty file (the seed's
|
|
1328
|
+
* placeholder) reads as an empty store. Anything else that does not parse
|
|
1329
|
+
* as a known version is refused, never read as empty, so it is never
|
|
1330
|
+
* overwritten.
|
|
1331
|
+
*/
|
|
1332
|
+
readSealed() {
|
|
1333
|
+
this.assertSafeDirectory();
|
|
1334
|
+
const read = this.readPrivate();
|
|
1335
|
+
const fingerprint = read?.fingerprint ?? 'absent';
|
|
1336
|
+
const raw = read?.text ?? null;
|
|
1337
|
+
if (raw === null || !raw.trim()) {
|
|
1338
|
+
return { file: emptyFile(), raw: emptyFile(), fingerprint };
|
|
1339
|
+
}
|
|
1340
|
+
let json;
|
|
1341
|
+
try {
|
|
1342
|
+
json = JSON.parse(raw);
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_INVALID', `the Nexus device file is not valid JSON and was left untouched: ${this.location}`);
|
|
1346
|
+
}
|
|
1347
|
+
const version = typeof json === 'object' && json !== null && 'version' in json ? json.version : undefined;
|
|
1348
|
+
if (typeof version === 'number' && version > NEXUS_DEVICE_FILE_VERSION) {
|
|
1349
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_NEWER', `the Nexus device file is format version ${version}, newer than this CLI supports (${NEXUS_DEVICE_FILE_VERSION}); upgrade cleo. The file was left untouched: ${this.location}`);
|
|
1350
|
+
}
|
|
1351
|
+
const parsed = sealedFileSchema.safeParse(json);
|
|
1352
|
+
if (!parsed.success) {
|
|
1353
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_INVALID', `the Nexus device file does not match format version ${NEXUS_DEVICE_FILE_VERSION} and was left untouched: ${this.location} (${issuesText(parsed.error)})`);
|
|
1354
|
+
}
|
|
1355
|
+
return { file: parsed.data, raw: json, fingerprint };
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Open the file without following links and check the open descriptor
|
|
1359
|
+
* (so nothing can be swapped between the check and the read): a regular
|
|
1360
|
+
* file with one link, owned by this user, with no group or other bits.
|
|
1361
|
+
*
|
|
1362
|
+
* @returns The contents and the file's fencing fingerprint, or `null` when
|
|
1363
|
+
* the file does not exist.
|
|
1364
|
+
*/
|
|
1365
|
+
readPrivate() {
|
|
1366
|
+
if (process.platform === 'win32') {
|
|
1367
|
+
// No O_NOFOLLOW on Windows: refuse symlinks and other reparse points first.
|
|
1368
|
+
try {
|
|
1369
|
+
if (lstatSync(this.location).isSymbolicLink())
|
|
1370
|
+
throw this.symlinkError('read');
|
|
1371
|
+
}
|
|
1372
|
+
catch (err) {
|
|
1373
|
+
if (err instanceof NexusDeviceStoreError)
|
|
1374
|
+
throw err;
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
let fd;
|
|
1379
|
+
try {
|
|
1380
|
+
fd = openSync(this.location, fsConstants.O_RDONLY | NO_FOLLOW | NON_BLOCK);
|
|
1381
|
+
}
|
|
1382
|
+
catch (err) {
|
|
1383
|
+
const code = err instanceof Error && 'code' in err ? err.code : undefined;
|
|
1384
|
+
if (code === 'ENOENT')
|
|
1385
|
+
return null;
|
|
1386
|
+
if (code === 'ELOOP' || code === 'EMLINK')
|
|
1387
|
+
throw this.symlinkError('read');
|
|
1388
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
1389
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_PERMISSIONS', `cannot open ${this.location} (${String(code)}). ${this.ownerRemedy()}`);
|
|
1390
|
+
}
|
|
1391
|
+
throw err;
|
|
1392
|
+
}
|
|
1393
|
+
try {
|
|
1394
|
+
const st = fstatSync(fd);
|
|
1395
|
+
if (!st.isFile()) {
|
|
1396
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_UNSAFE', `refusing to read ${this.location}: it is not a regular file`);
|
|
1397
|
+
}
|
|
1398
|
+
if (process.platform !== 'win32') {
|
|
1399
|
+
if (st.nlink !== 1) {
|
|
1400
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_UNSAFE', `refusing to read ${this.location}: it has ${st.nlink} hard links, so another path can read or replace it. Remove the other links, or delete the file and run \`cleo login nexus\``);
|
|
1401
|
+
}
|
|
1402
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
1403
|
+
if ((st.mode & 0o077) !== 0 || (uid !== null && st.uid !== uid)) {
|
|
1404
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_PERMISSIONS', `refusing to read ${this.location}: it must be owned by you with mode 0600 (it is ${(st.mode & 0o777).toString(8)}, owner uid ${st.uid}). ${this.ownerRemedy()}`);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const text = readFileSync(fd, 'utf-8');
|
|
1408
|
+
const big = fstatSync(fd, { bigint: true });
|
|
1409
|
+
return { text, fingerprint: `${big.ino}:${big.size}:${big.mtimeNs}` };
|
|
1410
|
+
}
|
|
1411
|
+
finally {
|
|
1412
|
+
closeSync(fd);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
/** The remedy for a mode or owner problem, including the sudo case. */
|
|
1416
|
+
ownerRemedy() {
|
|
1417
|
+
return `Run: chmod 600 "${this.location}". If it is owned by another user (for example it was created by a cleo run under sudo), run: sudo chown "$(id -un)" "${this.location}" && chmod 600 "${this.location}"`;
|
|
1418
|
+
}
|
|
1419
|
+
/** A symlink refusal for `op`. */
|
|
1420
|
+
symlinkError(op) {
|
|
1421
|
+
return new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_SYMLINK', `refusing to ${op} the Nexus device file through a symlink: ${this.location}`);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Refuse a CLEO home another user could use to replace the file: one owned
|
|
1425
|
+
* by someone else, or one inside a parent that another user owns and that
|
|
1426
|
+
* is group- or world-writable without the sticky bit. A symlinked home is
|
|
1427
|
+
* resolved first, and the checks apply to the real directory and its real
|
|
1428
|
+
* parent (review N4). A home owned by the caller that is group- or
|
|
1429
|
+
* world-writable is allowed here: reads check the open descriptor, and
|
|
1430
|
+
* {@link NexusDeviceStore.prepareDirectory} tightens it on the first write.
|
|
1431
|
+
* Skipped on Windows, where the profile ACL governs.
|
|
1432
|
+
*
|
|
1433
|
+
* @returns The real home, its stat and whether the configured path is a
|
|
1434
|
+
* symlink, or `null` when it does not exist (or on Windows).
|
|
1435
|
+
*/
|
|
1436
|
+
assertSafeDirectory() {
|
|
1437
|
+
if (process.platform === 'win32')
|
|
1438
|
+
return null;
|
|
1439
|
+
const dir = dirname(this.location);
|
|
1440
|
+
let viaSymlink;
|
|
1441
|
+
let real;
|
|
1442
|
+
let st;
|
|
1443
|
+
try {
|
|
1444
|
+
viaSymlink = lstatSync(dir).isSymbolicLink();
|
|
1445
|
+
real = viaSymlink ? realpathSync(dir) : dir;
|
|
1446
|
+
st = statSync(real);
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
return null; // absent: created 0700 by prepareDirectory()
|
|
1450
|
+
}
|
|
1451
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
1452
|
+
if (!st.isDirectory() || (uid !== null && st.uid !== uid)) {
|
|
1453
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_DIR_UNSAFE', `refusing to use ${this.location}: the CLEO home ${real} must be a directory owned by you (it is owned by uid ${st.uid}). Fix its owner, for example: sudo chown "$(id -un)" "${real}"`);
|
|
1454
|
+
}
|
|
1455
|
+
const parent = dirname(real);
|
|
1456
|
+
let pst = null;
|
|
1457
|
+
try {
|
|
1458
|
+
pst = statSync(parent);
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
pst = null;
|
|
1462
|
+
}
|
|
1463
|
+
if (pst !== null &&
|
|
1464
|
+
uid !== null &&
|
|
1465
|
+
pst.uid !== uid &&
|
|
1466
|
+
(pst.mode & 0o022) !== 0 &&
|
|
1467
|
+
(pst.mode & 0o1000) === 0) {
|
|
1468
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_DIR_UNSAFE', `refusing to use ${this.location}: the CLEO home's parent ${parent} is owned by another user (uid ${pst.uid}) and writable by group or others, so the home could be replaced. Move the CLEO home (CLEO_HOME) somewhere you own`);
|
|
1469
|
+
}
|
|
1470
|
+
return { real, st, viaSymlink };
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Before a write: create the CLEO home 0700 if it is missing, check it,
|
|
1474
|
+
* and tighten a home the caller owns that is group- or world-writable with
|
|
1475
|
+
* `chmod go-w` (the CLEO home is CLEO's own directory). A home reached
|
|
1476
|
+
* through a symlink is never changed: its target may be a directory CLEO
|
|
1477
|
+
* does not own the purpose of, so only a warning is given (review N4).
|
|
1478
|
+
*
|
|
1479
|
+
* @returns A one-line warning when the home was tightened or should be, else `null`.
|
|
1480
|
+
*/
|
|
1481
|
+
prepareDirectory() {
|
|
1482
|
+
const dir = dirname(this.location);
|
|
1483
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1484
|
+
const home = this.assertSafeDirectory();
|
|
1485
|
+
if (home === null || (home.st.mode & 0o022) === 0)
|
|
1486
|
+
return null;
|
|
1487
|
+
const before = home.st.mode & 0o7777;
|
|
1488
|
+
const after = before & ~0o022;
|
|
1489
|
+
if (home.viaSymlink) {
|
|
1490
|
+
return `W_NEXUS_DEVICE_HOME_WRITABLE: the CLEO home ${dir} is a symlink to ${home.real}, which is writable by group or others (${before.toString(8)}). It was not changed; if that directory is only for CLEO, run: chmod go-w "${home.real}"`;
|
|
1491
|
+
}
|
|
1492
|
+
chmodSync(dir, after);
|
|
1493
|
+
return `W_NEXUS_DEVICE_HOME_TIGHTENED: the CLEO home ${dir} was writable by group or others (${before.toString(8)}); it was changed to ${after.toString(8)} (chmod go-w)`;
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Write `state` atomically at 0600: a new temp file (`O_EXCL | O_NOFOLLOW`),
|
|
1497
|
+
* fsync (F_FULLFSYNC on macOS, through libuv), rename over the target, then
|
|
1498
|
+
* fsync the directory so the rename survives a crash. No backup copy is
|
|
1499
|
+
* made. On Windows a rename blocked by a scanner (EPERM, EBUSY) is retried.
|
|
1500
|
+
*/
|
|
1501
|
+
writeAtomic(state, expected) {
|
|
1502
|
+
const dir = dirname(this.location);
|
|
1503
|
+
const tmp = join(dir, `.${basename(this.location)}.${randomBytes(6).toString('hex')}.tmp`);
|
|
1504
|
+
try {
|
|
1505
|
+
const fd = openSync(tmp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | NO_FOLLOW, 0o600);
|
|
1506
|
+
try {
|
|
1507
|
+
writeSync(fd, `${JSON.stringify(state, null, 2)}\n`);
|
|
1508
|
+
fsyncSync(fd);
|
|
1509
|
+
}
|
|
1510
|
+
finally {
|
|
1511
|
+
closeSync(fd);
|
|
1512
|
+
}
|
|
1513
|
+
// Fencing (review L1): the file must still be what this transaction
|
|
1514
|
+
// last read or wrote. Anything else means another writer got in.
|
|
1515
|
+
if (fingerprintOf(this.location) !== expected) {
|
|
1516
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_LOCK_COMPROMISED', `${this.location} was changed by another writer while this process held the lock; nothing was written. Retry the command`);
|
|
1517
|
+
}
|
|
1518
|
+
renameWithRetry(tmp, this.location);
|
|
1519
|
+
}
|
|
1520
|
+
catch (err) {
|
|
1521
|
+
try {
|
|
1522
|
+
unlinkSync(tmp);
|
|
1523
|
+
}
|
|
1524
|
+
catch {
|
|
1525
|
+
/* already renamed or never created */
|
|
1526
|
+
}
|
|
1527
|
+
if (err instanceof NexusDeviceStoreError)
|
|
1528
|
+
throw err;
|
|
1529
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1530
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_IO', `could not write ${this.location}; the previous file is intact: ${detail}`);
|
|
1531
|
+
}
|
|
1532
|
+
fsyncDirectory(dir);
|
|
1533
|
+
return fingerprintOf(this.location);
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Create the file owner-only, in an owner-only directory, if it does not
|
|
1537
|
+
* exist, so the lock never creates it at the default mode. `O_EXCL` plus
|
|
1538
|
+
* `O_NOFOLLOW` close the window between the symlink check and the create.
|
|
1539
|
+
*/
|
|
1540
|
+
seed() {
|
|
1541
|
+
let fd;
|
|
1542
|
+
try {
|
|
1543
|
+
fd = openSync(this.location, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | NO_FOLLOW, 0o600);
|
|
1544
|
+
}
|
|
1545
|
+
catch (err) {
|
|
1546
|
+
const code = err instanceof Error && 'code' in err ? err.code : undefined;
|
|
1547
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
1548
|
+
throw new NexusDeviceStoreError('E_NEXUS_DEVICE_FILE_PERMISSIONS', `cannot create ${this.location} (${String(code)}). ${this.ownerRemedy()}`);
|
|
1549
|
+
}
|
|
1550
|
+
if (code !== 'EEXIST')
|
|
1551
|
+
throw err;
|
|
1552
|
+
// It exists: make sure it is not a link before the lock touches it.
|
|
1553
|
+
try {
|
|
1554
|
+
if (lstatSync(this.location).isSymbolicLink())
|
|
1555
|
+
throw this.symlinkError('write');
|
|
1556
|
+
}
|
|
1557
|
+
catch (inner) {
|
|
1558
|
+
if (inner instanceof NexusDeviceStoreError)
|
|
1559
|
+
throw inner;
|
|
1560
|
+
}
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
try {
|
|
1564
|
+
writeSync(fd, `${JSON.stringify(emptyFile())}\n`);
|
|
1565
|
+
fsyncSync(fd);
|
|
1566
|
+
}
|
|
1567
|
+
finally {
|
|
1568
|
+
closeSync(fd);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Delete temp files a crashed writer left behind. Runs under the lock, so
|
|
1573
|
+
* no live writer's temp file can match.
|
|
1574
|
+
*/
|
|
1575
|
+
sweepTempFiles() {
|
|
1576
|
+
const dir = dirname(this.location);
|
|
1577
|
+
let entries;
|
|
1578
|
+
try {
|
|
1579
|
+
entries = readdirSync(dir);
|
|
1580
|
+
}
|
|
1581
|
+
catch {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
for (const entry of entries) {
|
|
1585
|
+
if (!TEMP_FILE.test(entry))
|
|
1586
|
+
continue;
|
|
1587
|
+
try {
|
|
1588
|
+
unlinkSync(join(dir, entry));
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
/* best effort */
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Delete any rotated backup of the file (L3). This store never makes one,
|
|
1597
|
+
* but a generic `withLock` write elsewhere would, and a backup keeps old
|
|
1598
|
+
* tokens and keys on disk. Best effort.
|
|
1599
|
+
*/
|
|
1600
|
+
purgeBackups() {
|
|
1601
|
+
const backupDir = join(dirname(this.location), '.backups');
|
|
1602
|
+
const prefix = `${basename(this.location)}.`;
|
|
1603
|
+
let entries;
|
|
1604
|
+
try {
|
|
1605
|
+
entries = readdirSync(backupDir);
|
|
1606
|
+
}
|
|
1607
|
+
catch {
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
for (const entry of entries) {
|
|
1611
|
+
if (!entry.startsWith(prefix))
|
|
1612
|
+
continue;
|
|
1613
|
+
try {
|
|
1614
|
+
unlinkSync(join(backupDir, entry));
|
|
1615
|
+
}
|
|
1616
|
+
catch {
|
|
1617
|
+
/* best effort */
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
/** The raw JSON value of one entry, exactly as it was parsed from the file. */
|
|
1623
|
+
function rawEntry(raw, origin, userId) {
|
|
1624
|
+
if (!isPlainObject(raw))
|
|
1625
|
+
return undefined;
|
|
1626
|
+
const devices = raw.devices;
|
|
1627
|
+
if (!isPlainObject(devices))
|
|
1628
|
+
return undefined;
|
|
1629
|
+
const users = devices[origin];
|
|
1630
|
+
return isPlainObject(users) ? users[userId] : undefined;
|
|
1631
|
+
}
|
|
1632
|
+
/** A fresh, empty file value. */
|
|
1633
|
+
function emptyFile() {
|
|
1634
|
+
return { version: NEXUS_DEVICE_FILE_VERSION, devices: {} };
|
|
1635
|
+
}
|
|
1636
|
+
/** Block the thread for `ms` (used only between Windows rename retries). */
|
|
1637
|
+
function sleepSync(ms) {
|
|
1638
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Rename `from` over `to`. On Windows a scanner or indexer briefly holding
|
|
1642
|
+
* the target makes the rename fail with EPERM or EBUSY; retry a few times.
|
|
1643
|
+
*/
|
|
1644
|
+
function renameWithRetry(from, to) {
|
|
1645
|
+
const attempts = process.platform === 'win32' ? 10 : 1;
|
|
1646
|
+
for (let i = 1;; i++) {
|
|
1647
|
+
try {
|
|
1648
|
+
renameSync(from, to);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
catch (err) {
|
|
1652
|
+
const code = err instanceof Error && 'code' in err ? err.code : undefined;
|
|
1653
|
+
if (i >= attempts || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES'))
|
|
1654
|
+
throw err;
|
|
1655
|
+
sleepSync(20 * i);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/** fsync a directory so a rename in it is durable. Platforms that cannot (Windows) are skipped. */
|
|
1660
|
+
function fsyncDirectory(dir) {
|
|
1661
|
+
if (process.platform === 'win32')
|
|
1662
|
+
return;
|
|
1663
|
+
let fd;
|
|
1664
|
+
try {
|
|
1665
|
+
fd = openSync(dir, fsConstants.O_RDONLY);
|
|
1666
|
+
}
|
|
1667
|
+
catch {
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
try {
|
|
1671
|
+
fsyncSync(fd);
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
/* some filesystems refuse fsync on a directory; the rename itself is atomic */
|
|
1675
|
+
}
|
|
1676
|
+
finally {
|
|
1677
|
+
closeSync(fd);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
//# sourceMappingURL=nexus-device.js.map
|