@modelprofile.com/authswitch 8.1.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 (45) 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 +8 -4
  5. package/dist_ts/authority-import-contract.js +12 -13
  6. package/dist_ts/classes.authoritybroker.js +3 -2
  7. package/dist_ts/classes.authorityclient.d.ts +17 -11
  8. package/dist_ts/classes.authorityclient.js +28 -22
  9. package/dist_ts/classes.authoritydaemon.d.ts +8 -0
  10. package/dist_ts/classes.authoritydaemon.js +74 -58
  11. package/dist_ts/classes.authoritydatabase.d.ts +9 -1
  12. package/dist_ts/classes.authoritydatabase.js +29 -12
  13. package/dist_ts/classes.authorityimport.d.ts +17 -2
  14. package/dist_ts/classes.authorityimport.js +29 -3
  15. package/dist_ts/classes.authoritymodels.d.ts +4 -1
  16. package/dist_ts/classes.authoritymodels.js +15 -3
  17. package/dist_ts/classes.authorityservice.d.ts +10 -0
  18. package/dist_ts/classes.authorityservice.js +24 -1
  19. package/dist_ts/classes.claudeauthority.js +19 -11
  20. package/dist_ts/classes.claudenative.d.ts +63 -3
  21. package/dist_ts/classes.claudenative.js +68 -8
  22. package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
  23. package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
  24. package/dist_ts/ts_migration/index.js +3 -1
  25. package/dist_ts/ts_migration/legacysources/nativestores.js +15 -8
  26. package/dist_ts/ts_migration/legacysources/shared.d.ts +3 -2
  27. package/dist_ts/ts_migration/legacysources/shared.js +3 -5
  28. package/package.json +5 -1
  29. package/readme.md +65 -2
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/authority-contract.ts +106 -0
  32. package/ts/authority-import-contract.ts +12 -11
  33. package/ts/classes.authoritybroker.ts +2 -1
  34. package/ts/classes.authorityclient.ts +43 -21
  35. package/ts/classes.authoritydaemon.ts +71 -56
  36. package/ts/classes.authoritydatabase.ts +29 -11
  37. package/ts/classes.authorityimport.ts +30 -2
  38. package/ts/classes.authoritymodels.ts +11 -2
  39. package/ts/classes.authorityservice.ts +27 -0
  40. package/ts/classes.claudeauthority.ts +22 -10
  41. package/ts/classes.claudenative.ts +104 -10
  42. package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
  43. package/ts/ts_migration/index.ts +2 -0
  44. package/ts/ts_migration/legacysources/nativestores.ts +15 -7
  45. 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';
@@ -44,6 +44,8 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
44
44
  const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
45
45
  value !== null && typeof value === 'object' && !Array.isArray(value)
46
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.
47
49
  const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError('Invalid authswitch authority request.'); };
48
50
  const validBindingId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
49
51
  const validBindingCapability = (value: unknown): value is string => typeof value === 'string'
@@ -202,7 +204,7 @@ export class AuthSwitchAuthorityDaemon {
202
204
  const prepare = async (): Promise<void> => {
203
205
  if (this.closing || this.drainingAccounts.has(request.accountId)
204
206
  || this.preuseAccounts.has(request.accountId)) {
205
- throw new Error('Account runtime drain is already in progress.');
207
+ throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
206
208
  }
207
209
  this.drainingAccounts.add(request.accountId);
208
210
  this.deviceDrainOwners.set(request.accountId, request.operationId);
@@ -229,46 +231,67 @@ export class AuthSwitchAuthorityDaemon {
229
231
  });
230
232
  }
