diff --git a/AGENTS.md b/AGENTS.md index f6ff206..5ae9744 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/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/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.** - `models-registry.json` β€” single source of truth for `(provider, model) β†’ metadata`. SPOT. - `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. - `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. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ded3b..a3df35f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this ## 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//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` 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()` β†’ `createHash().update()`. 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) 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. diff --git a/README.md b/README.md index 0a65de4..547060a 100644 --- a/README.md +++ b/README.md @@ -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 | | `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`) | -| `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 | | `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 | @@ -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. - **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). --- diff --git a/lib/keys.mjs b/lib/keys.mjs new file mode 100644 index 0000000..175c8ee --- /dev/null +++ b/lib/keys.mjs @@ -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. +// 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: ') 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} 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().) + 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; +} diff --git a/test-features.mjs b/test-features.mjs index 33f2fca..ca4ae28 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -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`); + } + }); + }); + +});