@cortexkit/common-auth 0.5.0 → 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.
@@ -1,6 +1,7 @@
1
1
  export type { AtomicWriteOptions } from './atomic-write.js';
2
2
  export { writeJsonAtomic } from './atomic-write.js';
3
3
  export { WRITER_LOCK_CONSTANTS } from './lock-constants.js';
4
+ export type { LockLoss, RefreshFileLock } from './refresh-file-lock.js';
4
5
  export { acquireRefreshFileLock, isLostMarkerRaceError, } from './refresh-file-lock.js';
5
- export type { LockOptions } from './with-lock.js';
6
+ export type { LockOptions, LockOwnershipDetails } from './with-lock.js';
6
7
  export { LockContentionError, LockOwnershipError, lockPathFor, withLock, } from './with-lock.js';
@@ -1,4 +1,18 @@
1
1
  export declare function isLostMarkerRaceError(error: unknown): boolean;
2
+ export interface LockLoss {
3
+ readonly reason: 'taken-over' | 'expired' | 'unreadable' | 'renewal-failed' | 'marker-lost';
4
+ readonly expectedOwnerId: string;
5
+ readonly observedOwnerId?: string;
6
+ readonly observedExpiresAt?: number;
7
+ }
8
+ export interface RefreshFileLock {
9
+ readonly ownerId: string;
10
+ assertOwned(): Promise<void>;
11
+ release(): Promise<void>;
12
+ /** Resolves once on detected loss; remains pending after an owner's release. */
13
+ whenLost(): Promise<LockLoss>;
14
+ hasLost(): boolean;
15
+ }
2
16
  export declare function acquireRefreshFileLock(options: {
3
17
  name: string;
4
18
  ttlMs: number;
@@ -12,7 +26,4 @@ export declare function acquireRefreshFileLock(options: {
12
26
  renew?: boolean;
13
27
  renewIntervalMs?: number;
14
28
  onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-confirmed' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
15
- }): Promise<{
16
- release: () => Promise<void>;
17
- assertOwned: () => Promise<void>;
18
- } | null>;
29
+ }): Promise<RefreshFileLock | null>;
@@ -19,6 +19,30 @@ export async function acquireRefreshFileLock(options) {
19
19
  const now = options.now ?? Date.now;
20
20
  let renewTimer = null;
21
21
  let released = false;
22
+ let loss;
23
+ let resolveLoss;
24
+ const lostPromise = new Promise((resolve) => {
25
+ resolveLoss = resolve;
26
+ });
27
+ function recordLoss(reason, owner) {
28
+ if (loss || released)
29
+ return;
30
+ loss = Object.freeze({
31
+ reason,
32
+ expectedOwnerId: ownerId,
33
+ ...(typeof owner?.ownerId === 'string'
34
+ ? { observedOwnerId: owner.ownerId }
35
+ : {}),
36
+ ...(typeof owner?.expiresAt === 'number'
37
+ ? { observedExpiresAt: owner.expiresAt }
38
+ : {}),
39
+ });
40
+ if (renewTimer) {
41
+ clearRefreshLockRenewalTimeout(renewTimer);
42
+ renewTimer = null;
43
+ }
44
+ resolveLoss(loss);
45
+ }
22
46
  let renewalInFlight = null;
23
47
  // Only the owner of this exclusively-created marker may remove or renew a
24
48
  // lock. A contender recovering a stale marker can accidentally rename a newer
@@ -203,7 +227,7 @@ export async function acquireRefreshFileLock(options) {
203
227
  }
204
228
  }
205
229
  function scheduleRenewal() {
206
- if (!options.renew || released)
230
+ if (!options.renew || released || loss)
207
231
  return;
208
232
  const intervalMs = options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3));