231
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
+
232
254
  private register(): void {
233
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchUsage>(
255
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchUsage>(
234
256
  'authswitch.authority.usage', async request => {
235
257
  if (request === null || typeof request !== 'object' || Array.isArray(request)
236
258
  || !Object.hasOwn(request, 'accountId') || !Object.hasOwn(request, 'loginId')
237
259
  || Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
238
260
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
239
261
  || (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
240
- if (this.closing) throw new Error('Authswitch authority is closing.');
262
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
241
263
  return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
242
264
  }));
243
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSwitchClaudeNative>(
265
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSwitchClaudeNative>(
244
266
  'authswitch.authority.claude.switch', async request => {
245
267
  if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
246
268
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
247
269
  || request.purpose !== 'claude_host_native') invalid();
248
- 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.');
249
272
  return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
250
273
  }));
251
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoff>(
274
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoff>(
252
275
  'authswitch.authority.claude.handoff', async request => {
253
276
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
254
- 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.');
255
278
  return { handoff: await this.claudeNative.getHandoff(request.operationId) };
256
279
  }));
257
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoffs>(
280
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoffs>(
258
281
  'authswitch.authority.claude.handoffs', async request => {
259
282
  if (request === null || typeof request !== 'object' || Array.isArray(request)
260
283
  || Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
261
- 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.');
262
285
  return this.claudeNative.listHandoffs(request.after ?? null, request.limit ?? 128);
263
286
  }));
264
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSnapshot>(
287
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSnapshot>(
265
288
  'authswitch.authority.snapshot', async request => {
266
289
  if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
267
290
  const keys = Object.keys(request);
268
291
  if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
269
292
  return { snapshot: await this.broker.snapshot(request) };
270
293
  }));
271
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchDoctor>(
294
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchDoctor>(
272
295
  'authswitch.authority.doctor', async request => {
273
296
  if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
274
297
  const cursorKeys = ['accountAfter', 'loginAfter', 'codexHomeAfter', 'claudeHomeAfter',
@@ -279,63 +302,63 @@ export class AuthSwitchAuthorityDaemon {
279
302
  || request.limit < 1 || request.limit > 128))) invalid();
280
303
  return { page: await this.doctorPage(request) };
281
304
  }));
282
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportInventory>(
305
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportInventory>(
283
306
  'authswitch.authority.import.inventory', async request => {
284
307
  // A read with no parameters: the inventory is the whole host, and a caller cannot narrow it into
285
308
  // believing a source does not exist.
286
309
  if (request === null || typeof request !== 'object' || Array.isArray(request)
287
310
  || Object.keys(request).length !== 0) invalid();
288
- if (this.closing) throw new Error('Authswitch authority is shutting down.');
311
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
289
312
  return { inventory: await this.legacyImport.inventory() };
290
313
  }));
291
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportStatus>(
314
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportStatus>(
292
315
  'authswitch.authority.import.status', async request => {
293
316
  if (request === null || typeof request !== 'object' || Array.isArray(request)
294
317
  || Object.keys(request).some(key => !['after', 'limit'].includes(key))
295
318
  || (request.after !== undefined && !validHash(request.after))
296
319
  || (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
297
320
  || request.limit < 1 || request.limit > 128))) invalid();
298
- if (this.closing) throw new Error('Authswitch authority is shutting down.');
321
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
299
322
  return { status: await this.legacyImport.status(request) };
300
323
  }));
301
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchEvents>(
324
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchEvents>(
302
325
  'authswitch.authority.events', async (request, tools) => {
303
326
  if (!hasKeys(request, ['epoch', 'afterRevision', 'waitMs'])) invalid();
304
327
  return this.broker.events(request.epoch, request.afterRevision, request.waitMs, tools?.abortSignal);
305
328
  }));
306
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginAdd>(
329
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginAdd>(
307
330
  'authswitch.authority.add', async request => {
308
331
  if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
309
332
  || request.providerId !== 'openai' || request.flow !== 'device') invalid();
310
- if (this.closing) throw new Error('Authswitch authority is closing.');
333
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
311
334
  return { operation: await this.broker.beginAddOpenAi(request.operationId) };
312
335
  }));
313
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginReauth>(
336
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
314
337
  'authswitch.authority.reauth', async request => {
315
338
  if (!hasKeys(request, ['operationId', 'accountId', 'loginId', 'purpose', 'flow']) || request.flow !== 'device'
316
339
  || typeof request.operationId !== 'string'
317
340
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
318
341
  || request.purpose !== 'openai_managed') invalid();
319
- if (this.closing) throw new Error('Authswitch authority is closing.');
342
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
320
343
  return { operation: await this.beginReauthOpenAi(request) };
321
344
  }));
