@cortexkit/common-auth 0.6.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.
@@ -22,6 +22,11 @@ export declare function countUnknownIdentityRows(rows: readonly PoolRow[]): numb
22
22
  * without an entry gets one at epoch 1. Nothing is ever deleted.
23
23
  */
24
24
  export declare function disableIn(tx: RowEditor, id: string, reason: string): void;
25
+ /**
26
+ * Marks a row enabled: `enabled: true` in the roster row and no
27
+ * `disabledReason` in its entry. A row without an entry is not given one.
28
+ */
29
+ export declare function enableIn(tx: RowEditor, id: string): void;
25
30
  /**
26
31
  * Two enabled OAuth rows with one wire identity are the same account: the
27
32
  * earlier row in roster order stays enabled and every later one is disabled
@@ -24,6 +24,22 @@ export function disableIn(tx, id, reason) {
24
24
  const entry = tx.entry(id) ?? { credentialEpoch: 1, needsFirstReading: true };
25
25
  tx.setEntry(id, { ...entry, disabledReason: reason });
26
26
  }
27
+ /**
28
+ * Marks a row enabled: `enabled: true` in the roster row and no
29
+ * `disabledReason` in its entry. A row without an entry is not given one.
30
+ */
31
+ export function enableIn(tx, id) {
32
+ const raw = tx.rosterRow(id);
33
+ if (!raw)
34
+ return;
35
+ raw.enabled = true;
36
+ const entry = tx.entry(id);
37
+ if (entry && 'disabledReason' in entry) {
38
+ const next = { ...entry };
39
+ delete next.disabledReason;
40
+ tx.setEntry(id, next);
41
+ }
42
+ }
27
43
  /**
28
44
  * Two enabled OAuth rows with one wire identity are the same account: the
29
45
  * earlier row in roster order stays enabled and every later one is disabled
@@ -6,12 +6,13 @@ 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
+ export type { ProviderStateMutator, RowTransitionMutator, UpdateProviderStateResult, } from './provider-state.js';
10
+ export { DECLINE_TRANSITION } from './provider-state.js';
10
11
  export type { PullHook, PullRequest } from './pull.js';
11
12
  export type { ProviderRefresh, ProviderRefreshResult, RefreshOptions, RefreshOutcome, } from './refresh.js';
12
13
  export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.js';
13
14
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
14
- export type { AddInput, AddResult, CredentialWriteInput, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
15
+ export type { AddInput, AddResult, CredentialWriteInput, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, RowTransitionOptions, RowTransitionResult, } from './rows.js';
15
16
  export type { PullReason } from './runtime.js';
16
17
  export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, ProviderStateCodec, ProviderStateDrop, ProviderStateReplacement, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
17
18
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, PROVIDER_STATE_KEY, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
@@ -1,6 +1,7 @@
1
1
  export { PoolOperationError, PoolReentryError } from './errors.js';
2
2
  export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity.js';
3
3
  export { openPoolStore } from './pool.js';
4
+ export { DECLINE_TRANSITION } from './provider-state.js';
4
5
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
5
6
  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
7
  export { POOL_OWNED_KEYS } from './settings.js';
@@ -6,7 +6,7 @@ import { type ProviderStateMutator, type UpdateProviderStateResult } from './pro
6
6
  import { type PullHook } from './pull.js';
7
7
  import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
8
8
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
9
- import { type AddInput, type AddResult, type CredentialWriteInput, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
9
+ import { type AddInput, type AddResult, type CredentialWriteInput, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions, type RowTransitionOptions, type RowTransitionResult } from './rows.js';
10
10
  import { type PoolCredential, type PoolRow, type ProviderStateCodec, type QuotaCodec, type RotateCredential, type StoredCredential } from './schema.js';
11
11
  import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
12
12
  export interface OpenPoolStoreOptions {
@@ -119,19 +119,18 @@ export interface PoolStore {
119
119
  /**
120
120
  * Sets `enabled: false` and the entry's `disabledReason`. Takes the row
121
121
  * lock, then `extraLocks`, then the store locks (the row lock and
122
- * `extraLocks` since 0.2.3).
122
+ * `extraLocks` since 0.2.3). Since 0.7.0 it may be fenced on the
123
+ * credential the caller's evidence is about (`attribution`) and carry a
124
+ * provider-state change that lands with it (`providerState`); see
125
+ * `RowTransitionOptions`.
123
126
  */
