teamclaude-cloud 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cloud.js ADDED
@@ -0,0 +1,618 @@
1
+ // teamclaude-cloud client — zero runtime dependencies (Node built-ins only).
2
+ //
3
+ // Talks to the Supabase `cloud` Edge Function (token sync + auth-key control) and
4
+ // Supabase Auth (owner login). Pure HTTP helpers so a mock server can drive tests.
5
+ //
6
+ // Config lives under the teamclaude config as `config.cloud`:
7
+ // { url, anonKey, email, session: { access_token, refresh_token, expires_at } }
8
+ import { createHash } from 'node:crypto';
9
+ import { normalizeExpiresAt } from './oauth.js';
10
+
11
+ // Built-in PUBLIC cloud endpoint — our own domain only. A consumer PC pulls with
12
+ // ONLY an auth key (`teamclaude cloud pull --key <key>` works right after
13
+ // `npm install`, no `cloud login`), and owner login goes through the cloud API's
14
+ // /auth proxy — so no backing-infrastructure host or API key ships in this
15
+ // package. Override via `cloud login --url` or a `config.cloud` entry —
16
+ // resolveCloud() layers user config on top of this.
17
+ export const DEFAULT_CLOUD = {
18
+ url: 'https://auth.teamclaude.cloud',
19
+ };
20
+
21
+ /**
22
+ * Resolve the cloud endpoint for a command: user config (from `cloud login`)
23
+ * layered over the built-in public default. So key-only `pull` works with zero
24
+ * config, while an explicit `cloud login` (custom url/anonKey/session) still wins.
25
+ */
26
+ export function resolveCloud(config) {
27
+ return { ...DEFAULT_CLOUD, ...(config && config.cloud ? config.cloud : {}) };
28
+ }
29
+
30
+ /**
31
+ * Remove live accounts (and their in-memory config entries) that are no longer in
32
+ * the desired disk set — the removal counterpart to syncAccountsFromDisk (which
33
+ * only adds/updates). Used after a cloud pull prunes accounts (group change or key
34
+ * revocation). Matches UUID-first then name, as everywhere else. In-flight requests
35
+ * holding the removed account OBJECT drain safely (AccountManager keys on object
36
+ * identity, not array index). Pure logic over the AccountManager interface so it is
37
+ * unit-testable with a lightweight fake. Returns the count removed.
38
+ */
39
+ export function reconcileRemovedAccounts(diskConfig, memConfig, accountManager) {
40
+ const desiredUuids = new Set();
41
+ const desiredNames = new Set();
42
+ for (const a of (diskConfig && diskConfig.accounts) || []) {
43
+ if (a.accountUuid) desiredUuids.add(a.accountUuid);
44
+ desiredNames.add(a.name); // name is only a fallback for UUID-less (legacy) live accounts
45
+ }
46
+ // UUID-FIRST (same precedence as syncAccountsFromDisk): a live account that HAS a
47
+ // UUID is judged by UUID only — never kept by a name that collides with a
48
+ // different-UUID desired account (else a revoked/removed uuid:A,name:X account
49
+ // survives because desired uuid:B shares name X). Only a UUID-less live account
50
+ // falls back to a name match.
51
+ const isDesired = (a) => a.accountUuid ? desiredUuids.has(a.accountUuid) : desiredNames.has(a.name);
52
+ let removed = 0;
53
+ for (const am of accountManager.accounts.filter((a) => !isDesired(a))) {
54
+ const idx = accountManager.accounts.indexOf(am); // current index (removeAccount re-indexes)
55
+ if (idx >= 0) {
56
+ accountManager.removeAccount(idx);
57
+ console.log(`[TeamClaude] Removed account "${am.name}" — no longer allowed by cloud`);
58
+ removed++;
59
+ }
60
+ }
61
+ if (memConfig && Array.isArray(memConfig.accounts)) {
62
+ memConfig.accounts = memConfig.accounts.filter(isDesired); // mirror so a later save doesn't resurrect
63
+ }
64
+ return removed;
65
+ }
66
+
67
+ // A stable, non-reversible per-KEY tag used to scope the merge prune. Deriving it
68
+ // from the auth key (not the group) means a key that MOVES groups replaces its own
69
+ // account set (old-group accounts get pruned), while two DIFFERENT keys never
70
+ // prune each other (Codex HIGH: key-move revocation + multi-key footgun).
71
+ export function keySource(authKey) {
72
+ return 'key:' + createHash('sha256').update(String(authKey)).digest('hex').slice(0, 16);
73
+ }
74
+
75
+ const DEFAULT_TIMEOUT_MS = 30_000;
76
+
77
+ async function httpJson(url, { method = 'GET', headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
78
+ const ctrl = new AbortController();
79
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
80
+ try {
81
+ const res = await fetch(url, {
82
+ method,
83
+ headers: { 'content-type': 'application/json', ...headers },
84
+ body: body != null ? JSON.stringify(body) : undefined,
85
+ signal: ctrl.signal,
86
+ // Never follow redirects: an https→http 307/308 would replay credential
87
+ // bodies (passwords, JWTs, auth keys) over plaintext. The cloud API never
88
+ // legitimately redirects, so treat any redirect as an error.
89
+ redirect: 'error',
90
+ });
91
+ const text = await res.text();
92
+ let data = null;
93
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
94
+ return { ok: res.ok, status: res.status, data };
95
+ } finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+
100
+ function fnBase(cloud) {
101
+ return `${cloud.url.replace(/\/+$/, '')}/functions/v1/cloud`;
102
+ }
103
+
104
+ // Refuse to send credentials (passwords, JWTs, refresh/auth keys) over plaintext
105
+ // HTTP. Loopback is allowed for local development against a mock/dev server.
106
+ function assertSecureUrl(url) {
107
+ let u;
108
+ try { u = new URL(url); } catch { throw new Error(`Invalid cloud URL: ${url}`); }
109
+ const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1';
110
+ if (u.protocol !== 'https:' && !loopback) {
111
+ throw new Error(`Refusing to send credentials over ${u.protocol}//. Use https:// (loopback excepted).`);
112
+ }
113
+ }
114
+
115
+ function requireCloud(cloud) {
116
+ if (!cloud || !cloud.url) {
117
+ throw new Error('Cloud not configured. Run `teamclaude cloud login` first.');
118
+ }
119
+ assertSecureUrl(cloud.url);
120
+ }
121
+
122
+ // The Edge Function itself needs no API key; `apikey` is only sent when a user
123
+ // config carries one (a legacy login against a raw Supabase host).
124
+ function fnHeaders(cloud, extra = {}) {
125
+ const h = { ...extra };
126
+ if (cloud.anonKey) h.apikey = cloud.anonKey;
127
+ return h;
128
+ }
129
+
130
+ function requireSession(cloud) {
131
+ requireCloud(cloud);
132
+ if (!cloud.session || !cloud.session.access_token) {
133
+ throw new Error('Not logged in to cloud. Run `teamclaude cloud login`.');
134
+ }
135
+ }
136
+
137
+ // ── owner auth (via the cloud API's /auth proxy) ─────────────────────────────
138
+ // Login goes through OUR endpoint — the CLI never talks to the backing auth
139
+ // host directly and needs no API key.
140
+
141
+ /** Password grant → session. Does NOT persist; caller stores it in config.cloud. */
142
+ export async function cloudLogin(cloud, email, password) {
143
+ assertSecureUrl(cloud.url);
144
+ const res = await httpJson(`${fnBase(cloud)}/auth/login`, {
145
+ method: 'POST',
146
+ headers: fnHeaders(cloud),
147
+ body: { email, password },
148
+ });
149
+ if (!res.ok) throw new Error(`Cloud login failed (${res.status}): ${describe(res.data)}`);
150
+ return {
151
+ access_token: res.data.access_token,
152
+ refresh_token: res.data.refresh_token,
153
+ expires_at: res.data.expires_at, // seconds epoch
154
+ };
155
+ }
156
+
157
+ /** Refresh an expired owner session. */
158
+ export async function cloudRefreshSession(cloud, refreshToken) {
159
+ assertSecureUrl(cloud.url);
160
+ const res = await httpJson(`${fnBase(cloud)}/auth/refresh`, {
161
+ method: 'POST',
162
+ headers: fnHeaders(cloud),
163
+ body: { refresh_token: refreshToken },
164
+ });
165
+ if (!res.ok) throw new Error(`Cloud session refresh failed (${res.status}): ${describe(res.data)}`);
166
+ return {
167
+ access_token: res.data.access_token,
168
+ refresh_token: res.data.refresh_token,
169
+ expires_at: res.data.expires_at,
170
+ };
171
+ }
172
+
173
+ // ── token sync ───────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Map one `<config>.quota.json` snapshot entry (exportQuotaState shape) to the
177
+ * usage payload the cloud stores for the dashboard: 5h session, 7d weekly, and
178
+ * model-scoped weekly windows (e.g. `7d_oi`, shown as "Fable"). Returns null
179
+ * when the account has no measured numbers — no row is written for it, so the
180
+ * dashboard shows "no data" instead of a fake zero (fail-loud, not fallback).
181
+ */
182
+ export function buildUsageFromQuota(entry) {
183
+ const q = entry?.quota;
184
+ if (!q || typeof q !== 'object') return null;
185
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
186
+ const modelWeekly = {};
187
+ for (const [label, w] of Object.entries(q.modelWeekly || {})) {
188
+ const utilization = num(w?.utilization), reset = num(w?.reset);
189
+ if (utilization == null && reset == null) continue;
190
+ modelWeekly[label] = { utilization, reset };
191
+ }
192
+ const usage = {
193
+ session: { utilization: num(q.unified5h), reset: num(q.unified5hReset) },
194
+ weekly: { utilization: num(q.unified7d), reset: num(q.unified7dReset) },
195
+ modelWeekly,
196
+ };
197
+ const measured = usage.session.utilization != null || usage.weekly.utilization != null
198
+ || Object.keys(modelWeekly).length > 0;
199
+ return measured ? usage : null;
200
+ }
201
+
202
+ /**
203
+ * Push local accounts' tokens to the cloud (owner-authenticated).
204
+ * `expiresAt` is normalized to ms so the cloud freshest-token-wins compares apples to apples.
205
+ * `usageByUuid` (optional Map/object accountUuid → usage from buildUsageFromQuota)
206
+ * rides along so the dashboard can show 5h/7d/Fable utilization.
207
+ */
208
+ export async function cloudPush(cloud, accounts, usageByUuid = null) {
209
+ requireSession(cloud);
210
+ const usageOf = (uuid) => {
211
+ if (!usageByUuid || !uuid) return undefined;
212
+ const u = usageByUuid instanceof Map ? usageByUuid.get(uuid) : usageByUuid[uuid];
213
+ return u || undefined;
214
+ };
215
+ const payload = {
216
+ accounts: (accounts || []).map((a) => ({
217
+ accountUuid: a.accountUuid,
218
+ name: a.name,
219
+ tier: a.subscriptionType || a.tier || null,
220
+ type: a.type || 'oauth',
221
+ accessToken: a.accessToken ?? null,
222
+ refreshToken: a.refreshToken ?? null,
223
+ expiresAt: normalizeExpiresAt(a.expiresAt) || 0,
224
+ usage: usageOf(a.accountUuid),
225
+ })),
226
+ };
227
+ const res = await httpJson(`${fnBase(cloud)}/sync/push`, {
228
+ method: 'POST',
229
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
230
+ body: payload,
231
+ });
232
+ if (!res.ok) throw new Error(`Cloud push failed (${res.status}): ${describe(res.data)}`);
233
+ return res.data; // { ok, pushed, updated, skipped }
234
+ }
235
+
236
+ /**
237
+ * Usage-only push (owner session): update the cloud's live 5h/7d/Fable usage for
238
+ * the given accounts WITHOUT touching tokens, so the dashboard reflects real-time
239
+ * usage instead of only-on-push. `usageByUuid` is a Map/object accountUuid ->
240
+ * buildUsageFromQuota() output. Only accounts that already exist in the cloud
241
+ * (established by a prior cloudPush) are updated; the cloud never creates an
242
+ * account or writes a token from this path. Returns { ok, updated, skipped }.
243
+ */
244
+ export async function cloudPushUsage(cloud, usageByUuid) {
245
+ requireSession(cloud);
246
+ const usage = {};
247
+ const entries = usageByUuid instanceof Map ? usageByUuid.entries() : Object.entries(usageByUuid || {});
248
+ for (const [uuid, u] of entries) { if (uuid && u) usage[uuid] = u; }
249
+ if (!Object.keys(usage).length) return { ok: true, updated: 0, skipped: 0 };
250
+ const res = await httpJson(`${fnBase(cloud)}/sync/usage`, {
251
+ method: 'POST',
252
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
253
+ body: { usage },
254
+ });
255
+ if (!res.ok) throw new Error(`Cloud usage push failed (${res.status}): ${describe(res.data)}`);
256
+ return res.data; // { ok, updated, skipped }
257
+ }
258
+
259
+ /**
260
+ * Consumer usage report (auth key): the proxies that actually USE the accounts
261
+ * report the live 5h/7d/Fable numbers they observe — no owner session required,
262
+ * the pull key IS the credential (same as pull/refresh). The cloud restricts the
263
+ * write to the key's group accounts. Same payload/result as cloudPushUsage.
264
+ */
265
+ export async function cloudReportUsage(cloud, authKey, usageByUuid) {
266
+ requireCloud(cloud);
267
+ if (!authKey) throw new Error('Missing auth key');
268
+ const usage = {};
269
+ const entries = usageByUuid instanceof Map ? usageByUuid.entries() : Object.entries(usageByUuid || {});
270
+ for (const [uuid, u] of entries) { if (uuid && u) usage[uuid] = u; }
271
+ if (!Object.keys(usage).length) return { ok: true, updated: 0, skipped: 0 };
272
+ const res = await httpJson(`${fnBase(cloud)}/sync/usage`, {
273
+ method: 'POST',
274
+ headers: fnHeaders(cloud, { 'x-teamclaude-key': authKey }),
275
+ body: { usage },
276
+ });
277
+ if (!res.ok) throw new Error(`Cloud usage report failed (${res.status}): ${describe(res.data)}`);
278
+ return res.data; // { ok, updated, skipped }
279
+ }
280
+
281
+ /**
282
+ * Cloud-centralized token refresh (auth key). Ask the cloud for a fresh access
283
+ * token for `accountUuid` instead of refreshing (rotating) the OAuth refresh token
284
+ * locally — the cloud does the single rotation, so the same account is safe to use
285
+ * from many PCs at once. Returns { accessToken, refreshToken, expiresAt, refreshed }.
286
+ * Throws on any non-2xx (the caller should fall back to a local refresh).
287
+ */
288
+ export async function cloudRefreshToken(cloud, authKey, accountUuid, minRemainingMs) {
289
+ requireCloud(cloud);
290
+ if (!authKey) throw new Error('Auth key required for cloud refresh.');
291
+ if (!accountUuid) throw new Error('accountUuid required for cloud refresh.');
292
+ const body = { accountUuid };
293
+ if (Number.isFinite(minRemainingMs)) body.minRemainingMs = minRemainingMs;
294
+ const res = await httpJson(`${fnBase(cloud)}/sync/refresh`, {
295
+ method: 'POST',
296
+ headers: fnHeaders(cloud, { 'x-teamclaude-key': authKey }),
297
+ body,
298
+ });
299
+ if (!res.ok) throw new Error(`Cloud refresh failed (${res.status}): ${describe(res.data)}`);
300
+ return {
301
+ accessToken: res.data.accessToken,
302
+ refreshToken: res.data.refreshToken,
303
+ expiresAt: normalizeExpiresAt(res.data.expiresAt) || 0,
304
+ refreshed: !!res.data.refreshed,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Pull allowed accounts+tokens using an auth key (no owner session needed).
310
+ * Returns `{ accounts, allowed, groupId, source }`:
311
+ * - `accounts` — the token payload (accounts that actually have a cloud token), ms-normalized.
312
+ * - `allowed` — the authoritative allow-set (EVERY enabled account uuid in the
313
+ * key's group, token or not); the merge prunes against THIS, so an allowed-but-
314
+ * tokenless account isn't pruned (Codex HIGH-B).
315
+ * - `groupId` — informational (the key's group).
316
+ * - `source` — a stable per-key tag for scoping the merge prune (keySource).
317
+ * The server fails loud (non-2xx) on any DB/decrypt error, so a thrown pull never
318
+ * reaches merge and never prunes on a transient failure.
319
+ */
320
+ export async function cloudPull(cloud, authKey) {
321
+ requireCloud(cloud);
322
+ if (!authKey) throw new Error('Auth key required for pull.');
323
+ const res = await httpJson(`${fnBase(cloud)}/sync/pull`, {
324
+ headers: fnHeaders(cloud, { 'x-teamclaude-key': authKey }),
325
+ });
326
+ if (!res.ok) {
327
+ // Attach the HTTP status so a live-sync loop can classify the failure:
328
+ // 403 = the key was revoked/invalidated (stop serving); 5xx/0/network =
329
+ // transient (keep last-known accounts, retry next interval). See index.js
330
+ // syncFromCloudOnce (irreversible-mutation 3-way: revoke vs transient).
331
+ const err = new Error(`Cloud pull failed (${res.status}): ${describe(res.data)}`);
332
+ err.status = res.status;
333
+ err.revoked = res.status === 403;
334
+ throw err;
335
+ }
336
+ const accounts = (res.data.accounts || []).map((a) => ({
337
+ ...a,
338
+ expiresAt: normalizeExpiresAt(a.expiresAt) || 0,
339
+ }));
340
+ // `allowed` is the server's authoritative allow-set. If the server didn't send
341
+ // it (an OLDER function version), we CANNOT safely prune — using the token
342
+ // payload as the allow-set could delete legitimate local accounts on a partial
343
+ // response (Codex round 4). So leave it null → merge skips pruning entirely
344
+ // (additive-only, the pre-group behavior).
345
+ const allowed = Array.isArray(res.data.allowed) ? res.data.allowed : null;
346
+ return { accounts, allowed, groupId: res.data.groupId ?? null, source: keySource(authKey) };
347
+ }
348
+
349
+ // ── auth-key management (owner-authenticated) ────────────────────────────────
350
+
351
+ export async function cloudKeyCreate(cloud, { label, groupId } = {}) {
352
+ requireSession(cloud);
353
+ const res = await httpJson(`${fnBase(cloud)}/auth-keys`, {
354
+ method: 'POST',
355
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
356
+ body: { label: label ?? null, groupId: groupId ?? null },
357
+ });
358
+ if (!res.ok) throw new Error(`Key create failed (${res.status}): ${describe(res.data)}`);
359
+ return res.data; // { ok, id, key (raw, once), prefix, label, status, groupId }
360
+ }
361
+
362
+ /** Reveal (decrypt) a key's full value for re-copy. Owner-only; only works for
363
+ * keys issued with encrypted storage (410 not_revealable for old hash-only keys). */
364
+ export async function cloudKeyReveal(cloud, id) {
365
+ requireSession(cloud);
366
+ const res = await httpJson(`${fnBase(cloud)}/auth-keys/${encodeURIComponent(id)}/reveal`, {
367
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
368
+ });
369
+ if (!res.ok) throw new Error(`Key reveal failed (${res.status}): ${describe(res.data)}`);
370
+ return res.data; // { ok, id, key }
371
+ }
372
+
373
+ /** Move a key to another group. */
374
+ export async function cloudKeyMove(cloud, id, groupId) {
375
+ requireSession(cloud);
376
+ const res = await httpJson(`${fnBase(cloud)}/auth-keys/${encodeURIComponent(id)}/group`, {
377
+ method: 'PUT',
378
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
379
+ body: { groupId },
380
+ });
381
+ if (!res.ok) throw new Error(`Key move failed (${res.status}): ${describe(res.data)}`);
382
+ return res.data; // { ok, id, groupId, group }
383
+ }
384
+
385
+ export async function cloudKeyList(cloud) {
386
+ requireSession(cloud);
387
+ const res = await httpJson(`${fnBase(cloud)}/auth-keys`, {
388
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
389
+ });
390
+ if (!res.ok) throw new Error(`Key list failed (${res.status}): ${describe(res.data)}`);
391
+ return res.data.keys || [];
392
+ }
393
+
394
+ export async function cloudKeyRevoke(cloud, id) {
395
+ requireSession(cloud);
396
+ const res = await httpJson(`${fnBase(cloud)}/auth-keys/${encodeURIComponent(id)}/revoke`, {
397
+ method: 'POST',
398
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
399
+ });
400
+ if (!res.ok) throw new Error(`Key revoke failed (${res.status}): ${describe(res.data)}`);
401
+ return res.data;
402
+ }
403
+
404
+ // ── group management (owner-authenticated) ───────────────────────────────────
405
+ // Accounts are allowed per GROUP (not per key). An account may be enabled in at
406
+ // most one group at a time — the cloud answers 409 (account_in_use) otherwise.
407
+
408
+ export async function cloudGroupList(cloud) {
409
+ requireSession(cloud);
410
+ const res = await httpJson(`${fnBase(cloud)}/groups`, {
411
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
412
+ });
413
+ if (!res.ok) throw new Error(`Group list failed (${res.status}): ${describe(res.data)}`);
414
+ return res.data.groups || []; // [{ id, name, accounts: [{accountUuid, name, enabled, priority}], activeKeys }]
415
+ }
416
+
417
+ export async function cloudGroupCreate(cloud, name) {
418
+ requireSession(cloud);
419
+ const res = await httpJson(`${fnBase(cloud)}/groups`, {
420
+ method: 'POST',
421
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
422
+ body: { name },
423
+ });
424
+ if (!res.ok) throw new Error(`Group create failed (${res.status}): ${describe(res.data)}`);
425
+ return res.data; // { ok, id, name }
426
+ }
427
+
428
+ export async function cloudGroupRename(cloud, id, name) {
429
+ requireSession(cloud);
430
+ const res = await httpJson(`${fnBase(cloud)}/groups/${encodeURIComponent(id)}`, {
431
+ method: 'PUT',
432
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
433
+ body: { name },
434
+ });
435
+ if (!res.ok) throw new Error(`Group rename failed (${res.status}): ${describe(res.data)}`);
436
+ return res.data;
437
+ }
438
+
439
+ export async function cloudGroupDelete(cloud, id) {
440
+ requireSession(cloud);
441
+ const res = await httpJson(`${fnBase(cloud)}/groups/${encodeURIComponent(id)}`, {
442
+ method: 'DELETE',
443
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
444
+ });
445
+ if (!res.ok) throw new Error(`Group delete failed (${res.status}): ${describe(res.data)}`);
446
+ return res.data; // { ok, id, keysMovedTo }
447
+ }
448
+
449
+ /**
450
+ * Replace a group's allowed accounts. `accounts` = [{accountUuid, enabled, priority}].
451
+ * A 409 means one of the accounts is enabled in another group (exclusivity) —
452
+ * the error message names the conflicting group(s).
453
+ * `expectedRevision` (optional) enables optimistic-concurrency: pass the revision
454
+ * you read; a stale write (concurrent edit) is rejected 409.
455
+ */
456
+ export async function cloudGroupSetAccounts(cloud, id, accounts, expectedRevision = null) {
457
+ requireSession(cloud);
458
+ const body = { accounts: accounts || [] };
459
+ if (Number.isInteger(expectedRevision)) body.expectedRevision = expectedRevision;
460
+ const res = await httpJson(`${fnBase(cloud)}/groups/${encodeURIComponent(id)}/accounts`, {
461
+ method: 'PUT',
462
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
463
+ body,
464
+ });
465
+ if (!res.ok) {
466
+ if (res.status === 409 && res.data?.error === 'stale_revision') {
467
+ throw new Error('Group changed concurrently (409 stale_revision): re-list and retry.');
468
+ }
469
+ if (res.status === 409 && Array.isArray(res.data?.conflicts)) {
470
+ const who = res.data.conflicts.map((c) => `${c.accountName} (그룹 "${c.group}")`).join(', ');
471
+ throw new Error(`Group mapping conflict (409): 이미 다른 그룹에서 사용 중 — ${who}`);
472
+ }
473
+ throw new Error(`Group mapping failed (${res.status}): ${describe(res.data)}`);
474
+ }
475
+ return res.data; // { ok, id, accounts, revision }
476
+ }
477
+
478
+ // ── account global enable/disable ────────────────────────────────────────────
479
+
480
+ export async function cloudAccountSetEnabled(cloud, accountUuid, enabled) {
481
+ requireSession(cloud);
482
+ const res = await httpJson(`${fnBase(cloud)}/accounts/${encodeURIComponent(accountUuid)}`, {
483
+ method: 'PATCH',
484
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
485
+ body: { enabled: !!enabled },
486
+ });
487
+ if (!res.ok) throw new Error(`Account update failed (${res.status}): ${describe(res.data)}`);
488
+ return res.data; // { ok, accountUuid, enabled }
489
+ }
490
+
491
+ // ── merge pulled accounts into a local config (freshest-token-wins) ──────────
492
+
493
+ /**
494
+ * Merge cloud-pulled accounts into a teamclaude config in place.
495
+ * Matches by accountUuid then name. Never overwrites a locally-fresher token
496
+ * (compares ms-normalized expiresAt) — the same rule the proxy uses on disk sync.
497
+ *
498
+ * The pulled `priority` (the key's GROUP ordering) is control-plane state and is
499
+ * applied even when the token itself is skipped as staler: the group config is
500
+ * authoritative for how a consumer orders its accounts, and the local proxy's
501
+ * existing `_priority`-then-use-or-lose selection consumes it unchanged.
502
+ * `priority: null` clears the field back to automatic (use-or-lose) ordering.
503
+ *
504
+ * **Revocation propagation (Codex HIGH #3):** a pull returns the *authoritative*
505
+ * allowed set for the key's group. Accounts written by a pull are tagged with
506
+ * their source group (`cloudSource: <groupId>`); on each subsequent pull, any
507
+ * account tagged with THIS group's source but NOT in the new set is **pruned** —
508
+ * otherwise removing an account from the group (or disabling/moving it) would
509
+ * leave its token locally, where the proxy keeps refreshing and using it
510
+ * indefinitely. Scoping the prune to the source group means alternating pulls
511
+ * with two different keys/groups don't prune each other's accounts. Accounts
512
+ * with no source tag (locally-added, or pulled before this version) are never
513
+ * pruned. When `source` (groupId) is null (a pre-group server response) nothing
514
+ * is tagged and nothing is pruned — the additive-only legacy behavior.
515
+ *
516
+ * @param {object} config teamclaude config (mutated in place)
517
+ * @param {Array} pulled cloudPull().accounts — the token payload
518
+ * @param {string|null} source cloudPull().source — a stable per-key tag; scopes tagging + pruning
519
+ * @param {Array|null} allowed cloudPull().allowed — authoritative allow-set (uuids/names) to prune against.
520
+ * Defaults to the pulled token payload's keys when omitted.
521
+ * Returns a summary { added, updated, skipped, removed }.
522
+ */
523
+ export function mergePulledAccounts(config, pulled, source = null, allowed = null) {
524
+ const summary = { added: 0, updated: 0, skipped: 0, removed: 0 };
525
+ if (!Array.isArray(config.accounts)) config.accounts = [];
526
+ const applyPriority = (target, incoming) => {
527
+ if (!('priority' in incoming)) return; // pre-group cloud response — leave local as-is
528
+ if (Number.isInteger(incoming.priority)) target.priority = incoming.priority;
529
+ else delete target.priority; // null → automatic ordering
530
+ };
531
+ // Multi-source tagging: an account can be managed by several keys (a consumer
532
+ // using >1 key whose groups overlap). Each key adds its source; an account is
533
+ // only pruned once NO managing key still allows it (Codex round 4 R4-3).
534
+ const addSource = (target) => {
535
+ if (source == null) return;
536
+ if (!Array.isArray(target.cloudSources)) target.cloudSources = [];
537
+ if (!target.cloudSources.includes(source)) target.cloudSources.push(source);
538
+ };
539
+ const hasAnyToken = (a) => a.accessToken != null || a.refreshToken != null;
540
+ const list = pulled || [];
541
+ for (const a of list) {
542
+ const incomingExp = normalizeExpiresAt(a.expiresAt) || 0;
543
+ // Match by accountUuid FIRST across the whole array; fall back to name only
544
+ // for legacy rows without a UUID. A name-first match could otherwise overwrite
545
+ // the wrong account when a same-name/different-UUID row appears earlier.
546
+ let idx = a.accountUuid ? config.accounts.findIndex((x) => x.accountUuid === a.accountUuid) : -1;
547
+ if (idx === -1) idx = config.accounts.findIndex((x) => !x.accountUuid && x.name === a.name);
548
+ if (idx === -1) {
549
+ const fresh = {
550
+ name: a.name,
551
+ accountUuid: a.accountUuid,
552
+ type: a.type || 'oauth',
553
+ accessToken: a.accessToken ?? null,
554
+ refreshToken: a.refreshToken ?? null,
555
+ expiresAt: incomingExp,
556
+ subscriptionType: a.tier || undefined,
557
+ enabled: true,
558
+ };
559
+ addSource(fresh);
560
+ applyPriority(fresh, a);
561
+ config.accounts.push(fresh);
562
+ summary.added++;
563
+ continue;
564
+ }
565
+ const cur = config.accounts[idx];
566
+ addSource(cur); // now managed by this key (so this key can prune it later)
567
+ // A payload entry with no token at all (allowed-but-tokenless) must not clobber
568
+ // a valid local token — only apply priority/metadata, keep the local token.
569
+ if (!hasAnyToken(a)) { applyPriority(cur, a); summary.skipped++; continue; }
570
+ const curExp = normalizeExpiresAt(cur.expiresAt) || 0;
571
+ if (curExp > incomingExp) { applyPriority(cur, a); summary.skipped++; continue; } // local token fresher — keep it
572
+ config.accounts[idx] = {
573
+ ...cur,
574
+ accountUuid: a.accountUuid || cur.accountUuid,
575
+ type: a.type || cur.type || 'oauth',
576
+ // Preserve a local token field the payload leaves null — a partial token
577
+ // payload (one field null) must not wipe the other (Codex round 4 R4-2).
578
+ accessToken: a.accessToken ?? cur.accessToken ?? null,
579
+ refreshToken: a.refreshToken ?? cur.refreshToken ?? null,
580
+ expiresAt: incomingExp,
581
+ subscriptionType: a.tier || cur.subscriptionType,
582
+ };
583
+ applyPriority(config.accounts[idx], a);
584
+ summary.updated++;
585
+ }
586
+ // Prune: only when the server sent an authoritative allow-set (`allowed`). Never
587
+ // prune against the token payload alone — a partial payload could delete
588
+ // legitimate accounts (Codex round 4 R4-1). Scope to THIS key's source: drop
589
+ // this source from any account it no longer allows; prune only accounts left
590
+ // with no managing source.
591
+ if (source != null && Array.isArray(allowed)) {
592
+ const allowSet = new Set(allowed);
593
+ // Ensure EVERY account this key currently allows carries this key's source —
594
+ // including allowed-but-tokenless accounts that never appear in the token
595
+ // payload (else a later prune by another key would drop them, Codex round 5).
596
+ for (const x of config.accounts) {
597
+ if (allowSet.has(x.accountUuid) || allowSet.has(x.name)) addSource(x);
598
+ }
599
+ const before = config.accounts.length;
600
+ config.accounts = config.accounts.filter((x) => {
601
+ const sources = Array.isArray(x.cloudSources) ? x.cloudSources : [];
602
+ if (!sources.includes(source)) return true; // not managed by this key — keep
603
+ if (allowSet.has(x.accountUuid) || allowSet.has(x.name)) return true; // still allowed by this key
604
+ // This key no longer allows it — drop this source. Keep if another key still manages it.
605
+ x.cloudSources = sources.filter((s) => s !== source);
606
+ return x.cloudSources.length > 0;
607
+ });
608
+ summary.removed = before - config.accounts.length;
609
+ }
610
+ return summary;
611
+ }
612
+
613
+ function describe(data) {
614
+ if (data == null) return '';
615
+ if (typeof data === 'string') return data;
616
+ if (data.error) return String(data.error) + (data.detail ? ` — ${data.detail}` : '');
617
+ return JSON.stringify(data);
618
+ }