322
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetOperation>(
345
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetOperation>(
323
346
  'authswitch.authority.operation', async request => {
324
347
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
325
348
  return { operation: await this.broker.getOperation(request.operationId) };
326
349
  }));
327
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchListOperations>(
350
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchListOperations>(
328
351
  'authswitch.authority.operations', async request => {
329
352
  if (request === null || typeof request !== 'object' || Array.isArray(request)
330
353
  || Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
331
354
  return this.broker.listOperations(request.after ?? null, request.limit ?? 128);
332
355
  }));
333
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelOperation>(
356
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelOperation>(
334
357
  'authswitch.authority.cancel', async request => {
335
358
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
336
359
  return { operation: await this.broker.cancelOperation(request.operationId) };
337
360
  }));
338
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchStartPreuse>(
361
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchStartPreuse>(
339
362
  'authswitch.authority.preuse.start', async request => {
340
363
  if (request === null || typeof request !== 'object' || Array.isArray(request)
341
364
  || !['operationId', 'accountId', 'loginId', 'purpose', 'prompt'].every(key => Object.hasOwn(request, key))
@@ -344,7 +367,7 @@ export class AuthSwitchAuthorityDaemon {
344
367
  || typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
345
368
  || typeof request.prompt !== 'string'
346
369
  || (request.model !== undefined && typeof request.model !== 'string')) invalid();
347
- if (this.closing) throw new Error('Authswitch authority is closing.');
370
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
348
371
  const reservation: IPreuseAccountReservation = { operationId: request.operationId };
349
372
  let ownsReservation = false;
350
373
  const release = (): void => {
@@ -371,28 +394,28 @@ export class AuthSwitchAuthorityDaemon {
371
394
  throw error;
372
395
  }
373
396
  }));
374
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetPreuse>(
397
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetPreuse>(
375
398
  'authswitch.authority.preuse.operation', async request => {
376
399
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
377
400
  return { operation: await this.preuse.get(request.operationId) };
378
401
  }));
379
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelPreuse>(
402
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelPreuse>(
380
403
  'authswitch.authority.preuse.cancel', async request => {
381
404
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
382
405
  return { operation: await this.preuse.cancel(request.operationId) };
383
406
  }));
384
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRenameAccount>(
407
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRenameAccount>(
385
408
  'authswitch.authority.rename', async request => {
386
409
  if (!hasKeys(request, ['accountId', 'expectedRevision', 'label']) || typeof request.accountId !== 'string'
387
410
  || typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
388
411
  return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
389
412
  }));
390
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRemoveAccount>(
413
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
391
414
  'authswitch.authority.remove', async request => {
392
415
  if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
393
416
  || typeof request.expectedRevision !== 'number') invalid();
394
417
  if (this.drainingAccounts.has(request.accountId) || this.preuseAccounts.has(request.accountId)) {
395
- throw new Error('Account runtime drain is already in progress.');
418
+ throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
396
419
  }
397
420
  this.drainingAccounts.add(request.accountId);
398
421
  try {
@@ -400,7 +423,7 @@ export class AuthSwitchAuthorityDaemon {
400
423
  return { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
401
424
  } finally { this.drainingAccounts.delete(request.accountId); }
402
425
  }));
403
- this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBindAccount>(
426
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBindAccount>(
404
427
  'authswitch.authority.bind', async request => {
405
428
  if (!hasKeys(request, ['accountId', 'loginId', 'purpose', 'runtime', 'scopeId', 'incarnationId'])
406
429
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
@@ -408,7 +431,7 @@ export class AuthSwitchAuthorityDaemon {
408
431
  || typeof request.incarnationId !== 'string') invalid();
409
432
  return this.broker.bindAccount(request);
410
433
  }));
411
- this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchResolveAccess>(
434
+ this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchResolveAccess>(
412
435
  'authswitch.authority.resolveAccess', async request => {
413
436
  if ((typeof request !== 'object' || request === null || Array.isArray(request))
414
437
  || !['bindingId', 'capability', 'minValidityMs'].every(key => Object.hasOwn(request, key))
@@ -417,41 +440,33 @@ export class AuthSwitchAuthorityDaemon {
417
440
  || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
418
441
  || typeof request.minValidityMs !== 'number'
419
442
  || (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
420
- if (this.closing) throw new Error('Authswitch authority is closing.');
443
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
421
444
  const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId, request.capability));
422
445
  try {
423
446
  return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
424
447
  request.rejectedGrantGeneration);
425
448
  } finally { finish(); }
426
449
  }));
427
- this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchReleaseBinding>(
450
+ this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchReleaseBinding>(
428
451
  'authswitch.authority.release', async request => {
429
452
  if (!hasKeys(request, ['bindingId', 'capability'])
430
453
  || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
431
- if (this.closing) throw new Error('Authswitch authority is closing.');
454
+ if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
432
455
  return this.releaseRuntimeBinding(request.bindingId, request.capability);
433
456
  }));
434
- this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportSubmit>(
457
+ this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
435
458
  'authswitch.authority.import.submit', async request => {
436
459
  // Backend-only: an external submission carries the material of a store this package cannot read.
437
460
  if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
438
461
  || request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
439
462
  || !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
- }
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 }) };
455
470
  }));
456
471
  }
