@modelprofile.com/authswitch 8.1.0 → 9.0.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 (73) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +73 -0
  3. package/dist_ts/authority-contract.js +55 -2
  4. package/dist_ts/authority-import-contract.d.ts +8 -4
  5. package/dist_ts/authority-import-contract.js +12 -13
  6. package/dist_ts/authority-paths.d.ts +37 -0
  7. package/dist_ts/authority-paths.js +46 -0
  8. package/dist_ts/authority-runtime-contract.d.ts +11 -1
  9. package/dist_ts/classes.authoritybroker.d.ts +9 -0
  10. package/dist_ts/classes.authoritybroker.js +72 -11
  11. package/dist_ts/classes.authorityclient.d.ts +17 -11
  12. package/dist_ts/classes.authorityclient.js +47 -22
  13. package/dist_ts/classes.authoritydaemon.d.ts +22 -3
  14. package/dist_ts/classes.authoritydaemon.js +106 -59
  15. package/dist_ts/classes.authoritydatabase.d.ts +9 -1
  16. package/dist_ts/classes.authoritydatabase.js +39 -13
  17. package/dist_ts/classes.authorityimport.d.ts +33 -6
  18. package/dist_ts/classes.authorityimport.js +102 -25
  19. package/dist_ts/classes.authoritymodels.d.ts +4 -1
  20. package/dist_ts/classes.authoritymodels.js +15 -3
  21. package/dist_ts/classes.authoritypreuse.js +8 -3
  22. package/dist_ts/classes.authorityservice.d.ts +20 -11
  23. package/dist_ts/classes.authorityservice.js +37 -23
  24. package/dist_ts/classes.claudeauthority.js +19 -11
  25. package/dist_ts/classes.claudenative.d.ts +63 -3
  26. package/dist_ts/classes.claudenative.js +68 -8
  27. package/dist_ts/classes.cli.d.ts +10 -2
  28. package/dist_ts/classes.cli.js +12 -4
  29. package/dist_ts/classes.codexmanaged.d.ts +8 -0
  30. package/dist_ts/classes.codexmanaged.js +19 -12
  31. package/dist_ts/classes.legacyfence.d.ts +53 -0
  32. package/dist_ts/classes.legacyfence.js +189 -0
  33. package/dist_ts/classes.operations.d.ts +15 -3
  34. package/dist_ts/classes.operations.js +22 -4
  35. package/dist_ts/classes.service.d.ts +21 -2
  36. package/dist_ts/classes.service.js +35 -8
  37. package/dist_ts/classes.tui.d.ts +2 -1
  38. package/dist_ts/classes.tui.js +3 -2
  39. package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
  40. package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
  41. package/dist_ts/ts_migration/index.js +3 -1
  42. package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
  43. package/dist_ts/ts_migration/legacysources/nativestores.js +15 -8
  44. package/dist_ts/ts_migration/legacysources/shared.d.ts +3 -2
  45. package/dist_ts/ts_migration/legacysources/shared.js +3 -5
  46. package/package.json +11 -7
  47. package/readme.md +188 -13
  48. package/ts/00_commitinfo_data.ts +1 -1
  49. package/ts/authority-contract.ts +125 -0
  50. package/ts/authority-import-contract.ts +12 -11
  51. package/ts/authority-paths.ts +69 -0
  52. package/ts/authority-runtime-contract.ts +11 -1
  53. package/ts/classes.authoritybroker.ts +70 -11
  54. package/ts/classes.authorityclient.ts +59 -21
  55. package/ts/classes.authoritydaemon.ts +116 -60
  56. package/ts/classes.authoritydatabase.ts +39 -12
  57. package/ts/classes.authorityimport.ts +131 -26
  58. package/ts/classes.authoritymodels.ts +11 -2
  59. package/ts/classes.authoritypreuse.ts +7 -1
  60. package/ts/classes.authorityservice.ts +42 -30
  61. package/ts/classes.claudeauthority.ts +22 -10
  62. package/ts/classes.claudenative.ts +104 -10
  63. package/ts/classes.cli.ts +14 -3
  64. package/ts/classes.codexmanaged.ts +18 -7
  65. package/ts/classes.legacyfence.ts +219 -0
  66. package/ts/classes.operations.ts +22 -3
  67. package/ts/classes.service.ts +45 -8
  68. package/ts/classes.tui.ts +3 -1
  69. package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
  70. package/ts/ts_migration/index.ts +2 -0
  71. package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
  72. package/ts/ts_migration/legacysources/nativestores.ts +15 -7
  73. package/ts/ts_migration/legacysources/shared.ts +4 -6
