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/README.md +320 -0
- package/package.json +38 -0
- package/src/account-manager.js +1271 -0
- package/src/cloud.js +618 -0
- package/src/config.js +148 -0
- package/src/index.js +1726 -0
- package/src/oauth.js +347 -0
- package/src/server.js +1267 -0
- package/src/tui.js +843 -0
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
|
+
}
|