@cortexkit/common-auth 0.5.0 → 0.7.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.
@@ -1,10 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { PoolOperationError } from './errors.js';
2
3
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
- import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
4
+ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
4
5
  import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
6
+ import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
5
7
  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 { bindReplacement } from './torn.js';
8
+ import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
9
+ import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
8
10
  /** Fields of a state entry that belong to the credential it replaces. */
9
11
  const CREDENTIAL_STATE_FIELDS = [
10
12
  'access',
@@ -90,6 +92,14 @@ function bindingInTx(tx, id, stored, learnt) {
90
92
  export async function rotateIn(rt, tx, id, given, extra = {}) {
91
93
  const credential = onRowEndpoint(tx, id, given);
92
94
  const prior = tx.stateAccount(id);
95
+ const stateWrite = extra.providerState ?? { kind: 'keep' };
96
+ const epoch = tx.entry(id)?.credentialEpoch;
97
+ const credentialEpoch = typeof epoch === 'number' ? epoch : 1;
98
+ // The provider state goes in the same state write as the credential and
99
+ // its stamp, so no reader ever sees one without the other. A kept value is
100
+ // bound by the new stamp only if the old one bound it to this row at the
101
+ // epoch being written; a replace moves the epoch, so it never keeps one.
102
+ const providerStateBinding = providerStateCoverage(rt.ctx.providerState, stateWrite, prior, prior && Object.hasOwn(prior, PROVIDER_STATE_KEY) ? tx.row(id) : undefined, credentialEpoch);
93
103
  const priorStamp = typeof prior?.lastRefreshedAt === 'number'
94
104
  ? prior.lastRefreshedAt
95
105
  : undefined;
@@ -104,12 +114,20 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
104
114
  delete kept.lastQuotaRefreshError;
105
115
  delete kept.quota;
106
116
  }
117
+ if (stateWrite.kind === 'clear')
118
+ delete kept[PROVIDER_STATE_KEY];
119
+ else if (stateWrite.kind === 'set')
120
+ kept[PROVIDER_STATE_KEY] = stateWrite.value;
107
121
  const stored = storedCredential(credential, stamp);
108
- const epoch = tx.entry(id)?.credentialEpoch;
109
122
  tx.setStateAccount(id, {
110
123
  ...kept,
111
124
  ...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 }),
125
+ [CREDENTIAL_STAMP_KEY]: stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
126
+ replace: extra.binding !== undefined,
127
+ ...(providerStateBinding !== undefined
128
+ ? { providerState: providerStateBinding }
129
+ : {}),
130
+ }),
113
131
  });
114
132
  await tx.commitState(stored);
115
133
  return stored;
@@ -126,9 +144,14 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
126
144
  async function stampIdentityIn(tx, row, identity) {
127
145
  if (!row.credential || row.stamp !== 'bound')
128
146
  return;
147
+ const account = tx.stateAccount(row.id);
148
+ // The provider state stays bound across the identity being learnt: the old
149
+ // stamp bound it to the row with no identity, the new one to the identity
150
+ // the config is about to record.
151
+ const providerState = boundProviderStateDigest(account, row.credential, row.credentialEpoch ?? 1, row.identity);
129
152
  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)),
153
+ ...(account ?? {}),
154
+ [CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity), providerState !== undefined ? { providerState } : {}),
132
155
  });
133
156
  await tx.commitState();
134
157
  }