@@ -10,10 +10,10 @@ 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 { authSwitchImportRefusalReason } from './authority-import-contract.js';
13
+ import { AuthSwitchRefusal } from './authority-contract.js';
14
14
  import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
15
15
  TAuthSwitchImportSubmission } from './authority-import-contract.js';
16
- import { AuthSwitchAuthorityImport, AuthSwitchImportRefusal,
16
+ import { AuthSwitchAuthorityImport,
17
17
  type IAuthSwitchAuthorityImportOptions } from './classes.authorityimport.js';
18
18
  import { authSwitchBindingCapabilityHash, AuthSwitchAuthorityBroker,
19
19
  type IAuthSwitchAuthorityBrokerOptions } from './classes.authoritybroker.js';
@@ -37,13 +37,46 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
37
37
  claudeStatus?: ClaudeAccountStatus;
38
38
  /** Activated only after a verified backend source receipt has registered the native home. */
39
39
  claudeNative?: IClaudeNativeAuthorityOptions;
40
- /** Where the one-time import reads the legacy stores. Defaults to this user's own locations. */
41
- legacyImport?: IAuthSwitchAuthorityImportOptions;
40
+ /** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
41
+ legacyImport: TAuthSwitchAuthorityLegacyImport;
42
+ }
43
+
44
+ /**
45
+ * Where a daemon's one-time import reads, stated by whoever builds it.
46
+ *
47
+ * The legacy stores are this user's real credentials, so which host they are read from is never something a
48
+ * daemon decides for its caller: it is named, or the daemon is built with no import surface at all.
49
+ * `AuthSwitchAuthorityService`'s entry point is the one place that may name this host's own locations,
50
+ * because that is the daemon that exists to serve this host. The importer requires those two locations of
51
+ * every caller, so naming its options here is already the whole statement.
52
+ */
53
+ export type TAuthSwitchAuthorityLegacyImport = IAuthSwitchAuthorityImportOptions | 'none';
54
+
55
+ /** What a caller that named neither is told. It names the two answers and who may take the third. */
56
+ const legacyImportRequired = 'Authswitch authority requires explicit legacy source locations: pass '
57
+ + 'legacyImport with an env and an absolute homeDirectory, or legacyImport: "none" for a daemon with no '
58
+ + 'account import. Only the authswitch service entry point may take them from this host.';
59
+
60
+ /** What the import routes answer on a daemon that was built with no import surface. */
61
+ const legacyImportAbsent = 'This authority was started without legacy source locations, so it serves no '
62
+ + 'account import.';
63
+
64
+ /** The same check for a caller with types and one without: stated locations, or stated absence. */
65
+ function assertStatedLegacyImport(value: unknown): asserts value is TAuthSwitchAuthorityLegacyImport {
66
+ if (value === 'none') return;
67
+ if (value === null || typeof value !== 'object'
68
+ || typeof (value as { env?: unknown }).env !== 'object' || (value as { env?: unknown }).env === null
69
+ || typeof (value as { homeDirectory?: unknown }).homeDirectory !== 'string'
70
+ || !plugins.path.isAbsolute((value as { homeDirectory: string }).homeDirectory)) {
71
+ throw new Error(legacyImportRequired);
72
+ }
42
73
  }
43
74
 
44
75
  const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
45
76
  value !== null && typeof value === 'object' && !Array.isArray(value)
46
77
  && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
78
+ // A malformed request is a caller's own bug, not an instruction for an owner, so it stays what it was:
79
+ // its own text, no refusal marker. A client must not present it as something the owner can act on.
47
80
  const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError('Invalid authswitch authority request.'); };
48
81
  const validBindingId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
49
82
  const validBindingCapability = (value: unknown): value is string => typeof value === 'string'
