@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
package/readme.md CHANGED
@@ -49,8 +49,11 @@ const receipt = await client.getOperation(operation.id);
49
49
  ```
50
50
 
51
51
  Browser code can import the credential-free DTOs from
52
- `@modelprofile.com/authswitch/authority-contract`. A backend binds an account to a
53
- runtime incarnation by exact account ID, login ID and purpose, and keeps the returned
52
+ `@modelprofile.com/authswitch/authority-contract`. The backend-only binding, access and release
53
+ requests are named by `@modelprofile.com/authswitch/authority-runtime-contract`, and the one-time
54
+ import's own DTOs by `@modelprofile.com/authswitch/authority-import-contract`; the runtime contract
55
+ belongs to the runtime socket and its capability must never reach a browser. A backend binds an
56
+ account to a runtime incarnation by exact account ID, login ID and purpose, and keeps the returned
54
57
  capability private. Targeted reauthentication likewise requires the exact login; an
55
58
  account's presentation default never selects a grant for either action. Completed and
56
59
  interrupted operations remain discoverable through `getOperation()` and paged
@@ -76,6 +79,31 @@ the runtime and clears its durable run before internal revocation. Binding relea
76
79
  or changes ownership of the account grant. External-binding recovery after an owner crash remains
77
80
  a prerequisite for the full account-mutation cutover.
78
81
 
82
+ Every route on either socket answers in one of three ways: a result, a **refusal**, or a fault. A refusal
83
+ is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the
84
+ owner does instead -- so it crosses the wire marked, with the message verbatim beside
85
+ `{ reason, code }`. A fault is anything the daemon did not decide: the transport replaces its text with
86
+ `Internal server error`, and a caller must never present that as if it said what to do next. A malformed
87
+ request is neither; it keeps its own text and carries no marker, because a caller's own bug is not an
88
+ instruction for an owner.
89
+
90
+ `asAuthSwitchRefusal(error)` from `@modelprofile.com/authswitch/authority-contract` is the one reader of
91
+ that marker: it answers `{ code, instruction }` for a refusal and `null` for a fault. The code is the
92
+ closed `TAuthSwitchRefusalCode` set, which is what a client branches on without reading the text:
93
+ `authority_closing`, `not_found`, `account_changed`, `account_busy`, `login_unavailable`,
94
+ `native_owner_holds_login`, `claude_home_unregistered`, `claude_handoff_pending`,
95
+ `claude_receipt_missing` and `import_refusal`. The instruction is authored for the owner and contains no
96
+ path, credential or digest; only an importer refusal may name the process the owner has to stop.
97
+
98
+ The import routes keep the marker they published in 8.1.0, `authswitch_import_refusal`, and
99
+ `isAuthSwitchImportRefusal` from `@modelprofile.com/authswitch/authority-import-contract` is the same
100
+ reader asking for the `import_refusal` code, so a consumer written against 8.1.0 keeps working and a
101
+ client of this release still reads the payload a daemon of that release answers with. The distinction
102
+ matters most for `import.submit`: a refusal there is a submit that decided -- it left nothing half-done,
103
+ and the ledger row it wrote says where that source stands -- while an unmarked failure leaves the outcome
104
+ unknown, and that source must then be read with `authswitch.authority.import.status` rather than
105
+ submitted again.
106
+
79
107
  `AuthSwitchClient.subscribe(onSnapshot, onEvent, signal, { onStatus })` reports `current`
80
108
  after a fresh snapshot or verified heartbeat, `unavailable` on disconnect or resync, and
81
109
  `closed` on abort. Keep cached account actions disabled while status is not `current`.
@@ -110,6 +138,9 @@ authswitch account preuse operation get <operation-id> [--json]
110
138
  authswitch account preuse operation cancel <operation-id> [--json]
111
139
  authswitch account preuse operation resume <operation-id> [--json]
112
140
  authswitch authority doctor [--json]
141
+ authswitch authority import inventory [--json]
142
+ authswitch authority import status [--json]
143
+ authswitch authority import submit <source-id> --digest <source-digest> [--json]
113
144
  ```
114
145
 
115
146
  `account list --json` prints the complete, consistent credential-free authority snapshot.
@@ -164,6 +195,84 @@ If the daemon is unavailable before the first response, it fails with a fixed di
164
195
  stderr and leaves stdout empty. The older native-store commands documented below still coexist until the coordinated
