@cortexkit/common-auth 0.11.0 → 0.11.2

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/README.md CHANGED
@@ -7,4 +7,20 @@ Shared libraries for the CortexKit auth plugins for OpenCode and Pi: openai-auth
7
7
  - [OpenCode 2 transport spike](research/opencode2-transport/REPORT.md): can a plugin own the transport on OpenCode 2?
8
8
  - [OpenCode 2 native-driver spike](research/opencode2-thin/REPORT.md): multi-account support through hooks on OpenCode 2's built-in OpenAI driver.
9
9
 
10
+ ## Mutation catalogue
11
+
12
+ A passing safety test proves little until it has been seen to fail. [`mutations.toml`](mutations.toml) is the checked-in catalogue: each row deliberately breaks one guard in `src/` (or a CI scan script) and names the Bun test that must fail because of it, so a guard cannot silently stop guarding. A row earns its place by guarding a silent, costly failure: lock exclusion, crash safety, credential loss or leakage, a single-use token used twice, misattributed quota, a wire contract, data loss. Style and cosmetics do not.
13
+
14
+ The pinned [`ck-mutate`](https://github.com/cortexkit/commons/tree/7d08e73722fa3e79bbcc2607753978ab768f1c6b/crates/cortexkit-mutate) runner replays it from a clean tree. It installs and builds first (fixtures import `dist/`), checks each named test passes, applies the break, rebuilds, requires the test to fail, and restores the source byte for byte:
15
+
16
+ ```sh
17
+ cargo install --locked --git https://github.com/cortexkit/commons --rev 7d08e73722fa3e79bbcc2607753978ab768f1c6b cortexkit-mutate
18
+ ck-mutate check
19
+ ck-mutate run --all
20
+ ck-mutate run --diff origin/main
21
+ ck-mutate run --only fs-lock-try-once-refuses-held-lock
22
+ ```
23
+
24
+ Every row must report CAUGHT. Replay with the Bun version CI pins (1.3.14): the row `rpc-declared-oversize-413-half-closes` guards the RPC server's workaround for Bun 1.3.14 reusing a connection the server refused with 413. Bun 1.4.2 never reuses it, so that row survives there. Rows are command rows: `{test}` is a Bun `-t` filter, a regular expression over the full test name (describe names and test name joined by spaces), so ids are anchored, escaped, and spell each space `\s`. CI replays the rows a pull request's diff selects, the full catalogue on every push to main, and the full catalogue with `--broad` nightly. Never `git checkout` a file while a replay runs: it removes the mutation and fakes a survivor.
25
+
10
26
  Licensed under MIT; see [LICENSE](LICENSE).
@@ -87,6 +87,12 @@ export interface ClaustrumScopedAttempt {
87
87
  * in from the roster or from a token parse.
88
88
  */
89
89
  readonly assertedAccountIdentity?: string;
90
+ /**
91
+ * The non-secret Google Cloud project id the vault served in get_scoped for
92
+ * this attempt, if any. Never filled in from list_scoped, the roster or a
93
+ * token parse. A project change in the vault takes effect on the next send.
94
+ */
95
+ readonly projectId?: string;
90
96
  /**
91
97
  * Kept in memory only and hidden from JSON.stringify and object spreads, so
92
98
  * logging a receipt never leaks it. Authorize again for every dispatch and retry.
@@ -298,6 +298,9 @@ export class ClaustrumScopedCustody {
298
298
  ...(assertedIdentity !== undefined && {
299
299
  assertedAccountIdentity: assertedIdentity,
300
300
  }),
301
+ ...(served.projectId !== undefined && {
302
+ projectId: served.projectId,
303
+ }),
301
304
  recordVersion: served.recordVersion,
302
305
  expiresAtMs,
303
306
  }, 'accessToken', { value: accessToken, enumerable: false }));
@@ -2,7 +2,9 @@
2
2
  * Why an OpenCode 2 auth hook refused a request.
3
3
  *
4
4
  * - `no-account`: the adapter had no account to offer for this request, so
5
- * the request is stopped before the host picks a transport.
5
+ * the request is stopped before the host picks a transport. Also used when
6
+ * session forgetting or installation disposal revokes an in-flight auth
7
+ * selection: that selection no longer has an account it may send under.
6
8
  * - `host-credential-on-wire`: after every rewrite, a header still carried
7
9
  * one of the host's placeholder credentials. Sending it would leak a
8
10
  * non-routable value to the provider and fail with a confusing error.
@@ -129,6 +129,9 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
129
129
  let warnedUnmarkedSend = false;
130
130
  const listeners = new Map();
131
131
  let seq = 0;
132
+ let disposed = false;
133
+ /** Only active hook calls are held; forgetting never leaves a session tombstone. */
134
+ const inFlight = new Map();
132
135
  const warn = (message, data) => {
133
136
  try {
134
137
  logger?.warn(message, data);
@@ -200,12 +203,47 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
200
203
  const accountOf = (rec) => rec.accountId === undefined
201
204
  ? undefined
202
205
  : { ...rec.scope, accountId: rec.accountId };
203
- const noAccount = (scope) => new OpenCode2AuthError({
206
+ const noAccount = (scope, message) => new OpenCode2AuthError({
204
207
  kind: 'no-account',
205
208
  providerID,
206
209
  sessionID: scope.sessionID,
207
210
  requestKind: scope.kind,
211
+ ...(message === undefined ? {} : { message }),
208
212
  });
213
+ const revokeSelections = (sessionID) => {
214
+ inFlight.get(sessionID)?.clear();
215
+ inFlight.delete(sessionID);
216
+ };
217
+ /**
218
+ * Owns selection through its transport handoff, not just the adapter calls.
219
+ * Deleting a session's set revokes its old calls without poisoning reuse of
220
+ * that id. Each await must recheck before calling the adapter or publishing.
221
+ */
222
+ const withSelection = async (scope, run) => {
223
+ const revoked = () => noAccount(scope, `${providerID} ${scope.kind} request has no account: its auth selection was revoked by session forgetting or installation disposal`);
224
+ if (disposed)
225
+ throw revoked();
226
+ let selections = inFlight.get(scope.sessionID);
227
+ if (!selections) {
228
+ selections = new Set();
229
+ inFlight.set(scope.sessionID, selections);
230
+ }
231
+ const token = {};
232
+ selections.add(token);
233
+ const assertActive = () => {
234
+ if (!selections.has(token))
235
+ throw revoked();
236
+ };
237
+ try {
238
+ return await run(assertActive);
239
+ }
240
+ finally {
241
+ selections.delete(token);
242
+ // An old call may settle after a new call has reused the session id.
243
+ if (selections.size === 0 && inFlight.get(scope.sessionID) === selections)
244
+ inFlight.delete(scope.sessionID);
245
+ }
246
+ };
209
247
  const guard = (scope, headers) => {
210
248
  for (const [name, value] of headers) {
211
249
  if (forbidden.some((secret) => value.includes(secret))) {
@@ -240,7 +278,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
240
278
  }
241
279
  }
242
280
  };
243
- const select = async (scope, hook, transport) => {
281
+ const select = async (scope, hook, assertActive, transport) => {
244
282
  const key = keyOf(scope.sessionID, scope.kind);
245
283
  const retried = pendingRetries.get(key);
246
284
  pendingRetries.delete(key);
@@ -254,10 +292,12 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
254
292
  : { rerouteFrom: retried.rerouteFrom }),
255
293
  };
256
294
  const accountId = await adapter.chooseAccount(input);
295
+ assertActive();
257
296
  let headers = {};
258
297
  let data;
259
298
  if (accountId !== undefined) {
260
299
  const result = await adapter.accountHeaders({ ...scope, accountId });
300
+ assertActive();
261
301
  if (isHeadersResult(result)) {
262
302
  headers = result.headers;
263
303
  data = result.attempt;
@@ -306,7 +346,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
306
346
  // of the attempt it started. Choosing here too keeps a request that skipped
307
347
  // it from going out under the host credential, and a second send of the
308
348
  // same attempt from reusing the first send's attempt.
309
- const bind = async (scope, hook, transport, mark) => {
349
+ const bind = async (scope, hook, transport, mark, assertActive) => {
310
350
  const key = keyOf(scope.sessionID, scope.kind);
311
351
  let rec = mark === undefined ? undefined : attempts.get(mark);
312
352
  if (rec?.key !== key)
@@ -322,7 +362,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
322
362
  }
323
363
  }
324
364
  if (!rec || rec.transport !== undefined || rec.ended)
325
- rec = await select(scope, hook, transport);
365
+ rec = await select(scope, hook, assertActive, transport);
326
366
  else {
327
367
  rec.transport = transport;
328
368
  if (rec.attempt)
@@ -443,18 +483,20 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
443
483
  };
444
484
  const scoped = { providerID };
445
485
  const registrations = [];
446
- registrations.push(await ctx.session.hook('model.request', async (draft) => {
447
- const rec = await select(scopeOf(draft), 'model.request');
486
+ registrations.push(await ctx.session.hook('model.request', (draft) => withSelection(scopeOf(draft), async (assertActive) => {
487
+ const rec = await select(scopeOf(draft), 'model.request', assertActive);
488
+ assertActive();
448
489
  if (rec.accountId === undefined)
449
490
  throw noAccount(rec.scope);
450
491
  applyHeaderEdits(draft.headers, {
451
492
  ...rec.headers,
452
493
  [ATTEMPT_HEADER]: rec.id,
453
494
  });
454
- }, scoped));
455
- registrations.push(await ctx.session.hook('http.request', async (draft) => {
495
+ }), scoped));
496
+ registrations.push(await ctx.session.hook('http.request', (draft) => withSelection(scopeOf(draft), async (assertActive) => {
456
497
  const mark = draft.request.headers.get(ATTEMPT_HEADER) ?? undefined;
457
- const rec = await bind(scopeOf(draft), 'http.request', 'http', mark);
498
+ const rec = await bind(scopeOf(draft), 'http.request', 'http', mark, assertActive);
499
+ assertActive();
458
500
  const account = accountOf(rec);
459
501
  const attempt = rec.attempt;
460
502
  if (!account || !attempt)
@@ -475,6 +517,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
475
517
  request,
476
518
  attempt,
477
519
  })) ?? request;
520
+ assertActive();
478
521
  }
479
522
  const headers = new Headers(request.headers);
480
523
  headers.delete(ATTEMPT_HEADER);
@@ -484,7 +527,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
484
527
  byRequest.set(final, rec);
485
528
  draft.request = final;
486
529
  });
487
- }, scoped));
530
+ }), scoped));
488
531
  registrations.push(await ctx.session.hook('http.response', async (draft) => {
489
532
  const rec = byRequest.get(draft.request);
490
533
  if (!rec) {
@@ -554,7 +597,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
554
597
  if (response !== original)
555
598
  draft.response = response;
556
599
  }, scoped));
557
- registrations.push(await ctx.session.hook('experimental.ws.handshake', async (draft) => {
600
+ registrations.push(await ctx.session.hook('experimental.ws.handshake', (draft) => withSelection(scopeOf(draft), async (assertActive) => {
558
601
  let mark;
559
602
  for (const name of Object.keys(draft.headers)) {
560
603
  if (name.toLowerCase() !== ATTEMPT_HEADER)
@@ -562,7 +605,8 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
562
605
  mark ??= draft.headers[name];
563
606
  delete draft.headers[name];
564
607
  }
565
- const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws', mark);
608
+ const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws', mark, assertActive);
609
+ assertActive();
566
610
  const account = accountOf(rec);
567
611
  const attempt = rec.attempt;
568
612
  if (!account || !attempt)
@@ -578,7 +622,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
578
622
  draft.url = url;
579
623
  guard(rec.scope, Object.entries(draft.headers));
580
624
  });
581
- }, scoped));
625
+ }), scoped));
582
626
  const rewriteFrame = adapter.rewriteWebSocketFrame?.bind(adapter);
583
627
  if (rewriteFrame) {
584
628
  registrations.push(await ctx.session.hook('experimental.ws.send', async (draft) => {
@@ -624,68 +668,79 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
624
668
  return;
625
669
  }
626
670
  const rec = picked;
627
- rec.judged = true;
628
- let decision = hostDecision;
629
- let reason;
630
- let rerouteFrom;
631
- const attempt = rec.attempt;
632
- if (rec.accountId === undefined || !attempt) {
633
- decision = { retry: false };
634
- reason = 'no-account';
635
- }
636
- else if (rec.outputStarted) {
637
- // The user has already seen part of this answer; a retry would
638
- // send it again.
639
- decision = { retry: false };
640
- reason = 'output-started';
641
- }
642
- else {
643
- if (!rec.limit) {
644
- const signal = adapter.limitFromError?.(draft.error, attempt);
645
- if (signal)
646
- noteLimit(rec, signal, 'error');
671
+ await withSelection(rec.scope, async (assertActive) => {
672
+ rec.judged = true;
673
+ let decision = hostDecision;
674
+ let reason;
675
+ let rerouteFrom;
676
+ const attempt = rec.attempt;
677
+ if (rec.accountId === undefined || !attempt) {
678
+ decision = { retry: false };
679
+ reason = 'no-account';
647
680
  }
648
- // The next `chooseAccount` should see whatever the plugin learnt
649
- // from how this attempt ended.
650
- if (rec.ended)
651
- await rec.ended;
652
- if (rec.limit) {
653
- await rec.limit.delivered;
654
- rec.rerouted = true;
655
- rerouteFrom = { accountId: rec.accountId, limit: rec.limit.signal };
656
- // No delay: the next attempt goes to another account, and the
657
- // host would otherwise wait out the refused account's backoff,
658
- // or not retry at all for errors it deems final.
659
- decision = { retry: true, delay: 0 };
660
- reason = 'reroute';
681
+ else if (rec.outputStarted) {
682
+ // The user has already seen part of this answer; a retry would
683
+ // send it again.
684
+ decision = { retry: false };
685
+ reason = 'output-started';
661
686
  }
662
687
  else {
663
- reason = 'host-decides';
688
+ if (!rec.limit) {
689
+ const signal = adapter.limitFromError?.(draft.error, attempt);
690
+ if (signal)
691
+ noteLimit(rec, signal, 'error');
692
+ }
693
+ // The next `chooseAccount` should see whatever the plugin learnt
694
+ // from how this attempt ended.
695
+ if (rec.ended) {
696
+ await rec.ended;
697
+ assertActive();
698
+ }
699
+ if (rec.limit) {
700
+ await rec.limit.delivered;
701
+ assertActive();
702
+ rec.rerouted = true;
703
+ rerouteFrom = {
704
+ accountId: rec.accountId,
705
+ limit: rec.limit.signal,
706
+ };
707
+ // No delay: the next attempt goes to another account, and the
708
+ // host would otherwise wait out the refused account's backoff,
709
+ // or not retry at all for errors it deems final.
710
+ decision = { retry: true, delay: 0 };
711
+ reason = 'reroute';
712
+ }
713
+ else {
714
+ reason = 'host-decides';
715
+ }
664
716
  }
665
- }
666
- draft.decision = decision;
667
- // The host's retry runs `model.request` again for this attempt's
668
- // session and kind; that next attempt, and no other attempt of the
669
- // session, is told what this one ended with.
670
- if (decision.retry && rec.accountId !== undefined) {
671
- pendingRetries.set(rec.key, {
672
- sessionID: rec.scope.sessionID,
673
- accountId: rec.accountId,
674
- ...(rerouteFrom === undefined ? {} : { rerouteFrom }),
717
+ draft.decision = decision;
718
+ // The host's retry runs `model.request` again for this attempt's
719
+ // session and kind; that next attempt, and no other attempt of the
720
+ // session, is told what this one ended with.
721
+ if (decision.retry && rec.accountId !== undefined) {
722
+ pendingRetries.set(rec.key, {
723
+ sessionID: rec.scope.sessionID,
724
+ accountId: rec.accountId,
725
+ ...(rerouteFrom === undefined ? {} : { rerouteFrom }),
726
+ });
727
+ }
728
+ await emit('retry', {
729
+ sessionID: draft.sessionID,
730
+ ...(rec.accountId === undefined
731
+ ? {}
732
+ : { accountId: rec.accountId }),
733
+ kind: rec.scope.kind,
734
+ attempt: draft.attempt,
735
+ reason,
736
+ hostDecision,
737
+ decision,
738
+ ...(attempt === undefined ? {} : { handle: attempt }),
675
739
  });
676
- }
677
- await emit('retry', {
678
- sessionID: draft.sessionID,
679
- ...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
680
- kind: rec.scope.kind,
681
- attempt: draft.attempt,
682
- reason,
683
- hostDecision,
684
- decision,
685
- ...(attempt === undefined ? {} : { handle: attempt }),
686
740
  });
687
741
  }, scoped));
688
742
  const forgetSession = (sessionID) => {
743
+ revokeSelections(sessionID);
689
744
  for (const rec of [...attempts.values()]) {
690
745
  if (rec.scope.sessionID !== sessionID)
691
746
  continue;
@@ -716,7 +771,6 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
716
771
  }
717
772
  })();
718
773
  }
719
- let disposed = false;
720
774
  return {
721
775
  on(event, listener) {
722
776
  let set = listeners.get(event);
@@ -741,6 +795,8 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
741
795
  if (disposed)
742
796
  return;
743
797
  disposed = true;
798
+ for (const sessionID of inFlight.keys())
799
+ revokeSelections(sessionID);
744
800
  abort.abort();
745
801
  for (const rec of attempts.values())
746
802
  abandon(rec, 'the installation was disposed');
@@ -381,10 +381,13 @@ export interface OpenCode2AuthInstallation<Q, A = unknown> {
381
381
  * handle.
382
382
  */
383
383
  accountFor(sessionID: string, kind: RequestKind): string | undefined;
384
- /** Drops every attempt of a session. Session deletion does this itself. */
384
+ /**
385
+ * Drops every attempt and revokes in-flight auth selections of a session.
386
+ * Session deletion does this itself. Later requests may reuse the session id.
387
+ */
385
388
  forgetSession(sessionID: string): void;
386
389
  /** Number of attempts held. */
387
390
  readonly size: number;
388
- /** Removes every hook and stops listening for session deletion. */
391
+ /** Removes every hook, revokes in-flight auth and stops listening for session deletion. */
389
392
  dispose(): Promise<void>;
390
393
  }
@@ -293,10 +293,10 @@ export async function startRpcServer(options) {
293
293
  token,
294
294
  async stop() {
295
295
  await new Promise((resolve) => {
296
- server.close(() => resolve());
297
- // Stop accepting connections before ending requests that may never finish.
296
+ // Bun's close() disables closeAllConnections(), and a peer sending
297
+ // incomplete headers may not have reached the tracked connection set.
298
298
  server.closeAllConnections?.();
299
- // Bun exposes closeAllConnections but leaves partial requests open.
299
+ server.close(() => resolve());
300
300
  for (const socket of connections)
301
301
  socket.destroy();
302
302
  });
@@ -12,7 +12,7 @@ export type { PullHook, PullRequest } from './pull.js';
12
12
  export type { ProviderRefresh, ProviderRefreshResult, RefreshOptions, RefreshOutcome, } from './refresh.js';
13
13
  export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.js';
14
14
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
15
- export type { AddInput, AddResult, CredentialWriteInput, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, RowTransitionOptions, RowTransitionResult, } from './rows.js';
15
+ export type { AddInput, AddResult, CredentialWriteInput, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, RowTransitionOptions, RowTransitionResult, RowWriteOptions, } from './rows.js';
16
16
  export type { PullReason } from './runtime.js';
17
17
  export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, ProviderStateCodec, ProviderStateDrop, ProviderStateReplacement, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
18
18
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, PROVIDER_STATE_KEY, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
@@ -6,7 +6,7 @@ import { type ProviderStateMutator, type UpdateProviderStateResult } from './pro
6
6
  import { type PullHook } from './pull.js';
7
7
  import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
8
8
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
9
- import { type AddInput, type AddResult, type CredentialWriteInput, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions, type RowTransitionOptions, type RowTransitionResult } from './rows.js';
9
+ import { type AddInput, type AddResult, type CredentialWriteInput, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions, type RowTransitionOptions, type RowTransitionResult, type RowWriteOptions } from './rows.js';
10
10
  import { type PoolCredential, type PoolRow, type ProviderStateCodec, type QuotaCodec, type RotateCredential, type StoredCredential } from './schema.js';
11
11
  import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
12
12
  export interface OpenPoolStoreOptions {
@@ -100,8 +100,10 @@ export interface PoolStore {
100
100
  * Gives a row a new credential and a new credential epoch. Since 0.6.0 the
101
101
  * row's provider state is whatever `ProviderStateCodec.onReplace` returns;
102
102
  * without that hook it is `input.providerState`, else cleared.
103
+ * Optional `attribution` fences the write on the prior credential under the
104
+ * locks, before any write or replacement hook; see `RowWriteOptions`.
103
105
  */
104
- replace(id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
106
+ replace(id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowWriteOptions): Promise<{
105
107
  id: string;
106
108
  credential: StoredCredential;
107
109
  credentialEpoch: number;
@@ -111,8 +113,9 @@ export interface PoolStore {
111
113
  * endpoint. Since 0.4.1 an API key may leave out `baseURL` and `authHeader`
112
114
  * to keep the row's, and one that gives another is refused
113
115
  * (`endpoint-mismatch`) before writing: that is a `replace`.
116
+ * Takes the same optional attribution fence as `replace` (`RowWriteOptions`).
114
117
  */
115
- rotate(id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
118
+ rotate(id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowWriteOptions): Promise<{
116
119
  id: string;
117
120
  credential: StoredCredential;
118
121
  }>;
@@ -53,6 +53,9 @@ export function replacementProviderState(codec, row, credentialEpoch, identity,
53
53
  : structuredClone(row.providerState), {
54
54
  id: row.id,
55
55
  credentialEpoch,
56
+ ...(row.identity !== undefined
57
+ ? { previousIdentity: row.identity }
58
+ : {}),
56
59
  ...(identity !== undefined ? { identity } : {}),
57
60
  ...(incoming !== undefined
58
61
  ? { incoming: structuredClone(incoming) }
@@ -20,6 +20,20 @@ export interface RowOperationOptions {
20
20
  */
21
21
  extraLocks?: readonly PoolLockSpec[];
22
22
  }
23
+ /** Options of `replace` and `rotate`. Without `attribution` a call behaves as before. */
24
+ export interface RowWriteOptions extends RowOperationOptions {
25
+ /**
26
+ * The credential epoch and recorded identity the caller read the row at
27
+ * when it decided on this write; an identity left out means the row had
28
+ * none. Compared exactly, under the row and store locks, before any write
29
+ * or replacement hook, including the completion of an interrupted replace.
30
+ * The call is refused (`attribution`, retryable, nothing written) once the
31
+ * row holds another epoch or identity, so a write decided on an older
32
+ * credential never overwrites the one that replaced it. `disable`, `enable`,
33
+ * `recordQuota` and `updateProviderState` take the same fence.
34
+ */
35
+ attribution?: Attribution;
36
+ }
23
37
  /**
24
38
  * Options of `disable`, `enable` and `remove`. The provider-wide lock guards
25
39
  * changes to the recorded identity a row lock is named by; none of these
@@ -127,12 +141,12 @@ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string,
127
141
  transition?: StampedTransition;
128
142
  }): Promise<StoredCredential>;
129
143
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
130
- export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
144
+ export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowWriteOptions): Promise<{
131
145
  id: string;
132
146
  credential: StoredCredential;
133
147
  credentialEpoch: number;
134
148
  }>;
135
- export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
149
+ export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowWriteOptions): Promise<{
136
150
  id: string;
137
151
  credential: StoredCredential;
138
152
  }>;
@@ -179,6 +179,22 @@ function requireUsableRow(operation, id, row, credential) {
179
179
  throw refusal(operation, id, 'type-mismatch', `row ${id} holds a ${row.type} credential`);
180
180
  return row;
181
181
  }
182
+ function validateAttribution(operation, id, fence) {
183
+ if (fence !== undefined &&
184
+ !isCredentialEpoch(isRecord(fence) ? fence.credentialEpoch : undefined))
185
+ throw refusal(operation, id, 'invalid-input', 'the attribution must name a credential epoch that is a positive safe integer');
186
+ }
187
+ /** Exact recorded identity, including absence, is part of a credential fence. */
188
+ function assertRowAttribution(operation, id, row, fence) {
189
+ if (!row)
190
+ throw unknownRow(operation, id);
191
+ // A row that failed validation has no usable credential epoch to compare.
192
+ if (row.invalid)
193
+ throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
194
+ if ((row.credentialEpoch ?? 1) !== fence.credentialEpoch ||
195
+ row.identity !== fence.identity)
196
+ throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
197
+ }
182
198
  /** The row is recorded for another account than the one given. */
183
199
  function identityMismatch(operation, id) {
184
200
  return refusal(operation, id, 'identity-mismatch', `row ${id} is recorded for another account; a credential of a different account is a replacement`);
@@ -318,6 +334,7 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
318
334
  const { ctx } = rt;
319
335
  const result = await runOperation(ctx, 'replace', id, options.onFailure, async (locks, progress) => {
320
336
  checkInput('replace', id, credential);
337
+ validateAttribution('replace', id, options.attribution);
321
338
  const incoming = input.providerState === undefined
322
339
  ? undefined
323
340
  : acceptProviderState(ctx.providerState, 'replace', id, input.providerState);
@@ -328,6 +345,15 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
328
345
  for (const extra of options.extraLocks ?? [])
329
346
  await locks.acquire(extra);
330
347
  return withTransaction(ctx, locks, progress, { operation: 'replace', rowId: id }, async (tx) => {
348
+ if (options.attribution !== undefined) {
349
+ // A replace interrupted between its two file writes is read as
350
+ // already done: tx.row shows its new epoch and identity. Compare
351
+ // the fence with that row before finishing the interrupted write
352
+ // on disk, so a stale caller does not even complete a replacement
353
+ // it was never issued for.
354
+ assertRowAttribution('replace', id, tx.row(id), options.attribution);
355
+ await tx.completeTorn();
356
+ }
331
357
  const row = requireUsableRow('replace', id, tx.row(id), credential);
332
358
  if (rowLockKey(row) !== rowLockKey(seen))
333
359
  throw keyChanged('replace', id);
@@ -368,7 +394,7 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
368
394
  });
369
395
  await tx.commitConfig();
370
396
  return { id, credential: stored, credentialEpoch };
371
- });
397
+ }, { completeTorn: options.attribution === undefined });
372
398
  });
373
399
  if (credential.type === 'oauth')
374
400
  rt.firePull(id, 'replace');
@@ -379,6 +405,7 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
379
405
  const { ctx } = rt;
380
406
  return runOperation(ctx, 'rotate', id, options.onFailure, async (locks, progress) => {
381
407
  checkInput('rotate', id, credential);
408
+ validateAttribution('rotate', id, options.attribution);
382
409
  const incoming = input.providerState === undefined
383
410
  ? undefined
384
411
  : acceptProviderState(ctx.providerState, 'rotate', id, input.providerState);
@@ -389,6 +416,10 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
389
416
  for (const extra of options.extraLocks ?? [])
390
417
  await locks.acquire(extra);
391
418
  return withTransaction(ctx, locks, progress, { operation: 'rotate', rowId: id }, async (tx) => {
419
+ if (options.attribution !== undefined) {
420
+ assertRowAttribution('rotate', id, tx.row(id), options.attribution);
421
+ await tx.completeTorn();
422
+ }
392
423
  const row = requireUsableRow('rotate', id, tx.row(id), credential);
393
424
  if (rowLockKey(row) !== rowLockKey(seen))
394
425
  throw keyChanged('rotate', id);
@@ -428,7 +459,7 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
428
459
  if (configChanged)
429
460
  await tx.commitConfig();
430
461
  return { id, credential: stored };
431
- });
462
+ }, { completeTorn: options.attribution === undefined });
432
463
  });
433
464
  }
434
465
  /**
@@ -451,9 +482,7 @@ async function transitionRow(rt, operation, id, flag, options) {
451
482
  const fence = options.attribution;
452
483
  const mutator = options.providerState;
453
484
  return runOperation(ctx, operation, id, options.onFailure, async (locks, progress) => {
454
- if (fence !== undefined &&
455
- !isCredentialEpoch(isRecord(fence) ? fence.credentialEpoch : undefined))
456
- throw refusal(operation, id, 'invalid-input', 'the attribution must name a credential epoch that is a positive safe integer');
485
+ validateAttribution(operation, id, fence);
457
486
  if (mutator !== undefined) {
458
487
  if (typeof mutator !== 'function')
459
488
  throw refusal(operation, id, 'invalid-input', 'the provider-state mutator must be a function');
@@ -477,14 +506,8 @@ async function transitionRow(rt, operation, id, flag, options) {
477
506
  throw unknownRow(operation, id);
478
507
  if (rowLockKey(row) !== rowLockKey(seen))
479
508
  throw keyChanged(operation, id);
480
- if (fence !== undefined) {
481
- // An invalid entry has no epoch to compare the fence with.
482
- if (row.invalid)
483
- throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
484
- if ((row.credentialEpoch ?? 1) !== fence.credentialEpoch ||
485
- row.identity !== fence.identity)
486
- throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
487
- }
509
+ if (fence !== undefined)
510
+ assertRowAttribution(operation, id, row, fence);
488
511
  if (flag.enabled &&
489
512
  row.disabledReason?.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX))
490
513
  throw refusal('enable', id, 'identity-contradicted', `row ${id} needs an identity-validated credential replacement before it can be enabled`);
@@ -52,14 +52,16 @@ export interface QuotaCodec {
52
52
  merge(stored: unknown | undefined, observation: unknown): unknown;
53
53
  }
54
54
  /**
55
- * What `ProviderStateCodec.onReplace` is told about a replacement: the row,
56
- * the credential epoch the new credential starts, the identity the replace
57
- * records (absent: none), and the provider state the caller handed to
58
- * `replace`, if any.
55
+ * What `ProviderStateCodec.onReplace` is told about a replacement: the row id,
56
+ * the credential epoch the new credential starts, `previousIdentity` (the
57
+ * identity the locked row recorded before the replace), `identity` (the
58
+ * identity the replace records), and the provider state the caller handed to
59
+ * `replace`, if any. Each identity is absent when none was recorded.
59
60
  */
60
61
  export interface ProviderStateReplacement {
61
62
  id: string;
62
63
  credentialEpoch: number;
64
+ previousIdentity?: string;
63
65
  identity?: string;
64
66
  incoming?: unknown;
65
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {