@modelprofile.com/authswitch 9.1.0 → 9.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +4 -0
- package/dist_ts/authority-contract.js +1 -1
- package/dist_ts/authority-runtime-contract.d.ts +29 -0
- package/dist_ts/classes.authoritybroker.d.ts +16 -0
- package/dist_ts/classes.authoritybroker.js +28 -5
- package/dist_ts/classes.authorityclient.d.ts +6 -1
- package/dist_ts/classes.authorityclient.js +23 -6
- package/dist_ts/classes.authoritydaemon.d.ts +17 -0
- package/dist_ts/classes.authoritydaemon.js +43 -10
- package/dist_ts/classes.authoritydatabase.d.ts +8 -1
- package/dist_ts/classes.authoritydatabase.js +13 -5
- package/package.json +1 -1
- package/readme.md +35 -16
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +4 -0
- package/ts/authority-runtime-contract.ts +30 -0
- package/ts/classes.authoritybroker.ts +28 -4
- package/ts/classes.authorityclient.ts +21 -6
- package/ts/classes.authoritydaemon.ts +47 -10
- package/ts/classes.authoritydatabase.ts +14 -5
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -73,12 +73,13 @@ Browser code can import the credential-free DTOs from
|
|
|
73
73
|
`@modelprofile.com/authswitch/authority-contract`. The backend-only binding, access and release
|
|
74
74
|
requests are named by `@modelprofile.com/authswitch/authority-runtime-contract`, and the one-time
|
|
75
75
|
import's own DTOs by `@modelprofile.com/authswitch/authority-import-contract`; those capabilities must
|
|
76
|
-
never reach a browser. Two of the
|
|
77
|
-
access and releasing a binding -- while `authswitch.authority.bind`
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
never reach a browser. Two of the four runtime requests are served on the runtime socket -- resolving
|
|
77
|
+
access and releasing a binding -- while `authswitch.authority.bind` and its inverse
|
|
78
|
+
`authswitch.authority.unbind` are served on the **management** socket by design: minting a capability is the
|
|
79
|
+
act that authorizes a runtime, so only a trusted management caller may perform it, and a container that is
|
|
80
|
+
given the runtime directory can use and release a binding it was handed but can never create one or
|
|
81
|
+
release one it was not handed. A backend binds an account to a runtime incarnation by exact account ID,
|
|
82
|
+
login ID and purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the
|
|
82
83
|
exact login; an account's presentation default never selects a grant for either action. Completed and
|
|
83
84
|
interrupted operations remain discoverable through `getOperation()` and paged
|
|
84
85
|
`listOperations()`. `watchOperation(id, afterRevision, waitMs = 30000)` is the same read as a long poll,
|
|
@@ -91,8 +92,14 @@ callers supply one UUID for the logical start and reuse it after a lost response
|
|
|
91
92
|
UUID returns the same matching receipt without starting another provider login; using it for a
|
|
92
93
|
different target is rejected. Cancelling a durable `starting` receipt prevents a late provider
|
|
93
94
|
handle from promoting or overwriting it. Aborting a client request only detaches that request and
|
|
94
|
-
does not cancel the daemon-owned login.
|
|
95
|
-
|
|
95
|
+
does not cancel the daemon-owned login. A client that disconnects before its answer -- a long poll it
|
|
96
|
+
stopped reading, or one the daemon's shutdown settles after the client went away -- ends only its own
|
|
97
|
+
connection: the daemon drops that request's pending work and keeps serving every other client. Each
|
|
98
|
+
request is bounded by its own deadline -- 35 s unless its route states another, 120 s for an import submit,
|
|
99
|
+
the wait plus 5 s for a long poll -- and nothing in the transport ends it sooner: the daemon gives a
|
|
100
|
+
connection 35 s from acceptance to deliver its complete request, however it trickles in, and sets no limit
|
|
101
|
+
once it is admitted.
|
|
102
|
+
The runtime-only socket resolves that binding to a current access token; its directory can be mounted into a
|
|
96
103
|
container without exposing the management socket. A provider that rejects a specific
|
|
97
104
|
access-token generation can request a newer one through `rejectedGrantGeneration`; the
|
|
98
105
|
authority coalesces concurrent rejection callbacks and never replays an uncertain refresh.
|
|
@@ -105,20 +112,32 @@ server-side resolution handlers, but handler completion does not prove response
|
|
|
105
112
|
cannot retract an access token already received or in use; the runtime owner therefore owns that
|
|
106
113
|
drain. Generic release cannot revoke a managed Codex binding, whose daemon-owned stop still closes
|
|
107
114
|
the runtime and clears its durable run before internal revocation. Binding release never transfers
|
|
108
|
-
or changes ownership of the account grant.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
or changes ownership of the account grant.
|
|
116
|
+
|
|
117
|
+
A holder that lost its capability -- a binding an earlier run of it left behind -- releases it with
|
|
118
|
+
`unbind({ runtime, scopeId, incarnationId, expectedRevision })` (`authswitch.authority.unbind`), naming the
|
|
119
|
+
binding by the identity it bound it under, as the snapshot and `getBinding` publish it. Unbinding needs no
|
|
120
|
+
ready login, so the leftover bindings of an account whose sign-in has ended are released, and the account
|
|
121
|
+
removed, without signing in again. It is authorized exactly as `bind` is: the management socket is the trust
|
|
122
|
+
boundary, and a runtime and scope are the namespace a holder binds under and owns -- AGL, for example, binds
|
|
123
|
+
under its own installation's scopes -- so a holder names only its own. The daemon releases only the binding
|
|
124
|
+
at exactly that runtime and scope, and only while it is still the incarnation and revision the holder read:
|
|
125
|
+
another scope, another runtime, or a successor bound in the meantime is left alone, and the answer is
|
|
126
|
+
`inactive`, as it is for a binding already released. A binding's revision is the authority revision of the
|
|
127
|
+
bind that wrote it, so it never repeats for a runtime and scope, even after a release: a repeated or stale
|
|
128
|
+
unbind never names a successor bound under the same incarnation. Its capability is fenced and released exactly as the
|
|
129
|
+
runtime release would, so admitted access resolutions finish first. Managed Codex bindings are never named
|
|
130
|
+
here. While the login is ready, a holder can instead bind the same runtime and scope again, which replaces
|
|
131
|
+
the capability, and release the returned one.
|
|
113
132
|
|
|
114
133
|
`getBinding(bindingId)` (`authswitch.authority.binding`) reads one binding by the id `bind` returned,
|
|
115
134
|
credential-free, or `null` once the authority holds none by that id, so a backend checks its binding without
|
|
116
135
|
reading the whole snapshot.
|
|
117
136
|
|
|
118
137
|
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
|
|
120
|
-
runtime whose next access would fail. Managed Codex
|
|
121
|
-
Codex as before and then takes them with the account.
|
|
138
|
+
those runtimes: their holder stops them and releases the bindings first -- by capability, or by `unbind` when
|
|
139
|
+
it no longer holds one -- so a removal never strands a runtime whose next access would fail. Managed Codex
|
|
140
|
+
bindings are the daemon's own; removal drains managed Codex as before and then takes them with the account.
|
|
122
141
|
|
|
123
142
|
Every route on either socket answers in one of three ways: a result, a **refusal**, or a fault. A refusal
|
|
124
143
|
is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/authority-contract.ts
CHANGED
|
@@ -178,6 +178,10 @@ export interface IAuthSwitchBinding {
|
|
|
178
178
|
runtime: 'flex' | 'codex' | 'opencode' | 'claude';
|
|
179
179
|
scopeId: string;
|
|
180
180
|
incarnationId: string;
|
|
181
|
+
/**
|
|
182
|
+
* The authority revision at which this runtime and scope were last bound. It grows with every bind and never
|
|
183
|
+
* repeats for them, not even after a release and a new bind, so it names exactly one bind.
|
|
184
|
+
*/
|
|
181
185
|
revision: number;
|
|
182
186
|
}
|
|
183
187
|
|
|
@@ -25,6 +25,36 @@ export interface IReq_AuthSwitchBindAccount extends ITypedRequest {
|
|
|
25
25
|
response: { binding: IAuthSwitchBinding; capability: string };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Backend-only release of a binding by the identity its holder bound it under, served on the MANAGEMENT
|
|
30
|
+
* socket beside `bind`, of which it is the inverse.
|
|
31
|
+
*
|
|
32
|
+
* It is how a holder releases a binding it no longer holds the capability for -- one an earlier run of it
|
|
33
|
+
* left behind -- and it needs no ready login: an account whose sign-in has ended can have its leftover
|
|
34
|
+
* bindings released, and then be removed, without signing in again first. It is authorized as `bind` is: a
|
|
35
|
+
* caller trusted with the management socket names a runtime and scope, the namespace a holder binds under
|
|
36
|
+
* and owns. It releases only the binding at exactly that runtime and scope, and only while it is still the
|
|
37
|
+
* incarnation and revision the holder read from the snapshot or `getBinding`; any other binding -- another
|
|
38
|
+
* scope, another runtime, or a successor bound meanwhile -- is left alone and the answer is `inactive`.
|
|
39
|
+
* A binding's revision names one bind and never repeats for its runtime and scope, so a successor is told
|
|
40
|
+
* apart even when it was bound under the same incarnation after the one read was released.
|
|
41
|
+
* The daemon's own managed Codex bindings are never named here. Like the runtime release, it waits for
|
|
42
|
+
* the access resolutions already admitted for that binding.
|
|
43
|
+
*/
|
|
44
|
+
export interface IReq_AuthSwitchUnbind extends ITypedRequest {
|
|
45
|
+
method: 'authswitch.authority.unbind';
|
|
46
|
+
request: {
|
|
47
|
+
runtime: Exclude<IAuthSwitchBinding['runtime'], 'codex'>;
|
|
48
|
+
scopeId: string;
|
|
49
|
+
incarnationId: string;
|
|
50
|
+
/** The binding's `revision` as the holder read it. */
|
|
51
|
+
expectedRevision: number;
|
|
52
|
+
};
|
|
53
|
+
response: {
|
|
54
|
+
state: 'released' | 'inactive';
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
/** Backend-only access operation. Refresh grants never cross this interface. */
|
|
29
59
|
export interface IReq_AuthSwitchResolveAccess extends ITypedRequest {
|
|
30
60
|
method: 'authswitch.authority.resolveAccess';
|
|
@@ -678,7 +678,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
678
678
|
const capability = plugins.crypto.randomBytes(32).toString('base64url');
|
|
679
679
|
const capabilityHash = idHash(capability);
|
|
680
680
|
const updateId = plugins.crypto.randomUUID();
|
|
681
|
-
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (
|
|
681
|
+
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (_existing, account, grant, revision) => {
|
|
682
682
|
// A ChatGPT login backs every runtime, Claude Code included; a Claude account reaches Claude Code
|
|
683
683
|
// through its native switch instead, so it never binds (its provider is not OpenAI).
|
|
684
684
|
if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
|
|
@@ -687,7 +687,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
687
687
|
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
688
688
|
}
|
|
689
689
|
return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
|
|
690
|
-
incarnationId: input.incarnationId, revision
|
|
690
|
+
incarnationId: input.incarnationId, revision,
|
|
691
691
|
grantAuthorizationGeneration: grant.authorizationGeneration, capabilityHash, updateId };
|
|
692
692
|
});
|
|
693
693
|
this.publish();
|
|
@@ -709,13 +709,37 @@ export class AuthSwitchAuthorityBroker {
|
|
|
709
709
|
}
|
|
710
710
|
|
|
711
711
|
public async releaseExternalBinding(bindingId: string, capability: string): Promise<'released' | 'inactive'> {
|
|
712
|
-
if (
|
|
712
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
|
|
713
|
+
return this.releaseExternalCapability(bindingId, authSwitchBindingCapabilityHash(capability));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Releases a caller-held binding while it still carries exactly this capability, named by its hash. */
|
|
717
|
+
public async releaseExternalCapability(bindingId: string, capabilityHash: string): Promise<'released' | 'inactive'> {
|
|
718
|
+
if (!isId(bindingId) || !isId(capabilityHash)) throw new Error('Invalid runtime binding release.');
|
|
713
719
|
const released = await this.database.releaseExternalBinding(plugins.crypto.randomUUID(), bindingId,
|
|
714
|
-
|
|
720
|
+
capabilityHash);
|
|
715
721
|
if (released) this.publish();
|
|
716
722
|
return released ? 'released' : 'inactive';
|
|
717
723
|
}
|
|
718
724
|
|
|
725
|
+
/**
|
|
726
|
+
* The caller-held binding a holder names by the identity it bound under, while it is still exactly the
|
|
727
|
+
* incarnation and revision the holder read, or `null`. Its capability hash is what a release then fences
|
|
728
|
+
* and compares, so a successor bound after this read is never released in its place.
|
|
729
|
+
*/
|
|
730
|
+
public async findHeldBinding(input: { runtime: Exclude<IAuthSwitchBinding['runtime'], 'codex'>; scopeId: string;
|
|
731
|
+
incarnationId: string; expectedRevision: number }): Promise<{ bindingId: string; capabilityHash: string } | null> {
|
|
732
|
+
if (!['flex', 'opencode', 'claude'].includes(input.runtime) || !safeScope(input.scopeId, 512)
|
|
733
|
+
|| !safeScope(input.incarnationId, 256) || !validRevision(input.expectedRevision)) {
|
|
734
|
+
throw new Error('Invalid runtime binding release.');
|
|
735
|
+
}
|
|
736
|
+
const bindingId = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
|
|
737
|
+
const binding = await this.database.readBinding(bindingId);
|
|
738
|
+
if (!binding || binding.runtime !== input.runtime || binding.scopeId !== input.scopeId
|
|
739
|
+
|| binding.incarnationId !== input.incarnationId || binding.revision !== input.expectedRevision) return null;
|
|
740
|
+
return { bindingId, capabilityHash: binding.capabilityHash };
|
|
741
|
+
}
|
|
742
|
+
|
|
719
743
|
public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
|
|
720
744
|
rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
721
745
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
|
|
@@ -11,30 +11,37 @@ import type {
|
|
|
11
11
|
IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
12
12
|
} from './authority-contract.js';
|
|
13
13
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
14
|
-
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
14
|
+
IReq_AuthSwitchResolveAccess, IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
|
|
15
15
|
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus,
|
|
16
16
|
IReq_AuthSwitchImportSubmit } from './authority-import-contract.js';
|
|
17
17
|
import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
|
|
18
18
|
maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One request on one connection, bounded by its own deadline alone.
|
|
22
|
+
*
|
|
23
|
+
* `TypedRequest.fire` turns each request's `timeoutMs` into the abort of `signal`, so the transport arms no
|
|
24
|
+
* timer of its own: an idle limit here would end a request that was allowed longer -- an import submit is
|
|
25
|
+
* allowed 120 s -- before its deadline. Every authority request carries one; a request without it is refused.
|
|
26
|
+
*/
|
|
20
27
|
const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promise<ITypedRequest> => {
|
|
21
28
|
return new Promise((resolve, reject) => {
|
|
22
|
-
if (signal
|
|
29
|
+
if (!signal) { reject(new Error('Authswitch daemon requests need a deadline.')); return; }
|
|
30
|
+
if (signal.aborted) { reject(signal.reason); return; }
|
|
23
31
|
const socket = plugins.net.createConnection(path);
|
|
24
32
|
let settled = false;
|
|
25
33
|
const frame = new AuthSwitchAuthorityFrameReader();
|
|
26
34
|
const finish = (error?: unknown, response?: ITypedRequest): void => {
|
|
27
35
|
if (settled) return;
|
|
28
36
|
settled = true;
|
|
29
|
-
signal
|
|
37
|
+
signal.removeEventListener('abort', abort);
|
|
30
38
|
socket.destroy();
|
|
31
39
|
if (error) reject(error);
|
|
32
40
|
else if (response) resolve(response);
|
|
33
41
|
else reject(new Error('Authswitch daemon returned no response.'));
|
|
34
42
|
};
|
|
35
|
-
const abort = () => finish(signal
|
|
36
|
-
signal
|
|
37
|
-
socket.setTimeout(35_000, () => finish(new Error('Authswitch daemon request timed out.')));
|
|
43
|
+
const abort = () => finish(signal.reason ?? new Error('Account request was cancelled.'));
|
|
44
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
38
45
|
socket.on('connect', () => {
|
|
39
46
|
const raw = JSON.stringify(payload);
|
|
40
47
|
if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { finish(new Error('Account request is too large.')); return; }
|
|
@@ -409,6 +416,14 @@ export class AuthSwitchClient {
|
|
|
409
416
|
signal?: AbortSignal): Promise<IReq_AuthSwitchBindAccount['response']> {
|
|
410
417
|
return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input, 35_000, false, signal);
|
|
411
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Releases the holder's own binding by the runtime, scope, incarnation and revision it was bound under,
|
|
421
|
+
* without its capability and without a ready login; `inactive` when no binding matches all four.
|
|
422
|
+
*/
|
|
423
|
+
public unbind(input: IReq_AuthSwitchUnbind['request'],
|
|
424
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchUnbind['response']> {
|
|
425
|
+
return this.request<IReq_AuthSwitchUnbind>('authswitch.authority.unbind', input, 35_000, false, signal);
|
|
426
|
+
}
|
|
412
427
|
public resolveAccess(input: IReq_AuthSwitchResolveAccess['request'],
|
|
413
428
|
signal?: AbortSignal): Promise<IReq_AuthSwitchResolveAccess['response']> {
|
|
414
429
|
return this.request<IReq_AuthSwitchResolveAccess>('authswitch.authority.resolveAccess',
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
11
11
|
} from './authority-contract.js';
|
|
12
12
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
13
|
-
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
13
|
+
IReq_AuthSwitchResolveAccess, IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
|
|
14
14
|
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
15
15
|
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
|
|
16
16
|
TAuthSwitchImportSubmission } from './authority-import-contract.js';
|
|
@@ -40,6 +40,11 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
|
|
|
40
40
|
claudeNative?: IClaudeNativeAuthorityOptions;
|
|
41
41
|
/** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
|
|
42
42
|
legacyImport: TAuthSwitchAuthorityLegacyImport;
|
|
43
|
+
/**
|
|
44
|
+
* How long an accepted connection has to deliver its complete request, counted from its acceptance and
|
|
45
|
+
* never extended by the bytes it sends: 35 s unless stated.
|
|
46
|
+
*/
|
|
47
|
+
requestDeliveryMs?: number;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
@@ -139,12 +144,17 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
139
144
|
private closing = false;
|
|
140
145
|
/** Every admitted request until its answer is written; shutdown settles these before it closes the store. */
|
|
141
146
|
private readonly inFlight = new Set<Promise<void>>();
|
|
147
|
+
private readonly requestDeliveryMs: number;
|
|
142
148
|
|
|
143
149
|
constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
|
|
144
150
|
// First, before anything is constructed: a daemon that was going to read the wrong host's credentials
|
|
145
151
|
// must not come into existence at all. The type states it and this states it again, because a caller
|
|
146
152
|
// without types is exactly the caller that would have defaulted here.
|
|
147
153
|
assertStatedLegacyImport(options.legacyImport);
|
|
154
|
+
this.requestDeliveryMs = options.requestDeliveryMs ?? 35_000;
|
|
155
|
+
if (!Number.isSafeInteger(this.requestDeliveryMs) || this.requestDeliveryMs <= 0) {
|
|
156
|
+
throw new Error('Authswitch authority requestDeliveryMs must be a positive whole number of milliseconds.');
|
|
157
|
+
}
|
|
148
158
|
this.database = new AuthSwitchAuthorityDatabase(options);
|
|
149
159
|
const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
150
160
|
this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
|
|
@@ -185,8 +195,9 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
185
195
|
this.register();
|
|
186
196
|
}
|
|
187
197
|
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
/** One exact capability of one binding: what a release fences and what an access resolution holds open. */
|
|
199
|
+
private runtimeBindingKey(bindingId: string, capabilityHash: string): string {
|
|
200
|
+
return `${bindingId}:${capabilityHash}`;
|
|
190
201
|
}
|
|
191
202
|
|
|
192
203
|
private beginRuntimeBindingAccess(key: string): () => void {
|
|
@@ -213,8 +224,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
213
224
|
}
|
|
214
225
|
|
|
215
226
|
private releaseRuntimeBinding(bindingId: string,
|
|
216
|
-
|
|
217
|
-
const key = this.runtimeBindingKey(bindingId,
|
|
227
|
+
capabilityHash: string): Promise<IReq_AuthSwitchReleaseBinding['response']> {
|
|
228
|
+
const key = this.runtimeBindingKey(bindingId, capabilityHash);
|
|
218
229
|
const existing = this.runtimeBindingReleases.get(key);
|
|
219
230
|
if (existing) return existing;
|
|
220
231
|
// Capabilities are freshly random for every bind, including a successor that reuses the
|
|
@@ -222,7 +233,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
222
233
|
this.runtimeBindingReleaseFences.add(key);
|
|
223
234
|
const task = (async (): Promise<IReq_AuthSwitchReleaseBinding['response']> => {
|
|
224
235
|
await this.waitForRuntimeBindingAccess(key);
|
|
225
|
-
const state = await this.broker.
|
|
236
|
+
const state = await this.broker.releaseExternalCapability(bindingId, capabilityHash);
|
|
226
237
|
this.runtimeBindingReleaseFences.delete(key);
|
|
227
238
|
return { state };
|
|
228
239
|
})();
|
|
@@ -482,6 +493,17 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
482
493
|
|| typeof request.incarnationId !== 'string') invalid();
|
|
483
494
|
return this.broker.bindAccount(request);
|
|
484
495
|
}));
|
|
496
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchUnbind>(
|
|
497
|
+
'authswitch.authority.unbind', async request => {
|
|
498
|
+
if (!hasKeys(request, ['runtime', 'scopeId', 'incarnationId', 'expectedRevision'])
|
|
499
|
+
|| !['flex', 'opencode', 'claude'].includes(request.runtime) || typeof request.scopeId !== 'string'
|
|
500
|
+
|| typeof request.incarnationId !== 'string' || typeof request.expectedRevision !== 'number') invalid();
|
|
501
|
+
// The holder's own binding, found by the identity it bound under whatever its login's state; from
|
|
502
|
+
// here it is released exactly as its capability would release it.
|
|
503
|
+
const held = await this.broker.findHeldBinding(request);
|
|
504
|
+
if (!held) return { state: 'inactive' };
|
|
505
|
+
return this.releaseRuntimeBinding(held.bindingId, held.capabilityHash);
|
|
506
|
+
}));
|
|
485
507
|
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchResolveAccess>(
|
|
486
508
|
'authswitch.authority.resolveAccess', async request => {
|
|
487
509
|
if ((typeof request !== 'object' || request === null || Array.isArray(request))
|
|
@@ -491,7 +513,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
491
513
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
|
|
492
514
|
|| typeof request.minValidityMs !== 'number'
|
|
493
515
|
|| (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
|
|
494
|
-
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId,
|
|
516
|
+
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId,
|
|
517
|
+
authSwitchBindingCapabilityHash(request.capability)));
|
|
495
518
|
try {
|
|
496
519
|
return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
|
|
497
520
|
request.rejectedGrantGeneration);
|
|
@@ -501,7 +524,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
501
524
|
'authswitch.authority.release', async request => {
|
|
502
525
|
if (!hasKeys(request, ['bindingId', 'capability'])
|
|
503
526
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
|
|
504
|
-
return this.releaseRuntimeBinding(request.bindingId, request.capability);
|
|
527
|
+
return this.releaseRuntimeBinding(request.bindingId, authSwitchBindingCapabilityHash(request.capability));
|
|
505
528
|
}));
|
|
506
529
|
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
|
|
507
530
|
'authswitch.authority.import.submit', async request => {
|
|
@@ -890,13 +913,24 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
890
913
|
await plugins.fs.promises.unlink(path);
|
|
891
914
|
}
|
|
892
915
|
|
|
916
|
+
/**
|
|
917
|
+
* Serves one request on one connection. A connection's failure is its own: a client that went away before
|
|
918
|
+
* its answer -- EPIPE or a reset on the write, a read error -- only destroys that socket, and the close
|
|
919
|
+
* that follows aborts the request it had admitted. Nothing one client does can end the daemon.
|
|
920
|
+
*
|
|
921
|
+
* A connection has one deadline to deliver its complete request, `requestDeliveryMs` from acceptance, which
|
|
922
|
+
* no byte it sends extends: a client dripping a request slowly holds its connection no longer than one that
|
|
923
|
+
* sends nothing. Once admitted, the request runs until it answers or its client goes away: the client's own
|
|
924
|
+
* deadline governs, which may be longer than the delivery deadline.
|
|
925
|
+
*/
|
|
893
926
|
private accept(socket: plugins.net.Socket, router: plugins.typedrequest.TypedRouter): void {
|
|
894
927
|
this.sockets.add(socket);
|
|
895
928
|
const abort = new AbortController();
|
|
896
|
-
|
|
929
|
+
const delivery = setTimeout(() => socket.destroy(), this.requestDeliveryMs);
|
|
930
|
+
socket.on('error', () => socket.destroy());
|
|
931
|
+
socket.on('close', () => { clearTimeout(delivery); this.sockets.delete(socket); abort.abort(); });
|
|
897
932
|
const frame = new AuthSwitchAuthorityFrameReader();
|
|
898
933
|
let handled = false;
|
|
899
|
-
socket.setTimeout(35_000, () => socket.destroy());
|
|
900
934
|
socket.on('data', chunk => {
|
|
901
935
|
if (handled) { socket.destroy(); return; }
|
|
902
936
|
if (!Buffer.isBuffer(chunk)) { socket.destroy(); return; }
|
|
@@ -905,12 +939,15 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
905
939
|
catch { socket.destroy(); return; }
|
|
906
940
|
if (input === null) return;
|
|
907
941
|
handled = true;
|
|
942
|
+
clearTimeout(delivery);
|
|
908
943
|
let request: ITypedRequest;
|
|
909
944
|
try { request = JSON.parse(input) as ITypedRequest; }
|
|
910
945
|
catch { socket.destroy(); return; }
|
|
911
946
|
const answered: Promise<void> = router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
|
|
912
947
|
trustedAbortSignal: abort.signal })
|
|
913
948
|
.then(async response => {
|
|
949
|
+
// A client that is already gone is owed no answer.
|
|
950
|
+
if (!socket.writable) return;
|
|
914
951
|
const raw = JSON.stringify(response);
|
|
915
952
|
if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { socket.destroy(); return; }
|
|
916
953
|
await new Promise<void>(resolve => socket.end(raw + '\n', () => resolve()));
|
|
@@ -1267,8 +1267,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1267
1267
|
const listed = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
|
|
1268
1268
|
throw new AuthSwitchRefusal('account_busy', `This account still backs ${listed} runtime bindings. `
|
|
1269
1269
|
+ 'Stop those runtimes and release their bindings before removing the account. A holder that lost '
|
|
1270
|
-
+ 'its capability
|
|
1271
|
-
+ 'if binding again is refused, reauthenticate the account first.');
|
|
1270
|
+
+ 'its capability unbinds its own runtime and scope instead, which needs no sign-in.');
|
|
1272
1271
|
}
|
|
1273
1272
|
await scan(async (grant, stored) => {
|
|
1274
1273
|
if (grant.state === 'removed') return;
|
|
@@ -1349,9 +1348,16 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1349
1348
|
}
|
|
1350
1349
|
}
|
|
1351
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* Binds a runtime and scope, or rebinds them. The binding's revision is the authority revision of the event
|
|
1353
|
+
* that writes it, handed to `change` as `revision`: the authority revision never repeats, so no two binds of
|
|
1354
|
+
* a runtime and scope ever share one -- not even across a release, which deletes the record, followed by a
|
|
1355
|
+
* bind that inserts it again -- and a holder that names a binding by the revision it read never names its
|
|
1356
|
+
* successor. Records written before carry smaller revisions, which a later bind therefore still exceeds.
|
|
1357
|
+
*/
|
|
1352
1358
|
public async changeBinding(updateId: string, bindingId: string, accountId: string, grantId: string,
|
|
1353
1359
|
change: (draft: IStoredAuthorityBinding | null, account: IStoredAuthorityAccount,
|
|
1354
|
-
grant: IStoredAuthorityGrant | null) => IStoredAuthorityBinding): Promise<{
|
|
1360
|
+
grant: IStoredAuthorityGrant | null, revision: number) => IStoredAuthorityBinding): Promise<{
|
|
1355
1361
|
meta: IStoredAuthorityMeta; binding: IStoredAuthorityBinding;
|
|
1356
1362
|
}> {
|
|
1357
1363
|
try {
|
|
@@ -1363,8 +1369,11 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1363
1369
|
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: grantId }, { session });
|
|
1364
1370
|
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
1365
1371
|
const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
|
|
1366
|
-
const
|
|
1367
|
-
|
|
1372
|
+
const revision = metaStored.revision + 1;
|
|
1373
|
+
const draft = change(current ? structuredClone(AuthSwitchAuthorityBindingModel.exact.toPersisted(current)) : null,
|
|
1374
|
+
account, grant, revision);
|
|
1375
|
+
if (draft.id !== bindingId || draft.accountId !== accountId || draft.updateId !== updateId
|
|
1376
|
+
|| draft.revision !== revision) throw new Error('Binding change has an invalid identity.');
|
|
1368
1377
|
if (draft.grantId !== grantId || grant?.accountId !== accountId) {
|
|
1369
1378
|
throw new Error('Binding login does not belong to the account.');
|
|
1370
1379
|
}
|