165
196
  major-version migration and removal; these authority routes never use them.
166
197
 
198
+ ### The Claude native home
199
+
200
+ The daemon serves Claude Code's own credential store, which stays the native store that Claude Code
201
+ reads. `authswitch.authority.claude.switch`, `.handoff` and `.handoffs` on the management socket move
202
+ that one home between two authority logins and report the journal of those moves, and
203
+ `authswitch.authority.usage` answers for an Anthropic account from it. The home is
204
+ `CLAUDE_CONFIG_DIR` or `~/.claude`, and the authority identifies it by the hash of the credential file
205
+ Claude Code owns -- the same value the import publishes as that source's path hash, which is what lets a
206
+ verified receipt adopt it.
207
+
208
+ The daemon is wired for that home whether or not Claude Code is installed, so `doctor` reports
209
+ `claudeNativeAuthority: configured` on any host; the `claudeHomes` page is what says whether a home has
210
+ been adopted. Until a verified `claude_native` import has adopted one, every Claude route refuses. A
211
+ `CLAUDE_CONFIG_DIR` that is not an absolute path is refused when the daemon starts, because the same
212
+ variable also decides which file the import would read; it is never resolved against a working directory.
213
+
214
+ A switch is only attempted when the host can prove Claude Code uses that native login: exactly the
215
+ verified Claude Code release, a subscriber login in the store, no credential override in the
216
+ environment, no managed or local settings selecting another auth source, and no scoped Claude session
217
+ running. Anything else refuses and leaves both logins where they were. A move that reaches the home and
218
+ then cannot complete that proof is quarantined with `problem: unsupported_effective_auth`, and the
219
+ handoff's optional `proofFailure` names which condition failed -- `unsupported_release`, `subscription`,
220
+ `override`, `settings`, `profile`, `profile_unreadable` or `running_session` -- so an owner is told what
221
+ to change. It is absent whenever `problem` is anything else. The settings that proof reads are the ones
222
+ the daemon may speak for: the user settings file of the resolved home and the managed settings directory.
223
+ A project's `.claude/settings.json` is read from the working directory of each running Claude session,
224
+ never from the daemon's own, which is its service unit's and selects nothing. While a login is in the
225
+ native home, Claude Code is its only refresher; the authority takes it back by the same journaled handoff.
226
+
227
+ ## The one-time account import
228
+
229
+ The three `authority import` commands move the accounts that existed before the authority into it.
230
+
231
+ `import inventory` reads this host and changes nothing: no authority record, no legacy file, no
232
+ credential. It lists every Codex stash directory, saved credential record and native Codex, Claude and
233
+ OpenCode store with the authority account it resolves to, derived from the claims the stored token already
234
+ carries -- no provider is contacted. A malformed or unrecognised entry is reported rather than skipped, so
235
+ an empty result is never mistaken for an empty store. The report carries no token, no token digest and no
236
+ source path; a source is named by a hash of its location and a digest of its bytes.
237
+
238
+ Each source is classified by who refreshes its login **after** the import.
239
+
240
+ - **adopt** -- a copy authswitch made for itself, and AGL's own records. Nothing else refreshes such a
241
+ copy, so the authority takes it over outright. It is verified by one real refresh, which rotates the
242
+ token: from that moment the legacy copy is dead. That is the point of no return for that account, and the
243
+ only fallback is one device sign-in.
244
+ - **project** -- a native Codex, Claude or OpenCode store. The vendor tool keeps refreshing its own store,
245
+ so the authority registers the account, leaves the grant with its native owner and holds no copy of the
246
+ credential. It is verified by a provider read with the access token already in the store, which rotates
247
+ nothing. Two refreshers on one rotating refresh token is the failure this avoids.
248
+
249
+ A verified Claude projection also adopts the native home it came from, so the daemon may serve Claude
250
+ switches for it. The receipt is what adopts it, and a home belongs to one login at a time: a second home
251
+ for a login another one already holds -- a `.credentials.json` copied to another `CLAUDE_CONFIG_DIR` --
252
+ and a home whose active login has since moved are both refused with the importer's own code, so the
253
+ submit is known to have decided, the ledger row stays `verified` and no home changed.
254
+
255
+ `import submit` takes the source id and the digest the owner read in the inventory. The daemon re-reads the
256
+ source itself and refuses if the digest changed, so approving one source can never import a different one;
257
+ no credential crosses the socket for a source this package can read. It refuses while an authswitch watch, a
258
+ native harness process or Codex's app-server is running, and an app-server it cannot ask counts as running.
259
+ An adopt whose refresh fails, or whose answer is lost, is never retried: the source is quarantined, its grant
260
+ is left needing re-authentication, and the command says so. A projection fails differently, because nothing
261
+ rotating was ever sent for it: a native store that could not be proven live stays a pending native owner and
262
+ is imported by submitting the same source again once its own tool has refreshed it.
263
+
264
+ `import status` reads the migration ledger, the grants and the handoffs -- never the host -- and reports, per
265
+ source, where it stands, which owner refreshes its login now, and what to do next: nothing, submit the same
266
+ source again, or sign in to that account again. Submitting a source again is how an interrupted run resumes,
267
+ and the answer comes from the same durable record the daemon decides on: a source whose sign-in was in
268
+ flight when the run died is reported as needing a device sign-in, not as resumable, because a rotating
269
+ refresh token is never sent a second time. Sources that were never submitted have no ledger row and appear
270
+ only in the inventory.
271
+
272
+ An import deletes nothing: retiring the legacy files is a separate, later step. The authority store is
273
+ deliberately not backed up, so losing it costs one device sign-in per account -- and the remote-control
274
+ enrollments and runtime bindings with it.
275
+
167
276
  `AuthSwitchClient.getUsage(accountId, loginId)` reads OpenAI managed limits or a Claude
