@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.
- package/dist/admin-api.d.ts +38 -7
- package/dist/admin-api.js +173 -38
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/proxy.js +5 -0
- package/docs/admin-api.md +75 -4
- package/package.json +1 -1
package/dist/admin-api.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
* POST /admin/login/
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
27
|
-
* a `poolStatus` snapshot, which it does
|
|
28
|
-
* headless, admin-token-gated
|
|
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).
|
|
124
|
-
// unauthenticated flood is handled by the 'auth'
|
|
125
|
-
|
|
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
|
-
|
|
153
|
-
if (!
|
|
154
|
-
send(res,
|
|
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/
|
|
170
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
//
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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.
|
|
357
|
+
readonly maxTested: "2.1.224";
|
|
358
358
|
};
|
|
359
359
|
/**
|
|
360
360
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/live-fingerprint.js
CHANGED
|
@@ -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.
|
|
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`,
|
|
99
|
-
`request_count`. It's the admin-token-gated
|
|
100
|
-
`GET /accounts` pool view; a headless
|
|
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.
|
|
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": {
|