@modelprofile.com/authswitch 9.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +67 -1
- package/dist_ts/authority-contract.js +13 -2
- package/dist_ts/authority-import-contract.d.ts +6 -0
- package/dist_ts/classes.authoritybroker.d.ts +21 -15
- package/dist_ts/classes.authoritybroker.js +94 -31
- package/dist_ts/classes.authorityclient.d.ts +22 -2
- package/dist_ts/classes.authorityclient.js +79 -13
- package/dist_ts/classes.authoritydaemon.d.ts +7 -0
- package/dist_ts/classes.authoritydaemon.js +45 -31
- package/dist_ts/classes.authoritydatabase.d.ts +21 -3
- package/dist_ts/classes.authoritydatabase.js +88 -11
- package/dist_ts/classes.authorityimport.js +2 -2
- package/dist_ts/classes.authoritymodels.js +5 -3
- package/dist_ts/classes.codexmanaged.d.ts +0 -9
- package/dist_ts/classes.codexmanaged.js +8 -28
- package/dist_ts/codexcontract.d.ts +30 -0
- package/dist_ts/codexcontract.js +174 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/package.json +8 -8
- package/readme.md +75 -17
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +71 -4
- package/ts/authority-import-contract.ts +6 -0
- package/ts/classes.authoritybroker.ts +94 -32
- package/ts/classes.authorityclient.ts +86 -15
- package/ts/classes.authoritydaemon.ts +44 -23
- package/ts/classes.authoritydatabase.ts +88 -11
- package/ts/classes.authorityimport.ts +1 -1
- package/ts/classes.authoritymodels.ts +4 -1
- package/ts/classes.codexmanaged.ts +6 -26
- package/ts/codexcontract.ts +200 -0
- package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
|
@@ -5,7 +5,8 @@ import type {
|
|
|
5
5
|
IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
|
|
6
6
|
IReq_AuthSwitchClaudeNativeHandoff, IReq_AuthSwitchClaudeNativeHandoffs,
|
|
7
7
|
IReq_AuthSwitchSwitchClaudeNative, IReq_AuthSwitchEvents,
|
|
8
|
-
IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
8
|
+
IReq_AuthSwitchGetBinding, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
9
|
+
IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchRenameAccount,
|
|
9
10
|
IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchSnapshot,
|
|
10
11
|
IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
11
12
|
} from './authority-contract.js';
|
|
@@ -53,10 +54,36 @@ const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promi
|
|
|
53
54
|
});
|
|
54
55
|
};
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The daemon answered with an older contract than this client reads: it runs an authswitch release from before
|
|
59
|
+
* this one and was not restarted after the upgrade. Restarting it onto the installed release is the repair.
|
|
60
|
+
*/
|
|
61
|
+
export class AuthSwitchDaemonOutdatedError extends Error {
|
|
62
|
+
constructor() {
|
|
63
|
+
super('The authswitch authority daemon runs an older release than this client. Restart it onto the '
|
|
64
|
+
+ 'installed authswitch: authswitch authority service stop, then authswitch authority service start.');
|
|
65
|
+
this.name = 'AuthSwitchDaemonOutdatedError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A snapshot page carries every member of this release's contract. `schemaVersion` is not bumped for an
|
|
71
|
+
* additive member, so an older daemon is told apart by the members its answer lacks.
|
|
72
|
+
*/
|
|
73
|
+
const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapshot => {
|
|
74
|
+
const page: Partial<IAuthSwitchSnapshot> = snapshot;
|
|
75
|
+
if (!Array.isArray(page.nativeAssignments) || page.nextNativeAssignmentCursor === undefined) {
|
|
76
|
+
throw new AuthSwitchDaemonOutdatedError();
|
|
77
|
+
}
|
|
78
|
+
return snapshot;
|
|
79
|
+
};
|
|
80
|
+
|
|
56
81
|
/** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
|
|
57
82
|
export interface IAuthSwitchSubscriptionStatus {
|
|
58
83
|
state: 'current' | 'unavailable' | 'closed';
|
|
59
|
-
|
|
84
|
+
/** `daemon_outdated`: the daemon runs an older release; see `AuthSwitchDaemonOutdatedError`. */
|
|
85
|
+
reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error'
|
|
86
|
+
| 'daemon_outdated';
|
|
60
87
|
epoch: string | null;
|
|
61
88
|
revision: number | null;
|
|
62
89
|
lastVerifiedAt: string | null;
|
|
@@ -66,6 +93,8 @@ export interface IAuthSwitchSubscriptionOptions {
|
|
|
66
93
|
onStatus?: (status: IAuthSwitchSubscriptionStatus) => void | Promise<void>;
|
|
67
94
|
/** Bounded long-poll interval; defaults to 30 seconds. */
|
|
68
95
|
heartbeatMs?: number;
|
|
96
|
+
/** Deliver snapshots that also carry removed accounts and logins; see `IReq_AuthSwitchSnapshot`. */
|
|
97
|
+
includeRemoved?: boolean;
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
/**
|
|
@@ -96,8 +125,10 @@ export class AuthSwitchClient {
|
|
|
96
125
|
.fire(request, { timeoutMs, maxRetries: 0, abortSignal: signal });
|
|
97
126
|
}
|
|
98
127
|
|
|
128
|
+
/** One snapshot page; an older daemon's answer fails with `AuthSwitchDaemonOutdatedError`. */
|
|
99
129
|
public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}, signal?: AbortSignal): Promise<IAuthSwitchSnapshot> {
|
|
100
|
-
return (await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
|
|
130
|
+
return assertCurrentSnapshot((await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
|
|
131
|
+
options, 35_000, false, signal)).snapshot);
|
|
101
132
|
}
|
|
102
133
|
|
|
103
134
|
/** One independently stamped diagnostic page; callers may continue with its independent cursors. */
|
|
@@ -132,30 +163,39 @@ export class AuthSwitchClient {
|
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
/** Assemble a consistent view from bounded database pages; restart if a writer changes the revision. */
|
|
135
|
-
public async snapshotAll(options: { limit?: number; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
|
|
166
|
+
public async snapshotAll(options: { limit?: number; includeRemoved?: boolean; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
|
|
136
167
|
while (true) {
|
|
137
168
|
if (options.signal?.aborted) throw options.signal.reason ?? new Error('Account snapshot cancelled.');
|
|
138
169
|
let accountAfter: string | undefined;
|
|
139
170
|
let loginAfter: string | undefined;
|
|
140
171
|
let bindingAfter: string | undefined;
|
|
172
|
+
let nativeAssignmentAfter: string | undefined;
|
|
141
173
|
let aggregate: IAuthSwitchSnapshot | undefined;
|
|
142
174
|
let changed = false;
|
|
143
175
|
while (true) {
|
|
144
|
-
const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter,
|
|
145
|
-
|
|
146
|
-
|
|
176
|
+
const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter, nativeAssignmentAfter,
|
|
177
|
+
limit: options.limit, ...(options.includeRemoved ? { includeRemoved: true } : {}) }, options.signal);
|
|
178
|
+
if (!aggregate) {
|
|
179
|
+
aggregate = { ...page, accounts: [...page.accounts], logins: [...page.logins], bindings: [...page.bindings],
|
|
180
|
+
nativeAssignments: [...page.nativeAssignments] };
|
|
181
|
+
} else if (aggregate.epoch !== page.epoch || aggregate.revision !== page.revision) {
|
|
147
182
|
changed = true;
|
|
148
183
|
break;
|
|
149
184
|
} else {
|
|
150
185
|
aggregate.accounts.push(...page.accounts);
|
|
151
186
|
aggregate.logins.push(...page.logins);
|
|
152
187
|
aggregate.bindings.push(...page.bindings);
|
|
188
|
+
aggregate.nativeAssignments.push(...page.nativeAssignments);
|
|
153
189
|
}
|
|
154
190
|
accountAfter = page.nextAccountCursor ?? accountAfter ?? page.accounts.at(-1)?.id;
|
|
155
191
|
loginAfter = page.nextLoginCursor ?? loginAfter ?? page.logins.at(-1)?.id;
|
|
156
192
|
bindingAfter = page.nextBindingCursor ?? bindingAfter ?? page.bindings.at(-1)?.id;
|
|
157
|
-
|
|
158
|
-
|
|
193
|
+
nativeAssignmentAfter = page.nextNativeAssignmentCursor ?? nativeAssignmentAfter
|
|
194
|
+
?? page.nativeAssignments.at(-1)?.id;
|
|
195
|
+
if (!page.nextAccountCursor && !page.nextLoginCursor && !page.nextBindingCursor
|
|
196
|
+
&& !page.nextNativeAssignmentCursor) {
|
|
197
|
+
return { ...aggregate, nextAccountCursor: null, nextLoginCursor: null, nextBindingCursor: null,
|
|
198
|
+
nextNativeAssignmentCursor: null };
|
|
159
199
|
}
|
|
160
200
|
}
|
|
161
201
|
if (!changed) throw new Error('Authority snapshot could not complete.');
|
|
@@ -188,9 +228,9 @@ export class AuthSwitchClient {
|
|
|
188
228
|
* full, and since it holds nothing, a process whose only remaining handle it is can exit inside it --
|
|
189
229
|
* dropping the closing status this loop owes its consumer.
|
|
190
230
|
*/
|
|
191
|
-
const retry = () => new Promise<void>(resolve => {
|
|
231
|
+
const retry = (delayMs = 250) => new Promise<void>(resolve => {
|
|
192
232
|
if (signal.aborted) { resolve(); return; }
|
|
193
|
-
const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); },
|
|
233
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, delayMs);
|
|
194
234
|
timer.unref();
|
|
195
235
|
const aborted = () => { clearTimeout(timer); resolve(); };
|
|
196
236
|
signal.addEventListener('abort', aborted, { once: true });
|
|
@@ -208,19 +248,34 @@ export class AuthSwitchClient {
|
|
|
208
248
|
snapshot = undefined;
|
|
209
249
|
if (available) { available = false; await status('unavailable', reason); }
|
|
210
250
|
};
|
|
251
|
+
/**
|
|
252
|
+
* An older daemon answers every attempt the same way until it is restarted, so the consumer is told why once
|
|
253
|
+
* per outage, and the loop keeps trying at a slower pace: the restart that repairs it needs nothing else.
|
|
254
|
+
*/
|
|
255
|
+
let outdatedReported = false;
|
|
256
|
+
const unreadable = async (error: unknown): Promise<boolean> => {
|
|
257
|
+
if (!(error instanceof AuthSwitchDaemonOutdatedError)) return false;
|
|
258
|
+
snapshot = undefined;
|
|
259
|
+
available = false;
|
|
260
|
+
if (!outdatedReported) { outdatedReported = true; await status('unavailable', 'daemon_outdated'); }
|
|
261
|
+
await retry(5_000);
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
211
264
|
try {
|
|
212
265
|
if (!signal.aborted) await status('unavailable', 'initial');
|
|
213
266
|
while (!signal.aborted) {
|
|
214
267
|
if (!snapshot) {
|
|
215
268
|
let fresh: IAuthSwitchSnapshot;
|
|
216
|
-
try { fresh = await this.snapshotAll({ signal }); }
|
|
217
|
-
catch {
|
|
269
|
+
try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
|
|
270
|
+
catch (error) {
|
|
218
271
|
if (signal.aborted) break;
|
|
272
|
+
if (await unreadable(error)) continue;
|
|
219
273
|
await invalidate('disconnect');
|
|
220
274
|
await retry();
|
|
221
275
|
continue;
|
|
222
276
|
}
|
|
223
277
|
snapshot = fresh;
|
|
278
|
+
outdatedReported = false;
|
|
224
279
|
await onSnapshot(fresh);
|
|
225
280
|
available = true;
|
|
226
281
|
lastEpoch = fresh.epoch;
|
|
@@ -243,9 +298,10 @@ export class AuthSwitchClient {
|
|
|
243
298
|
}
|
|
244
299
|
if (result.events.length) {
|
|
245
300
|
let fresh: IAuthSwitchSnapshot;
|
|
246
|
-
try { fresh = await this.snapshotAll({ signal }); }
|
|
247
|
-
catch {
|
|
301
|
+
try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
|
|
302
|
+
catch (error) {
|
|
248
303
|
if (signal.aborted) break;
|
|
304
|
+
if (await unreadable(error)) continue;
|
|
249
305
|
await invalidate('disconnect');
|
|
250
306
|
await retry();
|
|
251
307
|
continue;
|
|
@@ -290,6 +346,16 @@ export class AuthSwitchClient {
|
|
|
290
346
|
return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
|
|
291
347
|
{ operationId }, 35_000, false, signal)).operation;
|
|
292
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Waits up to `waitMs` for the sign-in to change past `afterRevision` and answers with it; a finished
|
|
351
|
+
* sign-in answers at once, and a timed-out wait with the unchanged operation. Follow a sign-in by passing
|
|
352
|
+
* each answer's `revision` back until its state is final.
|
|
353
|
+
*/
|
|
354
|
+
public async watchOperation(operationId: string, afterRevision: number, waitMs = 30_000,
|
|
355
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchGetOperation['response']['operation']> {
|
|
356
|
+
return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
|
|
357
|
+
{ operationId, afterRevision, waitMs }, waitMs + 5_000, false, signal)).operation;
|
|
358
|
+
}
|
|
293
359
|
public async cancelOperation(operationId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchCancelOperation['response']['operation']> {
|
|
294
360
|
return (await this.request<IReq_AuthSwitchCancelOperation>('authswitch.authority.cancel',
|
|
295
361
|
{ operationId }, 35_000, false, signal)).operation;
|
|
@@ -334,6 +400,11 @@ export class AuthSwitchClient {
|
|
|
334
400
|
return this.request<IReq_AuthSwitchClaudeNativeHandoffs>('authswitch.authority.claude.handoffs',
|
|
335
401
|
{ after, limit }, 35_000, false, signal);
|
|
336
402
|
}
|
|
403
|
+
/** The binding `bind` returned, credential-free, or null once this authority holds none by that id. */
|
|
404
|
+
public async getBinding(bindingId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchGetBinding['response']['binding']> {
|
|
405
|
+
return (await this.request<IReq_AuthSwitchGetBinding>('authswitch.authority.binding',
|
|
406
|
+
{ bindingId }, 35_000, false, signal)).binding;
|
|
407
|
+
}
|
|
337
408
|
public bindAccount(input: IReq_AuthSwitchBindAccount['request'],
|
|
338
409
|
signal?: AbortSignal): Promise<IReq_AuthSwitchBindAccount['response']> {
|
|
339
410
|
return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input, 35_000, false, signal);
|
|
@@ -4,7 +4,8 @@ import type {
|
|
|
4
4
|
IAuthSwitchDoctorPage, IReq_AuthSwitchDoctor,
|
|
5
5
|
IReq_AuthSwitchBeginAdd, IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
|
|
6
6
|
IReq_AuthSwitchClaudeNativeHandoff, IReq_AuthSwitchClaudeNativeHandoffs, IReq_AuthSwitchSwitchClaudeNative,
|
|
7
|
-
IReq_AuthSwitchEvents, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
7
|
+
IReq_AuthSwitchEvents, IReq_AuthSwitchGetBinding, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
8
|
+
IReq_AuthSwitchRemoveAccount,
|
|
8
9
|
IReq_AuthSwitchRenameAccount, IReq_AuthSwitchSnapshot,
|
|
9
10
|
IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
10
11
|
} from './authority-contract.js';
|
|
@@ -136,6 +137,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
136
137
|
private readonly runtimeBindingReleaseFences = new Set<string>();
|
|
137
138
|
private readonly runtimeBindingReleases = new Map<string, Promise<IReq_AuthSwitchReleaseBinding['response']>>();
|
|
138
139
|
private closing = false;
|
|
140
|
+
/** Every admitted request until its answer is written; shutdown settles these before it closes the store. */
|
|
141
|
+
private readonly inFlight = new Set<Promise<void>>();
|
|
139
142
|
|
|
140
143
|
constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
|
|
141
144
|
// First, before anything is constructed: a daemon that was going to read the wrong host's credentials
|
|
@@ -279,8 +282,12 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
279
282
|
private handler<T extends ITypedRequest>(method: T['method'],
|
|
280
283
|
run: plugins.typedrequest.THandlerFunction<T>): plugins.typedrequest.TypedHandler<T> {
|
|
281
284
|
return new plugins.typedrequest.TypedHandler<T>(method, async (request, tools) => {
|
|
282
|
-
try {
|
|
283
|
-
|
|
285
|
+
try {
|
|
286
|
+
// The one admission check: once shutdown begins no route starts work, and the requests already
|
|
287
|
+
// admitted are settled before the store closes.
|
|
288
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
289
|
+
return await run(request, tools);
|
|
290
|
+
} catch (error) {
|
|
284
291
|
if (error instanceof AuthSwitchRefusal) {
|
|
285
292
|
throw new plugins.typedrequest.TypedResponseError(error.message, error.data);
|
|
286
293
|
}
|
|
@@ -297,7 +304,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
297
304
|
|| Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
|
|
298
305
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
299
306
|
|| (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
|
|
300
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
301
307
|
return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
|
|
302
308
|
}));
|
|
303
309
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSwitchClaudeNative>(
|
|
@@ -305,7 +311,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
305
311
|
if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
|
|
306
312
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
307
313
|
|| request.purpose !== 'claude_host_native') invalid();
|
|
308
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
309
314
|
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
310
315
|
return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
|
|
311
316
|
}));
|
|
@@ -326,7 +331,9 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
326
331
|
'authswitch.authority.snapshot', async request => {
|
|
327
332
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
|
|
328
333
|
const keys = Object.keys(request);
|
|
329
|
-
if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'
|
|
334
|
+
if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'nativeAssignmentAfter', 'limit',
|
|
335
|
+
'includeRemoved'].includes(key))
|
|
336
|
+
|| (request.includeRemoved !== undefined && typeof request.includeRemoved !== 'boolean')) invalid();
|
|
330
337
|
return { snapshot: await this.broker.snapshot(request) };
|
|
331
338
|
}));
|
|
332
339
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchDoctor>(
|
|
@@ -346,7 +353,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
346
353
|
// believing a source does not exist.
|
|
347
354
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
348
355
|
|| Object.keys(request).length !== 0) invalid();
|
|
349
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
350
356
|
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
351
357
|
return { inventory: await this.legacyImport.inventory() };
|
|
352
358
|
}));
|
|
@@ -357,7 +363,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
357
363
|
|| (request.after !== undefined && !validHash(request.after))
|
|
358
364
|
|| (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
|
|
359
365
|
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
360
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
361
366
|
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
362
367
|
return { status: await this.legacyImport.status(request) };
|
|
363
368
|
}));
|
|
@@ -370,7 +375,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
370
375
|
'authswitch.authority.add', async request => {
|
|
371
376
|
if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
|
|
372
377
|
|| request.providerId !== 'openai' || request.flow !== 'device') invalid();
|
|
373
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
374
378
|
return { operation: await this.broker.beginAddOpenAi(request.operationId) };
|
|
375
379
|
}));
|
|
376
380
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
|
|
@@ -379,13 +383,16 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
379
383
|
|| typeof request.operationId !== 'string'
|
|
380
384
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
381
385
|
|| request.purpose !== 'openai_managed') invalid();
|
|
382
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
383
386
|
return { operation: await this.beginReauthOpenAi(request) };
|
|
384
387
|
}));
|
|
385
388
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetOperation>(
|
|
386
|
-
'authswitch.authority.operation', async request => {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
+
'authswitch.authority.operation', async (request, tools) => {
|
|
390
|
+
const waits = hasKeys(request, ['operationId', 'afterRevision', 'waitMs']);
|
|
391
|
+
if ((!waits && !hasKeys(request, ['operationId'])) || typeof request.operationId !== 'string'
|
|
392
|
+
|| (waits && (!Number.isSafeInteger(request.afterRevision) || request.afterRevision! < 0
|
|
393
|
+
|| !Number.isSafeInteger(request.waitMs) || request.waitMs! < 0 || request.waitMs! > 30_000))) invalid();
|
|
394
|
+
return { operation: await this.broker.getOperation(request.operationId, waits
|
|
395
|
+
? { afterRevision: request.afterRevision!, waitMs: request.waitMs! } : undefined, tools?.abortSignal) };
|
|
389
396
|
}));
|
|
390
397
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchListOperations>(
|
|
391
398
|
'authswitch.authority.operations', async request => {
|
|
@@ -407,7 +414,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
407
414
|
|| typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
|
|
408
415
|
|| typeof request.prompt !== 'string'
|
|
409
416
|
|| (request.model !== undefined && typeof request.model !== 'string')) invalid();
|
|
410
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
411
417
|
const reservation: IPreuseAccountReservation = { operationId: request.operationId };
|
|
412
418
|
let ownsReservation = false;
|
|
413
419
|
const release = (): void => {
|
|
@@ -450,6 +456,11 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
450
456
|
|| typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
|
|
451
457
|
return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
|
|
452
458
|
}));
|
|
459
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetBinding>(
|
|
460
|
+
'authswitch.authority.binding', async request => {
|
|
461
|
+
if (!hasKeys(request, ['bindingId']) || !validBindingId(request.bindingId)) invalid();
|
|
462
|
+
return { binding: await this.broker.getBinding(request.bindingId) };
|
|
463
|
+
}));
|
|
453
464
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
|
|
454
465
|
'authswitch.authority.remove', async request => {
|
|
455
466
|
if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
|
|
@@ -480,7 +491,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
480
491
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
|
|
481
492
|
|| typeof request.minValidityMs !== 'number'
|
|
482
493
|
|| (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
|
|
483
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
484
494
|
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId, request.capability));
|
|
485
495
|
try {
|
|
486
496
|
return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
|
|
@@ -491,7 +501,6 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
491
501
|
'authswitch.authority.release', async request => {
|
|
492
502
|
if (!hasKeys(request, ['bindingId', 'capability'])
|
|
493
503
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
|
|
494
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
495
504
|
return this.releaseRuntimeBinding(request.bindingId, request.capability);
|
|
496
505
|
}));
|
|
497
506
|
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
|
|
@@ -500,14 +509,17 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
500
509
|
if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
|
|
501
510
|
|| request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
|
|
502
511
|
|| !validImportSubmission(request.submission)) invalid();
|
|
503
|
-
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
504
512
|
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
505
513
|
// An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
|
|
506
514
|
// `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
|
|
507
515
|
// leaves the import outcome unknown, and the source must be read with `import.status` rather
|
|
508
516
|
// than submitted again.
|
|
509
|
-
|
|
510
|
-
|
|
517
|
+
// An import writes accounts, logins and native homes, also on a path that fails after a commit, so
|
|
518
|
+
// every outcome wakes the event readers; they read the committed revision themselves.
|
|
519
|
+
try {
|
|
520
|
+
return { result: await this.legacyImport.submit({ submission: request.submission,
|
|
521
|
+
callerQuiescent: true, acknowledgeRunOrder: true }) };
|
|
522
|
+
} finally { this.broker.notifyChanged(); }
|
|
511
523
|
}));
|
|
512
524
|
}
|
|
513
525
|
|
|
@@ -896,16 +908,23 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
896
908
|
let request: ITypedRequest;
|
|
897
909
|
try { request = JSON.parse(input) as ITypedRequest; }
|
|
898
910
|
catch { socket.destroy(); return; }
|
|
899
|
-
void router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
|
|
911
|
+
const answered: Promise<void> = router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
|
|
900
912
|
trustedAbortSignal: abort.signal })
|
|
901
|
-
.then(response => {
|
|
913
|
+
.then(async response => {
|
|
902
914
|
const raw = JSON.stringify(response);
|
|
903
915
|
if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { socket.destroy(); return; }
|
|
904
|
-
socket.end(raw + '\n');
|
|
905
|
-
}).catch(() => socket.destroy())
|
|
916
|
+
await new Promise<void>(resolve => socket.end(raw + '\n', () => resolve()));
|
|
917
|
+
}).catch(() => { socket.destroy(); })
|
|
918
|
+
.finally(() => { this.inFlight.delete(answered); });
|
|
919
|
+
this.inFlight.add(answered);
|
|
906
920
|
});
|
|
907
921
|
}
|
|
908
922
|
|
|
923
|
+
/**
|
|
924
|
+
* Stops admitting work -- the sockets keep listening so every new request is answered `authority_closing`
|
|
925
|
+
* -- settles everything already admitted, a waiting `events` or operation read being woken by the broker and
|
|
926
|
+
* answering from the still-open store, and only then closes the sockets and the store.
|
|
927
|
+
*/
|
|
909
928
|
public async close(): Promise<void> {
|
|
910
929
|
this.closing = true;
|
|
911
930
|
const errors: unknown[] = [];
|
|
@@ -923,6 +942,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
923
942
|
try { await this.usage.close(); } catch (error) { errors.push(error); }
|
|
924
943
|
try { await this.broker.close(); } catch (error) { errors.push(error); }
|
|
925
944
|
if (this.claudeNative) try { await this.claudeNative.close(); } catch (error) { errors.push(error); }
|
|
945
|
+
// A request that arrives meanwhile is refused at admission and joins this set; none of those reads the store.
|
|
946
|
+
while (this.inFlight.size) await Promise.allSettled([...this.inFlight]);
|
|
926
947
|
const management = this.managementServer;
|
|
927
948
|
const runtime = this.runtimeServer;
|
|
928
949
|
this.managementServer = undefined;
|
|
@@ -56,6 +56,11 @@ const sameUsageSource = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsageC
|
|
|
56
56
|
&& left.nativeSource.credentialDigest === right.nativeSource.credentialDigest
|
|
57
57
|
&& left.nativeSource.configDigest === right.nativeSource.configDigest
|
|
58
58
|
&& left.nativeSource.accessTokenDigest === right.nativeSource.accessTokenDigest);
|
|
59
|
+
/**
|
|
60
|
+
* Runtime bindings whose holder releases them over the runtime socket. Managed Codex bindings are the daemon's:
|
|
61
|
+
* the daemon closes that runtime and clears its durable run before it revokes the binding itself.
|
|
62
|
+
*/
|
|
63
|
+
const callerReleasedRuntimes: readonly IStoredAuthorityBinding['runtime'][] = ['flex', 'opencode', 'claude'];
|
|
59
64
|
const storedOperation = (stored: Parameters<typeof AuthSwitchAuthorityOperationModel.exact.toPersisted>[0]): IStoredAuthorityOperation => {
|
|
60
65
|
const operation = AuthSwitchAuthorityOperationModel.exact.toPersisted(stored);
|
|
61
66
|
assertStoredAuthorityOperation(operation);
|
|
@@ -67,6 +72,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
67
72
|
private engine?: plugins.nosqldbEngine.LocalNoSqlDb;
|
|
68
73
|
private database?: plugins.nosqldb.SmartdataDb;
|
|
69
74
|
private started = false;
|
|
75
|
+
private readonly deviceOperationObservers = new Set<(operationId: string) => void>();
|
|
70
76
|
|
|
71
77
|
constructor(private readonly options: IAuthSwitchAuthorityDatabaseOptions) {}
|
|
72
78
|
|
|
@@ -284,6 +290,20 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
284
290
|
return stored ? AuthSwitchAuthorityMigrationLedgerModel.exact.toPersisted(stored) : null;
|
|
285
291
|
}
|
|
286
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Calls `observer` with the id of every device sign-in this database writes, after the write committed or
|
|
295
|
+
* was proven. The writers below are the only ones, so an observer misses no change; it runs synchronously
|
|
296
|
+
* and must only wake a reader. Returns the unsubscribe.
|
|
297
|
+
*/
|
|
298
|
+
public observeDeviceOperations(observer: (operationId: string) => void): () => void {
|
|
299
|
+
this.deviceOperationObservers.add(observer);
|
|
300
|
+
return () => { this.deviceOperationObservers.delete(observer); };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private deviceOperationChanged(operationId: string): void {
|
|
304
|
+
for (const observer of this.deviceOperationObservers) observer(operationId);
|
|
305
|
+
}
|
|
306
|
+
|
|
287
307
|
public async readOperation(id: string): Promise<IStoredAuthorityOperation | null> {
|
|
288
308
|
this.db();
|
|
289
309
|
const stored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id });
|
|
@@ -339,7 +359,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
339
359
|
&& current.accountId === intent.accountId && current.grantId === intent.grantId
|
|
340
360
|
&& current.purpose === intent.purpose;
|
|
341
361
|
try {
|
|
342
|
-
|
|
362
|
+
const admitted = await this.transaction(async session => {
|
|
343
363
|
const existingStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne(
|
|
344
364
|
{ id: intent.id }, { session });
|
|
345
365
|
if (existingStored) {
|
|
@@ -369,11 +389,15 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
369
389
|
if (result.status !== 'inserted') throw new Error('Account operation ID already exists.');
|
|
370
390
|
return { operation, created: true };
|
|
371
391
|
});
|
|
392
|
+
if (admitted.created) this.deviceOperationChanged(intent.id);
|
|
393
|
+
return admitted;
|
|
372
394
|
} catch (error) {
|
|
373
395
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
374
396
|
const stored = await this.readOperation(intent.id);
|
|
375
397
|
if (stored && sameRequest(stored)) {
|
|
376
|
-
|
|
398
|
+
const created = stored.updateId === intent.updateId;
|
|
399
|
+
if (created) this.deviceOperationChanged(intent.id);
|
|
400
|
+
return { operation: stored, created };
|
|
377
401
|
}
|
|
378
402
|
throw new Error('Account operation creation outcome is unknown; no provider login was started.');
|
|
379
403
|
}
|
|
@@ -435,11 +459,15 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
435
459
|
current: stored, change: model => Object.assign(model, draft),
|
|
436
460
|
});
|
|
437
461
|
if (result.status !== 'transitioned') throw new Error('Account operation changed concurrently.');
|
|
462
|
+
this.deviceOperationChanged(operationId);
|
|
438
463
|
return draft;
|
|
439
464
|
} catch (error) {
|
|
440
465
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
441
466
|
const stored = await this.readOperation(operationId);
|
|
442
|
-
if (stored?.kind !== 'preuse_openai' && stored?.updateId === updateId)
|
|
467
|
+
if (stored?.kind !== 'preuse_openai' && stored?.updateId === updateId) {
|
|
468
|
+
this.deviceOperationChanged(operationId);
|
|
469
|
+
return stored;
|
|
470
|
+
}
|
|
443
471
|
throw new Error('Account operation transition outcome is unknown.');
|
|
444
472
|
}
|
|
445
473
|
}
|
|
@@ -742,10 +770,16 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
742
770
|
}
|
|
743
771
|
}
|
|
744
772
|
|
|
745
|
-
|
|
773
|
+
/**
|
|
774
|
+
* One repeatable-read page of the published authority state. Native homes are part of it: every write that
|
|
775
|
+
* changes which account a home runs on appends an account event, so the metadata revision covers them.
|
|
776
|
+
*/
|
|
777
|
+
public async page(accountAfter: string | null, grantAfter: string | null, bindingAfter: string | null,
|
|
778
|
+
claudeHomeAfter: string | null, limit: number): Promise<{
|
|
746
779
|
meta: IStoredAuthorityMeta; accounts: IStoredAuthorityAccount[]; grants: IStoredAuthorityGrant[];
|
|
747
|
-
bindings: IStoredAuthorityBinding[];
|
|
780
|
+
bindings: IStoredAuthorityBinding[]; claudeHomes: IStoredAuthorityClaudeHome[];
|
|
748
781
|
nextAccountCursor: string | null; nextGrantCursor: string | null; nextBindingCursor: string | null;
|
|
782
|
+
nextClaudeHomeCursor: string | null;
|
|
749
783
|
}> {
|
|
750
784
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid authority page size.');
|
|
751
785
|
return this.transaction(async session => {
|
|
@@ -763,15 +797,21 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
763
797
|
filter: bindingAfter ? { id: { $gt: bindingAfter } } : {},
|
|
764
798
|
sort: { id: 1 }, limit: limit + 1, session,
|
|
765
799
|
});
|
|
800
|
+
const claudeHomes = await AuthSwitchAuthorityClaudeHomeModel.exact.findStored({
|
|
801
|
+
filter: claudeHomeAfter ? { id: { $gt: claudeHomeAfter } } : {},
|
|
802
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
803
|
+
});
|
|
766
804
|
const accountPage = accounts.slice(0, limit).map(item => AuthSwitchAuthorityAccountModel.exact.toPersisted(item));
|
|
767
805
|
const grantPage = grants.slice(0, limit).map(item => AuthSwitchAuthorityGrantModel.exact.toPersisted(item));
|
|
768
806
|
const bindingPage = bindings.slice(0, limit).map(item => AuthSwitchAuthorityBindingModel.exact.toPersisted(item));
|
|
807
|
+
const claudeHomePage = claudeHomes.slice(0, limit).map(item => AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(item));
|
|
769
808
|
return {
|
|
770
809
|
meta: AuthSwitchAuthorityMetaModel.exact.toPersisted(meta),
|
|
771
|
-
accounts: accountPage, grants: grantPage, bindings: bindingPage,
|
|
810
|
+
accounts: accountPage, grants: grantPage, bindings: bindingPage, claudeHomes: claudeHomePage,
|
|
772
811
|
nextAccountCursor: accounts.length > limit ? accountPage.at(-1)!.id : null,
|
|
773
812
|
nextGrantCursor: grants.length > limit ? grantPage.at(-1)!.id : null,
|
|
774
813
|
nextBindingCursor: bindings.length > limit ? bindingPage.at(-1)!.id : null,
|
|
814
|
+
nextClaudeHomeCursor: claudeHomes.length > limit ? claudeHomePage.at(-1)!.id : null,
|
|
775
815
|
};
|
|
776
816
|
});
|
|
777
817
|
}
|
|
@@ -1013,7 +1053,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1013
1053
|
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
1014
1054
|
}): Promise<{ account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant; operation: IStoredAuthorityDeviceOperation }> {
|
|
1015
1055
|
try {
|
|
1016
|
-
|
|
1056
|
+
const committed = await this.transaction(async session => {
|
|
1017
1057
|
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1018
1058
|
const operationStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id: operationId }, { session });
|
|
1019
1059
|
if (!metaStored || !operationStored) throw new Error('Device operation is unavailable.');
|
|
@@ -1061,6 +1101,8 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1061
1101
|
updateId, 'account', accountId);
|
|
1062
1102
|
return { ...draft, operation: completed };
|
|
1063
1103
|
});
|
|
1104
|
+
this.deviceOperationChanged(operationId);
|
|
1105
|
+
return committed;
|
|
1064
1106
|
} catch (error) {
|
|
1065
1107
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
1066
1108
|
const [event, operation, pair] = await Promise.all([
|
|
@@ -1070,6 +1112,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1070
1112
|
if (event && operation?.kind !== 'preuse_openai' && operation?.state === 'complete' && operation.updateId === updateId
|
|
1071
1113
|
&& pair.account?.updateId === updateId && pair.grant?.updateId === updateId
|
|
1072
1114
|
&& operation.result?.accountId === accountId && operation.result.grantId === grantId) {
|
|
1115
|
+
this.deviceOperationChanged(operationId);
|
|
1073
1116
|
return { account: pair.account, grant: pair.grant, operation };
|
|
1074
1117
|
}
|
|
1075
1118
|
throw new Error('Device login commit outcome is unknown; inspect the durable operation before retrying.');
|
|
@@ -1163,7 +1206,11 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1163
1206
|
}
|
|
1164
1207
|
}
|
|
1165
1208
|
|
|
1166
|
-
/**
|
|
1209
|
+
/**
|
|
1210
|
+
* Remove all managed grants in one transaction. Native owners must be stopped and handed off first, and a
|
|
1211
|
+
* runtime binding its holder releases must be released first; the daemon's own managed Codex bindings go
|
|
1212
|
+
* with the account.
|
|
1213
|
+
*/
|
|
1167
1214
|
public async removeAccountAndGrants(updateId: string, accountId: string,
|
|
1168
1215
|
expectedRevision: number, statusObservedAt: string): Promise<IStoredAuthorityAccount> {
|
|
1169
1216
|
try {
|
|
@@ -1199,6 +1246,30 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1199
1246
|
throw new AuthSwitchRefusal('claude_handoff_pending', 'Resolve the Claude native handoff before removing this account.');
|
|
1200
1247
|
}
|
|
1201
1248
|
});
|
|
1249
|
+
// A runtime the caller holds is released by the caller, which stops that runtime first; removing the
|
|
1250
|
+
// account under it would strand a runtime whose next access fails. Managed Codex bindings are the
|
|
1251
|
+
// daemon's own, and the daemon has proven their runs gone before it asks for this removal.
|
|
1252
|
+
const heldRuntimes = new Set<string>();
|
|
1253
|
+
let heldAfter: string | null = null;
|
|
1254
|
+
while (true) {
|
|
1255
|
+
const bindings = await AuthSwitchAuthorityBindingModel.exact.findStored({
|
|
1256
|
+
filter: heldAfter ? { accountId, id: { $gt: heldAfter } } : { accountId },
|
|
1257
|
+
sort: { id: 1 }, limit: 128, session,
|
|
1258
|
+
});
|
|
1259
|
+
for (const binding of bindings) {
|
|
1260
|
+
if (callerReleasedRuntimes.includes(binding.runtime)) heldRuntimes.add(binding.runtime);
|
|
1261
|
+
}
|
|
1262
|
+
if (bindings.length < 128) break;
|
|
1263
|
+
heldAfter = bindings.at(-1)!.id;
|
|
1264
|
+
}
|
|
1265
|
+
if (heldRuntimes.size) {
|
|
1266
|
+
const names = [...heldRuntimes].sort();
|
|
1267
|
+
const listed = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
|
|
1268
|
+
throw new AuthSwitchRefusal('account_busy', `This account still backs ${listed} runtime bindings. `
|
|
1269
|
+
+ 'Stop those runtimes and release their bindings before removing the account. A holder that lost '
|
|
1270
|
+
+ 'its capability binds the same runtime and scope again and releases the capability that returns; '
|
|
1271
|
+
+ 'if binding again is refused, reauthenticate the account first.');
|
|
1272
|
+
}
|
|
1202
1273
|
await scan(async (grant, stored) => {
|
|
1203
1274
|
if (grant.state === 'removed') return;
|
|
1204
1275
|
const draft: IStoredAuthorityGrant = {
|
|
@@ -1331,7 +1402,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1331
1402
|
if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
|
|
1332
1403
|
const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
|
|
1333
1404
|
if (!current || current.capabilityHash !== capabilityHash
|
|
1334
|
-
|| (externalOnly && !
|
|
1405
|
+
|| (externalOnly && !callerReleasedRuntimes.includes(current.runtime))) return false;
|
|
1335
1406
|
const result = await AuthSwitchAuthorityBindingModel.exact.delete({ current }, { session });
|
|
1336
1407
|
if (result.status !== 'deleted') throw new Error('Runtime binding changed during revocation.');
|
|
1337
1408
|
await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
@@ -1354,7 +1425,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1354
1425
|
return this.revokeBindingExact(updateId, bindingId, capabilityHash, false);
|
|
1355
1426
|
}
|
|
1356
1427
|
|
|
1357
|
-
/** External callers
|
|
1428
|
+
/** External callers release only Flex, OpenCode and Claude bindings; managed Codex cleanup stays daemon-owned. */
|
|
1358
1429
|
public releaseExternalBinding(updateId: string, bindingId: string, capabilityHash: string): Promise<boolean> {
|
|
1359
1430
|
return this.revokeBindingExact(updateId, bindingId, capabilityHash, true);
|
|
1360
1431
|
}
|
|
@@ -1579,15 +1650,21 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1579
1650
|
activeGrantId: grantId, pendingOperationId: null, status: 'ready', revision: 1,
|
|
1580
1651
|
createdAt: input.now, updateId };
|
|
1581
1652
|
assertStoredAuthorityClaudeHome(home);
|
|
1653
|
+
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1654
|
+
if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
|
|
1582
1655
|
const inserted = await AuthSwitchAuthorityClaudeHomeModel.exact.insert(home, { session });
|
|
1583
1656
|
if (inserted.status !== 'inserted') throw new Error('Claude native home changed concurrently.');
|
|
1657
|
+
// The snapshot publishes which account this home runs on, so registering it is an account change.
|
|
1658
|
+
await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
1659
|
+
updateId, 'account', accountId);
|
|
1584
1660
|
return home;
|
|
1585
1661
|
});
|
|
1586
1662
|
} catch (error) {
|
|
1587
1663
|
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError)
|
|
1588
1664
|
|| !['ambiguous_write', 'unique_conflict'].includes(error.code)) throw error;
|
|
1589
1665
|
const home = await this.readClaudeHome(homeId);
|
|
1590
|
-
if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId
|
|
1666
|
+
if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId
|
|
1667
|
+
&& await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId })) return home;
|
|
1591
1668
|
// One grant, one home: the unique index refused a second home for a login another one already holds.
|
|
1592
1669
|
if (error.code === 'unique_conflict') {
|
|
1593
1670
|
throw new AuthSwitchRefusal('import_refusal',
|
|
@@ -347,7 +347,7 @@ export class AuthSwitchAuthorityImport {
|
|
|
347
347
|
const grant = row.grantId === null ? null : await this.database.readGrant(row.grantId);
|
|
348
348
|
const handoff = await this.database.readHandoff(importHandoffId(row.id));
|
|
349
349
|
entries.push({
|
|
350
|
-
sourceId: row.id, sourceKind: row.sourceKind, status: row.status,
|
|
350
|
+
sourceId: row.id, sourceKind: row.sourceKind, sourcePathHash: row.sourcePathHash, status: row.status,
|
|
351
351
|
accountId: row.accountId, loginId: row.grantId, owner: grant?.owner ?? null,
|
|
352
352
|
action: importAction(row.status, handoff), statusObservedAt: row.statusObservedAt,
|
|
353
353
|
});
|