@askalf/dario 6.19.0 → 6.19.2
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 +1 -1
- package/dist/account-sync.d.ts +174 -0
- package/dist/account-sync.js +351 -0
- package/dist/accounts.d.ts +40 -1
- package/dist/accounts.js +76 -1
- package/dist/cc-template-data.json +9 -7
- package/dist/cli.js +51 -2
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/proxy.d.ts +8 -0
- package/dist/proxy.js +84 -6
- package/docs/admin-api.md +8 -0
- package/docs/configuration.md +1 -0
- package/docs/docker.md +35 -4
- package/docs/multi-instance.md +29 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ dario runs entirely on your machine, authenticates as you with your own Claude l
|
|
|
117
117
|
| Dependencies | **0 runtime.** Verify: `npm ls --production` |
|
|
118
118
|
| Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore, published with OIDC trusted publishing: no long-lived npm token exists to leak |
|
|
119
119
|
| Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push to master, every PR and weekly · [ClusterFuzzLite](./.github/workflows/cflite.yml) fuzzes the SSE translator, the rejection parsers and the cch stamper weekly · the [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/askalf/dario) badge above and the [Best Practices](https://www.bestpractices.dev/projects/13638) entry are live |
|
|
120
|
-
| Tests | **
|
|
120
|
+
| Tests | **285 test files** run in parallel by `npm test` on Node 18, 20 and 22; the live e2e / compat / stealth suites have their own entry points. Green on every release |
|
|
121
121
|
| Credentials | Your own subscription tokens, never logged, redacted from errors, `0600` on disk in `0700` dirs |
|
|
122
122
|
| Network | Binds `127.0.0.1` by default; upstream only to configured backends over HTTPS; hardcoded SSRF allow-list; refuses a non-loopback bind without `DARIO_API_KEY`; refuses cross-origin and DNS-rebound browser requests |
|
|
123
123
|
| Telemetry | **None.** No analytics, no tracking, nothing phones home |
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared account registry (`--pool-shared-accounts`). Every instance merges
|
|
3
|
+
* the Claude accounts in its `~/.dario/accounts/` with a registry on the
|
|
4
|
+
* refresh-lock service, through three endpoints both reference backends
|
|
5
|
+
* implement (redis-lock/, cloudflare/refresh-lock/):
|
|
6
|
+
*
|
|
7
|
+
* POST /accounts/list {} → { accounts: { alias: AccountCredentials }, removed: { alias: removedAt } }
|
|
8
|
+
* POST /accounts/<alias>/put { credentials } → { stored }
|
|
9
|
+
* POST /accounts/<alias>/remove { removedAt } → { removed }
|
|
10
|
+
*
|
|
11
|
+
* A sync runs at startup, before the pool loads, then every 15 seconds and
|
|
12
|
+
* after each admin API change. It publishes the removals this process made,
|
|
13
|
+
* reads the registry, writes into the accounts directory every record newer
|
|
14
|
+
* than the file there, removes the files a removal marker covers, pushes
|
|
15
|
+
* every file newer than the registry's record, and hands the running pool
|
|
16
|
+
* what changed, checked against the removals made here up to that moment.
|
|
17
|
+
* Newer is credentialsAreNewer: the later grant, then the later expiry. A
|
|
18
|
+
* removal marker beats every grant made before it and loses to every grant
|
|
19
|
+
* made after it, so the instances' clocks must agree (NTP) to well under the
|
|
20
|
+
* time between a removal and a later login.
|
|
21
|
+
*
|
|
22
|
+
* Every call fails open: with the service unreachable an instance serves the
|
|
23
|
+
* accounts on its own disk, logging once per outage. The registry holds
|
|
24
|
+
* every seat's full credentials, refresh tokens included, with no expiry, so
|
|
25
|
+
* DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
|
|
26
|
+
* separate from `--pool-shared-state`.
|
|
27
|
+
*/
|
|
28
|
+
import { type AccountCredentials } from './accounts.js';
|
|
29
|
+
/** Where the sync reads and writes this instance's accounts. accounts.ts in production; a test passes its own. */
|
|
30
|
+
export interface AccountSyncStore {
|
|
31
|
+
loadAll(): Promise<AccountCredentials[]>;
|
|
32
|
+
save(creds: AccountCredentials, opts: {
|
|
33
|
+
fetchedAt: number;
|
|
34
|
+
removedAt?: number;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
saved: boolean;
|
|
37
|
+
replaced?: AccountCredentials;
|
|
38
|
+
}>;
|
|
39
|
+
remove(alias: string, removedAt: number): Promise<boolean>;
|
|
40
|
+
/** Removals this process made, alias to epoch ms. */
|
|
41
|
+
localRemovals(): ReadonlyMap<string, number>;
|
|
42
|
+
}
|
|
43
|
+
export declare const accountFileStore: AccountSyncStore;
|
|
44
|
+
/** What one sync changed on this instance, handed to the running proxy. */
|
|
45
|
+
export interface AccountSyncChange {
|
|
46
|
+
/** Records the pool lacks or holds older credentials for. */
|
|
47
|
+
records: AccountCredentials[];
|
|
48
|
+
/** Seats the pool still holds whose account was removed. */
|
|
49
|
+
removed: Array<{
|
|
50
|
+
alias: string;
|
|
51
|
+
removedAt: number;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
/** The credentials a live pool seat holds, as far as ordering them needs. */
|
|
55
|
+
export type LiveSeat = {
|
|
56
|
+
expiresAt: number;
|
|
57
|
+
grantedAt?: number;
|
|
58
|
+
};
|
|
59
|
+
export interface AccountSyncStatus {
|
|
60
|
+
enabled: true;
|
|
61
|
+
intervalMs: number;
|
|
62
|
+
lastSyncAt: number | null;
|
|
63
|
+
lastOkAt: number | null;
|
|
64
|
+
/** Records and removal markers the registry held at the last good read. */
|
|
65
|
+
registryAccounts: number | null;
|
|
66
|
+
registryRemoved: number | null;
|
|
67
|
+
/** Cumulative: records written from the registry, files removed, records pushed, removals published. */
|
|
68
|
+
saved: number;
|
|
69
|
+
removed: number;
|
|
70
|
+
pushed: number;
|
|
71
|
+
removalsPublished: number;
|
|
72
|
+
errors: number;
|
|
73
|
+
lastError: string | null;
|
|
74
|
+
}
|
|
75
|
+
export interface AccountSyncOptions {
|
|
76
|
+
baseUrl: string;
|
|
77
|
+
token: string;
|
|
78
|
+
/** Sync interval, ms. Default 15000. */
|
|
79
|
+
intervalMs?: number;
|
|
80
|
+
store?: AccountSyncStore;
|
|
81
|
+
log?: (line: string) => void;
|
|
82
|
+
}
|
|
83
|
+
export declare const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15000;
|
|
84
|
+
export interface AccountSyncPlan {
|
|
85
|
+
/** Registry records to write here, each with the removal time it must outlive. */
|
|
86
|
+
save: Array<{
|
|
87
|
+
creds: AccountCredentials;
|
|
88
|
+
removedAt?: number;
|
|
89
|
+
}>;
|
|
90
|
+
/** Local files a removal covers. */
|
|
91
|
+
remove: Array<{
|
|
92
|
+
alias: string;
|
|
93
|
+
removedAt: number;
|
|
94
|
+
}>;
|
|
95
|
+
/** Local records newer than the registry's. */
|
|
96
|
+
push: AccountCredentials[];
|
|
97
|
+
}
|
|
98
|
+
/** The latest removal of `alias` either side knows of, or undefined. */
|
|
99
|
+
export declare function removalTime(alias: string, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): number | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Merge this instance's account files with the registry, per alias. A record
|
|
102
|
+
* counts only when it outlives the latest removal of its alias (the
|
|
103
|
+
* registry's marker or one this process made); of the two that count, the
|
|
104
|
+
* newer wins. The registry's goes to disk, ours goes to the registry, and a
|
|
105
|
+
* local file no record outlives is removed.
|
|
106
|
+
*/
|
|
107
|
+
export declare function planAccountSync(local: readonly AccountCredentials[], registry: ReadonlyMap<string, AccountCredentials>, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): AccountSyncPlan;
|
|
108
|
+
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
109
|
+
export declare function isRegistryRecord(alias: string, rec: unknown): rec is AccountCredentials;
|
|
110
|
+
/** How `dario accounts remove` ended with the shared account registry on. */
|
|
111
|
+
export type SharedRemovalResult =
|
|
112
|
+
/** The registry took the removal, and the file here is gone. */
|
|
113
|
+
'removed'
|
|
114
|
+
/** The registry holds a login for the alias granted after the removal; nothing changed. */
|
|
115
|
+
| 'registry-newer'
|
|
116
|
+
/** The registry took the removal, but the file here holds a login granted after it, which stays. */
|
|
117
|
+
| 'local-newer'
|
|
118
|
+
/** The registry took the removal, but the file here, which it covers, could not be deleted. */
|
|
119
|
+
| 'local-failed';
|
|
120
|
+
/**
|
|
121
|
+
* Remove an account from the registry, then from this instance's disk, for
|
|
122
|
+
* `dario accounts remove`. The removal is published first: a running
|
|
123
|
+
* replica would otherwise push the account back from its own copy. The file
|
|
124
|
+
* goes only once the registry took the removal, and only when it holds no
|
|
125
|
+
* login granted after it. Throws when the service does not answer.
|
|
126
|
+
*/
|
|
127
|
+
export declare function removeAccountEverywhere(baseUrl: string, token: string, alias: string, opts?: {
|
|
128
|
+
store?: AccountSyncStore;
|
|
129
|
+
removedAt?: number;
|
|
130
|
+
}): Promise<SharedRemovalResult>;
|
|
131
|
+
export declare class AccountSync {
|
|
132
|
+
readonly intervalMs: number;
|
|
133
|
+
private readonly baseUrl;
|
|
134
|
+
private readonly token;
|
|
135
|
+
private readonly store;
|
|
136
|
+
private readonly log;
|
|
137
|
+
private onChange;
|
|
138
|
+
private live;
|
|
139
|
+
private timer;
|
|
140
|
+
private running;
|
|
141
|
+
private queued;
|
|
142
|
+
private readonly published;
|
|
143
|
+
private markers;
|
|
144
|
+
private down;
|
|
145
|
+
private stats;
|
|
146
|
+
constructor(opts: AccountSyncOptions);
|
|
147
|
+
/**
|
|
148
|
+
* Connect a running pool: `live` reads a seat's credentials, `onChange`
|
|
149
|
+
* gets what each later sync changed. Until then a sync updates the
|
|
150
|
+
* accounts directory only, which is all the startup sync needs, since the
|
|
151
|
+
* pool loads from that directory after it.
|
|
152
|
+
*/
|
|
153
|
+
attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange) => Promise<void> | void): void;
|
|
154
|
+
/** Sync on the interval. The timer never keeps the process alive. */
|
|
155
|
+
start(): void;
|
|
156
|
+
stop(): void;
|
|
157
|
+
status(): AccountSyncStatus;
|
|
158
|
+
/**
|
|
159
|
+
* The latest removal of `alias` this instance knows of: the registry's
|
|
160
|
+
* marker at the last good read, or one this process made. A path that
|
|
161
|
+
* writes account files outside the sync checks it, so it never restores a
|
|
162
|
+
* login the sync just removed.
|
|
163
|
+
*/
|
|
164
|
+
removalOf(alias: string): number | undefined;
|
|
165
|
+
/** One sync. Null when the registry could not be read; never throws. */
|
|
166
|
+
syncOnce(): Promise<AccountSyncChange | null>;
|
|
167
|
+
private run;
|
|
168
|
+
/**
|
|
169
|
+
* One POST to the service. Null on any failure: the caller carries on with
|
|
170
|
+
* the accounts on disk, which is the whole contract. Logged once per
|
|
171
|
+
* transition into and out of the failed state, not per call.
|
|
172
|
+
*/
|
|
173
|
+
private call;
|
|
174
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared account registry (`--pool-shared-accounts`). Every instance merges
|
|
3
|
+
* the Claude accounts in its `~/.dario/accounts/` with a registry on the
|
|
4
|
+
* refresh-lock service, through three endpoints both reference backends
|
|
5
|
+
* implement (redis-lock/, cloudflare/refresh-lock/):
|
|
6
|
+
*
|
|
7
|
+
* POST /accounts/list {} → { accounts: { alias: AccountCredentials }, removed: { alias: removedAt } }
|
|
8
|
+
* POST /accounts/<alias>/put { credentials } → { stored }
|
|
9
|
+
* POST /accounts/<alias>/remove { removedAt } → { removed }
|
|
10
|
+
*
|
|
11
|
+
* A sync runs at startup, before the pool loads, then every 15 seconds and
|
|
12
|
+
* after each admin API change. It publishes the removals this process made,
|
|
13
|
+
* reads the registry, writes into the accounts directory every record newer
|
|
14
|
+
* than the file there, removes the files a removal marker covers, pushes
|
|
15
|
+
* every file newer than the registry's record, and hands the running pool
|
|
16
|
+
* what changed, checked against the removals made here up to that moment.
|
|
17
|
+
* Newer is credentialsAreNewer: the later grant, then the later expiry. A
|
|
18
|
+
* removal marker beats every grant made before it and loses to every grant
|
|
19
|
+
* made after it, so the instances' clocks must agree (NTP) to well under the
|
|
20
|
+
* time between a removal and a later login.
|
|
21
|
+
*
|
|
22
|
+
* Every call fails open: with the service unreachable an instance serves the
|
|
23
|
+
* accounts on its own disk, logging once per outage. The registry holds
|
|
24
|
+
* every seat's full credentials, refresh tokens included, with no expiry, so
|
|
25
|
+
* DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
|
|
26
|
+
* separate from `--pool-shared-state`.
|
|
27
|
+
*/
|
|
28
|
+
import { loadAllAccounts, saveSharedAccount, removeSharedAccount, localAccountRemovals, credentialsAreNewer, grantedAfter, isValidAccountAlias, } from './accounts.js';
|
|
29
|
+
import { maskEmail } from './pool.js';
|
|
30
|
+
import { clampTimerMs } from './timer-ms.js';
|
|
31
|
+
export const accountFileStore = {
|
|
32
|
+
loadAll: loadAllAccounts,
|
|
33
|
+
save: saveSharedAccount,
|
|
34
|
+
remove: removeSharedAccount,
|
|
35
|
+
localRemovals: localAccountRemovals,
|
|
36
|
+
};
|
|
37
|
+
export const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15_000;
|
|
38
|
+
const CALL_TIMEOUT_MS = 3_000;
|
|
39
|
+
/** The latest removal of `alias` either side knows of, or undefined. */
|
|
40
|
+
export function removalTime(alias, markers, localRemovals) {
|
|
41
|
+
const a = markers.get(alias), b = localRemovals.get(alias);
|
|
42
|
+
if (a === undefined)
|
|
43
|
+
return b;
|
|
44
|
+
if (b === undefined)
|
|
45
|
+
return a;
|
|
46
|
+
return Math.max(a, b);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Merge this instance's account files with the registry, per alias. A record
|
|
50
|
+
* counts only when it outlives the latest removal of its alias (the
|
|
51
|
+
* registry's marker or one this process made); of the two that count, the
|
|
52
|
+
* newer wins. The registry's goes to disk, ours goes to the registry, and a
|
|
53
|
+
* local file no record outlives is removed.
|
|
54
|
+
*/
|
|
55
|
+
export function planAccountSync(local, registry, markers, localRemovals) {
|
|
56
|
+
const plan = { save: [], remove: [], push: [] };
|
|
57
|
+
const localByAlias = new Map(local.map((a) => [a.alias, a]));
|
|
58
|
+
for (const alias of new Set([...localByAlias.keys(), ...registry.keys()])) {
|
|
59
|
+
const removedAt = removalTime(alias, markers, localRemovals);
|
|
60
|
+
const mine = localByAlias.get(alias);
|
|
61
|
+
const theirs = registry.get(alias);
|
|
62
|
+
const mineCounts = mine !== undefined && grantedAfter(mine, removedAt);
|
|
63
|
+
const theirsCount = theirs !== undefined && grantedAfter(theirs, removedAt);
|
|
64
|
+
if (theirsCount && (!mineCounts || credentialsAreNewer(theirs, mine))) {
|
|
65
|
+
plan.save.push(removedAt === undefined ? { creds: theirs } : { creds: theirs, removedAt });
|
|
66
|
+
}
|
|
67
|
+
else if (mineCounts) {
|
|
68
|
+
if (!theirsCount || credentialsAreNewer(mine, theirs))
|
|
69
|
+
plan.push.push(mine);
|
|
70
|
+
}
|
|
71
|
+
else if (mine !== undefined && removedAt !== undefined) {
|
|
72
|
+
plan.remove.push({ alias, removedAt });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return plan;
|
|
76
|
+
}
|
|
77
|
+
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
78
|
+
export function isRegistryRecord(alias, rec) {
|
|
79
|
+
const r = rec;
|
|
80
|
+
return !!r && typeof r === 'object' && r.alias === alias && isValidAccountAlias(alias)
|
|
81
|
+
&& typeof r.accessToken === 'string' && r.accessToken.length > 0
|
|
82
|
+
&& typeof r.refreshToken === 'string' && r.refreshToken.length > 0
|
|
83
|
+
&& typeof r.expiresAt === 'number' && Number.isFinite(r.expiresAt)
|
|
84
|
+
&& Array.isArray(r.scopes) && typeof r.deviceId === 'string' && typeof r.accountUuid === 'string';
|
|
85
|
+
}
|
|
86
|
+
function parseRegistry(raw) {
|
|
87
|
+
const out = new Map();
|
|
88
|
+
if (!raw || typeof raw !== 'object')
|
|
89
|
+
return out;
|
|
90
|
+
for (const [alias, rec] of Object.entries(raw))
|
|
91
|
+
if (isRegistryRecord(alias, rec))
|
|
92
|
+
out.set(alias, rec);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function parseMarkers(raw) {
|
|
96
|
+
const out = new Map();
|
|
97
|
+
if (!raw || typeof raw !== 'object')
|
|
98
|
+
return out;
|
|
99
|
+
for (const [alias, at] of Object.entries(raw)) {
|
|
100
|
+
if (isValidAccountAlias(alias) && typeof at === 'number' && Number.isFinite(at))
|
|
101
|
+
out.set(alias, at);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
async function registryCall(baseUrl, token, path, body) {
|
|
106
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}${path}`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
109
|
+
body: JSON.stringify(body),
|
|
110
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw new Error(`HTTP ${res.status}`);
|
|
114
|
+
return (await res.json());
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Remove an account from the registry, then from this instance's disk, for
|
|
118
|
+
* `dario accounts remove`. The removal is published first: a running
|
|
119
|
+
* replica would otherwise push the account back from its own copy. The file
|
|
120
|
+
* goes only once the registry took the removal, and only when it holds no
|
|
121
|
+
* login granted after it. Throws when the service does not answer.
|
|
122
|
+
*/
|
|
123
|
+
export async function removeAccountEverywhere(baseUrl, token, alias, opts = {}) {
|
|
124
|
+
const store = opts.store ?? accountFileStore;
|
|
125
|
+
const removedAt = opts.removedAt ?? Date.now();
|
|
126
|
+
const res = await registryCall(baseUrl, token, `/accounts/${encodeURIComponent(alias)}/remove`, { removedAt });
|
|
127
|
+
if (res.removed !== true)
|
|
128
|
+
return 'registry-newer';
|
|
129
|
+
if (await store.remove(alias, removedAt))
|
|
130
|
+
return 'removed';
|
|
131
|
+
// Not removed by this call: a running proxy on this disk applied the
|
|
132
|
+
// marker first, a login landed in the file after the removal, or the
|
|
133
|
+
// file could not be deleted. Only an absent file counts as removed.
|
|
134
|
+
const current = (await store.loadAll()).find((a) => a.alias === alias);
|
|
135
|
+
if (!current)
|
|
136
|
+
return 'removed';
|
|
137
|
+
return grantedAfter(current, removedAt) ? 'local-newer' : 'local-failed';
|
|
138
|
+
}
|
|
139
|
+
function describeAccount(c) {
|
|
140
|
+
return maskEmail(c.accountEmail) ?? c.accountId ?? 'unknown account';
|
|
141
|
+
}
|
|
142
|
+
export class AccountSync {
|
|
143
|
+
intervalMs;
|
|
144
|
+
baseUrl;
|
|
145
|
+
token;
|
|
146
|
+
store;
|
|
147
|
+
log;
|
|
148
|
+
onChange = null;
|
|
149
|
+
live = null;
|
|
150
|
+
timer = null;
|
|
151
|
+
// One sync at a time, and at most one waiting: a call while one runs gets
|
|
152
|
+
// the next run, which starts after it and so sees what it wrote.
|
|
153
|
+
running = Promise.resolve();
|
|
154
|
+
queued = null;
|
|
155
|
+
// Removal time last published per alias, so each removal goes out once.
|
|
156
|
+
published = new Map();
|
|
157
|
+
// The registry's removal markers at the last good read.
|
|
158
|
+
markers = new Map();
|
|
159
|
+
down = false;
|
|
160
|
+
stats = {
|
|
161
|
+
lastSyncAt: null, lastOkAt: null,
|
|
162
|
+
registryAccounts: null, registryRemoved: null,
|
|
163
|
+
saved: 0, removed: 0, pushed: 0, removalsPublished: 0, errors: 0, lastError: null,
|
|
164
|
+
};
|
|
165
|
+
constructor(opts) {
|
|
166
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
167
|
+
this.token = opts.token;
|
|
168
|
+
this.intervalMs = clampTimerMs(Math.max(1_000, opts.intervalMs ?? DEFAULT_ACCOUNT_SYNC_INTERVAL_MS));
|
|
169
|
+
this.store = opts.store ?? accountFileStore;
|
|
170
|
+
this.log = opts.log ?? ((line) => console.error(line));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Connect a running pool: `live` reads a seat's credentials, `onChange`
|
|
174
|
+
* gets what each later sync changed. Until then a sync updates the
|
|
175
|
+
* accounts directory only, which is all the startup sync needs, since the
|
|
176
|
+
* pool loads from that directory after it.
|
|
177
|
+
*/
|
|
178
|
+
attach(live, onChange) {
|
|
179
|
+
this.live = live;
|
|
180
|
+
this.onChange = onChange;
|
|
181
|
+
}
|
|
182
|
+
/** Sync on the interval. The timer never keeps the process alive. */
|
|
183
|
+
start() {
|
|
184
|
+
if (this.timer)
|
|
185
|
+
return;
|
|
186
|
+
this.timer = setInterval(() => { void this.syncOnce(); }, this.intervalMs);
|
|
187
|
+
this.timer.unref?.();
|
|
188
|
+
}
|
|
189
|
+
stop() {
|
|
190
|
+
if (this.timer)
|
|
191
|
+
clearInterval(this.timer);
|
|
192
|
+
this.timer = null;
|
|
193
|
+
}
|
|
194
|
+
status() {
|
|
195
|
+
return { enabled: true, intervalMs: this.intervalMs, ...this.stats };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The latest removal of `alias` this instance knows of: the registry's
|
|
199
|
+
* marker at the last good read, or one this process made. A path that
|
|
200
|
+
* writes account files outside the sync checks it, so it never restores a
|
|
201
|
+
* login the sync just removed.
|
|
202
|
+
*/
|
|
203
|
+
removalOf(alias) {
|
|
204
|
+
return removalTime(alias, this.markers, this.store.localRemovals());
|
|
205
|
+
}
|
|
206
|
+
/** One sync. Null when the registry could not be read; never throws. */
|
|
207
|
+
syncOnce() {
|
|
208
|
+
if (this.queued)
|
|
209
|
+
return this.queued;
|
|
210
|
+
const next = this.running.then(() => {
|
|
211
|
+
this.queued = null;
|
|
212
|
+
return this.run().catch((err) => {
|
|
213
|
+
this.stats.errors++;
|
|
214
|
+
this.stats.lastError = err instanceof Error ? err.message : String(err);
|
|
215
|
+
this.log(`[dario] shared accounts: sync failed (${this.stats.lastError}); retrying on the next sync`);
|
|
216
|
+
return null;
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
this.queued = next;
|
|
220
|
+
this.running = next;
|
|
221
|
+
return next;
|
|
222
|
+
}
|
|
223
|
+
async run() {
|
|
224
|
+
this.stats.lastSyncAt = Date.now();
|
|
225
|
+
// Removals first: a list read after them no longer holds those records,
|
|
226
|
+
// and one read before would hand them straight back.
|
|
227
|
+
for (const [alias, at] of this.store.localRemovals()) {
|
|
228
|
+
if ((this.published.get(alias) ?? -Infinity) >= at)
|
|
229
|
+
continue;
|
|
230
|
+
const res = await this.call(`/accounts/${encodeURIComponent(alias)}/remove`, { removedAt: at });
|
|
231
|
+
if (!res)
|
|
232
|
+
break;
|
|
233
|
+
this.published.set(alias, at);
|
|
234
|
+
this.stats.removalsPublished++;
|
|
235
|
+
}
|
|
236
|
+
const fetchedAt = Date.now();
|
|
237
|
+
const res = await this.call('/accounts/list', {});
|
|
238
|
+
if (!res)
|
|
239
|
+
return null;
|
|
240
|
+
const registry = parseRegistry(res.accounts);
|
|
241
|
+
const markers = parseMarkers(res.removed);
|
|
242
|
+
this.markers = markers;
|
|
243
|
+
this.stats.registryAccounts = registry.size;
|
|
244
|
+
this.stats.registryRemoved = markers.size;
|
|
245
|
+
const local = await this.store.loadAll();
|
|
246
|
+
const localRemovals = this.store.localRemovals();
|
|
247
|
+
const plan = planAccountSync(local, registry, markers, localRemovals);
|
|
248
|
+
// The record each alias ends on here: the files that outlive their
|
|
249
|
+
// alias's removal, then whatever the registry replaced.
|
|
250
|
+
const kept = new Map();
|
|
251
|
+
for (const a of local)
|
|
252
|
+
if (grantedAfter(a, removalTime(a.alias, markers, localRemovals)))
|
|
253
|
+
kept.set(a.alias, a);
|
|
254
|
+
const removed = [];
|
|
255
|
+
for (const { alias, removedAt } of plan.remove) {
|
|
256
|
+
// Kept when a login landed in the file since it was read; the next
|
|
257
|
+
// sync takes that login to the pool.
|
|
258
|
+
if (!(await this.store.remove(alias, removedAt)))
|
|
259
|
+
continue;
|
|
260
|
+
kept.delete(alias);
|
|
261
|
+
removed.push({ alias, removedAt });
|
|
262
|
+
this.stats.removed++;
|
|
263
|
+
this.log(`[dario] shared accounts: removed account "${alias}"; it was removed on another instance`);
|
|
264
|
+
}
|
|
265
|
+
for (const { creds, removedAt } of plan.save) {
|
|
266
|
+
const out = await this.store.save(creds, removedAt === undefined ? { fetchedAt } : { fetchedAt, removedAt });
|
|
267
|
+
if (!out.saved)
|
|
268
|
+
continue;
|
|
269
|
+
kept.set(creds.alias, creds);
|
|
270
|
+
this.stats.saved++;
|
|
271
|
+
const was = out.replaced;
|
|
272
|
+
if (!was) {
|
|
273
|
+
this.log(`[dario] shared accounts: added account "${creds.alias}" (${describeAccount(creds)}) from the registry`);
|
|
274
|
+
}
|
|
275
|
+
else if (was.accountId && creds.accountId && was.accountId !== creds.accountId) {
|
|
276
|
+
// Aliases are one namespace across every instance. Two instances
|
|
277
|
+
// that each logged a different subscription in under one alias
|
|
278
|
+
// (`login`, most often) keep only the newer grant.
|
|
279
|
+
this.log(`[dario] shared accounts: account "${creds.alias}" here was ${describeAccount(was)} and is now ${describeAccount(creds)}, a newer login under the same alias on another instance; aliases are shared across instances, so give each subscription its own alias`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const creds of plan.push) {
|
|
283
|
+
// A removal made here while this sync ran waits for the next one;
|
|
284
|
+
// pushing the file it removed would bring the account back until then.
|
|
285
|
+
const removedSince = this.store.localRemovals().get(creds.alias);
|
|
286
|
+
if (!grantedAfter(creds, removedSince))
|
|
287
|
+
continue;
|
|
288
|
+
const out = await this.call(`/accounts/${encodeURIComponent(creds.alias)}/put`, { credentials: creds });
|
|
289
|
+
if (!out)
|
|
290
|
+
break;
|
|
291
|
+
if (!out.stored)
|
|
292
|
+
continue;
|
|
293
|
+
this.stats.pushed++;
|
|
294
|
+
if (!registry.has(creds.alias))
|
|
295
|
+
this.log(`[dario] shared accounts: published account "${creds.alias}" to the registry`);
|
|
296
|
+
}
|
|
297
|
+
const change = { records: [], removed };
|
|
298
|
+
if (this.live) {
|
|
299
|
+
// The pool gets the state as of now: a removal made here while this
|
|
300
|
+
// sync waited on the service covers the records read before it.
|
|
301
|
+
const removedNow = this.store.localRemovals();
|
|
302
|
+
for (const [alias, rec] of kept)
|
|
303
|
+
if (!grantedAfter(rec, removedNow.get(alias)))
|
|
304
|
+
kept.delete(alias);
|
|
305
|
+
for (const rec of kept.values()) {
|
|
306
|
+
const seat = this.live(rec.alias);
|
|
307
|
+
if (!seat || credentialsAreNewer(rec, seat))
|
|
308
|
+
change.records.push(rec);
|
|
309
|
+
}
|
|
310
|
+
// A seat whose file went another way (a CLI removal in another
|
|
311
|
+
// process, or a peer on a shared accounts volume) is still in the pool.
|
|
312
|
+
const planned = new Set(plan.remove.map((r) => r.alias));
|
|
313
|
+
for (const alias of new Set([...markers.keys(), ...removedNow.keys()])) {
|
|
314
|
+
if (kept.has(alias) || planned.has(alias))
|
|
315
|
+
continue;
|
|
316
|
+
const removedAt = removalTime(alias, markers, removedNow);
|
|
317
|
+
const seat = this.live(alias);
|
|
318
|
+
if (seat && !grantedAfter(seat, removedAt))
|
|
319
|
+
removed.push({ alias, removedAt });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (this.onChange && (change.records.length > 0 || change.removed.length > 0))
|
|
323
|
+
await this.onChange(change);
|
|
324
|
+
return change;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* One POST to the service. Null on any failure: the caller carries on with
|
|
328
|
+
* the accounts on disk, which is the whole contract. Logged once per
|
|
329
|
+
* transition into and out of the failed state, not per call.
|
|
330
|
+
*/
|
|
331
|
+
async call(path, body) {
|
|
332
|
+
try {
|
|
333
|
+
const out = await registryCall(this.baseUrl, this.token, path, body);
|
|
334
|
+
this.stats.lastOkAt = Date.now();
|
|
335
|
+
if (this.down) {
|
|
336
|
+
this.down = false;
|
|
337
|
+
this.log(`[dario] shared accounts: service reachable again (${this.baseUrl})`);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
this.stats.errors++;
|
|
343
|
+
this.stats.lastError = err instanceof Error ? err.message : String(err);
|
|
344
|
+
if (!this.down) {
|
|
345
|
+
this.down = true;
|
|
346
|
+
this.log(`[dario] shared accounts: ${this.baseUrl} unreachable (${this.stats.lastError}); serving the accounts on this instance's disk until it answers again`);
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Whether `alias` is one an account file can be stored under. */
|
|
2
|
+
export declare function isValidAccountAlias(alias: string): boolean;
|
|
1
3
|
export interface AccountCredentials {
|
|
2
4
|
alias: string;
|
|
3
5
|
accessToken: string;
|
|
@@ -53,7 +55,39 @@ export declare function credentialsAreNewer(candidate: {
|
|
|
53
55
|
}): boolean;
|
|
54
56
|
export declare function saveAccount(creds: AccountCredentials): Promise<void>;
|
|
55
57
|
export declare function _setAccountWriteHookForTest(hook: (() => Promise<void>) | null): void;
|
|
58
|
+
/** The account files this process has removed, alias to epoch ms. */
|
|
59
|
+
export declare function localAccountRemovals(): ReadonlyMap<string, number>;
|
|
56
60
|
export declare function removeAccount(alias: string): Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Whether credentials outlive a removal made at `removedAt`: granted after
|
|
63
|
+
* it, so a login that came later. With no removal, every record does. A
|
|
64
|
+
* record with no grant time predates grant times, so it predates the removal.
|
|
65
|
+
*/
|
|
66
|
+
export declare function grantedAfter(creds: {
|
|
67
|
+
grantedAt?: number;
|
|
68
|
+
}, removedAt: number | undefined): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Write a record read from the shared account registry (account-sync.ts),
|
|
71
|
+
* inside the alias's write chain. Skipped when this process removed the file
|
|
72
|
+
* at or after `fetchedAt`, since the registry was read before that removal,
|
|
73
|
+
* and when the file already holds credentials at least as new that outlive
|
|
74
|
+
* `removedAt`. `replaced` is the record the write overwrote.
|
|
75
|
+
*/
|
|
76
|
+
export declare function saveSharedAccount(creds: AccountCredentials, opts: {
|
|
77
|
+
fetchedAt: number;
|
|
78
|
+
removedAt?: number;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
saved: boolean;
|
|
81
|
+
replaced?: AccountCredentials;
|
|
82
|
+
}>;
|
|
83
|
+
/**
|
|
84
|
+
* Remove an account file because the shared account registry holds a
|
|
85
|
+
* removal made at `removedAt`, inside the alias's write chain. A file granted
|
|
86
|
+
* after the removal is a later login and is kept. Not recorded in
|
|
87
|
+
* localAccountRemovals: publishing it again under a later time would remove
|
|
88
|
+
* a login a peer made between the two.
|
|
89
|
+
*/
|
|
90
|
+
export declare function removeSharedAccount(alias: string, removedAt: number): Promise<boolean>;
|
|
57
91
|
/**
|
|
58
92
|
* Carry the organization the proxy observed on a seat into a record that is
|
|
59
93
|
* about to be written anyway — its token refresh (dario#1244). The observation
|
|
@@ -253,8 +287,13 @@ export declare const MIGRATED_LOGIN_ALIAS = "login";
|
|
|
253
287
|
*
|
|
254
288
|
* @param preferredAlias caller may request a specific alias. If it's
|
|
255
289
|
* already the reserved `login` (or collides), falls back to `default`.
|
|
290
|
+
* @param opts.removedAt when the shared account registry (account-sync.ts)
|
|
291
|
+
* removed the alias. A login granted before it is not back-filled, since
|
|
292
|
+
* that would serve a removed account; a later login is.
|
|
256
293
|
*/
|
|
257
|
-
export declare function ensureLoginCredentialsInPool(alias?: string
|
|
294
|
+
export declare function ensureLoginCredentialsInPool(alias?: string, opts?: {
|
|
295
|
+
removedAt?: number | undefined;
|
|
296
|
+
}): Promise<string | null>;
|
|
258
297
|
/**
|
|
259
298
|
* Detect divergence between `accounts/login.json` and the current
|
|
260
299
|
* `credentials.json` (or whichever store loadCredentials finds), and
|