168
277
  host login's profile and usage through the management socket. The tagged, credential-free
169
278
  reading preserves Claude's labelled five-hour, weekly and model windows, plan provenance,
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/authswitch',
6
- version: '8.0.0',
6
+ version: '8.2.0',
7
7
  description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
8
8
  }
@@ -2,6 +2,99 @@ import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
2
  import type { IAuthSwitchUsageSnapshot } from './classes.authorityusage.js';
3
3
  export type { IAuthSwitchUsageSnapshot } from './classes.authorityusage.js';
4
4
 
5
+ /**
6
+ * Why the authority declined, as a closed set a client branches on without reading the text.
7
+ *
8
+ * A refusal is an answer: the daemon did the check, nothing was left half-done, and the message says what
9
+ * the owner does instead. Everything else stays a fault whose text the transport replaces, and a caller
10
+ * must never present a fault as if it said what to do next.
11
+ */
12
+ export type TAuthSwitchRefusalCode =
13
+ | 'authority_closing'
14
+ | 'not_found'
15
+ | 'account_changed'
16
+ | 'account_busy'
17
+ | 'login_unavailable'
18
+ | 'native_owner_holds_login'
19
+ | 'claude_home_unregistered'
20
+ | 'claude_handoff_pending'
21
+ | 'claude_receipt_missing'
22
+ | 'import_refusal';
23
+
24
+ /** The marker the importer has published since 8.1.0; it keeps its own value on the wire. */
25
+ export const authSwitchImportRefusalMarker = 'authswitch_import_refusal';
26
+ export const authSwitchRefusalReason = 'authswitch_refusal';
27
+
28
+ export type TAuthSwitchRefusalReason = typeof authSwitchRefusalReason | typeof authSwitchImportRefusalMarker;
29
+
30
+ /** Everything a refusal puts on the wire beside its message: two literals, never a value of its own. */
31
+ export interface IAuthSwitchRefusalData {
32
+ reason: TAuthSwitchRefusalReason;
33
+ code: TAuthSwitchRefusalCode;
34
+ }
35
+
36
+ /** What the owner is told when the answer is an instruction rather than a fault. */
37
+ export interface IAuthSwitchRefusal {
38
+ code: TAuthSwitchRefusalCode;
39
+ /** The instruction, authored by the handler that declined. */
40
+ instruction: string;
41
+ }
42
+
43
+ const refusalCodes: ReadonlySet<string> = new Set<TAuthSwitchRefusalCode>([
44
+ 'authority_closing', 'not_found', 'account_changed', 'account_busy',
45
+ 'login_unavailable', 'native_owner_holds_login', 'claude_home_unregistered',
46
+ 'claude_handoff_pending', 'claude_receipt_missing', 'import_refusal',
47
+ ]);
48
+
49
+ const isRefusalCode = (value: unknown): value is TAuthSwitchRefusalCode =>
50
+ typeof value === 'string' && refusalCodes.has(value);
51
+
52
+ /** The importer keeps the marker it published; every other code shares the authority's own. */
53
+ const reasonOf = (code: TAuthSwitchRefusalCode): TAuthSwitchRefusalReason =>
54
+ code === 'import_refusal' ? authSwitchImportRefusalMarker : authSwitchRefusalReason;
55
+
56
+ /**
57
+ * One refusal, thrown by a handler and turned into a marked answer by the router that owns the wire.
58
+ *
59
+ * It accepts nothing but a code of the closed set and the text the owner is to read, and builds the marker
60
+ * from the code alone, so no handler can attach a value of its own to what a client branches on.
61
+ */
62
+ export class AuthSwitchRefusal extends Error {
63
+ public readonly code: TAuthSwitchRefusalCode;
64
+
65
+ constructor(code: TAuthSwitchRefusalCode, instruction: string) {
66
+ super(instruction);
67
+ this.code = code;
68
+ }
69
+
70
+ /** Everything this refusal puts on the wire beside its message. */
71
+ public get data(): IAuthSwitchRefusalData { return { reason: reasonOf(this.code), code: this.code }; }
72
+ }
73
+
74
+ /**
75
+ * The refusal an error answer carries, or null when it is a fault whose text says nothing.
76
+ *
77
+ * This is the only reader of `errorData` in the package: the importer's published predicate is expressed
78
+ * through it, so a caller never has to know which of the two markers a route uses.
79
+ *
80
+ * 8.1.0 marked the importer's refusal with the reason alone. A per-user daemon keeps running across an
81
+ * upgrade until its owner restarts it, so a newer client still meets that payload, and it names the one
82
+ * refusal that release could answer with -- which is why it is read rather than treated as a fault.
83
+ */
84
+ export const asAuthSwitchRefusal = (error: unknown): IAuthSwitchRefusal | null => {
85
+ if (error instanceof AuthSwitchRefusal) return { code: error.code, instruction: error.message };
86
+ if (typeof error !== 'object' || error === null || !('errorData' in error)) return null;
87
+ const data: unknown = error.errorData;
88
+ if (typeof data !== 'object' || data === null || !('reason' in data)) return null;
89
+ const code: unknown = 'code' in data ? data.code : undefined;
90
+ const instruction = 'message' in error && typeof error.message === 'string' ? error.message : '';
91
+ if (data.reason === authSwitchImportRefusalMarker) {
92
+ return code === undefined || code === 'import_refusal' ? { code: 'import_refusal', instruction } : null;
93
+ }
94
+ if (data.reason !== authSwitchRefusalReason || !isRefusalCode(code)) return null;
95
+ return { code, instruction };
96
+ };
97
+
5
98
  /** Credential-free account management contract. Safe to import in browser code. */