@@ -165,6 +188,9 @@ export async function addRow(rt, input, options = {}) {
165
188
  const { id, credential, identity, label } = input;
166
189
  const result = await runOperation(ctx, 'add', id, options.onFailure, async (locks, progress) => {
167
190
  checkInput('add', id, credential);
191
+ const incoming = input.providerState === undefined
192
+ ? undefined
193
+ : acceptProviderState(ctx.providerState, 'add', id, input.providerState);
168
194
  if (ctx.removedIds.has(id))
169
195
  throw refusal('add', id, 'id-removed', `id ${id} was removed from the roster in this process and is not reused`);
170
196
  await locks.acquire(rowLockSpec(rt, { id, identity }));
@@ -190,7 +216,13 @@ export async function addRow(rt, input, options = {}) {
190
216
  same.identity !== undefined &&
191
217
  identity !== same.identity)
192
218
  throw identityMismatch('add', same.id);
193
- const stored = await rotateIn(rt, tx, same.id, credential);
219
+ const stored = await rotateIn(rt, tx, same.id, credential, {
220
+ ...(incoming !== undefined
221
+ ? {
222
+ providerState: mergedProviderState(ctx.providerState, 'add', same.id, same.providerState, incoming),
223
+ }
224
+ : {}),
225
+ });
194
226
  return { id: same.id, outcome: 'rotated', credential: stored };
195
227
  }
196
228
  const existing = rows.find((row) => row.id === id);
@@ -219,6 +251,9 @@ export async function addRow(rt, input, options = {}) {
219
251
  });
220
252
  const stored = await rotateIn(rt, tx, id, credential, {
221
253
  clearErrors: true,
254
+ ...(incoming !== undefined
255
+ ? { providerState: { kind: 'set', value: incoming } }
256
+ : {}),
222
257
  });
223
258
  if (!existing.hasEntry)
224
259
  await tx.commitConfig();
@@ -253,7 +288,11 @@ export async function addRow(rt, input, options = {}) {
253
288
  // roster row could load beside a credential left under its id by an
254
289
  // interrupted removal. Nothing of such a leftover entry is kept.
255
290
  tx.dropStateAccount(id);
256
- const stored = await rotateIn(rt, tx, id, credential);
291
+ const stored = await rotateIn(rt, tx, id, credential, {
292
+ ...(incoming !== undefined
293
+ ? { providerState: { kind: 'set', value: incoming } }
294
+ : {}),
295
+ });
257
296
  await tx.commitConfig();
258
297
  return { id, outcome, credential: stored };
259
298
  });
@@ -268,6 +307,9 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
268
307
  const { ctx } = rt;
