@modelprofile.com/authswitch 9.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +67 -1
- package/dist_ts/authority-contract.js +13 -2
- package/dist_ts/authority-import-contract.d.ts +6 -0
- package/dist_ts/classes.authoritybroker.d.ts +21 -15
- package/dist_ts/classes.authoritybroker.js +94 -31
- package/dist_ts/classes.authorityclient.d.ts +22 -2
- package/dist_ts/classes.authorityclient.js +79 -13
- package/dist_ts/classes.authoritydaemon.d.ts +7 -0
- package/dist_ts/classes.authoritydaemon.js +45 -31
- package/dist_ts/classes.authoritydatabase.d.ts +21 -3
- package/dist_ts/classes.authoritydatabase.js +88 -11
- package/dist_ts/classes.authorityimport.js +2 -2
- package/dist_ts/classes.authoritymodels.js +5 -3
- package/dist_ts/classes.codexmanaged.d.ts +0 -9
- package/dist_ts/classes.codexmanaged.js +8 -28
- package/dist_ts/codexcontract.d.ts +30 -0
- package/dist_ts/codexcontract.js +174 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/package.json +8 -8
- package/readme.md +75 -17
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +71 -4
- package/ts/authority-import-contract.ts +6 -0
- package/ts/classes.authoritybroker.ts +94 -32
- package/ts/classes.authorityclient.ts +86 -15
- package/ts/classes.authoritydaemon.ts +44 -23
- package/ts/classes.authoritydatabase.ts +88 -11
- package/ts/classes.authorityimport.ts +1 -1
- package/ts/classes.authoritymodels.ts +4 -1
- package/ts/classes.codexmanaged.ts +6 -26
- package/ts/codexcontract.ts +200 -0
- package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
package/readme.md
CHANGED
|
@@ -37,6 +37,18 @@ that tool -- `ownerTool` is `claude_code`, `codex` or `opencode`, and `null` exa
|
|
|
37
37
|
native tool holds it, because the authority refreshes it or nothing does. The authority
|
|
38
38
|
names it rather than leaving a consumer to derive it from the purpose, which would be wrong
|
|
39
39
|
as soon as two purposes share a tool or one tool gains a second purpose.
|
|
40
|
+
The snapshot also lists `nativeAssignments`: which account a vendor tool's own home on this host runs on,
|
|
41
|
+
as `{ id, tool, accountId, loginId, state, revision }` with the home named by its hash. Only homes the
|
|
42
|
+
authority itself switches are recorded -- Claude Code homes adopted by a verified import -- and `state` is
|
|
43
|
+
`switching` while a handoff to another account is in flight and `quarantined` until one is resolved. A
|
|
44
|
+
Codex or OpenCode store that its own tool still refreshes appears as its login instead, with
|
|
45
|
+
`owner: 'legacy_native'` and its `ownerTool`, because the authority does not decide what that store holds.
|
|
46
|
+
Every change of an assignment is an account event, so a subscriber sees it like any other change.
|
|
47
|
+
A removed account is left out of the snapshot unless the caller asks with `includeRemoved` (on `snapshot`,
|
|
48
|
+
`snapshotAll` and `subscribe`): it then appears with `removed: true` and its logins with `health: 'removed'`.
|
|
49
|
+
An account label follows one published rule, `isAuthSwitchAccountLabel` (at most
|
|
50
|
+
`authSwitchAccountLabelMaxLength` characters, no space at either end, no control character); a rename that
|
|
51
|
+
breaks it is refused with `invalid_input`.
|
|
40
52
|
|
|
41
53
|
```ts
|
|
42
54
|
import { randomUUID } from 'node:crypto';
|
|
@@ -50,6 +62,11 @@ const operationId = randomUUID();
|
|
|
50
62
|
const operation = await client.beginAddOpenAi(operationId);
|
|
51
63
|
// Keep operation.id to discover its final result after navigation or reconnect.
|
|
52
64
|
const receipt = await client.getOperation(operation.id);
|
|
65
|
+
// Or follow it without polling: each answer comes when the operation changes.
|
|
66
|
+
let current = operation;
|
|
67
|
+
while (['starting', 'pending', 'committing'].includes(current.state)) {
|
|
68
|
+
current = await client.watchOperation(current.id, current.revision);
|
|
69
|
+
}
|
|
53
70
|
```
|
|
54
71
|
|
|
55
72
|
Browser code can import the credential-free DTOs from
|
|
@@ -64,7 +81,11 @@ never create one. A backend binds an account to a runtime incarnation by exact a
|
|
|
64
81
|
purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the
|
|
65
82
|
exact login; an account's presentation default never selects a grant for either action. Completed and
|
|
66
83
|
interrupted operations remain discoverable through `getOperation()` and paged
|
|
67
|
-
`listOperations()`.
|
|
84
|
+
`listOperations()`. `watchOperation(id, afterRevision, waitMs = 30000)` is the same read as a long poll,
|
|
85
|
+
the way `events` waits: it answers as soon as the operation's revision passes `afterRevision` -- a sign-in
|
|
86
|
+
answers `starting` with no prompt, then `pending` with its device prompt, then its outcome -- at once for a
|
|
87
|
+
finished operation, and with the unchanged operation once `waitMs` (at most 30000) runs out. Only a change
|
|
88
|
+
of that operation answers it. A completed receipt and its account/grant change commit together, so
|
|
68
89
|
the receipt resolves a lost response or an event that arrives first. Add and reauthentication
|
|
69
90
|
callers supply one UUID for the logical start and reuse it after a lost response. Replaying that
|
|
70
91
|
UUID returns the same matching receipt without starting another provider login; using it for a
|
|
@@ -75,16 +96,29 @@ resolves that binding to a current access token; its directory can be mounted in
|
|
|
75
96
|
container without exposing the management socket. A provider that rejects a specific
|
|
76
97
|
access-token generation can request a newer one through `rejectedGrantGeneration`; the
|
|
77
98
|
authority coalesces concurrent rejection callbacks and never replays an uncertain refresh.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
runtime
|
|
99
|
+
A ChatGPT login backs a Claude Code runtime too (`runtime: 'claude'`); a Claude account never binds,
|
|
100
|
+
because it reaches Claude Code through the native switch. After an external Flex, OpenCode or Claude
|
|
101
|
+
runtime has fenced new work and drained its pending access resolutions and provider requests, it can
|
|
102
|
+
release its exact binding capability through the runtime socket. Every bind receives a fresh random capability, so a stale release cannot fence
|
|
81
103
|
or delete a successor even if the caller reused an incarnation label. Release waits for admitted
|
|
82
104
|
server-side resolution handlers, but handler completion does not prove response delivery and
|
|
83
105
|
cannot retract an access token already received or in use; the runtime owner therefore owns that
|
|
84
106
|
drain. Generic release cannot revoke a managed Codex binding, whose daemon-owned stop still closes
|
|
85
107
|
the runtime and clears its durable run before internal revocation. Binding release never transfers
|
|
86
|
-
or changes ownership of the account grant.
|
|
87
|
-
a
|
|
108
|
+
or changes ownership of the account grant. A holder that crashed and lost its capability recovers
|
|
109
|
+
its binding without a new route: the snapshot (and `getBinding`) publishes each binding's runtime and
|
|
110
|
+
scope, binding that runtime and scope again replaces the capability, and releasing the returned
|
|
111
|
+
capability releases the binding. If binding again is refused because the account needs a new sign-in,
|
|
112
|
+
reauthenticate it first.
|
|
113
|
+
|
|
114
|
+
`getBinding(bindingId)` (`authswitch.authority.binding`) reads one binding by the id `bind` returned,
|
|
115
|
+
credential-free, or `null` once the authority holds none by that id, so a backend checks its binding without
|
|
116
|
+
reading the whole snapshot.
|
|
117
|
+
|
|
118
|
+
Removing an account that still backs a Flex, OpenCode or Claude binding is refused with `account_busy`, naming
|
|
119
|
+
those runtimes: their holder stops them and releases the bindings first, so a removal never strands a
|
|
120
|
+
runtime whose next access would fail. Managed Codex bindings are the daemon's own; removal drains managed
|
|
121
|
+
Codex as before and then takes them with the account.
|
|
88
122
|
|
|
89
123
|
Every route on either socket answers in one of three ways: a result, a **refusal**, or a fault. A refusal
|
|
90
124
|
is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the
|
|
@@ -99,7 +133,8 @@ that marker: it answers `{ code, instruction }` for a refusal and `null` for a f
|
|
|
99
133
|
closed `TAuthSwitchRefusalCode` set, which is what a client branches on without reading the text:
|
|
100
134
|
`authority_closing`, `not_found`, `account_changed`, `account_busy`, `login_unavailable`,
|
|
101
135
|
`binding_unauthorized`, `native_owner_holds_login`, `claude_home_unregistered`, `claude_handoff_pending`,
|
|
102
|
-
`claude_receipt_missing
|
|
136
|
+
`claude_receipt_missing`, `import_refusal`, `codex_unsupported`, `invalid_input`, `login_needs_reauth` and
|
|
137
|
+
`access_not_fresh`, plus the two the legacy commands answer with on this side of
|
|
103
138
|
the socket, `authority_holds_login` and `authority_unavailable` (see "The legacy fence" below). The
|
|
104
139
|
instruction is authored for the owner and contains no path, credential or digest; only an importer refusal
|
|
105
140
|
may name the process the owner has to stop. The set is producer-owned: a consumer branches on the codes it
|
|
@@ -112,10 +147,12 @@ credential was being resolved. Those are one answer on purpose: an absent bindin
|
|
|
112
147
|
compared against the same zero hash, so nothing distinguishes them, and the repair is the same either way --
|
|
113
148
|
bind again. A binding whose account has since been reauthorized reads `login_unavailable` instead, because
|
|
114
149
|
binding again cannot help until that account holds a login again. A device sign-in or preuse operation this
|
|
115
|
-
authority does not hold reads `not_found`, from whichever route met it.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
150
|
+
authority does not hold reads `not_found`, from whichever route met it. What the access loop decides about
|
|
151
|
+
the login itself is marked as well: `login_needs_reauth` when only a new device sign-in brings the login back
|
|
152
|
+
(after which a bound runtime binds again), and `access_not_fresh` when the login is intact but the provider
|
|
153
|
+
could not renew its access yet, so the same request can succeed later. A changed identity or a view that
|
|
154
|
+
keeps moving during resolution stays an unmarked fault, because nobody decided it. Binding an *account* this
|
|
155
|
+
authority does not hold at all stays one too: the storage layer throws `Account is not available for this runtime.`,
|
|
119
156
|
which a socket caller reads as `Internal server error`, because naming it is a decision about which layer
|
|
120
157
|
authors owner instructions rather than a wording fix.
|
|
121
158
|
|
|
@@ -128,6 +165,15 @@ and the ledger row it wrote says where that source stands -- while an unmarked f
|
|
|
128
165
|
unknown, and that source must then be read with `authswitch.authority.import.status` rather than
|
|
129
166
|
submitted again.
|
|
130
167
|
|
|
168
|
+
A daemon that is shutting down admits no new request -- every route answers `authority_closing` -- and
|
|
169
|
+
settles the requests it already admitted before it closes its store: a waiting `events` or
|
|
170
|
+
`watchOperation` read answers normally from the open store instead of failing.
|
|
171
|
+
|
|
172
|
+
A client reading a daemon that runs an older release -- one not restarted after an upgrade -- fails with
|
|
173
|
+
`AuthSwitchDaemonOutdatedError`, which names the restart (`authswitch authority service stop`, then
|
|
174
|
+
`start`), and a subscription reports `unavailable` with the reason `daemon_outdated` once and retries every
|
|
175
|
+
five seconds until the daemon is restarted.
|
|
176
|
+
|
|
131
177
|
`AuthSwitchClient.subscribe(onSnapshot, onEvent, signal, { onStatus })` reports `current`
|
|
132
178
|
after a fresh snapshot or verified heartbeat, `unavailable` on disconnect or resync, and
|
|
133
179
|
`closed` on abort. Keep cached account actions disabled while status is not `current`.
|
|
@@ -293,7 +339,9 @@ that refusal nor "already imported" ever clears: no route forgets a row or re-po
|
|
|
293
339
|
|
|
294
340
|
`import status` reads the migration ledger, the grants and the handoffs -- never the host -- and reports, per
|
|
295
341
|
source, where it stands, which owner refreshes its login now, and what to do next: nothing, submit the same
|
|
296
|
-
source again, or sign in to that account again.
|
|
342
|
+
source again, or sign in to that account again. Each entry also carries the source's `sourcePathHash`, so a
|
|
343
|
+
backend that submitted an external source finds its own record by `sourceKind` and that hash -- also after a
|
|
344
|
+
submit whose outcome it never learned -- without deriving the ledger row id. Submitting a source again is how an interrupted run resumes,
|
|
297
345
|
and the answer comes from the same durable record the daemon decides on: a source whose sign-in was in
|
|
298
346
|
flight when the run died is reported as needing a device sign-in, not as resumable, because a rotating
|
|
299
347
|
refresh token is never sent a second time. Sources that were never submitted have no ledger row and appear
|
|
@@ -339,7 +387,7 @@ generated prose and credentials are absent from persistence and management respo
|
|
|
339
387
|
removal and targeted reauthentication refuse while that account has an active preuse request.
|
|
340
388
|
|
|
341
389
|
A trusted in-process daemon host can call `AuthSwitchAuthorityDaemon.startManagedCodex()`
|
|
342
|
-
for a bound account and workspace. It owns a private Codex
|
|
390
|
+
for a bound account and workspace. It owns a private Codex app-server, passes only
|
|
343
391
|
the authority's current access token through Codex's external-token login, answers its
|
|
344
392
|
unauthorized callback through the authority, and revokes the binding on stop. The returned
|
|
345
393
|
runtime exposes an interactive `codex --remote unix://…` launch descriptor and native
|
|
@@ -363,13 +411,23 @@ directory other users cannot rewrite, and the socket behind it is the user's own
|
|
|
363
411
|
directory of the user's; anything else fails the start with that package's error. Codex keeps a
|
|
364
412
|
zero-byte startup lock in that directory for each socket path it has served; the file is
|
|
365
413
|
Codex's own and authswitch does not remove it.
|
|
366
|
-
Managed Codex runs
|
|
367
|
-
|
|
368
|
-
|
|
414
|
+
Managed Codex runs Codex 0.156.0 or newer -- the floor `@modelprofile.com/mcp-crossharness`'s
|
|
415
|
+
`requireCodexVersion` checks, the first release that publishes the socket alias -- and checks the
|
|
416
|
+
contract at every start instead of pinning one release. Before anything is recorded or started, the
|
|
417
|
+
`codex` it will run must report a readable version at or above the floor, and its own protocol
|
|
418
|
+
description (`codex app-server generate-json-schema`) must still offer every surface managed Codex uses:
|
|
419
|
+
the requests `account/login/start`, `thread/start`, `thread/resume` and `turn/start`; the external
|
|
420
|
+
ChatGPT token login with the fields it sends; the `account/chatgptAuthTokens/refresh` callback with the
|
|
421
|
+
`unauthorized` reason and the reply it gives; and the thread and turn parameters it sends. After
|
|
422
|
+
connecting, the app-server must report exactly the version that was checked, because Codex can replace
|
|
423
|
+
itself between the check and the start. Each failure is the `codex_unsupported` refusal naming the
|
|
424
|
+
version or the missing surface, so a Codex update that drops something managed Codex relies on stops new
|
|
425
|
+
managed sessions by name instead of failing one later. A prerelease above the floor runs; a prerelease of
|
|
426
|
+
0.156.0 itself is below it.
|
|
369
427
|
Managed-Codex process ownership observation is qualified on Linux and reads exact argument
|
|
370
428
|
boundaries from `/proc`; on other hosts doctor reports the process observation as unknown,
|
|
371
429
|
and authority actions that require survivor proof refuse instead of guessing.
|
|
372
|
-
Codex 0.157
|
|
430
|
+
Codex (verified through 0.157) does not persist an unused thread created by `thread/start`; resume applies
|
|
373
431
|
after Codex has committed a rollout. The existing CLI has not yet been moved to this path.
|
|
374
432
|
|
|
375
433
|
This SDK is additive at this stage. The commands documented below still use the existing
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/authority-contract.ts
CHANGED
|
@@ -25,7 +25,15 @@ export type TAuthSwitchRefusalCode =
|
|
|
25
25
|
| 'claude_home_unregistered'
|
|
26
26
|
| 'claude_handoff_pending'
|
|
27
27
|
| 'claude_receipt_missing'
|
|
28
|
-
| 'import_refusal'
|
|
28
|
+
| 'import_refusal'
|
|
29
|
+
/** The Codex on this host is older than managed Codex runs, or no longer offers a surface it uses. */
|
|
30
|
+
| 'codex_unsupported'
|
|
31
|
+
/** A value the owner typed breaks a rule the instruction states, such as an account label. */
|
|
32
|
+
| 'invalid_input'
|
|
33
|
+
/** The account's login has ended and only a new device sign-in brings it back. */
|
|
34
|
+
| 'login_needs_reauth'
|
|
35
|
+
/** The login is intact, but the provider could not renew its access yet; the same request can succeed later. */
|
|
36
|
+
| 'access_not_fresh';
|
|
29
37
|
|
|
30
38
|
/** The marker the importer has published since 8.1.0; it keeps its own value on the wire. */
|
|
31
39
|
export const authSwitchImportRefusalMarker = 'authswitch_import_refusal';
|
|
@@ -50,7 +58,8 @@ const refusalCodes: ReadonlySet<string> = new Set<TAuthSwitchRefusalCode>([
|
|
|
50
58
|
'authority_closing', 'not_found', 'account_changed', 'account_busy',
|
|
51
59
|
'login_unavailable', 'binding_unauthorized', 'native_owner_holds_login', 'claude_home_unregistered',
|
|
52
60
|
'claude_handoff_pending', 'claude_receipt_missing', 'import_refusal',
|
|
53
|
-
'authority_holds_login', 'authority_unavailable',
|
|
61
|
+
'authority_holds_login', 'authority_unavailable', 'codex_unsupported', 'invalid_input',
|
|
62
|
+
'login_needs_reauth', 'access_not_fresh',
|
|
54
63
|
]);
|
|
55
64
|
|
|
56
65
|
const isRefusalCode = (value: unknown): value is TAuthSwitchRefusalCode =>
|
|
@@ -102,6 +111,18 @@ export const asAuthSwitchRefusal = (error: unknown): IAuthSwitchRefusal | null =
|
|
|
102
111
|
return { code, instruction };
|
|
103
112
|
};
|
|
104
113
|
|
|
114
|
+
/** The longest account label, in UTF-16 code units. */
|
|
115
|
+
export const authSwitchAccountLabelMaxLength = 128;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The account label rule, the one the authority applies to a rename: one to `authSwitchAccountLabelMaxLength`
|
|
119
|
+
* characters, no space at either end, no control character. A consumer validates with this rather than
|
|
120
|
+
* restating it; the authority answers a label that breaks it with the `invalid_input` refusal.
|
|
121
|
+
*/
|
|
122
|
+
export const isAuthSwitchAccountLabel = (value: unknown): value is string => typeof value === 'string'
|
|
123
|
+
&& value.trim() === value && value.length > 0 && value.length <= authSwitchAccountLabelMaxLength
|
|
124
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
125
|
+
|
|
105
126
|
/** Credential-free account management contract. Safe to import in browser code. */
|
|
106
127
|
export type TAuthSwitchLoginPurpose = 'openai_managed' | 'claude_host_native' | 'claude_container_setup' | 'opencode_native';
|
|
107
128
|
export type TAuthSwitchLoginHealth = 'ready' | 'refreshing' | 'retry_wait' | 'needs_reauth' | 'unverified' | 'pending_handoff' | 'handoff_quarantined' | 'removed';
|
|
@@ -149,12 +170,35 @@ export interface IAuthSwitchLogin {
|
|
|
149
170
|
export interface IAuthSwitchBinding {
|
|
150
171
|
id: string;
|
|
151
172
|
accountId: string;
|
|
173
|
+
/**
|
|
174
|
+
* The runtime an OpenAI (ChatGPT) login backs. `flex`, `opencode` and `claude` bindings are held and
|
|
175
|
+
* released by their caller over the runtime socket; `codex` is the daemon's own managed Codex. A Claude
|
|
176
|
+
* account never binds: it reaches Claude Code through the native switch.
|
|
177
|
+
*/
|
|
152
178
|
runtime: 'flex' | 'codex' | 'opencode' | 'claude';
|
|
153
179
|
scopeId: string;
|
|
154
180
|
incarnationId: string;
|
|
155
181
|
revision: number;
|
|
156
182
|
}
|
|
157
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Which account a vendor tool's own home on this host runs on. Credential-free: the home is named by the
|
|
186
|
+
* hash the authority registered it under, never by its path.
|
|
187
|
+
*
|
|
188
|
+
* Only homes the authority itself switches are recorded: Claude Code homes adopted by a verified import.
|
|
189
|
+
* A Codex or OpenCode store that a native tool still refreshes appears as its login instead, with
|
|
190
|
+
* `owner: 'legacy_native'` and its `ownerTool`, because the authority does not decide what that store holds.
|
|
191
|
+
*/
|
|
192
|
+
export interface IAuthSwitchNativeAssignment {
|
|
193
|
+
id: string;
|
|
194
|
+
tool: TAuthSwitchLoginOwnerTool;
|
|
195
|
+
accountId: string;
|
|
196
|
+
loginId: string;
|
|
197
|
+
/** `switching` while a handoff to another account is in flight; `quarantined` until it is resolved. */
|
|
198
|
+
state: 'ready' | 'switching' | 'quarantined';
|
|
199
|
+
revision: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
158
202
|
export interface IAuthSwitchSnapshot {
|
|
159
203
|
schemaVersion: 2;
|
|
160
204
|
epoch: string;
|
|
@@ -163,9 +207,11 @@ export interface IAuthSwitchSnapshot {
|
|
|
163
207
|
accounts: IAuthSwitchAccount[];
|
|
164
208
|
logins: IAuthSwitchLogin[];
|
|
165
209
|
bindings: IAuthSwitchBinding[];
|
|
210
|
+
nativeAssignments: IAuthSwitchNativeAssignment[];
|
|
166
211
|
nextAccountCursor: string | null;
|
|
167
212
|
nextLoginCursor: string | null;
|
|
168
213
|
nextBindingCursor: string | null;
|
|
214
|
+
nextNativeAssignmentCursor: string | null;
|
|
169
215
|
}
|
|
170
216
|
|
|
171
217
|
/** Persisted account evidence. No provider request is made while collecting diagnostics. */
|
|
@@ -391,7 +437,13 @@ export interface IReq_AuthSwitchClaudeNativeHandoffs extends ITypedRequest {
|
|
|
391
437
|
|
|
392
438
|
export interface IReq_AuthSwitchSnapshot extends ITypedRequest {
|
|
393
439
|
method: 'authswitch.authority.snapshot';
|
|
394
|
-
request: { accountAfter?: string; loginAfter?: string; bindingAfter?: string;
|
|
440
|
+
request: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; nativeAssignmentAfter?: string;
|
|
441
|
+
limit?: number;
|
|
442
|
+
/**
|
|
443
|
+
* Also publish removed accounts (`removed: true`) and their removed logins (`health: 'removed'`), so a
|
|
444
|
+
* consumer can show them apart. Absent or false keeps the snapshot to what is live.
|
|
445
|
+
*/
|
|
446
|
+
includeRemoved?: boolean };
|
|
395
447
|
response: { snapshot: IAuthSwitchSnapshot };
|
|
396
448
|
}
|
|
397
449
|
|
|
@@ -441,9 +493,14 @@ export interface IReq_AuthSwitchListOperations extends ITypedRequest {
|
|
|
441
493
|
response: { operations: IAuthSwitchOperation[]; nextCursor: string | null };
|
|
442
494
|
}
|
|
443
495
|
|
|
496
|
+
/**
|
|
497
|
+
* One device sign-in. `afterRevision` and `waitMs` come together or not at all: with them the read is a long
|
|
498
|
+
* poll that answers once the operation's revision passes `afterRevision`, at once for a finished sign-in, or
|
|
499
|
+
* with the unchanged operation after `waitMs` (at most 30000).
|
|
500
|
+
*/
|
|
444
501
|
export interface IReq_AuthSwitchGetOperation extends ITypedRequest {
|
|
445
502
|
method: 'authswitch.authority.operation';
|
|
446
|
-
request: { operationId: string };
|
|
503
|
+
request: { operationId: string; afterRevision?: number; waitMs?: number };
|
|
447
504
|
response: { operation: IAuthSwitchOperation };
|
|
448
505
|
}
|
|
449
506
|
|
|
@@ -478,6 +535,16 @@ export interface IReq_AuthSwitchRenameAccount extends ITypedRequest {
|
|
|
478
535
|
response: { account: IAuthSwitchAccount };
|
|
479
536
|
}
|
|
480
537
|
|
|
538
|
+
/**
|
|
539
|
+
* One binding by the id `bind` returned, credential-free, or `null` when this authority holds no binding by
|
|
540
|
+
* that id. A backend checks that its binding still stands without reading the whole snapshot.
|
|
541
|
+
*/
|
|
542
|
+
export interface IReq_AuthSwitchGetBinding extends ITypedRequest {
|
|
543
|
+
method: 'authswitch.authority.binding';
|
|
544
|
+
request: { bindingId: string };
|
|
545
|
+
response: { binding: IAuthSwitchBinding | null };
|
|
546
|
+
}
|
|
547
|
+
|
|
481
548
|
export interface IReq_AuthSwitchRemoveAccount extends ITypedRequest {
|
|
482
549
|
method: 'authswitch.authority.remove';
|
|
483
550
|
request: { accountId: string; expectedRevision: number };
|
|
@@ -173,6 +173,12 @@ export const isAuthSwitchImportRefusal = (error: unknown): boolean =>
|
|
|
173
173
|
export interface IAuthSwitchImportStatusEntry {
|
|
174
174
|
sourceId: string;
|
|
175
175
|
sourceKind: TAuthSwitchImportSourceKind;
|
|
176
|
+
/**
|
|
177
|
+
* The source location as a hash, exactly as it was submitted or inventoried. A backend that submits an
|
|
178
|
+
* external source finds its own record here by `sourceKind` and this hash, including after a submit whose
|
|
179
|
+
* outcome it never learned.
|
|
180
|
+
*/
|
|
181
|
+
sourcePathHash: string;
|
|
176
182
|
status: TAuthSwitchImportLedgerStatus;
|
|
177
183
|
accountId: string | null;
|
|
178
184
|
loginId: string | null;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
|
|
3
|
-
IAuthSwitchOperation, IAuthSwitchSnapshot,
|
|
4
|
-
|
|
3
|
+
IAuthSwitchNativeAssignment, IAuthSwitchOperation, IAuthSwitchSnapshot, IReq_AuthSwitchSnapshot,
|
|
4
|
+
TAuthSwitchLoginOwnerTool } from './authority-contract.js';
|
|
5
|
+
import { AuthSwitchRefusal, isAuthSwitchAccountLabel } from './authority-contract.js';
|
|
5
6
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
6
|
-
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
|
|
7
|
+
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityClaudeHome, IStoredAuthorityGrant,
|
|
7
8
|
IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
|
|
8
9
|
import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
|
|
9
10
|
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
@@ -41,8 +42,6 @@ const sameUsageAuthority = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsa
|
|
|
41
42
|
const isUuid = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
|
|
42
43
|
const deviceOperationTerminal = (operation: IStoredAuthorityDeviceOperation): boolean =>
|
|
43
44
|
!['starting', 'pending', 'committing'].includes(operation.state);
|
|
44
|
-
const safeLabel = (value: unknown): value is string => typeof value === 'string' && value.trim() === value
|
|
45
|
-
&& value.length > 0 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
46
45
|
const safeScope = (value: unknown, maximum: number): value is string => typeof value === 'string'
|
|
47
46
|
&& value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
|
|
48
47
|
const validRevision = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
|
@@ -70,6 +69,10 @@ const bindingMoved = 'This runtime binding changed while its credential was bein
|
|
|
70
69
|
+ 'account again.';
|
|
71
70
|
const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
|
|
72
71
|
+ 'Reauthenticate the account, then bind it again.';
|
|
72
|
+
const loginNeedsReauth = 'This account\'s sign-in has ended. Reauthenticate the account with a new device '
|
|
73
|
+
+ 'sign-in; a runtime bound to it binds again afterwards.';
|
|
74
|
+
const accessNotFresh = 'The provider could not renew this account\'s access yet. Try the request again '
|
|
75
|
+
+ 'shortly; the authority keeps retrying the renewal.';
|
|
73
76
|
const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
|
|
74
77
|
id: account.id, providerId: account.providerId, label: account.label, email: account.email,
|
|
75
78
|
plan: account.plan, removed: account.removed, revision: account.revision,
|
|
@@ -133,6 +136,12 @@ const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding =>
|
|
|
133
136
|
scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
|
|
134
137
|
});
|
|
135
138
|
|
|
139
|
+
const publicClaudeAssignment = (home: IStoredAuthorityClaudeHome): IAuthSwitchNativeAssignment => ({
|
|
140
|
+
id: home.id, tool: 'claude_code', accountId: home.activeAccountId, loginId: home.activeGrantId,
|
|
141
|
+
state: home.status === 'quarantined' ? 'quarantined' : home.pendingOperationId !== null ? 'switching' : 'ready',
|
|
142
|
+
revision: home.revision,
|
|
143
|
+
});
|
|
144
|
+
|
|
136
145
|
interface IManagedOperation {
|
|
137
146
|
id: string;
|
|
138
147
|
handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
|
|
@@ -154,6 +163,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
154
163
|
private readonly operations = new Map<string, IManagedOperation>();
|
|
155
164
|
private readonly refreshes = new Map<string, Promise<void>>();
|
|
156
165
|
private readonly listeners = new Set<() => void>();
|
|
166
|
+
/** Operation long polls; woken by their operation's own changes, and all of them on close. */
|
|
167
|
+
private readonly operationWaiters = new Set<() => void>();
|
|
157
168
|
private claudeRefresh?: (grantId: string) => Promise<void>;
|
|
158
169
|
private maintenance?: Promise<void>;
|
|
159
170
|
private timer?: NodeJS.Timeout;
|
|
@@ -267,19 +278,23 @@ export class AuthSwitchAuthorityBroker {
|
|
|
267
278
|
for (const listener of this.listeners) listener();
|
|
268
279
|
}
|
|
269
280
|
|
|
270
|
-
public async snapshot(options:
|
|
281
|
+
public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}): Promise<IAuthSwitchSnapshot> {
|
|
271
282
|
if ((options.accountAfter !== undefined && !isId(options.accountAfter))
|
|
272
283
|
|| (options.loginAfter !== undefined && !isId(options.loginAfter))
|
|
273
|
-
|| (options.bindingAfter !== undefined && !isId(options.bindingAfter))
|
|
284
|
+
|| (options.bindingAfter !== undefined && !isId(options.bindingAfter))
|
|
285
|
+
|| (options.nativeAssignmentAfter !== undefined && !isId(options.nativeAssignmentAfter))) {
|
|
286
|
+
throw new Error('Invalid account snapshot cursor.');
|
|
287
|
+
}
|
|
274
288
|
const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
|
|
275
|
-
options.bindingAfter ?? null, options.limit ?? 128);
|
|
289
|
+
options.bindingAfter ?? null, options.nativeAssignmentAfter ?? null, options.limit ?? 128);
|
|
276
290
|
return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
|
|
277
291
|
generatedAt: new Date(this.now()).toISOString(),
|
|
278
|
-
accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
|
|
279
|
-
logins: page.grants.filter(grant => grant.state !== 'removed').map(publicLogin),
|
|
292
|
+
accounts: page.accounts.filter(account => options.includeRemoved || !account.removed).map(publicAccount),
|
|
293
|
+
logins: page.grants.filter(grant => options.includeRemoved || grant.state !== 'removed').map(publicLogin),
|
|
280
294
|
bindings: page.bindings.map(publicBinding),
|
|
295
|
+
nativeAssignments: page.claudeHomes.map(publicClaudeAssignment),
|
|
281
296
|
nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
|
|
282
|
-
nextBindingCursor: page.nextBindingCursor };
|
|
297
|
+
nextBindingCursor: page.nextBindingCursor, nextNativeAssignmentCursor: page.nextClaudeHomeCursor };
|
|
283
298
|
}
|
|
284
299
|
|
|
285
300
|
public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
|
|
@@ -554,11 +569,46 @@ export class AuthSwitchAuthorityBroker {
|
|
|
554
569
|
return publicOperation(pending);
|
|
555
570
|
}
|
|
556
571
|
|
|
557
|
-
|
|
572
|
+
/**
|
|
573
|
+
* One device sign-in. With `wait`, the read is a long poll, the way `events` waits: it answers at once when
|
|
574
|
+
* the operation's revision is past `afterRevision` or the sign-in has finished, and otherwise when the
|
|
575
|
+
* operation next changes or `waitMs` runs out, whichever is first -- so a caller follows the prompt and
|
|
576
|
+
* the outcome without polling. A timed-out wait answers with the unchanged operation.
|
|
577
|
+
*/
|
|
578
|
+
public async getOperation(operationId: string, wait?: { afterRevision: number; waitMs: number },
|
|
579
|
+
signal?: AbortSignal): Promise<IAuthSwitchOperation> {
|
|
558
580
|
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
581
|
+
if (wait !== undefined && (!validRevision(wait.afterRevision) || !Number.isSafeInteger(wait.waitMs)
|
|
582
|
+
|| wait.waitMs < 0 || wait.waitMs > 30_000)) throw new Error('Invalid operation wait.');
|
|
583
|
+
const read = async (): Promise<IStoredAuthorityDeviceOperation> => {
|
|
584
|
+
const operation = await this.database.readOperation(operationId);
|
|
585
|
+
if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
586
|
+
return operation;
|
|
587
|
+
};
|
|
588
|
+
const answers = (operation: IStoredAuthorityDeviceOperation): boolean => wait === undefined
|
|
589
|
+
|| operation.revision > wait.afterRevision || deviceOperationTerminal(operation);
|
|
590
|
+
const immediate = await read();
|
|
591
|
+
if (answers(immediate) || wait!.waitMs === 0 || this.closed || signal?.aborted) return publicOperation(immediate);
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
let finished = false;
|
|
594
|
+
const settle = (outcome: () => void) => {
|
|
595
|
+
if (finished) return;
|
|
596
|
+
finished = true;
|
|
597
|
+
clearTimeout(timer);
|
|
598
|
+
unobserve();
|
|
599
|
+
this.operationWaiters.delete(wake);
|
|
600
|
+
signal?.removeEventListener('abort', wake);
|
|
601
|
+
outcome();
|
|
602
|
+
};
|
|
603
|
+
const wake = () => settle(() => { void read().then(operation => resolve(publicOperation(operation)), reject); });
|
|
604
|
+
const timer = setTimeout(wake, wait!.waitMs);
|
|
605
|
+
const unobserve = this.database.observeDeviceOperations(changed => { if (changed === operationId) wake(); });
|
|
606
|
+
this.operationWaiters.add(wake);
|
|
607
|
+
signal?.addEventListener('abort', wake, { once: true });
|
|
608
|
+
// Close the read/register race without holding a database session for the wait.
|
|
609
|
+
void read().then(operation => { if (answers(operation)) wake(); },
|
|
610
|
+
error => settle(() => reject(error)));
|
|
611
|
+
});
|
|
562
612
|
}
|
|
563
613
|
|
|
564
614
|
public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
|
|
@@ -595,7 +645,11 @@ export class AuthSwitchAuthorityBroker {
|
|
|
595
645
|
}
|
|
596
646
|
|
|
597
647
|
public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
|
|
598
|
-
if (!isId(accountId) || !validRevision(expectedRevision)
|
|
648
|
+
if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
|
|
649
|
+
if (!isAuthSwitchAccountLabel(label)) {
|
|
650
|
+
throw new AuthSwitchRefusal('invalid_input', 'An account label has between one and one hundred and twenty-eight '
|
|
651
|
+
+ 'characters, with no space at either end and no control character. Choose another label.');
|
|
652
|
+
}
|
|
599
653
|
const updateId = plugins.crypto.randomUUID();
|
|
600
654
|
const updated = await this.database.changeAccount(updateId, accountId, account => {
|
|
601
655
|
if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
|
|
@@ -625,10 +679,11 @@ export class AuthSwitchAuthorityBroker {
|
|
|
625
679
|
const capabilityHash = idHash(capability);
|
|
626
680
|
const updateId = plugins.crypto.randomUUID();
|
|
627
681
|
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (existing, account, grant) => {
|
|
682
|
+
// A ChatGPT login backs every runtime, Claude Code included; a Claude account reaches Claude Code
|
|
683
|
+
// through its native switch instead, so it never binds (its provider is not OpenAI).
|
|
628
684
|
if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
|
|
629
685
|
|| grant.owner !== 'daemon'
|
|
630
|
-
|| grant.purpose !== input.purpose || account.providerId !== 'openai'
|
|
631
|
-
|| input.runtime === 'claude') {
|
|
686
|
+
|| grant.purpose !== input.purpose || account.providerId !== 'openai') {
|
|
632
687
|
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
633
688
|
}
|
|
634
689
|
return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
|
|
@@ -639,6 +694,12 @@ export class AuthSwitchAuthorityBroker {
|
|
|
639
694
|
return { binding: publicBinding(updated.binding), capability };
|
|
640
695
|
}
|
|
641
696
|
|
|
697
|
+
public async getBinding(bindingId: string): Promise<IAuthSwitchBinding | null> {
|
|
698
|
+
if (!isId(bindingId)) throw new Error('Invalid runtime binding.');
|
|
699
|
+
const binding = await this.database.readBinding(bindingId);
|
|
700
|
+
return binding ? publicBinding(binding) : null;
|
|
701
|
+
}
|
|
702
|
+
|
|
642
703
|
public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
|
|
643
704
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
|
|
644
705
|
const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
|
|
@@ -710,13 +771,12 @@ export class AuthSwitchAuthorityBroker {
|
|
|
710
771
|
}
|
|
711
772
|
|
|
712
773
|
/**
|
|
713
|
-
* The shared managed-access loop
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
* before this point, in the view `resolveAccess` supplies.
|
|
774
|
+
* The shared managed-access loop. What it decides about the login itself is a marked refusal both callers
|
|
775
|
+
* read the same way: `login_needs_reauth` when only a new sign-in brings the login back, `access_not_fresh`
|
|
776
|
+
* when the login is intact and the provider could not renew it yet. The usage reader still folds either
|
|
777
|
+
* into its own problem; a bound runtime shows the instruction. What only concerns a binding is decided
|
|
778
|
+
* before this point, in the view `resolveAccess` supplies, and a changed identity or a view that keeps
|
|
779
|
+
* moving stays an unmarked fault, because nobody decided it.
|
|
720
780
|
*/
|
|
721
781
|
private async resolveManagedAccess(readView: () => Promise<{
|
|
722
782
|
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
@@ -725,20 +785,20 @@ export class AuthSwitchAuthorityBroker {
|
|
|
725
785
|
const { account, grant } = await readView();
|
|
726
786
|
if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
|
|
727
787
|
|| account.providerId !== 'openai' || grant.providerId !== 'openai') {
|
|
728
|
-
throw new
|
|
788
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
729
789
|
}
|
|
730
790
|
if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
|
|
731
791
|
throw new Error('Provider rejected a future account grant generation.');
|
|
732
792
|
}
|
|
733
793
|
if (grant.state === 'exchange_may_have_been_sent') {
|
|
734
794
|
const inFlight = this.refreshes.get(account.id);
|
|
735
|
-
if (!inFlight) throw new
|
|
795
|
+
if (!inFlight) throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
736
796
|
await inFlight;
|
|
737
797
|
continue;
|
|
738
798
|
}
|
|
739
799
|
if (grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
|
|
740
800
|
|| !['ready', 'retry_wait'].includes(grant.state)) {
|
|
741
|
-
throw new
|
|
801
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
742
802
|
}
|
|
743
803
|
const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
|
|
744
804
|
if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
|
|
@@ -747,9 +807,9 @@ export class AuthSwitchAuthorityBroker {
|
|
|
747
807
|
rejectedCurrent ? rejectedGrantGeneration : undefined);
|
|
748
808
|
continue;
|
|
749
809
|
}
|
|
750
|
-
throw new
|
|
810
|
+
throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
751
811
|
}
|
|
752
|
-
if (!grant.accessExpiresAt) throw new
|
|
812
|
+
if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
753
813
|
const credential = await this.unsealCredential(account, grant);
|
|
754
814
|
const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
|
|
755
815
|
if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
|
|
@@ -767,7 +827,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
767
827
|
|| latest.grant.state === 'exchange_may_have_been_sent') continue;
|
|
768
828
|
if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
|
|
769
829
|
|| (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
|
|
770
|
-
throw new
|
|
830
|
+
throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
771
831
|
}
|
|
772
832
|
return { accessToken: credential.accessToken, accountId: account.workspaceId,
|
|
773
833
|
isFedrampAccount: info.chatgptAccountIsFedramp, expiresAt: grant.accessExpiresAt,
|
|
@@ -852,7 +912,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
852
912
|
});
|
|
853
913
|
this.publish();
|
|
854
914
|
} catch { /* A persisted attempt marker forces needs_reauth on daemon restart. */ }
|
|
855
|
-
|
|
915
|
+
// The grant is now `needs_reauth`: an uncertain rotation is never replayed, so only a sign-in repairs it.
|
|
916
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
856
917
|
}
|
|
857
918
|
}
|
|
858
919
|
|
|
@@ -861,6 +922,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
861
922
|
this.closed = true;
|
|
862
923
|
if (this.timer) clearTimeout(this.timer);
|
|
863
924
|
for (const wake of this.listeners) wake();
|
|
925
|
+
for (const wake of this.operationWaiters) wake();
|
|
864
926
|
this.closing = (async () => {
|
|
865
927
|
if (this.maintenance) await this.maintenance;
|
|
866
928
|
await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
|