124
- disable(id: string, reason: string, options?: RowToggleOptions): Promise<{
125
- id: string;
126
- }>;
127
+ disable(id: string, reason: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
127
128
  /**
128
129
  * Clears `enabled: false` and `disabledReason` (since 0.2.3); refuses with
129
130
  * `duplicate-identity` when another enabled OAuth row holds the row's
130
- * identity. Locks as `disable`.
131
+ * identity. Locks as `disable`, and takes the same options since 0.7.0.
131
132
  */
132
- enable(id: string, options?: RowToggleOptions): Promise<{
133
- id: string;
134
- }>;
133
+ enable(id: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
135
134
  /**
136
135
  * Deletes the roster row, its per-row entry and its state-file credential
137
136
  * (since 0.2.3). Locks as `disable`; `protect` can refuse the id.
@@ -1,5 +1,6 @@
1
1
  import type { Attribution } from './attribution.js';
2
2
  import type { PoolOperation } from './errors.js';
3
+ import { type Transaction } from './mutate.js';
3
4
  import type { RowToggleOptions } from './rows.js';
4
5
  import { type StoreRuntime } from './runtime.js';
5
6
  import { type PoolRow, type ProviderStateCodec } from './schema.js';
@@ -84,3 +85,47 @@ export type UpdateProviderStateResult = {
84
85
  * `replace` stamps such a row.
85
86
  */
86
87
  export declare function updateProviderStateRow(rt: StoreRuntime, id: string, fence: Attribution, mutator: ProviderStateMutator, options?: RowToggleOptions): Promise<UpdateProviderStateResult>;
88
+ /**
89
+ * Returned by the provider-state mutator of an attributed `disable` or
90
+ * `enable` to decline the whole transition: nothing is written, neither the
91
+ * provider state nor the row's enabled flag, and the call resolves with
92
+ * `declined: true`. A mutator declines when the state it is shown is newer
93
+ * than what its caller saw, such as an eligibility recorded after the
94
+ * request whose refusal is being acted on. It is a value of its own because
95
+ * `undefined` already means "clear the provider state". `Symbol.for` keeps it
96
+ * equal across two copies of this module loaded in one process.
97
+ */
98
+ export declare const DECLINE_TRANSITION: unique symbol;
99
+ /**
100
+ * The provider-state mutator of an attributed `disable` or `enable`: as
101
+ * `ProviderStateMutator`, and it may also return `DECLINE_TRANSITION`.
102
+ */
103
+ export type RowTransitionMutator = (current: unknown | undefined, row: PoolRow) => unknown | typeof DECLINE_TRANSITION | Promise<unknown | typeof DECLINE_TRANSITION>;
104
+ /**
105
+ * What a provider-state mutator asks of a row, worked out under the locks
106
+ * before anything is written. `changed` carries the row's whole next
107
+ * state-file account entry (the value, and the stamp rebound to it when its
108
+ * credential-bound part moved); `value` is the next value, absent when it is
109
+ * cleared.
110
+ */
111
+ export type ProviderStatePlan = {
112
+ kind: 'declined';
113
+ } | {
114
+ kind: 'unchanged';
115
+ value?: unknown;
116
+ } | {
117
+ kind: 'changed';
118
+ value?: unknown;
119
+ account: Record<string, unknown>;
120
+ };
121
+ /**
122
+ * Runs a provider-state mutator for a row loaded under every lock and
123
+ * already checked by the caller (present, valid, inside its attribution
124
+ * fence), and plans the write. Refuses (`no-credential`) a row holding no
125
+ * credential, and (`unbound-credential`) one whose credential carries no
126
+ * stamp of this store at the row's epoch and identity: no stamp could bind
127
+ * the value, so no reader would ever show it. `DECLINE_TRANSITION` is
128
+ * honoured only when `declinable` is set; elsewhere it is not JSON and is
129
+ * refused as such.
130
+ */
131
+ export declare function planProviderStateIn(tx: Transaction, codec: ProviderStateCodec, operation: PoolOperation, row: PoolRow, mutator: ProviderStateMutator | RowTransitionMutator, declinable?: boolean): Promise<ProviderStatePlan>;
@@ -132,55 +132,92 @@ export async function updateProviderStateRow(rt, id, fence, mutator, options = {
132
132
  const epoch = row.credentialEpoch ?? 1;
133
133
  if (epoch !== captured || row.identity !== fence.identity)
134
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))
135
+ const plan = await planProviderStateIn(tx, codec, operation, row, mutator);
136
+ if (plan.kind !== 'changed')
156
137
  return {
157
138
  id,
158
139
  outcome: 'unchanged',
159
- ...(next !== undefined ? { providerState: next } : {}),
140
+ ...(plan.kind === 'unchanged' && plan.value !== undefined
141
+ ? { providerState: plan.value }
142
+ : {}),
160
143
  };
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);
144
+ tx.setStateAccount(id, plan.account);
180
145
  await tx.commitState();
181
- return next === undefined
146
+ return plan.value === undefined
182
147
  ? { id, outcome: 'cleared' }
183
- : { id, outcome: 'updated', providerState: next };
148
+ : { id, outcome: 'updated', providerState: plan.value };
184
149
  });
185
150
  });
186
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
+ }
@@ -1,7 +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
+ import { type ProviderStateWrite, type RowTransitionMutator, type UpdateProviderStateResult } from './provider-state.js';
5
5
  import type { PoolLockSpec } from './refresh-lock.js';
6
6
  import { type StoreRuntime } from './runtime.js';
7
7
  import { type CredentialBinding, type PoolCredential, type PoolRow, type RotateCredential, type StoredCredential } from './schema.js';
@@ -134,23 +134,57 @@ export declare function rotateRow(rt: StoreRuntime, id: string, credential: Rota
134
134
  credential: StoredCredential;
135
135
  }>;
136
136
  /**
137
- * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
138
- * extra locks before the store locks, as the other row writes do, so it waits
139
- * 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.
140
139
  */
141
- 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 {
142
163
  id: string;
143
- }>;
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>;
144
179
  /**
145
180
  * Clears a row's `enabled: false` and its `disabledReason` in one config
146
181
  * write. An OAuth row whose recorded identity another enabled OAuth row holds
147
182
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
148
183
  * that makes `add` store such a row disabled. Enabling a row that is already
149
- * enabled writes nothing.
184
+ * enabled writes nothing. See `RowTransitionOptions` for the attributed
185
+ * form, which may change the provider state with it.
150
186
  */
151
- export declare function enableRow(rt: StoreRuntime, id: string, options?: RowToggleOptions): Promise<{
152
- id: string;
153
- }>;
187
+ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
154
188
  /**
155
189
  * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
156
190
  * lives in the roster row) in one config write, then its credential and
@@ -1,11 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { PoolOperationError } from './errors.js';
2
3
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
- import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
4
+ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
4
5
  import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
5
- import { acceptProviderState, mergedProviderState, providerStateCoverage, replacementProviderState, } from './provider-state.js';
6
+ import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
6
7
  import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
7
8
  import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
8
- import { bindReplacement } from './torn.js';
9
+ import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
9
10
  /** Fields of a state entry that belong to the credential it replaces. */
10
11
  const CREDENTIAL_STATE_FIELDS = [
11
12
  'access',
@@ -420,72 +421,154 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
420
421
  });
421
422
  }
422
423
  /**
423
- * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
424
- * extra locks before the store locks, as the other row writes do, so it waits
425
- * for a refresh of the row instead of landing during its provider call.
424
+ * `disable` and `enable` in one place. Takes the row lock, then the extra
425
+ * locks, then the store locks, as every row write does, so it waits for a
426
+ * refresh of the row instead of landing during its provider call.
427
+ *
428
+ * With a provider-state mutator that changes the value, the transition is
429
+ * written as a replace is: the state file first, carrying the value and, in
430
+ * the stamp, the transition itself; then the config, flipping the row and
431
+ * recording the transition's mark (see `torn.ts`). A stop between the two
432
+ * leaves a row every reader shows transitioned beside its new value. When
433
+ * the config already says what the transition would write (an `enable` of
434
+ * an enabled row), the value alone is written, in one state write.
426
435
  */
