@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelprofile.com/authswitch",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "private": false,
5
5
  "description": "Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status",
6
6
  "main": "dist_ts/index.js",
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 three runtime requests are served on the runtime socket -- resolving
77
- access and releasing a binding -- while `authswitch.authority.bind` is served on the **management** socket
78
- by design: minting a capability is the act that authorizes a runtime, so only a trusted management caller
79
- may perform it, and a container that is given the runtime directory can use a binding it was handed but can
80
- never create one. A backend binds an account to a runtime incarnation by exact account ID, login ID and
81
- purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the
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. The runtime-only socket
95
- resolves that binding to a current access token; its directory can be mounted into a
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. 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.
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, 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.
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
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/authswitch',
6
- version: '9.1.0',
6
+ version: '9.2.0',
7
7
  description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
8
8
  }
@@ -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, (existing, account, grant) => {
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: (existing?.revision ?? 0) + 1,
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 (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
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
- authSwitchBindingCapabilityHash(capability));
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?.aborted) { reject(signal.reason); return; }
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?.removeEventListener('abort', abort);
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?.reason ?? new Error('Account request was cancelled.'));
36
- signal?.addEventListener('abort', abort, { once: true });
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
- private runtimeBindingKey(bindingId: string, capability: string): string {
189
- return `${bindingId}:${authSwitchBindingCapabilityHash(capability)}`;
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
- capability: string): Promise<IReq_AuthSwitchReleaseBinding['response']> {
217
- const key = this.runtimeBindingKey(bindingId, capability);
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.releaseExternalBinding(bindingId, capability);
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, request.capability));
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
- socket.on('close', () => { this.sockets.delete(socket); abort.abort(); });
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 binds the same runtime and scope again and releases the capability that returns; '
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 draft = change(current ? structuredClone(AuthSwitchAuthorityBindingModel.exact.toPersisted(current)) : null, account, grant);
1367
- if (draft.id !== bindingId || draft.accountId !== accountId || draft.updateId !== updateId) throw new Error('Binding change has an invalid identity.');
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
  }