@cortexkit/common-auth 0.4.6 → 0.6.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.
@@ -2,8 +2,9 @@ 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 { acceptProviderState, mergedProviderState, providerStateCoverage, replacementProviderState, } from './provider-state.js';
5
6
  import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
6
- import { CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
7
+ import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
7
8
  import { bindReplacement } from './torn.js';
8
9
  /** Fields of a state entry that belong to the credential it replaces. */
9
10
  const CREDENTIAL_STATE_FIELDS = [
@@ -90,6 +91,14 @@ function bindingInTx(tx, id, stored, learnt) {
90
91
  export async function rotateIn(rt, tx, id, given, extra = {}) {
91
92
  const credential = onRowEndpoint(tx, id, given);
92
93
  const prior = tx.stateAccount(id);
94
+ const stateWrite = extra.providerState ?? { kind: 'keep' };
95
+ const epoch = tx.entry(id)?.credentialEpoch;
96
+ const credentialEpoch = typeof epoch === 'number' ? epoch : 1;
97
+ // The provider state goes in the same state write as the credential and
98
+ // its stamp, so no reader ever sees one without the other. A kept value is
99
+ // bound by the new stamp only if the old one bound it to this row at the
100
+ // epoch being written; a replace moves the epoch, so it never keeps one.
101
+ const providerStateBinding = providerStateCoverage(rt.ctx.providerState, stateWrite, prior, prior && Object.hasOwn(prior, PROVIDER_STATE_KEY) ? tx.row(id) : undefined, credentialEpoch);
93
102
  const priorStamp = typeof prior?.lastRefreshedAt === 'number'
94
103
  ? prior.lastRefreshedAt
95
104
  : undefined;
@@ -104,12 +113,20 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
104
113
  delete kept.lastQuotaRefreshError;
105
114
  delete kept.quota;
106
115
  }
116
+ if (stateWrite.kind === 'clear')
117
+ delete kept[PROVIDER_STATE_KEY];
118
+ else if (stateWrite.kind === 'set')
119
+ kept[PROVIDER_STATE_KEY] = stateWrite.value;
107
120
  const stored = storedCredential(credential, stamp);
108
- const epoch = tx.entry(id)?.credentialEpoch;
109
121
  tx.setStateAccount(id, {
110
122
  ...kept,
111
123
  ...stateFieldsFor(credential, stamp),
112
- [CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), { replace: extra.binding !== undefined }),
124
+ [CREDENTIAL_STAMP_KEY]: stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
125
+ replace: extra.binding !== undefined,
126
+ ...(providerStateBinding !== undefined
127
+ ? { providerState: providerStateBinding }
128
+ : {}),
129
+ }),
113
130
  });
114
131
  await tx.commitState(stored);
115
132
  return stored;
@@ -126,9 +143,14 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
126
143
  async function stampIdentityIn(tx, row, identity) {
127
144
  if (!row.credential || row.stamp !== 'bound')
128
145
  return;
146
+ const account = tx.stateAccount(row.id);
147
+ // The provider state stays bound across the identity being learnt: the old
148
+ // stamp bound it to the row with no identity, the new one to the identity
149
+ // the config is about to record.
150
+ const providerState = boundProviderStateDigest(account, row.credential, row.credentialEpoch ?? 1, row.identity);
129
151
  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)),
152
+ ...(account ?? {}),
153
+ [CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity), providerState !== undefined ? { providerState } : {}),
132
154
  });
133
155
  await tx.commitState();
134
156
  }
