@modelprofile.com/authswitch 8.0.0 → 8.2.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 +56 -0
- package/dist_ts/authority-contract.js +54 -2
- package/dist_ts/authority-import-contract.d.ts +211 -0
- package/dist_ts/authority-import-contract.js +17 -0
- package/dist_ts/classes.authoritybroker.js +3 -2
- package/dist_ts/classes.authoritycli.d.ts +4 -0
- package/dist_ts/classes.authoritycli.js +148 -11
- package/dist_ts/classes.authorityclient.d.ts +24 -11
- package/dist_ts/classes.authorityclient.js +40 -22
- package/dist_ts/classes.authoritydaemon.d.ts +12 -0
- package/dist_ts/classes.authoritydaemon.js +123 -36
- package/dist_ts/classes.authoritydatabase.d.ts +14 -1
- package/dist_ts/classes.authoritydatabase.js +48 -16
- package/dist_ts/classes.authorityimport.d.ts +176 -0
- package/dist_ts/classes.authorityimport.js +664 -0
- package/dist_ts/classes.authoritymodels.d.ts +16 -1
- package/dist_ts/classes.authoritymodels.js +31 -3
- package/dist_ts/classes.authorityservice.d.ts +10 -0
- package/dist_ts/classes.authorityservice.js +24 -1
- package/dist_ts/classes.claudeauthority.js +19 -11
- package/dist_ts/classes.claudenative.d.ts +63 -3
- package/dist_ts/classes.claudenative.js +68 -8
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +6 -3
- package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
- package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/dist_ts/ts_migration/legacysources/authswitchstores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/authswitchstores.js +244 -0
- package/dist_ts/ts_migration/legacysources/index.d.ts +18 -0
- package/dist_ts/ts_migration/legacysources/index.js +24 -0
- package/dist_ts/ts_migration/legacysources/material.d.ts +37 -0
- package/dist_ts/ts_migration/legacysources/material.js +120 -0
- package/dist_ts/ts_migration/legacysources/nativestores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/nativestores.js +214 -0
- package/dist_ts/ts_migration/legacysources/shared.d.ts +125 -0
- package/dist_ts/ts_migration/legacysources/shared.js +140 -0
- package/package.json +9 -1
- package/readme.md +111 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +106 -0
- package/ts/authority-import-contract.ts +217 -0
- package/ts/classes.authoritybroker.ts +2 -1
- package/ts/classes.authoritycli.ts +164 -10
- package/ts/classes.authorityclient.ts +65 -21
- package/ts/classes.authoritydaemon.ts +121 -34
- package/ts/classes.authoritydatabase.ts +49 -15
- package/ts/classes.authorityimport.ts +746 -0
- package/ts/classes.authoritymodels.ts +32 -2
- package/ts/classes.authorityservice.ts +27 -0
- package/ts/classes.claudeauthority.ts +22 -10
- package/ts/classes.claudenative.ts +104 -10
- package/ts/index.ts +5 -2
- package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
- package/ts/ts_migration/legacysources/authswitchstores.ts +211 -0
- package/ts/ts_migration/legacysources/index.ts +35 -0
- package/ts/ts_migration/legacysources/material.ts +134 -0
- package/ts/ts_migration/legacysources/nativestores.ts +212 -0
- package/ts/ts_migration/legacysources/shared.ts +218 -0
|
@@ -10,6 +10,11 @@ import type {
|
|
|
10
10
|
} from './authority-contract.js';
|
|
11
11
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
12
12
|
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
13
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
14
|
+
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
|
|
15
|
+
TAuthSwitchImportSubmission } from './authority-import-contract.js';
|
|
16
|
+
import { AuthSwitchAuthorityImport,
|
|
17
|
+
type IAuthSwitchAuthorityImportOptions } from './classes.authorityimport.js';
|
|
13
18
|
import { authSwitchBindingCapabilityHash, AuthSwitchAuthorityBroker,
|
|
14
19
|
type IAuthSwitchAuthorityBrokerOptions } from './classes.authoritybroker.js';
|
|
15
20
|
import { AuthSwitchAuthorityDatabase, type IAuthSwitchAuthorityDatabaseOptions } from './classes.authoritydatabase.js';
|
|
@@ -32,15 +37,41 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
|
|
|
32
37
|
claudeStatus?: ClaudeAccountStatus;
|
|
33
38
|
/** Activated only after a verified backend source receipt has registered the native home. */
|
|
34
39
|
claudeNative?: IClaudeNativeAuthorityOptions;
|
|
40
|
+
/** Where the one-time import reads the legacy stores. Defaults to this user's own locations. */
|
|
41
|
+
legacyImport?: IAuthSwitchAuthorityImportOptions;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
|
|
38
45
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
39
46
|
&& Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
47
|
+
// A malformed request is a caller's own bug, not an instruction for an owner, so it stays what it was:
|
|
48
|
+
// its own text, no refusal marker. A client must not present it as something the owner can act on.
|
|
40
49
|
const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError('Invalid authswitch authority request.'); };
|
|
41
50
|
const validBindingId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
42
51
|
const validBindingCapability = (value: unknown): value is string => typeof value === 'string'
|
|
43
52
|
&& /^[A-Za-z0-9_-]{43}$/.test(value);
|
|
53
|
+
const validHash = (value: unknown): value is string => typeof value === 'string'
|
|
54
|
+
&& /^[a-f0-9]{64}$/.test(value);
|
|
55
|
+
const boundedToken = (value: unknown): value is string => typeof value === 'string'
|
|
56
|
+
&& value.length > 0 && value.length <= 8192;
|
|
57
|
+
|
|
58
|
+
/** A submission names a local source by id and digest, or carries one external store's own login. */
|
|
59
|
+
const validImportSubmission = (value: unknown): value is TAuthSwitchImportSubmission => {
|
|
60
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
61
|
+
const submission = value as Record<string, unknown>;
|
|
62
|
+
if (submission.kind === 'local') {
|
|
63
|
+
return hasKeys(submission, ['kind', 'sourceId', 'sourceDigest'])
|
|
64
|
+
&& validHash(submission.sourceId) && validHash(submission.sourceDigest);
|
|
65
|
+
}
|
|
66
|
+
if (submission.kind !== 'external'
|
|
67
|
+
|| !hasKeys(submission, ['kind', 'sourceKind', 'sourcePathHash', 'sourceDigest', 'credential'])
|
|
68
|
+
|| submission.sourceKind !== 'agl_flex' || !validHash(submission.sourcePathHash)
|
|
69
|
+
|| !validHash(submission.sourceDigest)) return false;
|
|
70
|
+
const credential = submission.credential;
|
|
71
|
+
return hasKeys(credential, ['providerId', 'accessToken', 'refreshToken', 'idToken'])
|
|
72
|
+
&& credential.providerId === 'openai' && boundedToken(credential.accessToken)
|
|
73
|
+
&& boundedToken(credential.refreshToken) && boundedToken(credential.idToken);
|
|
74
|
+
};
|
|
44
75
|
|
|
45
76
|
interface IPreuseAccountReservation {
|
|
46
77
|
operationId: string;
|
|
@@ -52,6 +83,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
52
83
|
public readonly broker: AuthSwitchAuthorityBroker;
|
|
53
84
|
public readonly usage: AuthSwitchAuthorityUsage;
|
|
54
85
|
public readonly preuse: AuthSwitchAuthorityPreuse;
|
|
86
|
+
public readonly legacyImport: AuthSwitchAuthorityImport;
|
|
55
87
|
public readonly claudeNative?: ClaudeNativeAuthority;
|
|
56
88
|
private readonly managementRouter = new plugins.typedrequest.TypedRouter();
|
|
57
89
|
private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
|
|
@@ -102,6 +134,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
102
134
|
provider,
|
|
103
135
|
});
|
|
104
136
|
this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
|
|
137
|
+
this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
|
|
105
138
|
if (options.claudeNative) {
|
|
106
139
|
this.claudeNative = new ClaudeNativeAuthority(this.database, {
|
|
107
140
|
...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
|
|
@@ -171,7 +204,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
171
204
|
const prepare = async (): Promise<void> => {
|
|
172
205
|
if (this.closing || this.drainingAccounts.has(request.accountId)
|
|
173
206
|
|| this.preuseAccounts.has(request.accountId)) {
|
|
174
|
-
throw new
|
|
207
|
+
throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
|
|
175
208
|
}
|
|
176
209
|
this.drainingAccounts.add(request.accountId);
|
|
177
210
|
this.deviceDrainOwners.set(request.accountId, request.operationId);
|
|
@@ -198,46 +231,67 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
198
231
|
});
|
|
199
232
|
}
|
|
200
233
|
|
|
234
|
+
/**
|
|
235
|
+
* One handler, with the only place a refusal becomes a marked answer.
|
|
236
|
+
*
|
|
237
|
+
* Both routers register through here, so a handler states its refusal by throwing `AuthSwitchRefusal`
|
|
238
|
+
* and never touches the wire shape; anything else it throws stays the transport's sanitised fault,
|
|
239
|
+
* which is what keeps "the outcome is unknown" distinguishable from "here is what to do instead".
|
|
240
|
+
*/
|
|
241
|
+
private handler<T extends ITypedRequest>(method: T['method'],
|
|
242
|
+
run: plugins.typedrequest.THandlerFunction<T>): plugins.typedrequest.TypedHandler<T> {
|
|
243
|
+
return new plugins.typedrequest.TypedHandler<T>(method, async (request, tools) => {
|
|
244
|
+
try { return await run(request, tools); }
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error instanceof AuthSwitchRefusal) {
|
|
247
|
+
throw new plugins.typedrequest.TypedResponseError(error.message, error.data);
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
201
254
|
private register(): void {
|
|
202
|
-
this.managementRouter.addTypedHandler(
|
|
255
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchUsage>(
|
|
203
256
|
'authswitch.authority.usage', async request => {
|
|
204
257
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
205
258
|
|| !Object.hasOwn(request, 'accountId') || !Object.hasOwn(request, 'loginId')
|
|
206
259
|
|| Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
|
|
207
260
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
208
261
|
|| (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
|
|
209
|
-
if (this.closing) throw new
|
|
262
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
210
263
|
return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
|
|
211
264
|
}));
|
|
212
|
-
this.managementRouter.addTypedHandler(
|
|
265
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSwitchClaudeNative>(
|
|
213
266
|
'authswitch.authority.claude.switch', async request => {
|
|
214
267
|
if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
|
|
215
268
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
216
269
|
|| request.purpose !== 'claude_host_native') invalid();
|
|
217
|
-
if (this.closing
|
|
270
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
271
|
+
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
218
272
|
return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
|
|
219
273
|
}));
|
|
220
|
-
this.managementRouter.addTypedHandler(
|
|
274
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoff>(
|
|
221
275
|
'authswitch.authority.claude.handoff', async request => {
|
|
222
276
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
223
|
-
if (!this.claudeNative) throw new
|
|
277
|
+
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
224
278
|
return { handoff: await this.claudeNative.getHandoff(request.operationId) };
|
|
225
279
|
}));
|
|
226
|
-
this.managementRouter.addTypedHandler(
|
|
280
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoffs>(
|
|
227
281
|
'authswitch.authority.claude.handoffs', async request => {
|
|
228
282
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
229
283
|
|| Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
|
|
230
|
-
if (!this.claudeNative) throw new
|
|
284
|
+
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
231
285
|
return this.claudeNative.listHandoffs(request.after ?? null, request.limit ?? 128);
|
|
232
286
|
}));
|
|
233
|
-
this.managementRouter.addTypedHandler(
|
|
287
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSnapshot>(
|
|
234
288
|
'authswitch.authority.snapshot', async request => {
|
|
235
289
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
|
|
236
290
|
const keys = Object.keys(request);
|
|
237
291
|
if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
|
|
238
292
|
return { snapshot: await this.broker.snapshot(request) };
|
|
239
293
|
}));
|
|
240
|
-
this.managementRouter.addTypedHandler(
|
|
294
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchDoctor>(
|
|
241
295
|
'authswitch.authority.doctor', async request => {
|
|
242
296
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
|
|
243
297
|
const cursorKeys = ['accountAfter', 'loginAfter', 'codexHomeAfter', 'claudeHomeAfter',
|
|
@@ -248,44 +302,63 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
248
302
|
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
249
303
|
return { page: await this.doctorPage(request) };
|
|
250
304
|
}));
|
|
251
|
-
this.managementRouter.addTypedHandler(
|
|
305
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportInventory>(
|
|
306
|
+
'authswitch.authority.import.inventory', async request => {
|
|
307
|
+
// A read with no parameters: the inventory is the whole host, and a caller cannot narrow it into
|
|
308
|
+
// believing a source does not exist.
|
|
309
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
310
|
+
|| Object.keys(request).length !== 0) invalid();
|
|
311
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
312
|
+
return { inventory: await this.legacyImport.inventory() };
|
|
313
|
+
}));
|
|
314
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportStatus>(
|
|
315
|
+
'authswitch.authority.import.status', async request => {
|
|
316
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
317
|
+
|| Object.keys(request).some(key => !['after', 'limit'].includes(key))
|
|
318
|
+
|| (request.after !== undefined && !validHash(request.after))
|
|
319
|
+
|| (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
|
|
320
|
+
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
321
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
322
|
+
return { status: await this.legacyImport.status(request) };
|
|
323
|
+
}));
|
|
324
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchEvents>(
|
|
252
325
|
'authswitch.authority.events', async (request, tools) => {
|
|
253
326
|
if (!hasKeys(request, ['epoch', 'afterRevision', 'waitMs'])) invalid();
|
|
254
327
|
return this.broker.events(request.epoch, request.afterRevision, request.waitMs, tools?.abortSignal);
|
|
255
328
|
}));
|
|
256
|
-
this.managementRouter.addTypedHandler(
|
|
329
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginAdd>(
|
|
257
330
|
'authswitch.authority.add', async request => {
|
|
258
331
|
if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
|
|
259
332
|
|| request.providerId !== 'openai' || request.flow !== 'device') invalid();
|
|
260
|
-
if (this.closing) throw new
|
|
333
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
261
334
|
return { operation: await this.broker.beginAddOpenAi(request.operationId) };
|
|
262
335
|
}));
|
|
263
|
-
this.managementRouter.addTypedHandler(
|
|
336
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
|
|
264
337
|
'authswitch.authority.reauth', async request => {
|
|
265
338
|
if (!hasKeys(request, ['operationId', 'accountId', 'loginId', 'purpose', 'flow']) || request.flow !== 'device'
|
|
266
339
|
|| typeof request.operationId !== 'string'
|
|
267
340
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
268
341
|
|| request.purpose !== 'openai_managed') invalid();
|
|
269
|
-
if (this.closing) throw new
|
|
342
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
270
343
|
return { operation: await this.beginReauthOpenAi(request) };
|
|
271
344
|
}));
|
|
272
|
-
this.managementRouter.addTypedHandler(
|
|
345
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetOperation>(
|
|
273
346
|
'authswitch.authority.operation', async request => {
|
|
274
347
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
275
348
|
return { operation: await this.broker.getOperation(request.operationId) };
|
|
276
349
|
}));
|
|
277
|
-
this.managementRouter.addTypedHandler(
|
|
350
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchListOperations>(
|
|
278
351
|
'authswitch.authority.operations', async request => {
|
|
279
352
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
280
353
|
|| Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
|
|
281
354
|
return this.broker.listOperations(request.after ?? null, request.limit ?? 128);
|
|
282
355
|
}));
|
|
283
|
-
this.managementRouter.addTypedHandler(
|
|
356
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelOperation>(
|
|
284
357
|
'authswitch.authority.cancel', async request => {
|
|
285
358
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
286
359
|
return { operation: await this.broker.cancelOperation(request.operationId) };
|
|
287
360
|
}));
|
|
288
|
-
this.managementRouter.addTypedHandler(
|
|
361
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchStartPreuse>(
|
|
289
362
|
'authswitch.authority.preuse.start', async request => {
|
|
290
363
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
291
364
|
|| !['operationId', 'accountId', 'loginId', 'purpose', 'prompt'].every(key => Object.hasOwn(request, key))
|
|
@@ -294,7 +367,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
294
367
|
|| typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
|
|
295
368
|
|| typeof request.prompt !== 'string'
|
|
296
369
|
|| (request.model !== undefined && typeof request.model !== 'string')) invalid();
|
|
297
|
-
if (this.closing) throw new
|
|
370
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
298
371
|
const reservation: IPreuseAccountReservation = { operationId: request.operationId };
|
|
299
372
|
let ownsReservation = false;
|
|
300
373
|
const release = (): void => {
|
|
@@ -321,28 +394,28 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
321
394
|
throw error;
|
|
322
395
|
}
|
|
323
396
|
}));
|
|
324
|
-
this.managementRouter.addTypedHandler(
|
|
397
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetPreuse>(
|
|
325
398
|
'authswitch.authority.preuse.operation', async request => {
|
|
326
399
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
327
400
|
return { operation: await this.preuse.get(request.operationId) };
|
|
328
401
|
}));
|
|
329
|
-
this.managementRouter.addTypedHandler(
|
|
402
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelPreuse>(
|
|
330
403
|
'authswitch.authority.preuse.cancel', async request => {
|
|
331
404
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
332
405
|
return { operation: await this.preuse.cancel(request.operationId) };
|
|
333
406
|
}));
|
|
334
|
-
this.managementRouter.addTypedHandler(
|
|
407
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRenameAccount>(
|
|
335
408
|
'authswitch.authority.rename', async request => {
|
|
336
409
|
if (!hasKeys(request, ['accountId', 'expectedRevision', 'label']) || typeof request.accountId !== 'string'
|
|
337
410
|
|| typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
|
|
338
411
|
return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
|
|
339
412
|
}));
|
|
340
|
-
this.managementRouter.addTypedHandler(
|
|
413
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
|
|
341
414
|
'authswitch.authority.remove', async request => {
|
|
342
415
|
if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
|
|
343
416
|
|| typeof request.expectedRevision !== 'number') invalid();
|
|
344
417
|
if (this.drainingAccounts.has(request.accountId) || this.preuseAccounts.has(request.accountId)) {
|
|
345
|
-
throw new
|
|
418
|
+
throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
|
|
346
419
|
}
|
|
347
420
|
this.drainingAccounts.add(request.accountId);
|
|
348
421
|
try {
|
|
@@ -350,7 +423,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
350
423
|
return { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
|
|
351
424
|
} finally { this.drainingAccounts.delete(request.accountId); }
|
|
352
425
|
}));
|
|
353
|
-
this.managementRouter.addTypedHandler(
|
|
426
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBindAccount>(
|
|
354
427
|
'authswitch.authority.bind', async request => {
|
|
355
428
|
if (!hasKeys(request, ['accountId', 'loginId', 'purpose', 'runtime', 'scopeId', 'incarnationId'])
|
|
356
429
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
@@ -358,7 +431,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
358
431
|
|| typeof request.incarnationId !== 'string') invalid();
|
|
359
432
|
return this.broker.bindAccount(request);
|
|
360
433
|
}));
|
|
361
|
-
this.runtimeRouter.addTypedHandler(
|
|
434
|
+
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchResolveAccess>(
|
|
362
435
|
'authswitch.authority.resolveAccess', async request => {
|
|
363
436
|
if ((typeof request !== 'object' || request === null || Array.isArray(request))
|
|
364
437
|
|| !['bindingId', 'capability', 'minValidityMs'].every(key => Object.hasOwn(request, key))
|
|
@@ -367,20 +440,34 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
367
440
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
|
|
368
441
|
|| typeof request.minValidityMs !== 'number'
|
|
369
442
|
|| (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
|
|
370
|
-
if (this.closing) throw new
|
|
443
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
371
444
|
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId, request.capability));
|
|
372
445
|
try {
|
|
373
446
|
return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
|
|
374
447
|
request.rejectedGrantGeneration);
|
|
375
448
|
} finally { finish(); }
|
|
376
449
|
}));
|
|
377
|
-
this.runtimeRouter.addTypedHandler(
|
|
450
|
+
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchReleaseBinding>(
|
|
378
451
|
'authswitch.authority.release', async request => {
|
|
379
452
|
if (!hasKeys(request, ['bindingId', 'capability'])
|
|
380
453
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
|
|
381
|
-
if (this.closing) throw new
|
|
454
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
382
455
|
return this.releaseRuntimeBinding(request.bindingId, request.capability);
|
|
383
456
|
}));
|
|
457
|
+
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
|
|
458
|
+
'authswitch.authority.import.submit', async request => {
|
|
459
|
+
// Backend-only: an external submission carries the material of a store this package cannot read.
|
|
460
|
+
if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
|
|
461
|
+
|| request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
|
|
462
|
+
|| !validImportSubmission(request.submission)) invalid();
|
|
463
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
464
|
+
// An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
|
|
465
|
+
// `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
|
|
466
|
+
// leaves the import outcome unknown, and the source must be read with `import.status` rather
|
|
467
|
+
// than submitted again.
|
|
468
|
+
return { result: await this.legacyImport.submit({ submission: request.submission,
|
|
469
|
+
callerQuiescent: true, acknowledgeRunOrder: true }) };
|
|
470
|
+
}));
|
|
384
471
|
}
|
|
385
472
|
|
|
386
473
|
private async doctorPage(request: IReq_AuthSwitchDoctor['request']): Promise<IAuthSwitchDoctorPage> {
|
|
@@ -521,13 +608,13 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
521
608
|
const run = home.activeRun;
|
|
522
609
|
if (!run) continue;
|
|
523
610
|
const survivor = await findManagedCodexProcess(run.socketPath);
|
|
524
|
-
if (survivor) throw new
|
|
611
|
+
if (survivor) throw new AuthSwitchRefusal('account_busy', 'A managed Codex app-server still owns this account; stop its work before changing the account.');
|
|
525
612
|
if (run.pid === null || run.startedAt === null) {
|
|
526
|
-
throw new
|
|
613
|
+
throw new AuthSwitchRefusal('account_busy', 'A managed Codex launch has uncertain process ownership; reconcile it before changing the account.');
|
|
527
614
|
}
|
|
528
615
|
try {
|
|
529
616
|
process.kill(run.pid, 0);
|
|
530
|
-
throw new
|
|
617
|
+
throw new AuthSwitchRefusal('account_busy', 'A managed Codex process identity is still present; reconcile it before changing the account.');
|
|
531
618
|
} catch (error) {
|
|
532
619
|
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')) throw error;
|
|
533
620
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
2
3
|
import {
|
|
3
4
|
AuthSwitchAuthorityAccountModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityClaudeHandoffModel,
|
|
4
5
|
AuthSwitchAuthorityClaudeHomeModel, AuthSwitchAuthorityCodexHomeModel, AuthSwitchAuthorityEnrollmentModel,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
assertStoredAuthorityBinding, assertStoredAuthorityClaudeHandoff, assertStoredAuthorityClaudeHome,
|
|
9
10
|
assertStoredAuthorityEnrollment, assertStoredAuthorityGrant,
|
|
10
11
|
assertStoredAuthorityHandoff, assertStoredAuthorityMigrationLedger, assertStoredAuthorityOperation,
|
|
11
|
-
assertStoredAuthorityUsage,
|
|
12
|
+
assertStoredAuthorityUsage, grantRefresherTransferRefusal, transfersGrantRefresher,
|
|
12
13
|
type IStoredAuthorityAccount, type IStoredAuthorityBinding, type IStoredAuthorityClaudeHandoff,
|
|
13
14
|
type IStoredAuthorityClaudeHome, type IStoredAuthorityCodexHome, type IStoredAuthorityEnrollment,
|
|
14
15
|
type IStoredAuthorityEvent, type IStoredAuthorityGrant, type IStoredAuthorityHandoff,
|
|
@@ -358,7 +359,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
358
359
|
|| account.primaryGrantId !== intent.grantId || !grant || grant.accountId !== intent.accountId
|
|
359
360
|
|| grant.providerId !== 'openai' || grant.purpose !== 'openai_managed'
|
|
360
361
|
|| grant.owner !== 'daemon' || ['removed', 'exchange_may_have_been_sent'].includes(grant.state)) {
|
|
361
|
-
throw new
|
|
362
|
+
throw new AuthSwitchRefusal('login_unavailable', 'Selected OpenAI login is unavailable for reauthentication.');
|
|
362
363
|
}
|
|
363
364
|
expectedGrantGeneration = grant.grantGeneration;
|
|
364
365
|
}
|
|
@@ -573,6 +574,19 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
573
574
|
return stored.map(item => AuthSwitchAuthorityMigrationLedgerModel.exact.toPersisted(item));
|
|
574
575
|
}
|
|
575
576
|
|
|
577
|
+
/** Every migration ledger row in id order, one bounded page at a time. */
|
|
578
|
+
public async migrationLedgerPage(after: string | null,
|
|
579
|
+
limit = 128): Promise<{ rows: IStoredAuthorityMigrationLedger[]; nextCursor: string | null }> {
|
|
580
|
+
this.db();
|
|
581
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid migration page size.');
|
|
582
|
+
const stored = await AuthSwitchAuthorityMigrationLedgerModel.exact.findStored({
|
|
583
|
+
filter: after ? { id: { $gt: after } } : {}, sort: { id: 1 }, limit: limit + 1,
|
|
584
|
+
});
|
|
585
|
+
const rows = stored.slice(0, limit)
|
|
586
|
+
.map(item => AuthSwitchAuthorityMigrationLedgerModel.exact.toPersisted(item));
|
|
587
|
+
return { rows, nextCursor: stored.length > limit ? rows[rows.length - 1].id : null };
|
|
588
|
+
}
|
|
589
|
+
|
|
576
590
|
public async readAccountAndPrimaryGrant(id: string): Promise<{
|
|
577
591
|
account: IStoredAuthorityAccount | null; grant: IStoredAuthorityGrant | null;
|
|
578
592
|
}> {
|
|
@@ -973,9 +987,8 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
973
987
|
if (accountStored && accountStored.primaryGrantId !== grantId) {
|
|
974
988
|
throw new Error('Primary grant identity cannot be replaced.');
|
|
975
989
|
}
|
|
976
|
-
if (grantStored &&
|
|
977
|
-
|
|
978
|
-
throw new Error('Native ownership requires a verified handoff.');
|
|
990
|
+
if (grantStored && transfersGrantRefresher(grantStored.owner, draft.grant.owner)) {
|
|
991
|
+
throw new Error(grantRefresherTransferRefusal(grantStored.owner, draft.grant.owner));
|
|
979
992
|
}
|
|
980
993
|
await this.persistAccount(session, accountStored, draft.account);
|
|
981
994
|
await this.persistGrant(session, grantStored, draft.grant);
|
|
@@ -1026,9 +1039,9 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1026
1039
|
if (accountStored && accountStored.primaryGrantId !== grantId) {
|
|
1027
1040
|
throw new Error('Primary grant identity cannot be replaced.');
|
|
1028
1041
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
throw new Error(
|
|
1042
|
+
// A device login commits daemon ownership; it is no proof that a native owner has stopped refreshing.
|
|
1043
|
+
if (grantStored && transfersGrantRefresher(grantStored.owner, draft.grant.owner)) {
|
|
1044
|
+
throw new Error(grantRefresherTransferRefusal(grantStored.owner, draft.grant.owner));
|
|
1032
1045
|
}
|
|
1033
1046
|
const completed: IStoredAuthorityDeviceOperation = {
|
|
1034
1047
|
...operation, state: 'complete', prompt: null,
|
|
@@ -1081,10 +1094,14 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1081
1094
|
if (draft.id !== grantId || draft.accountId !== account.id || draft.updateId !== updateId) {
|
|
1082
1095
|
throw new Error('Grant change has an invalid identity.');
|
|
1083
1096
|
}
|
|
1097
|
+
// This writer carries no handoff proof, so it may only move a grant within the ownership it already has.
|
|
1098
|
+
if (transfersGrantRefresher(grant.owner, draft.owner)) {
|
|
1099
|
+
throw new Error(grantRefresherTransferRefusal(grant.owner, draft.owner));
|
|
1100
|
+
}
|
|
1084
1101
|
if (grant.purpose === 'claude_host_native' && draft.state === 'exchange_may_have_been_sent'
|
|
1085
1102
|
&& grant.state !== 'exchange_may_have_been_sent'
|
|
1086
1103
|
&& !(await this.hasVerifiedClaudeGrantReceipt(account.id, grant.id, session))) {
|
|
1087
|
-
throw new
|
|
1104
|
+
throw new AuthSwitchRefusal('claude_receipt_missing', 'Claude refresh requires a verified import receipt.');
|
|
1088
1105
|
}
|
|
1089
1106
|
const accountDraft: IStoredAuthorityAccount = { ...account, revision: account.revision + 1,
|
|
1090
1107
|
statusObservedAt: draft.statusObservedAt, updateId };
|
|
@@ -1124,6 +1141,10 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1124
1141
|
|| draft.providerId !== account.providerId || draft.updateId !== updateId) {
|
|
1125
1142
|
throw new Error('Secondary grant identity is invalid.');
|
|
1126
1143
|
}
|
|
1144
|
+
// A secondary grant transfers its refresher by a verified handoff as well; one token, one refresher.
|
|
1145
|
+
if (grant && transfersGrantRefresher(grant.owner, draft.owner)) {
|
|
1146
|
+
throw new Error(grantRefresherTransferRefusal(grant.owner, draft.owner));
|
|
1147
|
+
}
|
|
1127
1148
|
await this.persistGrant(session, grantStored, draft);
|
|
1128
1149
|
const updatedAccount = { ...account, revision: account.revision + 1,
|
|
1129
1150
|
statusObservedAt: draft.statusObservedAt, updateId };
|
|
@@ -1150,7 +1171,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1150
1171
|
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1151
1172
|
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
1152
1173
|
if (!metaStored || !accountStored || accountStored.removed || accountStored.revision !== expectedRevision) {
|
|
1153
|
-
throw new
|
|
1174
|
+
throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before removing.');
|
|
1154
1175
|
}
|
|
1155
1176
|
const account = AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored);
|
|
1156
1177
|
const scan = async (visit: (grant: IStoredAuthorityGrant,
|
|
@@ -1169,13 +1190,13 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1169
1190
|
await scan(async grant => {
|
|
1170
1191
|
if (grant.state === 'removed') return;
|
|
1171
1192
|
if (grant.owner === 'claude_native' || grant.owner === 'legacy_native') {
|
|
1172
|
-
throw new
|
|
1193
|
+
throw new AuthSwitchRefusal('native_owner_holds_login', 'Stop the native owner and complete its handoff before removing this account.');
|
|
1173
1194
|
}
|
|
1174
1195
|
if (grant.state === 'exchange_may_have_been_sent') {
|
|
1175
|
-
throw new
|
|
1196
|
+
throw new AuthSwitchRefusal('account_busy', 'Wait for the account refresh to finish before removing it.');
|
|
1176
1197
|
}
|
|
1177
1198
|
if (['handoff_pending', 'handoff_quarantined'].includes(grant.state)) {
|
|
1178
|
-
throw new
|
|
1199
|
+
throw new AuthSwitchRefusal('claude_handoff_pending', 'Resolve the Claude native handoff before removing this account.');
|
|
1179
1200
|
}
|
|
1180
1201
|
});
|
|
1181
1202
|
await scan(async (grant, stored) => {
|
|
@@ -1514,7 +1535,15 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1514
1535
|
return { handoffs, nextCursor: stored.length > limit ? handoffs.at(-1)!.id : null };
|
|
1515
1536
|
}
|
|
1516
1537
|
|
|
1517
|
-
/**
|
|
1538
|
+
/**
|
|
1539
|
+
* Only a verified backend import receipt may adopt a native home. No startup autodiscovery exists.
|
|
1540
|
+
*
|
|
1541
|
+
* `import.submit` is the one route that reaches this, so its two decided outcomes -- a home another grant
|
|
1542
|
+
* holds, and a login another home already holds -- answer with the importer's published code: the
|
|
1543
|
+
* transaction refused, the ledger row stays `verified` and nothing rotating was sent, while an unmarked
|
|
1544
|
+
* failure of that route means the outcome is unknown and the source must be read with `import.status`
|
|
1545
|
+
* instead. The ambiguous write below keeps that unmarked meaning, because it genuinely is unknown.
|
|
1546
|
+
*/
|
|
1518
1547
|
public async registerClaudeHomeFromReceipt(input: { homeId: string; accountId: string; grantId: string;
|
|
1519
1548
|
receiptId: string; sourceDigest: string; accountUuid: string; organizationUuid: string;
|
|
1520
1549
|
updateId: string; now: string }): Promise<IStoredAuthorityClaudeHome> {
|
|
@@ -1542,7 +1571,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1542
1571
|
}
|
|
1543
1572
|
if (current) {
|
|
1544
1573
|
if (current.activeAccountId !== accountId || current.activeGrantId !== grantId) {
|
|
1545
|
-
throw new
|
|
1574
|
+
throw new AuthSwitchRefusal('import_refusal', 'Claude native home belongs to another grant.');
|
|
1546
1575
|
}
|
|
1547
1576
|
return AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(current);
|
|
1548
1577
|
}
|
|
@@ -1559,6 +1588,11 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1559
1588
|
|| !['ambiguous_write', 'unique_conflict'].includes(error.code)) throw error;
|
|
1560
1589
|
const home = await this.readClaudeHome(homeId);
|
|
1561
1590
|
if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId) return home;
|
|
1591
|
+
// One grant, one home: the unique index refused a second home for a login another one already holds.
|
|
1592
|
+
if (error.code === 'unique_conflict') {
|
|
1593
|
+
throw new AuthSwitchRefusal('import_refusal',
|
|
1594
|
+
'This Claude login is already registered to another native home.');
|
|
1595
|
+
}
|
|
1562
1596
|
throw new Error('Claude native home registration outcome is unknown.');
|
|
1563
1597
|
}
|
|
1564
1598
|
}
|