457
472
 
@@ -593,13 +608,13 @@ export class AuthSwitchAuthorityDaemon {
593
608
  const run = home.activeRun;
594
609
  if (!run) continue;
595
610
  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.');
611
+ if (survivor) throw new AuthSwitchRefusal('account_busy', 'A managed Codex app-server still owns this account; stop its work before changing the account.');
597
612
  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.');
613
+ throw new AuthSwitchRefusal('account_busy', 'A managed Codex launch has uncertain process ownership; reconcile it before changing the account.');
599
614
  }
600
615
  try {
601
616
  process.kill(run.pid, 0);
602
- 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.');
603
618
  } catch (error) {
604
619
  if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')) throw error;
605
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,
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
3
3
  import { authSwitchEnvironmentId } from './classes.operations.js';
4
+ import { AuthSwitchRefusal } from './authority-contract.js';
4
5
  import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
5
6
  import { ClaudeAccountStatus, type IClaudeLogin, type IClaudeStatusRead } from './classes.claudestatus.js';
6
7
  import { ClaudeTokenRefresh, type TClaudeAuthorityRefreshResult } from './classes.claudetokenrefresh.js';
@@ -168,8 +169,17 @@ const importAction = (status: TAuthSwitchImportLedgerStatus,
168
169
  return handoff !== null && spentHandoffPhases.includes(handoff.phase) ? 'device_login' : 'resume';
169
170
  };
170
171
 
171
- /** A refusal the owner can act on. It never leaves a half-written import behind. */
172
- export class AuthSwitchImportRefusal extends Error {}
172
+ /**
173
+ * A refusal the owner can act on. It never leaves a half-written import behind.
174
+ *
175
+ * One class hierarchy for the whole authority: this is the authority's refusal with the importer's code,
176
+ * which is what keeps the marker this route has published since 8.1.0 on the wire.
177
+ */
178
+ export class AuthSwitchImportRefusal extends AuthSwitchRefusal {
179
+ constructor(instruction: string) {
180
+ super('import_refusal', instruction);
181
+ }
182
+ }
173
183
 
174
184
  // The annotation on the binding, not only on the arrow, is what lets a refusal narrow the code after it.
175
185
  const refuse: (reason: string) => never = reason => { throw new AuthSwitchImportRefusal(reason); };
@@ -504,10 +514,28 @@ export class AuthSwitchAuthorityImport {
504
514
  throw error;
505
515
  }
506
516
  await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId);
517
+ if (record.sourceKind === 'claude_native') await this.adoptClaudeHome(record, identity);
507
518
  await this.moveLedger(record.id, 'complete', identity.accountId, identity.grantId);
508
519
  return this.result(record, 'complete', identity, []);
509
520
  }
510
521
 
