@askalf/dario 4.8.105 → 4.8.106

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.
@@ -39,6 +39,27 @@ export declare function addAccountViaOAuth(alias: string): Promise<AccountCreden
39
39
  * full URL to the browser.
40
40
  */
41
41
  export declare function addAccountViaManualOAuth(alias: string): Promise<AccountCredentials>;
42
+ /**
43
+ * Non-interactive first half of the manual add-account flow (#599): validate
44
+ * the alias, generate PKCE + state, and build the authorize URL the user opens
45
+ * in a browser. The caller keeps `codeVerifier` + `state` and passes them back
46
+ * to `completeAddAccount` after the user supplies the displayed code. Shared by
47
+ * the `dario accounts add --manual` CLI and the headless admin API — the secret
48
+ * (codeVerifier) never leaves the process that started the flow.
49
+ */
50
+ export declare function startAddAccount(alias: string): Promise<{
51
+ authorizeUrl: string;
52
+ codeVerifier: string;
53
+ state: string;
54
+ }>;
55
+ /**
56
+ * Non-interactive second half (#599): exchange the authorization `code` for
57
+ * tokens (PKCE-verified server-side), attach a device/account identity, and
58
+ * persist the account to `~/.dario/accounts/<alias>.json`. Throws — with any
59
+ * upstream secrets redacted — on a failed exchange. Shared by the CLI and the
60
+ * admin API.
61
+ */
62
+ export declare function completeAddAccount(alias: string, code: string, codeVerifier: string, state: string): Promise<AccountCredentials>;
42
63
  export declare function getAccountsDir(): string;
43
64
  /**
44
65
  * Error subclass for the keychain-import path so the CLI can render
package/dist/accounts.js CHANGED
@@ -329,15 +329,11 @@ export async function addAccountViaOAuth(alias) {
329
329
  * full URL to the browser.
330
330
  */