427
- export async function disableRow(rt, id, reason, options = {}) {
428
- assertNotInsideHook('disable');
429
- return runOperation(rt.ctx, 'disable', id, options.onFailure, async (locks, progress) => {
430
- const { row: seen } = await readRow(rt, 'disable', id);
436
+ async function transitionRow(rt, operation, id, flag, options) {
437
+ assertNotInsideHook(operation);
438
+ const { ctx } = rt;
439
+ const codec = ctx.providerState;
440
+ const fence = options.attribution;
441
+ const mutator = options.providerState;
442
+ return runOperation(ctx, operation, id, options.onFailure, async (locks, progress) => {
443
+ if (fence !== undefined &&
444
+ !isCredentialEpoch(isRecord(fence) ? fence.credentialEpoch : undefined))
445
+ throw refusal(operation, id, 'invalid-input', 'the attribution must name a credential epoch that is a positive safe integer');
446
+ if (mutator !== undefined) {
447
+ if (typeof mutator !== 'function')
448
+ throw refusal(operation, id, 'invalid-input', 'the provider-state mutator must be a function');
449
+ // A provider-state value belongs to one credential, so a change to
450
+ // it must say which credential it was decided for.
451
+ if (fence === undefined)
452
+ throw refusal(operation, id, 'invalid-input', 'a provider-state change needs the attribution of the credential it is for');
453
+ if (!codec)
454
+ throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
455
+ }
456
+ const { row: seen } = await readRow(rt, operation, id);
431
457
  await locks.acquire(rowLockSpec(rt, seen));
432
458
  for (const extra of options.extraLocks ?? [])
433
459
  await locks.acquire(extra);
434
- return withTransaction(rt.ctx, locks, progress, { operation: 'disable', rowId: id }, async (tx) => {
435
- const row = tx.row(id);
460
+ return withTransaction(ctx, locks, progress, { operation, rowId: id }, async (tx) => {
461
+ const loaded = tx.row(id);
462
+ const row = flag.enabled
463
+ ? requireUsableRow('enable', id, loaded)
464
+ : loaded;
436
465
  if (!row || !tx.rosterRow(id))
437
- throw unknownRow('disable', id);
466
+ throw unknownRow(operation, id);
438
467
  if (rowLockKey(row) !== rowLockKey(seen))
439
- throw keyChanged('disable', id);
440
- disableIn(tx, id, reason);
468
+ throw keyChanged(operation, id);
469
+ if (fence !== undefined) {
470
+ // An invalid entry has no epoch to compare the fence with.
471
+ if (row.invalid)
472
+ throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
473
+ if ((row.credentialEpoch ?? 1) !== fence.credentialEpoch ||
474
+ row.identity !== fence.identity)
475
+ throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
476
+ }
477
+ // An enable of a row that is already enabled has nothing to write
478
+ // to the config; a disable always rewrites it, as it always has.
479
+ const writesConfig = !flag.enabled || !row.enabled || row.disabledReason !== undefined;
480
+ if (flag.enabled && writesConfig && row.type === 'oauth') {
481
+ const holder = row.identity === undefined
482
+ ? undefined
483
+ : tx
484
+ .rows()
485
+ .find((other) => other.id !== id &&
486
+ other.invalid === undefined &&
487
+ other.type === 'oauth' &&
488
+ other.enabled &&
489
+ other.identity === row.identity);
490
+ if (holder)
491
+ throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
492
+ }
493
+ if (mutator === undefined || codec === undefined) {
494
+ if (!writesConfig)
495
+ return { id };
496
+ if (flag.enabled)
497
+ enableIn(tx, id);
498
+ else
499
+ disableIn(tx, id, flag.reason);
500
+ await tx.commitConfig();
501
+ return { id };
502
+ }
503
+ if (!row.credential)
504
+ throw refusal(operation, id, 'no-credential', `row ${id} holds no credential`);
505
+ // A strict store refuses here, but not for an attribution alone:
506
+ // disabling or enabling a row whose credential the store cannot
507
+ // prove is harmless (an unbound row is never a candidate), while
508
+ // writing a provider state would vouch for that credential.
509
+ requireBound(operation, row);
510
+ const plan = await planProviderStateIn(tx, codec, operation, row, mutator, true);
511
+ if (plan.kind === 'declined')
512
+ return { id, declined: true };
513
+ const result = {
514
+ id,
515
+ providerStateOutcome: plan.kind === 'unchanged'
516
+ ? 'unchanged'
517
+ : plan.value === undefined
518
+ ? 'cleared'
519
+ : 'updated',
520
+ ...(plan.value !== undefined ? { providerState: plan.value } : {}),
521
+ };
522
+ if (plan.kind === 'unchanged') {
523
+ if (writesConfig) {
524
+ if (flag.enabled)
525
+ enableIn(tx, id);
526
+ else
527
+ disableIn(tx, id, flag.reason);
528
+ await tx.commitConfig();
529
+ }
530
+ return result;
531
+ }
532
+ if (!writesConfig) {
533
+ tx.setStateAccount(id, plan.account);
534
+ await tx.commitState();
535
+ return result;
536
+ }
537
+ const transition = flag.enabled
538
+ ? { mark: randomUUID(), enabled: true }
539
+ : { mark: randomUUID(), enabled: false, reason: flag.reason };
540
+ const stamp = plan.account[CREDENTIAL_STAMP_KEY];
541
+ tx.setStateAccount(id, {
542
+ ...plan.account,
543
+ [CREDENTIAL_STAMP_KEY]: {
544
+ ...stamp,
545
+ [TRANSITION_STAMP_KEY]: transition,
546
+ },
547
+ });
548
+ await tx.commitState();
549
+ applyTransition(tx, id, transition);
441
550
  await tx.commitConfig();
442
- return { id };
551
+ return result;
443
552
  });
444
553
  });
445
554
  }
555
+ /**
556
+ * Marks a row disabled with a reason. See `RowTransitionOptions` for the
557
+ * attributed form, which may change the provider state with it.
558
+ */
559
+ export function disableRow(rt, id, reason, options = {}) {
560
+ return transitionRow(rt, 'disable', id, { enabled: false, reason }, options);
561
+ }
446
562
  /**
447
563
  * Clears a row's `enabled: false` and its `disabledReason` in one config
448
564
  * write. An OAuth row whose recorded identity another enabled OAuth row holds
449
565
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
450
566
  * that makes `add` store such a row disabled. Enabling a row that is already
451
- * enabled writes nothing.
567
+ * enabled writes nothing. See `RowTransitionOptions` for the attributed
568
+ * form, which may change the provider state with it.
452
569
  */
453
- export async function enableRow(rt, id, options = {}) {
454
- assertNotInsideHook('enable');
455
- return runOperation(rt.ctx, 'enable', id, options.onFailure, async (locks, progress) => {
456
- const { row: seen } = await readRow(rt, 'enable', id);
457
- await locks.acquire(rowLockSpec(rt, seen));
458
- for (const extra of options.extraLocks ?? [])
459
- await locks.acquire(extra);
460
- return withTransaction(rt.ctx, locks, progress, { operation: 'enable', rowId: id }, async (tx) => {
461
- const row = requireUsableRow('enable', id, tx.row(id));
462
- if (rowLockKey(row) !== rowLockKey(seen))
463
- throw keyChanged('enable', id);
464
- if (row.enabled && row.disabledReason === undefined)
465
- return { id };
466
- if (row.type === 'oauth' && row.identity !== undefined) {
467
- const holder = tx
468
- .rows()
469
- .find((other) => other.id !== id &&
470
- other.invalid === undefined &&
471
- other.type === 'oauth' &&
472
- other.enabled &&
473
- other.identity === row.identity);
474
- if (holder)
475
- throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
476
- }
477
- const raw = tx.rosterRow(id);
478
- raw.enabled = true;
479
- const entry = tx.entry(id);
480
- if (entry && 'disabledReason' in entry) {
481
- const next = { ...entry };
482
- delete next.disabledReason;
483
- tx.setEntry(id, next);
484
- }
485
- await tx.commitConfig();
486
- return { id };
487
- });
488
- });
570
+ export function enableRow(rt, id, options = {}) {
571
+ return transitionRow(rt, 'enable', id, { enabled: true }, options);
489
572
  }
490
573
  /**
491
574
  * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
@@ -168,9 +168,11 @@ export interface PoolRow {
168
168
  * it belongs to, and the config still holds the replaced row. Also set when
169
169
  * a write that gives a row its first identity (`recordIdentity`, or a
170
170
  * `rotate` or refresh that learns one) stopped after stamping the identity
171
- * and before recording it in the config. The row is shown as the write
172
- * leaves it once completed, is never a candidate, and the next store write
173
- * on it writes the config to match.
171
+ * and before recording it in the config, and when an attributed `disable`
172
+ * or `enable` that changed the provider state stopped after its state
173
+ * write and before flipping the row in the config. The row is shown as the
174
+ * write leaves it once completed, is never a candidate, and the next store
175
+ * write on it writes the config to match.
174
176
  */
175
177
  torn?: true;
176
178
  /**
@@ -1,5 +1,31 @@
1
1
  import { type RowEditor } from './identity.js';
2
2
  import { type CredentialBinding, type CredentialStamp, type PoolRow, type ProviderStateCodec, type QuotaCodec } from './schema.js';
3
+ /** Key, inside a credential stamp, of an attributed enable or disable. */
4
+ export declare const TRANSITION_STAMP_KEY = "transition";
5
+ /**
6
+ * Key, inside a per-row config entry, of the mark of the last transition the
7
+ * config carries out. Older readers ignore it.
8
+ */
9
+ export declare const TRANSITION_MARK_KEY = "transitionMark";
10
+ /**
11
+ * An attributed enable or disable as its state write records it: `mark` is
12
+ * unique to that write, `enabled` is the flag it sets, and `reason` the
13
+ * disabled reason (a disable's only).
14
+ */
15
+ export interface StampedTransition {
16
+ mark: string;
17
+ enabled: boolean;
18
+ reason?: string;
19
+ }
20
+ /**
21
+ * Carries a transition out in a config being edited: the row's enabled flag
22
+ * and reason as the transition says, and its mark recorded in the entry (a
23
+ * row without an entry gets one at epoch 1, as `disableIn` gives it). An
24
+ * enable then applies the duplicate-identity rule, as every write that
25
+ * enables an OAuth row with a known identity does: the earlier row in roster
26
+ * order keeps the identity.
27
+ */
28
+ export declare function applyTransition(editor: RowEditor, id: string, transition: StampedTransition): void;
3
29
  /** How a row left between the two writes of an operation is completed. */
4
30
  export type TornCompletion = {
5
31
  kind: 'replace';
@@ -9,6 +35,9 @@ export type TornCompletion = {
9
35
  } | {
10
36
  kind: 'identity';
11
37
  identity: string;
38
+ } | {
39
+ kind: 'transition';
40
+ transition: StampedTransition;
12
41
  };
13
42
  /** Rows left between the two writes of an operation, by row id. */
14
43
  export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
@@ -1,4 +1,4 @@
1
- import { disableIdentityDuplicates, recordIdentityIn, } from './identity.js';
1
+ import { disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
2
2
  import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
3
3
  /*
4
4
  * A replace changes both files: the state file gets the new credential and
@@ -46,7 +46,69 @@ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, e
46
46
  * exactly (digest and dispatch digest), that names an identity the config
47
47
  * does not record; that is completed forward the same way, by recording the
48
48
  * identity.
49
+ *
50
+ * An attributed `disable` or `enable` that also changes the provider state
51
+ * follows the same order. Its state write carries the new value and, in the
52
+ * stamp, the transition (`transition: {mark, enabled, reason?}`, see
53
+ * `StampedTransition`); its config write then flips the row and records the
54
+ * transition's mark in the row's entry (`transitionMark`). A stamp bound to
55
+ * the row (this credential, epoch and identity, the binding that also shows
56
+ * the provider state beside it) whose transition mark the entry does not
57
+ * record is such a write stopped between its two writes, and is completed
58
+ * forward the same way: readers are shown the row disabled or enabled as the
59
+ * transition says, beside the value written with it. Once the config records
60
+ * the mark the transition says nothing more, so a later `enable` or
61
+ * `disable` of the row is never undone by it; a credential write drops it
62
+ * with the rest of the old stamp.
63
+ */
64
+ /** Key, inside a credential stamp, of an attributed enable or disable. */
65
+ export const TRANSITION_STAMP_KEY = 'transition';
66
+ /**
67
+ * Key, inside a per-row config entry, of the mark of the last transition the
68
+ * config carries out. Older readers ignore it.
49
69
  */
70
+ export const TRANSITION_MARK_KEY = 'transitionMark';
71
+ /** The well-formed transition in a raw stamp, or undefined. */
72
+ function stampedTransition(rawStamp) {
73
+ if (!isRecord(rawStamp))
74
+ return undefined;
75
+ const raw = rawStamp[TRANSITION_STAMP_KEY];
76
+ if (!isRecord(raw))
77
+ return undefined;
78
+ if (typeof raw.mark !== 'string' || raw.mark.length === 0)
79
+ return undefined;
80
+ if (raw.enabled === true)
81
+ return { mark: raw.mark, enabled: true };
82
+ if (raw.enabled === false && typeof raw.reason === 'string')
83
+ return { mark: raw.mark, enabled: false, reason: raw.reason };
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Carries a transition out in a config being edited: the row's enabled flag
88
+ * and reason as the transition says, and its mark recorded in the entry (a
89
+ * row without an entry gets one at epoch 1, as `disableIn` gives it). An
90
+ * enable then applies the duplicate-identity rule, as every write that
91
+ * enables an OAuth row with a known identity does: the earlier row in roster
92
+ * order keeps the identity.
93
+ */
94
+ export function applyTransition(editor, id, transition) {
95
+ if (transition.enabled)
96
+ enableIn(editor, id);
97
+ else
98
+ disableIn(editor, id, transition.reason ?? '');
99
+ if (!editor.rosterRow(id))
100
+ return;
101
+ const entry = editor.entry(id) ?? {
102
+ credentialEpoch: 1,
103
+ needsFirstReading: true,
104
+ };
105
+ editor.setEntry(id, { ...entry, [TRANSITION_MARK_KEY]: transition.mark });
106
+ if (!transition.enabled)
107
+ return;
108
+ const row = editor.rows().find((candidate) => candidate.id === id);
109
+ if (row?.type === 'oauth' && row.identity !== undefined)
110
+ disableIdentityDuplicates(editor, row.identity);
111
+ }
50
112
  /**
51
113
  * The credential a torn replace leaves once its config write lands: the
52
114
  * credential loaded beside the stamp, with the endpoint the stamp's binding
@@ -122,6 +184,13 @@ export function tornStamps(config, state, codec, options = {}) {
122
184
  row.identity === undefined) {
123
185
  torn.set(row.id, { kind: 'identity', identity: stamp.binding.identity });
124
186
  }
187
+ else if (stamp.credentialEpoch === epoch &&
188
+ stamp.binding.identity === row.identity) {
189
+ const transition = stampedTransition(account[CREDENTIAL_STAMP_KEY]);
190
+ if (transition &&
191
+ entryIn(config, row.id)?.[TRANSITION_MARK_KEY] !== transition.mark)
192
+ torn.set(row.id, { kind: 'transition', transition });
193
+ }
125
194
  }
126
195
  return torn;
127
196
  }
@@ -175,6 +244,8 @@ export function completeTornRows(config, state, codec, options = {}) {
175
244
  for (const [id, completion] of completions) {
176
245
  if (completion.kind === 'identity')
177
246
  recordIdentityIn(editor, id, completion.identity);
247
+ else if (completion.kind === 'transition')
248
+ applyTransition(editor, id, completion.transition);
178
249
  else if (completion.stamp.binding.identity !== undefined)
179
250
  disableIdentityDuplicates(editor, completion.stamp.binding.identity);
180
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {