@askalf/dario 5.4.29 → 5.4.31

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.
@@ -7,10 +7,12 @@
7
7
  * `DARIO_API_KEY` as a fallback — even on loopback, since they add/remove
8
8
  * OAuth credentials):
9
9
  *
10
- * POST /admin/login/start { alias? } -> { alias, authorize_url, expires_at }
11
- * POST /admin/login/complete { alias, code } -> { alias, status, expires_at }
12
- * GET /admin/accounts -> { accounts: [...], count }
13
- * DELETE /admin/accounts/<alias> -> { alias, removed }
10
+ * POST /admin/login/start { alias? } -> { alias, authorize_url, expires_at }
11
+ * POST /admin/login/start-needed { threshold? } -> { started: [...], count, truncated? }
12
+ * POST /admin/login/complete { alias, code } -> { alias, status, expires_at }
13
+ * POST /admin/login/complete { items: [{alias,code}] } -> { results: [...], count, truncated? }
14
+ * GET /admin/accounts -> { accounts: [...], count }
15
+ * DELETE /admin/accounts/<alias> -> { alias, removed }
14
16
  *
15
17
  * The login flow mirrors `dario accounts add --manual` (PKCE + manual paste):
16
18
  * `/start` returns the authorize URL the operator opens in a browser; they POST
@@ -21,11 +23,28 @@
21
23
  * alias is optional on `/start`: omit it and a non-colliding default
22
24
  * (`account-1`, …) is generated and returned for you to pass to `/complete`.
23
25
  *