522
+ /**
523
+ * Adopts the native Claude home this source is, so the daemon may serve `claude.*` for it.
524
+ *
525
+ * The verified receipt is the only thing that may adopt a home, and the source id it was earned with is
526
+ * the home id, so nothing else has to be trusted here. Re-submitting the same source returns the same
527
+ * home record rather than writing a second one.
528
+ */
529
+ private async adoptClaudeHome(record: ILegacySourceRecord,
530
+ identity: { accountId: string; subject: string; workspaceId: string; grantId: string }): Promise<void> {
531
+ await this.database.registerClaudeHomeFromReceipt({
532
+ homeId: record.sourcePathHash, accountId: identity.accountId, grantId: identity.grantId,
533
+ receiptId: record.id, sourceDigest: record.sourceDigest,
534
+ accountUuid: identity.subject, organizationUuid: identity.workspaceId,
535
+ updateId: plugins.crypto.randomUUID(), now: this.stamp(),
536
+ });
537
+ }
538
+
511
539
  /** The non-rotating proof: the stored access token is asked about itself and must name this account. */
512
540
  private async proveWithoutRotation(record: ILegacySourceRecord,
513
541
  identity: { accountId: string; subject: string; workspaceId: string },
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import type { TAuthSwitchLoginPrompt } from './authority-contract.js';
2
+ import type { TAuthSwitchClaudeProofFailure, TAuthSwitchLoginPrompt } from './authority-contract.js';
3
3
  import type { IAuthSwitchStoredUsage } from './classes.authorityusage.js';
4
4
 
5
5
  export interface IStoredAuthorityMeta {
@@ -155,6 +155,8 @@ export interface IStoredAuthorityClaudeHandoff {
155
155
  sealedOutgoing: string | null;
156
156
  runningEffectiveAuth: 'no_scoped_sessions' | 'unsupported_effective_auth' | null;
157
157
  problem: 'none' | 'native_uncertain' | 'foreign_or_torn' | 'unsupported_effective_auth' | 'database_uncertain';
158
+ /** Which condition of the native-login proof failed; stored, so a lost answer stays diagnosable. */
159
+ proofFailure: TAuthSwitchClaudeProofFailure | null;
158
160
  startedAt: string;
159
161
  updatedAt: string;
160
162
  revision: number;
@@ -502,7 +504,7 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
502
504
  if (!object(value) || !exactKeys(value, ['id', 'homeId', 'outgoingAccountId', 'outgoingGrantId',
503
505
  'incomingAccountId', 'incomingGrantId', 'phase', 'before', 'after', 'sourceIdentity',
504
506
  'targetIdentity', 'sourceAccessDigest', 'targetAccessDigest', 'sealedOutgoing',
505
- 'runningEffectiveAuth', 'problem', 'startedAt', 'updatedAt', 'revision', 'updateId'])
507
+ 'runningEffectiveAuth', 'problem', 'proofFailure', 'startedAt', 'updatedAt', 'revision', 'updateId'])
506
508
  || !uuid(value.id) || !hash(value.homeId) || !hash(value.outgoingAccountId)
507
509
  || !hash(value.outgoingGrantId) || !hash(value.incomingAccountId) || !hash(value.incomingGrantId)
508
510
  || value.outgoingAccountId === value.incomingAccountId || value.outgoingGrantId === value.incomingGrantId
@@ -514,8 +516,14 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
514
516
  || !ciphertext(value.sealedOutgoing)
515
517
  || ![null, 'no_scoped_sessions', 'unsupported_effective_auth'].includes(value.runningEffectiveAuth as null)
516
518
  || !['none', 'native_uncertain', 'foreign_or_torn', 'unsupported_effective_auth', 'database_uncertain'].includes(String(value.problem))
519
+ || ![null, 'unsupported_release', 'override', 'profile', 'profile_unreadable', 'settings',
520
+ 'subscription', 'running_session'].includes(value.proofFailure as null)
517
521
  || !iso(value.startedAt) || !iso(value.updatedAt) || !revision(value.revision)
518
522
  || value.revision === 0 || !uuid(value.updateId)) throw new Error('Invalid Claude native handoff.');
523
+ // The detail belongs to exactly the problem it explains, so a client can trust one without the other.
524
+ if (value.proofFailure !== null && value.problem !== 'unsupported_effective_auth') {
525
+ throw new Error('Claude handoff names a proof failure without the problem it explains.');
526
+ }
519
527
  const hasProof = value.before !== null && value.after !== null
520
528
  && value.sourceAccessDigest !== null && value.targetAccessDigest !== null;
521
529
  const hasNoProof = value.before === null && value.after === null
@@ -838,6 +846,7 @@ export class AuthSwitchAuthorityClaudeHandoffModel extends plugins.nosqldb.Smart
838
846
  @plugins.nosqldb.svDb() public sealedOutgoing!: string | null;
839
847
  @plugins.nosqldb.svDb() public runningEffectiveAuth!: IStoredAuthorityClaudeHandoff['runningEffectiveAuth'];
840
848
  @plugins.nosqldb.svDb() public problem!: IStoredAuthorityClaudeHandoff['problem'];
849
+ @plugins.nosqldb.svDb() public proofFailure!: IStoredAuthorityClaudeHandoff['proofFailure'];
841
850
  @plugins.nosqldb.svDb() public startedAt!: string;
842
851
  @plugins.nosqldb.svDb() public updatedAt!: string;
843
852
  @plugins.nosqldb.svDb() public revision!: number;
@@ -1,5 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { AuthSwitchAuthorityDaemon } from './classes.authoritydaemon.js';
3
+ import type { IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
4
+ import { ClaudeNativeAdapter, claudeNativeUserSettingsFiles,
5
+ resolveClaudeNativeHome } from './classes.claudenative.js';
3
6
 
4
7
  export interface IAuthSwitchAuthorityPaths {
5
8
  runtimeDirectory: string;
@@ -60,12 +63,36 @@ export class AuthSwitchAuthorityService {
60
63
  public stop(): Promise<plugins.smartdaemon.ISystemdUnitState> { return this.unit.stop(); }
61
64
  }
62
65
 
66
+ /**
67
+ * The Claude native authority this user's daemon serves.
68
+ *
69
+ * It is wired whether or not Claude Code is installed: nothing here touches the filesystem, and every
70
+ * operation that needs the native home refuses until a verified import receipt has registered it. An
71
+ * environment that names a home this daemon cannot resolve is a named startup failure rather than a
72
+ * silently missing capability, because the same environment also decides which file the import reads.
73
+ */
74
+ export const resolveAuthSwitchClaudeNativeAuthority = (env: NodeJS.ProcessEnv = process.env,
75
+ homeDirectory: string = plugins.os.homedir()): IClaudeNativeAuthorityOptions => {
76
+ const resolution = resolveClaudeNativeHome(env, homeDirectory);
77
+ if (resolution.kind === 'unusable') {
78
+ throw new Error(`Authswitch authority cannot locate Claude Code's native home: ${resolution.defect.problem}.`);
79
+ }
80
+ const { configDir, configFile, homeId } = resolution.home;
81
+ // The adapter re-derives which home Claude Code itself selects; it must read the environment this
82
+ // home was resolved from, or the two could disagree about the same host. Its settings sources are
83
+ // stated from that same home: this daemon's working directory is the service unit's own (the package
84
+ // root, see `AuthSwitchAuthorityService`), so a project settings file beside it selects nothing.
85
+ return { homeId, adapter: new ClaudeNativeAdapter({ configDir, configFile, env,
86
+ settingsFiles: claudeNativeUserSettingsFiles(configDir) }) };
87
+ };
88
+
63
89
  export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuthorityPaths()): Promise<void> => {
64
90
  const daemon = new AuthSwitchAuthorityDaemon({
65
91
  dataDirectory: paths.dataDirectory,
66
92
  socketPath: paths.databaseSocketPath,
67
93
  authoritySocketPath: paths.authoritySocketPath,
68
94
  runtimeSocketPath: paths.runtimeSocketPath,
95
+ claudeNative: resolveAuthSwitchClaudeNativeAuthority(),
69
96
  });
70
97
  await daemon.start();
71
98
  await new Promise<void>((resolve, reject) => {