@cortexkit/common-auth 0.4.2 → 0.4.4

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.
@@ -1,7 +1,7 @@
1
1
  import { PoolOperationError } from './errors.js';
2
2
  import { toFailure, withTransaction } from './mutate.js';
3
3
  import { LockStack } from './refresh-lock.js';
4
- import { refusal, unknownRow } from './runtime.js';
4
+ import { refusal, requireBound, unknownRow, } from './runtime.js';
5
5
  import { isCredentialEpoch } from './schema.js';
6
6
  /**
7
7
  * Merges a quota observation into a row's stored map under the store locks,
@@ -26,6 +26,7 @@ export async function recordQuota(rt, id, attribution, observation) {
26
26
  // has no such triple on disk, so no reading is recorded for it.
27
27
  if (!row.credential)
28
28
  throw refusal('pull', id, 'no-credential', `row ${id} holds no credential`);
29
+ requireBound('pull', row);
29
30
  const entry = tx.entry(id);
30
31
  if (row.torn ||
31
32
  !entry ||
@@ -19,7 +19,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
19
19
  * lock outcomes (a wait that ran out, and a lease found lost); the rest are
20
20
  * refusals and failures of the operation itself.
21
21
  */
22
- export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
22
+ export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
23
23
  /**
24
24
  * The single failure value of every store operation. `committed` is present
25
25
  * only when the operation had already written a credential to the state file
@@ -12,7 +12,7 @@ export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.j
12
12
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
13
13
  export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
14
14
  export type { PullReason } from './runtime.js';
15
- export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
15
+ export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
16
16
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
17
17
  export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
18
18
  export { POOL_OWNED_KEYS } from './settings.js';
@@ -23,6 +23,12 @@ export interface StoreContext {
23
23
  hold?: (point: HoldPoint, rowId: string) => void | Promise<void>;
24
24
  /** Ids whose per-row entry a library write dropped in this process. */
25
25
  removedIds: Set<string>;
26
+ /**
27
+ * When true, rows whose credential stamp is not bound load `unbound` and
28
+ * the operations that use a credential refuse them (see
29
+ * `OpenPoolStoreOptions.requireCredentialStamps`).
30
+ */
31
+ requireCredentialStamps?: boolean;
26
32
  }