269
308
  const result = await runOperation(ctx, 'replace', id, options.onFailure, async (locks, progress) => {
270
309
  checkInput('replace', id, credential);
310
+ const incoming = input.providerState === undefined
311
+ ? undefined
312
+ : acceptProviderState(ctx.providerState, 'replace', id, input.providerState);
271
313
  const { row: seen } = await readRow(rt, 'replace', id);
272
314
  await locks.acquire(rowLockSpec(rt, seen));
273
315
  if (credential.type === 'oauth')
@@ -285,6 +327,9 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
285
327
  // Refused before anything is written; the row keeps its credential.
286
328
  if (!isCredentialEpoch(credentialEpoch))
287
329
  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`);
330
+ // Decided before anything is written: a hook that throws or
331
+ // returns a value the codec rejects leaves the row as it was.
332
+ const providerState = replacementProviderState(ctx.providerState, row, credentialEpoch, input.identity, incoming);
288
333
  const binding = {
289
334
  ...(input.identity !== undefined
290
335
  ? { identity: input.identity }
@@ -308,6 +353,7 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
308
353
  const stored = await rotateIn(rt, tx, id, credential, {
309
354
  clearErrors: true,
310
355
  binding,
356
+ providerState,
311
357
  });
312
358
  await tx.commitConfig();
313
359
  return { id, credential: stored, credentialEpoch };
@@ -322,6 +368,9 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
322
368
  const { ctx } = rt;
323
369
  return runOperation(ctx, 'rotate', id, options.onFailure, async (locks, progress) => {
324
370
  checkInput('rotate', id, credential);
371
+ const incoming = input.providerState === undefined
372
+ ? undefined
373
+ : acceptProviderState(ctx.providerState, 'rotate', id, input.providerState);
325
374
  const { row: seen } = await readRow(rt, 'rotate', id);
326
375
  await locks.acquire(rowLockSpec(rt, seen));
327
376
  if (input.identity !== undefined && credential.type === 'oauth')
@@ -347,6 +396,11 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
347
396
  : undefined;
348
397
  const stored = await rotateIn(rt, tx, id, credential, {
349
398
  identity: learnt,
399
+ ...(incoming !== undefined
400
+ ? {
401
+ providerState: mergedProviderState(ctx.providerState, 'rotate', id, row.providerState, incoming),
402
+ }
403
+ : {}),
350
404
  });
351
405
  let configChanged = false;
352
406
  if (!row.hasEntry) {
@@ -367,72 +421,154 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
367
421
  });
368
422
  }
369
423
  /**
370
- * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
371
- * extra locks before the store locks, as the other row writes do, so it waits
372
- * for a refresh of the row instead of landing during its provider call.
424
+ * `disable` and `enable` in one place. Takes the row lock, then the extra
425
+ * locks, then the store locks, as every row write does, so it waits for a
426
+ * refresh of the row instead of landing during its provider call.
427
+ *
428
+ * With a provider-state mutator that changes the value, the transition is
429
+ * written as a replace is: the state file first, carrying the value and, in
430
+ * the stamp, the transition itself; then the config, flipping the row and
431
+ * recording the transition's mark (see `torn.ts`). A stop between the two
432
+ * leaves a row every reader shows transitioned beside its new value. When
433
+ * the config already says what the transition would write (an `enable` of
434
+ * an enabled row), the value alone is written, in one state write.
373
435
  */
374
- export async function disableRow(rt, id, reason, options = {}) {
375
- assertNotInsideHook('disable');
376
- return runOperation(rt.ctx, 'disable', id, options.onFailure, async (locks, progress) => {
377
- const { row: seen } = await readRow(rt, 'disable', id);
436
+ async function transitionRow(rt, operation, id, flag, options) {
437
+ assertNotInsideHook(operation);
438
+ const { ctx } = rt;
439
+ const codec = ctx.providerState;
440
+ const fence = options.attribution;
441
+ const mutator = options.providerState;
442
+ return runOperation(ctx, operation, id, options.onFailure, async (locks, progress) => {
443
+ if (fence !== undefined &&
444
+ !isCredentialEpoch(isRecord(fence) ? fence.credentialEpoch : undefined))
445
+ throw refusal(operation, id, 'invalid-input', 'the attribution must name a credential epoch that is a positive safe integer');
446
+ if (mutator !== undefined) {
447
+ if (typeof mutator !== 'function')
448
+ throw refusal(operation, id, 'invalid-input', 'the provider-state mutator must be a function');
449
+ // A provider-state value belongs to one credential, so a change to
450
+ // it must say which credential it was decided for.
451
+ if (fence === undefined)
452
+ throw refusal(operation, id, 'invalid-input', 'a provider-state change needs the attribution of the credential it is for');
453
+ if (!codec)
454
+ throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
455
+ }
456
+ const { row: seen } = await readRow(rt, operation, id);
378
457
  await locks.acquire(rowLockSpec(rt, seen));
379
458
  for (const extra of options.extraLocks ?? [])
380
459
  await locks.acquire(extra);
381
- return withTransaction(rt.ctx, locks, progress, { operation: 'disable', rowId: id }, async (tx) => {
382
- const row = tx.row(id);
460
+ return withTransaction(ctx, locks, progress, { operation, rowId: id }, async (tx) => {
461
+ const loaded = tx.row(id);
462
+ const row = flag.enabled
463
+ ? requireUsableRow('enable', id, loaded)
464
+ : loaded;
383
465
  if (!row || !tx.rosterRow(id))
384
- throw unknownRow('disable', id);
466
+ throw unknownRow(operation, id);
385
467
  if (rowLockKey(row) !== rowLockKey(seen))
386
- throw keyChanged('disable', id);
387
- disableIn(tx, id, reason);
468
+ throw keyChanged(operation, id);
469
+ if (fence !== undefined) {
470
+ // An invalid entry has no epoch to compare the fence with.
471
+ if (row.invalid)
472
+ throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
473
+ if ((row.credentialEpoch ?? 1) !== fence.credentialEpoch ||
474
+ row.identity !== fence.identity)
475
+ throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
476
+ }
477
+ // An enable of a row that is already enabled has nothing to write
478
+ // to the config; a disable always rewrites it, as it always has.
479
+ const writesConfig = !flag.enabled || !row.enabled || row.disabledReason !== undefined;
480
+ if (flag.enabled && writesConfig && row.type === 'oauth') {
481
+ const holder = row.identity === undefined
482
+ ? undefined
483
+ : tx
484
+ .rows()
485
+ .find((other) => other.id !== id &&
486
+ other.invalid === undefined &&
487
+ other.type === 'oauth' &&
488
+ other.enabled &&
489
+ other.identity === row.identity);
490
+ if (holder)
491
+ throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
492
+ }
493
+ if (mutator === undefined || codec === undefined) {
494
+ if (!writesConfig)
495
+ return { id };
496
+ if (flag.enabled)
497
+ enableIn(tx, id);
498
+ else
499
+ disableIn(tx, id, flag.reason);
500
+ await tx.commitConfig();
501
+ return { id };
502
+ }
503
+ if (!row.credential)
504
+ throw refusal(operation, id, 'no-credential', `row ${id} holds no credential`);
505
+ // A strict store refuses here, but not for an attribution alone:
506
+ // disabling or enabling a row whose credential the store cannot
507
+ // prove is harmless (an unbound row is never a candidate), while
508
+ // writing a provider state would vouch for that credential.
509
+ requireBound(operation, row);
510
+ const plan = await planProviderStateIn(tx, codec, operation, row, mutator, true);
511
+ if (plan.kind === 'declined')
512
+ return { id, declined: true };
513
+ const result = {
514
+ id,
515
+ providerStateOutcome: plan.kind === 'unchanged'
516
+ ? 'unchanged'
517
+ : plan.value === undefined
518
+ ? 'cleared'
519
+ : 'updated',
520
+ ...(plan.value !== undefined ? { providerState: plan.value } : {}),
521
+ };
522
+ if (plan.kind === 'unchanged') {
523
+ if (writesConfig) {
524
+ if (flag.enabled)
525
+ enableIn(tx, id);
526
+ else
527
+ disableIn(tx, id, flag.reason);
528
+ await tx.commitConfig();
529
+ }
530
+ return result;
531
+ }
532
+ if (!writesConfig) {
533
+ tx.setStateAccount(id, plan.account);
534
+ await tx.commitState();
535
+ return result;
536
+ }
537
+ const transition = flag.enabled
538
+ ? { mark: randomUUID(), enabled: true }
539
+ : { mark: randomUUID(), enabled: false, reason: flag.reason };
540
+ const stamp = plan.account[CREDENTIAL_STAMP_KEY];
541
+ tx.setStateAccount(id, {
542
+ ...plan.account,
543
+ [CREDENTIAL_STAMP_KEY]: {
544
+ ...stamp,
545
+ [TRANSITION_STAMP_KEY]: transition,
546
+ },
547
+ });
548
+ await tx.commitState();
549
+ applyTransition(tx, id, transition);
388
550
  await tx.commitConfig();
389
- return { id };
551
+ return result;
390
552
  });
391
553
  });
392
554
  }
555
+ /**
556
+ * Marks a row disabled with a reason. See `RowTransitionOptions` for the
557
+ * attributed form, which may change the provider state with it.
558
+ */
559
+ export function disableRow(rt, id, reason, options = {}) {
560
+ return transitionRow(rt, 'disable', id, { enabled: false, reason }, options);
561
+ }
393
562
  /**
394
563
  * Clears a row's `enabled: false` and its `disabledReason` in one config
395
564
  * write. An OAuth row whose recorded identity another enabled OAuth row holds
396
565
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
397
566
  * that makes `add` store such a row disabled. Enabling a row that is already
398
- * enabled writes nothing.
567
+ * enabled writes nothing. See `RowTransitionOptions` for the attributed
568
+ * form, which may change the provider state with it.
399
569
  */
400
- export async function enableRow(rt, id, options = {}) {
401
- assertNotInsideHook('enable');
402
- return runOperation(rt.ctx, 'enable', id, options.onFailure, async (locks, progress) => {
403
- const { row: seen } = await readRow(rt, 'enable', id);
404
- await locks.acquire(rowLockSpec(rt, seen));
405
- for (const extra of options.extraLocks ?? [])
406
- await locks.acquire(extra);
407
- return withTransaction(rt.ctx, locks, progress, { operation: 'enable', rowId: id }, async (tx) => {
408
- const row = requireUsableRow('enable', id, tx.row(id));
409
- if (rowLockKey(row) !== rowLockKey(seen))
410
- throw keyChanged('enable', id);
411
- if (row.enabled && row.disabledReason === undefined)
412
- return { id };
413
- if (row.type === 'oauth' && row.identity !== undefined) {
414
- const holder = tx
415
- .rows()
416
- .find((other) => other.id !== id &&
417
- other.invalid === undefined &&
418
- other.type === 'oauth' &&
419
- other.enabled &&
420
- other.identity === row.identity);
421
- if (holder)
422
- throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
423
- }
424
- const raw = tx.rosterRow(id);
425
- raw.enabled = true;
426
- const entry = tx.entry(id);
427
- if (entry && 'disabledReason' in entry) {
428
- const next = { ...entry };
429
- delete next.disabledReason;
430
- tx.setEntry(id, next);
431
- }
432
- await tx.commitConfig();
433
- return { id };
434
- });
435
- });
570
+ export function enableRow(rt, id, options = {}) {
571
+ return transitionRow(rt, 'enable', id, { enabled: true }, options);
436
572
  }
437
573
  /**
438
574
  * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
@@ -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;
@@ -93,9 +168,11 @@ export interface PoolRow {
93
168
  * it belongs to, and the config still holds the replaced row. Also set when
94
169
  * a write that gives a row its first identity (`recordIdentity`, or a
95
170
  * `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.
171
+ * and before recording it in the config, and when an attributed `disable`
172
+ * or `enable` that changed the provider state stopped after its state
173
+ * write and before flipping the row in the config. The row is shown as the
174
+ * write leaves it once completed, is never a candidate, and the next store
175
+ * write on it writes the config to match.
99
176
  */
100
177
  torn?: true;
101
178
  /**
@@ -147,6 +224,12 @@ export interface CredentialBinding {
147
224
  * only on replace. `replace` marks a stamp written by a replace, which is
148
225
  * what lets a reader complete a replace that stopped after writing the
149
226
  * credential: a stamp from any other write is never completed as torn.
227
+ *
228
+ * `providerState` is the digest of the credential-bound part of the provider
229
+ * state beside the credential (see `providerStateDigest`); it is absent when
230
+ * the row has none, so the stamp of a row without provider state is exactly
231
+ * what 0.5.0 wrote. It is apart from `digest` and `dispatch`, which keep
232
+ * their meaning: the credential's stamp status never depends on it.
150
233
  */
151
234
  export interface CredentialStamp {
152
235
  credentialEpoch: number;
@@ -154,7 +237,13 @@ export interface CredentialStamp {
154
237
  dispatch?: string;
155
238
  binding?: CredentialBinding;
156
239
  replace?: true;
240
+ providerState?: string;
157
241
  }
242
+ /**
243
+ * Key, inside a state-file account entry, of the provider state kept beside
244
+ * the credential. Older readers ignore it.
245
+ */
246
+ export declare const PROVIDER_STATE_KEY = "commonAuthProviderState";
158
247
  export type ConfigClassification = {
159
248
  status: 'ready';
160
249
  exists: boolean;
@@ -208,7 +297,28 @@ export declare function dispatchDigest(credential: PoolCredential | StoredCreden
208
297
  */
209
298
  export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding: CredentialBinding, options?: {
210
299
  replace?: boolean;
300
+ providerState?: string;
211
301
  }): CredentialStamp;
302
+ /**
303
+ * The digest a stamp carries for a provider state: of its credential-bound
304
+ * part (`ProviderStateCodec.credentialBound`, the whole value without it), as
305
+ * it serializes. The store only ever stores values that survive a JSON round
306
+ * trip unchanged, so the digest of a value read back from disk equals the one
307
+ * computed when it was written. Its input prefix differs from every other
308
+ * digest the store writes.
309
+ */
310
+ export declare function providerStateDigest(codec: Pick<ProviderStateCodec, 'credentialBound'> | undefined, value: unknown): string;
311
+ /**
312
+ * The provider-state digest of the stamp in a state-file account entry, when
313
+ * that stamp binds it to this row: written with this credential (its lineage
314
+ * digest matches), at this credential epoch, naming this recorded identity.
315
+ * Whether the value beside it still has that digest is checked apart, by
316
+ * `providerStateFields`; this alone is what a write that keeps the value
317
+ * carries into the stamp it writes, so a value no stamp of this store bound
318
+ * to the row stays unbound rather than being vouched for, and one edited
319
+ * after it was bound stays detectably edited.
320
+ */
321
+ export declare function boundProviderStateDigest(account: unknown, credential: PoolCredential | StoredCredential | undefined, credentialEpoch: number | undefined, identity: string | undefined): string | undefined;
212
322
  /**
213
323
  * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
214
324
  * adding one may give back the same number, so a replace would not move the
@@ -245,7 +355,7 @@ export declare function setEntryIn(config: Record<string, unknown>, id: string,
245
355
  * malformed per-row entry makes that one row invalid (never a candidate) and
246
356
  * blocks nothing else.
247
357
  */
248
- export declare function buildRawRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): PoolRow[];
358
+ export declare function buildRawRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, providerCodec?: ProviderStateCodec): PoolRow[];
249
359
  /** The row-lock key: recorded wire identity when known, else the local id. */
250
360
  export declare function rowLockKey(row: Pick<PoolRow, 'id' | 'identity'>): string;
251
361
  /**