@cortexkit/common-auth 0.4.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }>;