27
33
  export interface Snapshot {
28
34
  configExists: boolean;
@@ -94,8 +100,9 @@ export declare class Transaction {
94
100
  entry(id: string): Record<string, unknown> | undefined;
95
101
  setEntry(id: string, entry: Record<string, unknown>): void;
96
102
  /**
97
- * Writes the config of every row torn between the writes of a replace as
98
- * that replace would have left it (see `completeTornRows`), in one config
103
+ * Writes the config of every row torn between the writes of a replace, or
104
+ * of a write giving it its first identity, as that write would have left it
105
+ * (see `completeTornRows`), in one config
99
106
  * write ahead of the operation's own. The write is counted apart from the
100
107
  * operation's: it is setup, like a pull giving a row its entry, so a later
101
108
  * refusal still reports `before-first-write`.
@@ -39,7 +39,9 @@ export async function readPool(ctx) {
39
39
  stateExists: state.exists,
40
40
  config: config.config,
41
41
  state: state.state,
42
- rows: loadRows(config.config, state.state, ctx.codec),
42
+ rows: loadRows(config.config, state.state, ctx.codec, {
43
+ requireCredentialStamps: ctx.requireCredentialStamps === true,
44
+ }),
43
45
  };
44
46
  }
45
47
  /** The refusal for a pool that is not ready, as a failure value. */
@@ -86,7 +88,9 @@ export class Transaction {
86
88
  * `PoolRow.torn`).
87
89
  */
88
90
  rows() {
89
- return loadRows(this.config, this.state, this.ctx.codec);
91
+ return loadRows(this.config, this.state, this.ctx.codec, {
92
+ requireCredentialStamps: this.ctx.requireCredentialStamps === true,
93
+ });
90
94
  }
91
95
  row(id) {
92
96
  return this.rows().find((row) => row.id === id);
@@ -123,14 +127,15 @@ export class Transaction {
123
127
  setEntryIn(this.config, id, entry);
124
128
  }
125
129
  /**
126
- * Writes the config of every row torn between the writes of a replace as
127
- * that replace would have left it (see `completeTornRows`), in one config
130
+ * Writes the config of every row torn between the writes of a replace, or
131
+ * of a write giving it its first identity, as that write would have left it
132
+ * (see `completeTornRows`), in one config
128
133
  * write ahead of the operation's own. The write is counted apart from the
129
134
  * operation's: it is setup, like a pull giving a row its entry, so a later
130
135
  * refusal still reports `before-first-write`.
131
136
  */
132
137
  async completeTorn() {
133
- const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec);
138
+ const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec, { requireCredentialStamps: this.ctx.requireCredentialStamps === true });
134
139
  if (torn.length === 0)
135
140
  return;
136
141
  this.config = config;
@@ -14,6 +14,19 @@ export interface OpenPoolStoreOptions {
14
14
  configPath: string;
15
15
  statePath: string;
16
16
  quota: QuotaCodec;
17
+ /**
18
+ * Refuse every credential this store did not stamp (default false, which
19
+ * loads unstamped and mis-stamped credentials as older writers left them).
20
+ * When true, a row whose `stamp` is not `bound` loads `unbound` and is no
21
+ * candidate, and every operation that would use or keep its credential or
22
+ * what was observed about it (`refresh`, quota pulls, `recordQuota`,
23
+ * `recordIdentity`, `rotate`, and an `add` of the same secret or onto the
24
+ * same credential-less row) refuses with `unbound-credential` before any
25
+ * provider call or write, checked again under the locks and at commit.
26
+ * Nothing makes such a row bound except `replace`, which starts a new
27
+ * credential epoch and drops what was observed about the old one.
28
+ */
29
+ requireCredentialStamps?: boolean;
17
30
  /** Injected clock for leases, refresh stamps and `addedAt`. */
18
31
  now?: () => number;
19
32
  /**
@@ -59,6 +59,7 @@ export function openPoolStore(options) {
59
59
  ...(options.onLockStep ? { onLockStep: options.onLockStep } : {}),
60
60
  },
61
61
  removedIds: memory.removedIds,
62
+ requireCredentialStamps: options.requireCredentialStamps === true,
62
63
  ...(options.logger ? { logger: options.logger } : {}),
63
64
  ...(options.onStep ? { onStep: options.onStep } : {}),
64
65
  ...(options.hold ? { hold: options.hold } : {}),
@@ -1,6 +1,6 @@
1
1
  import { PoolOperationError } from './errors.js';
2
2
  import type { PoolLogger } from './hooks.js';
3
- import type { PullReason, StoreRuntime } from './runtime.js';
3
+ import { type PullReason, type StoreRuntime } from './runtime.js';
4
4
  import type { StoredCredential } from './schema.js';
5
5
  /** What a pull is issued with: the credential and its attribution tuple. */
6
6
  export interface PullRequest {
@@ -2,6 +2,7 @@ import { recordQuota } from './attribution.js';
2
2
  import { PoolOperationError } from './errors.js';
3
3
  import { toFailure, withTransaction } from './mutate.js';
4
4
  import { LockStack } from './refresh-lock.js';
5
+ import { requireBound } from './runtime.js';
5
6
  /**
6
7
  * Fires quota pulls without ever making a caller wait for one. A pull first
7
8
  * gives a row without a per-row entry its entry at epoch 1 (its own locked
@@ -50,6 +51,14 @@ export class PullScheduler {
50
51
  try {
51
52
  const request = await withTransaction(ctx, locks, progress, { operation: 'pull', rowId: id }, async (tx) => {
52
53
  const row = tx.row(id);
54
+ // A row that would pull but for its unbound credential refuses, so
55
+ // the failure hook hears of it instead of the pull vanishing.
56
+ if (row?.unbound &&
57
+ row.enabled &&
58
+ row.type === 'oauth' &&
59
+ row.credential &&
60
+ !row.invalid)
61
+ requireBound('pull', row);
53
62
  // Disabled, API-key and credential-less rows never pull.
54
63
  if (!row?.candidate || row.type !== 'oauth')
55
64
  return undefined;
@@ -3,7 +3,7 @@ import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
3
  import { recordIdentityIn } from './identity.js';
4
4
  import { runOperation, withTransaction } from './mutate.js';
5
5
  import { rotateIn } from './rows.js';
6
- import { readRow, refusal, rowLockSpec } from './runtime.js';
6
+ import { readRow, refusal, requireBound, rowLockSpec, } from './runtime.js';
7
7
  import { rotationStamp, rotationStampUntrusted, rowLockKey, } from './schema.js';
8
8
  function requireRefreshable(id, row) {
9
9
  if (!row)
@@ -47,6 +47,7 @@ export async function refreshRow(rt, id, provider, options = {}) {
47
47
  const captureProgress = { writes: 0 };
48
48
  const read = await withTransaction(ctx, locks, captureProgress, { operation: 'refresh', rowId: id }, async (tx) => {
49
49
  let row = requireRefreshable(id, tx.row(id));
50
+ requireBound('refresh', row);
50
51
  if (!row.hasEntry) {
51
52
  tx.setEntry(id, { credentialEpoch: 1, needsFirstReading: true });
52
53
  await tx.commitConfig();
@@ -113,6 +114,10 @@ export async function refreshRow(rt, id, provider, options = {}) {
113
114
  kind: 'attribution',
114
115
  message: `row ${id} changed credential while its refresh was in flight; the rotation is discarded`,
115
116
  });
117
+ // The epoch fence above does not see a credential another writer
118
+ // swapped in under the same epoch; the stamp does, and committing
119
+ // the rotation would stamp the swapped row as bound.
120
+ requireBound('refresh', current);
116
121
  const reason = await options.refuse?.(current);
117
122
  if (reason !== undefined)
118
123
  return { refused: reason };
@@ -128,13 +133,20 @@ export async function refreshRow(rt, id, provider, options = {}) {
128
133
  refresh: result.refresh,
129
134
  expires: result.expires,
130
135
  };
136
+ const learnt = current.identity === undefined && result.identity
137
+ ? result.identity
138
+ : undefined;
139
+ // The rotated credential's stamp names a learnt identity before the
140
+ // config records it, so a crash between the two writes is completed
141
+ // forward rather than leaving an identity no stamp proves.
131
142
  const stored = await rotateIn(rt, tx, id, credential, {
132
143
  stamp: rotationStamp(prior, now),
144
+ identity: learnt,
133
145
  });
134
146
  let identity = current.identity;
135
- if (current.identity === undefined && result.identity) {
136
- recordIdentityIn(tx, id, result.identity);
137
- identity = result.identity;
147
+ if (learnt !== undefined) {
148
+ recordIdentityIn(tx, id, learnt);
149
+ identity = learnt;
138
150
  await tx.commitConfig();
139
151
  }
140
152
  return { stored, identity, refused: undefined };
@@ -89,15 +89,19 @@ export type AddResult = {
89
89
  };
90
90
  /**
91
91
  * Writes a credential into the state file (one write), stamped with the
92
- * credential epoch the row's entry holds in `tx` (1 without an entry) and,
93
- * for a replace, the binding the config is about to get. A rotation is the
94
- * same lineage: no epoch bump, no identity or quota change. An API key must
95
- * belong to the endpoint the row holds in `tx` (see `onRowEndpoint`).
92
+ * credential epoch the row's entry holds in `tx` (1 without an entry) and a
93
+ * binding: for a replace, the one the config is about to get (and the stamp
94
+ * is marked as a replace's); for every other write, the row's config as it
95
+ * stands in `tx` with the identity the operation is about to record
96
+ * (`identity`, see `bindingInTx`). A rotation is the same lineage: no epoch
97
+ * bump, no identity or quota change. An API key must belong to the endpoint
98
+ * the row holds in `tx` (see `onRowEndpoint`).
96
99
  */
97
100
  export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, given: RotateCredential, extra?: {
98
101
  stamp?: number;
99
102
  clearErrors?: boolean;
100
103
  binding?: CredentialBinding;
104
+ identity?: string;
101
105
  }): Promise<StoredCredential>;
102
106
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
103
107
  export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
@@ -2,7 +2,7 @@ import { PoolOperationError } from './errors.js';
2
2
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
3
  import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
4
4
  import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
5
- import { readRow, refusal, rowLockSpec, unknownRow, } from './runtime.js';
5
+ import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
6
6
  import { CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
7
7
  import { bindReplacement } from './torn.js';
8
8
  /** Fields of a state entry that belong to the credential it replaces. */
@@ -50,12 +50,42 @@ function onRowEndpoint(tx, id, credential) {
50
50
  throw refusal(tx.info.operation, id, 'endpoint-mismatch', `row ${id} sends its key to another endpoint or header; a key for another endpoint is a replacement`);
51
51
  return { ...credential, baseURL, authHeader };
52
52
  }
53
+ /**
54
+ * The binding of a credential written into a row without a replace: the
55
+ * config as it stands in `tx`, plus the identity the same operation is about
56
+ * to record (`learnt`), if any. The identity is the roster row's recorded
57
+ * one, read the way `buildRawRows` reads it. A learnt identity is stamped
58
+ * before the config records it, so a crash between the two writes leaves a
59
+ * stamp naming an identity the config lacks, which every reader completes
60
+ * forward (see `torn.ts`); the reverse order would leave a config identity
61
+ * no stamp proves. An API key's endpoint is the one it was checked against
62
+ * (see `onRowEndpoint`).
63
+ */
64
+ function bindingInTx(tx, id, stored, learnt) {
65
+ const raw = tx.rosterRow(id);
66
+ const identity = learnt ??
67
+ (isRecord(raw) && typeof raw.accountId === 'string' && raw.accountId
68
+ ? raw.accountId
69
+ : undefined);
70
+ return {
71
+ ...(identity !== undefined ? { identity } : {}),
72
+ ...(stored.type === 'api'
73
+ ? {
74
+ baseURL: stored.baseURL,
75
+ authHeader: stored.authHeader ?? 'authorization-bearer',
76
+ }
77
+ : {}),
78
+ };
79
+ }
53
80
  /**
54
81
  * Writes a credential into the state file (one write), stamped with the
55
- * credential epoch the row's entry holds in `tx` (1 without an entry) and,
56
- * for a replace, the binding the config is about to get. A rotation is the
57
- * same lineage: no epoch bump, no identity or quota change. An API key must
58
- * belong to the endpoint the row holds in `tx` (see `onRowEndpoint`).
82
+ * credential epoch the row's entry holds in `tx` (1 without an entry) and a
83
+ * binding: for a replace, the one the config is about to get (and the stamp
84
+ * is marked as a replace's); for every other write, the row's config as it
85
+ * stands in `tx` with the identity the operation is about to record
86
+ * (`identity`, see `bindingInTx`). A rotation is the same lineage: no epoch
87
+ * bump, no identity or quota change. An API key must belong to the endpoint
88
+ * the row holds in `tx` (see `onRowEndpoint`).
59
89
  */
60
90
  export async function rotateIn(rt, tx, id, given, extra = {}) {
61
91
  const credential = onRowEndpoint(tx, id, given);
@@ -79,11 +109,29 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
79
109
  tx.setStateAccount(id, {
80
110
  ...kept,
81
111
  ...stateFieldsFor(credential, stamp),
82
- [CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding),
112
+ [CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), { replace: extra.binding !== undefined }),
83
113
  });
84
114
  await tx.commitState(stored);
85
115
  return stored;
86
116
  }
117
+ /**
118
+ * Restamps a bound row's credential, unchanged, so its stamp names the
119
+ * identity the caller is about to record in the config (same epoch, same
120
+ * credential, one state write). Written before the config for the reason
121
+ * `bindingInTx` gives. A row whose stamp is not bound (possible only without
122
+ * `requireCredentialStamps`) gets no new stamp, because a fresh stamp would
123
+ * vouch for a credential this store never proved: its identity is recorded in
124
+ * the config only, and the row keeps the stamp status it had.
125
+ */
126
+ async function stampIdentityIn(tx, row, identity) {
127
+ if (!row.credential || row.stamp !== 'bound')
128
+ return;
129
+ tx.setStateAccount(row.id, {
130
+ ...(tx.stateAccount(row.id) ?? {}),
131
+ [CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity)),
132
+ });
133
+ await tx.commitState();
134
+ }
87
135
  function checkInput(operation, id, credential) {
88
136
  const idIssue = operation === 'add' ? idProblem(id) : undefined;
89
137
  if (idIssue)
@@ -129,6 +177,11 @@ export async function addRow(rt, input, options = {}) {
129
177
  const fingerprint = fingerprintOf(credential);
130
178
  const same = rows.find((row) => row.invalid === undefined && row.fingerprint === fingerprint);
131
179
  if (same) {
180
+ // Re-adding a secret whose stamp is not proved would rotate it in
181
+ // and keep the identity and quota recorded beside it, making that
182
+ // unproved record look bound. The add is refused instead, and the
183
+ // caller replaces the row, which starts a new credential epoch.
184
+ requireBound('add', same);
132
185
  // The same secret is the same credential, so re-adding it rotates
133
186
  // that row. An identity or endpoint given with it must match the
134
187
  // row's; a different one is refused rather than silently replaced
@@ -146,6 +199,10 @@ export async function addRow(rt, input, options = {}) {
146
199
  throw refusal('add', id, 'invalid-row', `row ${id} is invalid`);
147
200
  if (existing.credential)
148
201
  throw refusal('add', id, 'id-exists', `row ${id} already holds a credential`);
202
+ // Completing a credential-less row keeps its epoch, identity and
203
+ // quota; when stamps are required those belong to no proved
204
+ // credential, so the row must be replaced instead.
205
+ requireBound('add', existing);
149
206
  if (existing.type !== credential.type)
150
207
  throw refusal('add', id, 'type-mismatch', `row ${id} is a ${existing.type} row`);
151
208
  if (identity !== undefined &&
@@ -275,6 +332,9 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
275
332
  const row = requireUsableRow('rotate', id, tx.row(id), credential);
276
333
  if (rowLockKey(row) !== rowLockKey(seen))
277
334
  throw keyChanged('rotate', id);
335
+ // A rotation keeps the row's epoch, identity and quota, so it must
336
+ // never be what stamps an unproved row as bound.
337
+ requireBound('rotate', row);
278
338
  // A rotation stays with one account: it may record the first
279
339
  // identity the row learns, but a credential of another known
280
340
  // account is a replacement (new epoch, quota and errors dropped).
@@ -282,7 +342,12 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
282
342
  row.identity !== undefined &&
283
343
  input.identity !== row.identity)
284
344
  throw identityMismatch('rotate', id);
285
- const stored = await rotateIn(rt, tx, id, credential);
345
+ const learnt = input.identity !== undefined && row.identity === undefined
346
+ ? input.identity
347
+ : undefined;
348
+ const stored = await rotateIn(rt, tx, id, credential, {
349
+ identity: learnt,
350
+ });
286
351
  let configChanged = false;
287
352
  if (!row.hasEntry) {
288
353
  tx.setEntry(id, {
@@ -553,10 +618,15 @@ export async function recordRowIdentity(rt, id, identity, attribution, options =
553
618
  const row = requireUsableRow('recordIdentity', id, tx.row(id));
554
619
  if (rowLockKey(row) !== rowLockKey(seen))
555
620
  throw keyChanged('recordIdentity', id);
621
+ requireBound('recordIdentity', row);
556
622
  if ((row.credentialEpoch ?? 1) !== captured)
557
623
  throw refusal('recordIdentity', id, 'attribution', `the identity for ${id} was looked up for a credential the row no longer holds`, true);
558
624
  if (row.identity !== undefined && row.identity !== identity)
559
625
  throw identityMismatch('recordIdentity', id);
626
+ // The stamp names the identity first; a crash before the config
627
+ // write leaves a row every reader completes forward.
628
+ if (row.identity === undefined)
629
+ await stampIdentityIn(tx, row, identity);
560
630
  const disabled = recordIdentityIn(tx, id, identity);
561
631
  await tx.commitConfig();
562
632
  return { id, disabled };
@@ -25,4 +25,12 @@ export declare function readRow(rt: StoreRuntime, operation: PoolOperation, id:
25
25
  row: PoolRow;
26
26
  }>;
27
27
  export declare function unknownRow(operation: PoolOperation, id: string): PoolOperationError;
28
+ /**
29
+ * Refuses a row the store was told to distrust: opened with
30
+ * `requireCredentialStamps`, a row whose credential stamp is not bound loads
31
+ * `unbound` (see `PoolRow.unbound`). Called on every locked read of the row
32
+ * an operation acts on, before it calls a provider or writes, so a credential
33
+ * another writer swapped in while the operation waited is refused too.
34
+ */
35
+ export declare function requireBound(operation: PoolOperation, row: PoolRow): void;
28
36
  export declare function refusal(operation: PoolOperation, id: string, kind: PoolOperationError['kind'], message: string, retryable?: boolean): PoolOperationError;
@@ -33,6 +33,17 @@ export function unknownRow(operation, id) {
33
33
  message: `no row ${id} in the pool`,
34
34
  });
35
35
  }
36
+ /**
37
+ * Refuses a row the store was told to distrust: opened with
38
+ * `requireCredentialStamps`, a row whose credential stamp is not bound loads
39
+ * `unbound` (see `PoolRow.unbound`). Called on every locked read of the row
40
+ * an operation acts on, before it calls a provider or writes, so a credential
41
+ * another writer swapped in while the operation waited is refused too.
42
+ */
43
+ export function requireBound(operation, row) {
44
+ if (row.unbound)
45
+ throw refusal(operation, row.id, 'unbound-credential', `row ${row.id}'s credential is not the one this store stamped for it (stamp ${row.stamp}); replace it with fresh material`);
46
+ }
36
47
  export function refusal(operation, id, kind, message, retryable = false) {
37
48
  return new PoolOperationError({
38
49
  operation,
@@ -42,6 +42,27 @@ export interface QuotaCodec {
42
42
  validate(value: unknown): boolean;
43
43
  merge(stored: unknown | undefined, observation: unknown): unknown;
44
44
  }
45
+ /**
46
+ * What the stamp beside a row's credential proves (see `CredentialStamp`).
47
+ *
48
+ * `none`: the row holds no credential, so there is nothing to stamp.
49
+ * `bound`: the stamp was written with this credential (its digest matches),
50
+ * names the row's credential epoch (1 for a row without a per-row entry),
51
+ * the identity it records (or its recording none) and any endpoint it
52
+ * records are the row's, and its dispatch digest matches everything a
53
+ * request would send (the OAuth access and refresh tokens and expiry; the API
54
+ * key with its `baseURL` and `authHeader`).
55
+ * `missing`: the credential carries no stamp (written by a writer that does
56
+ * not know about stamps, or one that dropped it).
57
+ * `malformed`: the stamp is not one this store writes (wrong shape, or an
58
+ * epoch outside the positive safe integers).
59
+ * `mismatched`: the stamp was written for another credential, epoch,
60
+ * identity, endpoint, or token to send than the row now holds.
61
+ * `legacy`: a well-formed stamp written with this credential's secret but
62
+ * with no dispatch digest (written by 0.4.3 or earlier), so it proves
63
+ * neither the token sent nor the account or endpoint it goes to.
64
+ */
65
+ export type CredentialStampStatus = 'none' | 'bound' | 'missing' | 'malformed' | 'mismatched' | 'legacy';
45
66
  /** One row of the pool as loaded. */
46
67
  export interface PoolRow {
47
68
  id: string;
@@ -69,11 +90,29 @@ export interface PoolRow {
69
90
  /**
70
91
  * Set when a replace stopped between its two writes: the state file holds
71
92
  * the new credential, stamped with the epoch and the identity or endpoint
72
- * it belongs to, and the config still holds the replaced row. The row is
73
- * shown as the replace leaves it once completed, is never a candidate, and
74
- * the next store write on it writes the config to match.
93
+ * it belongs to, and the config still holds the replaced row. Also set when
94
+ * a write that gives a row its first identity (`recordIdentity`, or a
95
+ * `rotate` or refresh that learns one) stopped after stamping the identity
96
+ * and before recording it in the config. The row is shown as the write
97
+ * leaves it once completed, is never a candidate, and the next store write
98
+ * on it writes the config to match.
75
99
  */
76
100
  torn?: true;
101
+ /**
102
+ * Whether the credential is the one the store last stamped for this row
103
+ * (see `CredentialStampStatus`). Every row the store loads carries it; a
104
+ * torn row reports the stamp of the row as it is shown completed. It is
105
+ * optional only so that rows built by hand (test fixtures) still type.
106
+ */
107
+ stamp?: CredentialStampStatus;
108
+ /**
109
+ * Set only when the store was opened with `requireCredentialStamps` and
110
+ * the row's `stamp` is not `bound`: the row is never a candidate, and
111
+ * `refresh`, quota pulls, `recordQuota`, `recordIdentity`, `rotate` and a
112
+ * re-`add` onto it refuse with `unbound-credential`. Only `replace` (or a
113
+ * new row) makes it usable again.
114
+ */
115
+ unbound?: true;
77
116
  }
78
117
  /**
79
118
  * Key, inside a state-file account entry, of the stamp naming what the
@@ -81,8 +120,9 @@ export interface PoolRow {
81
120
  */
82
121
  export declare const CREDENTIAL_STAMP_KEY = "commonAuthPool";
83
122
  /**
84
- * The config-side half of a replacement: the identity the new credential
85
- * belongs to (absent: none is known) and, for an API key, its endpoint.
123
+ * What the config holds for a credential when its stamp is written: the
124
+ * identity it belongs to (absent: none is known yet) and, for an API key, its
125
+ * endpoint. For a replace it is the config the replace is about to write.
86
126
  */
87
127
  export interface CredentialBinding {
88
128
  identity?: string;
@@ -93,14 +133,27 @@ export interface CredentialBinding {
93
133
  * Written beside every credential the store puts in the state file. It names
94
134
  * the credential epoch the credential belongs to and a digest of its secret,
95
135
  * so a stamp left beside a credential another writer put there afterwards is
96
- * recognisable and ignored. A replace also records the binding it gives the
97
- * row, which is what lets a reader complete a replace that stopped after
98
- * writing the credential.
136
+ * recognisable and ignored.
137
+ *
138
+ * `digest` covers only the refresh token or API key: it names the credential
139
+ * lineage, and torn-replace detection matches it, including against stamps
140
+ * older versions wrote, so it keeps that exact definition. `dispatch` covers
141
+ * everything a request sends (see `dispatchDigest`), so a token or endpoint
142
+ * changed beside an unchanged lineage secret is caught; stamps written by
143
+ * 0.4.3 or earlier lack it.
144
+ *
145
+ * `binding` is the config the credential was written beside (see
146
+ * `CredentialBinding`); every write since 0.4.4 records it, earlier versions
147
+ * only on replace. `replace` marks a stamp written by a replace, which is
148
+ * what lets a reader complete a replace that stopped after writing the
149
+ * credential: a stamp from any other write is never completed as torn.
99
150
  */
100
151
  export interface CredentialStamp {
101
152
  credentialEpoch: number;
102
153
  digest: string;
154
+ dispatch?: string;
103
155
  binding?: CredentialBinding;
156
+ replace?: true;
104
157
  }
105
158
  export type ConfigClassification = {
106
159
  status: 'ready';
@@ -137,7 +190,25 @@ export declare function fingerprintOf(credential: PoolCredential | StoredCredent
137
190
  * dedupe key.
138
191
  */
139
192
  export declare function credentialDigest(credential: PoolCredential | StoredCredential): string;
140
- export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding?: CredentialBinding): CredentialStamp;
193
+ /**
194
+ * The digest of everything a request made with the credential sends or is
195
+ * served by: the OAuth access token, refresh token and expiry (the expiry
196
+ * decides whether the access token is used or refreshed first), or the API
197
+ * key with the `baseURL` and header it is sent to. Its input prefix differs
198
+ * from both the fingerprint's and `credentialDigest`'s, so it never equals
199
+ * either. The parts are JSON-encoded as a list, so no two credentials share
200
+ * an input. An access token or expiry the state file cannot hold as loaded
201
+ * (not a string, not a finite number) counts as absent, which is how
202
+ * `buildRawRows` loads it back.
203
+ */
204
+ export declare function dispatchDigest(credential: PoolCredential | StoredCredential): string;
205
+ /**
206
+ * The stamp for a credential written into a row at `credentialEpoch`, beside
207
+ * the config `binding` describes. `replace` is set only by a replace.
208
+ */
209
+ export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding: CredentialBinding, options?: {
210
+ replace?: boolean;
211
+ }): CredentialStamp;
141
212
  /**
142
213
  * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
143
214
  * adding one may give back the same number, so a replace would not move the
@@ -69,11 +69,49 @@ export function credentialDigest(credential) {
69
69
  .update(`credential-stamp\0${secretOf(credential)}`)
70
70
  .digest('hex');
71
71
  }
72
- export function stampFor(credential, credentialEpoch, binding) {
72
+ /**
73
+ * The digest of everything a request made with the credential sends or is
74
+ * served by: the OAuth access token, refresh token and expiry (the expiry
75
+ * decides whether the access token is used or refreshed first), or the API
76
+ * key with the `baseURL` and header it is sent to. Its input prefix differs
77
+ * from both the fingerprint's and `credentialDigest`'s, so it never equals
78
+ * either. The parts are JSON-encoded as a list, so no two credentials share
79
+ * an input. An access token or expiry the state file cannot hold as loaded
80
+ * (not a string, not a finite number) counts as absent, which is how
81
+ * `buildRawRows` loads it back.
82
+ */
83
+ export function dispatchDigest(credential) {
84
+ const parts = credential.type === 'oauth'
85
+ ? [
86
+ 'oauth',
87
+ typeof credential.access === 'string' ? credential.access : null,
88
+ credential.refresh,
89
+ typeof credential.expires === 'number' &&
90
+ Number.isFinite(credential.expires)
91
+ ? credential.expires
92
+ : null,
93
+ ]
94
+ : [
95
+ 'api',
96
+ credential.apiKey,
97
+ credential.baseURL.trim(),
98
+ credential.authHeader ?? 'authorization-bearer',
99
+ ];
100
+ return createHash('sha256')
101
+ .update(`credential-dispatch\0${JSON.stringify(parts)}`)
102
+ .digest('hex');
103
+ }
104
+ /**
105
+ * The stamp for a credential written into a row at `credentialEpoch`, beside
106
+ * the config `binding` describes. `replace` is set only by a replace.
107
+ */
108
+ export function stampFor(credential, credentialEpoch, binding, options = {}) {
73
109
  return {
74
110
  credentialEpoch,
75
111
  digest: credentialDigest(credential),
76
- ...(binding ? { binding: { ...binding } } : {}),
112
+ dispatch: dispatchDigest(credential),
113
+ binding: { ...binding },
114
+ ...(options.replace ? { replace: true } : {}),
77
115
  };
78
116
  }
79
117
  /**
@@ -93,8 +131,21 @@ export function parseStamp(raw) {
93
131
  return undefined;
94
132
  if (typeof raw.digest !== 'string')
95
133
  return undefined;
134
+ if ('dispatch' in raw && typeof raw.dispatch !== 'string')
135
+ return undefined;
136
+ if ('replace' in raw && raw.replace !== true)
137
+ return undefined;
138
+ const marks = {
139
+ ...(typeof raw.dispatch === 'string' ? { dispatch: raw.dispatch } : {}),
140
+ ...(raw.replace === true ? { replace: true } : {}),
141
+ };
142
+ // Every stamp that carries a dispatch digest is written with a binding; one
143
+ // without is not a stamp this store writes, and it would leave the row's
144
+ // identity unchecked.
96
145
  if (!('binding' in raw))
97
- return { credentialEpoch: epoch, digest: raw.digest };
146
+ return 'dispatch' in raw || 'replace' in raw
147
+ ? undefined
148
+ : { credentialEpoch: epoch, digest: raw.digest };
98
149
  const binding = raw.binding;
99
150
  if (!isRecord(binding))
100
151
  return undefined;
@@ -110,6 +161,7 @@ export function parseStamp(raw) {
110
161
  return {
111
162
  credentialEpoch: epoch,
112
163
  digest: raw.digest,
164
+ ...marks,
113
165
  binding: {
114
166
  ...(typeof binding.identity === 'string'
115
167
  ? { identity: binding.identity }
@@ -124,6 +176,61 @@ export function parseStamp(raw) {
124
176
  },
125
177
  };
126
178
  }
179
+ /**
180
+ * Whether the binding of a stamp is the row's: the identity it names (or its
181
+ * naming none) must be exactly the row's recorded identity, and an endpoint
182
+ * it names must be the one the row sends its API key to.
183
+ *
184
+ * Every write that gives a row an identity (`add`, `replace`, `rotate` or a
185
+ * refresh that learns one, `recordIdentity`) writes the stamp naming it
186
+ * before the config, so this store never leaves a config identity beside a
187
+ * stamp that names none or another: that is another writer's doing and is
188
+ * `mismatched`. The reverse, a stamp naming an identity beside a config that
189
+ * has none, is such a write stopped between its two writes: the row as
190
+ * `buildRawRows` reads it from the files is `mismatched`, but `loadRows`,
191
+ * which every reader goes through, shows it with the identity recorded (see
192
+ * `torn.ts`), where it is `bound`.
193
+ */
194
+ function bindingAgrees(binding, credential, identity) {
195
+ if (binding.identity !== identity)
196
+ return false;
197
+ if (binding.baseURL !== undefined &&
198
+ (credential.type !== 'api' || credential.baseURL !== binding.baseURL))
199
+ return false;
200
+ if (binding.authHeader !== undefined &&
201
+ (credential.type !== 'api' || credential.authHeader !== binding.authHeader))
202
+ return false;
203
+ return true;
204
+ }
205
+ /**
206
+ * The stamp status of a loaded row (see `CredentialStampStatus`).
207
+ * `credentialEpoch` is undefined only when the row's per-row entry exists but
208
+ * failed validation, in which case no stamp can match it.
209
+ */
210
+ function stampStatusOf(credential, credentialEpoch, identity, account) {
211
+ if (!credential)
212
+ return 'none';
213
+ if (!isRecord(account) || !Object.hasOwn(account, CREDENTIAL_STAMP_KEY))
214
+ return 'missing';
215
+ const stamp = parseStamp(account[CREDENTIAL_STAMP_KEY]);
216
+ if (!stamp)
217
+ return 'malformed';
218
+ if (stamp.digest !== credentialDigest(credential))
219
+ return 'mismatched';
220
+ // A stamp from 0.4.3 or earlier proves neither the token sent nor (its
221
+ // binding being optional and identity-lenient) the account, so it is
222
+ // reported as such whatever else it says, and never as bound.
223
+ if (stamp.dispatch === undefined)
224
+ return 'legacy';
225
+ if (stamp.credentialEpoch !== credentialEpoch)
226
+ return 'mismatched';
227
+ // `parseStamp` refuses a stamp with a dispatch digest and no binding.
228
+ if (!stamp.binding || !bindingAgrees(stamp.binding, credential, identity))
229
+ return 'mismatched';
230
+ if (stamp.dispatch !== dispatchDigest(credential))
231
+ return 'mismatched';
232
+ return 'bound';
233
+ }
127
234
  export function classifyConfig(read) {
128
235
  if (!read.exists)
129
236
  return { status: 'ready', exists: false, config: {} };
@@ -302,6 +409,7 @@ export function buildRawRows(config, state, codec) {
302
409
  hasEntry: Object.hasOwn(entries, id),
303
410
  candidate: false,
304
411
  invalid: 'roster',
412
+ stamp: 'none',
305
413
  });
306
414
  continue;
307
415
  }
@@ -311,6 +419,9 @@ export function buildRawRows(config, state, codec) {
311
419
  const entry = hasEntry ? parseEntry(entries[id], codec) : undefined;
312
420
  const credential = credentialFor(raw, stateAccounts[id]);
313
421
  const enabled = raw.enabled !== false;
422
+ const identity = typeof raw.accountId === 'string' && raw.accountId
423
+ ? raw.accountId
424
+ : undefined;
314
425
  const row = {
315
426
  id,
316
427
  type,
@@ -320,9 +431,7 @@ export function buildRawRows(config, state, codec) {
320
431
  candidate: false,
321
432
  ...(typeof raw.label === 'string' ? { label: raw.label } : {}),
322
433
  ...(typeof raw.addedAt === 'number' ? { addedAt: raw.addedAt } : {}),
323
- ...(typeof raw.accountId === 'string' && raw.accountId
324
- ? { identity: raw.accountId }
325
- : {}),
434
+ ...(identity !== undefined ? { identity } : {}),
326
435
  ...(credential
327
436
  ? { credential, fingerprint: fingerprintOf(credential) }
328
437
  : {}),
@@ -331,6 +440,9 @@ export function buildRawRows(config, state, codec) {
331
440
  ? { disabledReason: entry.disabledReason }
332
441
  : {}),
333
442
  ...(entry && 'quota' in entry ? { quota: entry.quota } : {}),
443
+ // A row without a per-row entry is at credential epoch 1 (the epoch the
444
+ // store stamps and later gives it), so its stamp is checked against 1.
445
+ stamp: stampStatusOf(credential, entry ? entry.credentialEpoch : hasEntry ? undefined : 1, identity, stateAccounts[id]),
334
446
  };
335
447
  if (hasEntry && !entry) {
336
448
  row.invalid = 'entry';
@@ -1,9 +1,19 @@
1
1
  import { type RowEditor } from './identity.js';
2
2
  import { type CredentialBinding, type CredentialStamp, type PoolRow, type QuotaCodec } from './schema.js';
3
- /** Stamps of rows torn between the two writes of a replace, by row id. */
4
- export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): Map<string, CredentialStamp & {
5
- binding: CredentialBinding;
6
- }>;
3
+ /** How a row left between the two writes of an operation is completed. */
4
+ export type TornCompletion = {
5
+ kind: 'replace';
6
+ stamp: CredentialStamp & {
7
+ binding: CredentialBinding;
8
+ };
9
+ } | {
10
+ kind: 'identity';
11
+ identity: string;
12
+ };
13
+ /** Rows left between the two writes of an operation, by row id. */
14
+ export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
15
+ requireCredentialStamps?: boolean;
16
+ }): Map<string, TornCompletion>;
7
17
  /**
8
18
  * The config half of a replacement: the row's entry moves to the new epoch,
9
19
  * loses its quota and needs a first reading; its roster row gets the new
@@ -13,11 +23,13 @@ export declare function tornStamps(config: Record<string, unknown>, state: Recor
13
23
  */
14
24
  export declare function bindReplacement(editor: RowEditor, id: string, credentialEpoch: number, binding: CredentialBinding): void;
15
25
  /**
16
- * The config with every torn row completed as its replace would have left
17
- * it, and the ids completed. The config passed in is not modified; when
18
- * nothing is torn it is returned as is.
26
+ * The config with every torn row completed as its interrupted write would
27
+ * have left it, and the ids completed. The config passed in is not modified;
28
+ * when nothing is torn it is returned as is.
19
29
  */
20
- export declare function completeTornRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): {
30
+ export declare function completeTornRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
31
+ requireCredentialStamps?: boolean;
32
+ }): {
21
33
  config: Record<string, unknown>;
22
34
  torn: string[];
23
35
  };
@@ -25,5 +37,12 @@ export declare function completeTornRows(config: Record<string, unknown>, state:
25
37
  * The rows every reader gets. A torn row is shown completed (the identity,
26
38
  * endpoint and epoch its stamp names, beside the credential it stamps), is
27
39
  * marked `torn` and is never a candidate; every other row is as on disk.
40
+ * With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
41
+ * `unbound` and is never a candidate either. A torn row is shown with the
42
+ * stamp of the interrupted write, which binds the completed row, so it is not
43
+ * unbound; once a store write puts its completion on disk it is no longer
44
+ * torn and is a candidate again like any other row.
28
45
  */
29
- export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): PoolRow[];
46
+ export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
47
+ requireCredentialStamps?: boolean;
48
+ }): PoolRow[];
@@ -1,5 +1,5 @@
1
- import { disableIdentityDuplicates } from './identity.js';
2
- import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
1
+ import { disableIdentityDuplicates, recordIdentityIn, } from './identity.js';
2
+ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
3
3
  /*
4
4
  * A replace changes both files: the state file gets the new credential and
5
5
  * the config gets the new epoch, identity or endpoint. It writes the state
@@ -15,11 +15,37 @@ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, entryIn, isRecord
15
15
  * A stamp counts only beside the credential it was written with (its digest
16
16
  * matches): a writer that does not know about stamps may put another
17
17
  * credential beside an old one, and that stamp then says nothing. A stamp at
18
- * or behind the config's epoch is never torn: this store's own writes leave
19
- * the state file ahead of the config, never behind it.
18
+ * or behind the config's epoch is never torn as a replace: this store's own
19
+ * writes leave the state file ahead of the config, never behind it.
20
+ *
21
+ * Only a replace's stamp is ever completed as a replace. Since 0.4.4 every
22
+ * stamp carries a binding, so a replace's says so itself (`replace: true`); a
23
+ * stamp written by 0.4.3 or earlier has no dispatch digest, and those
24
+ * versions wrote a binding only on replace, so such a stamp with a binding is
25
+ * a replace's. A stamp from any other write that sits ahead of the config was
26
+ * not left by a crash (those writes keep the row's epoch), and completing it
27
+ * would rewrite the row's identity and drop its quota on a foreign writer's
28
+ * say-so. With `requireCredentialStamps` a 0.4.3-shaped replace stamp is not
29
+ * completed either: it proves nothing about the token sent, so the strict
30
+ * store leaves the row as it is on disk (`legacy`, unbound) rather than act
31
+ * on it; a store without the option completes it as before.
32
+ *
33
+ * A write that gives a row its first identity (`recordIdentity`, or a
34
+ * `rotate` or refresh that learns one) follows the same order: the stamp
35
+ * naming the identity first, then the config recording it. A crash between
36
+ * leaves a stamp at the row's epoch, matching the credential beside it
37
+ * exactly (digest and dispatch digest), that names an identity the config
38
+ * does not record; that is completed forward the same way, by recording the
39
+ * identity.
20
40
  */
21
- /** Stamps of rows torn between the two writes of a replace, by row id. */
22
- export function tornStamps(config, state, codec) {
41
+ /** Whether a well-formed stamp was written by a replace. */
42
+ function isReplaceStamp(stamp) {
43
+ if (!stamp.binding)
44
+ return false;
45
+ return stamp.replace === true || stamp.dispatch === undefined;
46
+ }
47
+ /** Rows left between the two writes of an operation, by row id. */
48
+ export function tornStamps(config, state, codec, options = {}) {
23
49
  const accounts = isRecord(state.accounts) ? state.accounts : {};
24
50
  const torn = new Map();
25
51
  for (const row of buildRawRows(config, state, codec)) {
@@ -36,9 +62,24 @@ export function tornStamps(config, state, codec) {
36
62
  if (stamp.digest !== credentialDigest(row.credential))
37
63
  continue;
38
64
  // A row without an entry is at epoch 1, as everywhere else.
39
- if (stamp.credentialEpoch <= (row.credentialEpoch ?? 1))
40
- continue;
41
- torn.set(row.id, { ...stamp, binding: stamp.binding });
65
+ const epoch = row.credentialEpoch ?? 1;
66
+ if (stamp.credentialEpoch > epoch) {
67
+ if (!isReplaceStamp(stamp))
68
+ continue;
69
+ if (options.requireCredentialStamps && stamp.dispatch === undefined)
70
+ continue;
71
+ torn.set(row.id, {
72
+ kind: 'replace',
73
+ stamp: { ...stamp, binding: stamp.binding },
74
+ });
75
+ }
76
+ else if (stamp.credentialEpoch === epoch &&
77
+ stamp.dispatch !== undefined &&
78
+ stamp.dispatch === dispatchDigest(row.credential) &&
79
+ stamp.binding.identity !== undefined &&
80
+ row.identity === undefined) {
81
+ torn.set(row.id, { kind: 'identity', identity: stamp.binding.identity });
82
+ }
42
83
  }
43
84
  return torn;
44
85
  }
@@ -71,13 +112,13 @@ export function bindReplacement(editor, id, credentialEpoch, binding) {
71
112
  raw.authHeader = binding.authHeader;
72
113
  }
73
114
  /**
74
- * The config with every torn row completed as its replace would have left
75
- * it, and the ids completed. The config passed in is not modified; when
76
- * nothing is torn it is returned as is.
115
+ * The config with every torn row completed as its interrupted write would
116
+ * have left it, and the ids completed. The config passed in is not modified;
117
+ * when nothing is torn it is returned as is.
77
118
  */
78
- export function completeTornRows(config, state, codec) {
79
- const stamps = tornStamps(config, state, codec);
80
- if (stamps.size === 0)
119
+ export function completeTornRows(config, state, codec, options = {}) {
120
+ const completions = tornStamps(config, state, codec, options);
121
+ if (completions.size === 0)
81
122
  return { config, torn: [] };
82
123
  const next = structuredClone(config);
83
124
  const editor = {
@@ -86,28 +127,39 @@ export function completeTornRows(config, state, codec) {
86
127
  entry: (id) => entryIn(next, id),
87
128
  setEntry: (id, entry) => setEntryIn(next, id, entry),
88
129
  };
89
- for (const [id, stamp] of stamps)
90
- bindReplacement(editor, id, stamp.credentialEpoch, stamp.binding);
91
- for (const stamp of stamps.values())
92
- if (stamp.binding.identity !== undefined)
93
- disableIdentityDuplicates(editor, stamp.binding.identity);
94
- return { config: next, torn: [...stamps.keys()] };
130
+ for (const [id, completion] of completions)
131
+ if (completion.kind === 'replace')
132
+ bindReplacement(editor, id, completion.stamp.credentialEpoch, completion.stamp.binding);
133
+ for (const [id, completion] of completions) {
134
+ if (completion.kind === 'identity')
135
+ recordIdentityIn(editor, id, completion.identity);
136
+ else if (completion.stamp.binding.identity !== undefined)
137
+ disableIdentityDuplicates(editor, completion.stamp.binding.identity);
138
+ }
139
+ return { config: next, torn: [...completions.keys()] };
95
140
  }
96
141
  /**
97
142
  * The rows every reader gets. A torn row is shown completed (the identity,
98
143
  * endpoint and epoch its stamp names, beside the credential it stamps), is
99
144
  * marked `torn` and is never a candidate; every other row is as on disk.
145
+ * With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
146
+ * `unbound` and is never a candidate either. A torn row is shown with the
147
+ * stamp of the interrupted write, which binds the completed row, so it is not
148
+ * unbound; once a store write puts its completion on disk it is no longer
149
+ * torn and is a candidate again like any other row.
100
150
  */
101
- export function loadRows(config, state, codec) {
102
- const { config: whole, torn } = completeTornRows(config, state, codec);
151
+ export function loadRows(config, state, codec, options = {}) {
152
+ const { config: whole, torn } = completeTornRows(config, state, codec, options);
103
153
  const rows = buildRawRows(whole, state, codec);
104
- if (torn.length === 0)
105
- return rows;
106
154
  for (const row of rows) {
107
- if (!torn.includes(row.id))
108
- continue;
109
- row.torn = true;
110
- row.candidate = false;
155
+ if (torn.includes(row.id)) {
156
+ row.torn = true;
157
+ row.candidate = false;
158
+ }
159
+ if (options.requireCredentialStamps && row.stamp !== 'bound') {
160
+ row.unbound = true;
161
+ row.candidate = false;
162
+ }
111
163
  }
112
164
  return rows;
113
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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": {