209
233
  renewTimer = setRefreshLockRenewalTimeout(() => {
@@ -213,19 +237,25 @@ export async function acquireRefreshFileLock(options) {
213
237
  const markerAcquired = await withEvictionMarker(async () => {
214
238
  const owner = await readOwner();
215
239
  const currentNow = now();
216
- if (released || owner?.ownerId !== ownerId) {
240
+ if (released || loss) {
241
+ shouldReschedule = false;
242
+ return;
243
+ }
244
+ if (owner?.ownerId !== ownerId) {
245
+ recordLoss('taken-over', owner);
217
246
  shouldReschedule = false;
218
247
  return;
219
248
  }
220
249
  // An expired lease is no longer ours to extend; a contender may
221
250
  // already be eligible to acquire it.
222
- if (Number(owner?.expiresAt) <= currentNow) {
251
+ if (!(Number(owner?.expiresAt) > currentNow)) {
252
+ recordLoss('expired', owner);
223
253
  shouldReschedule = false;
224
254
  return;
225
255
  }
226
256
  if (options.onStep)
227
257
  await options.onStep('renewal-owner-confirmed');
228
- if (released) {
258
+ if (released || loss) {
229
259
  shouldReschedule = false;
230
260
  return;
231
261
  }
@@ -233,7 +263,7 @@ export async function acquireRefreshFileLock(options) {
233
263
  return;
234
264
  if (options.onStep)
235
265
  await options.onStep('renewal-write-fenced');
236
- if (released) {
266
+ if (released || loss) {
237
267
  shouldReschedule = false;
238
268
  return;
239
269
  }
@@ -241,10 +271,15 @@ export async function acquireRefreshFileLock(options) {
241
271
  return;
242
272
  if (options.onStep)
243
273
  await options.onStep('renewal-write-ready');
274
+ if (released || loss) {
275
+ shouldReschedule = false;
276
+ return;
277
+ }
244
278
  await writeOwner();
245
279
  if (!(await ownsEvictionMarker())) {
246
280
  // If marker ownership cannot be read, stop claiming the lease
247
281
  // and remove only a record that still carries our owner id.
282
+ recordLoss('marker-lost');
248
283
  shouldReschedule = false;
249
284
  await relinquishLockAfterMarkerLoss();
250
285
  return;
@@ -255,7 +290,17 @@ export async function acquireRefreshFileLock(options) {
255
290
  }
256
291
  }
257
292
  catch {
258
- // Transient marker and filesystem failures retry on the next interval.
293
+ // Retry transient failures only while the lease can still be verified.
294
+ try {
295
+ const owner = await readOwner();
296
+ if (owner?.ownerId !== ownerId)
297
+ recordLoss('taken-over', owner);
298
+ else if (!(Number(owner?.expiresAt) > now()))
299
+ recordLoss('expired', owner);
300
+ }
301
+ catch {
302
+ recordLoss('renewal-failed');
303
+ }
259
304
  }
260
305
  finally {
261
306
  if (options.onStep) {
@@ -266,7 +311,7 @@ export async function acquireRefreshFileLock(options) {
266
311
  // Errors from the onStep observer must not reject the renewal.
267
312
  }
268
313
  }
269
- if (shouldReschedule && !released)
314
+ if (shouldReschedule && !released && !loss)
270
315
  scheduleRenewal();
271
316
  }
272
317
  })();
@@ -341,20 +386,35 @@ export async function acquireRefreshFileLock(options) {
341
386
  return null;
342
387
  scheduleRenewal();
343
388
  return {
389
+ ownerId,
390
+ whenLost: () => lostPromise,
391
+ hasLost: () => loss !== undefined,
344
392
  assertOwned: async () => {
393
+ let observed;
345
394
  try {
346
395
  const owner = await readOwner();
396
+ observed = owner;
347
397
  if (!released &&
398
+ !loss &&
348
399
  owner?.ownerId === ownerId &&
349
400
  Number(owner?.expiresAt) > now())
350
401
  return;
402
+ recordLoss(owner?.ownerId !== ownerId ? 'taken-over' : 'expired', owner);
351
403
  }
352
404
  catch {
353
405
  // Unreadable ownership is not evidence of a valid lease.
406
+ recordLoss('unreadable');
354
407
  }
355
408
  throw new LockOwnershipError({
356
409
  target: options.path,
357
410
  name: options.name,
411
+ expectedOwnerId: ownerId,
412
+ ...(typeof observed?.ownerId === 'string'
413
+ ? { observedOwnerId: observed.ownerId }
414
+ : {}),
415
+ ...(typeof observed?.expiresAt === 'number'
416
+ ? { observedExpiresAt: observed.expiresAt }
417
+ : {}),
358
418
  });
359
419
  },
360
420
  release: async () => {
@@ -11,15 +11,16 @@ export declare class LockContentionError extends Error {
11
11
  timeoutMs: number;
12
12
  });
13
13
  }
14
+ export interface LockOwnershipDetails {
15
+ target: string;
16
+ name: string;
17
+ expectedOwnerId?: string;
18
+ observedOwnerId?: string;
19
+ observedExpiresAt?: number;
20
+ }
14
21
  export declare class LockOwnershipError extends Error {
15
- readonly details: {
16
- target: string;
17
- name: string;
18
- };
19
- constructor(details: {
20
- target: string;
21
- name: string;
22
- });
22
+ readonly details: LockOwnershipDetails;
23
+ constructor(details: LockOwnershipDetails);
23
24
  }
24
25
  export interface LockOptions {
25
26
  name: string;
@@ -1,6 +1,6 @@
1
1
  import type { StoredCredential } from './schema.js';
2
2
  /** Every library operation that can fail, as named in the failure value. */
3
- export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'updateSettings' | 'recordIdentity' | 'refresh' | 'pull';
3
+ export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'updateSettings' | 'recordIdentity' | 'updateProviderState' | 'refresh' | 'pull';
4
4
  /**
5
5
  * How far an operation got before it failed.
6
6
  *
@@ -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' | 'unbound-credential' | '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' | 'invalid-provider-state' | '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
@@ -6,13 +6,14 @@ export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity
6
6
  export type { HoldPoint, InitializeOutcome, WriteStep } from './mutate.js';
7
7
  export type { OpenPoolStoreOptions, PoolLoad, PoolStore, } from './pool.js';
8
8
  export { openPoolStore } from './pool.js';
9
+ export type { ProviderStateMutator, UpdateProviderStateResult, } from './provider-state.js';
9
10
  export type { PullHook, PullRequest } from './pull.js';
10
11
  export type { ProviderRefresh, ProviderRefreshResult, RefreshOptions, RefreshOutcome, } from './refresh.js';
11
12
  export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.js';
12
13
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
13
- export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
14
+ export type { AddInput, AddResult, CredentialWriteInput, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
14
15
  export type { PullReason } from './runtime.js';
15
- export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
16
- export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
16
+ export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, ProviderStateCodec, ProviderStateDrop, ProviderStateReplacement, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
17
+ export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, PROVIDER_STATE_KEY, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
17
18
  export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
18
19
  export { POOL_OWNED_KEYS } from './settings.js';
@@ -2,5 +2,5 @@ export { PoolOperationError, PoolReentryError } from './errors.js';
2
2
  export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity.js';
3
3
  export { openPoolStore } from './pool.js';
4
4
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
5
- export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
5
+ export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, PROVIDER_STATE_KEY, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
6
6
  export { POOL_OWNED_KEYS } from './settings.js';
@@ -1,7 +1,7 @@
1
1
  import { type PoolFailurePhase, type PoolOperation, PoolOperationError } from './errors.js';
2
2
  import { type PoolLogger } from './hooks.js';
3
3
  import { type LockEnvironment, LockStack, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
4
- import { type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
4
+ import { type PoolRow, type ProviderStateCodec, type QuotaCodec, type StoredCredential } from './schema.js';
5
5
  /** Named points on the write path, for crash and ownership injection. */
6
6
  export type WriteStep = 'before-config-write' | 'after-config-write' | 'before-state-write' | 'after-state-write';
7
7
  /** Awaitable pause points on the pull and refresh paths. */
@@ -11,6 +11,8 @@ export interface StoreContext {
11
11
  configPath: string;
12
12
  statePath: string;
13
13
  codec: QuotaCodec;
14
+ /** The provider-state codec; without one no row shows a provider state. */
15
+ providerState?: ProviderStateCodec;
14
16
  now: () => number;
15
17
  storeLocks: readonly PoolLockSpec[];
16
18
  lockDefaults: PoolLockOptions;
@@ -41,6 +41,7 @@ export async function readPool(ctx) {
41
41
  state: state.state,
42
42
  rows: loadRows(config.config, state.state, ctx.codec, {
43
43
  requireCredentialStamps: ctx.requireCredentialStamps === true,
44
+ ...(ctx.providerState ? { providerState: ctx.providerState } : {}),
44
45
  }),
45
46
  };
46
47
  }
@@ -90,6 +91,9 @@ export class Transaction {
90
91
  rows() {
91
92
  return loadRows(this.config, this.state, this.ctx.codec, {
92
93
  requireCredentialStamps: this.ctx.requireCredentialStamps === true,
94
+ ...(this.ctx.providerState
95
+ ? { providerState: this.ctx.providerState }
96
+ : {}),
93
97
  });
94
98
  }
95
99
  row(id) {
@@ -2,11 +2,12 @@ import { type Attribution } from './attribution.js';
2
2
  import type { PoolOperationError } from './errors.js';
3
3
  import type { PoolLogger } from './hooks.js';
4
4
  import { type HoldPoint, type InitializeOutcome, type StoreContext } from './mutate.js';
5
+ import { type ProviderStateMutator, type UpdateProviderStateResult } from './provider-state.js';
5
6
  import { type PullHook } from './pull.js';
6
7
  import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
7
8
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
8
- import { type AddInput, type AddResult, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
9
- import { type PoolCredential, type PoolRow, type QuotaCodec, type RotateCredential, type StoredCredential } from './schema.js';
9
+ import { type AddInput, type AddResult, type CredentialWriteInput, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
10
+ import { type PoolCredential, type PoolRow, type ProviderStateCodec, type QuotaCodec, type RotateCredential, type StoredCredential } from './schema.js';
10
11
  import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
11
12
  export interface OpenPoolStoreOptions {
12
13
  /** The provider every row of this pool belongs to; keys the provider-wide lock. */
@@ -14,6 +15,13 @@ export interface OpenPoolStoreOptions {
14
15
  configPath: string;
15
16
  statePath: string;
16
17
  quota: QuotaCodec;
18
+ /**
19
+ * The codec of the provider state kept beside each row's credential (see
20
+ * `ProviderStateCodec`). Without it no row shows a provider state, and
21
+ * every write that would set one refuses (`invalid-input`); writes that
22
+ * leave it alone keep the value on disk as it is, and `replace` clears it.
23
+ */
24
+ providerState?: ProviderStateCodec;
17
25
  /**
18
26
  * Refuse every credential this store did not stamp (default false, which
19
27
  * loads unstamped and mis-stamped credentials as older writers left them).
@@ -81,9 +89,12 @@ export interface PoolStore {
81
89
  status: InitializeOutcome;
82
90
  }>;
83
91
  add(input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
84
- replace(id: string, credential: PoolCredential, input?: {
85
- identity?: string;
86
- }, options?: RowOperationOptions): Promise<{
92
+ /**
93
+ * Gives a row a new credential and a new credential epoch. Since 0.6.0 the
94
+ * row's provider state is whatever `ProviderStateCodec.onReplace` returns;
95
+ * without that hook it is `input.providerState`, else cleared.
96
+ */
97
+ replace(id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
87
98
  id: string;
88
99
  credential: StoredCredential;
89
100
  credentialEpoch: number;
@@ -94,12 +105,17 @@ export interface PoolStore {
94
105
  * to keep the row's, and one that gives another is refused
95
106
  * (`endpoint-mismatch`) before writing: that is a `replace`.
96
107
  */
97
- rotate(id: string, credential: RotateCredential, input?: {
98
- identity?: string;
99
- }, options?: RowOperationOptions): Promise<{
108
+ rotate(id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
100
109
  id: string;
101
110
  credential: StoredCredential;
102
111
  }>;
112
+ /**
113
+ * Changes a row's provider state without touching its credential (since
114
+ * 0.6.0), under the row lock, `extraLocks` and the store locks. Refuses
115
+ * (`attribution`) once the row has moved off the credential epoch or
116
+ * identity in `fence`, and (`unknown-row`) once it is removed.
117
+ */
118
+ updateProviderState(id: string, fence: Attribution, mutator: ProviderStateMutator, options?: RowToggleOptions): Promise<UpdateProviderStateResult>;
103
119
  /**
104
120
  * Sets `enabled: false` and the entry's `disabledReason`. Takes the row
105
121
  * lock, then `extraLocks`, then the store locks (the row lock and
@@ -1,5 +1,6 @@
1
1
  import { recordQuota } from './attribution.js';
2
2
  import { initializePool, readPool, } from './mutate.js';
3
+ import { updateProviderStateRow, } from './provider-state.js';
3
4
  import { PullScheduler } from './pull.js';
4
5
  import { refreshRow, } from './refresh.js';
5
6
  import { POOL_LOCK_DEFAULTS, } from './refresh-lock.js';
@@ -47,6 +48,7 @@ export function openPoolStore(options) {
47
48
  configPath: options.configPath,
48
49
  statePath: options.statePath,
49
50
  codec: options.quota,
51
+ ...(options.providerState ? { providerState: options.providerState } : {}),
50
52
  now: options.now ?? Date.now,
51
53
  storeLocks: options.storeLocks ?? [
52
54
  { name: 'save', path: options.configPath },
@@ -98,6 +100,7 @@ export function openPoolStore(options) {
98
100
  add: (input, callOptions) => addRow(rt, input, callOptions),
99
101
  replace: (id, credential, input, callOptions) => replaceRow(rt, id, credential, input, callOptions),
100
102
  rotate: (id, credential, input, callOptions) => rotateRow(rt, id, credential, input, callOptions),
103
+ updateProviderState: (id, fence, mutator, callOptions) => updateProviderStateRow(rt, id, fence, mutator, callOptions),
101
104
  disable: (id, reason, callOptions) => disableRow(rt, id, reason, callOptions),
102
105
  enable: (id, callOptions) => enableRow(rt, id, callOptions),
103
106
  remove: (id, callOptions) => removeRow(rt, id, callOptions),
@@ -0,0 +1,86 @@
1
+ import type { Attribution } from './attribution.js';
2
+ import type { PoolOperation } from './errors.js';
3
+ import type { RowToggleOptions } from './rows.js';
4
+ import { type StoreRuntime } from './runtime.js';
5
+ import { type PoolRow, type ProviderStateCodec } from './schema.js';
6
+ /**
7
+ * What a credential write does to the provider state beside it: `keep`
8
+ * leaves the value on disk as it is (bound by the new stamp only if the old
9
+ * stamp bound it to the same row, epoch and identity), `set` stores a value
10
+ * the codec accepted, and `clear` deletes it.
11
+ */
12
+ export type ProviderStateWrite = {
13
+ kind: 'keep';
14
+ } | {
15
+ kind: 'set';
16
+ value: unknown;
17
+ } | {
18
+ kind: 'clear';
19
+ };
20
+ /**
21
+ * A provider state as the store will store it: a JSON round trip of it (so
22
+ * its digest is the same once read back from disk), accepted by the codec.
23
+ * Refused before anything is written when the store has no codec, the value
24
+ * is not JSON, or the codec rejects it.
25
+ */
26
+ export declare function acceptProviderState(codec: ProviderStateCodec | undefined, operation: PoolOperation, id: string, value: unknown, what?: string): unknown;
27
+ /**
28
+ * The write for a value a credential write brings (`add` of a secret the
29
+ * pool holds, `rotate`, a refresh): merged with the row's value on disk by
30
+ * the codec's `merge` when both exist, else the incoming value as is.
31
+ */
32
+ export declare function mergedProviderState(codec: ProviderStateCodec | undefined, operation: PoolOperation, id: string, onDisk: unknown, incoming: unknown): ProviderStateWrite;
33
+ /**
34
+ * The provider state a replace leaves on the row, decided before anything is
35
+ * written: whatever the codec's `onReplace` returns (undefined clears it), or
36
+ * without that hook the value handed to `replace`, else nothing. The old
37
+ * credential's value is never kept by default: it describes the account the
38
+ * replaced credential belonged to.
39
+ */
40
+ export declare function replacementProviderState(codec: ProviderStateCodec | undefined, row: PoolRow, credentialEpoch: number, identity: string | undefined, incoming: unknown): ProviderStateWrite;
41
+ /**
42
+ * The provider-state digest the stamp of a credential write carries, and the
43
+ * state-file fields it changes. `set` binds the new value; `keep` carries the
44
+ * old stamp's digest forward only when that stamp bound it to this row as it
45
+ * stood before the write (same credential lineage, the epoch being written,
46
+ * the identity recorded before the write); `clear` binds nothing.
47
+ */
48
+ export declare function providerStateCoverage(codec: ProviderStateCodec | undefined, write: ProviderStateWrite, prior: Record<string, unknown> | undefined, priorRow: PoolRow | undefined, credentialEpoch: number): string | undefined;
49
+ /**
50
+ * Receives a private copy of the row's provider state (undefined when the row
51
+ * shows none) and the row as loaded under the locks, and returns the next
52
+ * provider state; returning undefined clears it, so a mutator that means to
53
+ * keep the value returns it. It runs under the row lock and the store locks,
54
+ * so it must not call back into the store (`PoolReentryError`).
55
+ */
56
+ export type ProviderStateMutator = (current: unknown | undefined, row: PoolRow) => unknown | Promise<unknown>;
57
+ export type UpdateProviderStateResult = {
58
+ id: string;
59
+ /** The provider state now on disk; absent when the row has none. */
60
+ providerState?: unknown;
61
+ /**
62
+ * `unchanged`: the mutator returned what the row already shows (or cleared
63
+ * a row that holds none); nothing was written.
64
+ */
65
+ outcome: 'updated' | 'cleared' | 'unchanged';
66
+ };
67
+ /**
68
+ * Changes a row's provider state without touching its credential, in one
69
+ * state-file write under the row lock, the caller's extra locks and the
70
+ * store locks. `fence` is what the caller read the row at: the write is
71
+ * refused (`attribution`, retryable) when the row has since moved to another
72
+ * credential epoch or recorded identity, and (`unknown-row`) once it is
73
+ * removed, so a writer that read the row before a replace or a removal never
74
+ * lands its value on the new credential or brings a removed row's state back.
75
+ *
76
+ * The value is bound by the stamp already beside the credential. When its
77
+ * credential-bound part is unchanged the stamp is left byte for byte as it
78
+ * is; otherwise only the stamp's provider-state digest changes, so the
79
+ * credential's stamp status never moves. With `requireCredentialStamps`, an
80
+ * unbound row refuses (`unbound-credential`) as every other strict path
81
+ * does. Without it, a row whose stamp was not written by this store with this
82
+ * credential at the row's epoch and identity refuses the same way: no stamp
83
+ * could bind the value, so no reader would ever show it. A `rotate` or
84
+ * `replace` stamps such a row.
85
+ */
86
+ export declare function updateProviderStateRow(rt: StoreRuntime, id: string, fence: Attribution, mutator: ProviderStateMutator, options?: RowToggleOptions): Promise<UpdateProviderStateResult>;
@@ -0,0 +1,186 @@
1
+ import { assertNotInsideHook, runInsideHook } from './hooks.js';
2
+ import { runOperation, withTransaction } from './mutate.js';
3
+ import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
4
+ import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialDigest, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, parseStamp, providerStateDigest, rowLockKey, } from './schema.js';
5
+ /**
6
+ * A provider state as the store will store it: a JSON round trip of it (so
7
+ * its digest is the same once read back from disk), accepted by the codec.
8
+ * Refused before anything is written when the store has no codec, the value
9
+ * is not JSON, or the codec rejects it.
10
+ */
11
+ export function acceptProviderState(codec, operation, id, value, what = 'the provider state') {
12
+ if (!codec)
13
+ throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
14
+ let normalized;
15
+ try {
16
+ const text = JSON.stringify(value);
17
+ normalized = text === undefined ? undefined : JSON.parse(text);
18
+ }
19
+ catch {
20
+ normalized = undefined;
21
+ }
22
+ if (normalized === undefined)
23
+ throw refusal(operation, id, 'invalid-provider-state', `${what} cannot be stored as JSON`);
24
+ if (!codec.validate(normalized))
25
+ throw refusal(operation, id, 'invalid-provider-state', `the provider-state codec rejected ${what}`);
26
+ return normalized;
27
+ }
28
+ /**
29
+ * The write for a value a credential write brings (`add` of a secret the
30
+ * pool holds, `rotate`, a refresh): merged with the row's value on disk by
31
+ * the codec's `merge` when both exist, else the incoming value as is.
32
+ */
33
+ export function mergedProviderState(codec, operation, id, onDisk, incoming) {
34
+ if (onDisk === undefined || !codec?.merge)
35
+ return { kind: 'set', value: incoming };
36
+ return {
37
+ kind: 'set',
38
+ value: acceptProviderState(codec, operation, id, codec.merge(structuredClone(onDisk), structuredClone(incoming)), 'the merged provider state'),
39
+ };
40
+ }
41
+ /**
42
+ * The provider state a replace leaves on the row, decided before anything is
43
+ * written: whatever the codec's `onReplace` returns (undefined clears it), or
44
+ * without that hook the value handed to `replace`, else nothing. The old
45
+ * credential's value is never kept by default: it describes the account the
46
+ * replaced credential belonged to.
47
+ */
48
+ export function replacementProviderState(codec, row, credentialEpoch, identity, incoming) {
49
+ const hook = codec?.onReplace;
50
+ const next = hook
51
+ ? hook(row.providerState === undefined
52
+ ? undefined
53
+ : structuredClone(row.providerState), {
54
+ id: row.id,
55
+ credentialEpoch,
56
+ ...(identity !== undefined ? { identity } : {}),
57
+ ...(incoming !== undefined
58
+ ? { incoming: structuredClone(incoming) }
59
+ : {}),
60
+ })
61
+ : incoming;
62
+ if (next === undefined)
63
+ return { kind: 'clear' };
64
+ if (!hook)
65
+ return { kind: 'set', value: next };
66
+ return {
67
+ kind: 'set',
68
+ value: acceptProviderState(codec, 'replace', row.id, next, 'the provider state onReplace returned'),
69
+ };
70
+ }
71
+ /**
72
+ * The provider-state digest the stamp of a credential write carries, and the
73
+ * state-file fields it changes. `set` binds the new value; `keep` carries the
74
+ * old stamp's digest forward only when that stamp bound it to this row as it
75
+ * stood before the write (same credential lineage, the epoch being written,
76
+ * the identity recorded before the write); `clear` binds nothing.
77
+ */
78
+ export function providerStateCoverage(codec, write, prior, priorRow, credentialEpoch) {
79
+ if (write.kind === 'set')
80
+ return providerStateDigest(codec, write.value);
81
+ if (write.kind === 'clear')
82
+ return undefined;
83
+ return boundProviderStateDigest(prior, priorRow?.credential, credentialEpoch, priorRow?.identity);
84
+ }
85
+ /**
86
+ * Changes a row's provider state without touching its credential, in one
87
+ * state-file write under the row lock, the caller's extra locks and the
88
+ * store locks. `fence` is what the caller read the row at: the write is
89
+ * refused (`attribution`, retryable) when the row has since moved to another
90
+ * credential epoch or recorded identity, and (`unknown-row`) once it is
91
+ * removed, so a writer that read the row before a replace or a removal never
92
+ * lands its value on the new credential or brings a removed row's state back.
93
+ *
94
+ * The value is bound by the stamp already beside the credential. When its
95
+ * credential-bound part is unchanged the stamp is left byte for byte as it
96
+ * is; otherwise only the stamp's provider-state digest changes, so the
97
+ * credential's stamp status never moves. With `requireCredentialStamps`, an
98
+ * unbound row refuses (`unbound-credential`) as every other strict path
99
+ * does. Without it, a row whose stamp was not written by this store with this
100
+ * credential at the row's epoch and identity refuses the same way: no stamp
101
+ * could bind the value, so no reader would ever show it. A `rotate` or
102
+ * `replace` stamps such a row.
103
+ */
104
+ export async function updateProviderStateRow(rt, id, fence, mutator, options = {}) {
105
+ assertNotInsideHook('updateProviderState');
106
+ const { ctx } = rt;
107
+ const operation = 'updateProviderState';
108
+ return runOperation(ctx, operation, id, options.onFailure, async (locks, progress) => {
109
+ const codec = ctx.providerState;
110
+ if (!codec)
111
+ throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
112
+ if (typeof mutator !== 'function')
113
+ throw refusal(operation, id, 'invalid-input', 'a mutator is required');
114
+ const captured = isRecord(fence) ? fence.credentialEpoch : undefined;
115
+ if (!isCredentialEpoch(captured))
116
+ throw refusal(operation, id, 'invalid-input', 'the credential epoch the provider state was read at is required');
117
+ const { row: seen } = await readRow(rt, operation, id);
118
+ await locks.acquire(rowLockSpec(rt, seen));
119
+ for (const extra of options.extraLocks ?? [])
120
+ await locks.acquire(extra);
121
+ return withTransaction(ctx, locks, progress, { operation, rowId: id }, async (tx) => {
122
+ const row = tx.row(id);
123
+ if (!row)
124
+ throw unknownRow(operation, id);
125
+ if (rowLockKey(row) !== rowLockKey(seen))
126
+ throw refusal(operation, id, 'row-key-changed', `row ${id}'s wire identity changed while its lock was being taken`, true);
127
+ if (row.invalid)
128
+ throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
129
+ if (!row.credential)
130
+ throw refusal(operation, id, 'no-credential', `row ${id} holds no credential`);
131
+ requireBound(operation, row);
132
+ const epoch = row.credentialEpoch ?? 1;
133
+ if (epoch !== captured || row.identity !== fence.identity)
134
+ throw refusal(operation, id, 'attribution', `the provider state for ${id} was read for a credential or account the row no longer holds`, true);
135
+ const account = tx.stateAccount(id);
136
+ const rawStamp = account?.[CREDENTIAL_STAMP_KEY];
137
+ const stamp = parseStamp(rawStamp);
138
+ if (!isRecord(rawStamp) ||
139
+ !stamp?.binding ||
140
+ stamp.digest !== credentialDigest(row.credential) ||
141
+ stamp.credentialEpoch !== epoch ||
142
+ stamp.binding.identity !== row.identity)
143
+ throw refusal(operation, id, 'unbound-credential', `row ${id}'s credential carries no stamp of this store to bind a provider state to (stamp ${row.stamp}); rotate or replace it first`);
144
+ const current = row.providerState === undefined
145
+ ? undefined
146
+ : structuredClone(row.providerState);
147
+ const returned = await runInsideHook(operation, () => mutator(current, row));
148
+ const next = returned === undefined
149
+ ? undefined
150
+ : acceptProviderState(codec, operation, id, returned, 'the provider state the mutator returned');
151
+ const held = account !== undefined && Object.hasOwn(account, PROVIDER_STATE_KEY);
152
+ if (next === undefined
153
+ ? !held
154
+ : row.providerState !== undefined &&
155
+ JSON.stringify(row.providerState) === JSON.stringify(next))
156
+ return {
157
+ id,
158
+ outcome: 'unchanged',
159
+ ...(next !== undefined ? { providerState: next } : {}),
160
+ };
161
+ const nextAccount = { ...account };
162
+ if (next === undefined) {
163
+ delete nextAccount[PROVIDER_STATE_KEY];
164
+ const nextStamp = { ...rawStamp };
165
+ delete nextStamp.providerState;
166
+ nextAccount[CREDENTIAL_STAMP_KEY] = nextStamp;
167
+ }
168
+ else {
169
+ nextAccount[PROVIDER_STATE_KEY] = next;
170
+ const digest = providerStateDigest(codec, next);
171
+ // A change confined to the part the codec does not bind to the
172
+ // credential leaves the stamp exactly as it was.
173
+ if (stamp.providerState !== digest)
174
+ nextAccount[CREDENTIAL_STAMP_KEY] = {
175
+ ...rawStamp,
176
+ providerState: digest,
177
+ };
178
+ }
179
+ tx.setStateAccount(id, nextAccount);
180
+ await tx.commitState();
181
+ return next === undefined
182
+ ? { id, outcome: 'cleared' }
183
+ : { id, outcome: 'updated', providerState: next };
184
+ });
185
+ });
186
+ }
@@ -10,6 +10,14 @@ export interface ProviderRefreshResult {
10
10
  expiresIn?: number;
11
11
  /** The account's wire identity, when the provider reports one. */
12
12
  identity?: string;
13
+ /**
14
+ * Provider state that changes with the new token (needs the store's
15
+ * provider-state codec). It is merged with the row's value on disk
16
+ * (`ProviderStateCodec.merge`) and written in the same state write as the
17
+ * rotated credential, under the same commit fence. Left out, the row keeps
18
+ * its value.
19
+ */
20
+ providerState?: unknown;
13
21
  }
14
22
  export type ProviderRefresh = (credential: OAuthCredential & {
15
23
  lastRefreshedAt?: number;
@@ -2,6 +2,7 @@ import { PoolOperationError } from './errors.js';
2
2
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
3
  import { recordIdentityIn } from './identity.js';
4
4
  import { runOperation, withTransaction } from './mutate.js';
5
+ import { acceptProviderState, mergedProviderState } from './provider-state.js';
5
6
  import { rotateIn } from './rows.js';
6
7
  import { readRow, refusal, requireBound, rowLockSpec, } from './runtime.js';
7
8
  import { rotationStamp, rotationStampUntrusted, rowLockKey, } from './schema.js';
@@ -98,6 +99,9 @@ export async function refreshRow(rt, id, provider, options = {}) {
98
99
  }
99
100
  if (typeof result?.refresh !== 'string' || !result.refresh.trim())
100
101
  throw refusal('refresh', id, 'provider', 'the provider returned no refresh token', true);
102
+ const incoming = result.providerState === undefined
103
+ ? undefined
104
+ : acceptProviderState(ctx.providerState, 'refresh', id, result.providerState, 'the provider state the provider returned');
101
105
  const commit = await withTransaction(ctx, locks, progress, { operation: 'refresh', rowId: id }, async (tx) => {
102
106
  const current = tx.row(id);
103
107
  const entry = tx.entry(id);
@@ -142,6 +146,11 @@ export async function refreshRow(rt, id, provider, options = {}) {
142
146
  const stored = await rotateIn(rt, tx, id, credential, {
143
147
  stamp: rotationStamp(prior, now),
144
148
  identity: learnt,
149
+ ...(incoming !== undefined
150
+ ? {
151
+ providerState: mergedProviderState(ctx.providerState, 'refresh', id, current.providerState, incoming),
152
+ }
153
+ : {}),
145
154
  });
146
155
  let identity = current.identity;
147
156
  if (learnt !== undefined) {
@@ -1,6 +1,7 @@
1
1
  import type { Attribution } from './attribution.js';
2
2
  import { PoolOperationError } from './errors.js';
3
3
  import { type Transaction } from './mutate.js';
4
+ import { type ProviderStateWrite } from './provider-state.js';
4
5
  import type { PoolLockSpec } from './refresh-lock.js';
5
6
  import { type StoreRuntime } from './runtime.js';
6
7
  import { type CredentialBinding, type PoolCredential, type PoolRow, type RotateCredential, type StoredCredential } from './schema.js';
@@ -80,6 +81,24 @@ export interface AddInput {
80
81
  credential: PoolCredential;
81
82
  identity?: string;
82
83
  label?: string;
84
+ /**
85
+ * Provider state for the credential, written in the same state write as
86
+ * the credential (needs the store's provider-state codec). On an `add`
87
+ * that rotates a row already holding this secret it is merged with the
88
+ * row's value (`ProviderStateCodec.merge`); left out, that row keeps its
89
+ * value.
90
+ */
91
+ providerState?: unknown;
92
+ }
93
+ /** What `replace` and `rotate` take beside the credential. */
94
+ export interface CredentialWriteInput {
95
+ identity?: string;
96
+ /**
97
+ * Provider state written in the same state write as the credential. For
98
+ * `rotate` it is merged with the row's value; left out, the row keeps its
99
+ * value. For `replace` see `ProviderStateCodec.onReplace`.
100
+ */
101
+ providerState?: unknown;
83
102
  }
84
103
  export type AddResult = {
85
104
  /** The row holding the credential; an existing row's id on a re-add. */
@@ -102,18 +121,15 @@ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string,
102
121
  clearErrors?: boolean;
103
122
  binding?: CredentialBinding;
104
123
  identity?: string;
124
+ providerState?: ProviderStateWrite;
105
125
  }): Promise<StoredCredential>;
106
126
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
107
- export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
108
- identity?: string;
109
- }, options?: RowOperationOptions): Promise<{
127
+ export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
110
128
  id: string;
111
129
  credential: StoredCredential;
112
130
  credentialEpoch: number;
113
131
  }>;
114
- export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: {
115
- identity?: string;
116
- }, options?: RowOperationOptions): Promise<{
132
+ export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
117
133
  id: string;
118
134
  credential: StoredCredential;
119
135
  }>;
@@ -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.5.0",
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": {