@askalf/dario 6.7.1 → 6.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  <p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
29
29
 
30
- <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~36k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
30
+ <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~38k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
31
31
 
32
32
  <sub><a href="#start-in-60-seconds">Start</a> · <a href="#point-your-tools-at-it">Your tools</a> · <a href="#what-it-does-with-a-request">Routing</a> · <a href="#two-plans-one-endpoint">Two plans</a> · <a href="#many-seats-one-endpoint">Pool</a> · <a href="#it-tracks-a-moving-target">Drift</a> · <a href="#trust--transparency">Trust</a> · <a href="#will-my-account-get-suspended">Risk</a> · <a href="#commands">Commands</a> · <a href="#faq">FAQ</a> · <a href="docs/returning.md">Coming back after a while?</a></sub>
33
33
 
@@ -373,6 +373,17 @@ Three things it does that a round-robin doesn't:
373
373
 
374
374
  `--pool-strategy=fill-first` concentrates new conversations on one seat until it drains, for primary/backup setups. Refresh tokens expire about 28 days after the original grant regardless of rotation, so every seat's grant age is tracked and surfaced in `dario accounts list`, `dario doctor` and `GET /accounts` before it becomes a silent outage. Provision over HTTP with the headless [admin API](./docs/admin-api.md); pin one request to one seat with `dario accounts check <alias>` (admin API required: `DARIO_ADMIN=1` and a `DARIO_ADMIN_TOKEN`). Internals and the live `/accounts` + `/analytics` endpoints: [multi-account-pool.md](./docs/multi-account-pool.md); covered end-to-end by [`test/pool-e2e.mjs`](./test/pool-e2e.mjs).
375
375
 
376
+ ### One key per developer
377
+
378
+ A shared dario serves several people through one `DARIO_API_KEY`, and nothing says whose traffic is whose except a header any client can set. Since 6.8 a **named key** ties attribution to the credential:
379
+
380
+ ```bash
381
+ dario keys create alice
382
+ dario keys create bob --seat=bobs-max --models=claude-sonnet-5,claude-haiku*
383
+ ```
384
+
385
+ The secret is printed once and only its hash is kept, in `~/.dario/keys.json`. The request authenticated with alice's key *is* alice's in `/analytics`, in the ledger (`dario usage --by-key`) and on every log line; a key can prefer one pool seat (taken while it has headroom, normal routing otherwise, so a developer's conversations ride their own subscription) and can be held to a model allowlist (`403` before anything goes upstream, in either wire shape). The running proxy picks up a created, rotated or revoked key on its next request; the root `DARIO_API_KEY` keeps working beside them; `/admin/keys` does the same over HTTP. Details: [keys.md](./docs/keys.md).
386
+
376
387
  ### Watch it happen
377
388
 
378
389
  Type `dario` with no arguments for a full-screen control panel: live request stream, per-model burn rate, rate-limit utilization per seat, billing-bucket breakdown, and an in-place config editor that writes `~/.dario/config.json`. Pure ANSI, zero new runtime deps. <kbd>Tab</kbd> moves between tabs, <kbd>r</kbd> refreshes, <kbd>R</kbd> resumes a halted overage guard, <kbd>q</kbd> quits.
@@ -515,9 +526,10 @@ Longer version, with specifics: [#68](https://github.com/askalf/dario/discussion
515
526
  | `dario doctor [--usage] [--probe] [--obedience] [--auth-check] [--bun-bootstrap] [--json]` | One aggregated health report: runtime/TLS, template and drift, OAuth, pool, refresh-grant age, failover readiness, backends |
516
527
  | `dario add altman` / `dario add amodei` | Attach a ChatGPT plan / a Claude account, by whose it is |
517
528
  | `dario accounts list` / `add` / `remove` / `check <alias>` | Pool management; `check` sends one pinned request per model through the running proxy (admin API on) |
529
+ | `dario keys create <name>` / `list` / `revoke` / `rotate` | One credential per developer on a shared dario: attributed by key, optional preferred seat and model allowlist, hashes on disk ([keys.md](./docs/keys.md)) |
518
530
  | `dario backend list` / `add` / `remove` | OpenAI-compatible API-key backends |
519
531
  | `dario codex list` / `add` / `remove` | ChatGPT accounts (the long form of `dario add altman`) |
520
- | `dario usage` · `dario compare` · `dario config` · `dario status` | Lifetime API-equivalent spend + burn rate for the last hour (`--card` writes the share card) · read the shadow-compare log · effective config, redacted · token health |
532
+ | `dario usage` · `dario compare` · `dario config` · `dario status` | Lifetime API-equivalent spend + burn rate for the last hour (`--card` writes the share card, `--by-key` splits it per key) · read the shadow-compare log · effective config, redacted · token health |
521
533
  | `dario resume` · `dario refresh` · `dario logout` · `dario upgrade` | Clear an overage halt · force a token refresh · delete credentials · safe self-update |
522
534
  | `dario mcp` · `dario subagent install` / `remove` / `status` | Reach dario from inside any MCP client, or from inside a Claude Code session, read-only |
523
535
 
@@ -529,7 +541,7 @@ Longer version, with specifics: [#68](https://github.com/askalf/dario/discussion
529
541
  | `GET /status` · `GET /accounts` · `GET /analytics` | OAuth detail · per-seat utilization and grant age · per-account / per-model stats and burn rate |
530
542
  | `POST /v1/messages/count_tokens` · `POST /v1/complete` | Token counting and the legacy Text Completions shape |
531
543
  | `GET /analytics/stream` · `GET /analytics/ledger` · `GET /codex` | Live analytics over SSE · the ledger's per-day table · ChatGPT-seat status, read without spending or exposing a token |
532
- | `/admin/*` | Provisioning, `GET /admin/accounts`, `POST /admin/resume`; only with `DARIO_ADMIN=1` ([admin API](./docs/admin-api.md)) |
544
+ | `/admin/*` | Provisioning, `GET /admin/accounts`, `/admin/keys`, `POST /admin/resume`; only with `DARIO_ADMIN=1` ([admin API](./docs/admin-api.md)) |
533
545
 
534
546
  Flags: [commands.md](./docs/commands.md), plus `dario --help` for the ones it doesn't list yet (`--effort`, `--max-tokens`, `--model-alias`, `--fast-model`, session rotation, concurrency caps, the pacing knobs behind `--stealth`) · env vars grouped by task, for Docker / k8s / systemd: [configuration.md](./docs/configuration.md) · SDK examples: [usage.md](./docs/usage.md).
535
547
 
@@ -68,6 +68,7 @@
68
68
  * against it.
69
69
  */
70
70
  import type { IncomingMessage, ServerResponse } from 'node:http';
71
+ import { type KeyStore } from './keys.js';
71
72
  /** Persisted account metadata surfaced by `GET /admin/accounts`. */
72
73
  export interface AdminAccountRecord {
73
74
  alias: string;
@@ -142,11 +143,13 @@ export interface AdminAccountLive {
142
143
  }
143
144
  /** An audited admin action — see `AdminDeps.audit`. Never carries secrets. */
144
145
  export interface AdminAuditEvent {
145
- action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited';
146
+ action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited' | 'key_create' | 'key_revoke' | 'key_rotate';
146
147
  ok: boolean;
147
148
  status: number;
148
149
  /** Account alias, when the action targets one. */
149
150
  alias?: string;
151
+ /** Named key, when the action targets one (dario#1318). The name only, never the secret. */
152
+ key?: string;
150
153
  /** Client address (`req.socket.remoteAddress`), when known. */
151
154
  remote?: string;
152
155
  /** Extra context, e.g. the rate-limited category ('auth' | 'mutation'). */
@@ -187,6 +190,13 @@ export interface AdminDeps {
187
190
  * auth are never gated. Absent = no limiting. Owned by the proxy (#620).
188
191
  */
189
192
  rateLimit?: (category: 'auth' | 'mutation') => number;
193
+ /**
194
+ * Named keys (dario#1318): the store the running proxy authenticates
195
+ * from, so `/admin/keys` edits the same file `dario keys` does and a key
196
+ * minted here works on the next request. `null` / absent = named keys are
197
+ * off on this proxy; the routes answer 404.
198
+ */
199
+ keys?: KeyStore | null;
190
200
  }
191
201
  /**
192
202
  * Handle an `/admin/*` request. Returns `true` if it owned the request (matched
package/dist/admin-api.js CHANGED
@@ -3,9 +3,11 @@ import { timingSafeEqual } from 'node:crypto';
3
3
  import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases, loadAccount, } from './accounts.js';
4
4
  import { parseManualPaste } from './oauth.js';
5
5
  import { grantAge } from './refresh-grant.js';
6
+ import { createKey, revokeKey, rotateKey, parseExpiry, publicKey, KEY_NAME_RE } from './keys.js';
6
7
  const PENDING_TTL_MS = 10 * 60_000;
7
8
  const MAX_PENDING = 64; // backstop against unbounded growth (distinct aliases)
8
9
  const ACCOUNTS_PREFIX = '/admin/accounts/';
10
+ const KEYS_PREFIX = '/admin/keys/';
9
11
  /**
10
12
  * `consecutiveAuthFailures` floor for `/admin/login/start-needed` to treat an
11
13
  * account as needing a new login rather than mid-blip. Empirically: 1 failure
@@ -175,11 +177,23 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
175
177
  const method = req.method ?? 'GET';
176
178
  const remote = req.socket?.remoteAddress;
177
179
  const isAccountDelete = method === 'DELETE' && urlPath.startsWith(ACCOUNTS_PREFIX) && urlPath.length > ACCOUNTS_PREFIX.length;
180
+ // Named keys (dario#1318): `/admin/keys`, `/admin/keys/<name>`,
181
+ // `/admin/keys/<name>/rotate`. The name is validated after auth so a
182
+ // malformed one is a 400 to a caller who holds the token, not a route miss.
183
+ const keyTarget = urlPath.startsWith(KEYS_PREFIX) && urlPath.length > KEYS_PREFIX.length
184
+ ? decodeURIComponent(urlPath.slice(KEYS_PREFIX.length))
185
+ : null;
186
+ const isKeyRotate = keyTarget !== null && keyTarget.endsWith('/rotate');
187
+ const keyName = keyTarget === null ? null : isKeyRotate ? keyTarget.slice(0, -'/rotate'.length) : keyTarget;
188
+ const isKeyRevoke = keyTarget !== null && !isKeyRotate && method === 'DELETE';
178
189
  const known = urlPath === '/admin/login/start' ||
179
190
  urlPath === '/admin/login/start-needed' ||
180
191
  urlPath === '/admin/login/complete' ||
181
192
  urlPath === '/admin/accounts' ||
182
- isAccountDelete;
193
+ urlPath === '/admin/keys' ||
194
+ isAccountDelete ||
195
+ isKeyRotate ||
196
+ isKeyRevoke;
183
197
  if (!known)
184
198
  return false;
185
199
  // Auth — always required, even on loopback (these mutate OAuth credentials).
@@ -209,7 +223,8 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
209
223
  // below). That is a deliberate cost-accounting choice, not an oversight: a
210
224
  // blanket pre-parse token here would let a single HTTP request move N
211
225
  // accounts' credentials for the price of one throttle token.
212
- const isMutation = urlPath === '/admin/login/start' || isAccountDelete;
226
+ const isMutation = urlPath === '/admin/login/start' || isAccountDelete
227
+ || (urlPath === '/admin/keys' && method === 'POST') || isKeyRotate || isKeyRevoke;
213
228
  if (isMutation) {
214
229
  const wait = deps.rateLimit?.('mutation') ?? 0;
215
230
  if (wait > 0) {
@@ -415,6 +430,90 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
415
430
  send(res, removed ? 200 : 404, { alias, removed });
416
431
  return true;
417
432
  }
433
+ // Named keys (dario#1318). Every route below edits the store the proxy
434
+ // authenticates from; the secret appears in exactly one response and is
435
+ // never stored, listed or logged.
436
+ if (urlPath === '/admin/keys' || keyName !== null) {
437
+ const store = deps.keys ?? null;
438
+ if (!store) {
439
+ send(res, 404, { error: 'named keys are off on this proxy', hint: 'start without --no-keys / DARIO_KEYS=0' });
440
+ return true;
441
+ }
442
+ if (store.error) {
443
+ send(res, 503, { error: `keys file unreadable: ${store.error}`, path: store.path });
444
+ return true;
445
+ }
446
+ // GET /admin/keys — every key, hashes excluded.
447
+ if (urlPath === '/admin/keys' && method === 'GET') {
448
+ const keys = store.list(now);
449
+ send(res, 200, { keys, count: keys.length, path: store.path });
450
+ return true;
451
+ }
452
+ // POST /admin/keys { name, seat?, models?, expires? } — the secret, once.
453
+ if (urlPath === '/admin/keys' && method === 'POST') {
454
+ const body = await readJsonBody(req);
455
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
456
+ if (!KEY_NAME_RE.test(name)) {
457
+ send(res, 400, { error: 'invalid or missing "name": letters, digits, _ - . only, up to 64, starting with a letter or digit' });
458
+ return true;
459
+ }
460
+ const seat = typeof body.seat === 'string' && body.seat.trim() ? body.seat.trim() : undefined;
461
+ const models = Array.isArray(body.models)
462
+ ? body.models.filter((m) => typeof m === 'string' && m.trim().length > 0).map((m) => m.trim())
463
+ : typeof body.models === 'string' ? body.models.split(',').map((m) => m.trim()).filter(Boolean) : undefined;
464
+ let expiresAt;
465
+ if (typeof body.expires === 'string' && body.expires.trim()) {
466
+ const parsed = parseExpiry(body.expires, now);
467
+ if (parsed === null) {
468
+ send(res, 400, { error: 'invalid "expires": use 30d, 12h, 2w, or an ISO date' });
469
+ return true;
470
+ }
471
+ expiresAt = parsed;
472
+ }
473
+ try {
474
+ const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, now }));
475
+ deps.audit?.({ action: 'key_create', ok: true, status: 201, key: made.record.name, remote, detail: seat ? `seat=${seat}` : undefined });
476
+ send(res, 201, { key: publicKey(made.record, now), secret: made.secret, note: 'the secret is shown once and is not stored' });
477
+ }
478
+ catch (err) {
479
+ const message = err.message;
480
+ const status = /already exists/.test(message) ? 409 : 400;
481
+ deps.audit?.({ action: 'key_create', ok: false, status, key: name, remote });
482
+ send(res, status, { error: message });
483
+ }
484
+ return true;
485
+ }
486
+ if (urlPath === '/admin/keys') {
487
+ send(res, 405, { error: 'Method not allowed (use GET or POST)' });
488
+ return true;
489
+ }
490
+ if (!KEY_NAME_RE.test(keyName)) {
491
+ send(res, 400, { error: 'invalid key name' });
492
+ return true;
493
+ }
494
+ // POST /admin/keys/<name>/rotate — a new secret under the same name; the old one stops at once.
495
+ if (isKeyRotate) {
496
+ if (method !== 'POST') {
497
+ send(res, 405, { error: 'Method not allowed (use POST)' });
498
+ return true;
499
+ }
500
+ const rotated = store.mutate((file) => rotateKey(file, keyName, now));
501
+ deps.audit?.({ action: 'key_rotate', ok: rotated !== null, status: rotated ? 200 : 404, key: keyName, remote });
502
+ if (!rotated) {
503
+ send(res, 404, { error: `no key named "${keyName}"` });
504
+ return true;
505
+ }
506
+ send(res, 200, { key: publicKey(rotated.record, now), secret: rotated.secret, note: 'the secret is shown once and is not stored' });
507
+ return true;
508
+ }
509
+ // DELETE /admin/keys/<name> — revoked, kept for the list.
510
+ if (isKeyRevoke) {
511
+ const revoked = store.mutate((file) => revokeKey(file, keyName));
512
+ deps.audit?.({ action: 'key_revoke', ok: revoked, status: revoked ? 200 : 404, key: keyName, remote });
513
+ send(res, revoked ? 200 : 404, { name: keyName, revoked });
514
+ return true;
515
+ }
516
+ }
418
517
  send(res, 405, { error: 'Method not allowed' });
419
518
  return true;
420
519
  }
package/dist/cli.js CHANGED
@@ -17,7 +17,8 @@
17
17
  // just want `parsePositiveIntEnv`) doesn't trigger a Bun relaunch or any
18
18
  // other startup side effect.
19
19
  import { unlink, writeFile } from 'node:fs/promises';
20
- import { formatLedgerSummary, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
20
+ import { formatLedgerSummary, formatLedgerConsumers, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
21
+ import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE } from './keys.js';
21
22
  import { loadAllAccounts as loadAllAccountsForIdentity, regenerateClientIdentity } from './accounts.js';
22
23
  import { maskEmail } from './pool.js';
23
24
  import { realpathSync, readFileSync } from 'node:fs';
@@ -635,6 +636,12 @@ async function proxy() {
635
636
  // On by default; see ProxyOptions.ledger.
636
637
  const ledger = !(args.includes('--no-ledger')
637
638
  || ['0', 'false', 'no', 'off'].includes((process.env['DARIO_LEDGER'] ?? '').toLowerCase()));
639
+ // --no-keys / DARIO_KEYS=0 — ignore ~/.dario/keys.json (v6.8, dario#1318);
640
+ // --keys-path=<file> / DARIO_KEYS_PATH moves it. See ProxyOptions.keys.
641
+ const keys = !(args.includes('--no-keys')
642
+ || ['0', 'false', 'no', 'off'].includes((process.env['DARIO_KEYS'] ?? '').toLowerCase()));
643
+ const keysPathArg = args.find(a => a.startsWith('--keys-path='));
644
+ const keysPath = keysPathArg ? keysPathArg.slice('--keys-path='.length) : undefined;
638
645
  // --preserve-output-format — carry the client body's `output_config.format`
639
646
  // (structured-output JSON schema) through to upstream instead of dropping it
640
647
  // during the CC rebuild. See ProxyOptions.preserveOutputFormat for rationale.
@@ -659,7 +666,166 @@ async function proxy() {
659
666
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
660
667
  process.exit(1);
661
668
  }
662
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, poolStrategy, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger });
669
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, poolStrategy, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath });
670
+ }
671
+ /**
672
+ * `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
673
+ * credential per developer, stored as a hash in ~/.dario/keys.json; the
674
+ * secret is printed once, here, and nowhere else. The running proxy re-reads
675
+ * the file on the next request, so nothing restarts.
676
+ */
677
+ async function keys() {
678
+ const sub = args[1];
679
+ const asJson = args.includes('--json');
680
+ const pathArg = args.find(a => a.startsWith('--keys-path='));
681
+ const path = pathArg ? pathArg.slice('--keys-path='.length) : resolveKeysPath();
682
+ const store = new KeyStore(path);
683
+ const now = Date.now();
684
+ const fmtDay = (iso) => iso ? iso.slice(0, 10) : '-';
685
+ const fmtAgo = (iso) => {
686
+ if (!iso)
687
+ return 'never';
688
+ const ms = now - Date.parse(iso);
689
+ if (ms < 60_000)
690
+ return 'just now';
691
+ if (ms < 3_600_000)
692
+ return `${Math.floor(ms / 60_000)}m ago`;
693
+ if (ms < 86_400_000)
694
+ return `${Math.floor(ms / 3_600_000)}h ago`;
695
+ return `${Math.floor(ms / 86_400_000)}d ago`;
696
+ };
697
+ const printSecret = (verb, k, secret) => {
698
+ if (asJson) {
699
+ process.stdout.write(JSON.stringify({ key: k, secret }, null, 2) + '\n');
700
+ return;
701
+ }
702
+ console.log('');
703
+ console.log(` Key "${k.name}" ${verb} (id ${k.id}).`);
704
+ console.log('');
705
+ console.log(` ${secret}`);
706
+ console.log('');
707
+ console.log(' Shown once; dario keeps only a hash. Give it to the client as its API');
708
+ console.log(' key — every request it authenticates is attributed to this name in');
709
+ console.log(' /analytics, the ledger (`dario usage --by-key`) and the log.');
710
+ if (k.seat)
711
+ console.log(` Preferred seat: ${k.seat} (used when it has headroom; otherwise normal routing)`);
712
+ if (k.models.length > 0)
713
+ console.log(` Models: ${k.models.join(', ')} (anything else is refused with 403)`);
714
+ if (k.expires)
715
+ console.log(` Expires: ${k.expires}`);
716
+ console.log(' A running proxy picks this up on its next request; no restart.');
717
+ console.log('');
718
+ };
719
+ if (!sub || sub === 'list' || sub === 'ls') {
720
+ store.load();
721
+ if (store.error) {
722
+ console.error(`[dario] keys: ${path} is unreadable: ${store.error}`);
723
+ process.exit(1);
724
+ }
725
+ const list = store.list(now);
726
+ if (asJson) {
727
+ process.stdout.write(JSON.stringify({ path, keys: list, count: list.length }, null, 2) + '\n');
728
+ return;
729
+ }
730
+ console.log('');
731
+ console.log(' dario — Keys');
732
+ console.log(' ────────────');
733
+ console.log('');
734
+ if (list.length === 0) {
735
+ console.log(` No named keys yet (${path}).`);
736
+ console.log('');
737
+ console.log(' One key per developer on a shared dario, attributed by credential:');
738
+ console.log('');
739
+ console.log(' dario keys create alice');
740
+ console.log(' dario keys create bob --seat=bobs-max --models=claude-sonnet-5');
741
+ console.log('');
742
+ return;
743
+ }
744
+ const w = Math.max(4, ...list.map((k) => k.name.length));
745
+ console.log(` ${'NAME'.padEnd(w)} ${'STATUS'.padEnd(7)} ${'SEAT'.padEnd(12)} ${'LAST USED'.padEnd(10)} ${'EXPIRES'.padEnd(10)} MODELS`);
746
+ for (const k of list) {
747
+ console.log(` ${k.name.padEnd(w)} ${k.status.padEnd(7)} ${(k.seat ?? '-').padEnd(12)} ${fmtAgo(k.last_used).padEnd(10)} ${fmtDay(k.expires).padEnd(10)} ${k.models.length ? k.models.join(', ') : 'any'}`);
748
+ }
749
+ console.log('');
750
+ console.log(` ${list.length} key${list.length === 1 ? '' : 's'} in ${path}. Spend per key: dario usage --by-key`);
751
+ console.log('');
752
+ return;
753
+ }
754
+ if (sub === 'create' || sub === 'add' || sub === 'new') {
755
+ const name = args[2];
756
+ if (!name || name.startsWith('--')) {
757
+ console.error('');
758
+ console.error(' Usage: dario keys create <name> [--seat=<alias>] [--models=a,b,prefix*] [--expires=30d|12h|2w|<ISO date>]');
759
+ console.error('');
760
+ process.exit(1);
761
+ }
762
+ const seatArg = args.find(a => a.startsWith('--seat='));
763
+ const modelsArg = args.find(a => a.startsWith('--models='));
764
+ const expiresArg = args.find(a => a.startsWith('--expires='));
765
+ const seat = seatArg ? seatArg.slice('--seat='.length).trim() : undefined;
766
+ const models = modelsArg ? modelsArg.slice('--models='.length).split(',').map((m) => m.trim()).filter(Boolean) : undefined;
767
+ let expiresAt;
768
+ if (expiresArg) {
769
+ const parsed = parseExpiry(expiresArg.slice('--expires='.length), now);
770
+ if (parsed === null) {
771
+ console.error(`[dario] --expires: "${expiresArg.slice('--expires='.length)}" is not 30d, 12h, 2w or an ISO date.`);
772
+ process.exit(1);
773
+ }
774
+ expiresAt = parsed;
775
+ }
776
+ try {
777
+ const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, now }));
778
+ printSecret('created', publicKey(made.record, now), made.secret);
779
+ }
780
+ catch (err) {
781
+ console.error(`[dario] ${err instanceof Error ? err.message : String(err)}`);
782
+ process.exit(1);
783
+ }
784
+ return;
785
+ }
786
+ if (sub === 'rotate' || sub === 'revoke' || sub === 'remove' || sub === 'rm' || sub === 'delete') {
787
+ const name = args[2];
788
+ if (!name || !KEY_NAME_RE.test(name)) {
789
+ console.error('');
790
+ console.error(` Usage: dario keys ${sub} <name>`);
791
+ console.error('');
792
+ process.exit(1);
793
+ }
794
+ try {
795
+ if (sub === 'rotate') {
796
+ const rotated = store.mutate((file) => rotateKey(file, name, now));
797
+ if (!rotated) {
798
+ console.error(`[dario] No key named "${name}".`);
799
+ process.exit(1);
800
+ }
801
+ printSecret('rotated — the old secret stopped working', publicKey(rotated.record, now), rotated.secret);
802
+ return;
803
+ }
804
+ if (sub === 'revoke') {
805
+ const ok = store.mutate((file) => revokeKey(file, name));
806
+ if (!ok) {
807
+ console.error(`[dario] No key named "${name}".`);
808
+ process.exit(1);
809
+ }
810
+ console.log(`[dario] Key "${name}" revoked. It stays in the list; \`dario keys remove ${name}\` forgets it.`);
811
+ return;
812
+ }
813
+ const ok = store.mutate((file) => deleteKey(file, name));
814
+ if (!ok) {
815
+ console.error(`[dario] No key named "${name}".`);
816
+ process.exit(1);
817
+ }
818
+ console.log(`[dario] Key "${name}" removed.`);
819
+ }
820
+ catch (err) {
821
+ console.error(`[dario] ${err instanceof Error ? err.message : String(err)}`);
822
+ process.exit(1);
823
+ }
824
+ return;
825
+ }
826
+ console.error(`[dario] Unknown keys subcommand: ${sub}`);
827
+ console.error('Usage: dario keys [list|create <name> [--seat=..] [--models=..] [--expires=..]|revoke <name>|rotate <name>|remove <name>] [--json] [--keys-path=<file>]');
828
+ process.exit(1);
663
829
  }
664
830
  /**
665
831
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
@@ -1505,6 +1671,25 @@ async function help() {
1505
1671
  entry by its platform identifier (Linux account
1506
1672
  attribute, Windows TargetName).
1507
1673
  dario accounts remove N Remove an account from the pool
1674
+ dario keys create NAME [--seat=ALIAS] [--models=a,b,prefix*] [--expires=30d]
1675
+ Mint a named key for one developer on a shared
1676
+ dario (v6.8). Prints the secret once; stores a
1677
+ hash in ~/.dario/keys.json. Requests it
1678
+ authenticates are attributed to NAME in
1679
+ /analytics, the ledger and the log, next to
1680
+ the root DARIO_API_KEY which keeps working.
1681
+ --seat prefers a pool seat when it has
1682
+ headroom (normal routing otherwise); --models
1683
+ refuses any other model with 403 before
1684
+ anything goes upstream; --expires refuses the
1685
+ key after 30d / 12h / 2w / an ISO date. The
1686
+ running proxy sees it on the next request.
1687
+ dario keys list Named keys: status, seat, last used, expiry,
1688
+ models. --json for the raw list.
1689
+ dario keys revoke NAME Refuse a key from now on (kept in the list).
1690
+ dario keys rotate NAME New secret, same name and settings; the old
1691
+ secret stops at once.
1692
+ dario keys remove NAME Forget a key entirely.
1508
1693
  dario codex list List ChatGPT-subscription accounts, served on
1509
1694
  /v1/chat/completions.
1510
1695
  dario codex add NAME Add a ChatGPT-subscription account (prints an
@@ -1594,6 +1779,9 @@ async function help() {
1594
1779
  down). --card[=file.svg] writes a share
1595
1780
  card of that number (default
1596
1781
  dario-api-equivalent.svg). (v6.6)
1782
+ --by-key splits the lifetime number per
1783
+ consumer: named key, x-dario-consumer
1784
+ header, or hashed user id. (v6.8)
1597
1785
  dario compare Read the shadow-compare log written by
1598
1786
  requests carrying \`x-dario-compare\`:
1599
1787
  per-model calls, success rate, median
@@ -1729,6 +1917,10 @@ async function help() {
1729
1917
  first request, across restarts. Env:
1730
1918
  DARIO_LEDGER=0; DARIO_LEDGER_PATH=<file>
1731
1919
  moves it. (v6.6)
1920
+ --no-keys Ignore named keys (~/.dario/keys.json): only
1921
+ DARIO_API_KEY authenticates. Env: DARIO_KEYS=0;
1922
+ --keys-path=<file> / DARIO_KEYS_PATH moves the
1923
+ file. See \`dario keys\`. (v6.8)
1732
1924
  --session-idle-rotate=MS Idle ms before an account's session id
1733
1925
  rotates (default: 900000 = 15 min).
1734
1926
  Real CC rotates once per conversation, not
@@ -2353,8 +2545,13 @@ async function usage() {
2353
2545
  const url = `http://127.0.0.1:${port}/analytics`;
2354
2546
  let payload = null;
2355
2547
  let connectError = null;
2548
+ // A proxy with DARIO_API_KEY set gates /analytics too; present the key when
2549
+ // the environment has it, as `accounts list --live` does (v6.8).
2550
+ const usageHeaders = {};
2551
+ if (process.env['DARIO_API_KEY'])
2552
+ usageHeaders['x-api-key'] = process.env['DARIO_API_KEY'];
2356
2553
  try {
2357
- const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
2554
+ const res = await fetch(url, { signal: AbortSignal.timeout(3000), headers: usageHeaders });
2358
2555
  if (!res.ok) {
2359
2556
  connectError = `proxy responded ${res.status}`;
2360
2557
  }
@@ -2408,6 +2605,18 @@ async function usage() {
2408
2605
  for (const line of formatLedgerSummary(lifetime))
2409
2606
  console.log(line);
2410
2607
  console.log('');
2608
+ // --by-key: the same number split by consumer — named key, header, or
2609
+ // hashed user id (v6.8, dario#1318).
2610
+ const consumers = Object.keys(lifetime.perConsumer ?? {}).length;
2611
+ if (args.includes('--by-key')) {
2612
+ for (const line of formatLedgerConsumers(lifetime))
2613
+ console.log(line);
2614
+ console.log('');
2615
+ }
2616
+ else if (consumers > 0) {
2617
+ console.log(` ${consumers} consumer${consumers === 1 ? '' : 's'} named — \`dario usage --by-key\` splits the number per key.`);
2618
+ console.log('');
2619
+ }
2411
2620
  }
2412
2621
  else if (lifetimeNote) {
2413
2622
  console.log(` API-equivalent spend: ${lifetimeNote}.`);
@@ -2612,6 +2821,7 @@ const commands = {
2612
2821
  resume,
2613
2822
  logout,
2614
2823
  accounts,
2824
+ keys,
2615
2825
  add,
2616
2826
  codex,
2617
2827
  backend,
package/dist/keys.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ export declare const KEYS_VERSION = 1;
2
+ export declare const KEY_PREFIX = "dk_";
3
+ /** Same charset as a pool alias — a key name is printed next to one. */
4
+ export declare const KEY_NAME_RE: RegExp;
5
+ export declare const KEYS_FLUSH_DELAY_MS = 3000;
6
+ export interface KeyRecord {
7
+ /** Stable short id (8 hex), for logs and rotation; never the secret. */
8
+ id: string;
9
+ name: string;
10
+ /** sha256 hex of the secret. */
11
+ hash: string;
12
+ /** ISO timestamp. */
13
+ created: string;
14
+ /** ISO timestamp of the last request this key authenticated. */
15
+ lastUsed?: string;
16
+ /** Refused, kept for the record. `dario keys revoke` sets it; `list` shows it. */
17
+ disabled?: boolean;
18
+ /** ISO timestamp; refused after. */
19
+ expires?: string;
20
+ /** Preferred pool seat alias. */
21
+ seat?: string;
22
+ /** Model allowlist: exact ids, or `prefix*`. Empty / absent = any model. */
23
+ models?: string[];
24
+ }
25
+ export interface KeysFile {
26
+ version: number;
27
+ keys: KeyRecord[];
28
+ }
29
+ export declare function keysPathFor(home?: string): string;
30
+ export declare function resolveKeysPath(env?: NodeJS.ProcessEnv): string;
31
+ /**
32
+ * sha256, not a slow KDF, on purpose: a key is 24 bytes from `randomBytes`
33
+ * (192 bits of entropy), never a human-chosen password, so a guess is not
34
+ * a threat a KDF could slow down, and this hash runs once per request on the
35
+ * hot path. This is how GitHub and Stripe store their API tokens.
36
+ * (CodeQL's js/insufficient-password-hash fires on the x-api-key source and
37
+ * is dismissed as a false positive for exactly this reason.)
38
+ */
39
+ export declare function hashKey(secret: string): string;
40
+ /** `dk_` + 48 hex characters. The prefix lets a reject log say "a named key" without the value. */
41
+ export declare function mintSecret(): string;
42
+ export declare function looksLikeNamedKey(value: string | undefined): boolean;
43
+ export declare function emptyKeysFile(): KeysFile;
44
+ /**
45
+ * Parse a keys file's text, keeping only well-formed records. A file that is
46
+ * not a keys file at all throws; the caller decides whether to move it aside.
47
+ */
48
+ export declare function parseKeysFile(text: string): KeysFile;
49
+ /** Missing file → empty. Unreadable or malformed → throws (never silently empty: that would "revoke" everyone). */
50
+ export declare function readKeysFile(path: string): KeysFile;
51
+ /** Atomic, 0600, parent 0700 — the same primitive config.json uses. */
52
+ export declare function writeKeysFile(path: string, file: KeysFile): void;
53
+ export interface CreateKeyOptions {
54
+ seat?: string;
55
+ models?: string[];
56
+ /** Absolute expiry, epoch ms. */
57
+ expiresAt?: number;
58
+ now?: number;
59
+ }
60
+ /** Mint a key. Returns the record (stored) and the secret (shown once). Mutates `file`. */
61
+ export declare function createKey(file: KeysFile, name: string, opts?: CreateKeyOptions): {
62
+ record: KeyRecord;
63
+ secret: string;
64
+ };
65
+ /** Mark a key refused. Returns false when there is no such key. The record stays, for the list. */
66
+ export declare function revokeKey(file: KeysFile, name: string): boolean;
67
+ /** Forget a key entirely (list no longer shows it). */
68
+ export declare function deleteKey(file: KeysFile, name: string): boolean;
69
+ /** New secret, same name, seat, models and expiry; the old secret stops working at once. */
70
+ export declare function rotateKey(file: KeysFile, name: string, now?: number): {
71
+ record: KeyRecord;
72
+ secret: string;
73
+ } | null;
74
+ export declare function keyIsUsable(k: KeyRecord, now?: number): boolean;
75
+ /**
76
+ * The record a presented secret belongs to, or null. Compares hashes in
77
+ * constant time, every record every time, so a miss takes as long as a hit
78
+ * and neither the count of keys nor which one matched leaks through timing.
79
+ * A disabled or expired key matches nothing — the caller cannot tell it from
80
+ * a wrong secret, on purpose.
81
+ */
82
+ export declare function matchKey(file: KeysFile, provided: string, now?: number): KeyRecord | null;
83
+ /** `models` entries are exact ids, or `prefix*`; case-insensitive. Absent list = any model. */
84
+ export declare function keyAllowsModel(k: Pick<KeyRecord, 'models'>, model: string | null | undefined): boolean;
85
+ /** What `dario keys list` and `GET /admin/keys` show: everything but the hash. */
86
+ export interface KeyPublic {
87
+ id: string;
88
+ name: string;
89
+ created: string;
90
+ last_used: string | null;
91
+ status: 'active' | 'revoked' | 'expired';
92
+ expires: string | null;
93
+ seat: string | null;
94
+ models: string[];
95
+ }
96
+ export declare function publicKey(k: KeyRecord, now?: number): KeyPublic;
97
+ /** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
98
+ export declare function parseExpiry(value: string, now?: number): number | null;
99
+ /**
100
+ * The proxy's live view of the file. Reloads when the file's mtime moves (a
101
+ * stat per auth — the cost of "no restart"), records last-used with a
102
+ * debounced write that re-reads first so it never clobbers an edit the CLI
103
+ * made in between.
104
+ */
105
+ export declare class KeyStore {
106
+ readonly path: string;
107
+ private file;
108
+ private mtimeMs;
109
+ private loadError;
110
+ private dirtyLastUsed;
111
+ private flushTimer;
112
+ constructor(path: string);
113
+ /** Read the file now. A malformed file is reported and leaves the last good state in place. */
114
+ load(): void;
115
+ get error(): string | null;
116
+ size(): number;
117
+ list(now?: number): KeyPublic[];
118
+ /** The record a request's credential names, or null. Reloads first when the file moved. */
119
+ match(provided: string | undefined, now?: number): KeyRecord | null;
120
+ /** Note a use; written to disk after a quiet moment. */
121
+ touch(k: KeyRecord, now?: number): void;
122
+ /** Apply a mutation to the file on disk (re-read first), then adopt it. */
123
+ mutate<T>(fn: (file: KeysFile) => T): T;
124
+ /** Persist pending last-used stamps. Safe to call at any time; a no-op when nothing is pending. */
125
+ flush(): void;
126
+ close(): void;
127
+ }