26
+ * `/admin/login/start-needed` (#913) is the bulk entry point for a pool that's
27
+ * partly broken: it finds every account whose live `consecutiveAuthFailures`
28
+ * has crossed `NEEDS_LOGIN_THRESHOLD`, starts a fresh login for each (same
29
+ * mechanics as `/start` — reuses `doStartLogin`), and returns all the
30
+ * authorize URLs in one response. The threshold, not the raw `auth-cooldown`
31
+ * boolean, is deliberate: a single 401 already shows `auth-cooldown` for 60s,
32
+ * indistinguishable from a genuinely dead refresh token by that field alone
33
+ * (verified empirically — see the PR). `consecutiveAuthFailures` is the only
34
+ * signal that actually separates "blip" from "needs a human."
35
+ *
36
+ * `/admin/login/complete` also accepts a **batch** body (`{ items: [...] }`)
37
+ * instead of a single `{ alias, code }` — completes several pending logins in
38
+ * one call (#913). Each item is rate-limited individually (see below), so a
39
+ * batch can't be used to bypass the mutation throttle by hiding N credential
40
+ * writes behind one HTTP request.
41
+ *
24
42
  * `GET /admin/accounts` reports each account's persisted metadata — alias,
25
43
  * scopes, token expiry — plus its live pool status (5h/7d utilization,
26
- * representative-claim, routing status, request count) when the proxy supplies
27
- * a `poolStatus` snapshot, which it does whenever pool mode is active. It's the
28
- * headless, admin-token-gated equivalent of the `GET /accounts` pool view.
44
+ * representative-claim, routing status, request count, consecutive auth
45
+ * failures) when the proxy supplies a `poolStatus` snapshot, which it does
46
+ * whenever pool mode is active. It's the headless, admin-token-gated
47
+ * equivalent of the `GET /accounts` pool view.
29
48
  *
30
49
  * Account changes take effect immediately: the proxy passes an
31
50
  * `onAccountsChanged` hook (src/proxy.ts) that hot-reloads the live pool from
@@ -41,6 +60,11 @@
41
60
  * `rateLimit` hook (token bucket in src/rate-limit.ts, owned by the proxy):
42
61
  * a throttled call returns `429` with `Retry-After` instead of acting. Reads
43
62
  * (`GET /admin/accounts`) and successful auth are never throttled (#620).
63
+ * The two bulk endpoints consume the SAME bucket once per item they actually
64
+ * act on, not once per HTTP request — a 50-account `start-needed` call costs
65
+ * 50 tokens, same as 50 individual `/start` calls, and stops (returning
66
+ * `truncated: true`) the moment the bucket runs dry rather than borrowing
67
+ * against it.
44
68
  */
45
69
  import type { IncomingMessage, ServerResponse } from 'node:http';
46
70
  /** Persisted account metadata surfaced by `GET /admin/accounts`. */
@@ -56,6 +80,13 @@ export interface AdminAccountLive {
56
80
  claim: string;
57
81
  status: string;
58
82
  requestCount: number;
83
+ /**
84
+ * Consecutive auth failures on this account (dario#234's cool-down
85
+ * counter). `status: 'auth-cooldown'` alone doesn't distinguish a single
86
+ * 60s blip from a dead refresh token — both look identical. This is the
87
+ * only field that does; `/admin/login/start-needed` filters on it.
88
+ */
89
+ consecutiveAuthFailures: number;
59
90
  }
60
91
  /** An audited admin action — see `AdminDeps.audit`. Never carries secrets. */
61
92
  export interface AdminAuditEvent {
package/dist/admin-api.js CHANGED
@@ -4,6 +4,19 @@ import { parseManualPaste } from './oauth.js';
4
4
  const PENDING_TTL_MS = 10 * 60_000;
5
5
  const MAX_PENDING = 64; // backstop against unbounded growth (distinct aliases)
6
6
  const ACCOUNTS_PREFIX = '/admin/accounts/';
7
+ /**
8
+ * `consecutiveAuthFailures` floor for `/admin/login/start-needed` to treat an
9
+ * account as needing a new login rather than mid-blip. Empirically: 1 failure
10
+ * = 60s cooldown (identical `auth-cooldown` shape to a dead account), 2 = 2min,
11
+ * 3 = 4min — by the third consecutive failure the account has been failing on
12
+ * and off for several minutes, past what a single transient 401 produces.
13
+ * Overridable per-call via `{ threshold }` since "how patient to be" is an
14
+ * operator judgment call, not a fixed constant this file should own alone.
15
+ */
16
+ const NEEDS_LOGIN_THRESHOLD = 3;
17
+ // Backstop on batch /complete — bounds worst-case response size / work
18
+ // independent of the rate limiter, which already caps actual throughput.
19
+ const MAX_BATCH_ITEMS = 64;
7
20
  // Keyed by account alias — one pending login per alias (#599).
8
21
  const pendingLogins = new Map();
9
22
  function prunePending(now) {
@@ -20,6 +33,66 @@ function nextDefaultAlias(taken) {
20
33
  return candidate; // taken is finite → always terminates
21
34
  }
22
35
  }
36
+ /**
37
+ * Core of `/admin/login/start`, factored out so `/admin/login/start-needed`
38
+ * (#913) can drive the same PKCE-start + pending-login-registration + audit
39
+ * path per alias without duplicating it. Never throws — startAddAccount's
40
+ * only failure mode (invalid alias) becomes a `{ ok: false }` result so a
41
+ * caller looping over many aliases can skip one bad alias without aborting
42
+ * the rest.
43
+ */
44
+ async function doStartLogin(alias, now, deps, remote) {
45
+ // Only a brand-new alias grows the map; a repeat start replaces in place.
46
+ if (!pendingLogins.has(alias) && pendingLogins.size >= MAX_PENDING) {
47
+ return { ok: false, status: 429, error: 'too many pending logins; complete or wait for one to expire' };
48
+ }
49
+ try {
50
+ const { authorizeUrl, codeVerifier, state } = await startAddAccount(alias);
51
+ const expiresAt = now + PENDING_TTL_MS;
52
+ pendingLogins.set(alias, { codeVerifier, state, expiresAt });
53
+ deps.audit?.({ action: 'login_start', ok: true, status: 200, alias, remote });
54
+ return { ok: true, alias, authorizeUrl, expiresAt };
55
+ }
56
+ catch (err) {
57
+ return { ok: false, status: 400, error: err.message };
58
+ }
59
+ }
60
+ /**
61
+ * Core of `/admin/login/complete`, factored out so the batch form (#913) can
62
+ * drive it per item. Unlike the pre-refactor inline version, a failed token
63
+ * exchange is now audited too (`login_complete ok:false`) — previously it
64
+ * only hit the outer catch-all and left no audit trail, which is fine for a
65
+ * human watching one response but not for a batch where a silent per-item
66
+ * failure would otherwise be undiscoverable after the fact.
67
+ */
68
+ async function doCompleteLogin(alias, rawCode, now, deps, remote) {
69
+ if (!alias || !rawCode)
70
+ return { ok: false, status: 400, error: 'missing "alias" or "code"' };
71
+ const p = pendingLogins.get(alias);
72
+ if (!p || p.expiresAt <= now) {
73
+ pendingLogins.delete(alias);
74
+ return { ok: false, status: 410, error: 'no pending login for that alias (unknown or expired) — start a new login' };
75
+ }
76
+ // Accept "code#state" or a bare code; verify the embedded state if present.
77
+ const { code, state: pastedState } = parseManualPaste(rawCode);
78
+ if (!code)
79
+ return { ok: false, status: 400, error: 'no authorization code found in "code"' };
80
+ if (pastedState && pastedState !== p.state) {
81
+ return { ok: false, status: 400, error: 'state mismatch — code is from a different login attempt' };
82
+ }
83
+ pendingLogins.delete(alias); // single-use, regardless of exchange outcome
84
+ try {
85
+ const creds = await completeAddAccount(alias, code, p.codeVerifier, p.state);
86
+ await deps.onAccountsChanged?.();
87
+ deps.audit?.({ action: 'login_complete', ok: true, status: 200, alias: creds.alias, remote });
88
+ return { ok: true, alias: creds.alias, expiresAt: creds.expiresAt };
89
+ }
90
+ catch (err) {
91
+ const message = err.message;
92
+ deps.audit?.({ action: 'login_complete', ok: false, status: 400, alias, remote });
93
+ return { ok: false, status: 400, error: message };
94
+ }
95
+ }
23
96
  /** On-disk account inventory — the default `AdminDeps.listAccounts`. */
24
97
  async function defaultListAccounts() {
25
98
  const aliases = await listAccountAliases();
@@ -97,6 +170,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
97
170
  const remote = req.socket?.remoteAddress;
98
171
  const isAccountDelete = method === 'DELETE' && urlPath.startsWith(ACCOUNTS_PREFIX) && urlPath.length > ACCOUNTS_PREFIX.length;
99
172
  const known = urlPath === '/admin/login/start' ||
173
+ urlPath === '/admin/login/start-needed' ||
100
174
  urlPath === '/admin/login/complete' ||
101
175
  urlPath === '/admin/accounts' ||
102
176
  isAccountDelete;
@@ -120,9 +194,16 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
120
194
  }
121
195
  return true;
122
196
  }
123
- // Rate-limit the mutating routes (reads are exempt). Runs after auth so an
124
- // unauthenticated flood is handled by the 'auth' bucket above, not this one.
125
- const isMutation = urlPath === '/admin/login/start' || urlPath === '/admin/login/complete' || isAccountDelete;
197
+ // Rate-limit the single-item mutating routes up front (reads are exempt).
198
+ // Runs after auth so an unauthenticated flood is handled by the 'auth'
199
+ // bucket above, not this one. `/admin/login/complete` and `/start-needed`
200
+ // are NOT gated here — both can act on a variable number of accounts per
201
+ // request, so they consume the bucket per item, inside their own handlers,
202
+ // after the body is parsed (see doStartLogin/doCompleteLogin call sites
203
+ // below). That is a deliberate cost-accounting choice, not an oversight: a
204
+ // blanket pre-parse token here would let a single HTTP request move N
205
+ // accounts' credentials for the price of one throttle token.
206
+ const isMutation = urlPath === '/admin/login/start' || isAccountDelete;
126
207
  if (isMutation) {
127
208
  const wait = deps.rateLimit?.('mutation') ?? 0;
128
209
  if (wait > 0) {
@@ -149,57 +230,109 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
149
230
  const taken = new Set([...records.map((r) => r.alias), ...pendingLogins.keys()]);
150
231
  alias = nextDefaultAlias(taken);
151
232
  }
152
- // Only a brand-new alias grows the map; a repeat /start replaces in place.
153
- if (!pendingLogins.has(alias) && pendingLogins.size >= MAX_PENDING) {
154
- send(res, 429, { error: 'too many pending logins; complete or wait for one to expire' });
233
+ const result = await doStartLogin(alias, now, deps, remote);
234
+ if (!result.ok) {
235
+ send(res, result.status, { error: result.error });
155
236
  return true;
156
237
  }
157
- const { authorizeUrl, codeVerifier, state } = await startAddAccount(alias); // throws on invalid alias
158
- const expiresAt = now + PENDING_TTL_MS;
159
- pendingLogins.set(alias, { codeVerifier, state, expiresAt });
160
- deps.audit?.({ action: 'login_start', ok: true, status: 200, alias, remote });
161
238
  send(res, 200, {
162
- alias,
163
- authorize_url: authorizeUrl,
164
- expires_at: new Date(expiresAt).toISOString(),
165
- instructions: `Open authorize_url, approve, then POST { "alias": "${alias}", "code": "<displayed code>" } to /admin/login/complete.`,
239
+ alias: result.alias,
240
+ authorize_url: result.authorizeUrl,
241
+ expires_at: new Date(result.expiresAt).toISOString(),
242
+ instructions: `Open authorize_url, approve, then POST { "alias": "${result.alias}", "code": "<displayed code>" } to /admin/login/complete.`,
166
243
  });
167
244
  return true;
168
245
  }
169
- // POST /admin/login/complete { alias, code }
170
- if (urlPath === '/admin/login/complete') {
246
+ // POST /admin/login/start-needed { threshold? } (#913)
247
+ // Bulk-starts a login for every live pool account whose consecutive auth
248
+ // failures have crossed `threshold` (default NEEDS_LOGIN_THRESHOLD) — the
249
+ // "which accounts are actually stuck, with a link for each" entry point.
250
+ // No-op (empty `started`) outside pool mode, where there's no live
251
+ // consecutiveAuthFailures signal to filter on.
252
+ if (urlPath === '/admin/login/start-needed') {
171
253
  if (method !== 'POST') {
172
254
  send(res, 405, { error: 'Method not allowed (use POST)' });
173
255
  return true;
174
256
  }
175
257
  const body = await readJsonBody(req);
176
- const alias = typeof body.alias === 'string' ? body.alias.trim() : '';
177
- const rawCode = typeof body.code === 'string' ? body.code : '';
178
- if (!alias || !rawCode) {
179
- send(res, 400, { error: 'missing "alias" or "code"' });
258
+ const threshold = typeof body.threshold === 'number' && body.threshold > 0
259
+ ? body.threshold
260
+ : NEEDS_LOGIN_THRESHOLD;
261
+ const live = deps.poolStatus?.() ?? null;
262
+ const candidates = live
263
+ ? [...live.entries()]
264
+ .filter(([, l]) => l.consecutiveAuthFailures >= threshold)
265
+ .map(([alias, l]) => ({ alias, consecutiveAuthFailures: l.consecutiveAuthFailures }))
266
+ : [];
267
+ const started = [];
268
+ let truncated = false;
269
+ for (const c of candidates) {
270
+ const wait = deps.rateLimit?.('mutation') ?? 0;
271
+ if (wait > 0) {
272
+ truncated = true;
273
+ break;
274
+ }
275
+ const result = await doStartLogin(c.alias, now, deps, remote);
276
+ // A per-alias failure (e.g. MAX_PENDING already full) is skipped, not
277
+ // fatal to the rest of the batch — the operator still gets every
278
+ // account that could be started, and can retry the skipped ones.
279
+ if (result.ok) {
280
+ started.push({
281
+ alias: result.alias,
282
+ authorize_url: result.authorizeUrl,
283
+ expires_at: new Date(result.expiresAt).toISOString(),
284
+ consecutive_auth_failures: c.consecutiveAuthFailures,
285
+ });
286
+ }
287
+ }
288
+ send(res, 200, { started, count: started.length, ...(truncated ? { truncated: true } : {}) });
289
+ return true;
290
+ }
291
+ // POST /admin/login/complete { alias, code } OR { items: [{alias,code}] } (#913)
292
+ if (urlPath === '/admin/login/complete') {
293
+ if (method !== 'POST') {
294
+ send(res, 405, { error: 'Method not allowed (use POST)' });
180
295
  return true;
181
296
  }
182
- const p = pendingLogins.get(alias);
183
- if (!p || p.expiresAt <= now) {
184
- pendingLogins.delete(alias);
185
- send(res, 410, { error: 'no pending login for that alias (unknown or expired) — start a new login' });
297
+ const body = await readJsonBody(req);
298
+ if (Array.isArray(body.items)) {
299
+ if (body.items.length > MAX_BATCH_ITEMS) {
300
+ send(res, 400, { error: `too many items in batch (max ${MAX_BATCH_ITEMS})` });
301
+ return true;
302
+ }
303
+ const results = [];
304
+ let truncated = false;
305
+ for (const raw of body.items) {
306
+ const item = (raw ?? {});
307
+ const alias = typeof item.alias === 'string' ? item.alias.trim() : '';
308
+ const rawCode = typeof item.code === 'string' ? item.code : '';
309
+ const wait = deps.rateLimit?.('mutation') ?? 0;
310
+ if (wait > 0) {
311
+ truncated = true;
312
+ break;
313
+ }
314
+ const result = await doCompleteLogin(alias, rawCode, now, deps, remote);
315
+ results.push(result.ok
316
+ ? { alias: result.alias, status: 'added', expires_at: new Date(result.expiresAt).toISOString() }
317
+ : { alias: alias || '(missing)', status: 'error', error: result.error });
318
+ }
319
+ send(res, 200, { results, count: results.length, ...(truncated ? { truncated: true } : {}) });
186
320
  return true;
187
321
  }
188
- // Accept "code#state" or a bare code; verify the embedded state if present.
189
- const { code, state: pastedState } = parseManualPaste(rawCode);
190
- if (!code) {
191
- send(res, 400, { error: 'no authorization code found in "code"' });
322
+ // Single-item form unchanged shape/behavior from before the batch form existed.
323
+ const alias = typeof body.alias === 'string' ? body.alias.trim() : '';
324
+ const rawCode = typeof body.code === 'string' ? body.code : '';
325
+ const wait = deps.rateLimit?.('mutation') ?? 0;
326
+ if (wait > 0) {
327
+ sendThrottled(res, wait, 'mutation', deps.audit, remote, alias || undefined);
192
328
  return true;
193
329
  }
194
- if (pastedState && pastedState !== p.state) {
195
- send(res, 400, { error: 'state mismatch — code is from a different login attempt' });
330
+ const result = await doCompleteLogin(alias, rawCode, now, deps, remote);
331
+ if (!result.ok) {
332
+ send(res, result.status, { error: result.error });
196
333
  return true;
197
334
  }
198
- pendingLogins.delete(alias); // single-use, regardless of exchange outcome
199
- const creds = await completeAddAccount(alias, code, p.codeVerifier, p.state);
200
- await deps.onAccountsChanged?.();
201
- deps.audit?.({ action: 'login_complete', ok: true, status: 200, alias: creds.alias, remote });
202
- send(res, 200, { alias: creds.alias, status: 'added', expires_at: new Date(creds.expiresAt).toISOString() });
335
+ send(res, 200, { alias: result.alias, status: 'added', expires_at: new Date(result.expiresAt).toISOString() });
203
336
  return true;
204
337
  }
205
338
  // GET /admin/accounts — persisted metadata + live pool status (#599).
@@ -223,6 +356,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
223
356
  claim: l.claim,
224
357
  status: l.status,
225
358
  request_count: l.requestCount,
359
+ consecutive_auth_failures: l.consecutiveAuthFailures,
226
360
  } : {}),
227
361
  };
228
362
  });
@@ -249,9 +383,10 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
249
383
  return true;
250
384
  }
251
385
  catch (err) {
252
- // startAddAccount throws on an invalid alias; completeAddAccount throws
253
- // (with secrets redacted) on a failed token exchange; readJsonBody throws
254
- // on oversized / malformed bodies.
386
+ // doStartLogin/doCompleteLogin catch their own failure modes (invalid
387
+ // alias, failed token exchange) and return a result instead of throwing
388
+ // this remains as the backstop for readJsonBody (oversized / malformed
389
+ // bodies) and removeAccount's alias validation.
255
390
  send(res, 400, { error: err.message });
256
391
  return true;
257
392
  }
@@ -354,7 +354,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
354
354
  */
355
355
  export declare const SUPPORTED_CC_RANGE: {
356
356
  readonly min: "1.0.0";
357
- readonly maxTested: "2.1.222";
357
+ readonly maxTested: "2.1.224";
358
358
  };
359
359
  /**
360
360
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -969,7 +969,7 @@ export function detectDrift(t, installedOverride) {
969
969
  */
970
970
  export const SUPPORTED_CC_RANGE = {
971
971
  min: '1.0.0',
972
- maxTested: '2.1.222',
972
+ maxTested: '2.1.224',
973
973
  };
974
974
  /**
975
975
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
package/dist/proxy.js CHANGED
@@ -1759,6 +1759,11 @@ export async function startProxy(opts = {}) {
1759
1759
  claim: a.rateLimit.claim,
1760
1760
  status: isInAuthCooldown(a, snapNow) ? 'auth-cooldown' : a.rateLimit.status,
1761
1761
  requestCount: a.requestCount,
1762
+ // Raw streak, not just the cooldown boolean: a single 401 also
1763
+ // shows `auth-cooldown` for 60s, indistinguishable from a
1764
+ // genuinely dead refresh token by that field alone. The magnitude
1765
+ // is what /admin/login/start-needed filters on (#913).
1766
+ consecutiveAuthFailures: a.consecutiveAuthFailures,
1762
1767
  });
1763
1768
  }
1764
1769
  return snap;
package/docs/admin-api.md CHANGED
@@ -88,17 +88,88 @@ All endpoints accept the token as `authorization: Bearer <token>` or
88
88
  | Method + path | Body | Returns |
89
89
  |---|---|---|
90
90
  | `POST /admin/login/start` | `{ "alias"?: string }` | `{ alias, authorize_url, expires_at, instructions }` |
91
+ | `POST /admin/login/start-needed` | `{ "threshold"?: number }` | `{ started: [...], count, truncated? }` |
91
92
  | `POST /admin/login/complete` | `{ "alias": string, "code": string }` | `{ alias, status: "added", expires_at }` |
93
+ | `POST /admin/login/complete` (batch) | `{ "items": [{ "alias", "code" }, ...] }` | `{ results: [...], count, truncated? }` |
92
94
  | `GET /admin/accounts` | — | `{ accounts: [...], count }` |
93
95
  | `DELETE /admin/accounts/<alias>` | — | `{ alias, removed }` (`404` if no such alias) |
94
96
 
95
97
  `GET /admin/accounts` is the monitoring surface: each entry carries the
96
98
  persisted metadata (`alias`, `scopes`, `expires_in_ms`) **plus live pool
97
99
  status whenever pool mode is active** — `util5h` / `util7d` utilization,
98
- representative `claim` (e.g. `five_hour`), routing `status`, and
99
- `request_count`. It's the admin-token-gated equivalent of the proxy-key-gated
100
- `GET /accounts` pool view; a headless operator needs only the admin token to
101
- watch headroom.
100
+ representative `claim` (e.g. `five_hour`), routing `status`,
101
+ `request_count`, and `consecutive_auth_failures`. It's the admin-token-gated
102
+ equivalent of the proxy-key-gated `GET /accounts` pool view; a headless
103
+ operator needs only the admin token to watch headroom.
104
+
105
+ ## Bulk re-auth, in one round-trip
106
+
107
+ For a pool with several accounts, the round-trip of "notice one's broken,
108
+ `/start` it, `/complete` it" per account doesn't scale. Two endpoints collapse
109
+ that:
110
+
111
+ ```bash
112
+ # 1. One call: every account that's actually stuck, each with a ready link.
113
+ curl -s -X POST -H "$ADMIN" "$BASE/admin/login/start-needed"
114
+ # -> { "started": [
115
+ # { "alias": "acct-3", "authorize_url": "...", "expires_at": "...",
116
+ # "consecutive_auth_failures": 5 },
117
+ # { "alias": "acct-7", "authorize_url": "...", "expires_at": "...",
118
+ # "consecutive_auth_failures": 12 }
119
+ # ], "count": 2 }
120
+
121
+ # 2. Open each authorize_url, collect each code, complete them all in one call.
122
+ curl -s -X POST -H "$ADMIN" "$BASE/admin/login/complete" -d '{
123
+ "items": [
124
+ { "alias": "acct-3", "code": "<code from acct-3'"'"'s authorize_url>" },
125
+ { "alias": "acct-7", "code": "<code from acct-7'"'"'s authorize_url>" }
126
+ ]
127
+ }'
128
+ # -> { "results": [
129
+ # { "alias": "acct-3", "status": "added", "expires_at": "..." },
130
+ # { "alias": "acct-7", "status": "added", "expires_at": "..." }
131
+ # ], "count": 2 }
132
+ ```
133
+
134
+ **Why a threshold, not "any account currently cooling down."** A single
135
+ upstream 401 already puts an account into a 60-second cool-down
136
+ (`status: "auth-cooldown"` — see [multi-account-pool.md](./multi-account-pool.md)),
137
+ and that looks identical, field-for-field, to an account whose refresh token
138
+ is permanently dead. The only thing that actually separates a passing blip
139
+ from a genuinely stuck account is `consecutive_auth_failures` climbing over
140
+ several failed retries — `/admin/login/start-needed` filters on that count
141
+ (default floor: 3, roughly "failed, waited, failed again, waited longer,
142
+ failed a third time" — a few minutes of sustained failure, past what one bad
143
+ request produces), not the raw cool-down flag. Pass `{ "threshold": 1 }` to
144
+ be more aggressive, or a higher number to wait for a longer failure streak
145
+ before it's worth an operator's attention.
146
+
147
+ **No pool, no signal.** `consecutive_auth_failures` only exists once pool
148
+ mode is active (see the `poolStatus` note above); `start-needed` returns an
149
+ empty `started: []` outside pool mode rather than guessing.
150
+
151
+ **Rate limiting is per account acted on, not per HTTP call.** Both bulk
152
+ endpoints draw from the same mutation bucket as every other admin mutation —
153
+ starting or completing 10 accounts in one request costs the same 10 tokens
154
+ as 10 separate calls would. If the bucket runs dry partway through, the
155
+ response carries `truncated: true` and whatever succeeded before that point;
156
+ re-issue the same call (already-completed aliases won't be re-started —
157
+ `start-needed` only lists accounts still above the threshold) to pick up the
158
+ rest once the bucket refills.
159
+
160
+ **Batch `/complete` reports per-item, not all-or-nothing.** A bad or expired
161
+ code in one item doesn't fail the others — `results[i]` carries its own
162
+ `status: "added" | "error"`, mirroring what a solo `/complete` call for that
163
+ alias would have returned. The single-object form (`{ "alias", "code" }`,
164
+ no `items` wrapper) is unchanged and still returns the flat
165
+ `{ alias, status, expires_at }` shape.
166
+
167
+ **Not what idea 3 in #913 asked for, and doesn't need to be.** The
168
+ `redirect_uri` in `authorize_url` is Anthropic's own hosted
169
+ `https://platform.claude.com/oauth/code/callback` page, not anything dario
170
+ serves — open it in any browser on any machine, exactly as the zero-to-serving
171
+ walkthrough above already does. There's no dario-hosted callback in this flow
172
+ for a `DARIO_DOMAIN`/`DARIO_CALLBACK` variable to redirect.
102
173
 
103
174
  ## What the generic surfaces report (v4.8.117+)
104
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.4.29",
3
+ "version": "5.4.31",
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": {