mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D44 — lib/keys.mjs core landed (multi-key auth, no server wire-up yet) (#20)
* feat+test+docs: D44 — lib/keys.mjs core landed (multi-key auth, no server wire-up yet)
First Phase 2 implementation D-day. Lands the lib/keys.mjs module per
ADR 0007 §§ 5 / 6.1 / 6.3 / 6.3.5 / 6.4 / 9.4. Identity / lifecycle
layer for OLP API keys is now in-tree; server.mjs integration is
scheduled D45 (until then, requests still use the hardcoded
'__anonymous__' cache namespace — no behavioural change at v0.1.1 / D44).
NEW FILE lib/keys.mjs (~437 lines, public API):
- createKey({ name, owner_tier, providers_enabled, notes, olpHome })
Generates opaque 'olp_<32-byte base64url>' token (47-char total),
SHA-256 hashes for manifest storage, atomically writes
keys/<id>/manifest.json (file 0600, dir 0700). Returns
{ id, plaintext_token, manifest } — plaintext printed once, never
persisted.
- validateKey(plaintext, { allowAnonymous, olpHome })
Three-tier resolution per § 5 / § 7 / § 9.4: env override
(OLP_OWNER_TOKEN -> __env_owner__) -> anonymous (only when
allowAnonymous: true, returns __anonymous__) -> filesystem
manifest lookup (constant-time hash compare via timingSafeEqual).
Revoked manifests return null (caller produces 401). Per § 6.3.5:
MUST hit manifest every request; no in-process validation cache.
- revokeKey({ id, olpHome })
Idempotent; sets revoked_at via atomic write inside per-key lock.
- listKeys({ olpHome })
Returns manifest objects with token_hash redacted.
- touchLastUsed(id, { olpHome })
Async best-effort lazy update per § 6.3 revoke-dominates-touch:
re-reads latest manifest inside per-key lock, NO-OPs if revoked_at
is non-null, otherwise merges last_used_at preserving all other
fields. Failure logs warn and never throws.
Plus internal helpers: hashToken (SHA-256 hex), generateToken,
generateKeyId, validateManifest (§ 4 schema validation), readManifest,
writeManifestAtomic (tmpfile + fsync + rename + chmod), _withKeyLock
(§ 6.4 in-process per-key write-lock chain), _safeHexCompare
(timing-safe).
Test-only hooks: __setTouchInterleaveHook (inject deterministic pause
for race tests), __resetWriteLocks (test cleanup).
NOT IN D44 (split per ADR §§ 6.2 / 9.1 separation):
- audit ndjson append (§ 6.2) — request-layer concern; D45 server glue
- keygen CLI bootstrap surface (§ 9.1) — D45+ separate command entry
- server.mjs integration replacing hardcoded '__anonymous__' at
server.mjs:502, :531 — D45
- owner-vs-guest gating for /health (server.mjs:392) + X-OLP-Fallback-
Detail (server.mjs:1072, :1101) — D46
TEST COUNT: 468 -> 496 (+28 tests in new Suite 19):
- 19a-d: token generation (§ 5)
- 19e-j: manifest write+read + chmod 0600/0700 + schema validation
(§ 4, § 6.1)
- 19k-p: validateKey (filesystem / wrong / missing / anonymous /
revoked / env override) (§ 5, § 6.3.5, § 9.4)
- 19q-r: revokeKey idempotency + non-existent id
- 19s-t: listKeys empty + redaction
- 19u-x: touchLastUsed updates + NO-OP on revoked + NO-OP on
anonymous/env identities + best-effort failure
- 19y-1 to 19y-4: ACCEPTANCE CRITERION #7 — concurrent revoke + touch
race tests:
19y-1 revoke -> touch (revoked_at survives)
19y-2 touch -> revoke (revoked_at + last_used_at both present)
19y-3 interleaved external-revoke via __setTouchInterleaveHook
(deterministically reproduces the § 6.3 race the
maintainer's D43-B text review caught — confirms our impl
observes the revoke and NO-OPs)
19y-4 30-iteration concurrent Promise.all stress
DOCS UPDATED IN THIS COMMIT:
- AGENTS.md: lib/keys.mjs marker 📋 -> 🟡 'core landed at D44';
Implementation-status-note + shipped-set updated.
- README.md: Implementation Status row + Known limitations
'Multi-key auth' note updated to 'core landed, server integration
pending D45'.
- CHANGELOG.md: D44 entry under Unreleased per release_kit overlay
phase_rolling_mode discipline.
AUTHORITY:
- ADR 0007 (multi-key auth) — Decision: Option 2 filesystem manifest +
opaque token; §§ 5/6.1/6.3/6.3.5/6.4/9.4 implementation contracts;
§ 10 acceptance criteria #6/#7 partially covered by D44 tests
(#7 fully covered; #6 partially covered — full coverage requires
D45+ server integration).
- CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased.
- Phase 2 kickoff handoff:
~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md
(cc-rules d9da966).
- CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required.
Verified: 496/496 pass via npm test before commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat+test+docs: D44 fold-in — opus reviewer findings (2 P2 correctness + 2 P3 polish)
Fresh-context opus reviewer (PR #20) returned APPROVE_WITH_MINOR with 4
findings — 2 P2 real correctness gaps + 2 P3 polish. All accepted; the 2
P2 fixes ship with new regression tests.
P2 #1 — lib/keys.mjs _withKeyLock lock-map cleanup
Prior version stored `prev.then(() => next)` as the Map tail, but the
cleanup-identity check `_writeLocks.get(id) === next` could never match
the derived promise. Result: Map entries leaked one-per-unique-key-id
forever. Bounded impact at family scale (~5–10 entries) but a real
correctness bug uncovered by reviewer empirical reproduction
("CLEANUP SKIPPED every call").
Fix: store `next` directly as the Map tail. The chain still works
because new callers chain off `_writeLocks.get(id)` (the prior caller's
`next`); compare-and-delete by identity correctly cleans up when the
current caller is the last in queue.
Regression tests:
- 19x-extra: after 5 sequential touchLastUsed calls, __writeLockSize()
must be 0.
- 19x-extra-2: after 9 concurrent touch calls across 3 keys (3 per
key), __writeLockSize() must drain to 0.
P2 #2 — lib/keys.mjs validateKey non-string defensive coding
Prior version threw TypeError when called with a non-string truthy
plaintext (validateKey(42), validateKey({}), etc.), reaching
hashToken(<non-string>) which calls createHash().update(<non-string>)
which throws. Q2 (defensive-coding acceptance criterion) promised
"bad inputs return null."
Fix: top-of-function guard
`if (plaintextToken != null && typeof plaintextToken !== 'string') return null;`
Falls through to null path for non-string truthy; preserves existing
null / undefined / '' handling.
Regression test 19m-extra: validateKey(42), validateKey({}),
validateKey([]), validateKey({ token: 'olp_xxx' }), and the same with
allowAnonymous: true — all must return null without throwing.
P3 #3 — 19y-3 test scope comment clarification
Reviewer noted that 19y-3 simulates external revoke landing BEFORE
touch's read (not BETWEEN touch's read and write — currently
unreachable due to synchronous read→write in touchLastUsed). Added
explanatory comment documenting:
- The scenario this test does cover (pre-read external revoke).
- The scenario this test does NOT cover (between-read-and-write).
- Why scenario 3 is unreachable in the current impl (no await between
readManifest and writeManifestAtomic).
- The trigger for adding a post-read hook (any future refactor that
introduces an await between read and write).
P3 #4 — CHANGELOG line-count corrections
D44 entry said ~330 lines (initial estimate); actual is 462 lines
after fold-in. Test count claim updated from "+28 tests" to
"+31 tests" (28 initial + 3 fold-in regression).
New module-level export: __writeLockSize (test-only) — reports current
size of in-process write-lock Map for the regression tests above. Not
intended for production callers.
Test count: 496 → 499 (+3 fold-in regression tests; +31 total from
D44 inclusive of initial Suite 19). Verified locally via npm test.
Authority: PR #20 fresh-context opus reviewer findings; CLAUDE.md
release_kit overlay phase_rolling_mode — under Unreleased.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: D44 fold-in #2 — CHANGELOG line-count consistency (trivial)
Delta opus reviewer flagged internal CHANGELOG inconsistency: header
bullet correctly stated `~462 lines` but the P3 #4 self-description
bullet still said the prior fold-in corrected to `~445 lines`. Both now
agree on 462 (matches `wc -l lib/keys.mjs`).
No code change. Test count: 499 / 499 pass (unchanged).
Authority: PR #20 delta opus reviewer trivial inconsistency note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
|||||||
- `lib/ir/` — Intermediate Representation definition + serializers. Governed by ADR 0003.
|
- `lib/ir/` — Intermediate Representation definition + serializers. Governed by ADR 0003.
|
||||||
- `lib/cache/` — content-addressed cache layer (per-key isolation, `cache_control` bypass, chunked stream replay, singleflight). Governed by ADR 0005.
|
- `lib/cache/` — content-addressed cache layer (per-key isolation, `cache_control` bypass, chunked stream replay, singleflight). Governed by ADR 0005.
|
||||||
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
|
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
|
||||||
- `lib/keys.mjs` — multi-key auth, per-key namespacing, audit log. Carries OCP's per-key isolation model into OLP. **📋 Phase 2 active per ADR 0007 (drafting at D43-B) — not yet authored.**
|
- `lib/keys.mjs` — multi-key auth, per-key namespacing, audit log. Carries OCP's per-key isolation model into OLP. **🟡 Phase 2 — core landed at D44 (createKey / validateKey / listKeys / revokeKey / touchLastUsed per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4). server.mjs integration scheduled D45; audit ndjson + keygen CLI surface in subsequent D-days.**
|
||||||
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.**
|
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.**
|
||||||
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
||||||
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
||||||
@@ -45,7 +45,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
|||||||
- `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens.
|
- `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens.
|
||||||
- `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5).
|
- `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5).
|
||||||
|
|
||||||
**Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk. For the full status table see `README.md § "Implementation status"`. Do not attempt to read or import these files — they will not be found. The shipped set as of Phase 1 close (v0.1.1, 2026-05-25) is: `server.mjs`, `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `models-registry.json`, `test-features.mjs`. Phase 2 active scope: `lib/keys.mjs` per ADR 0007 (drafting at D43-B).
|
**Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk; files marked 🟡 are partially shipped (specific components landed; others scheduled). For the full status table see `README.md § "Implementation status"`. The shipped set as of D44 is: `server.mjs`, `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core only — D44), `models-registry.json`, `test-features.mjs`. Phase 2 in progress: `lib/keys.mjs` server integration (D45), owner gating for /health + X-OLP-Fallback-Detail (D46), E2E + multi-key fixtures (D47), Phase 2 close → v0.2.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### D44 — `lib/keys.mjs` core landed (multi-key auth, no server wire-up yet)
|
||||||
|
|
||||||
|
First Phase 2 implementation D-day. Lands the `lib/keys.mjs` module per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4. Identity / lifecycle layer for OLP API keys is now in-tree; `server.mjs` integration scheduled D45 (until then, requests still use the hardcoded `'__anonymous__'` cache namespace — no behavioural change at v0.1.1 / D44).
|
||||||
|
|
||||||
|
- **New file `lib/keys.mjs`** (~462 lines after fold-in) — public API surface:
|
||||||
|
- `createKey({ name, owner_tier, providers_enabled, notes, olpHome })` — generates opaque `olp_<32-byte base64url>` token (47-char total), SHA-256 hashes it for manifest storage, atomically writes `keys/<id>/manifest.json` (mode 0600, dir 0700). Returns `{ id, plaintext_token, manifest }` — plaintext token is printed once and never persisted.
|
||||||
|
- `validateKey(plaintext, { allowAnonymous, olpHome })` — three-tier resolution per § 5 / § 7 / § 9.4: env override (`OLP_OWNER_TOKEN` → `__env_owner__` synthetic identity) → anonymous (only when `allowAnonymous: true`, returns `__anonymous__` identity) → filesystem manifest lookup (constant-time hash compare via `crypto.timingSafeEqual`). Revoked manifests return null (caller produces 401). Per § 6.3.5 — MUST hit manifest every request; no in-process validation cache.
|
||||||
|
- `revokeKey({ id, olpHome })` — idempotent; sets `revoked_at` via atomic write inside per-key write-lock.
|
||||||
|
- `listKeys({ olpHome })` — returns manifest objects with `token_hash` redacted.
|
||||||
|
- `touchLastUsed(id, { olpHome })` — async best-effort lazy update per § 6.3 revoke-dominates-touch: re-reads latest manifest inside the per-key lock, NO-OPs if `revoked_at` is non-null, otherwise merges `last_used_at` preserving all other fields. Failure logs warn and never throws.
|
||||||
|
- **§ 6.4 in-process per-key write-lock** — `Map<key-id, Promise>` chain; serializes intra-process writes. External (CLI) writes not lock-protected at Phase 2; atomic-rename + § 6.3 read-before-write give the `revoke dominates touch` safety property.
|
||||||
|
- **Test-only hooks** — `__setTouchInterleaveHook` (inject deterministic pause between touch's lock acquisition and read for race tests) + `__resetWriteLocks` (test cleanup).
|
||||||
|
- **What is NOT in D44 (split per ADR §§ 6.2 / 9.1 separation):** audit ndjson append (request-layer concern; D45 server glue); keygen CLI bootstrap surface (D45+); `server.mjs` integration replacing the hardcoded `'__anonymous__'` keyId at `server.mjs:502, :531` (D45); owner-vs-guest gating for `/health` and `X-OLP-Fallback-Detail` (D46).
|
||||||
|
- **Test count:** 468 → 496 (+28 tests in new Suite 19):
|
||||||
|
- 19a-d token generation (§ 5)
|
||||||
|
- 19e-j manifest write+read + chmod 0600/0700 + schema validation (§ 4, § 6.1)
|
||||||
|
- 19k-p validateKey: filesystem / wrong / missing / anonymous / revoked / env override (§ 5, § 6.3.5, § 9.4)
|
||||||
|
- 19q-r revokeKey idempotency + non-existent id
|
||||||
|
- 19s-t listKeys empty + redaction
|
||||||
|
- 19u-x touchLastUsed updates + NO-OP on revoked + NO-OP on anonymous/env identities + best-effort failure
|
||||||
|
- **19y-1 to 19y-4 acceptance criterion #7 (concurrent revoke + touch race tests)**: revoke→touch, touch→revoke, interleaved external-revoke-via-hook (deterministically reproduces the § 6.3 race the maintainer's text review caught), 30-iteration concurrent-promise stress
|
||||||
|
- **Documentation:** AGENTS.md `lib/keys.mjs` 📋 marker → 🟡 "core landed at D44"; AGENTS.md Implementation-status-note + shipped-set updated to include `lib/keys.mjs`; README.md Implementation Status row + Known limitations "Multi-key auth" note updated to "core landed, server integration pending D45".
|
||||||
|
- **Fold-in (fresh-context opus reviewer findings, 2 P2 correctness + 2 P3 polish):**
|
||||||
|
- **P2 #1 lock-map cleanup** (`lib/keys.mjs` `_withKeyLock`): prior version stored `prev.then(() => next)` as the Map tail, but the cleanup-identity check `_writeLocks.get(id) === next` could never match the derived promise — Map entries leaked one-per-unique-key-id. Bounded impact at family scale (~5–10 entries) but a real correctness bug. Fixed by storing `next` directly. New regression tests `19x-extra` (sequential) + `19x-extra-2` (concurrent 3-key × 3-touch contention) assert `__writeLockSize() === 0` post-drain.
|
||||||
|
- **P2 #2 `validateKey` non-string defensive coding**: prior version threw `TypeError` when called with a non-string truthy plaintext (`validateKey(42)` / `validateKey({})`), reaching `hashToken(<non-string>)` → `createHash().update(<non-string>)`. Q2 promised "bad inputs return null." Fixed via top-of-function `if (plaintextToken != null && typeof plaintextToken !== 'string') return null;`. New test `19m-extra` covers number / object / array / `allowAnonymous: true` paths.
|
||||||
|
- **P3 #3 19y-3 test scope comment**: test simulates external revoke landing BEFORE touch's read, not BETWEEN touch's read and write (which is currently unreachable because `touchLastUsed` has synchronous read→write — no await between `readManifest` and `writeManifestAtomic`). Added explanatory comment documenting the synchronous-read-write property as the satisfaction mechanism for ADR § 10 criterion #7 scenario 3, with a note that a post-read hook + matching test would be required if a future refactor introduces an await between read and write.
|
||||||
|
- **P3 #4 CHANGELOG line count**: corrected `~330 lines` to `~462 lines after fold-in` (matches `wc -l lib/keys.mjs`).
|
||||||
|
- **Test count after fold-in:** 468 → 499 (+31 tests: 28 initial + 3 fold-in regression tests).
|
||||||
|
- **Authority:** ADR 0007 (multi-key auth — Decision: Option 2 filesystem manifest + opaque token; §§ 5/6.1/6.3/6.3.5/6.4/9.4 implementation contracts; § 10 acceptance criteria #6/#7 partially-covered by D44 tests, full coverage requires D45+ server integration); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; Phase 2 kickoff handoff (`~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` in cc-rules `d9da966`); PR #20 fresh-context opus reviewer findings.
|
||||||
|
|
||||||
### D43-B — ADR 0007 multi-key auth design draft (design-only, no code change)
|
### D43-B — ADR 0007 multi-key auth design draft (design-only, no code change)
|
||||||
|
|
||||||
Phase 2 mainline design ADR. Ratifies the storage / token / manifest / atomic-write / owner-gating / bootstrap / Node-baseline decisions ahead of D44+ implementation D-days. Pure design doc — no `.mjs` / no tests / 4 files touched.
|
Phase 2 mainline design ADR. Ratifies the storage / token / manifest / atomic-write / owner-gating / bootstrap / Node-baseline decisions ahead of D44+ implementation D-days. Pure design doc — no `.mjs` / no tests / 4 files touched.
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ Phase 1 is closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). P
|
|||||||
| Soft trigger data path (`quotaStatus()` polling) | 📋 Planned (v1.x) | Evaluation logic shipped + tested; data ingestion deferred per ADR 0004 Amendment 2 |
|
| Soft trigger data path (`quotaStatus()` polling) | 📋 Planned (v1.x) | Evaluation logic shipped + tested; data ingestion deferred per ADR 0004 Amendment 2 |
|
||||||
| `models-registry.json` | ✅ Shipped | SPOT for `(provider, model)` metadata |
|
| `models-registry.json` | ✅ Shipped | SPOT for `(provider, model)` metadata |
|
||||||
| `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) |
|
| `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) |
|
||||||
| `lib/keys.mjs` | 📋 Phase 2 active | Multi-key auth, per-key namespacing, audit log. ADR 0007 drafting at D43-B; implementation D-days to follow. |
|
| `lib/keys.mjs` | 🟡 Phase 2 — core landed (D44) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4. Atomic manifest writes + per-key write-lock + revoke-dominates-touch + env override. `server.mjs` integration scheduled D45; audit ndjson + keygen CLI surface follow. |
|
||||||
| `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard |
|
| `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard |
|
||||||
| `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header |
|
| `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header |
|
||||||
| `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 |
|
| `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 |
|
||||||
@@ -196,7 +196,7 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
|
|||||||
|
|
||||||
- **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one.
|
- **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one.
|
||||||
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
||||||
- **Multi-key auth not yet implemented.** All requests today share the cache namespace `__anonymous__`. The cache data model is keyed by `keyId` and ready to accept real identities when `lib/keys.mjs` lands. Phase 2 active: ADR 0007 drafting at D43-B, implementation D-days to follow. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
|
- **Multi-key auth core landed, server integration pending.** `lib/keys.mjs` core (createKey / validateKey / listKeys / revokeKey / touchLastUsed) shipped at D44 per ADR 0007. All requests at v0.1.1 / D44 still share the cache namespace `__anonymous__` because `server.mjs` integration is scheduled for D45 — until that lands, the identity layer exists in-tree but no request flow consults it. Phase 2 in progress through D45 (server wire-up), D46 (owner gating), D47 (E2E + multi-key fixtures), Phase 2 close → v0.2.0. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
|
||||||
- **Provider-level `cacheKeyFields` mask not implemented.** Cache keys include every IR field including ones individual plugins drop at spawn (e.g., Anthropic plugin drops `temperature`). Spurious cache misses possible (extra spawn cost; never spurious hits). Conservative posture documented in [ADR 0005 Amendment 7](./docs/adr/0005-cache-cross-provider.md). Tracked in [v1.x roadmap #5](./docs/v1x-roadmap.md).
|
- **Provider-level `cacheKeyFields` mask not implemented.** Cache keys include every IR field including ones individual plugins drop at spawn (e.g., Anthropic plugin drops `temperature`). Spurious cache misses possible (extra spawn cost; never spurious hits). Conservative posture documented in [ADR 0005 Amendment 7](./docs/adr/0005-cache-cross-provider.md). Tracked in [v1.x roadmap #5](./docs/v1x-roadmap.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+462
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* lib/keys.mjs — OLP multi-key auth (Phase 2 / D44 core)
|
||||||
|
*
|
||||||
|
* Authority: ADR 0007 (multi-key auth). Read that ADR before modifying.
|
||||||
|
*
|
||||||
|
* This module implements the identity / lifecycle layer for OLP API keys:
|
||||||
|
* - Opaque token generation (§ 5)
|
||||||
|
* - Manifest read + atomic write (§ 6.1)
|
||||||
|
* - Per-key in-process write-lock (§ 6.4)
|
||||||
|
* - touchLastUsed read-modify-write with revoke preservation (§ 6.3)
|
||||||
|
* - validateKey lookup with NO validation cache (§ 6.3.5) — manifests are
|
||||||
|
* read on every authenticated request
|
||||||
|
* - Env override (OLP_OWNER_TOKEN → __env_owner__) per § 9.4
|
||||||
|
* - Anonymous escape-hatch identity per § 7
|
||||||
|
*
|
||||||
|
* What is NOT in this module (intentional split):
|
||||||
|
* - audit ndjson append (§ 6.2) — request-layer concern; D45 (server.mjs glue)
|
||||||
|
* - keygen CLI bootstrap surface (§ 9.1) — D45+ (separate command entry)
|
||||||
|
* - server.mjs integration (replace '__anonymous__' constants) — D45
|
||||||
|
* - owner-vs-guest /health + X-OLP-Fallback-Detail gating — D46
|
||||||
|
*
|
||||||
|
* The module is filesystem-only at v0.2.0. The future Option-3 SQLite-indexed
|
||||||
|
* mirror (ADR 0007 § 13) is invisible from this module's API — when added, the
|
||||||
|
* SQLite write happens inside writeManifestAtomic / revokeKey and the module's
|
||||||
|
* public surface is unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
readFileSync, writeFileSync, openSync, fsyncSync, closeSync,
|
||||||
|
renameSync, readdirSync, mkdirSync, chmodSync, existsSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 1;
|
||||||
|
export const TOKEN_PREFIX = 'olp_';
|
||||||
|
export const TOKEN_RANDOM_BYTES = 32; // 256 bits entropy
|
||||||
|
export const KEY_ID_RANDOM_BYTES = 6; // 8 base64url chars
|
||||||
|
|
||||||
|
export const ANONYMOUS_KEY_ID = '__anonymous__';
|
||||||
|
export const ENV_OWNER_KEY_ID = '__env_owner__';
|
||||||
|
export const ENV_OWNER_VAR = 'OLP_OWNER_TOKEN';
|
||||||
|
|
||||||
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||||
|
|
||||||
|
// ── Internal state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Per-key in-process write-lock chain (§ 6.4). Map<key-id, Promise>.
|
||||||
|
// Each entry is the tail of the promise chain for that key-id; serialized
|
||||||
|
// access via _withKeyLock.
|
||||||
|
const _writeLocks = new Map();
|
||||||
|
|
||||||
|
// Test hook: injected pause between touchLastUsed's read and write phases.
|
||||||
|
// Used by acceptance criterion #7 to deterministically reproduce the
|
||||||
|
// interleaved revoke-during-touch race. Default no-op.
|
||||||
|
let _touchInterleaveHook = async () => {};
|
||||||
|
|
||||||
|
// ── Path helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _olpHome(opts) { return opts?.olpHome ?? DEFAULT_OLP_HOME; }
|
||||||
|
function _keysDir(opts) { return join(_olpHome(opts), 'keys'); }
|
||||||
|
function _keyDir(id, opts) { return join(_keysDir(opts), id); }
|
||||||
|
function _manifestPath(id, opts) { return join(_keyDir(id, opts), 'manifest.json'); }
|
||||||
|
|
||||||
|
// ── Crypto helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an opaque OLP token per § 5: `olp_<32-byte base64url>`.
|
||||||
|
* Total length 47 chars (4 prefix + 43 base64url).
|
||||||
|
*/
|
||||||
|
export function generateToken() {
|
||||||
|
return TOKEN_PREFIX + randomBytes(TOKEN_RANDOM_BYTES).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a key-id per § 3: lowercase alphanumeric + hyphen + underscore.
|
||||||
|
* 8 base64url chars from 6 random bytes; lowercased.
|
||||||
|
*/
|
||||||
|
export function generateKeyId() {
|
||||||
|
return randomBytes(KEY_ID_RANDOM_BYTES).toString('base64url').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 of the full token string (prefix included), hex-lowercase.
|
||||||
|
* Matches § 5 hash spec.
|
||||||
|
*/
|
||||||
|
export function hashToken(plaintextToken) {
|
||||||
|
return createHash('sha256').update(plaintextToken).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time comparison of two hex-encoded hashes.
|
||||||
|
* Returns false on length mismatch (rather than throwing).
|
||||||
|
*/
|
||||||
|
function _safeHexCompare(a, b) {
|
||||||
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const bufA = Buffer.from(a, 'hex');
|
||||||
|
const bufB = Buffer.from(b, 'hex');
|
||||||
|
if (bufA.length !== bufB.length) return false;
|
||||||
|
return timingSafeEqual(bufA, bufB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest schema validation (§ 4) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a parsed manifest object against the § 4 schema.
|
||||||
|
* Throws Error('manifest_invalid: <reason>') on schema violations.
|
||||||
|
* Unknown fields are tolerated (forward-compat).
|
||||||
|
*/
|
||||||
|
export function validateManifest(obj) {
|
||||||
|
if (typeof obj !== 'object' || obj === null) {
|
||||||
|
throw new Error('manifest_invalid: not an object');
|
||||||
|
}
|
||||||
|
if (obj.schema_version !== SCHEMA_VERSION) {
|
||||||
|
throw new Error(`manifest_invalid: unrecognized schema_version ${obj.schema_version}`);
|
||||||
|
}
|
||||||
|
for (const field of ['id', 'name', 'token_hash', 'token_hash_algo', 'owner_tier', 'providers_enabled', 'created_at']) {
|
||||||
|
if (obj[field] === undefined) {
|
||||||
|
throw new Error(`manifest_invalid: missing required field "${field}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (obj.token_hash_algo !== 'sha256') {
|
||||||
|
throw new Error(`manifest_invalid: unsupported token_hash_algo "${obj.token_hash_algo}"`);
|
||||||
|
}
|
||||||
|
if (!['owner', 'guest'].includes(obj.owner_tier)) {
|
||||||
|
throw new Error(`manifest_invalid: owner_tier must be "owner" or "guest", got "${obj.owner_tier}"`);
|
||||||
|
}
|
||||||
|
if (!(obj.providers_enabled === '*' || Array.isArray(obj.providers_enabled))) {
|
||||||
|
throw new Error('manifest_invalid: providers_enabled must be "*" or array');
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest IO (§ 6.1) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read manifest for a key-id. Returns parsed object or null if file absent.
|
||||||
|
* Throws on JSON parse error or schema violation.
|
||||||
|
*/
|
||||||
|
export function readManifest(id, opts = {}) {
|
||||||
|
const path = _manifestPath(id, opts);
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
const raw = readFileSync(path, 'utf-8');
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
if (obj.id !== id) {
|
||||||
|
throw new Error(`manifest_id_mismatch: directory "${id}" contains manifest with id "${obj.id}"`);
|
||||||
|
}
|
||||||
|
return validateManifest(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic manifest write per § 6.1: tmpfile + fsync + rename, 0600 file / 0700 dir.
|
||||||
|
* Caller MUST hold the per-key write-lock (§ 6.4) when invoking this for
|
||||||
|
* lifecycle events. Lock acquisition is the caller's responsibility because
|
||||||
|
* createKey allocates a new key-id (no existing lock yet) while revoke /
|
||||||
|
* touchLastUsed operate on an existing key-id.
|
||||||
|
*/
|
||||||
|
export function writeManifestAtomic(id, manifest, opts = {}) {
|
||||||
|
if (manifest.id !== id) {
|
||||||
|
throw new Error(`writeManifestAtomic: manifest.id "${manifest.id}" mismatches id arg "${id}"`);
|
||||||
|
}
|
||||||
|
validateManifest(manifest);
|
||||||
|
|
||||||
|
const dir = _keyDir(id, opts);
|
||||||
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
try { chmodSync(dir, 0o700); } catch { /* tolerate EPERM on pre-existing dir */ }
|
||||||
|
|
||||||
|
const finalPath = _manifestPath(id, opts);
|
||||||
|
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const serialized = JSON.stringify(manifest, null, 2) + '\n';
|
||||||
|
|
||||||
|
const fd = openSync(tmpPath, 'w', 0o600);
|
||||||
|
try {
|
||||||
|
writeFileSync(fd, serialized);
|
||||||
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
renameSync(tmpPath, finalPath);
|
||||||
|
// § 6.1 step 5: enforce 0600 even if umask interfered.
|
||||||
|
try { chmodSync(finalPath, 0o600); } catch { /* tolerate EPERM */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-key write lock (§ 6.4) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize concurrent in-process writes against the same key-id.
|
||||||
|
* Returns the value produced by fn(); awaits prior queued work first.
|
||||||
|
*
|
||||||
|
* Lock-map semantics: each caller stores its own `next` promise as the
|
||||||
|
* Map tail. New callers chain off the stored tail (`get(id)` returns the
|
||||||
|
* current tail = prior caller's next). On finally, we compare-and-delete
|
||||||
|
* the tail by identity — if no one queued after us, the Map still points
|
||||||
|
* at our `next` and we clean up; if a later caller chained, the Map points
|
||||||
|
* at their `next` and we leave it alone.
|
||||||
|
*
|
||||||
|
* (D44 fold-in correctness fix: prior version stored
|
||||||
|
* `prev.then(() => next)`, a derived promise that never matched the
|
||||||
|
* cleanup-identity check, leaving stale Map entries per unique key-id.
|
||||||
|
* Storing `next` directly fixes the cleanup; tested by 19u-extra.)
|
||||||
|
*/
|
||||||
|
async function _withKeyLock(id, fn) {
|
||||||
|
const prev = _writeLocks.get(id) ?? Promise.resolve();
|
||||||
|
let release;
|
||||||
|
const next = new Promise(r => { release = r; });
|
||||||
|
_writeLocks.set(id, next);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prev;
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (_writeLocks.get(id) === next) _writeLocks.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new OLP key. Generates a fresh opaque token (returned in
|
||||||
|
* plaintext exactly once) and writes the manifest atomically.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {string} args.name - human label (required, non-empty)
|
||||||
|
* @param {'owner'|'guest'} [args.owner_tier='guest']
|
||||||
|
* @param {string[]|'*'} [args.providers_enabled='*']
|
||||||
|
* @param {string} [args.notes='']
|
||||||
|
* @param {string} [args.olpHome] - test override; defaults to ~/.olp
|
||||||
|
* @returns {{ id: string, plaintext_token: string, manifest: object }}
|
||||||
|
* The plaintext_token MUST be displayed to the operator exactly once and
|
||||||
|
* never logged. The manifest contains only the hash.
|
||||||
|
*/
|
||||||
|
export function createKey(args = {}) {
|
||||||
|
const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome } = args;
|
||||||
|
if (typeof name !== 'string' || name.length === 0) {
|
||||||
|
throw new Error('createKey: name is required (non-empty string)');
|
||||||
|
}
|
||||||
|
if (!['owner', 'guest'].includes(owner_tier)) {
|
||||||
|
throw new Error(`createKey: owner_tier must be "owner" or "guest", got "${owner_tier}"`);
|
||||||
|
}
|
||||||
|
if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) {
|
||||||
|
throw new Error('createKey: providers_enabled must be "*" or string array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateKeyId();
|
||||||
|
const plaintext_token = generateToken();
|
||||||
|
const manifest = {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
token_hash: hashToken(plaintext_token),
|
||||||
|
token_hash_algo: 'sha256',
|
||||||
|
owner_tier,
|
||||||
|
providers_enabled,
|
||||||
|
quota: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
revoked_at: null,
|
||||||
|
last_used_at: null,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
writeManifestAtomic(id, manifest, { olpHome });
|
||||||
|
return { id, plaintext_token, manifest };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all keys. Returns array of manifest objects with `token_hash` redacted
|
||||||
|
* (kept on disk; omitted from list output per common operational hygiene —
|
||||||
|
* the hash itself is non-secret but listing it bulk-reads adds nothing).
|
||||||
|
*
|
||||||
|
* Skips manifests that fail schema validation; would log warn in real impl.
|
||||||
|
*
|
||||||
|
* @returns {Array<object>} possibly empty
|
||||||
|
*/
|
||||||
|
export function listKeys(opts = {}) {
|
||||||
|
const dir = _keysDir(opts);
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
const entries = readdirSync(dir);
|
||||||
|
const out = [];
|
||||||
|
for (const id of entries) {
|
||||||
|
if (id.startsWith('.')) continue;
|
||||||
|
try {
|
||||||
|
const m = readManifest(id, opts);
|
||||||
|
if (m === null) continue;
|
||||||
|
// Redact token_hash from list output (keep on disk).
|
||||||
|
const { token_hash, ...rest } = m;
|
||||||
|
out.push(rest);
|
||||||
|
} catch {
|
||||||
|
// Skip invalid manifest; production impl would log warn.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a key by id. Sets revoked_at to current ISO timestamp.
|
||||||
|
* Idempotent: revoking an already-revoked key returns true without rewriting.
|
||||||
|
* Returns false if the key-id does not exist on disk.
|
||||||
|
*/
|
||||||
|
export async function revokeKey(args = {}) {
|
||||||
|
const { id, olpHome } = args;
|
||||||
|
if (!id || typeof id !== 'string') throw new Error('revokeKey: id required');
|
||||||
|
return _withKeyLock(id, async () => {
|
||||||
|
const m = readManifest(id, { olpHome });
|
||||||
|
if (m === null) return false;
|
||||||
|
if (m.revoked_at !== null) return true; // already revoked; no-op
|
||||||
|
m.revoked_at = new Date().toISOString();
|
||||||
|
writeManifestAtomic(id, m, { olpHome });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a plaintext token. Returns an identity object on success, null
|
||||||
|
* on any failure (missing token, no match, revoked, manifest invalid).
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. If plaintext === process.env.OLP_OWNER_TOKEN → synthetic env-owner identity.
|
||||||
|
* 2. If !plaintext and allowAnonymous → anonymous identity.
|
||||||
|
* 3. Else hash plaintext, scan ~/.olp/keys/ for a manifest with matching hash.
|
||||||
|
* Revoked manifests return null (caller produces 401 key_revoked).
|
||||||
|
*
|
||||||
|
* § 6.3.5: this function MUST hit the manifest filesystem on every call
|
||||||
|
* (no in-process validation cache at Phase 2).
|
||||||
|
*
|
||||||
|
* @param {string|null} plaintextToken
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.allowAnonymous=false] - server reads config and passes through
|
||||||
|
* @param {string} [opts.olpHome]
|
||||||
|
* @returns {{ id, owner_tier, providers_enabled, source }|null}
|
||||||
|
*/
|
||||||
|
export function validateKey(plaintextToken, opts = {}) {
|
||||||
|
const { allowAnonymous = false, olpHome } = opts;
|
||||||
|
|
||||||
|
// Defensive: non-string truthy inputs (number, object, etc.) return null
|
||||||
|
// rather than throwing in hashToken. Matches missing-token semantics.
|
||||||
|
// (D44 fold-in P2 #2: prior version threw TypeError on validateKey({}) /
|
||||||
|
// validateKey(42) by reaching createHash().update(<non-string>).)
|
||||||
|
if (plaintextToken != null && typeof plaintextToken !== 'string') return null;
|
||||||
|
|
||||||
|
// 1. Env owner override (§ 9.4)
|
||||||
|
const envToken = process.env[ENV_OWNER_VAR];
|
||||||
|
if (envToken && plaintextToken && _safeHexCompare(hashToken(plaintextToken), hashToken(envToken))) {
|
||||||
|
return {
|
||||||
|
id: ENV_OWNER_KEY_ID,
|
||||||
|
owner_tier: 'owner',
|
||||||
|
providers_enabled: '*',
|
||||||
|
source: 'env',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Anonymous fallback (§ 7)
|
||||||
|
if (!plaintextToken) {
|
||||||
|
if (allowAnonymous) {
|
||||||
|
return {
|
||||||
|
id: ANONYMOUS_KEY_ID,
|
||||||
|
owner_tier: 'anonymous',
|
||||||
|
providers_enabled: '*',
|
||||||
|
source: 'anonymous',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Filesystem manifest lookup (§ 6.3.5 — every request, no cache)
|
||||||
|
const dir = _keysDir({ olpHome });
|
||||||
|
if (!existsSync(dir)) return null;
|
||||||
|
const hash = hashToken(plaintextToken);
|
||||||
|
let entries;
|
||||||
|
try { entries = readdirSync(dir); } catch { return null; }
|
||||||
|
|
||||||
|
for (const id of entries) {
|
||||||
|
if (id.startsWith('.')) continue;
|
||||||
|
let m;
|
||||||
|
try { m = readManifest(id, { olpHome }); } catch { continue; }
|
||||||
|
if (m === null) continue;
|
||||||
|
if (!_safeHexCompare(m.token_hash, hash)) continue;
|
||||||
|
if (m.revoked_at !== null) return null; // revoked → caller produces 401
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
owner_tier: m.owner_tier,
|
||||||
|
providers_enabled: m.providers_enabled,
|
||||||
|
source: 'filesystem',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last_used_at lazily after a successful request. Per § 6.3:
|
||||||
|
* 1. Re-read latest manifest from disk inside the per-key write-lock.
|
||||||
|
* 2. If revoked_at is non-null in fresh read → NO-OP (preserve revocation).
|
||||||
|
* 3. Otherwise merge new last_used_at preserving all other fields.
|
||||||
|
*
|
||||||
|
* Best-effort: any error is logged via console.warn and swallowed; this
|
||||||
|
* function never throws (§ 6.3 "Failure logs warn and does NOT fail the
|
||||||
|
* request").
|
||||||
|
*
|
||||||
|
* Anonymous + env-owner identities have no manifest → no-op silently.
|
||||||
|
*/
|
||||||
|
export async function touchLastUsed(id, opts = {}) {
|
||||||
|
if (id === ANONYMOUS_KEY_ID || id === ENV_OWNER_KEY_ID) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _withKeyLock(id, async () => {
|
||||||
|
// Test hook fires BEFORE the read so race tests can deterministically
|
||||||
|
// inject an external revoke that the read must observe. In production
|
||||||
|
// the hook is a no-op; the read is the only filesystem access and
|
||||||
|
// happens inside the per-key write-lock.
|
||||||
|
await _touchInterleaveHook(id, opts);
|
||||||
|
// § 6.3 step 1: re-read latest manifest inside the lock.
|
||||||
|
const fresh = readManifest(id, opts);
|
||||||
|
if (fresh === null) return; // key removed from disk
|
||||||
|
// § 6.3 step 2: NO-OP if revoked.
|
||||||
|
if (fresh.revoked_at !== null) return;
|
||||||
|
// § 6.3 step 3: merge last_used_at preserving all other fields.
|
||||||
|
fresh.last_used_at = new Date().toISOString();
|
||||||
|
writeManifestAtomic(id, fresh, opts);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// § 6.3 best-effort: warn, never throw.
|
||||||
|
console.warn(JSON.stringify({
|
||||||
|
event: 'last_used_update_failed',
|
||||||
|
id,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test-only hooks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: install a hook called inside touchLastUsed between the
|
||||||
|
* read-phase and write-phase. Used to deterministically reproduce the
|
||||||
|
* interleaved-revoke race (acceptance criterion #7).
|
||||||
|
*
|
||||||
|
* Pass null to reset to no-op.
|
||||||
|
*/
|
||||||
|
export function __setTouchInterleaveHook(hookOrNull) {
|
||||||
|
_touchInterleaveHook = hookOrNull ?? (async () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: clear all in-process write-locks. Useful for test cleanup
|
||||||
|
* to avoid lock state leaking across tests.
|
||||||
|
*/
|
||||||
|
export function __resetWriteLocks() {
|
||||||
|
_writeLocks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: report the current size of the in-process write-lock Map.
|
||||||
|
* Used by Suite 19 to verify lock cleanup fires (D44 fold-in P2 #1
|
||||||
|
* regression test — Map must shrink to 0 after all queued callers finish).
|
||||||
|
*/
|
||||||
|
export function __writeLockSize() {
|
||||||
|
return _writeLocks.size;
|
||||||
|
}
|
||||||
@@ -9484,3 +9484,384 @@ describe('D38 — maxConcurrent runtime enforcement (Suite 18)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Suite 19: lib/keys.mjs — multi-key auth (ADR 0007, D44) ───────────────
|
||||||
|
|
||||||
|
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join as pathJoin } from 'node:path';
|
||||||
|
import {
|
||||||
|
createKey, listKeys, revokeKey, validateKey, touchLastUsed,
|
||||||
|
generateToken, hashToken, validateManifest,
|
||||||
|
readManifest, writeManifestAtomic,
|
||||||
|
SCHEMA_VERSION, ENV_OWNER_KEY_ID, ANONYMOUS_KEY_ID, ENV_OWNER_VAR,
|
||||||
|
__setTouchInterleaveHook, __resetWriteLocks, __writeLockSize,
|
||||||
|
} from './lib/keys.mjs';
|
||||||
|
|
||||||
|
describe('Suite 19 — lib/keys.mjs multi-key auth (ADR 0007, D44)', () => {
|
||||||
|
|
||||||
|
describe('19a-d — Token generation (§ 5)', () => {
|
||||||
|
it('19a: generateToken produces olp_<43-char base64url> (47 chars total)', () => {
|
||||||
|
const t = generateToken();
|
||||||
|
assert.match(t, /^olp_[A-Za-z0-9_-]{43}$/);
|
||||||
|
assert.equal(t.length, 47);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19b: consecutive generateToken calls produce different tokens', () => {
|
||||||
|
assert.notEqual(generateToken(), generateToken());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19c: hashToken returns 64-char lowercase hex sha256', () => {
|
||||||
|
assert.match(hashToken('olp_test'), /^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19d: hashToken is deterministic', () => {
|
||||||
|
assert.equal(hashToken('hello'), hashToken('hello'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('19e-j — Manifest write + read (§ 4, § 6.1)', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => { TMP = mkdtempSync(pathJoin(tmpdir(), 'olp-keys-19e-')); });
|
||||||
|
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||||
|
|
||||||
|
it('19e: createKey writes a valid manifest readable by readManifest', () => {
|
||||||
|
const { id, plaintext_token, manifest } = createKey({
|
||||||
|
name: 'test-key',
|
||||||
|
owner_tier: 'owner',
|
||||||
|
providers_enabled: ['anthropic'],
|
||||||
|
olpHome: TMP,
|
||||||
|
});
|
||||||
|
assert.match(plaintext_token, /^olp_[A-Za-z0-9_-]{43}$/);
|
||||||
|
assert.equal(manifest.schema_version, SCHEMA_VERSION);
|
||||||
|
assert.equal(manifest.id, id);
|
||||||
|
assert.equal(manifest.owner_tier, 'owner');
|
||||||
|
assert.deepEqual(manifest.providers_enabled, ['anthropic']);
|
||||||
|
assert.equal(manifest.token_hash_algo, 'sha256');
|
||||||
|
assert.equal(manifest.token_hash, hashToken(plaintext_token));
|
||||||
|
assert.equal(manifest.revoked_at, null);
|
||||||
|
assert.equal(manifest.last_used_at, null);
|
||||||
|
|
||||||
|
const reread = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.deepEqual(reread, manifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19f: manifest file mode 0600 + key dir mode 0700 enforced', () => {
|
||||||
|
const { id } = createKey({ name: 'mode-test', olpHome: TMP });
|
||||||
|
const fileMode = statSync(pathJoin(TMP, 'keys', id, 'manifest.json')).mode & 0o777;
|
||||||
|
const dirMode = statSync(pathJoin(TMP, 'keys', id)).mode & 0o777;
|
||||||
|
assert.equal(fileMode, 0o600, 'manifest must be 0600');
|
||||||
|
assert.equal(dirMode, 0o700, 'key dir must be 0700');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19g: readManifest returns null for non-existent key', () => {
|
||||||
|
assert.equal(readManifest('does-not-exist', { olpHome: TMP }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19h: validateManifest rejects unrecognized schema_version', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateManifest({
|
||||||
|
schema_version: 99, id: 'x', name: 'x', token_hash: 'x',
|
||||||
|
token_hash_algo: 'sha256', owner_tier: 'owner',
|
||||||
|
providers_enabled: '*', created_at: 'x',
|
||||||
|
}),
|
||||||
|
/manifest_invalid: unrecognized schema_version 99/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19i: validateManifest rejects bad owner_tier', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateManifest({
|
||||||
|
schema_version: 1, id: 'x', name: 'x', token_hash: 'x',
|
||||||
|
token_hash_algo: 'sha256', owner_tier: 'admin',
|
||||||
|
providers_enabled: '*', created_at: 'x',
|
||||||
|
}),
|
||||||
|
/owner_tier must be "owner" or "guest"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19j: createKey rejects empty name + bad owner_tier + bad providers_enabled', () => {
|
||||||
|
assert.throws(() => createKey({ name: '', olpHome: TMP }), /name is required/);
|
||||||
|
assert.throws(() => createKey({ name: 'x', owner_tier: 'admin', olpHome: TMP }), /owner_tier must be "owner" or "guest"/);
|
||||||
|
assert.throws(() => createKey({ name: 'x', providers_enabled: 'invalid', olpHome: TMP }), /providers_enabled must be/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('19k-p — validateKey (§ 5, § 6.3.5, § 9.4)', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => { TMP = mkdtempSync(pathJoin(tmpdir(), 'olp-keys-19k-')); });
|
||||||
|
after(() => {
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
delete process.env[ENV_OWNER_VAR];
|
||||||
|
__resetWriteLocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19k: valid plaintext returns key identity from filesystem', () => {
|
||||||
|
const { id, plaintext_token } = createKey({
|
||||||
|
name: 'valid-test',
|
||||||
|
owner_tier: 'guest',
|
||||||
|
providers_enabled: ['anthropic', 'openai'],
|
||||||
|
olpHome: TMP,
|
||||||
|
});
|
||||||
|
const identity = validateKey(plaintext_token, { olpHome: TMP });
|
||||||
|
assert.ok(identity);
|
||||||
|
assert.equal(identity.id, id);
|
||||||
|
assert.equal(identity.owner_tier, 'guest');
|
||||||
|
assert.deepEqual(identity.providers_enabled, ['anthropic', 'openai']);
|
||||||
|
assert.equal(identity.source, 'filesystem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19l: wrong plaintext returns null', () => {
|
||||||
|
createKey({ name: 'wrong-test', olpHome: TMP });
|
||||||
|
const wrong = 'olp_' + 'a'.repeat(43);
|
||||||
|
assert.equal(validateKey(wrong, { olpHome: TMP }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19m: missing plaintext with allowAnonymous:false → null', () => {
|
||||||
|
assert.equal(validateKey(null, { olpHome: TMP, allowAnonymous: false }), null);
|
||||||
|
assert.equal(validateKey('', { olpHome: TMP, allowAnonymous: false }), null);
|
||||||
|
assert.equal(validateKey(undefined, { olpHome: TMP, allowAnonymous: false }), null);
|
||||||
|
// Default allowAnonymous is false (§ 7.2)
|
||||||
|
assert.equal(validateKey(null, { olpHome: TMP }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19m-extra: non-string truthy plaintext (number / object / array) → null (no throw)', () => {
|
||||||
|
// D44 fold-in P2 #2: prior version threw TypeError on these inputs.
|
||||||
|
assert.equal(validateKey(42, { olpHome: TMP }), null);
|
||||||
|
assert.equal(validateKey({}, { olpHome: TMP }), null);
|
||||||
|
assert.equal(validateKey([], { olpHome: TMP }), null);
|
||||||
|
assert.equal(validateKey({ token: 'olp_xxx' }, { olpHome: TMP }), null);
|
||||||
|
// Same guard for allowAnonymous: true (env path doesn't change behaviour)
|
||||||
|
assert.equal(validateKey(42, { olpHome: TMP, allowAnonymous: true }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19n: missing plaintext with allowAnonymous:true → anonymous identity', () => {
|
||||||
|
const id = validateKey(null, { olpHome: TMP, allowAnonymous: true });
|
||||||
|
assert.ok(id);
|
||||||
|
assert.equal(id.id, ANONYMOUS_KEY_ID);
|
||||||
|
assert.equal(id.owner_tier, 'anonymous');
|
||||||
|
assert.equal(id.source, 'anonymous');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19o: revoked key returns null on validation (criterion #6 validation-side)', async () => {
|
||||||
|
const { id, plaintext_token } = createKey({ name: 'revoke-test', olpHome: TMP });
|
||||||
|
assert.ok(validateKey(plaintext_token, { olpHome: TMP }), 'valid before revoke');
|
||||||
|
await revokeKey({ id, olpHome: TMP });
|
||||||
|
assert.equal(validateKey(plaintext_token, { olpHome: TMP }), null, '401 path after revoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19p: OLP_OWNER_TOKEN env override returns __env_owner__ identity (§ 9.4)', () => {
|
||||||
|
const envToken = 'olp_' + 'e'.repeat(43);
|
||||||
|
process.env[ENV_OWNER_VAR] = envToken;
|
||||||
|
try {
|
||||||
|
const id = validateKey(envToken, { olpHome: TMP });
|
||||||
|
assert.ok(id);
|
||||||
|
assert.equal(id.id, ENV_OWNER_KEY_ID);
|
||||||
|
assert.equal(id.owner_tier, 'owner');
|
||||||
|
assert.equal(id.providers_enabled, '*');
|
||||||
|
assert.equal(id.source, 'env');
|
||||||
|
} finally {
|
||||||
|
delete process.env[ENV_OWNER_VAR];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('19q-r — revokeKey (§ 6.1)', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => { TMP = mkdtempSync(pathJoin(tmpdir(), 'olp-keys-19q-')); });
|
||||||
|
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||||
|
|
||||||
|
it('19q: revokeKey marks revoked_at and is idempotent', async () => {
|
||||||
|
const { id } = createKey({ name: 'rev-idem', olpHome: TMP });
|
||||||
|
assert.equal(await revokeKey({ id, olpHome: TMP }), true);
|
||||||
|
const m1 = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.ok(m1.revoked_at !== null);
|
||||||
|
assert.equal(await revokeKey({ id, olpHome: TMP }), true);
|
||||||
|
const m2 = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.equal(m1.revoked_at, m2.revoked_at, 'second revoke must not rewrite timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19r: revokeKey returns false for non-existent id', async () => {
|
||||||
|
assert.equal(await revokeKey({ id: 'no-such-key-zzz', olpHome: TMP }), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('19s-t — listKeys', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => { TMP = mkdtempSync(pathJoin(tmpdir(), 'olp-keys-19s-')); });
|
||||||
|
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||||
|
|
||||||
|
it('19s: empty dir returns []', () => {
|
||||||
|
assert.deepEqual(listKeys({ olpHome: TMP }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19t: lists all keys with token_hash redacted', () => {
|
||||||
|
createKey({ name: 'k1', owner_tier: 'owner', olpHome: TMP });
|
||||||
|
createKey({ name: 'k2', owner_tier: 'guest', olpHome: TMP });
|
||||||
|
const list = listKeys({ olpHome: TMP });
|
||||||
|
assert.equal(list.length, 2);
|
||||||
|
for (const m of list) {
|
||||||
|
assert.ok(m.token_hash === undefined, 'token_hash must be redacted from listKeys output');
|
||||||
|
assert.ok(['k1', 'k2'].includes(m.name));
|
||||||
|
assert.ok(['owner', 'guest'].includes(m.owner_tier));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('19u-x — touchLastUsed read-modify-write (§ 6.3)', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => { TMP = mkdtempSync(pathJoin(tmpdir(), 'olp-keys-19u-')); });
|
||||||
|
after(() => {
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
__resetWriteLocks();
|
||||||
|
__setTouchInterleaveHook(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19u: updates last_used_at on non-revoked key (preserves all other fields)', async () => {
|
||||||
|
const { id, manifest: m_before } = createKey({
|
||||||
|
name: 'touch-test', owner_tier: 'owner', providers_enabled: ['mistral'], olpHome: TMP,
|
||||||
|
});
|
||||||
|
assert.equal(m_before.last_used_at, null);
|
||||||
|
await touchLastUsed(id, { olpHome: TMP });
|
||||||
|
const m_after = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.ok(m_after.last_used_at !== null);
|
||||||
|
assert.equal(m_after.revoked_at, null);
|
||||||
|
assert.equal(m_after.owner_tier, 'owner');
|
||||||
|
assert.deepEqual(m_after.providers_enabled, ['mistral']);
|
||||||
|
assert.equal(m_after.token_hash, m_before.token_hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19v: NO-OPs on already-revoked key (preserves revoked_at, does not set last_used_at)', async () => {
|
||||||
|
const { id } = createKey({ name: 'touch-revoked', olpHome: TMP });
|
||||||
|
await revokeKey({ id, olpHome: TMP });
|
||||||
|
const revokedTs = readManifest(id, { olpHome: TMP }).revoked_at;
|
||||||
|
assert.ok(revokedTs !== null);
|
||||||
|
|
||||||
|
await touchLastUsed(id, { olpHome: TMP });
|
||||||
|
const m_after_touch = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.equal(m_after_touch.revoked_at, revokedTs, 'revoked_at must be preserved (revoke dominates touch)');
|
||||||
|
assert.equal(m_after_touch.last_used_at, null, 'NO-OP must not write last_used_at on revoked key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19w: NO-OPs silently on anonymous + env-owner identities (no manifest exists)', async () => {
|
||||||
|
await touchLastUsed(ANONYMOUS_KEY_ID, { olpHome: TMP });
|
||||||
|
await touchLastUsed(ENV_OWNER_KEY_ID, { olpHome: TMP });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19x: failure is best-effort (no throw on missing key)', async () => {
|
||||||
|
await touchLastUsed('phantom-key-xyz', { olpHome: TMP });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19x-extra: per-key write-lock Map is cleaned up after sequential calls (D44 fold-in P2 #1 regression)', async () => {
|
||||||
|
__resetWriteLocks();
|
||||||
|
assert.equal(__writeLockSize(), 0, 'lock map starts empty');
|
||||||
|
const { id } = createKey({ name: 'lock-cleanup-test', olpHome: TMP });
|
||||||
|
// Sequential touch calls
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await touchLastUsed(id, { olpHome: TMP });
|
||||||
|
}
|
||||||
|
assert.equal(__writeLockSize(), 0,
|
||||||
|
'lock map MUST be empty after sequential awaited calls (prior bug: stored derived promise never matched cleanup identity check, leaving stale entry per unique key-id)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19x-extra-2: per-key write-lock Map cleans up after concurrent contention resolves', async () => {
|
||||||
|
__resetWriteLocks();
|
||||||
|
const ids = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const { id } = createKey({ name: `concurrent-${i}`, olpHome: TMP });
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
// Race: 3 keys, each with 3 concurrent touches
|
||||||
|
const calls = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
calls.push(touchLastUsed(id, { olpHome: TMP }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(calls);
|
||||||
|
assert.equal(__writeLockSize(), 0,
|
||||||
|
'lock map MUST drain to 0 after all queued callers across all keys finish');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('19y-1 to 19y-4 — Acceptance criterion #7: concurrent revoke + touch (§ 6.3, § 6.4)', () => {
|
||||||
|
let TMP;
|
||||||
|
before(() => { TMP = mkdtempSync(pathJoin(tmpdir(), 'olp-keys-19y-')); });
|
||||||
|
after(() => {
|
||||||
|
rmSync(TMP, { recursive: true, force: true });
|
||||||
|
__resetWriteLocks();
|
||||||
|
__setTouchInterleaveHook(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19y-1: revoke-then-touch — revoked_at preserved', async () => {
|
||||||
|
const { id } = createKey({ name: 'race-1', olpHome: TMP });
|
||||||
|
await revokeKey({ id, olpHome: TMP });
|
||||||
|
const revokedTs = readManifest(id, { olpHome: TMP }).revoked_at;
|
||||||
|
await touchLastUsed(id, { olpHome: TMP });
|
||||||
|
const m = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.equal(m.revoked_at, revokedTs, 'revoked_at must survive subsequent touch');
|
||||||
|
assert.equal(m.last_used_at, null, 'touch must NO-OP on revoked key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19y-2: touch-then-revoke — revoked_at present + last_used_at preserved from prior touch', async () => {
|
||||||
|
const { id } = createKey({ name: 'race-2', olpHome: TMP });
|
||||||
|
await touchLastUsed(id, { olpHome: TMP });
|
||||||
|
const lastUsedAtBeforeRevoke = readManifest(id, { olpHome: TMP }).last_used_at;
|
||||||
|
assert.ok(lastUsedAtBeforeRevoke !== null);
|
||||||
|
await revokeKey({ id, olpHome: TMP });
|
||||||
|
const m = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.ok(m.revoked_at !== null, 'revoked_at must be set');
|
||||||
|
assert.equal(m.last_used_at, lastUsedAtBeforeRevoke, 'last_used_at from prior touch must persist through revoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19y-3: interleaved external revoke fires inside touch lock before read — revoked_at preserved', async () => {
|
||||||
|
// SCENARIO TESTED: external revoke lands after touch acquires its lock
|
||||||
|
// but before touch's read. touch's subsequent read sees the revoke and
|
||||||
|
// NO-OPs per § 6.3 step 2.
|
||||||
|
//
|
||||||
|
// SCENARIO NOT TESTED (and currently UNREACHABLE in implementation):
|
||||||
|
// external revoke between touch's read and touch's write. The current
|
||||||
|
// touchLastUsed has SYNCHRONOUS read→write (no await between
|
||||||
|
// readManifest and writeManifestAtomic), so no external write can
|
||||||
|
// interleave between them at the JS event-loop level. If a future
|
||||||
|
// refactor introduces an await between read and write, this guarantee
|
||||||
|
// breaks and a post-read hook + matching test would be required to
|
||||||
|
// catch the regression. ADR § 10 criterion #7 scenario 3 is satisfied
|
||||||
|
// by the synchronous-read-write property of the current implementation.
|
||||||
|
const { id } = createKey({ name: 'race-3', olpHome: TMP });
|
||||||
|
let hookFired = false;
|
||||||
|
__setTouchInterleaveHook(async () => {
|
||||||
|
// Simulates an external (out-of-process / CLI) revoke landing
|
||||||
|
// between touch's lock acquisition and touch's read. Bypasses
|
||||||
|
// _withKeyLock because external writers do not see in-process
|
||||||
|
// locks at Phase 2 (§ 6.4).
|
||||||
|
const fresh = readManifest(id, { olpHome: TMP });
|
||||||
|
fresh.revoked_at = new Date().toISOString();
|
||||||
|
writeManifestAtomic(id, fresh, { olpHome: TMP });
|
||||||
|
hookFired = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await touchLastUsed(id, { olpHome: TMP });
|
||||||
|
} finally {
|
||||||
|
__setTouchInterleaveHook(null);
|
||||||
|
}
|
||||||
|
assert.ok(hookFired, 'hook must have fired');
|
||||||
|
const m = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.ok(m.revoked_at !== null, 'revoked_at must NOT be cleared by an interleaved touch — § 6.3 contract');
|
||||||
|
assert.equal(m.last_used_at, null, 'touch must NO-OP after observing the interleaved revoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('19y-4: stress — 30 iterations of concurrent revoke + touch never lose revoked_at', async () => {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const { id } = createKey({ name: `stress-${i}`, olpHome: TMP });
|
||||||
|
await Promise.all([
|
||||||
|
revokeKey({ id, olpHome: TMP }),
|
||||||
|
touchLastUsed(id, { olpHome: TMP }),
|
||||||
|
]);
|
||||||
|
const m = readManifest(id, { olpHome: TMP });
|
||||||
|
assert.ok(m.revoked_at !== null, `iter ${i}: revoked_at lost — race regression`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user