331
331
  export async function addAccountViaManualOAuth(alias) {
332
- const cfg = await detectCCOAuthConfig();
333
- const { codeVerifier, codeChallenge } = generatePKCE();
334
- // 32-byte state — same constraint as the auto flow. See dario#71.
335
- const state = base64url(randomBytes(32));
336
- const authUrl = buildManualAuthorizeUrl(cfg, codeChallenge, state);
332
+ const { authorizeUrl, codeVerifier, state } = await startAddAccount(alias);
337
333
  console.log('');
338
334
  console.log(` Open this URL in any browser to add account "${alias}":`);
339
335
  console.log('');
340
- console.log(` ${authUrl}`);
336
+ console.log(` ${authorizeUrl}`);
341
337
  console.log('');
342
338
  console.log(' Sign in with the Claude account you want to add. After you approve,');
343
339
  console.log(' Anthropic will display an authorization code. Paste it below');
@@ -351,6 +347,39 @@ export async function addAccountViaManualOAuth(alias) {
351
347
  if (returnedState && returnedState !== state) {
352
348
  throw new Error(`State mismatch — the pasted code is from a different login attempt. Re-run \`dario accounts add ${alias} --manual\` and paste the most recent code.`);
353
349
  }
350
+ return completeAddAccount(alias, code, codeVerifier, state);
351
+ }
352
+ /**
353
+ * Non-interactive first half of the manual add-account flow (#599): validate
354
+ * the alias, generate PKCE + state, and build the authorize URL the user opens
355
+ * in a browser. The caller keeps `codeVerifier` + `state` and passes them back
356
+ * to `completeAddAccount` after the user supplies the displayed code. Shared by
357
+ * the `dario accounts add --manual` CLI and the headless admin API — the secret
358
+ * (codeVerifier) never leaves the process that started the flow.
359
+ */
360
+ export async function startAddAccount(alias) {
361
+ if (!safeAliasPath(alias)) {
362
+ throw new Error(`invalid account alias "${alias}" (allowed: letters, digits, _-. — up to 64 chars, no path separators)`);
363
+ }
364
+ const cfg = await detectCCOAuthConfig();
365
+ const { codeVerifier, codeChallenge } = generatePKCE();
366
+ // 32-byte state — same constraint as the auto flow. See dario#71.
367
+ const state = base64url(randomBytes(32));
368
+ const authorizeUrl = buildManualAuthorizeUrl(cfg, codeChallenge, state);
369
+ return { authorizeUrl, codeVerifier, state };
370
+ }
371
+ /**
372
+ * Non-interactive second half (#599): exchange the authorization `code` for
373
+ * tokens (PKCE-verified server-side), attach a device/account identity, and
374
+ * persist the account to `~/.dario/accounts/<alias>.json`. Throws — with any
375
+ * upstream secrets redacted — on a failed exchange. Shared by the CLI and the
376
+ * admin API.
377
+ */
378
+ export async function completeAddAccount(alias, code, codeVerifier, state) {
379
+ if (!safeAliasPath(alias)) {
380
+ throw new Error(`invalid account alias "${alias}"`);
381
+ }
382
+ const cfg = await detectCCOAuthConfig();
354
383
  const tokenRes = await fetch(cfg.tokenUrl, {
355
384
  method: 'POST',
356
385
  headers: { 'Content-Type': 'application/json' },
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Headless admin API (#599) — an opt-in HTTP control plane for managing the
3
+ * account pool without console access. Mounted at `/admin/*` by the proxy
4
+ * (src/proxy.ts) ONLY when `DARIO_ADMIN=1`.
5
+ *
6
+ * Endpoints (all require the admin bearer token — `DARIO_ADMIN_TOKEN`, or
7
+ * `DARIO_API_KEY` as a fallback — even on loopback, since they add/remove
8
+ * OAuth credentials):
9
+ *
10
+ * POST /admin/login/start { alias } -> { login_id, authorize_url, expires_at }
11
+ * POST /admin/login/complete { login_id, code } -> { alias, status, expires_at }
12
+ * GET /admin/accounts -> { accounts: [...], count }
13
+ * DELETE /admin/accounts/<alias> -> { alias, removed }
14
+ *
15
+ * The login flow mirrors `dario accounts add --manual` (PKCE + manual paste):
16
+ * `/start` returns the authorize URL the operator opens in a browser; they POST
17
+ * the code Anthropic displays back to `/complete`. The PKCE verifier + state
18
+ * live in an in-memory map keyed by `login_id` with a short TTL — never on
19
+ * disk, never returned to the client, single-use.
20
+ *
21
+ * Account changes take effect on the next proxy restart (the pool is built at
22
+ * startup, src/proxy.ts). Hot-reload of a running pool is a deliberate
23
+ * follow-up — keeping this surface small while it manipulates credentials.
24
+ */
25
+ import type { IncomingMessage, ServerResponse } from 'node:http';
26
+ export interface AdminDeps {
27
+ /** Admin bearer token buffer; `null` = enabled but no token configured (fail closed). */
28
+ adminTokenBuf: Buffer | null;
29
+ /** Invoked after an account is added or removed (e.g. to log / signal a reload). */
30
+ onAccountsChanged?: () => void;
31
+ }
32
+ /**
33
+ * Handle an `/admin/*` request. Returns `true` if it owned the request (matched
34
+ * one of its routes and wrote a response), `false` if the path isn't one of
35
+ * ours — so the caller's existing routing (incl. the pre-existing
36
+ * `/admin/resume`) and the `DARIO_API_KEY` gate still run.
37
+ */
38
+ export declare function handleAdminRequest(req: IncomingMessage, res: ServerResponse, urlPath: string, deps: AdminDeps): Promise<boolean>;
39
+ /** Test-only: clear the pending-login map between cases. */
40
+ export declare function _resetAdminStateForTest(): void;
@@ -0,0 +1,199 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases, loadAccount, } from './accounts.js';
3
+ import { parseManualPaste } from './oauth.js';
4
+ const PENDING_TTL_MS = 10 * 60_000;
5
+ const MAX_PENDING = 64; // backstop against unbounded growth from repeated /start
6
+ const ACCOUNTS_PREFIX = '/admin/accounts/';
7
+ const pendingLogins = new Map();
8
+ function prunePending(now) {
9
+ for (const [id, p] of pendingLogins) {
10
+ if (p.expiresAt <= now)
11
+ pendingLogins.delete(id);
12
+ }
13
+ }
14
+ const HEADERS = {
15
+ 'Content-Type': 'application/json',
16
+ 'X-Content-Type-Options': 'nosniff',
17
+ 'Cache-Control': 'no-store',
18
+ };
19
+ function send(res, status, body) {
20
+ res.writeHead(status, HEADERS);
21
+ res.end(JSON.stringify(body));
22
+ }
23
+ /** Constant-time bearer / x-api-key check. Fails closed when no token configured. */
24
+ function adminAuthOk(req, tokenBuf) {
25
+ if (!tokenBuf)
26
+ return false;
27
+ const provided = req.headers['x-api-key']
28
+ || req.headers.authorization?.replace(/^Bearer\s+/i, '');
29
+ if (!provided)
30
+ return false;
31
+ const providedBuf = Buffer.from(provided);
32
+ if (providedBuf.length !== tokenBuf.length)
33
+ return false;
34
+ return timingSafeEqual(providedBuf, tokenBuf);
35
+ }
36
+ async function readJsonBody(req, limitBytes = 64 * 1024) {
37
+ return new Promise((resolve, reject) => {
38
+ const chunks = [];
39
+ let size = 0;
40
+ req.on('data', (c) => {
41
+ size += c.length;
42
+ if (size > limitBytes) {
43
+ req.destroy();
44
+ reject(new Error('request body too large'));
45
+ return;
46
+ }
47
+ chunks.push(c);
48
+ });
49
+ req.on('end', () => {
50
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
51
+ if (!raw) {
52
+ resolve({});
53
+ return;
54
+ }
55
+ try {
56
+ resolve(JSON.parse(raw));
57
+ }
58
+ catch {
59
+ reject(new Error('invalid JSON body'));
60
+ }
61
+ });
62
+ req.on('error', reject);
63
+ });
64
+ }
65
+ /**
66
+ * Handle an `/admin/*` request. Returns `true` if it owned the request (matched
67
+ * one of its routes and wrote a response), `false` if the path isn't one of
68
+ * ours — so the caller's existing routing (incl. the pre-existing
69
+ * `/admin/resume`) and the `DARIO_API_KEY` gate still run.
70
+ */
71
+ export async function handleAdminRequest(req, res, urlPath, deps) {
72
+ const method = req.method ?? 'GET';
73
+ const isAccountDelete = method === 'DELETE' && urlPath.startsWith(ACCOUNTS_PREFIX) && urlPath.length > ACCOUNTS_PREFIX.length;
74
+ const known = urlPath === '/admin/login/start' ||
75
+ urlPath === '/admin/login/complete' ||
76
+ urlPath === '/admin/accounts' ||
77
+ isAccountDelete;
78
+ if (!known)
79
+ return false;
80
+ // Auth — always required, even on loopback (these mutate OAuth credentials).
81
+ if (!adminAuthOk(req, deps.adminTokenBuf)) {
82
+ if (!deps.adminTokenBuf) {
83
+ send(res, 403, { error: 'admin API enabled but no token configured — set DARIO_ADMIN_TOKEN (or DARIO_API_KEY)' });
84
+ }
85
+ else {
86
+ send(res, 401, { error: 'Unauthorized', message: 'invalid or missing admin token' });
87
+ }
88
+ return true;
89
+ }
90
+ const now = Date.now();
91
+ prunePending(now);
92
+ try {
93
+ // POST /admin/login/start { alias }
94
+ if (urlPath === '/admin/login/start') {
95
+ if (method !== 'POST') {
96
+ send(res, 405, { error: 'Method not allowed (use POST)' });
97
+ return true;
98
+ }
99
+ const body = await readJsonBody(req);
100
+ const alias = typeof body.alias === 'string' ? body.alias.trim() : '';
101
+ if (!alias) {
102
+ send(res, 400, { error: 'missing "alias"' });
103
+ return true;
104
+ }
105
+ if (pendingLogins.size >= MAX_PENDING) {
106
+ send(res, 429, { error: 'too many pending logins; complete or wait for one to expire' });
107
+ return true;
108
+ }
109
+ const { authorizeUrl, codeVerifier, state } = await startAddAccount(alias); // throws on invalid alias
110
+ const loginId = randomUUID();
111
+ const expiresAt = now + PENDING_TTL_MS;
112
+ pendingLogins.set(loginId, { alias, codeVerifier, state, expiresAt });
113
+ send(res, 200, {
114
+ login_id: loginId,
115
+ authorize_url: authorizeUrl,
116
+ expires_at: new Date(expiresAt).toISOString(),
117
+ instructions: 'Open authorize_url, approve, then POST the displayed code to /admin/login/complete with this login_id.',
118
+ });
119
+ return true;
120
+ }
121
+ // POST /admin/login/complete { login_id, code }
122
+ if (urlPath === '/admin/login/complete') {
123
+ if (method !== 'POST') {
124
+ send(res, 405, { error: 'Method not allowed (use POST)' });
125
+ return true;
126
+ }
127
+ const body = await readJsonBody(req);
128
+ const loginId = typeof body.login_id === 'string' ? body.login_id : '';
129
+ const rawCode = typeof body.code === 'string' ? body.code : '';
130
+ if (!loginId || !rawCode) {
131
+ send(res, 400, { error: 'missing "login_id" or "code"' });
132
+ return true;
133
+ }
134
+ const p = pendingLogins.get(loginId);
135
+ if (!p || p.expiresAt <= now) {
136
+ pendingLogins.delete(loginId);
137
+ send(res, 410, { error: 'login_id unknown or expired — start a new login' });
138
+ return true;
139
+ }
140
+ // Accept "code#state" or a bare code; verify the embedded state if present.
141
+ const { code, state: pastedState } = parseManualPaste(rawCode);
142
+ if (!code) {
143
+ send(res, 400, { error: 'no authorization code found in "code"' });
144
+ return true;
145
+ }
146
+ if (pastedState && pastedState !== p.state) {
147
+ send(res, 400, { error: 'state mismatch — code is from a different login attempt' });
148
+ return true;
149
+ }
150
+ pendingLogins.delete(loginId); // single-use, regardless of exchange outcome
151
+ const creds = await completeAddAccount(p.alias, code, p.codeVerifier, p.state);
152
+ deps.onAccountsChanged?.();
153
+ send(res, 200, { alias: creds.alias, status: 'added', expires_at: new Date(creds.expiresAt).toISOString() });
154
+ return true;
155
+ }
156
+ // GET /admin/accounts
157
+ if (urlPath === '/admin/accounts') {
158
+ if (method !== 'GET') {
159
+ send(res, 405, { error: 'Method not allowed (use GET)' });
160
+ return true;
161
+ }
162
+ const aliases = await listAccountAliases();
163
+ const accounts = (await Promise.all(aliases.map(async (alias) => {
164
+ const a = await loadAccount(alias);
165
+ if (!a)
166
+ return null;
167
+ return { alias: a.alias, scopes: a.scopes, expires_in_ms: Math.max(0, a.expiresAt - now) };
168
+ }))).filter((a) => a !== null);
169
+ send(res, 200, {
170
+ accounts,
171
+ count: accounts.length,
172
+ note: 'live rate-limit / utilization is at GET /accounts when pool mode is active',
173
+ });
174
+ return true;
175
+ }
176
+ // DELETE /admin/accounts/<alias>
177
+ if (isAccountDelete) {
178
+ const alias = decodeURIComponent(urlPath.slice(ACCOUNTS_PREFIX.length));
179
+ const removed = await removeAccount(alias); // validates alias internally
180
+ if (removed)
181
+ deps.onAccountsChanged?.();
182
+ send(res, removed ? 200 : 404, { alias, removed });
183
+ return true;
184
+ }
185
+ send(res, 405, { error: 'Method not allowed' });
186
+ return true;
187
+ }
188
+ catch (err) {
189
+ // startAddAccount throws on an invalid alias; completeAddAccount throws
190
+ // (with secrets redacted) on a failed token exchange; readJsonBody throws
191
+ // on oversized / malformed bodies.
192
+ send(res, 400, { error: err.message });
193
+ return true;
194
+ }
195
+ }
196
+ /** Test-only: clear the pending-login map between cases. */
197
+ export function _resetAdminStateForTest() {
198
+ pendingLogins.clear();
199
+ }
package/dist/proxy.js CHANGED
@@ -16,6 +16,7 @@ import { Analytics, billingBucketFromClaim } from './analytics.js';
16
16
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
17
17
  import { notify as osNotify } from './notify.js';
18
18
  import { loadAllAccounts, loadAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale } from './accounts.js';
19
+ import { handleAdminRequest } from './admin-api.js';
19
20
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
20
21
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
21
22
  import { redactSecrets } from './redact.js';
@@ -1115,6 +1116,17 @@ export async function startProxy(opts = {}) {
1115
1116
  // Optional proxy authentication — pre-encode key buffer for performance
1116
1117
  const apiKey = process.env.DARIO_API_KEY;
1117
1118
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
1119
+ // Admin API (#599) — opt-in headless account management at /admin/*. Off
1120
+ // unless DARIO_ADMIN=1. Auth is ALWAYS required (even on loopback) because
1121
+ // these endpoints add/remove OAuth accounts: the admin token is
1122
+ // DARIO_ADMIN_TOKEN, falling back to DARIO_API_KEY. Enabled-but-tokenless
1123
+ // fails closed (403) so account control is never left open on loopback.
1124
+ const adminEnabled = process.env.DARIO_ADMIN === '1';
1125
+ const adminToken = process.env.DARIO_ADMIN_TOKEN || process.env.DARIO_API_KEY || '';
1126
+ const adminTokenBuf = adminToken ? Buffer.from(adminToken) : null;
1127
+ if (adminEnabled) {
1128
+ console.log(`[dario] admin API enabled at /admin/* (token ${adminTokenBuf ? 'configured' : 'MISSING — endpoints return 403 until DARIO_ADMIN_TOKEN is set'})`);
1129
+ }
1118
1130
  // CORS origin defaults to the localhost URL the proxy is served at. Users
1119
1131
  // binding to a non-loopback address (e.g. a Tailscale interface) can
1120
1132
  // override via DARIO_CORS_ORIGIN — otherwise browser-based clients hitting
@@ -1179,6 +1191,23 @@ export async function startProxy(opts = {}) {
1179
1191
  res.end(JSON.stringify(body));
1180
1192
  return;
1181
1193
  }
1194
+ // Admin API (#599) — self-authenticating with the admin token, mounted
1195
+ // BEFORE the DARIO_API_KEY gate so it requires its own token even on
1196
+ // loopback (where the proxy key is otherwise optional). handleAdminRequest
1197
+ // only acts on its own routes (/admin/login/*, /admin/accounts) and returns
1198
+ // false otherwise, so the pre-existing /admin/resume + the key gate below
1199
+ // still apply unchanged.
1200
+ if (adminEnabled && urlPath.startsWith('/admin/')) {
1201
+ const handled = await handleAdminRequest(req, res, urlPath, {
1202
+ adminTokenBuf,
1203
+ onAccountsChanged: () => {
1204
+ if (verbose)
1205
+ console.log('[dario] admin: account pool changed on disk (effective on next proxy restart)');
1206
+ },
1207
+ });
1208
+ if (handled)
1209
+ return;
1210
+ }
1182
1211
  if (!checkAuth(req)) {
1183
1212
  if (verbose) {
1184
1213
  // Silent auth rejects are hard to diagnose when a client's config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.8.105",
3
+ "version": "4.8.106",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {