@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.
Files changed (61) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +56 -0
  3. package/dist_ts/authority-contract.js +54 -2
  4. package/dist_ts/authority-import-contract.d.ts +211 -0
  5. package/dist_ts/authority-import-contract.js +17 -0
  6. package/dist_ts/classes.authoritybroker.js +3 -2
  7. package/dist_ts/classes.authoritycli.d.ts +4 -0
  8. package/dist_ts/classes.authoritycli.js +148 -11
  9. package/dist_ts/classes.authorityclient.d.ts +24 -11
  10. package/dist_ts/classes.authorityclient.js +40 -22
  11. package/dist_ts/classes.authoritydaemon.d.ts +12 -0
  12. package/dist_ts/classes.authoritydaemon.js +123 -36
  13. package/dist_ts/classes.authoritydatabase.d.ts +14 -1
  14. package/dist_ts/classes.authoritydatabase.js +48 -16
  15. package/dist_ts/classes.authorityimport.d.ts +176 -0
  16. package/dist_ts/classes.authorityimport.js +664 -0
  17. package/dist_ts/classes.authoritymodels.d.ts +16 -1
  18. package/dist_ts/classes.authoritymodels.js +31 -3
  19. package/dist_ts/classes.authorityservice.d.ts +10 -0
  20. package/dist_ts/classes.authorityservice.js +24 -1
  21. package/dist_ts/classes.claudeauthority.js +19 -11
  22. package/dist_ts/classes.claudenative.d.ts +63 -3
  23. package/dist_ts/classes.claudenative.js +68 -8
  24. package/dist_ts/index.d.ts +1 -0
  25. package/dist_ts/index.js +6 -3
  26. package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
  27. package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
  28. package/dist_ts/ts_migration/index.js +3 -1
  29. package/dist_ts/ts_migration/legacysources/authswitchstores.d.ts +2 -0
  30. package/dist_ts/ts_migration/legacysources/authswitchstores.js +244 -0
  31. package/dist_ts/ts_migration/legacysources/index.d.ts +18 -0
  32. package/dist_ts/ts_migration/legacysources/index.js +24 -0
  33. package/dist_ts/ts_migration/legacysources/material.d.ts +37 -0
  34. package/dist_ts/ts_migration/legacysources/material.js +120 -0
  35. package/dist_ts/ts_migration/legacysources/nativestores.d.ts +2 -0
  36. package/dist_ts/ts_migration/legacysources/nativestores.js +214 -0
  37. package/dist_ts/ts_migration/legacysources/shared.d.ts +125 -0
  38. package/dist_ts/ts_migration/legacysources/shared.js +140 -0
  39. package/package.json +9 -1
  40. package/readme.md +111 -2
  41. package/ts/00_commitinfo_data.ts +1 -1
  42. package/ts/authority-contract.ts +106 -0
  43. package/ts/authority-import-contract.ts +217 -0
  44. package/ts/classes.authoritybroker.ts +2 -1
  45. package/ts/classes.authoritycli.ts +164 -10
  46. package/ts/classes.authorityclient.ts +65 -21
  47. package/ts/classes.authoritydaemon.ts +121 -34
  48. package/ts/classes.authoritydatabase.ts +49 -15
  49. package/ts/classes.authorityimport.ts +746 -0
  50. package/ts/classes.authoritymodels.ts +32 -2
  51. package/ts/classes.authorityservice.ts +27 -0
  52. package/ts/classes.claudeauthority.ts +22 -10
  53. package/ts/classes.claudenative.ts +104 -10
  54. package/ts/index.ts +5 -2
  55. package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
  56. package/ts/ts_migration/index.ts +2 -0
  57. package/ts/ts_migration/legacysources/authswitchstores.ts +211 -0
  58. package/ts/ts_migration/legacysources/index.ts +35 -0
  59. package/ts/ts_migration/legacysources/material.ts +134 -0
  60. package/ts/ts_migration/legacysources/nativestores.ts +212 -0
  61. 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 Error('Account runtime drain is already in progress.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchUsage>(
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 Error('Authswitch authority is closing.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSwitchClaudeNative>(
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 || !this.claudeNative) throw new Error('Claude native handoff is unavailable.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoff>(
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 Error('Claude native handoff is unavailable.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoffs>(
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 Error('Claude native handoff is unavailable.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSnapshot>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchDoctor>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchEvents>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginAdd>(
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 Error('Authswitch authority is closing.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginReauth>(
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 Error('Authswitch authority is closing.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetOperation>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchListOperations>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelOperation>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchStartPreuse>(
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 Error('Authswitch authority is closing.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetPreuse>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelPreuse>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRenameAccount>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRemoveAccount>(
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 Error('Account runtime drain is already in progress.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBindAccount>(
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchResolveAccess>(
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 Error('Authswitch authority is closing.');
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(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchReleaseBinding>(
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 Error('Authswitch authority is closing.');
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 Error('A managed Codex app-server still owns this account; stop its work before changing the account.');
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 Error('A managed Codex launch has uncertain process ownership; reconcile it before changing the account.');
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 Error('A managed Codex process identity is still present; reconcile it before changing the account.');
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 Error('Selected OpenAI login is unavailable for reauthentication.');
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 && ['legacy_native', 'claude_native'].includes(grantStored.owner)
977
- && draft.grant.owner === 'daemon') {
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
- if (grantStored && ['legacy_native', 'claude_native'].includes(grantStored.owner)
1030
- && draft.grant.owner === 'daemon') {
1031
- throw new Error('Native ownership requires a verified handoff.');
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 Error('Claude refresh requires a verified import receipt.');
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 Error('Account changed; refresh before removing.');
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 Error('Stop the native owner and complete its handoff before removing this account.');
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 Error('Wait for the account refresh to finish before removing it.');
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 Error('Resolve the Claude native handoff before removing this account.');
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
- /** Only a verified backend import receipt may adopt a native home. No startup autodiscovery exists. */
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 Error('Claude native home belongs to another grant.');
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
  }