@@ -165,6 +187,9 @@ export async function addRow(rt, input, options = {}) {
165
187
  const { id, credential, identity, label } = input;
166
188
  const result = await runOperation(ctx, 'add', id, options.onFailure, async (locks, progress) => {
167
189
  checkInput('add', id, credential);
190
+ const incoming = input.providerState === undefined
191
+ ? undefined
192
+ : acceptProviderState(ctx.providerState, 'add', id, input.providerState);
168
193
  if (ctx.removedIds.has(id))
169
194
  throw refusal('add', id, 'id-removed', `id ${id} was removed from the roster in this process and is not reused`);
170
195
  await locks.acquire(rowLockSpec(rt, { id, identity }));
@@ -190,7 +215,13 @@ export async function addRow(rt, input, options = {}) {
190
215
  same.identity !== undefined &&
191
216
  identity !== same.identity)
192
217
  throw identityMismatch('add', same.id);
193
- const stored = await rotateIn(rt, tx, same.id, credential);
218
+ const stored = await rotateIn(rt, tx, same.id, credential, {
219
+ ...(incoming !== undefined
220
+ ? {
221
+ providerState: mergedProviderState(ctx.providerState, 'add', same.id, same.providerState, incoming),
222
+ }
223
+ : {}),
224
+ });
194
225
  return { id: same.id, outcome: 'rotated', credential: stored };
195
226
  }
196
227
  const existing = rows.find((row) => row.id === id);
@@ -219,6 +250,9 @@ export async function addRow(rt, input, options = {}) {
219
250
  });
220
251
  const stored = await rotateIn(rt, tx, id, credential, {
221
252
  clearErrors: true,
253
+ ...(incoming !== undefined
254
+ ? { providerState: { kind: 'set', value: incoming } }
255
+ : {}),
222
256
  });
223
257
  if (!existing.hasEntry)
224
258
  await tx.commitConfig();
@@ -253,7 +287,11 @@ export async function addRow(rt, input, options = {}) {
253
287
  // roster row could load beside a credential left under its id by an
254
288
  // interrupted removal. Nothing of such a leftover entry is kept.
255
289
  tx.dropStateAccount(id);
256
- const stored = await rotateIn(rt, tx, id, credential);
290
+ const stored = await rotateIn(rt, tx, id, credential, {
291
+ ...(incoming !== undefined
292
+ ? { providerState: { kind: 'set', value: incoming } }
293
+ : {}),
294
+ });
257
295
  await tx.commitConfig();
258
296
  return { id, outcome, credential: stored };
259
297
  });
@@ -268,6 +306,9 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
268
306
  const { ctx } = rt;
269
307
  const result = await runOperation(ctx, 'replace', id, options.onFailure, async (locks, progress) => {
270
308
  checkInput('replace', id, credential);
309
+ const incoming = input.providerState === undefined
310
+ ? undefined
311
+ : acceptProviderState(ctx.providerState, 'replace', id, input.providerState);
271
312
  const { row: seen } = await readRow(rt, 'replace', id);
272
313
  await locks.acquire(rowLockSpec(rt, seen));
273
314
  if (credential.type === 'oauth')
@@ -285,6 +326,9 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
285
326
  // Refused before anything is written; the row keeps its credential.
286
327
  if (!isCredentialEpoch(credentialEpoch))
287
328
  throw refusal('replace', id, 'invalid-row', `row ${id}'s credential epoch cannot advance past ${Number.MAX_SAFE_INTEGER}; remove the row and add the new credential as a new row`);
329
+ // Decided before anything is written: a hook that throws or
330
+ // returns a value the codec rejects leaves the row as it was.
331
+ const providerState = replacementProviderState(ctx.providerState, row, credentialEpoch, input.identity, incoming);
288
332
  const binding = {
289
333
  ...(input.identity !== undefined
290
334
  ? { identity: input.identity }
@@ -308,6 +352,7 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
308
352
  const stored = await rotateIn(rt, tx, id, credential, {
309
353
  clearErrors: true,
310
354
  binding,
355
+ providerState,
311
356
  });
312
357
  await tx.commitConfig();
313
358
  return { id, credential: stored, credentialEpoch };
@@ -322,6 +367,9 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
322
367
  const { ctx } = rt;
323
368
  return runOperation(ctx, 'rotate', id, options.onFailure, async (locks, progress) => {
324
369
  checkInput('rotate', id, credential);
370
+ const incoming = input.providerState === undefined
371
+ ? undefined
372
+ : acceptProviderState(ctx.providerState, 'rotate', id, input.providerState);
325
373
  const { row: seen } = await readRow(rt, 'rotate', id);
326
374
  await locks.acquire(rowLockSpec(rt, seen));
327
375
  if (input.identity !== undefined && credential.type === 'oauth')
@@ -347,6 +395,11 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
347
395
  : undefined;
348
396
  const stored = await rotateIn(rt, tx, id, credential, {
349
397
  identity: learnt,
398
+ ...(incoming !== undefined
399
+ ? {
400
+ providerState: mergedProviderState(ctx.providerState, 'rotate', id, row.providerState, incoming),
401
+ }
402
+ : {}),
350
403
  });
351
404
  let configChanged = false;
352
405
  if (!row.hasEntry) {
@@ -42,6 +42,69 @@ export interface QuotaCodec {
42
42
  validate(value: unknown): boolean;
43
43
  merge(stored: unknown | undefined, observation: unknown): unknown;
44
44
  }
45
+ /**
46
+ * What `ProviderStateCodec.onReplace` is told about a replacement: the row,
47
+ * the credential epoch the new credential starts, the identity the replace
48
+ * records (absent: none), and the provider state the caller handed to
49
+ * `replace`, if any.
50
+ */
51
+ export interface ProviderStateReplacement {
52
+ id: string;
53
+ credentialEpoch: number;
54
+ identity?: string;
55
+ incoming?: unknown;
56
+ }
57
+ /**
58
+ * Codec for the provider state a plugin keeps beside each row's credential
59
+ * (a project id, a device fingerprint, eligibility times: whatever belongs to
60
+ * that credential). The store never interprets the value: it stores it as
61
+ * JSON, asks `validate` before every write and on every load, and calls the
62
+ * hooks below where two values meet. Every hook is synchronous and runs
63
+ * under the store locks.
64
+ */
65
+ export interface ProviderStateCodec {
66
+ validate(value: unknown): boolean;
67
+ /**
68
+ * The part of a (valid) value that belongs to the credential: what is true
69
+ * of the account the credential signs in to, such as a project id or a
70
+ * device fingerprint, as opposed to what the plugin merely tracks about
71
+ * its use, such as a cooldown or a cursor. The credential stamp covers the
72
+ * digest of exactly this projection, so a foreign edit of it hides the
73
+ * value, while the rest may change through `updateProviderState` without
74
+ * the stamp being rewritten. It must be a pure function of the value that
75
+ * returns JSON with a deterministic key order. Without it the whole value
76
+ * is credential-bound.
77
+ */
78
+ credentialBound?(value: unknown): unknown;
79
+ /**
80
+ * Combines the value on disk with one a write brings (`add` of a secret the
81
+ * pool already holds, `rotate`, or a refresh whose provider returned a
82
+ * state). Called only when the row has a value on disk; without it the
83
+ * incoming value replaces the stored one. Its result is validated.
84
+ */
85
+ merge?(onDisk: unknown, incoming: unknown): unknown;
86
+ /**
87
+ * The provider state of a row once `replace` gives it a new credential
88
+ * epoch. `previous` is the old credential's value (undefined when the row
89
+ * shows none). Returning undefined clears it. Without this hook a replace
90
+ * keeps the value handed to `replace` and otherwise clears the state.
91
+ */
92
+ onReplace?(previous: unknown | undefined, replacement: ProviderStateReplacement): unknown | undefined;
93
+ }
94
+ /**
95
+ * Why a row shows no provider state although the state file holds one for it.
96
+ *
97
+ * `uncovered`: the value's credential-bound part (see
98
+ * `ProviderStateCodec.credentialBound`) is not the one this store last
99
+ * wrote beside the row's credential at its credential epoch and recorded
100
+ * identity. Another writer edited it, or
101
+ * wrote the credential beside it without knowing about it (an older version
102
+ * of this library, which drops the coverage, or a writer that does not know
103
+ * the pool at all), or it belongs to an earlier credential of the row.
104
+ * `invalid`: the value is covered but the codec rejects it, or the store was
105
+ * opened without a provider-state codec.
106
+ */
107
+ export type ProviderStateDrop = 'uncovered' | 'invalid';
45
108
  /**
46
109
  * What the stamp beside a row's credential proves (see `CredentialStamp`).
47
110
  *
@@ -82,6 +145,18 @@ export interface PoolRow {
82
145
  disabledReason?: string;
83
146
  /** The opaque quota map, as validated by the codec. */
84
147
  quota?: unknown;
148
+ /**
149
+ * The opaque provider state kept beside the credential, as validated by
150
+ * the provider-state codec. Absent when the row has none, or when the one
151
+ * on disk is not shown (see `providerStateDropped`).
152
+ */
153
+ providerState?: unknown;
154
+ /**
155
+ * Set when the state file holds a provider state for the row that is not
156
+ * shown, and why. The value stays on disk untouched until a write on the
157
+ * row sets or clears it; the row itself stays usable.
158
+ */
159
+ providerStateDropped?: ProviderStateDrop;
85
160
  hasEntry: boolean;
86
161
  /** A row that may be refreshed, pulled for, or admitted. */
87
162
  candidate: boolean;
@@ -147,6 +222,12 @@ export interface CredentialBinding {
147
222
  * only on replace. `replace` marks a stamp written by a replace, which is
148
223
  * what lets a reader complete a replace that stopped after writing the
149
224
  * credential: a stamp from any other write is never completed as torn.
225
+ *
226
+ * `providerState` is the digest of the credential-bound part of the provider
227
+ * state beside the credential (see `providerStateDigest`); it is absent when
228
+ * the row has none, so the stamp of a row without provider state is exactly
229
+ * what 0.5.0 wrote. It is apart from `digest` and `dispatch`, which keep
230
+ * their meaning: the credential's stamp status never depends on it.
150
231
  */
151
232
  export interface CredentialStamp {
152
233
  credentialEpoch: number;
@@ -154,7 +235,13 @@ export interface CredentialStamp {
154
235
  dispatch?: string;
155
236
  binding?: CredentialBinding;
156
237
  replace?: true;
238
+ providerState?: string;
157
239
  }
240
+ /**
241
+ * Key, inside a state-file account entry, of the provider state kept beside
242
+ * the credential. Older readers ignore it.
243
+ */
244
+ export declare const PROVIDER_STATE_KEY = "commonAuthProviderState";
158
245
  export type ConfigClassification = {
159
246
  status: 'ready';
160
247
  exists: boolean;
@@ -208,7 +295,28 @@ export declare function dispatchDigest(credential: PoolCredential | StoredCreden
208
295
  */
209
296
  export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding: CredentialBinding, options?: {
210
297
  replace?: boolean;
298
+ providerState?: string;
211
299
  }): CredentialStamp;
300
+ /**
301
+ * The digest a stamp carries for a provider state: of its credential-bound
302
+ * part (`ProviderStateCodec.credentialBound`, the whole value without it), as
303
+ * it serializes. The store only ever stores values that survive a JSON round
304
+ * trip unchanged, so the digest of a value read back from disk equals the one
305
+ * computed when it was written. Its input prefix differs from every other
306
+ * digest the store writes.
307
+ */
308
+ export declare function providerStateDigest(codec: Pick<ProviderStateCodec, 'credentialBound'> | undefined, value: unknown): string;
309
+ /**
310
+ * The provider-state digest of the stamp in a state-file account entry, when
311
+ * that stamp binds it to this row: written with this credential (its lineage
312
+ * digest matches), at this credential epoch, naming this recorded identity.
313
+ * Whether the value beside it still has that digest is checked apart, by
314
+ * `providerStateFields`; this alone is what a write that keeps the value
315
+ * carries into the stamp it writes, so a value no stamp of this store bound
316
+ * to the row stays unbound rather than being vouched for, and one edited
317
+ * after it was bound stays detectably edited.
318
+ */
319
+ export declare function boundProviderStateDigest(account: unknown, credential: PoolCredential | StoredCredential | undefined, credentialEpoch: number | undefined, identity: string | undefined): string | undefined;
212
320
  /**
213
321
  * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
214
322
  * adding one may give back the same number, so a replace would not move the
@@ -245,7 +353,7 @@ export declare function setEntryIn(config: Record<string, unknown>, id: string,
245
353
  * malformed per-row entry makes that one row invalid (never a candidate) and
246
354
  * blocks nothing else.
247
355
  */
248
- export declare function buildRawRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): PoolRow[];
356
+ export declare function buildRawRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, providerCodec?: ProviderStateCodec): PoolRow[];
249
357
  /** The row-lock key: recorded wire identity when known, else the local id. */
250
358
  export declare function rowLockKey(row: Pick<PoolRow, 'id' | 'identity'>): string;
251
359
  /**
@@ -18,6 +18,11 @@ export const REFRESH_STAMP_TOLERANCE_MS = 5 * 60_000;
18
18
  * credential beside it belongs to. Older readers ignore it.
19
19
  */
20
20
  export const CREDENTIAL_STAMP_KEY = POOL_KEY;
21
+ /**
22
+ * Key, inside a state-file account entry, of the provider state kept beside
23
+ * the credential. Older readers ignore it.
24
+ */
25
+ export const PROVIDER_STATE_KEY = 'commonAuthProviderState';
21
26
  export function isRecord(value) {
22
27
  return value != null && typeof value === 'object' && !Array.isArray(value);
23
28
  }
@@ -112,8 +117,49 @@ export function stampFor(credential, credentialEpoch, binding, options = {}) {
112
117
  dispatch: dispatchDigest(credential),
113
118
  binding: { ...binding },
114
119
  ...(options.replace ? { replace: true } : {}),
120
+ ...(options.providerState !== undefined
121
+ ? { providerState: options.providerState }
122
+ : {}),
115
123
  };
116
124
  }
125
+ /**
126
+ * The digest a stamp carries for a provider state: of its credential-bound
127
+ * part (`ProviderStateCodec.credentialBound`, the whole value without it), as
128
+ * it serializes. The store only ever stores values that survive a JSON round
129
+ * trip unchanged, so the digest of a value read back from disk equals the one
130
+ * computed when it was written. Its input prefix differs from every other
131
+ * digest the store writes.
132
+ */
133
+ export function providerStateDigest(codec, value) {
134
+ const bound = codec?.credentialBound ? codec.credentialBound(value) : value;
135
+ return createHash('sha256')
136
+ .update(`provider-state\0${JSON.stringify(bound ?? null)}`)
137
+ .digest('hex');
138
+ }
139
+ /**
140
+ * The provider-state digest of the stamp in a state-file account entry, when
141
+ * that stamp binds it to this row: written with this credential (its lineage
142
+ * digest matches), at this credential epoch, naming this recorded identity.
143
+ * Whether the value beside it still has that digest is checked apart, by
144
+ * `providerStateFields`; this alone is what a write that keeps the value
145
+ * carries into the stamp it writes, so a value no stamp of this store bound
146
+ * to the row stays unbound rather than being vouched for, and one edited
147
+ * after it was bound stays detectably edited.
148
+ */
149
+ export function boundProviderStateDigest(account, credential, credentialEpoch, identity) {
150
+ if (!credential || credentialEpoch === undefined)
151
+ return undefined;
152
+ if (!isRecord(account) || !Object.hasOwn(account, PROVIDER_STATE_KEY))
153
+ return undefined;
154
+ const stamp = parseStamp(account[CREDENTIAL_STAMP_KEY]);
155
+ if (!stamp ||
156
+ stamp.providerState === undefined ||
157
+ stamp.digest !== credentialDigest(credential) ||
158
+ stamp.credentialEpoch !== credentialEpoch ||
159
+ stamp.binding?.identity !== identity)
160
+ return undefined;
161
+ return stamp.providerState;
162
+ }
117
163
  /**
118
164
  * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
119
165
  * adding one may give back the same number, so a replace would not move the
@@ -135,15 +181,20 @@ export function parseStamp(raw) {
135
181
  return undefined;
136
182
  if ('replace' in raw && raw.replace !== true)
137
183
  return undefined;
184
+ if ('providerState' in raw && typeof raw.providerState !== 'string')
185
+ return undefined;
138
186
  const marks = {
139
187
  ...(typeof raw.dispatch === 'string' ? { dispatch: raw.dispatch } : {}),
140
188
  ...(raw.replace === true ? { replace: true } : {}),
189
+ ...(typeof raw.providerState === 'string'
190
+ ? { providerState: raw.providerState }
191
+ : {}),
141
192
  };
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.
193
+ // Every stamp that carries a dispatch digest or a provider-state digest is
194
+ // written with a binding; one without is not a stamp this store writes,
195
+ // and it would leave the row's identity unchecked.
145
196
  if (!('binding' in raw))
146
- return 'dispatch' in raw || 'replace' in raw
197
+ return 'dispatch' in raw || 'replace' in raw || 'providerState' in raw
147
198
  ? undefined
148
199
  : { credentialEpoch: epoch, digest: raw.digest };
149
200
  const binding = raw.binding;
@@ -389,7 +440,7 @@ export function setEntryIn(config, id, entry) {
389
440
  * malformed per-row entry makes that one row invalid (never a candidate) and
390
441
  * blocks nothing else.
391
442
  */
392
- export function buildRawRows(config, state, codec) {
443
+ export function buildRawRows(config, state, codec, providerCodec) {
393
444
  const entries = entriesOf(config);
394
445
  const stateAccounts = stateAccountsOf(state);
395
446
  const seen = new Set();
@@ -419,6 +470,9 @@ export function buildRawRows(config, state, codec) {
419
470
  const entry = hasEntry ? parseEntry(entries[id], codec) : undefined;
420
471
  const credential = credentialFor(raw, stateAccounts[id]);
421
472
  const enabled = raw.enabled !== false;
473
+ // A row without a per-row entry is at credential epoch 1 (the epoch the
474
+ // store stamps and later gives it), so its stamp is checked against 1.
475
+ const stampEpoch = entry ? entry.credentialEpoch : hasEntry ? undefined : 1;
422
476
  const identity = typeof raw.accountId === 'string' && raw.accountId
423
477
  ? raw.accountId
424
478
  : undefined;
@@ -440,9 +494,8 @@ export function buildRawRows(config, state, codec) {
440
494
  ? { disabledReason: entry.disabledReason }
441
495
  : {}),
442
496
  ...(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]),
497
+ ...providerStateFields(stateAccounts[id], credential, stampEpoch, identity, providerCodec),
498
+ stamp: stampStatusOf(credential, stampEpoch, identity, stateAccounts[id]),
446
499
  };
447
500
  if (hasEntry && !entry) {
448
501
  row.invalid = 'entry';
@@ -454,6 +507,30 @@ export function buildRawRows(config, state, codec) {
454
507
  }
455
508
  return rows;
456
509
  }
510
+ /**
511
+ * The provider-state fields of a loaded row. A value on disk is shown only
512
+ * when the stamp beside the credential binds a provider state to this row's
513
+ * credential, epoch and identity (see `boundProviderStateDigest`), the codec
514
+ * accepts the value, and the value's credential-bound part has the digest
515
+ * that stamp names. Otherwise the row says why it shows none and stays
516
+ * usable: the value is the plugin's own derived data, which it can rebuild,
517
+ * and the credential beside it may well be sound.
518
+ */
519
+ function providerStateFields(account, credential, credentialEpoch, identity, codec) {
520
+ if (!isRecord(account) || !Object.hasOwn(account, PROVIDER_STATE_KEY))
521
+ return {};
522
+ const digest = boundProviderStateDigest(account, credential, credentialEpoch, identity);
523
+ if (digest === undefined)
524
+ return { providerStateDropped: 'uncovered' };
525
+ const value = account[PROVIDER_STATE_KEY];
526
+ // The value is validated before it is projected, so the projection only
527
+ // ever sees a value of the shape the codec accepts.
528
+ if (!codec?.validate(value))
529
+ return { providerStateDropped: 'invalid' };
530
+ if (providerStateDigest(codec, value) !== digest)
531
+ return { providerStateDropped: 'uncovered' };
532
+ return { providerState: value };
533
+ }
457
534
  /** The row-lock key: recorded wire identity when known, else the local id. */
458
535
  export function rowLockKey(row) {
459
536
  return row.identity ?? row.id;
@@ -1,5 +1,5 @@
1
1
  import { type RowEditor } from './identity.js';
2
- import { type CredentialBinding, type CredentialStamp, type PoolRow, type QuotaCodec } from './schema.js';
2
+ import { type CredentialBinding, type CredentialStamp, type PoolRow, type ProviderStateCodec, type QuotaCodec } from './schema.js';
3
3
  /** How a row left between the two writes of an operation is completed. */
4
4
  export type TornCompletion = {
5
5
  kind: 'replace';
@@ -45,4 +45,5 @@ export declare function completeTornRows(config: Record<string, unknown>, state:
45
45
  */
46
46
  export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
47
47
  requireCredentialStamps?: boolean;
48
+ providerState?: ProviderStateCodec;
48
49
  }): PoolRow[];
@@ -192,7 +192,10 @@ export function completeTornRows(config, state, codec, options = {}) {
192
192
  */
193
193
  export function loadRows(config, state, codec, options = {}) {
194
194
  const { config: whole, torn } = completeTornRows(config, state, codec, options);
195
- const rows = buildRawRows(whole, state, codec);
195
+ // A torn replace wrote its provider state beside the new credential, under
196
+ // the stamp naming the new epoch, so the completed row shows the new
197
+ // credential with the state written for it, never with the old one.
198
+ const rows = buildRawRows(whole, state, codec, options.providerState);
196
199
  for (const row of rows) {
197
200
  if (torn.includes(row.id)) {
198
201
  row.torn = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.4.6",
3
+ "version": "0.6.0",
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": {
@@ -87,7 +87,7 @@
87
87
  "types": "bun run typecheck",
88
88
  "format": "biome check --write --unsafe .",
89
89
  "format:check": "biome format .",
90
- "lint": "biome lint .",
90
+ "lint": "biome check .",
91
91
  "prepublishOnly": "bun run build",
92
92
  "check:ranges": "bun scripts/check-installed-ranges.mjs"
93
93
  },