6
99
  export type TAuthSwitchLoginPurpose = 'openai_managed' | 'claude_host_native' | 'claude_container_setup' | 'opencode_native';
7
100
  export type TAuthSwitchLoginHealth = 'ready' | 'refreshing' | 'retry_wait' | 'needs_reauth' | 'unverified' | 'pending_handoff' | 'handoff_quarantined' | 'removed';
@@ -234,6 +327,17 @@ export interface IAuthSwitchPreuseOperation {
234
327
  revision: number;
235
328
  }
236
329
 
330
+ /**
331
+ * Why the host could not prove that Claude Code uses its own native login.
332
+ *
333
+ * Each value names one condition of that proof, so an owner is told what to change: install the supported
334
+ * release, sign in to a subscriber plan, remove a credential override or a settings source, let the
335
+ * profile read succeed, or close the running session.
336
+ */
337
+ export type TAuthSwitchClaudeProofFailure =
338
+ | 'unsupported_release' | 'override' | 'profile' | 'profile_unreadable'
339
+ | 'settings' | 'subscription' | 'running_session';
340
+
237
341
  /** Credential-free native file ownership result. It makes no claim about a running session's active request. */
238
342
  export interface IAuthSwitchClaudeNativeHandoff {
239
343
  id: string;
@@ -243,6 +347,8 @@ export interface IAuthSwitchClaudeNativeHandoff {
243
347
  phase: 'reserved' | 'prepared' | 'committed' | 'aborted' | 'quarantined';
244
348
  problem: 'none' | 'native_uncertain' | 'foreign_or_torn' | 'unsupported_effective_auth' | 'database_uncertain';
245
349
  runningEffectiveAuth: 'no_scoped_sessions' | 'unsupported_effective_auth' | null;
350
+ /** Which condition of the native-login proof failed, when `problem` is `unsupported_effective_auth`. */
351
+ proofFailure?: TAuthSwitchClaudeProofFailure;
246
352
  updatedAt: string;
247
353
  }
248
354
 
@@ -0,0 +1,217 @@
1
+ import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
+ import { asAuthSwitchRefusal, authSwitchImportRefusalMarker } from './authority-contract.js';
3
+
4
+ /**
5
+ * The one-time account import: reading the legacy stores that existed before the authority, and the
6
+ * credential-free report an owner reads before anything is imported.
7
+ *
8
+ * Importing a refresh token is a transfer of ownership, not a copy. The provider rotates a refresh token on
9
+ * use, so the moment one holder refreshes, every other copy of that token is dead. This contract therefore
10
+ * separates the classes of legacy source by who refreshes them after the import, and names the proof each
11
+ * class can offer without rotating anything.
12
+ */
13
+
14
+ /** Mirrors the migration ledger's source kinds, which are the only sources a ledger row can describe. */
15
+ export type TAuthSwitchImportSourceKind = 'authswitch_stash' | 'authswitch_backup' | 'agl_flex'
16
+ | 'codex_native' | 'opencode_native' | 'claude_native';
17
+
18
+ /**
19
+ * The ledger statuses a source can already hold when an inventory runs.
20
+ *
21
+ * - `prepared`: the source is named in the authority and nothing has been sent for it yet.
22
+ * - `pending_native_owner`: the account is registered and its native tool keeps the refresher, but the
23
+ * store could not be proven live. Nothing rotating was ever sent, so it is proven by submitting the
24
+ * source again once that tool has refreshed its own store.
25
+ * - `verified` and `complete`: the import settled; `complete` is the tombstone for that source.
26
+ * - `quarantined`: a rotating refresh may already have been consumed for this source, so it is never
27
+ * submitted again and its account is repaired by a device sign-in.
28
+ */
29
+ export type TAuthSwitchImportLedgerStatus = 'pending_native_owner' | 'prepared' | 'verified' | 'complete'
30
+ | 'quarantined';
31
+
32
+ /**
33
+ * What the ownership rule allows for a source.
34
+ *
35
+ * - `import`: the authority becomes the single refresher. Nothing else owns this copy, so taking it over
36
+ * strands no other holder.
37
+ * - `project`: a native tool keeps refreshing its own store; the authority registers the account and holds
38
+ * the grant as a projection it must never refresh. Two refreshers on one refresh token is the failure this
39
+ * avoids.
40
+ * - `refuse`: the source cannot become an authority grant at all, and the owner signs in again instead.
41
+ */
42
+ export type TAuthSwitchImportTreatment = 'import' | 'project' | 'refuse';
43
+
44
+ /**
45
+ * What can make this source's ledger row `verified`.
46
+ *
47
+ * - `rotating_refresh`: one real refresh through the authority. It proves the refresh token was live and
48
+ * makes the authority its holder in the same step -- and it is the point of no return, because the legacy
49
+ * copy is stale from that moment.
50
+ * - `access_token_read`: a provider read with the stored access token alone, matched against the identity
51
+ * derived locally from the same source bytes. It rotates nothing, so a native refresher keeps its grant.
52
+ * It is available only while that access token is still valid.
53
+ * - `none`: no proof exists for this source; it cannot be verified and cannot be imported.
54
+ */
55
+ export type TAuthSwitchImportProof = 'rotating_refresh' | 'access_token_read' | 'none';
56
+
57
+ /**
58
+ * One legacy source as the inventory found it. Credential-free by construction: identity claims and a digest
59
+ * of the source bytes, never a token, a token digest, or the source path.
60
+ */
61
+ export interface IAuthSwitchImportSource {
62
+ /** The migration ledger row this source would use. Derived from its kind and path, so it is stable. */
63
+ id: string;
64
+ sourceKind: TAuthSwitchImportSourceKind;
65
+ /** The source location as a hash. The ledger never stores a path and this DTO never exposes one. */
66
+ sourcePathHash: string;
67
+ /** Binds a later import to exactly these bytes. Changes whenever the source changes. */
68
+ sourceDigest: string;
69
+ treatment: TAuthSwitchImportTreatment;
70
+ proof: TAuthSwitchImportProof;
71
+ /** The authority account this source resolves to, derived locally from its identity claims. */
72
+ accountId: string | null;
73
+ providerId: string | null;
74
+ email: string | null;
75
+ plan: string | null;
76
+ label: string | null;
77
+ /** Expiry claim of the stored access token. It decides whether a non-rotating proof is available now. */
78
+ accessExpiresAt: string | null;
79
+ /** Remote-control enrollments carried by this source; `null` when they could not be counted. */
80
+ enrollmentCount: number | null;
81
+ /** The ledger row for this source, when one already exists. */
82
+ ledgerStatus: TAuthSwitchImportLedgerStatus | null;
83
+ /** Why this source cannot be submitted as it stands. An empty list means it can. */
84
+ problems: string[];
85
+ }
86
+
87
+ /** The complete credential-free report. `notice` is what the owner must know before importing anything. */
88
+ export interface IAuthSwitchImportInventory {
89
+ generatedAt: string;
90
+ /** Identifies the credential locations this inventory read, as `authSwitchEnvironmentId` computes them. */
91
+ environmentId: string;
92
+ notice: string[];
93
+ sources: IAuthSwitchImportSource[];
94
+ /** Locations that could not be read at all, so an empty result never looks like an empty store. */
95
+ problems: string[];
96
+ }
97
+
98
+ /** Management-only. Reads legacy stores and the ledger; changes nothing, and carries no credential. */
99
+ export interface IReq_AuthSwitchImportInventory extends ITypedRequest {
100
+ method: 'authswitch.authority.import.inventory';
101
+ request: Record<string, never>;
102
+ response: { inventory: IAuthSwitchImportInventory };
103
+ }
104
+
105
+ /** A ChatGPT login held by a store outside this package, submitted by the backend that owns its format. */
106
+ export interface IAuthSwitchImportOpenAiCredential {
107
+ providerId: 'openai';
108
+ accessToken: string;
109
+ refreshToken: string;
110
+ idToken: string;
111
+ }
112
+
113
+ /**
114
+ * What is being imported.
115
+ *
116
+ * A source this daemon can read itself is named only by its id: the daemon re-runs its readers, resolves the
117
+ * location from the hash and re-checks the digest, so no credential crosses the socket and no caller can
118
+ * substitute bytes the owner never approved. Only a store this package cannot read -- AGL's sealed Flex
119
+ * records -- submits material, and that is the whole reason submit lives on the backend-only runtime socket.
120
+ */
121
+ export type TAuthSwitchImportSubmission =
122
+ | { kind: 'local'; sourceId: string; sourceDigest: string }
123
+ | { kind: 'external'; sourceKind: 'agl_flex'; sourcePathHash: string; sourceDigest: string;
124
+ credential: IAuthSwitchImportOpenAiCredential };
125
+
126
+ /** The outcome of one source. `quarantined` always names what to do instead, never a silent failure. */
127
+ export interface IAuthSwitchImportResult {
128
+ sourceId: string;
129
+ sourceKind: TAuthSwitchImportSourceKind;
130
+ treatment: TAuthSwitchImportTreatment;
131
+ status: TAuthSwitchImportLedgerStatus;
132
+ accountId: string | null;
133
+ loginId: string | null;
134
+ problems: string[];
135
+ }
136
+
137
+ /**
138
+ * What the owner does next with one source.
139
+ *
140
+ * - `none`: the import settled; nothing is outstanding.
141
+ * - `resume`: submit the same source again. Nothing rotating has been sent for this source, so a second
142
+ * attempt is safe: an interrupted adopt continues from its durable record, and a native store that could
143
+ * not be proven live is proven the moment its own tool has refreshed it.
144
+ * - `device_login`: this source is finished as a source. Its account needs a fresh device sign-in, because a
145
+ * rotating refresh token whose outcome is unknown is never sent a second time.
146
+ */
147
+ export type TAuthSwitchImportAction = 'none' | 'resume' | 'device_login';
148
+
149
+ /**
150
+ * Marks an error answer whose text is an instruction for the owner, not a report of a fault.
151
+ *
152
+ * The daemon sets it on the refusals the importer authors and on nothing else: a refusal any other route
153
+ * authors carries the authority's own marker, and a failure neither of them decided answers with the
154
+ * transport's sanitised text, which a caller must never present as if it said what to do next. For a
155
+ * submit in particular, an unmarked failure means the outcome is unknown and the source must be read with
156
+ * `authswitch.authority.import.status` rather than submitted again.
157
+ *
158
+ * It is one code of the authority's refusal contract (`./authority-contract`), which every route now uses;
159
+ * this marker keeps its own value on the wire so a consumer written against 8.1.0 keeps working.
160
+ */
161
+ export const authSwitchImportRefusalReason = authSwitchImportRefusalMarker;
162
+
163
+ /** True when the daemon answered with an importer refusal, so `error.message` is the instruction to show. */
164
+ export const isAuthSwitchImportRefusal = (error: unknown): boolean =>
165
+ asAuthSwitchRefusal(error)?.code === 'import_refusal';
166
+
167
+ /**
168
+ * Where one source stands, read from the migration ledger, the grant it produced and its handoff.
169
+ *
170
+ * Only sources the authority has been told about appear here. A source that was never submitted has no
171
+ * ledger row; `authority import inventory` is the read that looks at the host and lists those.
172
+ */
173
+ export interface IAuthSwitchImportStatusEntry {
174
+ sourceId: string;
175
+ sourceKind: TAuthSwitchImportSourceKind;
176
+ status: TAuthSwitchImportLedgerStatus;
177
+ accountId: string | null;
178
+ loginId: string | null;
179
+ /**
180
+ * Who refreshes that login now, or `null` when the ledger row names no grant yet. `none` is the absence of
181
+ * a refresher: the grant needs a device sign-in before anything can use it again.
182
+ */
183
+ owner: 'daemon' | 'claude_native' | 'legacy_native' | 'none' | null;
184
+ action: TAuthSwitchImportAction;
185
+ statusObservedAt: string;
186
+ }
187
+
188
+ export interface IAuthSwitchImportStatus {
189
+ generatedAt: string;
190
+ entries: IAuthSwitchImportStatusEntry[];
191
+ nextCursor: string | null;
192
+ }
193
+
194
+ /** Management-only. Reads durable records only; it opens no legacy store and carries no credential. */
195
+ export interface IReq_AuthSwitchImportStatus extends ITypedRequest {
196
+ method: 'authswitch.authority.import.status';
197
+ request: { after?: string; limit?: number };
198
+ response: { status: IAuthSwitchImportStatus };
199
+ }
200
+
201
+ /**
202
+ * Backend-only. One source, one ownership decision.
203
+ *
204
+ * Both flags are required and both must be `true`. `callerQuiescent` is the caller's own assertion, because
205
+ * this daemon can observe a watch, a native process and Codex's app-server but cannot observe a host that
206
+ * uses the legacy library in its own process. `acknowledgeRunOrder` is the owner's acknowledgement that an
207
+ * imported source's legacy copy is dead and that the everyday commands must already run on the authority.
208
+ */
209
+ export interface IReq_AuthSwitchImportSubmit extends ITypedRequest {
210
+ method: 'authswitch.authority.import.submit';
211
+ request: {
212
+ submission: TAuthSwitchImportSubmission;
213
+ callerQuiescent: true;
214
+ acknowledgeRunOrder: true;
215
+ };
216
+ response: { result: IAuthSwitchImportResult };
217
+ }
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
3
3
  IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
4
+ import { AuthSwitchRefusal } from './authority-contract.js';
4
5
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
5
6
  import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
6
7
  IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
@@ -554,7 +555,7 @@ export class AuthSwitchAuthorityBroker {
554
555
  if (!isId(accountId) || !validRevision(expectedRevision) || !safeLabel(label)) throw new Error('Invalid account change.');
555
556
  const updateId = plugins.crypto.randomUUID();
556
557
  const updated = await this.database.changeAccount(updateId, accountId, account => {
557
- if (!account || account.removed || account.revision !== expectedRevision) throw new Error('Account changed; refresh before editing.');
558
+ if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
558
559
  return { ...account, label, revision: account.revision + 1, updateId,
559
560
  statusObservedAt: new Date(this.now()).toISOString() };
560
561
  });