mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 13:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87b6b73ec | ||
|
|
939f3e6bd9 | ||
|
|
06f619120d | ||
|
|
40064955ab | ||
|
|
4b9916341b | ||
|
|
d253c2b98d | ||
|
|
68851fe3d7 |
@@ -37,7 +37,9 @@ 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. **📋 Planned (Phase 2) — not yet authored.**
|
- `lib/keys.mjs` — multi-key auth, per-key namespacing, identity layer. Carries OCP's per-key isolation model into OLP. **✅ Phase 2 — D44 core + D45 server integration + D46 owner gating shipped (validateKey on every /v1/* + /health; chain filtered by providers_enabled; touchLastUsed fires post-response; /health payload trimmed for non-owner; X-OLP-Fallback-Detail gated by fallback_detail_header_policy).**
|
||||||
|
- `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`).
|
||||||
|
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).**
|
||||||
- `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 +47,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-24):** 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 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`.
|
**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; files marked ✅ are Phase 2 deliverables. The shipped set as of D47 is: `server.mjs` (with Phase 2 auth middleware + audit wire + owner-vs-non-owner gating), `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core + loadAuthConfigSync — D44 + D45), `lib/audit.mjs` (D45), `bin/olp-keys.mjs` (D47), `models-registry.json`, `test-features.mjs` (Suites 19–22). Phase 2 functional scope is complete; remaining is Phase 2 close → v0.2.0 (maintainer-triggered, explicit per CLAUDE.md `release_kit.phase_close_trigger`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ OLP distinguishes **Candidate Providers** (declared in this constitution as inte
|
|||||||
|
|
||||||
The v0.1 founding commit ships **zero Enabled Providers**. This is intentional: a constitution that names a provider as "default-enabled" while its CLI version, output shape, auth artifact, and exit-code semantics are still TBD violates Rules 1 (Cite First) and 3 (Match the Implementation). Enablement is a Phase audit deliverable, not a bootstrap claim.
|
The v0.1 founding commit ships **zero Enabled Providers**. This is intentional: a constitution that names a provider as "default-enabled" while its CLI version, output shape, auth artifact, and exit-code semantics are still TBD violates Rules 1 (Cite First) and 3 (Match the Implementation). Enablement is a Phase audit deliverable, not a bootstrap claim.
|
||||||
|
|
||||||
|
**Note on phase terminology.** "Phase" in the tables and audit triggers below (and in § One-shot Triggered Audits → "OpenAI Codex ToS formal pin") refers to the **original per-plugin enablement plan** captured at project founding (one Tier-D plugin enabled per phase). The milestone phase numbering in [`README.md` § Phase plan](./README.md#phase-plan) was re-aligned at v0.1.1 close (D43-A, 2026-05-25) to reflect actually-shipped bundling — Phase 1 shipped all three Tier-D plugins + cache + fallback together as a single milestone, and Phase 2 became multi-key auth per ADR 0007. The two numberings are orthogonal: ALIGNMENT.md tracks **per-plugin enablement maturity**; README tracks **milestone release scope**.
|
||||||
|
|
||||||
### Enabled Providers
|
### Enabled Providers
|
||||||
|
|
||||||
| Provider key | Tier | Default state | Authority pin | Inclusion source |
|
| Provider key | Tier | Default state | Authority pin | Inclusion source |
|
||||||
|
|||||||
+186
-1
@@ -4,7 +4,192 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
(empty — Phase 2 entries land here once Phase 2 opens)
|
(empty — Phase 3 entries land here once Phase 3 opens)
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-05-25
|
||||||
|
|
||||||
|
### Phase 2 — Multi-key auth + audit + owner gating + keygen CLI (D43-A → D47)
|
||||||
|
|
||||||
|
**Overview.** v0.2.0 closes Phase 2 — the multi-key authentication track that grew OLP from single-tenant anonymous-only proxy (v0.1.1) to a multi-identity deployment with per-key cache isolation, audit attribution, owner-vs-guest header gating, and a reproducible bootstrap CLI. 6 D-day commits (D43-A through D47) shipped between 2026-05-25 (single intensive session under the standing-autopilot grant). All 11 ADR 0007 § 10 acceptance criteria are implemented + tested.
|
||||||
|
|
||||||
|
**Test count: 468 (v0.1.1) → 544 (v0.2.0).** +76 tests across the Phase 2 arc.
|
||||||
|
|
||||||
|
**Phase 2 release_kit checklist**
|
||||||
|
|
||||||
|
- [x] All 6 D-day deliverables landed on main (D43-A, D43-B ADR draft, D44, D45, D46, D47)
|
||||||
|
- [x] CI green on every D-day merge commit + on this release commit's head
|
||||||
|
- [x] Fresh-context opus reviewer on every implementation D-day (D44/D45/D46/D47), maintainer text-review on D43-B ADR
|
||||||
|
- [x] All 11 ADR 0007 § 10 acceptance criteria (#1–#11) covered by Suite 19/20/21/22 tests
|
||||||
|
- [x] CHANGELOG "Unreleased" promoted to "## v0.2.0 — 2026-05-25" with D43-A through D47 entries
|
||||||
|
- [x] `package.json` bumped 0.1.1 → 0.2.0
|
||||||
|
- [x] `CLAUDE.md release_kit.phase_rolling_mode`: `current_phase` Phase 2 → Phase 3; `current_pre_release_identifier` `0.2.0-phase2` → `0.3.0-phase3`
|
||||||
|
- [x] README status header + Implementation Status + Phase plan reflect Phase 2 shipped
|
||||||
|
- [ ] Tag pushed (next step in this PR's lifecycle)
|
||||||
|
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push; D37 phase_rolling_mode gate will pass because Unreleased is now sentinel-only)
|
||||||
|
|
||||||
|
**ADR 0007 § 10 acceptance criteria — final ship status**
|
||||||
|
|
||||||
|
| # | Criterion | Covering tests |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Per-key cache namespace isolation | Suite 20i |
|
||||||
|
| 2 | Anonymous prod-default off → 401 | Suite 20a |
|
||||||
|
| 3 | Anonymous dev-mode on → 200 | Suite 20g |
|
||||||
|
| 4 | Owner-vs-guest `/health` gating | Suite 21a-d |
|
||||||
|
| 5 | Owner-vs-guest `X-OLP-Fallback-Detail` gating | Suite 21e-h |
|
||||||
|
| 6 | Post-revoke 401 within next request | Suite 19o + Suite 20e |
|
||||||
|
| 7 | Manifest atomicity + revoke-dominates-touch | Suite 19y-1..4 |
|
||||||
|
| 8 | Audit ndjson round-trip + PII guard | Suite 20j + 20j-stream + 20j-401 |
|
||||||
|
| 9 | Bootstrap keygen surface reproducible | Suite 22 |
|
||||||
|
| 10 | `OLP_OWNER_TOKEN` env override | Suite 19p + Suite 20f |
|
||||||
|
| 11 | `providers_enabled` 403 scope enforcement | Suite 20h |
|
||||||
|
|
||||||
|
**Known limitations carried beyond v0.2.0**
|
||||||
|
|
||||||
|
Phase 2 functional scope is complete. The following remain as Phase 3+ deferrals (tracked in `docs/v1x-roadmap.md` + new entries below):
|
||||||
|
|
||||||
|
- **Dashboard (`dashboard.html`)** — owner-only multi-provider quota / fallback / cache-hit-rate panels. Per ADR 0007 § 12 + v0.1 spec § 4.6. Phase 3 mainline.
|
||||||
|
- **Audit query layer + rotation** — `audit.ndjson` is append-only at v0.2.0; aggregate queries + log rotation deferred to Phase 3 alongside Dashboard.
|
||||||
|
- **`tried_providers` semantics on `key_no_provider_access` 403** — schema currently reports filter-rejected hops as "tried"; either ADR § 8 amendment (rename / add field) or D46+ semantic fix. Noted by D45 opus reviewer.
|
||||||
|
- **Per-provider per-key auth artifact mapping** — ADR § 12 explicit out-of-scope. Per-key cache + audit isolation works; per-key per-provider OAuth tokens (e.g., two OLP keys each authenticated to different OpenAI Codex accounts) is Phase 3+ work.
|
||||||
|
- **SQLite migration (Option 3 hybrid)** — ADR § 13 documents the forward path; trigger is Dashboard / SQL-aggregate-quota / multi-second audit-query workload. Requires engines bump (`>=22.13.0` or `>=23.4.0`) per ADR § 11 as a separate prior PR.
|
||||||
|
|
||||||
|
### D47 — `bin/olp-keys.mjs` keygen CLI (Phase 2 functional scope closes)
|
||||||
|
|
||||||
|
Fourth Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criterion #9 (bootstrap workflow must be reproducible without manual file editing) by shipping a minimal keygen CLI per § 9.1. **Phase 2 functional scope is complete with this D-day** — remaining work is Phase 2 close → v0.2.0 (maintainer-triggered, explicit per CLAUDE.md `release_kit.phase_close_trigger`).
|
||||||
|
|
||||||
|
- **New file `bin/olp-keys.mjs`** (~250 lines): subcommand CLI with three subcommands:
|
||||||
|
- `keygen [--owner] [--name=<label>] [--tier=guest|owner] [--providers=<csv>] [--force]` — creates a key + prints plaintext token to stdout ONCE; manifest stores only SHA-256 hash. `--force` revokes existing owner keys before creating the new owner (recovery flow per ADR § 9.3). `--providers=*` (default) or comma-separated allowlist.
|
||||||
|
- `list [--owner-only] [--include-revoked]` — lists keys with `token_hash` redacted (lib/keys.mjs `listKeys` already redacts).
|
||||||
|
- `revoke --id=<key-id>` — marks the key's `revoked_at`; idempotent (already-revoked → no-op + status message); missing id → exit 2.
|
||||||
|
- Common flag `--olp-home=<path>` overrides `~/.olp/`; defaults to `OLP_HOME` env or `~/.olp/`.
|
||||||
|
- **`package.json` `bin` field**: `olp-keys` → `./bin/olp-keys.mjs` so `npx olp-keys ...` resolves; also `npm run olp-keys ...` via scripts.
|
||||||
|
- **Module shape**: CLI exposes `runCli(argv, { out, err })` so tests can invoke it with synthetic argv + IO writers (no process spawn). Main guard auto-runs when invoked as entrypoint.
|
||||||
|
- **Plaintext token discipline**: per ADR § 5 + § 9.1, plaintext is printed exactly once on stdout. Never logged, never written to manifest, never written to audit. Operators must capture immediately; lost → `--force` revoke + regenerate.
|
||||||
|
- **`--force` async correctness**: `cmdKeygen` is async and `await`s each `revokeKey` (which is async — acquires per-key write lock per § 6.4). Sequence: revoke each existing owner manifest atomically → then `createKey` for new owner. Avoids the race where create-new runs before revoke-old completes.
|
||||||
|
- **Test surface (Suite 22, +20 tests — 524 → 544):**
|
||||||
|
- 22a-1..5: parseArgv unit tests (`--flag=value`, `--flag value`, boolean, mixed positional)
|
||||||
|
- 22b-1..5: keygen subcommand (owner default, name+providers, missing-name error, invalid-tier error, --force revoke-then-create flow with isolation tmpdir)
|
||||||
|
- 22c-1..3: list subcommand (empty, populated with token_hash-redaction check, --owner-only filter)
|
||||||
|
- 22d-1..4: revoke subcommand (valid id, idempotent re-revoke, missing-id error, nonexistent-id error)
|
||||||
|
- 22e-1..3: top-level CLI behaviour (--help / no args / unknown subcommand exit codes)
|
||||||
|
- **Documentation:** AGENTS.md `lib/keys.mjs` marker promoted to ✅; new `bin/olp-keys.mjs` entry. Implementation-status-note + shipped-set updated. README.md Implementation Status table gains `bin/olp-keys.mjs` row; Known limitations note updated to "Phase 2 functional scope complete; close pending"; new "Bootstrap workflow" section with copy-pasteable npx commands + recovery flow.
|
||||||
|
- **Test count:** 524 → 544 (+20 D47 tests in Suite 22).
|
||||||
|
- **Authority:** ADR 0007 (multi-key auth — § 5 token format, § 9.1 minimal keygen command surface, § 9.3 recovery, § 10 acceptance criterion #9 covered); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||||
|
|
||||||
|
### D46 — owner-vs-guest gating for `/health` + `X-OLP-Fallback-Detail` (Phase 2 closes header observability gap)
|
||||||
|
|
||||||
|
Third Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criteria #4 (`/health` payload trimming for non-owner) + #5 (`X-OLP-Fallback-Detail` emission gating per `fallback_detail_header_policy`). Phase 2 server surface is now fully gated end-to-end; remaining D-days are keygen CLI surface (D47+) and Phase 2 close (v0.2.0, maintainer-triggered).
|
||||||
|
|
||||||
|
- **`server.mjs` `handleHealth` identity-aware payload** per ADR § 7.1:
|
||||||
|
- Auth gate at top — `authenticate(req)` returns 401 for unauth + `allow_anonymous: false` (consistent with /v1/* routes); 200 with trimmed payload for anonymous / guest; 200 with full payload for owner.
|
||||||
|
- Trim controlled by `_authConfig.owner_only_endpoints` — if `/health` is in the list, non-owner gets `{ ok: true, version }`; else (operator removes it) full payload to everyone (v0.1.1 opt-out knob).
|
||||||
|
- `touchLastUsed` fired on `res.on('finish')` for filesystem identities (matches /v1/* pattern). No audit row on /health — high-volume monitoring endpoint, audit volume noise not justified at Phase 2 (would land with Phase 3 Dashboard if aggregate /health stats become needed).
|
||||||
|
- **`server.mjs` `withFallbackDetailHeader` identity-aware emission** per ADR § 7.2:
|
||||||
|
- New helper `shouldEmitFallbackDetailHeader(olpIdentity)` reads `_authConfig.fallback_detail_header_policy`:
|
||||||
|
- `'owner_only'` (default) → emit only when `olpIdentity.owner_tier === 'owner'`
|
||||||
|
- `'all'` → emit unconditionally (v0.1.1 opt-back-in for operators who want the diagnostic header for all identities)
|
||||||
|
- `'none'` → suppress unconditionally
|
||||||
|
- When `olpIdentity` is null (pre-auth error paths), defaults to emit — preserves the v0.1.1 ungated behaviour for pre-auth errors where identity is unknown.
|
||||||
|
- `withFallbackDetailHeader` signature gains a third `olpIdentity` argument; both call sites in `handleChatCompletions` updated to pass `olpIdentity`.
|
||||||
|
- **Test surface (Suite 21, +9 tests + 1 added in Suite 20 — 515 → 524):**
|
||||||
|
- **20m** /health with no auth + `allow_anonymous=false` → 401 (consistency with /v1/* routes)
|
||||||
|
- **21a-d** /health payload trimming (criterion #4): anonymous trimmed; guest trimmed; owner full; `owner_only_endpoints: []` opts out (guest gets full)
|
||||||
|
- **21e-h** X-OLP-Fallback-Detail emission gating (criterion #5): `owner_only` + guest → header absent; `owner_only` + owner → header present + valid JSON; `'all'` + guest → header present (v0.1.1 opt-back); `'none'` + owner → header absent (full suppression). Tests use a 2-hop chain (anthropic primary fail + openai secondary) to produce non-empty `fallbackDetail` for the header content.
|
||||||
|
- **Test-mode setup updated:** the global `__setAuthConfig({ allow_anonymous: true })` was extended to also pass `owner_only_endpoints: []` + `fallback_detail_header_policy: 'all'` so pre-D46 tests (Suite 18, F5 /health tests, D40 fallback-detail tests, etc.) continue to pass without modification — Suite 21 explicitly overrides per-case to exercise the production-default-gated paths.
|
||||||
|
- **Documentation:** AGENTS.md `lib/keys.mjs` marker updated to reflect D46 ship; Implementation-status-note updated. README.md Implementation Status row + Known limitations "Multi-key auth" note rewritten to reflect D46 ship + remaining keygen CLI.
|
||||||
|
- **Test count:** 515 → 524 (+9 D46 tests).
|
||||||
|
- **Authority:** ADR 0007 (multi-key auth — §§ 7.1 + 7.2 implementation contracts + § 10 acceptance criteria #4 + #5 covered); ADR 0004 Amendment 5 (D40 ratification of "Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands" — this D-day fulfils the deferral); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`).
|
||||||
|
|
||||||
|
### D45 — `server.mjs` auth integration + `lib/audit.mjs` (Phase 2 wire-up)
|
||||||
|
|
||||||
|
Second Phase 2 implementation D-day. Wires the D44 `lib/keys.mjs` identity layer into the request flow + lands `lib/audit.mjs` per ADR 0007 § 6.2 + § 8. Closes acceptance criteria #1 (per-key cache isolation, validation-side end-to-end), #2 (anonymous prod-default off), #3 (anonymous dev-mode on), #6 (post-revoke 401 within next request — full), #8 (audit ndjson round-trip), #10 (`OLP_OWNER_TOKEN` env override — full server-side), #11 (`providers_enabled` 403 scope). Owner-vs-guest gating for `/health` + `X-OLP-Fallback-Detail` (criteria #4, #5) remains in D46 scope.
|
||||||
|
|
||||||
|
- **New file `lib/audit.mjs`** (~75 lines): `appendAuditEvent(event, opts)` writes one JSON event per line to `~/.olp/logs/audit.ndjson` (file 0600, dir 0700). § 6.2 retry semantics: warn + 1 retry on first failure; per-process drop counter + warn on second failure; NEVER throws. Per-call `OLP_HOME` env resolution (matches `lib/keys.mjs`). Exports `getAuditDropCount` for future /health surface.
|
||||||
|
- **`lib/keys.mjs`** extended with `loadAuthConfigSync({ olpHome })` reading the `auth` block from `~/.olp/config.json` with defaults per ADR § 7.2 (`allow_anonymous: false`, `owner_only_endpoints: ['/health']`, `fallback_detail_header_policy: 'owner_only'`). Both `lib/keys.mjs` + `lib/audit.mjs` now resolve `OLP_HOME` env per call (precedence: opts.olpHome → process.env.OLP_HOME → ~/.olp) so tests and operator deployments can redirect without code edits.
|
||||||
|
- **`server.mjs` auth middleware integration:**
|
||||||
|
- `extractToken(req)` parses `Authorization: Bearer <token>` first, then `x-api-key: <token>`.
|
||||||
|
- `authenticate(req)` calls `validateKey(token, { allowAnonymous: _authConfig.allow_anonymous })`; returns identity on success, 401 `{ auth_required | invalid_or_revoked_key }` on failure.
|
||||||
|
- `isProviderEnabled(olpIdentity, providerKey)` enforces `providers_enabled` allowlist (`'*'` = all).
|
||||||
|
- `_authConfig` loaded at startup; warn `auth_allow_anonymous_enabled` fires if `allow_anonymous: true` so the relaxed posture is visible. Test seams `__setAuthConfig` / `__resetAuthConfig`.
|
||||||
|
- `handleChatCompletions` and `handleModels` both gated by `authenticate(req)` at top. Audit ctx object built throughout the handler lifecycle; `res.on('finish')` appends the row + fires `touchLastUsed` async (best-effort).
|
||||||
|
- **Identity-vs-credentials separation:** `olpIdentity` (the new validated identity) is consumed for cache namespacing + providers_enabled + audit; `authContext` passed to `provider.spawn()` REMAINS `null` so providers continue their own credential discovery (env / keychain / file). Per-provider per-key credential mapping is Phase 3+ scope per ADR § 12.
|
||||||
|
- `handleChatCompletions` chain filtered by `chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider))`; empty result returns 403 `key_no_provider_access` with helpful diagnostic message.
|
||||||
|
- `keyId = olpIdentity.keyId` (replacing hardcoded `'__anonymous__'` at the cache call sites).
|
||||||
|
- Audit captures fields throughout: post-auth (key_id, owner_tier); post-IR (model); post-chain-success (provider, fallback_hops, tried_providers, cache_status); post-chain-exhausted (error_code, providerUsed=chain[0], cache_status='miss'). Status code + latency populated on `res.on('finish')`.
|
||||||
|
- **Test surface (Suite 20, +15 tests, 499 → 514):**
|
||||||
|
- 20a-d: header parsing + valid key happy paths (Bearer / x-api-key / invalid → 401)
|
||||||
|
- 20e: revoked key 401 (closes criterion #6 end-to-end)
|
||||||
|
- 20f: `OLP_OWNER_TOKEN` env override returns 200 (criterion #10 full coverage)
|
||||||
|
- 20g: `allow_anonymous: true` + no header returns 200 (criterion #3)
|
||||||
|
- 20h + 20h-extra: `providers_enabled: ['mistral']` for anthropic model → 403; `'*'` baseline returns 200 (criterion #11)
|
||||||
|
- 20i: per-key cache namespace isolation — keys A/B with identical payload do not share cache (criterion #1 end-to-end)
|
||||||
|
- 20j + 20j-401: audit.ndjson written with § 8 schema fields including PII guard; 401 path also appends (criterion #8)
|
||||||
|
- 20k: filesystem key `last_used_at` populated after first successful request (D45 touchLastUsed wire)
|
||||||
|
- 20l + 20l-200: `/v1/models` also enforces auth (consistent gating across `/v1/*`)
|
||||||
|
- **Test-mode setup:** test-features.mjs sets `process.env.OLP_HOME` to a tmpdir at module load so audit + key writes don't pollute `~/.olp/`. After the server.mjs import resolves, calls `__setAuthConfig({ allow_anonymous: true })` so pre-D45 HTTP integration tests (Suite 18 etc.) that don't pass an Authorization header continue to pass as anonymous; Suite 20 explicitly overrides per-case for production-default-off coverage.
|
||||||
|
- **Documentation:** AGENTS.md `lib/keys.mjs` 🟡 marker updated + new `lib/audit.mjs` entry; AGENTS.md Implementation-status-note + shipped-set updated. README.md Implementation Status table gains `lib/audit.mjs` row + `lib/keys.mjs` row updated; Known limitations "Multi-key auth" note rewritten to reflect D45 ship + D46 follow-up; new env-vars and config block surfaced for users.
|
||||||
|
- **Test count:** 499 → 515 (+15 initial Suite 20 tests + 1 fold-in regression test `20j-stream` covering opus-P1 streaming audit-fidelity).
|
||||||
|
- **Fold-in (CI-fail recovery + fresh-context opus reviewer findings, 1 CI + 1 P1 + 2 P2 + 1 P3):**
|
||||||
|
- **CI Node 24 failure** — Suite 20 setup did not stub `CLAUDE_CODE_OAUTH_TOKEN` before the mock spawn ran; lib/providers/anthropic.mjs `_spawnAndStream` checks for an OAuth token BEFORE invoking the (mock) spawn, so the AUTH_MISSING pre-check fired and every Suite 20 200-expecting test 502'd on CI Node 24 (local Node 22 had the env from the maintainer's claude install). Fixed by `ensureSuite20FakeOAuth` / `restoreSuite20OAuth` helpers in `makeSuite20Server` / `teardownSuite20`; matches the existing pattern used at Suite 9 line ~2154 (`test-fake-oauth-token-for-cache-tests`).
|
||||||
|
- **P1 real-streaming audit fidelity** — single-hop streaming success path (server.mjs ~L1050+ `if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop ...)`) did not populate `auditCtx.provider` / `tried_providers` / `cache_status`, so audit rows for the most common deployed shape carried `provider: null`. Fixed by stamping these fields at the top of the streaming branch (between the streamPlugin null-check and the `streamHeaders` build) and amending `error_code` on the two streaming failure exit paths (`streaming_error_after_first_chunk` + `streaming_error_before_first_chunk`). New regression test `20j-stream` makes a streaming request and asserts the audit row's `provider`, `cache_status`, and `tried_providers` fields are populated.
|
||||||
|
- **P2 global test tmpdir cleanup** — `process.env.OLP_HOME = mkdtempSync(...)` at module load left a `/var/folders/.../olp-test-home-*` directory leak per `npm test` run. Fixed by `process.on('exit', () => rmSync(...))` registered immediately after the mkdtempSync. Best-effort; never throws at exit.
|
||||||
|
- **P3 handleModels 401 lacks OLP diagnostic headers** — `handleChatCompletions` 401 path passes `olpErrorHeaders({ startMs })` but `handleModels` did not. Aligned by adding the same headers to the `handleModels` `authResult.ok=false` return.
|
||||||
|
- **Deferred (acknowledged by reviewer as non-blocking):** P2 `tried_providers` semantics on `key_no_provider_access` 403 — schema currently reports filter-rejected hops as "tried" which a downstream Dashboard would misread; either ADR § 8 amendment (rename / add field) or D46+ semantic fix.
|
||||||
|
- **Authority:** ADR 0007 (multi-key auth — §§ 5/6.2/7/9.4 implementation contracts + § 10 acceptance criteria #1/#2/#3/#6/#8/#10/#11); 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`); standing autopilot grant (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`).
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- `docs/adr/0007-multi-key-auth.md` (new, ~420 lines after fold-ins): 13 sections covering Context / Decision (Option 2 + opaque key) / Storage layout (`~/.olp/keys/<key-id>/manifest.json` + `~/.olp/logs/audit.ndjson`) / Manifest schema (schema_version, token_hash, owner_tier, providers_enabled) / Token format (`olp_<32-byte base64url>`, SHA-256 hash) / Atomic write & audit append (manifest lifecycle-only atomic via tmpfile+fsync+rename; audit per-request append, warn + 1 retry, no memory buffer at Phase 2) / Owner-vs-guest-vs-anonymous gating (config.json `auth.allow_anonymous` default false, no env auto-detection) / Audit ndjson schema (no PII) / Bootstrap & recovery (minimal keygen command surface + `OLP_OWNER_TOKEN` env override with stable `__env_owner__` keyId) / Acceptance criteria (11 test surfaces for D44+) / Node baseline (Option 1 SQLite port rejection rationale citing `engines >=18` + CI 20/24 vs `node:sqlite` v22.5.0 with flag) / Out of scope (Dashboard, quota enforcement, audit query, file locking deferred to Phase 3+) / Future forward (Option 3 hybrid migration trigger + preconditions).
|
||||||
|
- `docs/adr/README.md` index: added ADR 0007 row with one-paragraph summary.
|
||||||
|
- `docs/v1x-roadmap.md` #2: marked **PHASE 2 ACTIVE (no longer deferred)**; "Design ADR (NOT YET RATIFIED)" → "Design ADR (ratified) → ADR 0007"; trigger updated to "already fired 2026-05-25"; code anchors pinned to exact line numbers (cache/store.mjs:77-79/:287, server.mjs:502/:531/:392/:1072/:1101).
|
||||||
|
- `CHANGELOG.md` Unreleased: this entry.
|
||||||
|
- **Fold-in #1 (fresh-context opus reviewer findings, 2 P2 + 3 P3, all polish):** § 6.2 step 1 — pin audit serialization timing to after status_code + latency_ms are known (resolves §10 #2 testability gap); new § 6.3.5 — explicit "no in-process validation cache at Phase 2" rule (resolves §10 #6 implicit-contract gap); § 6.1 — document deliberate omission of directory fsync after rename (single-process trade-off); § 9.4 — token-collision policy between `OLP_OWNER_TOKEN` and filesystem keys declared undefined behaviour; §10 #4 — test rephrased to assert against config-driven `owner_only_endpoints` rather than hardcoded payload shape.
|
||||||
|
- **Fold-in #2 (maintainer text-review findings, 1 P1 + 1 P2 + 1 P3):** § 6.3 rewritten to `last_used_at` revoke-dominates-touch semantics (P1 — fixes safety bug where lazy touch could overwrite revoke and silently clear `revoked_at`, breaking acceptance criterion #6 under concurrent CLI revoke + in-flight server request); § 6.4 reframed from "both states are valid" / "observability-grade" to "revoke dominates touch" with §6.3 as the load-bearing discipline; § 10 criterion #7 expanded to test all three orderings (revoke→touch, touch→revoke, interleaved) with explicit MUST: `revoked_at` non-null after revoke regardless of ordering; § 11 forward path step (1) corrected Node version history — minimum non-flag-gated baseline is v22.13.0 (LTS) / v23.4.0 (current), RC since v25.7.0, stable TBD (previous wording "Node v22.5.0+ for unflagged but RC" was factually wrong per https://nodejs.org/download/release/v22.12.0/docs/api/sqlite.html and https://nodejs.org/api/sqlite.html); this CHANGELOG entry line-count corrected from "~270 lines" to "~420 lines after fold-ins".
|
||||||
|
- **Test count:** 468 → 468 (design-only, no test change).
|
||||||
|
- **Authority:** Phase 2 kickoff handoff (`~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` in cc-rules `d9da966`); OLP v0.1 spec § 4.5 (planning authority for `~/.olp/` layout); OCP `keys.mjs` (prior-art for opaque-key + per-key isolation model); Node `node:sqlite` docs (https://nodejs.org/api/sqlite.html — Option 1 rejection rationale per ADR 0007 § 11); CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for design ADR per Iron Rule 10.
|
||||||
|
|
||||||
|
### D43-A — Phase 2 doc alignment (no code change)
|
||||||
|
|
||||||
|
Phase 1 was closed at v0.1.1; this commit aligns documentation surfaces to the Phase 2 reality before D43-B (ADR 0007 draft) lands. Pure doc cleanup; no `.mjs` or test changes.
|
||||||
|
|
||||||
|
- `CLAUDE.md release_kit.current_phase` Phase 1 → Phase 2; `current_pre_release_identifier` `0.1.0-bootstrap` → `0.2.0-phase2`.
|
||||||
|
- `README.md` status header + Implementation Status + Phase plan rewritten to reflect actually-shipped reality (v0.1.0 + v0.1.1 bundled the three Tier-D plugins + cache + fallback into a single Phase 1 milestone, not one phase per plugin as the original v0.1 spec planned). `lib/keys.mjs` row + "Multi-key auth not yet implemented" note updated to "Phase 2 active per ADR 0007 (drafting at D43-B)".
|
||||||
|
- `AGENTS.md` § Key files to know — `lib/keys.mjs` 📋 marker updated to "Phase 2 active per ADR 0007 (drafting at D43-B)"; Implementation-status-note paragraph dated 2026-05-25 + reflects Phase 1 close + Phase 2 active scope.
|
||||||
|
- `ALIGNMENT.md` § Provider Inventory — added one-paragraph "Note on phase terminology" clarifying that "Phase" in the Provider Inventory tables + § One-shot Triggered Audits "OpenAI Codex ToS formal pin" refers to the original per-plugin enablement plan, orthogonal to the milestone phase numbering in README. Fold-in for D43-A reviewer P2 finding; no governance-text change, no Speculative-Candidate plugin reclassification.
|
||||||
|
- **Test count:** 468 → 468 (no test change).
|
||||||
|
- **Authority:** `CLAUDE.md release_kit overlay phase_rolling_mode` — under Unreleased; Phase 2 kickoff handoff at `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md`; ADR 0007 forthcoming at D43-B.
|
||||||
|
|
||||||
## v0.1.1 — 2026-05-25
|
## v0.1.1 — 2026-05-25
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ release_kit:
|
|||||||
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently
|
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently
|
||||||
# violated (no version bump after many D-day pushes), check this section first
|
# violated (no version bump after many D-day pushes), check this section first
|
||||||
# before filing a compliance finding.
|
# before filing a compliance finding.
|
||||||
current_phase: Phase 1
|
current_phase: Phase 3
|
||||||
current_pre_release_identifier: "0.1.0-bootstrap"
|
current_pre_release_identifier: "0.3.0-phase3"
|
||||||
phase_close_trigger: explicit maintainer action (not automated)
|
phase_close_trigger: explicit maintainer action (not automated)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left.
|
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left.
|
||||||
|
|
||||||
> **Status:** v0.1 — bootstrap. Most of this README is a skeleton; sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
|
> **Status:** v0.2.0 shipped (2026-05-25) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI. Phase 3 (Dashboard + audit query layer) is the next milestone. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,9 +165,9 @@ If a fallback chain is exhausted, `X-OLP-Fallback-Exhausted` lists the tried pro
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation status (as of 2026-05-24)
|
## Implementation status (as of 2026-05-25, post-v0.2.0)
|
||||||
|
|
||||||
Phase 1 is in progress. This table reflects what is currently shipped vs. what is designed for later phases.
|
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 (Dashboard + audit query layer) is the next milestone. This table reflects what is currently shipped vs. what is designed for later phases.
|
||||||
|
|
||||||
| File / artifact | Status | Notes |
|
| File / artifact | Status | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -182,7 +182,9 @@ Phase 1 is in progress. This table reflects what is currently shipped vs. what i
|
|||||||
| 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` | 📋 Planned (Phase 2) | Multi-key auth, per-key namespacing, audit log |
|
| `lib/keys.mjs` | ✅ Phase 2 shipped (D44 + D45 + D46) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4 + `loadAuthConfigSync` for `auth.allow_anonymous` / `owner_only_endpoints` / `fallback_detail_header_policy`. Server wires `validateKey` per request, filters chain by `providers_enabled`, fires `touchLastUsed` post-response, trims `/health` payload for non-owner, gates `X-OLP-Fallback-Detail` emission by policy. |
|
||||||
|
| `bin/olp-keys.mjs` | ✅ Phase 2 shipped (D47) | Keygen CLI per ADR 0007 § 9.1. `npx olp-keys keygen --owner` generates an owner key + prints plaintext token once; `npx olp-keys list` enumerates keys (token_hash redacted); `npx olp-keys revoke --id=X` marks a key revoked. `--olp-home=<path>` overrides `~/.olp/`. |
|
||||||
|
| `lib/audit.mjs` | 🟡 Phase 2 — shipped at D45 | Append-only ndjson audit at `~/.olp/logs/audit.ndjson` per ADR 0007 § 6.2 + § 8. `appendAuditEvent` fires for every `/v1/*` request (success, 401, 403, 5xx). Warn + 1 retry on append failure; no memory buffer at Phase 2 (forward path). PII excluded (no message / response content). `OLP_HOME` env override supported for test/operator isolation. |
|
||||||
| `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 +198,29 @@ 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. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
|
- **Multi-key auth + owner gating + keygen CLI all shipped (D44 + D45 + D46 + D47); Phase 2 close pending.** `lib/keys.mjs` core shipped at D44; `server.mjs` invokes `validateKey` per request, filters the chain by `providers_enabled`, fires `touchLastUsed` post-response, and appends an audit row to `~/.olp/logs/audit.ndjson` for each `/v1/*` request (D45). At D46: `/health` payload trimmed for non-owner (returns `{ ok, version }` when `owner_only_endpoints` includes `/health`); `X-OLP-Fallback-Detail` emission gated by `fallback_detail_header_policy` (`owner_only` default suppresses for non-owner; `'all'` opts back into v0.1.1 behaviour; `'none'` suppresses entirely). At D47: keygen CLI shipped at `bin/olp-keys.mjs` — `npx olp-keys keygen --owner` produces an owner key (plaintext printed once); `npx olp-keys list` / `revoke` for lifecycle management. Phase 2 functional scope is complete; close to v0.2.0 is maintainer-triggered per CLAUDE.md `release_kit.phase_close_trigger`. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
|
||||||
|
|
||||||
|
**Bootstrap workflow (D47):** for first-run / production setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Generate an owner key (prints the plaintext token ONCE — capture it now)
|
||||||
|
npx olp-keys keygen --owner
|
||||||
|
|
||||||
|
# 2. Set production config (defaults to allow_anonymous: false)
|
||||||
|
# (Edit ~/.olp/config.json to enable providers + chains as usual)
|
||||||
|
|
||||||
|
# 3. Start the server
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 4. Validate the key works (substitute the captured plaintext token)
|
||||||
|
curl -H "Authorization: Bearer olp_..." http://localhost:3456/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recovery if owner token is lost:** `npx olp-keys keygen --owner --force` revokes the previous owner key + creates a fresh one (plaintext printed once).
|
||||||
|
|
||||||
|
**New env vars consumed at D45:** `OLP_HOME` (override `~/.olp/` location, used by tests + operator deployments); `OLP_OWNER_TOKEN` (synthetic env-owner identity for headless / CI deployments — bypasses filesystem manifest lookup with stable `__env_owner__` keyId).
|
||||||
|
|
||||||
|
**New config block consumed at D45:** `config.json auth.{ allow_anonymous, owner_only_endpoints, fallback_detail_header_policy }`. Default `allow_anonymous: false` (production-off); set true to accept requests without an OLP API key (development / single-user dev mode). Startup emits a warn when `allow_anonymous: true` so the relaxed posture is observable.
|
||||||
- **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).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -220,17 +244,16 @@ Read the ADRs in `docs/adr/` in order before proposing structural changes.
|
|||||||
|
|
||||||
OLP lands in phases. Each phase has its own PR series and Iron-Rule-10 reviewer; this README's placeholders are filled per-phase via the [`release_kit`](./CLAUDE.md) overlay.
|
OLP lands in phases. Each phase has its own PR series and Iron-Rule-10 reviewer; this README's placeholders are filled per-phase via the [`release_kit`](./CLAUDE.md) overlay.
|
||||||
|
|
||||||
- Phase 0 — Repo bootstrap, `ALIGNMENT.md`, founding ADRs, CI workflows, PR template. **(current)**
|
The original v0.1 spec (in `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations) planned one provider plugin per phase. The actual Phase 1 execution bundled the three Tier-D provider plugins + cache layer + fallback engine into a single shipped milestone (v0.1.0) followed by a cleanup batch (v0.1.1). The phase numbering below reflects what was actually shipped, not the original per-plugin partition.
|
||||||
- Phase 1 — `server.mjs` skeleton, IR, Anthropic plugin, cache D1+D4. Port from OCP.
|
|
||||||
- Phase 2 — OpenAI Codex plugin.
|
|
||||||
- Phase 3 — Mistral Vibe plugin.
|
|
||||||
- Phase 4 — Fallback engine + routing chains config + quota poll worker.
|
|
||||||
- Phase 5 — Cache cross-provider hardening (D2+D3).
|
|
||||||
- Phase 6 — Dashboard + observability (`/v0/management/quota`).
|
|
||||||
- Phase 7 — Release v0.1, OCP enters maintenance.
|
|
||||||
- Phase 8+ — Optional Grok / Kimi / tier-2 plugins; provider-native protocol endpoints; deterministic triggers.
|
|
||||||
|
|
||||||
Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations.
|
- **Phase 0** — Repo bootstrap, `ALIGNMENT.md`, founding ADRs, CI workflows, PR template. ✅ Shipped (2026-05-23).
|
||||||
|
- **Phase 1** — Multi-provider proxy core: `server.mjs`, IR, three Tier-D provider plugins (Anthropic / OpenAI Codex / Mistral Vibe), cache (D1+D4) + cleanup (D2 bypass / D3 chunked replay / D23 size cap), fallback engine with first-chunk safety + hard triggers + per-hop log observability, IR↔OpenAI translation under Rule 2(b). ✅ Shipped — v0.1.0 (2026-05-24) + v0.1.1 cleanup (2026-05-25, D35–D42).
|
||||||
|
- **Phase 2** — Multi-key auth (`lib/keys.mjs`) per ADR 0007: opaque OLP API keys, per-key cache namespacing, owner-vs-guest tier for header gating, audit ndjson (`lib/audit.mjs`), `/health` payload trimming + `X-OLP-Fallback-Detail` emission gating, `OLP_OWNER_TOKEN` env override, keygen CLI (`bin/olp-keys.mjs`). ✅ Shipped — v0.2.0 (2026-05-25, D43-A → D47). All 11 ADR 0007 § 10 acceptance criteria covered.
|
||||||
|
- **Phase 3** — Dashboard (`dashboard.html`) + audit query layer (deferred from Phase 2). Owner-only multi-provider quota panels, fallback rate, cache hit rate; localhost-bound by default. **(next)**.
|
||||||
|
- **Phase 4+** — v1.x roadmap items as triggered. Full deferred-work tracker: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md). Includes streaming-path singleflight ([issue #16](https://github.com/dtzp555-max/olp/issues/16) + ADR 0005 Amendment 8 design ratified), soft-trigger reactivation (ADR 0004 Amendment 2), `/health` activeSpawns integration, provider-level `cacheKeyFields` mask, streaming-path SPAWN_FAILED salvage.
|
||||||
|
- **Phase N (opt-in)** — Tier-2 / Tier-C provider plugins (Grok / Kimi / MiniMax / GLM / Qwen) per [ADR 0006](./docs/adr/0006-provider-inclusion.md); provider-native protocol endpoints; deterministic triggers. Triggered by tier-2 demand, not on the bootstrap path.
|
||||||
|
|
||||||
|
Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations. Phase 2 kickoff handoff: `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Executable
+269
@@ -0,0 +1,269 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* bin/olp-keys.mjs — OLP key management CLI (Phase 2 / D47)
|
||||||
|
*
|
||||||
|
* Authority: ADR 0007 § 9 (Bootstrap & recovery — minimal keygen command
|
||||||
|
* surface) + § 10 acceptance criterion #9 (bootstrap workflow must be
|
||||||
|
* reproducible without manual file editing).
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* keygen create a new OLP key; prints plaintext token to stdout ONCE
|
||||||
|
* list list all keys (manifests with token_hash redacted)
|
||||||
|
* revoke mark a key as revoked (idempotent; manifest stays for audit)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* olp-keys keygen --owner [--name=<label>] [--providers=anthropic,openai,...]
|
||||||
|
* olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=...]
|
||||||
|
* olp-keys keygen --owner --force (revokes existing owner keys; new owner)
|
||||||
|
* olp-keys list [--owner-only] [--include-revoked]
|
||||||
|
* olp-keys revoke --id=<key-id>
|
||||||
|
*
|
||||||
|
* Flags applicable to all subcommands:
|
||||||
|
* --olp-home=<path> override ~/.olp (defaults to OLP_HOME env or ~/.olp)
|
||||||
|
* --help print usage and exit 0
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 = success
|
||||||
|
* 1 = bad usage (missing args, unknown subcommand)
|
||||||
|
* 2 = operational failure (key not found, manifest invalid, FS error)
|
||||||
|
*
|
||||||
|
* The plaintext token from `keygen` is printed exactly once to stdout. It is
|
||||||
|
* never written to manifest, audit, or any log line. Operators must capture
|
||||||
|
* it immediately; lost → revoke + regenerate. Per ADR 0007 § 5 + § 9.1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createKey,
|
||||||
|
listKeys,
|
||||||
|
revokeKey,
|
||||||
|
readManifest,
|
||||||
|
} from '../lib/keys.mjs';
|
||||||
|
|
||||||
|
// ── Arg parsing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal flag parser. Supports:
|
||||||
|
* --flag=value → { flag: 'value' }
|
||||||
|
* --flag value → { flag: 'value' } (if next arg doesn't start with --)
|
||||||
|
* --flag → { flag: true }
|
||||||
|
* Returns { positional: string[], flags: Record<string, string|true> }.
|
||||||
|
*/
|
||||||
|
export function parseArgv(argv) {
|
||||||
|
const positional = [];
|
||||||
|
const flags = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg.startsWith('--')) {
|
||||||
|
const eq = arg.indexOf('=');
|
||||||
|
if (eq > 0) {
|
||||||
|
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
||||||
|
} else {
|
||||||
|
const name = arg.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next !== undefined && !next.startsWith('--')) {
|
||||||
|
flags[name] = next;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
flags[name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
positional.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { positional, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `OLP key management CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
|
||||||
|
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
|
||||||
|
olp-keys list [--owner-only] [--include-revoked]
|
||||||
|
olp-keys revoke --id=<key-id>
|
||||||
|
|
||||||
|
Common flags:
|
||||||
|
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
|
||||||
|
--help Print this message
|
||||||
|
|
||||||
|
Authority: ADR 0007 § 9 (bootstrap & recovery).`;
|
||||||
|
|
||||||
|
// ── Subcommand implementations ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function cmdKeygen(flags, ioOut, ioErr) {
|
||||||
|
const olpHome = flags['olp-home'];
|
||||||
|
const owner = flags.owner === true;
|
||||||
|
const force = flags.force === true;
|
||||||
|
let tier = flags.tier;
|
||||||
|
if (owner) tier = 'owner';
|
||||||
|
if (!tier) tier = 'guest';
|
||||||
|
if (tier !== 'owner' && tier !== 'guest') {
|
||||||
|
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const name = flags.name || (owner ? 'owner' : null);
|
||||||
|
if (!name) {
|
||||||
|
ioErr('Error: --name is required (or use --owner to default to "owner").\n');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const providersFlag = flags.providers;
|
||||||
|
let providers_enabled;
|
||||||
|
if (providersFlag === undefined || providersFlag === true) {
|
||||||
|
providers_enabled = '*';
|
||||||
|
} else if (typeof providersFlag === 'string') {
|
||||||
|
providers_enabled = providersFlag.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (providers_enabled.length === 0) providers_enabled = '*';
|
||||||
|
} else {
|
||||||
|
providers_enabled = '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --force: revoke any existing owner keys before creating the new one.
|
||||||
|
// revokeKey is async (acquires per-key write lock); await each so the new
|
||||||
|
// owner key's createKey doesn't race the revoke writes.
|
||||||
|
if (force && tier === 'owner') {
|
||||||
|
const existing = listKeys({ olpHome });
|
||||||
|
for (const m of existing) {
|
||||||
|
if (m.owner_tier === 'owner' && m.revoked_at === null) {
|
||||||
|
try {
|
||||||
|
await revokeKey({ id: m.id, olpHome });
|
||||||
|
ioErr(`Revoked existing owner key id=${m.id} name="${m.name}" (--force).\n`);
|
||||||
|
} catch (err) {
|
||||||
|
ioErr(`Warning: failed to revoke existing owner key id=${m.id}: ${err?.message ?? err}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome });
|
||||||
|
} catch (err) {
|
||||||
|
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plaintext token — printed ONCE per ADR § 5 + § 9.1.
|
||||||
|
ioOut(`\n OLP key created — capture the plaintext token NOW; it will not be shown again.\n\n`);
|
||||||
|
ioOut(` id: ${result.id}\n`);
|
||||||
|
ioOut(` name: ${result.manifest.name}\n`);
|
||||||
|
ioOut(` owner_tier: ${result.manifest.owner_tier}\n`);
|
||||||
|
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
|
||||||
|
ioOut(` created_at: ${result.manifest.created_at}\n`);
|
||||||
|
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
|
||||||
|
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
|
||||||
|
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
|
||||||
|
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdList(flags, ioOut, ioErr) {
|
||||||
|
const olpHome = flags['olp-home'];
|
||||||
|
const ownerOnly = flags['owner-only'] === true;
|
||||||
|
const includeRevoked = flags['include-revoked'] === true;
|
||||||
|
let keys = listKeys({ olpHome });
|
||||||
|
if (ownerOnly) keys = keys.filter(k => k.owner_tier === 'owner');
|
||||||
|
if (!includeRevoked) keys = keys.filter(k => k.revoked_at === null);
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
ioOut('No keys.\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ioOut(`\n ${keys.length} key${keys.length === 1 ? '' : 's'}:\n\n`);
|
||||||
|
for (const k of keys) {
|
||||||
|
const providers = typeof k.providers_enabled === 'string'
|
||||||
|
? k.providers_enabled
|
||||||
|
: `[${k.providers_enabled.join(', ')}]`;
|
||||||
|
const status = k.revoked_at === null ? 'active' : `revoked (${k.revoked_at})`;
|
||||||
|
const lastUsed = k.last_used_at ?? 'never';
|
||||||
|
ioOut(` id=${k.id}\n`);
|
||||||
|
ioOut(` name: ${k.name}\n`);
|
||||||
|
ioOut(` owner_tier: ${k.owner_tier}\n`);
|
||||||
|
ioOut(` providers: ${providers}\n`);
|
||||||
|
ioOut(` status: ${status}\n`);
|
||||||
|
ioOut(` created: ${k.created_at}\n`);
|
||||||
|
ioOut(` last_used: ${lastUsed}\n`);
|
||||||
|
if (k.notes) ioOut(` notes: ${k.notes}\n`);
|
||||||
|
ioOut('\n');
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdRevoke(flags, ioOut, ioErr) {
|
||||||
|
const olpHome = flags['olp-home'];
|
||||||
|
const id = typeof flags.id === 'string' ? flags.id : null;
|
||||||
|
if (!id) {
|
||||||
|
ioErr('Error: --id=<key-id> is required.\n');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the key exists before attempting revoke (clearer error path).
|
||||||
|
const m = readManifest(id, { olpHome });
|
||||||
|
if (m === null) {
|
||||||
|
ioErr(`Error: no key with id="${id}".\n`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (m.revoked_at !== null) {
|
||||||
|
ioOut(`Key id=${id} already revoked at ${m.revoked_at} (no-op).\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await revokeKey({ id, olpHome });
|
||||||
|
} catch (err) {
|
||||||
|
ioErr(`Error: revokeKey failed: ${err?.message ?? err}\n`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ioOut(`Revoked key id=${id} name="${m.name}".\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI entry ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the CLI with explicit argv + IO streams. Returns the intended exit code.
|
||||||
|
* Exported for tests (no process.exit, no direct stdout/stderr).
|
||||||
|
*
|
||||||
|
* @param {string[]} argv - args AFTER the subcommand name (e.g., ['keygen', '--owner']).
|
||||||
|
* The first element is the subcommand.
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {(s: string) => void} [opts.out] - stdout writer; defaults to process.stdout.write
|
||||||
|
* @param {(s: string) => void} [opts.err] - stderr writer; defaults to process.stderr.write
|
||||||
|
* @returns {Promise<number>} exit code 0 / 1 / 2
|
||||||
|
*/
|
||||||
|
export async function runCli(argv, opts = {}) {
|
||||||
|
const ioOut = opts.out ?? (s => process.stdout.write(s));
|
||||||
|
const ioErr = opts.err ?? (s => process.stderr.write(s));
|
||||||
|
|
||||||
|
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
||||||
|
ioOut(USAGE + '\n');
|
||||||
|
return argv.length === 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [subcommand, ...rest] = argv;
|
||||||
|
const { flags } = parseArgv(rest);
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'keygen': return await cmdKeygen(flags, ioOut, ioErr);
|
||||||
|
case 'list': return cmdList(flags, ioOut, ioErr);
|
||||||
|
case 'revoke': return await cmdRevoke(flags, ioOut, ioErr);
|
||||||
|
default:
|
||||||
|
ioErr(`Error: unknown subcommand "${subcommand}".\n${USAGE}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main guard: only run when invoked as the entrypoint. ESM equivalent of
|
||||||
|
// `require.main === module` is comparing import.meta.url against argv[1].
|
||||||
|
const isMain = (() => {
|
||||||
|
try {
|
||||||
|
return import.meta.url === `file://${process.argv[1]}`;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (isMain) {
|
||||||
|
runCli(process.argv.slice(2)).then(code => process.exit(code));
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# ADR 0007 — Multi-Key Auth (`lib/keys.mjs`)
|
||||||
|
|
||||||
|
- **Date:** 2026-05-25
|
||||||
|
- **Status:** Accepted (D43-B, design-only — implementation D-days D44+ follow)
|
||||||
|
- **Authors:** project maintainer (with AI drafting assistance)
|
||||||
|
- **Related:**
|
||||||
|
- OLP v0.1 spec § 4.5 (Auth & multi-key) — the planning authority for the `~/.olp/` layout used in § 3 below
|
||||||
|
- ADR 0001 (project founding) — single-tenant family-scale framing; this ADR keeps that framing while enabling multi-identity isolation within a single deployment
|
||||||
|
- ADR 0002 (plugin architecture) — `hints.cacheable` opt-out demonstrates the per-plugin gating pattern this ADR extends to per-key gating
|
||||||
|
- ADR 0004 Amendment 5 (D40 `X-OLP-Fallback-Detail`) — explicitly defers owner-only header gating to "Phase 2 when `lib/keys.mjs` lands"; this ADR is that landing event
|
||||||
|
- ADR 0005 (cache cross-provider) — D1 per-key isolation: the cache layer is already keyed by `keyId` (`Map<keyId, Map<cacheKey, CacheEntry>>` at `lib/cache/store.mjs:77-79`); this ADR fills the `keyId` source that is hardcoded to `'__anonymous__'` in `server.mjs` at v0.1.1
|
||||||
|
- **Prior-art authority:** OCP `keys.mjs` (at the maintainer workstation `~/ocp/keys.mjs`, OCP v3.13.0 production reference) — model adapted; storage strategy diverges per § 11 below
|
||||||
|
- **Phase 2 provenance:** `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` (committed in `cc-rules` `d9da966`) captures the catch-up brief, lane separation, and the four amendments the maintainer pinned during D43-B drafting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
OLP v0.1.1 ships with the cache namespace hardcoded to `'__anonymous__'` (server.mjs ~L502 buffered handler, ~L531 streaming handler). The cache data model in `lib/cache/store.mjs` is already segmented by `keyId` (per-key Map + per-key stats + per-key singleflight key composition), but the identity layer that produces a real `keyId` does not exist yet.
|
||||||
|
|
||||||
|
Phase 2 of OLP introduces multi-key authentication so a single OLP deployment can serve multiple human users (e.g., maintainer + family members + a CI client) with:
|
||||||
|
|
||||||
|
- **Per-key cache namespace isolation** — already wired in `store.mjs`; only the `keyId` source needs to land.
|
||||||
|
- **Per-key audit trail** — which key issued which request, what provider/model served it, what fallback / cache outcome resulted.
|
||||||
|
- **Per-key provider access scoping** — each key declares which providers it may invoke (`providers_enabled`).
|
||||||
|
- **Owner-vs-guest gating** for debug/observability surfaces that should not leak to non-owner identities, specifically:
|
||||||
|
- `/health` — currently returns full per-provider details to any caller. README has long claimed `/health` is owner-only (README.md § API Endpoints), but no auth gate has shipped. Phase 2 closes that gap.
|
||||||
|
- `X-OLP-Fallback-Detail` — D40 / ADR 0004 Amendment 5 explicitly shipped the header **ungated** at v0.1 with the note "Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands."
|
||||||
|
|
||||||
|
OCP solved an adjacent problem with `keys.mjs` (~417 LOC, SQLite-backed, single-tenant LAN mode). OLP cannot port that code verbatim — see § 11 (Node runtime baseline) — but the model (opaque key + per-key namespace + per-key audit + per-key quota) is the prior art this ADR adapts.
|
||||||
|
|
||||||
|
**Phase 2 is not a v1.x cross-phase deliverable.** `docs/v1x-roadmap.md` lists seven deferred items; only **#2 (multi-key auth)** is the Phase 2 mainline. The others (#1 streaming SF, #3 soft triggers, #4 `/health` `activeSpawns`, etc.) are triggered on demand and stay on the v1.x tracker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
OLP Phase 2 ships **filesystem-only multi-key auth with opaque tokens**, structured for migratability to a SQLite-indexed model when Phase 3+ Dashboard / SQL-aggregate quota work justifies that change.
|
||||||
|
|
||||||
|
Three load-bearing choices:
|
||||||
|
|
||||||
|
| Axis | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| **Storage** | Filesystem manifest per key (`~/.olp/keys/<key-id>/manifest.json`) + append-only audit ndjson (`~/.olp/logs/audit.ndjson`) | Matches v0.1 spec § 4.5 layout; zero-dep within current Node baseline (§ 11); trivially backed-up / human-inspectable / git-crypt-encryptable; per-key isolation natural via filesystem hierarchy |
|
||||||
|
| **Token format** | Opaque `olp_<32-byte base64url>`; manifest stores SHA-256 hash, never plaintext | Mirrors OCP's `ocp_<24-byte>` opaque pattern; revocation is single-record (no JWT revocation-list problem); validation is single manifest read; family-scale has no stateless-validation pressure |
|
||||||
|
| **Migration lane** | Manifest remains the declarative SPOT in all future revisions; SQLite (if added in Phase 3+) becomes a query-side index synced on every manifest/audit write | Forward path documented in § 13 — never a single-direction door; manifest schema is always source of truth |
|
||||||
|
|
||||||
|
The decision **rejects** three plausible alternatives:
|
||||||
|
|
||||||
|
- **Option 1 (direct SQLite port from OCP)** — rejected at v0.2.0 because of a runtime baseline mismatch documented in § 11, not because of any flaw in SQLite or in OCP's design.
|
||||||
|
- **JWT tokens** — rejected because OLP has no stateless-validation pressure (the deployment is a single Node process; one manifest read per request is cheaper than the JWT-issuance / rotation / revocation-list infrastructure).
|
||||||
|
- **Auto-detected "dev mode" anonymous fallback** — rejected because behavioural divergence based on heuristics (NODE_ENV, hostname, port, etc.) creates security-incident-prone surprises. Anonymous access is an explicit configuration toggle (§ 7) or it does not happen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Storage layout (`~/.olp/`)
|
||||||
|
|
||||||
|
The layout below is normative for v0.2.0. Each path is binary in spec — present-and-honored or absent-and-defaulted. No path may be silently created with a different name.
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.olp/
|
||||||
|
config.json — top-level config (existing); gains `auth` block per § 7
|
||||||
|
keys/ — chmod 0700; per-key SPOT
|
||||||
|
<key-id>/
|
||||||
|
manifest.json — chmod 0600; JSON; schema in § 4
|
||||||
|
logs/ — chmod 0700
|
||||||
|
audit.ndjson — chmod 0600; one JSON event per line; schema in § 8
|
||||||
|
providers/ — existing; per-provider auth artifact root
|
||||||
|
anthropic/credentials.json
|
||||||
|
openai/codex_token.json
|
||||||
|
mistral/api_key.env
|
||||||
|
cache/ — file-backed cache (📋 v1.x); chmod 0700 when introduced
|
||||||
|
```
|
||||||
|
|
||||||
|
**`<key-id>` format.** Lowercase alphanumeric + hyphen + underscore, 8–32 chars, generated by the keygen command. NOT derived from the secret token — `<key-id>` is the public namespace identifier (cache key prefix, audit `key_id` field, log correlator); the secret token is separate.
|
||||||
|
|
||||||
|
**Why a directory per key (vs one `keys.json` index file).** Future per-key augmentation (per-key cache index, per-key provider-specific auth override, per-key rate-limit state) can land as additional files in `keys/<key-id>/` without re-writing a shared index. The directory is the namespace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Manifest schema (`keys/<key-id>/manifest.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "<key-id>",
|
||||||
|
"name": "<human-label>",
|
||||||
|
"token_hash": "<sha256-hex of the opaque token>",
|
||||||
|
"token_hash_algo": "sha256",
|
||||||
|
"owner_tier": "owner" | "guest",
|
||||||
|
"providers_enabled": ["<provider-key>", ...] | "*",
|
||||||
|
"quota": null,
|
||||||
|
"created_at": "<ISO-8601 UTC>",
|
||||||
|
"revoked_at": null | "<ISO-8601 UTC>",
|
||||||
|
"last_used_at": null | "<ISO-8601 UTC>",
|
||||||
|
"notes": "<optional free-form>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field semantics:
|
||||||
|
|
||||||
|
- **`schema_version`** — `1` at v0.2.0. Increment on any non-additive change to this schema. Implementation reads `schema_version` first; rejects unrecognized versions with a clear error.
|
||||||
|
- **`id`** — matches the parent directory name. If they disagree, validation fails (`manifest_id_mismatch`).
|
||||||
|
- **`name`** — human label for `olp keys list` output. Required, non-empty.
|
||||||
|
- **`token_hash`** — SHA-256 of the plaintext token (lowercase hex). The plaintext token is NEVER stored.
|
||||||
|
- **`token_hash_algo`** — `"sha256"` at v0.2.0. Schema-versioned forward-compat for future algorithm rotation.
|
||||||
|
- **`owner_tier`** — `"owner"` grants full /health + X-OLP-Fallback-Detail visibility; `"guest"` does not (§ 7).
|
||||||
|
- **`providers_enabled`** — array of provider keys (matching `models-registry.json` provider entries) OR literal `"*"` for all providers. Empty array `[]` means the key can authenticate but cannot dispatch any provider call (returns 403 with `key_no_provider_access`).
|
||||||
|
- **`quota`** — `null` at Phase 2 (no enforcement). Reserved for Phase 3+ quota work. Implementations MUST read `null` as "no quota enforcement"; non-null shapes are deferred to a Phase 3 ADR amendment.
|
||||||
|
- **`created_at`** — set at key creation; never modified.
|
||||||
|
- **`revoked_at`** — `null` while active; set to current timestamp on revocation. Revoked keys fail validation with `401 key_revoked`; their manifest stays on disk for audit attribution.
|
||||||
|
- **`last_used_at`** — updated on successful validation. Best-effort (lazy write OK; failure to update does NOT fail the request — § 6).
|
||||||
|
- **`notes`** — optional; useful for "spouse's laptop", "Pi staging", etc.
|
||||||
|
|
||||||
|
**Schema rigidity.** Unrecognized fields cause a warn but not a reject (forward-compat). Missing required fields cause a reject (`manifest_invalid`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Token format
|
||||||
|
|
||||||
|
```
|
||||||
|
olp_<32 bytes from crypto.randomBytes, base64url-encoded, no padding>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prefix `olp_` is fixed (mirrors OCP's `ocp_`); enables grep / regex detection in logs / secret-scanners.
|
||||||
|
- 32 bytes = 256 bits of entropy. base64url-encoded = 43 characters; total token length = 47 characters including prefix.
|
||||||
|
- Hash with `crypto.createHash('sha256')` over the full token string (prefix included). Hex-lowercase the digest for `manifest.token_hash`.
|
||||||
|
|
||||||
|
**Why SHA-256, not argon2/bcrypt.** Argon2-class slow hashes are for low-entropy secrets (passwords). A 256-bit random token has no brute-force exposure in the relevant attack-cost model; SHA-256 is sufficient and ~6 orders of magnitude faster, which matters because validation runs on every request.
|
||||||
|
|
||||||
|
**No plaintext storage, ever.** The plaintext token leaves the keygen command (printed to stdout once) and the authenticated request (HTTP header). It is never logged, never written to manifest, never written to audit. The only persistent representation is the hash in `manifest.token_hash`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Atomic write & audit append
|
||||||
|
|
||||||
|
Two distinct write surfaces with different semantics:
|
||||||
|
|
||||||
|
### 6.1 Manifest writes (lifecycle events only)
|
||||||
|
|
||||||
|
Manifest writes fire ONLY on key lifecycle events: `createKey`, `revokeKey`, `updateKey` (e.g., setting `providers_enabled`), and `touchLastUsed` (the lazy `last_used_at` update). Manifest is **not** written per request — per-request state goes to audit.
|
||||||
|
|
||||||
|
Atomic write pattern (POSIX):
|
||||||
|
|
||||||
|
1. Compute target path: `~/.olp/keys/<key-id>/manifest.json`.
|
||||||
|
2. Write to tmpfile in same directory: `~/.olp/keys/<key-id>/manifest.json.tmp.<pid>.<counter>`.
|
||||||
|
3. `fsync()` the tmpfile fd.
|
||||||
|
4. `rename()` tmpfile → final path (same-filesystem atomic).
|
||||||
|
5. Directory mode 0700, file mode 0600 enforced on every write.
|
||||||
|
|
||||||
|
POSIX-strict atomic-replace also requires `fsync()` on the containing directory after the rename to guarantee survival of an OS crash mid-flush. Phase 2 deliberately omits the directory fsync: the single-process family-scale deployment model accepts a tiny window where a rename can be lost under abrupt host crash. The trade-off is documented here so a future POSIX-strict deployment knows where to add the step.
|
||||||
|
|
||||||
|
Failure semantics:
|
||||||
|
- Step 2/3/4 failure → throw; caller handles. Lifecycle commands (`olp keygen` / `olp keys revoke`) report failure to the operator and exit non-zero. Server requests do not trigger lifecycle writes (the `touchLastUsed` path is best-effort — see 6.3).
|
||||||
|
|
||||||
|
### 6.2 Audit ndjson appends (per-request)
|
||||||
|
|
||||||
|
Per-request audit events append a single newline-terminated JSON object to `~/.olp/logs/audit.ndjson`.
|
||||||
|
|
||||||
|
Append pattern:
|
||||||
|
|
||||||
|
1. Serialize event (§ 8 schema) with trailing `\n`. Serialization fires AFTER `status_code` is determined and `latency_ms` is measured (i.e., after the request handler emits the response, around `res.end()` finalization). This pinning is what makes acceptance criterion #2 testable — the 401-on-anonymous case records `status_code: 401` + `latency_ms` in the same audit event.
|
||||||
|
2. `fs.appendFile(path, line, { mode: 0o600 })` (Node default opens with append flag).
|
||||||
|
3. On EAGAIN / EBUSY / ENOSPC: log warn `audit_append_failed_once` + retry once (synchronous, no backoff at Phase 2 — family-scale write rate makes contention rare).
|
||||||
|
4. On second-failure: log warn `audit_append_dropped` with the failure reason + a per-process drop counter; **do not block the request**; **do not buffer** (memory buffer is a forward path in § 13, deliberately not in Phase 2 scope).
|
||||||
|
|
||||||
|
Failure semantics:
|
||||||
|
- Audit append failure NEVER fails the request. Auditing is observability, not authorization.
|
||||||
|
- Dropped audit events surface via the warn log and the dropped-count metric (exposed in /health owner-tier view per § 7).
|
||||||
|
|
||||||
|
### 6.3 `last_used_at` lazy update (revoke-dominates-touch)
|
||||||
|
|
||||||
|
The `touchLastUsed` write goes through the same atomic-write pattern as 6.1, but is fired async after request response is dispatched. Failure logs warn `last_used_update_failed` and does NOT fail the request.
|
||||||
|
|
||||||
|
**Read-modify-write with revoke preservation.** `touchLastUsed` MUST:
|
||||||
|
|
||||||
|
1. Re-read the latest manifest from disk inside the per-key write-lock (§6.4) — not reuse the snapshot the validating request held.
|
||||||
|
2. If `revoked_at` is non-null in the freshly-read manifest, NO-OP (do not write). A revocation occurred between request validation and this lazy touch; the request was the last legitimate use of the now-revoked key.
|
||||||
|
3. Otherwise, merge the new `last_used_at` value into the freshly-read manifest, preserving ALL other fields including `revoked_at`, and write via the atomic-rename pattern.
|
||||||
|
|
||||||
|
This protects against the failure mode where a stale manifest snapshot held by the touch path overwrites a fresh revoke and silently clears `revoked_at` back to `null`. The safety property is **revoke dominates touch**: any ordering of CLI revoke and server-side `touchLastUsed` (revoke-then-touch, touch-then-revoke, or interleaved) leaves a revoked manifest. This is the contract that makes acceptance criterion #6 (post-revoke 401 within the next request) honest under concurrent CLI revoke + in-flight server request.
|
||||||
|
|
||||||
|
### 6.3.5 No in-process validation cache (Phase 2)
|
||||||
|
|
||||||
|
Token validation MUST hit the manifest on every authenticated request at Phase 2 — implementations MUST NOT introduce an in-process LRU / TTL cache of validation results. Rationale: revocation must take effect on the next request without an invalidation hop; the family-scale request rate makes per-request manifest read O(1) on the OS file-system cache. This is the contract that makes acceptance criterion #6 (post-revoke 401 within the next request) honest. A validation cache is a forward-path consideration if Phase 3+ load profile demands it; a separate ADR amendment ratifies the cache shape + invalidation contract before any cache code lands.
|
||||||
|
|
||||||
|
### 6.4 Locking (single-process Phase 2)
|
||||||
|
|
||||||
|
OLP at v0.2.0 is a single Node process per host. Concurrent manifest writes from inside the process are serialized via an in-process Map of per-key write-locks (`Map<key-id, Promise>`). Concurrent writes from outside the process (e.g., maintainer running `olp keys revoke` while server is running) are not protected by file locks at Phase 2.
|
||||||
|
|
||||||
|
Safety frame: the atomic-rename pattern guarantees corruption-free file content (no partial-merge state on disk), and the **read-before-write discipline in §6.3** makes the worst case "stale `last_used_at` field" (observability-grade) rather than "revoked_at silently cleared" (security-grade). Without §6.3, a touch carrying a pre-revoke snapshot could overwrite revoke and break acceptance criterion #6; with §6.3, any interleaving of revoke and touch leaves a revoked manifest. The CLI `revoke` writer always wins the dimension that matters; the touch writer may lose its `last_used_at` update if it raced a revoke (acceptable — the revoked key will not be used again).
|
||||||
|
|
||||||
|
Forward path: file-locking (`flock(2)`) is reserved if a future Phase introduces multi-writer scenarios (e.g., a setup wizard process running alongside the server). With multi-writer, §6.3's read-before-write still holds the revoke-dominates-touch contract; file-locking only adds defense-in-depth against rare time-of-check-time-of-use windows where two processes both re-read a non-revoked manifest, then both write back with the touch path silently dropping a concurrent in-flight revoke from a third writer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Owner / guest / anonymous gating
|
||||||
|
|
||||||
|
### 7.1 The three identity classes
|
||||||
|
|
||||||
|
| Class | Source | Cache namespace | `/health` visibility | `X-OLP-Fallback-Detail` visibility | `/v1/chat/completions` |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **owner** | Valid OLP key with `owner_tier: "owner"` | per-key (`<key-id>`) | full per-provider details | yes (header emitted) | yes |
|
||||||
|
| **guest** | Valid OLP key with `owner_tier: "guest"` | per-key (`<key-id>`) | trimmed (`{ status, version }` only) | no (header suppressed) | yes (scoped by `providers_enabled`) |
|
||||||
|
| **anonymous** | No `Authorization` / `x-api-key` header, AND `config.json auth.allow_anonymous: true` | `__anonymous__` (shared) | trimmed (`{ status, version }` only) | no (header suppressed) | yes |
|
||||||
|
|
||||||
|
When `auth.allow_anonymous: false` (default) and no key is presented, all routes return `401 auth_required`.
|
||||||
|
|
||||||
|
### 7.2 Configuration
|
||||||
|
|
||||||
|
`~/.olp/config.json` gains an `auth` block:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"allow_anonymous": false,
|
||||||
|
"owner_only_endpoints": ["/health", "/v0/management/quota"],
|
||||||
|
"fallback_detail_header_policy": "owner_only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`allow_anonymous`** — default `false`. When `true`, requests without a key are accepted and namespaced under the legacy `__anonymous__` cache keyId.
|
||||||
|
- **`owner_only_endpoints`** — list of HTTP paths returning trimmed payloads to non-owner identities. `/health` is the canonical example.
|
||||||
|
- **`fallback_detail_header_policy`** — `"owner_only"` (default) emits `X-OLP-Fallback-Detail` only to owner tier. `"all"` reverts to v0.1.1 ungated behaviour. `"none"` suppresses unconditionally. The policy is the v0.1.1 → v0.2.0 migration knob for operators who want to delay re-gating.
|
||||||
|
|
||||||
|
### 7.3 Environment-based behaviour is rejected
|
||||||
|
|
||||||
|
Phase 2 deliberately does NOT auto-detect "dev" vs "production" via `NODE_ENV`, `hostname`, port, or any other heuristic. The rule is: `config.json auth.allow_anonymous` is the truth, and the operator sets it explicitly. Behavioural divergence on environment heuristics is a known source of "it works locally" security incidents and is out of scope by design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Audit ndjson schema
|
||||||
|
|
||||||
|
One JSON object per line (newline-terminated, UTF-8), written by the per-request audit-append path (§ 6.2).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ts": "<ISO-8601 UTC>",
|
||||||
|
"key_id": "<key-id>" | "__anonymous__" | "__env_owner__",
|
||||||
|
"owner_tier": "owner" | "guest" | "anonymous",
|
||||||
|
"method": "POST" | "GET" | ...,
|
||||||
|
"path": "/v1/chat/completions" | "/v1/models" | ...,
|
||||||
|
"provider": "<provider-key>" | null,
|
||||||
|
"model": "<requested-model>" | null,
|
||||||
|
"status_code": 200 | 401 | 503 | ...,
|
||||||
|
"latency_ms": <int>,
|
||||||
|
"cache_status": "hit" | "miss" | "bypass" | null,
|
||||||
|
"fallback_hops": <int>,
|
||||||
|
"tried_providers": ["<provider-key>", ...],
|
||||||
|
"error_code": null | "<ProviderError code>",
|
||||||
|
"ir_request_hash": "<short hex>" | null,
|
||||||
|
"chain_id": "<correlator>" | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field origin:
|
||||||
|
|
||||||
|
- `ts` / `key_id` / `owner_tier` — set by the auth middleware.
|
||||||
|
- `method` / `path` / `status_code` / `latency_ms` — set by the request handler.
|
||||||
|
- `provider` / `model` / `cache_status` / `fallback_hops` / `tried_providers` / `error_code` — sourced from the existing D28 per-hop log fields (no new computation; same shapes the structured log already exposes).
|
||||||
|
- `ir_request_hash` / `chain_id` — sourced from D28 fields directly; enable join across audit, structured log, and the `X-OLP-Fallback-Detail` tuple.
|
||||||
|
|
||||||
|
**No PII.** Audit deliberately captures NO request body, NO response body, NO IR-message content. Hash + shape only. This is a personal/family deployment property; do not relax without a separate ADR amendment.
|
||||||
|
|
||||||
|
**Rotation.** Phase 2 does NOT rotate `audit.ndjson`. Rotation policy lands in Phase 3 alongside Dashboard / audit query work (§ 12).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Bootstrap & recovery
|
||||||
|
|
||||||
|
### 9.1 Minimal keygen command surface
|
||||||
|
|
||||||
|
Phase 2 MUST ship at least one executable entry that:
|
||||||
|
|
||||||
|
1. Generates an opaque OLP token (§ 5 format).
|
||||||
|
2. Computes its SHA-256 hash.
|
||||||
|
3. Writes a `keys/<key-id>/manifest.json` per § 4 with `owner_tier: "owner"` (first key) or as specified by flag.
|
||||||
|
4. Prints the plaintext token to stdout **exactly once**. The token is otherwise never logged.
|
||||||
|
5. Returns non-zero on any failure (manifest path conflict, filesystem permission, etc.).
|
||||||
|
|
||||||
|
The concrete shape — `npx olp keygen --owner`, `node bin/keygen.mjs --owner`, `node lib/keys/cli.mjs keygen --owner`, etc. — is an implementation choice and lands at D44 or D45. ADR 0007 does not pin the shape; it pins the requirement that the surface exists and is reproducible without manual file editing.
|
||||||
|
|
||||||
|
### 9.2 First-run flow
|
||||||
|
|
||||||
|
When `~/.olp/keys/` is empty AND `auth.allow_anonymous: false` (defaults), the server refuses to start `/v1/chat/completions` requests with a clear `401 no_keys_configured` until the operator runs the keygen command. The server itself does NOT auto-generate a key on first run — explicit operator action is required so the plaintext-once contract (§ 9.1 step 4) is honored on a terminal the operator can see.
|
||||||
|
|
||||||
|
When `~/.olp/keys/` is empty AND `auth.allow_anonymous: true`, the server starts normally and serves all requests under `__anonymous__`. Useful for dev / single-user-no-multi-tenancy deployments.
|
||||||
|
|
||||||
|
### 9.3 Owner key loss / rotation
|
||||||
|
|
||||||
|
If the operator loses their owner token, recovery is `<keygen-command> --owner --force`:
|
||||||
|
|
||||||
|
1. Generate a fresh owner key (new `<key-id>`, new plaintext).
|
||||||
|
2. Mark all existing `owner_tier: "owner"` keys' `revoked_at` to current timestamp. (Existing guest keys are not affected.)
|
||||||
|
3. Print the new plaintext once.
|
||||||
|
|
||||||
|
The old token is permanently invalid after revocation; the manifest stays on disk for audit attribution.
|
||||||
|
|
||||||
|
### 9.4 `OLP_OWNER_TOKEN` environment override
|
||||||
|
|
||||||
|
For headless / CI / containerized deployments, the env var `OLP_OWNER_TOKEN` is honored:
|
||||||
|
|
||||||
|
- Server startup reads `OLP_OWNER_TOKEN`. If set, the value is treated as a synthetic owner identity with stable `key_id: "__env_owner__"`.
|
||||||
|
- The plaintext token is NEVER logged, NEVER written to manifest, NEVER written to audit. The raw token leaves the env var and the request `Authorization` header only.
|
||||||
|
- Cache namespacing uses `__env_owner__` as the `keyId`, isolating env-owner traffic from filesystem-owner traffic.
|
||||||
|
- Audit attribution uses `key_id: "__env_owner__"` and `owner_tier: "owner"`.
|
||||||
|
- Server startup logs warn `non_persistent_owner_token` with no token material, alerting the operator that the env-owner identity will disappear on restart unless re-set.
|
||||||
|
|
||||||
|
Filesystem-stored owner keys (from § 9.1/9.2) continue to validate independently when `OLP_OWNER_TOKEN` is set; the env-owner is an additive credential, not a replacement.
|
||||||
|
|
||||||
|
**Token-collision policy.** Hash-collision between an `OLP_OWNER_TOKEN` plaintext and a filesystem-stored key's plaintext is undefined behaviour at Phase 2 (cache namespacing would diverge silently between `__env_owner__` and the filesystem `<key-id>`, while audit attribution would split). Operators MUST NOT reuse the same plaintext token across both surfaces. A future Phase MAY add a collision-detection startup check; not in Phase 2 scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Acceptance criteria
|
||||||
|
|
||||||
|
Implementation D-days (D44+) MUST land tests covering:
|
||||||
|
|
||||||
|
1. **Per-key cache isolation** — Two keys A and B with identical request payloads do NOT share cache. `cache_status` is `miss` for both first calls and `hit` for the second call from the SAME key only.
|
||||||
|
2. **Anonymous prod-default off** — With `auth.allow_anonymous: false` (no override), a request without a key receives `401 auth_required`; the audit event is recorded with `key_id: "__anonymous__"` and `status_code: 401`.
|
||||||
|
3. **Anonymous dev-mode on** — With `auth.allow_anonymous: true`, the same request succeeds with `keyId="__anonymous__"`.
|
||||||
|
4. **Owner-vs-guest /health gating (with default `auth.owner_only_endpoints` config)** — Owner key sees the full per-provider `providers` map in `/health`; guest key + anonymous see only `{ status, version }`. Test rephrases if the operator's `owner_only_endpoints` config does not include `/health` (test must assert the same gating predicate the config produces, not a hardcoded trimmed payload shape).
|
||||||
|
5. **Owner-vs-guest X-OLP-Fallback-Detail gating** — Same response payload for both owner and guest; header present for owner only.
|
||||||
|
6. **Key revocation** — After `revoke`, subsequent requests with that token return `401 key_revoked` within the next request (no caching of validation).
|
||||||
|
7. **Manifest atomicity + revoke-dominates-touch (§ 6.3, § 6.4)** — Concurrent `revoke` + `touchLastUsed` writes do not corrupt the manifest AND revoke always survives. Test: spawn two writers racing on the same key (revoke vs `touchLastUsed`) under three orderings — revoke-then-touch, touch-then-revoke, and interleaved (touch reads pre-revoke snapshot, then revoke writes, then touch attempts write). For all three orderings, assert: (a) final file parses as valid JSON; (b) `revoked_at` is non-null and equals the revoke writer's timestamp; (c) `last_used_at` may have either writer's value. The test FAILS if any interleaving produces `revoked_at: null` after the revoke writer completed. This pins the §6.3 read-before-write discipline.
|
||||||
|
8. **Audit ndjson round-trip** — Every line in `audit.ndjson` parses as valid JSON; every required field present; PII fields (message content, response content) absent.
|
||||||
|
9. **Bootstrap keygen surface** — The minimal keygen command (whatever shape D44 chooses) runs end-to-end without manual file editing, produces a working owner key, and prints the plaintext exactly once.
|
||||||
|
10. **`OLP_OWNER_TOKEN` env override** — With the env var set, a request bearing the env token validates as `keyId="__env_owner__"` with `owner_tier="owner"`; the raw token does NOT appear in any log line, audit event, or stack trace.
|
||||||
|
11. **`providers_enabled` scope enforcement** — A guest key with `providers_enabled: ["anthropic"]` requesting `model` that routes to `openai` receives `403 key_no_provider_access` and an audit event with the rejection reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Node baseline / storage portability
|
||||||
|
|
||||||
|
Option 2 (filesystem-only) was chosen at v0.2.0 over Option 1 (direct port of OCP's SQLite-backed `keys.mjs`) because of a runtime-baseline mismatch, not a critique of SQLite or of OCP's design.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- OLP `package.json` declares `engines.node` `">=18"` (file line 11).
|
||||||
|
- CI test matrix in `.github/workflows/test.yml` runs Node 20 and 24 (file line 13).
|
||||||
|
- `node:sqlite` was added in Node **v22.5.0**; v22.12 still required the `--experimental-sqlite` runtime flag to import; current Node API docs mark the module as **Release Candidate** (post-experimental but pre-stable). Source: https://nodejs.org/api/sqlite.html (retrieved during D43-B drafting 2026-05-25).
|
||||||
|
|
||||||
|
Adopting `node:sqlite` at v0.2.0 would require, in this order:
|
||||||
|
|
||||||
|
1. Raise `engines.node` to a version where the API is at minimum non-flag-gated. Per Node's release-history docs — v22.5.0 added the API behind `--experimental-sqlite` (source: https://nodejs.org/download/release/v22.12.0/docs/api/sqlite.html confirms v22.12 still required the flag); the module moved past flag-gating in **v22.13.0 (LTS line)** and **v23.4.0 (current line)**; the API entered **Release Candidate at v25.7.0** per current docs (https://nodejs.org/api/sqlite.html). The minimum non-flag-gated baseline for `engines.node` is therefore `>=22.13.0` (or `>=23.4.0` on the non-LTS path). A stable (post-RC) baseline is TBD pending future Node releases beyond v25.x.
|
||||||
|
2. Update the CI test matrix to drop Node 20 (or move the SQLite-using code behind a runtime feature check that exercises both code paths in CI).
|
||||||
|
3. Accept Release-Candidate API stability risk in the project's storage layer for the period until the API moves to stable.
|
||||||
|
|
||||||
|
These three are achievable but are not zero-cost and have second-order effects (e.g., existing Node 20 deployments by family clients break on upgrade). Phase 2 does not undertake them; § 13 documents the forward path.
|
||||||
|
|
||||||
|
**Decision posture statement.** "SQLite is good; the runtime baseline says not yet."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope (Phase 3+)
|
||||||
|
|
||||||
|
The following are deliberately deferred from Phase 2 and tracked elsewhere:
|
||||||
|
|
||||||
|
- **Dashboard (`dashboard.html`)** — owner-only multi-provider quota / fallback / cache-hit-rate panels. Deferred to **Phase 3**. (Was originally bundled into "Phase 6" in the pre-v0.1.1 README phase plan; the post-D43-A plan re-aligned this to Phase 3.)
|
||||||
|
- **Quota enforcement (`manifest.quota` non-null shapes)** — manifest schema reserves the field; semantics + enforcement land in a Phase 3 ADR amendment.
|
||||||
|
- **Audit query layer / rotation** — `audit.ndjson` is append-only at Phase 2; rotation policy + indexed query lands with Dashboard work (Phase 3).
|
||||||
|
- **Per-key per-provider auth artifact mapping** — Phase 2 uses the global `~/.olp/providers/<name>/` artifacts for all keys. Per-key override (e.g., two OLP keys each authenticated to a different OpenAI Codex account) is a Phase 3+ concern; the spec § 4.5 phrasing "Multi-key support per provider" anticipates this without locking the design.
|
||||||
|
- **Audit memory buffer on append failure** — see § 6.2 note; deliberate forward-path-only.
|
||||||
|
- **File-locking (`flock(2)`)** — see § 6.4 note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Future forward — Option 3 migration (Phase 3+)
|
||||||
|
|
||||||
|
When Dashboard / SQL-aggregate quota / >5 users / multi-second audit-query workload arrives, OLP's storage layer migrates to a **hybrid** model that retains manifest as the declarative SPOT and adds a SQLite-indexed query mirror.
|
||||||
|
|
||||||
|
Required preconditions BEFORE any migration commit:
|
||||||
|
|
||||||
|
1. A separate prior PR raises `engines.node` and updates the CI matrix per § 11. This PR ships independently of any storage change.
|
||||||
|
2. An ADR amendment to this file documents the migration trigger (which of the criteria above fired) and the schema mapping from manifest → SQLite rows.
|
||||||
|
3. The migration code is a one-shot sync that reads every existing manifest, replays the audit log, populates SQLite from scratch, then begins dual-writing. Manifest writes remain authoritative; SQLite is rebuildable from manifest + audit at any time.
|
||||||
|
|
||||||
|
The migration is one-way (additive — SQLite gets added; manifest stays). Reverting from hybrid to manifest-only is supported by stopping SQLite writes and deleting the DB file.
|
||||||
|
|
||||||
|
**Forward-path audit memory buffer.** If audit append failures become non-rare (operational hint: `audit_append_dropped` count exceeds threshold in /health), Phase 3+ may add an in-process bounded buffer that flushes opportunistically. The buffer's design (size cap, flush interval, persistence on shutdown) is out of scope for Phase 2 and is a separate ADR amendment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- Closes the long-standing `lib/keys.mjs` 📋-Planned gap in AGENTS.md / README.md / v1x-roadmap.md.
|
||||||
|
- Lets D40 `X-OLP-Fallback-Detail` re-gate per its v0.1 deferral note.
|
||||||
|
- Lets README's long-standing claim "/health is owner-only" become factually true.
|
||||||
|
- Per-key cache namespacing becomes observable behaviour (was a latent affordance only).
|
||||||
|
- Family members can each have their own OLP key without sharing cache state.
|
||||||
|
- Audit trail per request enables troubleshooting questions ("did my call hit cache?", "which key triggered the fallback to mistral?") without inspecting logs.
|
||||||
|
|
||||||
|
**Negative / trade-offs:**
|
||||||
|
|
||||||
|
- Filesystem audit is O(N) for any aggregate query — acceptable until Phase 3 Dashboard work.
|
||||||
|
- Manifest atomicity at multi-writer scale is not bulletproof — see § 6.4; mitigated by the single-process Phase 2 deployment model.
|
||||||
|
- The plaintext-once contract puts UX burden on the keygen command output — operators must capture the token immediately on creation; lost = revoke + regenerate.
|
||||||
|
- Existing OCP users migrating will need new OLP keys (OCP's SQLite-backed keys are not portable to OLP's manifest layout — § 9 "Migration from OCP" in `scripts/migrate-from-ocp.mjs` 📋 Phase 7 may add a one-shot translator; not in Phase 2 scope).
|
||||||
|
|
||||||
|
**Reversibility:**
|
||||||
|
|
||||||
|
- Migration to Option 3 hybrid (§ 13) is supported and explicitly planned.
|
||||||
|
- Reverting Phase 2 entirely would require restoring the `__anonymous__` hardcoding in `server.mjs` and removing the auth middleware. The decision is reversible but no concrete trigger has been imagined; the decision is treated as durable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authority citations
|
||||||
|
|
||||||
|
- **OLP v0.1 spec § 4.5** (planning authority for `~/.olp/` layout in § 3) — at `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations.
|
||||||
|
- **OCP `keys.mjs`** (prior-art reference for opaque-key + per-key isolation model) — at `~/ocp/keys.mjs` on the maintainer's workstation; OCP v3.13.0 production.
|
||||||
|
- **Phase 2 kickoff handoff** (decision provenance for Option 2 + opaque + four amendments) — `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` committed in `cc-rules` `d9da966`.
|
||||||
|
- **Node `node:sqlite` documentation** (rejection rationale for Option 1 in § 11) — https://nodejs.org/api/sqlite.html (retrieved 2026-05-25).
|
||||||
|
- **`lib/cache/store.mjs:77-79, :287`** (proof that per-keyId namespace + singleflight composition are wired and ready to receive a real `keyId`).
|
||||||
|
- **`server.mjs:502, :531`** (the two hardcoded `'__anonymous__'` call sites Phase 2 implementation replaces).
|
||||||
|
- **`server.mjs:392, :1072, :1101`** (the three call sites Phase 2 implementation gates: `/health` handler entry and the two `X-OLP-Fallback-Detail` header-write paths).
|
||||||
|
- **ADR 0004 Amendment 5** (D40 ungated header + Phase 2 re-gating deferral) — `docs/adr/0004-fallback-engine.md`.
|
||||||
|
- **CLAUDE.md `release_kit.phase_rolling_mode` `current_pre_release_identifier`** = `"0.2.0-phase2"` — confirms this ADR lands in the Phase 2 sprint.
|
||||||
@@ -20,6 +20,7 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
|
|||||||
| [0004](0004-fallback-engine.md) | Fallback Engine Semantics & Safety | Trigger taxonomy (Hard / Soft / Deterministic-deferred / Cost-aware-deferred), idempotent-failure safety (first-chunk rule), chain advancement one-at-a-time, observability headers. |
|
| [0004](0004-fallback-engine.md) | Fallback Engine Semantics & Safety | Trigger taxonomy (Hard / Soft / Deterministic-deferred / Cost-aware-deferred), idempotent-failure safety (first-chunk rule), chain advancement one-at-a-time, observability headers. |
|
||||||
| [0005](0005-cache-cross-provider.md) | Cache Layer Cross-Provider Design | Cache key composition over `(provider, model, messages, …)`, per-model isolation, D1+D2+D3+D4 port from OCP v3.13.0, cross-provider fallback cache behaviour (correct miss). |
|
| [0005](0005-cache-cross-provider.md) | Cache Layer Cross-Provider Design | Cache key composition over `(provider, model, messages, …)`, per-model isolation, D1+D2+D3+D4 port from OCP v3.13.0, cross-provider fallback cache behaviour (correct miss). |
|
||||||
| [0006](0006-provider-inclusion.md) | Provider Inclusion / Exclusion + Risk-Tier Framework | The 4-tier classification (A excluded by default / B explicit consent / C opt-in / D eligible-for-default-enabled), Candidate-vs-Enabled distinction, current v0.1 candidate inventory (0 Enabled), Antigravity exclusion rationale (named prohibition + no cost advantage + reinstatement friction; pending primary-source pin), consent UX, future provider addition procedure. |
|
| [0006](0006-provider-inclusion.md) | Provider Inclusion / Exclusion + Risk-Tier Framework | The 4-tier classification (A excluded by default / B explicit consent / C opt-in / D eligible-for-default-enabled), Candidate-vs-Enabled distinction, current v0.1 candidate inventory (0 Enabled), Antigravity exclusion rationale (named prohibition + no cost advantage + reinstatement friction; pending primary-source pin), consent UX, future provider addition procedure. |
|
||||||
|
| [0007](0007-multi-key-auth.md) | Multi-Key Auth (`lib/keys.mjs`) | Phase 2 design ADR (D43-B, 2026-05-25). Option 2 (filesystem manifest at `~/.olp/keys/<key-id>/manifest.json`) + opaque `olp_<32-byte>` token + SHA-256 hash. Owner / guest / anonymous tier gating with explicit `config.json auth.allow_anonymous` (default false). Bootstrap keygen command surface + `OLP_OWNER_TOKEN` env override with stable synthetic `key_id`. Audit ndjson append-only at `~/.olp/logs/audit.ndjson`, warn+1-retry on append failure. Rejects direct SQLite port at v0.2.0 due to Node baseline (`engines >=18` + CI 20/24 vs `node:sqlite` added 22.5.0 / RC); Option 3 hybrid documented as forward path when Phase 3+ Dashboard / SQL-aggregate quota arrives. |
|
||||||
|
|
||||||
## When to write a new ADR
|
## When to write a new ADR
|
||||||
|
|
||||||
|
|||||||
+15
-12
@@ -24,19 +24,22 @@
|
|||||||
- **Trigger to start.** Any of: (a) report of N>1 concurrent identical streaming requests in the wild, (b) v1.x sprint planning kickoff with the maintainer explicitly opening this scope, (c) downstream feature requiring tee-streaming primitive (e.g., browser-side observer attaching to an existing stream).
|
- **Trigger to start.** Any of: (a) report of N>1 concurrent identical streaming requests in the wild, (b) v1.x sprint planning kickoff with the maintainer explicitly opening this scope, (c) downstream feature requiring tee-streaming primitive (e.g., browser-side observer attaching to an existing stream).
|
||||||
- **Estimated effort.** Design ADR ratified (Amendment 8) = 30 min done. Implementation = 200–400 lines + 15-20 tests + fresh-context reviewer pass. ~3-4 hours of subagent runtime with full Iron Rule 10 discipline.
|
- **Estimated effort.** Design ADR ratified (Amendment 8) = 30 min done. Implementation = 200–400 lines + 15-20 tests + fresh-context reviewer pass. ~3-4 hours of subagent runtime with full Iron Rule 10 discipline.
|
||||||
|
|
||||||
## #2 — Multi-key auth (`lib/keys.mjs`)
|
## #2 — Multi-key auth (`lib/keys.mjs`) — **PHASE 2 ACTIVE (no longer deferred)**
|
||||||
|
|
||||||
- **What.** Per-API-key identity, namespace scoping for the cache, ownership tier (owner vs guest) for header gating, and audit log of which key issued which request.
|
- **Status.** Phase 2 active as of 2026-05-25. Design ratified at D43-B. This entry stays for cross-reference but is no longer a v1.x deferral; implementation D-days D44+ execute within Phase 2.
|
||||||
- **Why deferred.** Phase 1 ships single-tenant — the cache layer carries `keyId='__anonymous__'` (D5). No real user identity is needed for personal/family use today.
|
- **What.** Per-API-key identity, namespace scoping for the cache, ownership tier (owner vs guest) for header gating, and audit log of which key issued which request. Detailed scope in ADR 0007.
|
||||||
- **Design ADR (NOT YET RATIFIED).** No design ADR exists yet. v1.x sprint must produce one before implementation.
|
- **Design ADR (ratified).** [`docs/adr/0007-multi-key-auth.md`](./adr/0007-multi-key-auth.md) — Option 2 (filesystem manifest) + opaque token, with explicit forward path to Option 3 hybrid (SQLite-indexed mirror) when Phase 3+ Dashboard / SQL-aggregate quota work justifies. Migratable, manifest-as-SPOT.
|
||||||
- **Tracking.** Not a GitHub issue (no governance event filed for it). Tracked here + in `AGENTS.md § Key files to know` (`lib/keys.mjs` marked 📋 Planned).
|
- **Tracking.** Not a GitHub issue. Tracked here + via ADR 0007 acceptance criteria (§ 10) which drive the D44+ test surface.
|
||||||
- **Blocks.**
|
- **Resolves.**
|
||||||
- `X-OLP-Fallback-Detail` owner-only gating (D40 / ADR 0004 Amendment 5 — currently ungated).
|
- `X-OLP-Fallback-Detail` owner-only gating (D40 / ADR 0004 Amendment 5 — currently ungated; Phase 2 re-gates per ADR 0007 § 7).
|
||||||
- `/health` per-key visibility (currently anonymous-only).
|
- `/health` per-key visibility (currently anonymous-only — owner / guest / anonymous tiers per ADR 0007 § 7).
|
||||||
- **Code anchors today.**
|
- **Code anchors today (unchanged at ADR ratification; replaced by D44+ implementation).**
|
||||||
- `lib/cache/store.mjs` per-keyId namespace Map — the data model is already keyed by `keyId`; only the keyId source is hardcoded.
|
- `lib/cache/store.mjs:77-79` per-keyId namespace Map — wire is in place.
|
||||||
- `server.mjs` request handlers — the `keyId='__anonymous__'` constant needs to be replaced by a header/token lookup.
|
- `lib/cache/store.mjs:287` singleflight composition `${keyId}:${cacheKey}` — wire is in place.
|
||||||
- **Trigger to start.** First multi-user deployment of OLP (e.g., maintainer + spouse + child accessing the same instance with separate identities).
|
- `server.mjs:502, :531` — the two `keyId='__anonymous__'` call sites to replace.
|
||||||
|
- `server.mjs:392` — `/health` handler entry (Phase 2 gate).
|
||||||
|
- `server.mjs:1072, :1101` — `X-OLP-Fallback-Detail` header-write paths (Phase 2 gate).
|
||||||
|
- **Trigger (already fired).** Maintainer opened Phase 2 sprint 2026-05-25.
|
||||||
|
|
||||||
## #3 — Soft trigger reactivation (ADR 0004 Amendment 2)
|
## #3 — Soft trigger reactivation (ADR 0004 Amendment 2)
|
||||||
|
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* lib/audit.mjs — OLP audit ndjson append (Phase 2 / D45)
|
||||||
|
*
|
||||||
|
* Authority: ADR 0007 § 6.2 (audit append semantics) + § 8 (event schema).
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - One JSON event per line; newline-terminated; UTF-8.
|
||||||
|
* - Append to ~/.olp/logs/audit.ndjson (chmod 0600 file, 0700 dir).
|
||||||
|
* - On append failure: log a warn ('audit_append_failed_once') + retry
|
||||||
|
* once synchronously.
|
||||||
|
* - On second-failure: increment per-process drop counter + log warn
|
||||||
|
* ('audit_append_dropped'); NEVER throw to the caller (audit is
|
||||||
|
* observability, not authorization). Per § 6.2.
|
||||||
|
* - No memory buffer at Phase 2 (forward-path note in ADR § 13).
|
||||||
|
*
|
||||||
|
* Atomicity note: Node's `fs.appendFileSync` opens with O_APPEND which is
|
||||||
|
* POSIX-atomic for writes <= PIPE_BUF (typically 4096 bytes). Our event
|
||||||
|
* payloads (§ 8 schema with hash + shape fields, no PII / no message
|
||||||
|
* content) are well under that limit, so concurrent in-process appends
|
||||||
|
* are line-atomic without explicit locking.
|
||||||
|
*
|
||||||
|
* No PII: § 8 explicitly excludes request body, response body, and IR
|
||||||
|
* message content. Hash + shape only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||||
|
const OLP_HOME_ENV = 'OLP_HOME';
|
||||||
|
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
|
||||||
|
|
||||||
|
let _dropCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
|
||||||
|
* process.env.OLP_HOME → ~/.olp. Resolved per call so tests setting the
|
||||||
|
* env mid-run take effect.
|
||||||
|
*/
|
||||||
|
function _resolveOlpHome(opts) {
|
||||||
|
if (opts?.olpHome) return opts.olpHome;
|
||||||
|
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
||||||
|
return DEFAULT_OLP_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a single audit event to ~/.olp/logs/audit.ndjson.
|
||||||
|
*
|
||||||
|
* @param {object} event - § 8 schema fields (ts, key_id, owner_tier,
|
||||||
|
* method, path, provider, model, status_code, latency_ms, cache_status,
|
||||||
|
* fallback_hops, tried_providers, error_code, ir_request_hash, chain_id).
|
||||||
|
* Caller is responsible for populating fields; missing fields are
|
||||||
|
* serialized as undefined → omitted by JSON.stringify.
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||||
|
* @param {(level: string, event: string, data?: object) => void} [opts.logEvent]
|
||||||
|
* - injectable structured logger; defaults to console.warn with JSON line
|
||||||
|
*/
|
||||||
|
export function appendAuditEvent(event, opts = {}) {
|
||||||
|
const olpHome = _resolveOlpHome(opts);
|
||||||
|
const logsDir = join(olpHome, 'logs');
|
||||||
|
const path = join(logsDir, 'audit.ndjson');
|
||||||
|
const line = JSON.stringify(event) + '\n';
|
||||||
|
const logEvent = opts.logEvent ?? ((level, ev, data) => {
|
||||||
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
||||||
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
||||||
|
try {
|
||||||
|
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
||||||
|
// Tighten dir mode in case it already existed with broader permissions.
|
||||||
|
try { chmodSync(logsDir, 0o700); } catch { /* tolerate EPERM */ }
|
||||||
|
appendFileSync(path, line, { mode: 0o600 });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt < RETRY_COUNT) {
|
||||||
|
logEvent('warn', 'audit_append_failed_once', {
|
||||||
|
path,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_dropCounter++;
|
||||||
|
logEvent('warn', 'audit_append_dropped', {
|
||||||
|
path,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
drop_count: _dropCounter,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-process count of audit events dropped due to repeated append failure.
|
||||||
|
* Useful for /health observability surface (D46).
|
||||||
|
*/
|
||||||
|
export function getAuditDropCount() {
|
||||||
|
return _dropCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: reset the drop counter to zero. Suite 20 uses this between
|
||||||
|
* cases to assert independent failure-handling counts.
|
||||||
|
*/
|
||||||
|
export function __resetAuditDropCount() {
|
||||||
|
_dropCounter = 0;
|
||||||
|
}
|
||||||
+522
@@ -0,0 +1,522 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
export const OLP_HOME_ENV = 'OLP_HOME';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OLP home directory. Precedence:
|
||||||
|
* 1. `opts.olpHome` (explicit caller override — tests, CLI flags)
|
||||||
|
* 2. `process.env.OLP_HOME` (operator / CI env override)
|
||||||
|
* 3. `~/.olp` (default per ADR 0007 § 3)
|
||||||
|
* Resolved dynamically per call so tests setting OLP_HOME mid-run take effect.
|
||||||
|
*/
|
||||||
|
function _resolveOlpHome(opts) {
|
||||||
|
if (opts?.olpHome) return opts.olpHome;
|
||||||
|
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
||||||
|
return DEFAULT_OLP_HOME;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 _resolveOlpHome(opts); }
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth config loader (§ 7.2) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the `auth` block from ~/.olp/config.json. All fields defaulted
|
||||||
|
* so partial / absent config is safe.
|
||||||
|
*
|
||||||
|
* Defaults per ADR § 7.2:
|
||||||
|
* - allow_anonymous: false (production-off default)
|
||||||
|
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
|
||||||
|
* - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads)
|
||||||
|
*
|
||||||
|
* Returns the auth config object. Never throws — missing file / parse
|
||||||
|
* error / missing `auth` key all fall back to defaults.
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
||||||
|
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none' }}
|
||||||
|
*/
|
||||||
|
export function loadAuthConfigSync(opts = {}) {
|
||||||
|
const olpHome = _resolveOlpHome(opts);
|
||||||
|
const path = join(olpHome, 'config.json');
|
||||||
|
const DEFAULTS = {
|
||||||
|
allow_anonymous: false,
|
||||||
|
owner_only_endpoints: ['/health'],
|
||||||
|
fallback_detail_header_policy: 'owner_only',
|
||||||
|
};
|
||||||
|
if (!existsSync(path)) return { ...DEFAULTS };
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(path, 'utf-8');
|
||||||
|
const cfg = JSON.parse(raw);
|
||||||
|
const auth = (cfg && typeof cfg === 'object' && cfg.auth && typeof cfg.auth === 'object')
|
||||||
|
? cfg.auth
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
allow_anonymous: typeof auth.allow_anonymous === 'boolean' ? auth.allow_anonymous : DEFAULTS.allow_anonymous,
|
||||||
|
owner_only_endpoints: Array.isArray(auth.owner_only_endpoints) ? auth.owner_only_endpoints : DEFAULTS.owner_only_endpoints,
|
||||||
|
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
|
||||||
|
? auth.fallback_detail_header_policy
|
||||||
|
: DEFAULTS.fallback_detail_header_policy,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON / unreadable file → safe defaults
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
+6
-2
@@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "olp",
|
"name": "olp",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server.mjs",
|
"main": "server.mjs",
|
||||||
|
"bin": {
|
||||||
|
"olp-keys": "./bin/olp-keys.mjs"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test": "node test-features.mjs"
|
"test": "node test-features.mjs",
|
||||||
|
"olp-keys": "node bin/olp-keys.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
+352
-18
@@ -48,6 +48,15 @@ import {
|
|||||||
buildDefaultChain,
|
buildDefaultChain,
|
||||||
loadFallbackConfigSync,
|
loadFallbackConfigSync,
|
||||||
} from './lib/fallback/engine.mjs';
|
} from './lib/fallback/engine.mjs';
|
||||||
|
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
|
||||||
|
import {
|
||||||
|
validateKey,
|
||||||
|
touchLastUsed,
|
||||||
|
loadAuthConfigSync,
|
||||||
|
ANONYMOUS_KEY_ID,
|
||||||
|
ENV_OWNER_KEY_ID,
|
||||||
|
} from './lib/keys.mjs';
|
||||||
|
import { appendAuditEvent } from './lib/audit.mjs';
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -93,6 +102,25 @@ if (_softTriggersConfigured) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth config (Phase 2 / D45, ADR 0007 § 7.2) ───────────────────────────
|
||||||
|
// auth.allow_anonymous default false (production-off). Test seam below.
|
||||||
|
let _authConfig = loadAuthConfigSync();
|
||||||
|
if (_authConfig.allow_anonymous === true) {
|
||||||
|
logEvent('warn', 'auth_allow_anonymous_enabled', {
|
||||||
|
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
||||||
|
export function __setAuthConfig(config) {
|
||||||
|
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — reset auth config to file-loaded state. */
|
||||||
|
export function __resetAuthConfig() {
|
||||||
|
_authConfig = loadAuthConfigSync();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Provider registry ─────────────────────────────────────────────────────
|
// ── Provider registry ─────────────────────────────────────────────────────
|
||||||
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
||||||
// ~/.olp/config.json has providers.enabled.X = true.
|
// ~/.olp/config.json has providers.enabled.X = true.
|
||||||
@@ -366,16 +394,42 @@ function jsonStringifyAscii(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges X-OLP-Fallback-Detail into a base header object when the per-hop
|
* Identity-aware gate for X-OLP-Fallback-Detail emission per ADR 0007 § 7.2.
|
||||||
* failure tuples are non-empty. Returns the base object unchanged otherwise.
|
* Reads `_authConfig.fallback_detail_header_policy`:
|
||||||
|
* - 'owner_only' (default) → emit only when olpIdentity.owner_tier === 'owner'
|
||||||
|
* - 'all' → emit unconditionally (v0.1.1 behaviour, opt-in)
|
||||||
|
* - 'none' → suppress unconditionally
|
||||||
|
* When olpIdentity is null (early-error paths before auth completed),
|
||||||
|
* defaults to 'all' → emit (preserves the v0.1.1 ungated behaviour for
|
||||||
|
* pre-auth errors where we don't yet know identity).
|
||||||
*
|
*
|
||||||
* D40 (issue #7).
|
* @param {{owner_tier?: string}|null|undefined} olpIdentity
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function shouldEmitFallbackDetailHeader(olpIdentity) {
|
||||||
|
const policy = _authConfig?.fallback_detail_header_policy ?? 'owner_only';
|
||||||
|
if (policy === 'none') return false;
|
||||||
|
if (policy === 'all') return true;
|
||||||
|
// 'owner_only' — gate by identity tier
|
||||||
|
if (!olpIdentity) return true; // pre-auth path: don't suppress diagnostic info
|
||||||
|
return olpIdentity.owner_tier === 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges X-OLP-Fallback-Detail into a base header object when the per-hop
|
||||||
|
* failure tuples are non-empty AND the per-request identity is permitted
|
||||||
|
* to see the header per the policy (ADR 0007 § 7.2). Returns the base
|
||||||
|
* object unchanged otherwise.
|
||||||
|
*
|
||||||
|
* D40 (issue #7) — gating added at D46 per ADR 0004 Amendment 5 ratification.
|
||||||
*
|
*
|
||||||
* @param {Record<string,string>} baseHeaders
|
* @param {Record<string,string>} baseHeaders
|
||||||
* @param {Array<object>|null|undefined} fallbackDetail
|
* @param {Array<object>|null|undefined} fallbackDetail
|
||||||
|
* @param {{owner_tier?: string}|null|undefined} olpIdentity - request identity; null on pre-auth paths
|
||||||
* @returns {Record<string,string>}
|
* @returns {Record<string,string>}
|
||||||
*/
|
*/
|
||||||
function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
|
function withFallbackDetailHeader(baseHeaders, fallbackDetail, olpIdentity) {
|
||||||
|
if (!shouldEmitFallbackDetailHeader(olpIdentity)) return baseHeaders;
|
||||||
const value = serializeFallbackDetailHeader(fallbackDetail);
|
const value = serializeFallbackDetailHeader(fallbackDetail);
|
||||||
if (value === null) return baseHeaders;
|
if (value === null) return baseHeaders;
|
||||||
return { ...baseHeaders, 'X-OLP-Fallback-Detail': value };
|
return { ...baseHeaders, 'X-OLP-Fallback-Detail': value };
|
||||||
@@ -383,13 +437,135 @@ function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
|
|||||||
|
|
||||||
// ── Route handlers ────────────────────────────────────────────────────────
|
// ── Route handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Auth middleware (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the plaintext OLP token from request headers.
|
||||||
|
* Tries `Authorization: Bearer <token>` first, then `x-api-key: <token>`.
|
||||||
|
* Returns the token string or null.
|
||||||
|
*
|
||||||
|
* @param {import('node:http').IncomingMessage} req
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function extractToken(req) {
|
||||||
|
const auth = req.headers['authorization'];
|
||||||
|
if (typeof auth === 'string') {
|
||||||
|
const match = /^Bearer\s+(\S+)$/i.exec(auth);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
const xKey = req.headers['x-api-key'];
|
||||||
|
if (typeof xKey === 'string' && xKey.length > 0) return xKey;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate the request per ADR 0007 §§ 5 / 7 / 9.4.
|
||||||
|
* - Try env-owner override first (OLP_OWNER_TOKEN).
|
||||||
|
* - Then filesystem manifest lookup by SHA-256 hash.
|
||||||
|
* - Else, if auth.allow_anonymous is true → anonymous identity.
|
||||||
|
* - Else → 401 auth_required.
|
||||||
|
*
|
||||||
|
* Returns { ok: true, authContext } on success or
|
||||||
|
* { ok: false, status, code, message } on failure (401).
|
||||||
|
*
|
||||||
|
* @param {import('node:http').IncomingMessage} req
|
||||||
|
* @returns {{ ok: true, authContext: { keyId: string, owner_tier: 'owner'|'guest'|'anonymous', providers_enabled: string[]|'*', source: 'env'|'filesystem'|'anonymous' } } | { ok: false, status: number, code: string, message: string }}
|
||||||
|
*/
|
||||||
|
function authenticate(req) {
|
||||||
|
const token = extractToken(req);
|
||||||
|
const identity = validateKey(token, { allowAnonymous: _authConfig.allow_anonymous });
|
||||||
|
if (identity === null) {
|
||||||
|
// Distinguish "no token presented" from "token presented but invalid/revoked".
|
||||||
|
// Both surface as 401 to the client (don't leak which case it was), but the
|
||||||
|
// server-side audit + log captures the source for operator diagnosis.
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
code: token ? 'invalid_or_revoked_key' : 'auth_required',
|
||||||
|
message: token
|
||||||
|
? 'OLP API key is invalid or has been revoked.'
|
||||||
|
: 'OLP API key required. Pass via "Authorization: Bearer <token>" or "x-api-key: <token>".',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
authContext: {
|
||||||
|
keyId: identity.id,
|
||||||
|
owner_tier: identity.owner_tier,
|
||||||
|
providers_enabled: identity.providers_enabled,
|
||||||
|
source: identity.source,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the given provider key is permitted for this identity.
|
||||||
|
* `providers_enabled: '*'` grants all; an array is a whitelist.
|
||||||
|
*
|
||||||
|
* @param {{ providers_enabled: string[]|'*' }} authContext
|
||||||
|
* @param {string} providerKey
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isProviderEnabled(authContext, providerKey) {
|
||||||
|
if (authContext.providers_enabled === '*') return true;
|
||||||
|
return Array.isArray(authContext.providers_enabled) && authContext.providers_enabled.includes(providerKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /health
|
* GET /health
|
||||||
* Returns server health including count of loaded providers and per-provider
|
*
|
||||||
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used
|
* Phase 2 / D46 (ADR 0007 § 7.1): identity-aware payload.
|
||||||
* by startup and /health endpoint per ADR 0002 § Provider contract description).
|
* - owner identity → full per-provider details (existing payload)
|
||||||
|
* - guest / anonymous → trimmed { ok, version } only
|
||||||
|
* - no auth, allow_anonymous=false → 401 (consistent with /v1/* routes)
|
||||||
|
*
|
||||||
|
* The trimming behavior is gated on `_authConfig.owner_only_endpoints` —
|
||||||
|
* the entry `/health` lives there by default. Removing `/health` from
|
||||||
|
* the list reverts to v0.1.1 full-payload-to-everyone behaviour.
|
||||||
|
*
|
||||||
|
* Authority: ADR 0007 § 7.1 (Identity-class table) + § 7.2 (owner_only_endpoints).
|
||||||
|
* Closes acceptance criterion #4.
|
||||||
*/
|
*/
|
||||||
async function handleHealth(req, res) {
|
async function handleHealth(req, res) {
|
||||||
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
// D46: audit on /health is intentionally NOT enabled at Phase 2.
|
||||||
|
// /health is a high-volume monitoring endpoint; per-call audit rows would
|
||||||
|
// generate operational noise that has no observability value until a
|
||||||
|
// Phase 3+ Dashboard aggregates /health stats. Deferred to Phase 3.
|
||||||
|
|
||||||
|
const authResult = authenticate(req);
|
||||||
|
if (!authResult.ok) {
|
||||||
|
return sendError(res, authResult.status, authResult.message, authResult.code);
|
||||||
|
}
|
||||||
|
const olpIdentity = authResult.authContext;
|
||||||
|
|
||||||
|
// Per § 7.1: owner sees full payload; guest + anonymous see trimmed.
|
||||||
|
// Per § 7.2: gating is opt-out via `owner_only_endpoints` config; if
|
||||||
|
// `/health` is removed from the list, all identities see the full
|
||||||
|
// payload (operators wanting v0.1.1 behaviour have this knob).
|
||||||
|
const gatedEndpoints = Array.isArray(_authConfig?.owner_only_endpoints)
|
||||||
|
? _authConfig.owner_only_endpoints
|
||||||
|
: ['/health'];
|
||||||
|
const isGated = gatedEndpoints.includes('/health');
|
||||||
|
const isOwner = olpIdentity.owner_tier === 'owner';
|
||||||
|
|
||||||
|
// Touch last_used_at for filesystem identities post-response. The callee
|
||||||
|
// also early-returns on ANONYMOUS / ENV_OWNER keyIds (lib/keys.mjs § 6.3
|
||||||
|
// wrapper) — this guard is defense-in-depth + skip the async call entirely
|
||||||
|
// for non-filesystem identities.
|
||||||
|
if (olpIdentity.keyId !== ANONYMOUS_KEY_ID && olpIdentity.keyId !== ENV_OWNER_KEY_ID) {
|
||||||
|
res.on('finish', () => {
|
||||||
|
touchLastUsed(olpIdentity.keyId).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGated && !isOwner) {
|
||||||
|
// Trimmed payload per § 7.1.
|
||||||
|
return sendJSON(res, 200, { ok: true, version: VERSION });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full payload (owner OR /health removed from owner_only_endpoints).
|
||||||
const enabled = loadedProviders.size;
|
const enabled = loadedProviders.size;
|
||||||
const available = listAllProviderNames().length;
|
const available = listAllProviderNames().length;
|
||||||
const providerStatuses = {};
|
const providerStatuses = {};
|
||||||
@@ -423,6 +599,46 @@ async function handleHealth(req, res) {
|
|||||||
* Empty case: if no providers are enabled, data: [] is returned naturally.
|
* Empty case: if no providers are enabled, data: [] is returned naturally.
|
||||||
*/
|
*/
|
||||||
function handleModels(req, res) {
|
function handleModels(req, res) {
|
||||||
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
// Audit + auth for /v1/models per Phase 2 / D45 (ADR 0007 § 7).
|
||||||
|
const auditCtx = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
key_id: ANONYMOUS_KEY_ID,
|
||||||
|
owner_tier: 'anonymous',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/v1/models',
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
status_code: 0,
|
||||||
|
latency_ms: 0,
|
||||||
|
cache_status: null,
|
||||||
|
fallback_hops: 0,
|
||||||
|
tried_providers: [],
|
||||||
|
error_code: null,
|
||||||
|
ir_request_hash: null,
|
||||||
|
chain_id: null,
|
||||||
|
};
|
||||||
|
let _authedKeyId = null;
|
||||||
|
res.on('finish', () => {
|
||||||
|
auditCtx.status_code = res.statusCode;
|
||||||
|
auditCtx.latency_ms = Date.now() - startMs;
|
||||||
|
try { appendAuditEvent(auditCtx); } catch { /* best-effort */ }
|
||||||
|
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
|
||||||
|
touchLastUsed(_authedKeyId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const authResult = authenticate(req);
|
||||||
|
if (!authResult.ok) {
|
||||||
|
auditCtx.error_code = authResult.code;
|
||||||
|
return sendError(res, authResult.status, authResult.message, authResult.code,
|
||||||
|
olpErrorHeaders({ startMs }));
|
||||||
|
}
|
||||||
|
auditCtx.key_id = authResult.authContext.keyId;
|
||||||
|
auditCtx.owner_tier = authResult.authContext.owner_tier;
|
||||||
|
_authedKeyId = authResult.authContext.keyId;
|
||||||
|
|
||||||
const data = [];
|
const data = [];
|
||||||
|
|
||||||
// Canonical entries first
|
// Canonical entries first
|
||||||
@@ -472,9 +688,64 @@ function handleModels(req, res) {
|
|||||||
async function handleChatCompletions(req, res) {
|
async function handleChatCompletions(req, res) {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
// Audit context — fields populated as the request proceeds; § 8 schema.
|
||||||
|
// Fired on res.on('finish') below regardless of success / error path.
|
||||||
|
const auditCtx = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
key_id: ANONYMOUS_KEY_ID, // updated post-auth
|
||||||
|
owner_tier: 'anonymous', // updated post-auth
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
status_code: 0, // updated on finish
|
||||||
|
latency_ms: 0, // updated on finish
|
||||||
|
cache_status: null,
|
||||||
|
fallback_hops: 0,
|
||||||
|
tried_providers: [],
|
||||||
|
error_code: null,
|
||||||
|
ir_request_hash: null,
|
||||||
|
chain_id: null,
|
||||||
|
};
|
||||||
|
// Wire post-response audit + lazy last_used_at update once, at the top, so
|
||||||
|
// every code path below (401, 400, 500, 200, streaming) emits an audit row.
|
||||||
|
// touchLastUsed is best-effort and never throws (§ 6.3).
|
||||||
|
let _authedKeyId = null;
|
||||||
|
res.on('finish', () => {
|
||||||
|
auditCtx.status_code = res.statusCode;
|
||||||
|
auditCtx.latency_ms = Date.now() - startMs;
|
||||||
|
try { appendAuditEvent(auditCtx); } catch { /* audit is best-effort */ }
|
||||||
|
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
|
||||||
|
// Anonymous + __env_owner__ have no on-disk manifest to touch; touchLastUsed
|
||||||
|
// itself early-returns for those identities (§ 6.3 wrapper). For
|
||||||
|
// filesystem-stored keys, fire-and-forget.
|
||||||
|
touchLastUsed(_authedKeyId).catch(() => { /* warned internally */ });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Authentication (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
|
||||||
|
// olpIdentity carries the OLP-side identity (keyId / owner_tier /
|
||||||
|
// providers_enabled). It is SEPARATE from `authContext` which is the
|
||||||
|
// per-provider OAuth/credential artifact passed to provider.spawn().
|
||||||
|
// Provider plugins treat `authContext === null` as "fall back to your
|
||||||
|
// own credential discovery (env / keychain / file)" — that contract is
|
||||||
|
// preserved at D45. Per-provider per-key credential mapping is a Phase
|
||||||
|
// 3+ concern (ADR 0007 § 12 Out of scope; v0.1 spec § 4.5 anticipated).
|
||||||
|
const authResult = authenticate(req);
|
||||||
|
if (!authResult.ok) {
|
||||||
|
auditCtx.error_code = authResult.code;
|
||||||
|
return sendError(res, authResult.status, authResult.message, authResult.code,
|
||||||
|
olpErrorHeaders({ startMs }));
|
||||||
|
}
|
||||||
|
const olpIdentity = authResult.authContext;
|
||||||
|
auditCtx.key_id = olpIdentity.keyId;
|
||||||
|
auditCtx.owner_tier = olpIdentity.owner_tier;
|
||||||
|
_authedKeyId = olpIdentity.keyId;
|
||||||
|
|
||||||
// Require JSON content-type
|
// Require JSON content-type
|
||||||
const ct = req.headers['content-type'] ?? '';
|
const ct = req.headers['content-type'] ?? '';
|
||||||
if (!ct.includes('application/json')) {
|
if (!ct.includes('application/json')) {
|
||||||
|
auditCtx.error_code = 'invalid_content_type';
|
||||||
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
|
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
|
||||||
olpErrorHeaders({ startMs }));
|
olpErrorHeaders({ startMs }));
|
||||||
}
|
}
|
||||||
@@ -483,6 +754,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
try {
|
try {
|
||||||
body = await readJSON(req);
|
body = await readJSON(req);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
auditCtx.error_code = 'invalid_request_body';
|
||||||
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
|
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
|
||||||
olpErrorHeaders({ startMs }));
|
olpErrorHeaders({ startMs }));
|
||||||
}
|
}
|
||||||
@@ -493,22 +765,22 @@ async function handleChatCompletions(req, res) {
|
|||||||
ir = openAIToIR(body);
|
ir = openAIToIR(body);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof BadRequestError) {
|
if (e instanceof BadRequestError) {
|
||||||
|
auditCtx.error_code = 'invalid_ir';
|
||||||
return sendError(res, 400, e.message, 'invalid_request_error',
|
return sendError(res, 400, e.message, 'invalid_request_error',
|
||||||
olpErrorHeaders({ startMs }));
|
olpErrorHeaders({ startMs }));
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
auditCtx.model = ir.model;
|
||||||
// Auth context is null at D5/D9 — providers fall back to their own credential
|
|
||||||
// discovery (env var, keychain, credentials file). Phase 2 multi-key
|
|
||||||
// infrastructure will pass a real authContext carrying the per-key OLP token.
|
|
||||||
const authContext = null;
|
|
||||||
|
|
||||||
// ── Fallback engine: build chain (ADR 0004) ─────────────────────────────
|
// ── Fallback engine: build chain (ADR 0004) ─────────────────────────────
|
||||||
// buildDefaultChain returns null if no enabled provider serves this model.
|
// buildDefaultChain returns null if no enabled provider serves this model.
|
||||||
// Per ADR 0004 § D9: at v0.1, chain is single-hop (no fallback) unless the
|
// Per ADR 0004 § D9: at v0.1, chain is single-hop (no fallback) unless the
|
||||||
// user has populated ~/.olp/config.json routing.chains.
|
// user has populated ~/.olp/config.json routing.chains.
|
||||||
const chain = buildDefaultChain(
|
//
|
||||||
|
// `let` (not `const`) because the chain may be filtered below per the
|
||||||
|
// authenticated key's providers_enabled allowlist (ADR 0007 § 10 #11).
|
||||||
|
let chain = buildDefaultChain(
|
||||||
ir.model,
|
ir.model,
|
||||||
loadedProviders,
|
loadedProviders,
|
||||||
_fallbackConfig.chains,
|
_fallbackConfig.chains,
|
||||||
@@ -517,6 +789,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
if (!chain) {
|
if (!chain) {
|
||||||
// ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec
|
// ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec
|
||||||
|
auditCtx.error_code = 'no_enabled_provider';
|
||||||
return sendError(
|
return sendError(
|
||||||
res, 503,
|
res, 503,
|
||||||
`No enabled providers for model ${ir.model}. See README § Supported Providers.`,
|
`No enabled providers for model ${ir.model}. See README § Supported Providers.`,
|
||||||
@@ -525,12 +798,39 @@ async function handleChatCompletions(req, res) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── providers_enabled scope enforcement (Phase 2 / D45, ADR 0007 § 10 #11) ─
|
||||||
|
// Filter the chain to providers this key is authorized for. If the resulting
|
||||||
|
// chain is empty, return 403 — the model exists in the registry but no hop
|
||||||
|
// is reachable from this identity's allowlist.
|
||||||
|
const _originalChainProviders = chain.map(hop => hop.provider);
|
||||||
|
chain = chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider));
|
||||||
|
if (chain.length === 0) {
|
||||||
|
auditCtx.error_code = 'key_no_provider_access';
|
||||||
|
auditCtx.tried_providers = _originalChainProviders;
|
||||||
|
const allowed = olpIdentity.providers_enabled === '*' ? '*' : (olpIdentity.providers_enabled ?? []).join(', ') || '(none)';
|
||||||
|
return sendError(
|
||||||
|
res, 403,
|
||||||
|
`This OLP key does not have access to any provider serving model "${ir.model}". Key's providers_enabled: [${allowed}]. Chain providers: [${_originalChainProviders.join(', ')}].`,
|
||||||
|
'key_no_provider_access',
|
||||||
|
olpErrorHeaders({ startMs, model: ir.model }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth context is null at D45 — providers fall back to their own credential
|
||||||
|
// discovery (env var, keychain, credentials file). The OLP-side identity
|
||||||
|
// (olpIdentity above) is consumed for cache namespacing + providers_enabled
|
||||||
|
// gating + audit attribution. Per-provider per-key credential mapping is a
|
||||||
|
// Phase 3+ deferral (ADR 0007 § 12).
|
||||||
|
const authContext = null;
|
||||||
|
|
||||||
const requestId = generateRequestId();
|
const requestId = generateRequestId();
|
||||||
|
|
||||||
// ── Cache layer (ADR 0005) ──────────────────────────────────────────────
|
// ── Cache layer (ADR 0005, namespaced per OLP key per ADR 0007 § 7) ─────
|
||||||
// keyId: '__anonymous__' at D5/D9. Phase 2 multi-key infrastructure wires the
|
// keyId is the authenticated identity's namespace token. For anonymous
|
||||||
// real OLP API key ID here for D1 per-key isolation.
|
// (when auth.allow_anonymous=true) this is the legacy '__anonymous__'
|
||||||
const keyId = '__anonymous__';
|
// shared namespace; for filesystem keys it is the per-key id; for the
|
||||||
|
// OLP_OWNER_TOKEN env override it is the synthetic '__env_owner__'.
|
||||||
|
const keyId = olpIdentity.keyId;
|
||||||
|
|
||||||
// D2 bypass (per-hop, per ADR 0005 § D2):
|
// D2 bypass (per-hop, per ADR 0005 § D2):
|
||||||
// cache_control markers bypass OLP's response cache ONLY when the active hop
|
// cache_control markers bypass OLP's response cache ONLY when the active hop
|
||||||
@@ -829,11 +1129,21 @@ async function handleChatCompletions(req, res) {
|
|||||||
if (!streamPlugin) {
|
if (!streamPlugin) {
|
||||||
// Provider disappeared between chain build and here (edge case).
|
// Provider disappeared between chain build and here (edge case).
|
||||||
// Release the slot we acquired above so the counter stays balanced.
|
// Release the slot we acquired above so the counter stays balanced.
|
||||||
|
auditCtx.provider = streamProvider;
|
||||||
|
auditCtx.error_code = 'no_enabled_provider';
|
||||||
releaseSpawn(streamProvider);
|
releaseSpawn(streamProvider);
|
||||||
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
|
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
|
||||||
olpErrorHeaders({ startMs, model: ir.model }));
|
olpErrorHeaders({ startMs, model: ir.model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D45 fold-in P1: populate audit ctx for the real-streaming path. Each
|
||||||
|
// exit below (success, error-after-first-chunk, pre-first-chunk-error,
|
||||||
|
// 503 above) leaves these fields representing the streaming attempt.
|
||||||
|
// Error paths amend `error_code`; success leaves it null.
|
||||||
|
auditCtx.provider = streamProvider;
|
||||||
|
auditCtx.tried_providers = [streamProvider];
|
||||||
|
auditCtx.cache_status = 'miss';
|
||||||
|
|
||||||
const streamHeaders = olpHeaders({
|
const streamHeaders = olpHeaders({
|
||||||
providerUsed: streamProvider,
|
providerUsed: streamProvider,
|
||||||
modelUsed: streamModel,
|
modelUsed: streamModel,
|
||||||
@@ -861,12 +1171,15 @@ async function handleChatCompletions(req, res) {
|
|||||||
model: streamModel,
|
model: streamModel,
|
||||||
error: irChunk.error,
|
error: irChunk.error,
|
||||||
});
|
});
|
||||||
|
auditCtx.error_code = 'streaming_error_after_first_chunk';
|
||||||
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
||||||
res.write(SSE_DONE);
|
res.write(SSE_DONE);
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No bytes written yet — throw to surface a clean error.
|
// No bytes written yet — throw to surface a clean error.
|
||||||
|
// auditCtx.error_code is set by the downstream catch handler (the
|
||||||
|
// outer streaming-path catch block fills it from the thrown error).
|
||||||
throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED');
|
throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +1285,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
model: streamModel,
|
model: streamModel,
|
||||||
error: e.message,
|
error: e.message,
|
||||||
});
|
});
|
||||||
|
auditCtx.error_code = e?.code ?? 'provider_error';
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
||||||
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
||||||
@@ -1059,6 +1373,18 @@ async function handleChatCompletions(req, res) {
|
|||||||
fallbackHops: fallbackHops ?? 0,
|
fallbackHops: fallbackHops ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit ctx capture for chain-exhausted / provider-error path (audit fires
|
||||||
|
// on res.on('finish'); fields populated here so the row reflects what we
|
||||||
|
// know at exhaustion time — provider is the chain[0] primary per ADR 0004
|
||||||
|
// step 4, tried_providers is the failed-hop trail from fallbackDetail).
|
||||||
|
auditCtx.provider = providerUsed ?? 'none';
|
||||||
|
auditCtx.fallback_hops = fallbackHops ?? 0;
|
||||||
|
auditCtx.tried_providers = Array.isArray(fallbackDetail)
|
||||||
|
? fallbackDetail.map(t => t?.provider).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
auditCtx.cache_status = 'miss';
|
||||||
|
auditCtx.error_code = originalError?.code ?? 'provider_error';
|
||||||
|
|
||||||
// Send error with standard OLP headers + optional exhausted header +
|
// Send error with standard OLP headers + optional exhausted header +
|
||||||
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
|
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
|
||||||
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
|
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
|
||||||
@@ -1069,7 +1395,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
type: 'provider_error',
|
type: 'provider_error',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const detailHeader = withFallbackDetailHeader({}, fallbackDetail);
|
const detailHeader = withFallbackDetailHeader({}, fallbackDetail, olpIdentity);
|
||||||
res.writeHead(errStatus, {
|
res.writeHead(errStatus, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(payload),
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
@@ -1101,8 +1427,16 @@ async function handleChatCompletions(req, res) {
|
|||||||
const headers = withFallbackDetailHeader(
|
const headers = withFallbackDetailHeader(
|
||||||
olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }),
|
olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }),
|
||||||
fallbackDetail,
|
fallbackDetail,
|
||||||
|
olpIdentity,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Audit ctx capture for success path (audit fires on res.on('finish');
|
||||||
|
// status_code + latency_ms populated then).
|
||||||
|
auditCtx.provider = providerUsed;
|
||||||
|
auditCtx.fallback_hops = fallbackHops;
|
||||||
|
auditCtx.tried_providers = (Array.isArray(fallbackDetail) ? fallbackDetail.map(t => t?.provider).filter(Boolean) : []).concat([providerUsed]);
|
||||||
|
auditCtx.cache_status = cacheStatus;
|
||||||
|
|
||||||
if (ir.stream) {
|
if (ir.stream) {
|
||||||
// Streaming response path: burst replay from buffered chunks.
|
// Streaming response path: burst replay from buffered chunks.
|
||||||
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
|
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
|
||||||
|
|||||||
+1414
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user