mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D47 — bin/olp-keys.mjs keygen CLI (Phase 2 functional scope closes) (#23)
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 bin/olp-keys.mjs (~250 lines): subcommand CLI keygen [--owner|--name=X|--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 (ADR § 9.3 recovery flow). list [--owner-only|--include-revoked] Lists keys with token_hash redacted. 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 then ~/.olp/). package.json bin field "bin": { "olp-keys": "./bin/olp-keys.mjs" } so npx olp-keys ... resolves. Also "scripts": { "olp-keys": "node bin/olp-keys.mjs" } for npm run. Module shape (testability) Exports runCli(argv, { out, err }) so tests invoke with synthetic argv + IO writers (no process spawn). Main guard auto-runs when invoked as entrypoint. Plaintext token discipline (ADR § 5 + § 9.1) Plaintext printed exactly once on stdout. Never logged, never written to manifest, never written to audit. Operators capture immediately; lost → --force revoke + regenerate. --force async correctness cmdKeygen is async and awaits 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 race where create-new runs before revoke-old completes. TESTS — Suite 22, +20 (524 → 544): 22a-1..5: parseArgv unit (--flag=value, --flag value, boolean, mixed positional) 22b-1..5: keygen (owner default, name+providers, missing-name error, invalid-tier error, --force revoke-then-create with isolation tmpdir) 22c-1..3: list (empty, populated with token_hash-redaction check, --owner-only filter) 22d-1..4: revoke (valid id, idempotent re-revoke, missing-id error, nonexistent-id error) 22e-1..3: top-level CLI (--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 row added for bin/olp-keys.mjs; Known limitations note rewritten to "Phase 2 functional scope complete; close pending"; new Bootstrap workflow section with copy-pasteable npx commands + recovery flow. - CHANGELOG.md: D47 entry under Unreleased per release_kit overlay. AUTHORITY: - ADR 0007 — § 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. Verified: 544/544 pass via npm test (no regression in 524 pre-D47 tests; 20 new Suite 22 tests all green). Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,8 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
||||
- `lib/ir/` — Intermediate Representation definition + serializers. Governed by ADR 0003.
|
||||
- `lib/cache/` — content-addressed cache layer (per-key isolation, `cache_control` bypass, chunked stream replay, singleflight). Governed by ADR 0005.
|
||||
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
|
||||
- `lib/keys.mjs` — multi-key auth, per-key namespacing, 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). Remaining: keygen CLI bootstrap surface; Phase 2 close → v0.2.0.**
|
||||
- `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.**
|
||||
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
||||
@@ -46,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.
|
||||
- `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5).
|
||||
|
||||
**Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk; files marked 🟡 are partially shipped (specific components landed; others scheduled). For the full status table see `README.md § "Implementation status"`. The shipped set as of D46 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), `models-registry.json`, `test-features.mjs` (Suites 19 + 20 + 21). Phase 2 in progress: keygen CLI bootstrap surface; Phase 2 close → v0.2.0 (maintainer-triggered).
|
||||
**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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,29 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -182,7 +182,8 @@ Phase 1 is closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). P
|
||||
| Soft trigger data path (`quotaStatus()` polling) | 📋 Planned (v1.x) | Evaluation logic shipped + tested; data ingestion deferred per ADR 0004 Amendment 2 |
|
||||
| `models-registry.json` | ✅ Shipped | SPOT for `(provider, model)` metadata |
|
||||
| `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) |
|
||||
| `lib/keys.mjs` | 🟡 Phase 2 — core + server integration + owner gating (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` (default `false` / `['/health']` / `'owner_only'`). 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. Remaining: keygen CLI bootstrap surface; Phase 2 close → v0.2.0. |
|
||||
| `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 |
|
||||
| `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header |
|
||||
@@ -197,7 +198,25 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
|
||||
|
||||
- **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one.
|
||||
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
||||
- **Multi-key auth + owner gating shipped (D44 + D45 + D46); keygen CLI 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). Phase 2 in progress through D47+ (keygen CLI surface, docs), Phase 2 close → v0.2.0 (maintainer-triggered). 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).
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
+5
-1
@@ -4,9 +4,13 @@
|
||||
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
||||
"type": "module",
|
||||
"main": "server.mjs",
|
||||
"bin": {
|
||||
"olp-keys": "./bin/olp-keys.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node test-features.mjs"
|
||||
"test": "node test-features.mjs",
|
||||
"olp-keys": "node bin/olp-keys.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -10657,3 +10657,243 @@ describe('Suite 21 — D46 owner-vs-guest gating (ADR 0007 §§ 7.1, 7.2)', () =
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 22: D47 keygen CLI (bin/olp-keys.mjs, ADR 0007 § 9.1) ───────────
|
||||
//
|
||||
// Tests for the minimal keygen command surface that satisfies ADR § 10
|
||||
// acceptance criterion #9 (bootstrap workflow must be reproducible without
|
||||
// manual file editing).
|
||||
|
||||
import { runCli as runOlpKeysCli, parseArgv as parseOlpKeysArgv } from './bin/olp-keys.mjs';
|
||||
|
||||
describe('Suite 22 — D47 keygen CLI (bin/olp-keys.mjs, ADR 0007 § 9.1)', () => {
|
||||
|
||||
describe('22a — parseArgv unit tests', () => {
|
||||
it('22a-1: empty argv → empty positional + empty flags', () => {
|
||||
const r = parseOlpKeysArgv([]);
|
||||
assert.deepEqual(r.positional, []);
|
||||
assert.deepEqual(r.flags, {});
|
||||
});
|
||||
|
||||
it('22a-2: --flag=value form', () => {
|
||||
const r = parseOlpKeysArgv(['--name=keyA', '--tier=owner']);
|
||||
assert.equal(r.flags.name, 'keyA');
|
||||
assert.equal(r.flags.tier, 'owner');
|
||||
});
|
||||
|
||||
it('22a-3: --flag value form (space-separated)', () => {
|
||||
const r = parseOlpKeysArgv(['--name', 'keyB', '--tier', 'guest']);
|
||||
assert.equal(r.flags.name, 'keyB');
|
||||
assert.equal(r.flags.tier, 'guest');
|
||||
});
|
||||
|
||||
it('22a-4: --flag (boolean, no value) when followed by another --flag', () => {
|
||||
const r = parseOlpKeysArgv(['--owner', '--force']);
|
||||
assert.equal(r.flags.owner, true);
|
||||
assert.equal(r.flags.force, true);
|
||||
});
|
||||
|
||||
it('22a-5: positional args + flags mixed', () => {
|
||||
const r = parseOlpKeysArgv(['keygen', '--owner']);
|
||||
assert.deepEqual(r.positional, ['keygen']);
|
||||
assert.equal(r.flags.owner, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('22b — keygen subcommand', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22b-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||
|
||||
it('22b-1: keygen --owner creates owner key + prints plaintext token to stdout', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['keygen', '--owner', '--olp-home', TMP], {
|
||||
out: s => { out += s; },
|
||||
err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /token \(plaintext\):\s+olp_[A-Za-z0-9_-]{43}/);
|
||||
assert.match(out, /owner_tier:\s+owner/);
|
||||
// Verify manifest was actually written
|
||||
const list = listKeys({ olpHome: TMP });
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].owner_tier, 'owner');
|
||||
});
|
||||
|
||||
it('22b-2: keygen --name=test --providers=anthropic,openai → guest key with explicit providers', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(
|
||||
['keygen', '--name=test-guest', '--providers=anthropic,openai', '--olp-home', TMP],
|
||||
{ out: s => { out += s; }, err: () => {} },
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /owner_tier:\s+guest/);
|
||||
assert.match(out, /providers_enabled:\s+\[anthropic, openai\]/);
|
||||
});
|
||||
|
||||
it('22b-3: keygen without --name AND without --owner → error exit 1', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['keygen', '--olp-home', TMP], {
|
||||
out: () => {},
|
||||
err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /--name is required/);
|
||||
});
|
||||
|
||||
it('22b-4: keygen --tier=admin → error exit 1 (invalid tier)', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(
|
||||
['keygen', '--name=bad', '--tier=admin', '--olp-home', TMP],
|
||||
{ out: () => {}, err: s => { err += s; } },
|
||||
);
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /--tier must be "owner" or "guest"/);
|
||||
});
|
||||
|
||||
it('22b-5: keygen --owner --force revokes existing owner + creates new', async () => {
|
||||
// Use a fresh tmpdir for this isolation
|
||||
const TMP2 = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22b5-'));
|
||||
try {
|
||||
// Create initial owner
|
||||
await runOlpKeysCli(['keygen', '--owner', '--olp-home', TMP2], { out: () => {}, err: () => {} });
|
||||
const list1 = listKeys({ olpHome: TMP2 });
|
||||
assert.equal(list1.length, 1);
|
||||
const originalId = list1[0].id;
|
||||
|
||||
// --force: revoke existing + create new
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['keygen', '--owner', '--force', '--olp-home', TMP2], {
|
||||
out: () => {},
|
||||
err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(err, new RegExp(`Revoked existing owner key id=${originalId}`));
|
||||
|
||||
// Listing including revoked must show 2 entries; active-only must show 1
|
||||
const listAll = listKeys({ olpHome: TMP2 });
|
||||
assert.equal(listAll.length, 2);
|
||||
const activeOwners = listAll.filter(k => k.owner_tier === 'owner' && k.revoked_at === null);
|
||||
assert.equal(activeOwners.length, 1, 'exactly one active owner after --force');
|
||||
const revokedOwners = listAll.filter(k => k.owner_tier === 'owner' && k.revoked_at !== null);
|
||||
assert.equal(revokedOwners.length, 1, 'exactly one revoked owner after --force');
|
||||
assert.equal(revokedOwners[0].id, originalId);
|
||||
} finally {
|
||||
rmSync(TMP2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('22c — list subcommand', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22c-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||
|
||||
it('22c-1: list with no keys → "No keys."', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['list', '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /No keys\./);
|
||||
});
|
||||
|
||||
it('22c-2: list after 2 keys → both visible with redacted token_hash', async () => {
|
||||
await runOlpKeysCli(['keygen', '--name=alpha', '--olp-home', TMP], { out: () => {}, err: () => {} });
|
||||
await runOlpKeysCli(['keygen', '--name=beta', '--owner', '--olp-home', TMP], { out: () => {}, err: () => {} });
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['list', '--olp-home', TMP], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /name:\s+alpha/);
|
||||
assert.match(out, /name:\s+beta/);
|
||||
assert.match(out, /owner_tier:\s+owner/);
|
||||
assert.match(out, /owner_tier:\s+guest/);
|
||||
// token_hash MUST NOT appear in list output (lib/keys.mjs listKeys redacts it)
|
||||
assert.ok(!out.includes('token_hash'), 'list output must not include token_hash field');
|
||||
});
|
||||
|
||||
it('22c-3: list --owner-only filters to owner_tier=owner', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['list', '--owner-only', '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.ok(!out.includes('name: alpha'), 'guest "alpha" must be filtered out with --owner-only');
|
||||
assert.match(out, /name:\s+beta/, 'owner "beta" must remain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('22d — revoke subcommand', () => {
|
||||
let TMP, keyId;
|
||||
before(async () => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22d-'));
|
||||
const r = createKey({ name: '22d-target', owner_tier: 'guest', olpHome: TMP });
|
||||
keyId = r.id;
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||
|
||||
it('22d-1: revoke --id=<valid> → exit 0, manifest revoked_at set', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['revoke', `--id=${keyId}`, '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, new RegExp(`Revoked key id=${keyId}`));
|
||||
const m = readManifest(keyId, { olpHome: TMP });
|
||||
assert.ok(m.revoked_at !== null);
|
||||
});
|
||||
|
||||
it('22d-2: revoke --id=<already-revoked> → exit 0 + "already revoked" message (idempotent)', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['revoke', `--id=${keyId}`, '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /already revoked/);
|
||||
});
|
||||
|
||||
it('22d-3: revoke without --id → exit 1', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['revoke', '--olp-home', TMP], {
|
||||
out: () => {}, err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /--id=<key-id> is required/);
|
||||
});
|
||||
|
||||
it('22d-4: revoke --id=<nonexistent> → exit 2', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['revoke', '--id=nonexistent-key', '--olp-home', TMP], {
|
||||
out: () => {}, err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 2);
|
||||
assert.match(err, /no key with id="nonexistent-key"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('22e — top-level CLI behaviour', () => {
|
||||
it('22e-1: --help → exit 0 with usage text', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['--help'], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /OLP key management CLI/);
|
||||
assert.match(out, /keygen/);
|
||||
assert.match(out, /list/);
|
||||
assert.match(out, /revoke/);
|
||||
});
|
||||
|
||||
it('22e-2: no args → exit 1 with usage text', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli([], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 1);
|
||||
assert.match(out, /OLP key management CLI/);
|
||||
});
|
||||
|
||||
it('22e-3: unknown subcommand → exit 1 with error', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['frobnicate'], { out: () => {}, err: s => { err += s; } });
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /unknown subcommand "frobnicate"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user