@@ -81,7 +114,8 @@ export class AuthSwitchAuthorityDaemon {
81
114
  public readonly broker: AuthSwitchAuthorityBroker;
82
115
  public readonly usage: AuthSwitchAuthorityUsage;
83
116
  public readonly preuse: AuthSwitchAuthorityPreuse;
84
- public readonly legacyImport: AuthSwitchAuthorityImport;
117
+ /** Absent on a daemon built with `legacyImport: 'none'`; its import routes then refuse. */
118
+ public readonly legacyImport?: AuthSwitchAuthorityImport;
85
119
  public readonly claudeNative?: ClaudeNativeAuthority;
86
120
  private readonly managementRouter = new plugins.typedrequest.TypedRouter();
87
121
  private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
@@ -104,6 +138,10 @@ export class AuthSwitchAuthorityDaemon {
104
138
  private closing = false;
105
139
 
106
140
  constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
141
+ // First, before anything is constructed: a daemon that was going to read the wrong host's credentials
142
+ // must not come into existence at all. The type states it and this states it again, because a caller
143
+ // without types is exactly the caller that would have defaulted here.
144
+ assertStatedLegacyImport(options.legacyImport);
107
145
  this.database = new AuthSwitchAuthorityDatabase(options);
108
146
  const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
109
147
  this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
@@ -132,7 +170,9 @@ export class AuthSwitchAuthorityDaemon {
132
170
  provider,
133
171
  });
134
172
  this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
135
- this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
173
+ if (options.legacyImport !== 'none') {
174
+ this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
175
+ }
136
176
  if (options.claudeNative) {
137
177
  this.claudeNative = new ClaudeNativeAuthority(this.database, {
138
178
  ...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
@@ -202,7 +242,7 @@ export class AuthSwitchAuthorityDaemon {
202
242
  const prepare = async (): Promise<void> => {
203
243
  if (this.closing || this.drainingAccounts.has(request.accountId)
204
244
  || this.preuseAccounts.has(request.accountId)) {
205
- throw new Error('Account runtime drain is already in progress.');
245
+ throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
206
246
  }
207
247
  this.drainingAccounts.add(request.accountId);
208
248
  this.deviceDrainOwners.set(request.accountId, request.operationId);
@@ -229,46 +269,67 @@ export class AuthSwitchAuthorityDaemon {
229
269
  });
230
270
  }
231
271
 
272
+ /**
273
+ * One handler, with the only place a refusal becomes a marked answer.
274
+ *
275
+ * Both routers register through here, so a handler states its refusal by throwing `AuthSwitchRefusal`
276
+ * and never touches the wire shape; anything else it throws stays the transport's sanitised fault,
277
+ * which is what keeps "the outcome is unknown" distinguishable from "here is what to do instead".
278
+ */
279
+ private handler<T extends ITypedRequest>(method: T['method'],
280
+ run: plugins.typedrequest.THandlerFunction<T>): plugins.typedrequest.TypedHandler<T> {
281
+ return new plugins.typedrequest.TypedHandler<T>(method, async (request, tools) => {
282
+ try { return await run(request, tools); }
283
+ catch (error) {
284
+ if (error instanceof AuthSwitchRefusal) {
285
+ throw new plugins.typedrequest.TypedResponseError(error.message, error.data);
286
+ }
287
+ throw error;
288
+ }
289
+ });
290
+ }
291
+
232
292
  private register(): void {
233
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchUsage>(
293
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchUsage>(
234
294
  'authswitch.authority.usage', async request => {
235
295
  if (request === null || typeof request !== 'object' || Array.isArray(request)
236
296
  || !Object.hasOwn(request, 'accountId') || !Object.hasOwn(request, 'loginId')
237
297
  || Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
238
298
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
239
299
  || (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
240
- if (this.closing) throw new Error('Authswitch authority is closing.');
300
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
241
301
  return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
242
302
  }));
243
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSwitchClaudeNative>(
303
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSwitchClaudeNative>(
244
304
  'authswitch.authority.claude.switch', async request => {
245
305
  if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
246
306
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
247
307
  || request.purpose !== 'claude_host_native') invalid();
248
- if (this.closing || !this.claudeNative) throw new Error('Claude native handoff is unavailable.');
308
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
309
+ if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
249
310
  return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
250
311
  }));
251
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoff>(
312
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoff>(
252
313
  'authswitch.authority.claude.handoff', async request => {
253
314
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
254
- if (!this.claudeNative) throw new Error('Claude native handoff is unavailable.');
315
+ if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
255
316
  return { handoff: await this.claudeNative.getHandoff(request.operationId) };
256
317
  }));
257
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoffs>(
318
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoffs>(
258
319
  'authswitch.authority.claude.handoffs', async request => {
259
320
  if (request === null || typeof request !== 'object' || Array.isArray(request)
260
321
  || Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
261
- if (!this.claudeNative) throw new Error('Claude native handoff is unavailable.');
322
+ if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
262
323
  return this.claudeNative.listHandoffs(request.after ?? null, request.limit ?? 128);
263
324
  }));
264
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSnapshot>(
325
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSnapshot>(
265
326
  'authswitch.authority.snapshot', async request => {
266
327
  if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
267
328
  const keys = Object.keys(request);
268
329
  if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
269
330
  return { snapshot: await this.broker.snapshot(request) };
270
331
  }));
271
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchDoctor>(
332
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchDoctor>(
272
333
  'authswitch.authority.doctor', async request => {
273
334
  if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
274
335
  const cursorKeys = ['accountAfter', 'loginAfter', 'codexHomeAfter', 'claudeHomeAfter',
@@ -279,63 +340,65 @@ export class AuthSwitchAuthorityDaemon {
279
340
  || request.limit < 1 || request.limit > 128))) invalid();
280
341
  return { page: await this.doctorPage(request) };
281
342
  }));
282
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportInventory>(
343
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportInventory>(
283
344
  'authswitch.authority.import.inventory', async request => {
284
345
  // A read with no parameters: the inventory is the whole host, and a caller cannot narrow it into
285
346
  // believing a source does not exist.
286
347
  if (request === null || typeof request !== 'object' || Array.isArray(request)
287
348
  || Object.keys(request).length !== 0) invalid();
288
- if (this.closing) throw new Error('Authswitch authority is shutting down.');
349
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
350
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
289
351
  return { inventory: await this.legacyImport.inventory() };
290
352
  }));
291
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportStatus>(
353
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportStatus>(
292
354
  'authswitch.authority.import.status', async request => {
293
355
  if (request === null || typeof request !== 'object' || Array.isArray(request)
294
356
  || Object.keys(request).some(key => !['after', 'limit'].includes(key))
295
357
  || (request.after !== undefined && !validHash(request.after))
296
358
  || (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
297
359
  || request.limit < 1 || request.limit > 128))) invalid();
298
- if (this.closing) throw new Error('Authswitch authority is shutting down.');
360
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
361
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
299
362
  return { status: await this.legacyImport.status(request) };
300
363
  }));
301
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchEvents>(
364
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchEvents>(
302
365
  'authswitch.authority.events', async (request, tools) => {
303
366
  if (!hasKeys(request, ['epoch', 'afterRevision', 'waitMs'])) invalid();
304
367
  return this.broker.events(request.epoch, request.afterRevision, request.waitMs, tools?.abortSignal);
305
368
  }));
306
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginAdd>(
369
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginAdd>(
307
370
  'authswitch.authority.add', async request => {
308
371
  if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
309
372
  || request.providerId !== 'openai' || request.flow !== 'device') invalid();
310
- if (this.closing) throw new Error('Authswitch authority is closing.');
373
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
311
374
  return { operation: await this.broker.beginAddOpenAi(request.operationId) };
312
375
  }));
313
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginReauth>(
376
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
314
377
  'authswitch.authority.reauth', async request => {
315
378
  if (!hasKeys(request, ['operationId', 'accountId', 'loginId', 'purpose', 'flow']) || request.flow !== 'device'
316
379
  || typeof request.operationId !== 'string'
317
380
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
318
381
  || request.purpose !== 'openai_managed') invalid();
319
- if (this.closing) throw new Error('Authswitch authority is closing.');
382
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
320
383
  return { operation: await this.beginReauthOpenAi(request) };
321
384
  }));
322
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetOperation>(
385
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetOperation>(
323
386
  'authswitch.authority.operation', async request => {
324
387
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
325
388
  return { operation: await this.broker.getOperation(request.operationId) };
326
389
  }));
327
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchListOperations>(
390
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchListOperations>(
328
391
  'authswitch.authority.operations', async request => {
329
392
  if (request === null || typeof request !== 'object' || Array.isArray(request)
330
393
  || Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
331
394
  return this.broker.listOperations(request.after ?? null, request.limit ?? 128);
332
395
  }));
333
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelOperation>(
396
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelOperation>(
334
397
  'authswitch.authority.cancel', async request => {
335
398
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
336
399
  return { operation: await this.broker.cancelOperation(request.operationId) };
337
400
  }));
338
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchStartPreuse>(
401
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchStartPreuse>(
339
402
  'authswitch.authority.preuse.start', async request => {
340
403
  if (request === null || typeof request !== 'object' || Array.isArray(request)
341
404
  || !['operationId', 'accountId', 'loginId', 'purpose', 'prompt'].every(key => Object.hasOwn(request, key))
@@ -344,7 +407,7 @@ export class AuthSwitchAuthorityDaemon {
344
407
  || typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
345
408
  || typeof request.prompt !== 'string'
346
409
  || (request.model !== undefined && typeof request.model !== 'string')) invalid();
347
- if (this.closing) throw new Error('Authswitch authority is closing.');
410
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
348
411
  const reservation: IPreuseAccountReservation = { operationId: request.operationId };
349
412
  let ownsReservation = false;
350
413
  const release = (): void => {
@@ -371,28 +434,28 @@ export class AuthSwitchAuthorityDaemon {
371
434
  throw error;
372
435
  }
373
436
  }));
374
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetPreuse>(
437
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetPreuse>(
375
438
  'authswitch.authority.preuse.operation', async request => {
376
439
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
377
440
  return { operation: await this.preuse.get(request.operationId) };
378
441
  }));
379
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelPreuse>(
442
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelPreuse>(
380
443
  'authswitch.authority.preuse.cancel', async request => {
381
444
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
382
445
  return { operation: await this.preuse.cancel(request.operationId) };
383
446
  }));
384
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRenameAccount>(
447
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRenameAccount>(
385
448
  'authswitch.authority.rename', async request => {
386
449
  if (!hasKeys(request, ['accountId', 'expectedRevision', 'label']) || typeof request.accountId !== 'string'
387
450
  || typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
388
451
  return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
389
452
  }));
390
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRemoveAccount>(
453
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
391
454
  'authswitch.authority.remove', async request => {
392
455
  if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
393
456
  || typeof request.expectedRevision !== 'number') invalid();
394
457
  if (this.drainingAccounts.has(request.accountId) || this.preuseAccounts.has(request.accountId)) {
395
- throw new Error('Account runtime drain is already in progress.');
458
+ throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
396
459
  }
397
460
  this.drainingAccounts.add(request.accountId);
398
461
  try {
@@ -400,7 +463,7 @@ export class AuthSwitchAuthorityDaemon {
400
463
  return { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
401
464
  } finally { this.drainingAccounts.delete(request.accountId); }
402
465
  }));
403
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBindAccount>(
466
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBindAccount>(
404
467
  'authswitch.authority.bind', async request => {
405
468
  if (!hasKeys(request, ['accountId', 'loginId', 'purpose', 'runtime', 'scopeId', 'incarnationId'])
406
469
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
@@ -408,7 +471,7 @@ export class AuthSwitchAuthorityDaemon {
408
471
  || typeof request.incarnationId !== 'string') invalid();
409
472
  return this.broker.bindAccount(request);
410
473
  }));
411
- this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchResolveAccess>(
474
+ this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchResolveAccess>(
412
475
  'authswitch.authority.resolveAccess', async request => {
413
476
  if ((typeof request !== 'object' || request === null || Array.isArray(request))
414
477
  || !['bindingId', 'capability', 'minValidityMs'].every(key => Object.hasOwn(request, key))
@@ -417,41 +480,34 @@ export class AuthSwitchAuthorityDaemon {
417
480
  || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
418
481
  || typeof request.minValidityMs !== 'number'
419
482
  || (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
420
- if (this.closing) throw new Error('Authswitch authority is closing.');
483
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
421
484
  const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId, request.capability));
422
485
  try {
423
486
  return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
424
487
  request.rejectedGrantGeneration);
425
488
  } finally { finish(); }
426
489
  }));
427
- this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchReleaseBinding>(
490
+ this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchReleaseBinding>(
428
491
  'authswitch.authority.release', async request => {
429
492
  if (!hasKeys(request, ['bindingId', 'capability'])
430
493
  || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
431
- if (this.closing) throw new Error('Authswitch authority is closing.');
494
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
432
495
  return this.releaseRuntimeBinding(request.bindingId, request.capability);
433
496
  }));
434
- this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportSubmit>(
497
+ this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
435
498
  'authswitch.authority.import.submit', async request => {
436
499
  // Backend-only: an external submission carries the material of a store this package cannot read.
437
500
  if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
438
501
  || request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
439
502
  || !validImportSubmission(request.submission)) invalid();
440
- if (this.closing) throw new Error('Authswitch authority is closing.');
441
- try {
442
- return { result: await this.legacyImport.submit({ submission: request.submission,
443
- callerQuiescent: true, acknowledgeRunOrder: true }) };
444
- } catch (error) {
445
- // A refusal is an answer, not a fault: it names what the owner does instead, and only a
446
- // TypedResponseError survives the wire with its text. The marker is what lets the caller tell
447
- // that instruction apart from the transport's sanitised text for an unexpected failure, whose
448
- // import outcome is unknown. Everything else stays sanitised and unmarked.
449
- if (error instanceof AuthSwitchImportRefusal) {
450
- throw new plugins.typedrequest.TypedResponseError(error.message,
451
- { reason: authSwitchImportRefusalReason });
452
- }
453
- throw error;
454
- }
503
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
504
+ if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
505
+ // An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
506
+ // `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
507
+ // leaves the import outcome unknown, and the source must be read with `import.status` rather
508
+ // than submitted again.
509
+ return { result: await this.legacyImport.submit({ submission: request.submission,
510
+ callerQuiescent: true, acknowledgeRunOrder: true }) };
455
511
  }));
456
512
  }
457
513
 
@@ -593,13 +649,13 @@ export class AuthSwitchAuthorityDaemon {
593
649
  const run = home.activeRun;
594
650
  if (!run) continue;
595
651
  const survivor = await findManagedCodexProcess(run.socketPath);
596
- if (survivor) throw new Error('A managed Codex app-server still owns this account; stop its work before changing the account.');
652
+ if (survivor) throw new AuthSwitchRefusal('account_busy', 'A managed Codex app-server still owns this account; stop its work before changing the account.');
597
653
  if (run.pid === null || run.startedAt === null) {
598
- throw new Error('A managed Codex launch has uncertain process ownership; reconcile it before changing the account.');
654
+ throw new AuthSwitchRefusal('account_busy', 'A managed Codex launch has uncertain process ownership; reconcile it before changing the account.');
599
655
  }
600
656
  try {
601
657
  process.kill(run.pid, 0);
602
- throw new Error('A managed Codex process identity is still present; reconcile it before changing the account.');
658
+ throw new AuthSwitchRefusal('account_busy', 'A managed Codex process identity is still present; reconcile it before changing the account.');
603
659
  } catch (error) {
604
660
  if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')) throw error;
605
661
  }
@@ -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,
@@ -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
  }
@@ -1038,9 +1039,9 @@ export class AuthSwitchAuthorityDatabase {
1038
1039
  if (accountStored && accountStored.primaryGrantId !== grantId) {
1039
1040
  throw new Error('Primary grant identity cannot be replaced.');
1040
1041
  }
1041
- if (grantStored && ['legacy_native', 'claude_native'].includes(grantStored.owner)
1042
- && draft.grant.owner === 'daemon') {
1043
- 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));
1044
1045
  }
1045
1046
  const completed: IStoredAuthorityDeviceOperation = {
1046
1047
  ...operation, state: 'complete', prompt: null,
@@ -1093,10 +1094,14 @@ export class AuthSwitchAuthorityDatabase {
1093
1094
  if (draft.id !== grantId || draft.accountId !== account.id || draft.updateId !== updateId) {
1094
1095
  throw new Error('Grant change has an invalid identity.');
1095
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
+ }
1096
1101
  if (grant.purpose === 'claude_host_native' && draft.state === 'exchange_may_have_been_sent'
1097
1102
  && grant.state !== 'exchange_may_have_been_sent'
1098
1103
  && !(await this.hasVerifiedClaudeGrantReceipt(account.id, grant.id, session))) {
1099
- throw new Error('Claude refresh requires a verified import receipt.');
1104
+ throw new AuthSwitchRefusal('claude_receipt_missing', 'Claude refresh requires a verified import receipt.');
1100
1105
  }
1101
1106
  const accountDraft: IStoredAuthorityAccount = { ...account, revision: account.revision + 1,
1102
1107
  statusObservedAt: draft.statusObservedAt, updateId };
@@ -1166,7 +1171,7 @@ export class AuthSwitchAuthorityDatabase {
1166
1171
  const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
1167
1172
  const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
1168
1173
  if (!metaStored || !accountStored || accountStored.removed || accountStored.revision !== expectedRevision) {
1169
- throw new Error('Account changed; refresh before removing.');
1174
+ throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before removing.');
1170
1175
  }
1171
1176
  const account = AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored);
1172
1177
  const scan = async (visit: (grant: IStoredAuthorityGrant,
@@ -1185,13 +1190,13 @@ export class AuthSwitchAuthorityDatabase {
1185
1190
  await scan(async grant => {
1186
1191
  if (grant.state === 'removed') return;
1187
1192
  if (grant.owner === 'claude_native' || grant.owner === 'legacy_native') {
1188
- 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.');
1189
1194
  }
1190
1195
  if (grant.state === 'exchange_may_have_been_sent') {
1191
- 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.');
1192
1197
  }
1193
1198
  if (['handoff_pending', 'handoff_quarantined'].includes(grant.state)) {
1194
- 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.');
1195
1200
  }
1196
1201
  });
1197
1202
  await scan(async (grant, stored) => {
@@ -1530,7 +1535,15 @@ export class AuthSwitchAuthorityDatabase {
1530
1535
  return { handoffs, nextCursor: stored.length > limit ? handoffs.at(-1)!.id : null };
1531
1536
  }
1532
1537
 
1533
- /** 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
+ */
1534
1547
  public async registerClaudeHomeFromReceipt(input: { homeId: string; accountId: string; grantId: string;
1535
1548
  receiptId: string; sourceDigest: string; accountUuid: string; organizationUuid: string;
1536
1549
  updateId: string; now: string }): Promise<IStoredAuthorityClaudeHome> {
@@ -1558,7 +1571,7 @@ export class AuthSwitchAuthorityDatabase {
1558
1571
  }
1559
1572
  if (current) {
1560
1573
  if (current.activeAccountId !== accountId || current.activeGrantId !== grantId) {
1561
- throw new Error('Claude native home belongs to another grant.');
1574
+ throw new AuthSwitchRefusal('import_refusal', 'Claude native home belongs to another grant.');
1562
1575
  }
1563
1576
  return AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(current);
1564
1577
  }
@@ -1575,6 +1588,11 @@ export class AuthSwitchAuthorityDatabase {
1575
1588
  || !['ambiguous_write', 'unique_conflict'].includes(error.code)) throw error;
1576
1589
  const home = await this.readClaudeHome(homeId);
1577
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
+ }
1578
1596
  throw new Error('Claude native home registration outcome is unknown.');
1579
1597
  }
1580
1598
  }
@@ -1797,9 +1815,18 @@ export class AuthSwitchAuthorityDatabase {
1797
1815
  throw new Error('Migration completion requires a verified import of the same account and grant.');
1798
1816
  }
1799
1817
  if (current) {
1818
+ // A row may follow its source's current bytes in exactly one state. `pending_native_owner` is a
1819
+ // projection whose own tool still refreshes it: nothing rotating was ever sent for such a row and
1820
+ // the authority holds no copy of its credential, so the file that tool has since rewritten is the
1821
+ // same login and the row must be able to reach it -- otherwise the retry its own refusal asks for
1822
+ // is impossible. It may never change the account or grant it named while doing so, and no other
1823
+ // status may move its digest at all.
1824
+ const followsSource = current.sourceDigest !== draft.sourceDigest;
1800
1825
  if (current.status === 'complete' || current.version !== draft.version
1801
1826
  || current.sourceKind !== draft.sourceKind || current.sourcePathHash !== draft.sourcePathHash
1802
- || current.sourceDigest !== draft.sourceDigest || draft.revision !== current.revision + 1) {
1827
+ || (followsSource && (current.status !== 'pending_native_owner' || current.accountId === null
1828
+ || current.accountId !== draft.accountId || current.grantId !== draft.grantId))
1829
+ || draft.revision !== current.revision + 1) {
1803
1830
  throw new Error('Migration source identity or terminal status cannot change.');
1804
1831
  }
1805
1832
  const result = await AuthSwitchAuthorityMigrationLedgerModel.exact.transition({