@cortexkit/common-auth 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
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 plan = await planProviderStateIn(tx, codec, operation, row, mutator);
136
+ if (plan.kind !== 'changed')
137
+ return {
138
+ id,
139
+ outcome: 'unchanged',
140
+ ...(plan.kind === 'unchanged' && plan.value !== undefined
141
+ ? { providerState: plan.value }
142
+ : {}),
143
+ };
144
+ tx.setStateAccount(id, plan.account);
145
+ await tx.commitState();
146
+ return plan.value === undefined
147
+ ? { id, outcome: 'cleared' }
148
+ : { id, outcome: 'updated', providerState: plan.value };
149
+ });
150
+ });
151
+ }
152
+ /**
153
+ * Returned by the provider-state mutator of an attributed `disable` or
154
+ * `enable` to decline the whole transition: nothing is written, neither the
155
+ * provider state nor the row's enabled flag, and the call resolves with
156
+ * `declined: true`. A mutator declines when the state it is shown is newer
157
+ * than what its caller saw, such as an eligibility recorded after the
158
+ * request whose refusal is being acted on. It is a value of its own because
159
+ * `undefined` already means "clear the provider state". `Symbol.for` keeps it
160
+ * equal across two copies of this module loaded in one process.
161
+ */
162
+ export const DECLINE_TRANSITION = Symbol.for('@cortexkit/common-auth/store/decline-transition');
163
+ /**
164
+ * Runs a provider-state mutator for a row loaded under every lock and
165
+ * already checked by the caller (present, valid, inside its attribution
166
+ * fence), and plans the write. Refuses (`no-credential`) a row holding no
167
+ * credential, and (`unbound-credential`) one whose credential carries no
168
+ * stamp of this store at the row's epoch and identity: no stamp could bind
169
+ * the value, so no reader would ever show it. `DECLINE_TRANSITION` is
170
+ * honoured only when `declinable` is set; elsewhere it is not JSON and is
171
+ * refused as such.
172
+ */
173
+ export async function planProviderStateIn(tx, codec, operation, row, mutator, declinable = false) {
174
+ const id = row.id;
175
+ const credential = row.credential;
176
+ if (!credential)
177
+ throw refusal(operation, id, 'no-credential', `row ${id} holds no credential`);
178
+ const epoch = row.credentialEpoch ?? 1;
179
+ const account = tx.stateAccount(id);
180
+ const rawStamp = account?.[CREDENTIAL_STAMP_KEY];
181
+ const stamp = parseStamp(rawStamp);
182
+ if (!isRecord(rawStamp) ||
183
+ !stamp?.binding ||
184
+ stamp.digest !== credentialDigest(credential) ||
185
+ stamp.credentialEpoch !== epoch ||
186
+ stamp.binding.identity !== row.identity)
187
+ 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`);
188
+ const current = row.providerState === undefined
189
+ ? undefined
190
+ : structuredClone(row.providerState);
191
+ const returned = await runInsideHook(operation, () => mutator(current, row));
192
+ if (declinable && returned === DECLINE_TRANSITION)
193
+ return { kind: 'declined' };
194
+ const next = returned === undefined
195
+ ? undefined
196
+ : acceptProviderState(codec, operation, id, returned, 'the provider state the mutator returned');
197
+ const held = account !== undefined && Object.hasOwn(account, PROVIDER_STATE_KEY);
198
+ if (next === undefined
199
+ ? !held
200
+ : row.providerState !== undefined &&
201
+ JSON.stringify(row.providerState) === JSON.stringify(next))
202
+ return { kind: 'unchanged', ...(next !== undefined ? { value: next } : {}) };
203
+ const nextAccount = { ...account };
204
+ if (next === undefined) {
205
+ delete nextAccount[PROVIDER_STATE_KEY];
206
+ const nextStamp = { ...rawStamp };
207
+ delete nextStamp.providerState;
208
+ nextAccount[CREDENTIAL_STAMP_KEY] = nextStamp;
209
+ }
210
+ else {
211
+ nextAccount[PROVIDER_STATE_KEY] = next;
212
+ const digest = providerStateDigest(codec, next);
213
+ // A change confined to the part the codec does not bind to the
214
+ // credential leaves the stamp exactly as it was.
215
+ if (stamp.providerState !== digest)
216
+ nextAccount[CREDENTIAL_STAMP_KEY] = { ...rawStamp, providerState: digest };
217
+ }
218
+ return {
219
+ kind: 'changed',
220
+ ...(next !== undefined ? { value: next } : {}),
221
+ account: nextAccount,
222
+ };
223
+ }
@@ -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, type RowTransitionMutator, type UpdateProviderStateResult } 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,39 +121,70 @@ 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
  }>;
120
136
  /**
121
- * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
122
- * extra locks before the store locks, as the other row writes do, so it waits
123
- * for a refresh of the row instead of landing during its provider call.
137
+ * Options of `disable` and `enable`. A call that passes neither
138
+ * `attribution` nor `providerState` behaves exactly as it did before 0.7.0.
124
139
  */
125
- export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: RowToggleOptions): Promise<{
140
+ export interface RowTransitionOptions extends RowToggleOptions {
141
+ /**
142
+ * The credential epoch and recorded identity the caller's evidence for the
143
+ * transition was obtained under (as `recordQuota`'s attribution: an
144
+ * identity left out means the row had none). The call is refused
145
+ * (`attribution`, retryable, nothing written) once the row holds another
146
+ * epoch or identity, so a provider's late answer about a replaced
147
+ * credential never disables, or switches back on, the row now holding its
148
+ * successor.
149
+ */
150
+ attribution?: Attribution;
151
+ /**
152
+ * A provider-state change made in the same transaction as the transition,
153
+ * under the rules of `updateProviderState` (codec validation, the stamp
154
+ * rebound to the value, `unbound-credential` for a row no stamp of this
155
+ * store can bind it to); it requires `attribution`. The value and the
156
+ * enabled flag land together: no reader, and no crash at any write point,
157
+ * shows one without the other. Returning `DECLINE_TRANSITION` declines the
158
+ * whole call and writes nothing.
159
+ */
160
+ providerState?: RowTransitionMutator;
161
+ }
162
+ export interface RowTransitionResult {
126
163
  id: string;
127
- }>;
164
+ /** The provider-state mutator declined: nothing was written. */
165
+ declined?: true;
166
+ /**
167
+ * Set when a provider-state mutator ran and did not decline: what it did
168
+ * to the value, as `updateProviderState` reports it.
169
+ */
170
+ providerStateOutcome?: UpdateProviderStateResult['outcome'];
171
+ /** The provider state the row now holds, when a mutator ran and left one. */
172
+ providerState?: unknown;
173
+ }
174
+ /**
175
+ * Marks a row disabled with a reason. See `RowTransitionOptions` for the
176
+ * attributed form, which may change the provider state with it.
177
+ */
178
+ export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
128
179
  /**
129
180
  * Clears a row's `enabled: false` and its `disabledReason` in one config
130
181
  * write. An OAuth row whose recorded identity another enabled OAuth row holds
131
182
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
132
183
  * that makes `add` store such a row disabled. Enabling a row that is already
133
- * enabled writes nothing.
184
+ * enabled writes nothing. See `RowTransitionOptions` for the attributed
185
+ * form, which may change the provider state with it.
134
186
  */
135
- export declare function enableRow(rt: StoreRuntime, id: string, options?: RowToggleOptions): Promise<{
136
- id: string;
137
- }>;
187
+ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
138
188
  /**
139
189
  * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
140
190
  * lives in the roster row) in one config write, then its credential and