@cortexkit/common-auth 0.2.1 → 0.2.3

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,5 +1,5 @@
1
1
  import { type ProjectedQuota } from '../quota/projection.js';
2
- import { type AdmissionInput, type AdmissionRefusal, type AdmissionResult, type WindowRef } from './admission.js';
2
+ import { type AdmissionInput, type AdmissionRefusal, type AdmissionResult, type RoutingRow, type WindowRef } from './admission.js';
3
3
  import { type StickyPin } from './pins.js';
4
4
  export declare const QUOTA_STALENESS_MS: number;
5
5
  export declare const MIN_RESET_HOURS: number;
@@ -63,14 +63,40 @@ export interface StickySelection {
63
63
  * configured order, preferring one with an applicable reset credit.
64
64
  */
65
65
  export declare function selectStickyCandidate(input: StickySelectionInput): StickySelection | undefined;
66
+ /** Reserve percent per window label; a missing label reserves nothing. */
67
+ export type ReservePercent = Readonly<Record<string, number>>;
68
+ /**
69
+ * Reserve percentages per row: a map keyed by row id, or a function of the
70
+ * row. A row the map lacks, or for which the function returns undefined,
71
+ * takes the shared `reservePercent`.
72
+ */
73
+ export type RowReservePercent = ReadonlyMap<string, ReservePercent> | ((row: RoutingRow) => ReservePercent | undefined);
74
+ /**
75
+ * What a valid pin does when its row is not dispatched.
76
+ *
77
+ * `keep`: the pin is retained whatever kept its row from this request.
78
+ *
79
+ * `move-on-confirmed-exhaustion`: the pin moves to the row this request is
80
+ * dispatched to when its own row was refused as confirmed exhausted (a spent
81
+ * window with a future reset, or a spent credit budget) or killed by the
82
+ * killswitch. A refusal for unknown quota (no reading yet, a missing window,
83
+ * an exhausted reading without a usable reset) and an exclusion (rate-limit
84
+ * mark, refresh backoff) keep the pin while this request is served elsewhere.
85
+ * With no admissible row the pin is retained either way.
86
+ */
87
+ export type RefusedPinPolicy = 'keep' | 'move-on-confirmed-exhaustion';
66
88
  export interface StickyRouteInput extends AdmissionInput {
67
89
  requestBytes: number;
68
90
  /** Bytes already committed per row, for example from other sessions' pins. */
69
91
  pendingBytes?: ReadonlyMap<string, number>;
70
92
  /** Killswitch verdict per row; a missing row passes. */
71
93
  killswitch?: ReadonlyMap<string, boolean>;
72
- /** Reserve percent per window label, applied to every row. */
73
- reservePercent?: Readonly<Record<string, number>>;
94
+ /** Reserve percent per window label, for every row without its own. */
95
+ reservePercent?: ReservePercent;
96
+ /** Per-row reserves, which replace `reservePercent` for the rows they cover. */
97
+ rowReservePercent?: RowReservePercent;
98
+ /** Defaults to `keep`. */
99
+ refusedPinPolicy?: RefusedPinPolicy;
74
100
  resetCreditsApplicable?: ReadonlyMap<string, number>;
75
101
  /** The session's current pin, if it has one. */
76
102
  pin?: StickyPin;
@@ -80,8 +106,9 @@ export interface StickyRouteInput extends AdmissionInput {
80
106
  }
81
107
  /**
82
108
  * What the caller does with the session's pin: keep it, replace it with
83
- * `pin`, or drop it. A valid pin is always kept, even when this request was
84
- * routed elsewhere because its row was refused or excluded.
109
+ * `pin`, or drop it. Under the default `keep` policy a valid pin is always
110
+ * kept, even when this request was routed elsewhere because its row was
111
+ * refused, excluded or killed; `refusedPinPolicy` can move it instead.
85
112
  */
86
113
  export type PinAction = {
87
114
  action: 'retain';
@@ -233,6 +233,24 @@ export function routeSticky(input) {
233
233
  };
234
234
  }
235
235
  }
236
+ // A spent window with a future reset, a spent credit budget and a killswitch
237
+ // verdict all say the pinned row will not serve until some known later time,
238
+ // so the pin may move. A row refused for want of a usable reading, or
239
+ // excluded by a short rate-limit mark or refresh backoff, may serve again on
240
+ // the next reading, so its pin stays.
241
+ const pinRefusal = input.pin ? refusals.get(input.pin.accountId) : undefined;
242
+ const pinMoves = pinValid &&
243
+ input.pin !== undefined &&
244
+ input.refusedPinPolicy === 'move-on-confirmed-exhaustion' &&
245
+ (input.killswitch?.get(input.pin.accountId) === false ||
246
+ pinRefusal?.reason === 'exhausted' ||
247
+ pinRefusal?.reason === 'budget-spent');
248
+ const reserveFor = (row) => {
249
+ const perRow = typeof input.rowReservePercent === 'function'
250
+ ? input.rowReservePercent(row)
251
+ : input.rowReservePercent?.get(row.id);
252
+ return perRow ?? input.reservePercent ?? {};
253
+ };
236
254
  const scope = input.scope;
237
255
  let candidates = input.rows
238
256
  .map((row, configuredOrder) => ({ row, configuredOrder }))
@@ -246,7 +264,7 @@ export function routeSticky(input) {
246
264
  ? undefined
247
265
  : (admitted.get(row.id)?.projection ??
248
266
  projectQuota(row.quota, scope)),
249
- reservePercent: input.reservePercent ?? {},
267
+ reservePercent: reserveFor(row),
250
268
  configuredOrder,
251
269
  ...(credits === undefined ? {} : { resetCreditsApplicable: credits }),
252
270
  ...(killswitchPasses === undefined ? {} : { killswitchPasses }),
@@ -284,7 +302,7 @@ export function routeSticky(input) {
284
302
  ...(selection.quotaCheckedAt === undefined
285
303
  ? {}
286
304
  : { quotaCheckedAt: selection.quotaCheckedAt }),
287
- pin: pinValid
305
+ pin: pinValid && !pinMoves
288
306
  ? { action: 'retain' }
289
307
  : {
290
308
  action: 'assign',
@@ -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' | 'recordIdentity' | 'refresh' | 'pull';
3
+ export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'recordIdentity' | 'refresh' | 'pull';
4
4
  /**
5
5
  * How far an operation got before it failed.
6
6
  *
@@ -16,7 +16,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
16
16
  * lock outcomes (a wait that ran out, and a lease found lost); the rest are
17
17
  * refusals and failures of the operation itself.
18
18
  */
19
- 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-key-changed' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
19
+ 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' | 'row-key-changed' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
20
20
  /**
21
21
  * The single failure value of every store operation. `committed` is present
22
22
  * only when the operation had already written a credential to the state file
@@ -10,7 +10,7 @@ export type { PullHook, PullRequest } from './pull.js';
10
10
  export type { ProviderRefresh, ProviderRefreshResult, RefreshOptions, RefreshOutcome, } from './refresh.js';
11
11
  export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.js';
12
12
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
13
- export type { AddInput, AddResult, FailureHook, RowOperationOptions, } from './rows.js';
13
+ export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, RemoveView, RowOperationOptions, RowToggleOptions, } from './rows.js';
14
14
  export type { PullReason } from './runtime.js';
15
15
  export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, StoredCredential, } from './schema.js';
16
16
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
@@ -76,10 +76,18 @@ export declare class Transaction {
76
76
  roster(): unknown[];
77
77
  /** The first roster row with this id (the one the pool loads). */
78
78
  rosterRow(id: string): Record<string, unknown> | undefined;
79
+ /**
80
+ * Drops every roster row carrying this id. The row's per-row entry goes with
81
+ * it on the next `commitConfig`, which drops entries for ids no longer in
82
+ * the roster. Returns how many roster rows were dropped.
83
+ */
84
+ dropRosterRows(id: string): number;
79
85
  entries(): Record<string, unknown>;
80
86
  entry(id: string): Record<string, unknown> | undefined;
81
87
  setEntry(id: string, entry: Record<string, unknown>): void;
82
88
  stateAccount(id: string): Record<string, unknown> | undefined;
89
+ /** Drops the row's credential and runtime fields from the state file's accounts. */
90
+ dropStateAccount(id: string): void;
83
91
  setStateAccount(id: string, fields: Record<string, unknown>): void;
84
92
  /**
85
93
  * Writes the config: legacy `version: 1` and the legacy roster beside
@@ -94,6 +94,17 @@ export class Transaction {
94
94
  rosterRow(id) {
95
95
  return this.roster().find((raw) => isRecord(raw) && raw.id === id);
96
96
  }
97
+ /**
98
+ * Drops every roster row carrying this id. The row's per-row entry goes with
99
+ * it on the next `commitConfig`, which drops entries for ids no longer in
100
+ * the roster. Returns how many roster rows were dropped.
101
+ */
102
+ dropRosterRows(id) {
103
+ const roster = this.roster();
104
+ const kept = roster.filter((raw) => !(isRecord(raw) && raw.id === id));
105
+ this.config.accounts = kept;
106
+ return roster.length - kept.length;
107
+ }
97
108
  entries() {
98
109
  if (!isRecord(this.config[POOL_KEY]))
99
110
  this.config[POOL_KEY] = {};
@@ -120,6 +131,11 @@ export class Transaction {
120
131
  const entry = Object.hasOwn(accounts, id) ? accounts[id] : undefined;
121
132
  return isRecord(entry) ? entry : undefined;
122
133
  }
134
+ /** Drops the row's credential and runtime fields from the state file's accounts. */
135
+ dropStateAccount(id) {
136
+ if (isRecord(this.state.accounts) && Object.hasOwn(this.state.accounts, id))
137
+ delete this.state.accounts[id];
138
+ }
123
139
  setStateAccount(id, fields) {
124
140
  if (!isRecord(this.state.accounts))
125
141
  this.state.accounts = {};
@@ -5,7 +5,7 @@ import { type HoldPoint, type InitializeOutcome, type StoreContext } from './mut
5
5
  import { type PullHook } from './pull.js';
6
6
  import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
7
7
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
8
- import { type AddInput, type AddResult, type RowOperationOptions } from './rows.js';
8
+ import { type AddInput, type AddResult, type RemoveOptions, type RemoveResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
9
9
  import { type PoolCredential, type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
10
10
  export interface OpenPoolStoreOptions {
11
11
  /** The provider every row of this pool belongs to; keys the provider-wide lock. */
@@ -80,9 +80,27 @@ export interface PoolStore {
80
80
  id: string;
81
81
  credential: StoredCredential;
82
82
  }>;
83
- disable(id: string, reason: string, options?: Pick<RowOperationOptions, 'onFailure'>): Promise<{
83
+ /**
84
+ * Sets `enabled: false` and the entry's `disabledReason`. Takes the row
85
+ * lock, then `extraLocks`, then the store locks (the row lock and
86
+ * `extraLocks` since 0.2.3).
87
+ */
88
+ disable(id: string, reason: string, options?: RowToggleOptions): Promise<{
89
+ id: string;
90
+ }>;
91
+ /**
92
+ * Clears `enabled: false` and `disabledReason` (since 0.2.3); refuses with
93
+ * `duplicate-identity` when another enabled OAuth row holds the row's
94
+ * identity. Locks as `disable`.
95
+ */
96
+ enable(id: string, options?: RowToggleOptions): Promise<{
84
97
  id: string;
85
98
  }>;
99
+ /**
100
+ * Deletes the roster row, its per-row entry and its state-file credential
101
+ * (since 0.2.3). Locks as `disable`; `protect` can refuse the id.
102
+ */
103
+ remove(id: string, options?: RemoveOptions): Promise<RemoveResult>;
86
104
  recordIdentity(id: string, identity: string, options?: RowOperationOptions): Promise<{
87
105
  id: string;
88
106
  disabled: string[];
@@ -3,7 +3,7 @@ import { initializePool, readPool, } from './mutate.js';
3
3
  import { PullScheduler } from './pull.js';
4
4
  import { refreshRow, } from './refresh.js';
5
5
  import { POOL_LOCK_DEFAULTS, } from './refresh-lock.js';
6
- import { addRow, disableRow, recordRowIdentity, replaceRow, rotateRow, } from './rows.js';
6
+ import { addRow, disableRow, enableRow, recordRowIdentity, removeRow, replaceRow, rotateRow, } from './rows.js';
7
7
  import { POOL_SCHEMA_VERSION, } from './schema.js';
8
8
  /**
9
9
  * Process-wide memory per config file: ids whose per-row entry a library
@@ -92,6 +92,8 @@ export function openPoolStore(options) {
92
92
  replace: (id, credential, input, callOptions) => replaceRow(rt, id, credential, input, callOptions),
93
93
  rotate: (id, credential, input, callOptions) => rotateRow(rt, id, credential, input, callOptions),
94
94
  disable: (id, reason, callOptions) => disableRow(rt, id, reason, callOptions),
95
+ enable: (id, callOptions) => enableRow(rt, id, callOptions),
96
+ remove: (id, callOptions) => removeRow(rt, id, callOptions),
95
97
  recordIdentity: (id, identity, callOptions) => recordRowIdentity(rt, id, identity, callOptions),
96
98
  refresh: (id, provider, callOptions) => refreshRow(rt, id, provider, callOptions),
97
99
  recordQuota: (id, attribution, observation) => recordQuota(rt, id, attribution, observation),
@@ -2,14 +2,61 @@ import type { PoolOperationError } from './errors.js';
2
2
  import { type Transaction } from './mutate.js';
3
3
  import type { PoolLockSpec } from './refresh-lock.js';
4
4
  import { type StoreRuntime } from './runtime.js';
5
- import { type PoolCredential, type StoredCredential } from './schema.js';
5
+ import { type PoolCredential, type PoolRow, type StoredCredential } from './schema.js';
6
6
  export type FailureHook = (rowId: string, error: PoolOperationError) => void | Promise<void>;
7
7
  export interface RowOperationOptions {
8
8
  /** Called once, awaited, on every non-success path, before locks release. */
9
9
  onFailure?: FailureHook;
10
10
  /** The provider-wide lock, when an operation may change identity keying. */
11
11
  providerLock?: PoolLockSpec;
12
+ /**
13
+ * Further locks taken after the row lock and the provider-wide lock, in
14
+ * this order, before the store locks: the same place `refresh` takes its
15
+ * extra locks, so a caller holding legacy locks around a row write and a
16
+ * refresh of that row acquire them in one order and cannot deadlock.
17
+ */
18
+ extraLocks?: readonly PoolLockSpec[];
12
19
  }
20
+ /**
21
+ * Options of `disable`, `enable` and `remove`. The provider-wide lock guards
22
+ * changes to the recorded identity a row lock is named by; none of these
23
+ * three records an identity, so none takes it. The extra locks are taken
24
+ * where every other row write takes them, after the row lock and before the
25
+ * store locks.
26
+ */
27
+ export type RowToggleOptions = Pick<RowOperationOptions, 'onFailure' | 'extraLocks'>;
28
+ /** What a `remove` protect predicate is shown, read under every lock. */
29
+ export interface RemoveView {
30
+ /**
31
+ * The row as loaded; undefined when the roster no longer holds the id and
32
+ * only its state-file entry is left (a removal interrupted between writes).
33
+ */
34
+ row: PoolRow | undefined;
35
+ /** The config file as read under the store locks. */
36
+ config: Readonly<Record<string, unknown>>;
37
+ /** The state file as read under the store locks. */
38
+ state: Readonly<Record<string, unknown>>;
39
+ }
40
+ export interface RemoveOptions extends RowToggleOptions {
41
+ /**
42
+ * Awaited under every lock before anything is written; a reason refuses
43
+ * the removal (kind `row-protected`) with both files unchanged. The store
44
+ * keeps no record of a plugin's in-flight work, so this is where a plugin
45
+ * refuses an id it reserves or one its own pending-operation record (kept
46
+ * in the config or state file) still names: reading that record from the
47
+ * locked files here cannot race a writer that holds the store locks.
48
+ */
49
+ protect?: (id: string, view: RemoveView) => string | undefined | Promise<string | undefined>;
50
+ }
51
+ export type RemoveResult = {
52
+ id: string;
53
+ /**
54
+ * `removed`: the roster row was dropped (and its state entry, if any).
55
+ * `completed`: only a state-file entry was left, by a removal interrupted
56
+ * between its config and state writes, and it is now dropped.
57
+ */
58
+ outcome: 'removed' | 'completed';
59
+ };
13
60
  export interface AddInput {
14
61
  id: string;
15
62
  credential: PoolCredential;
@@ -44,9 +91,34 @@ export declare function rotateRow(rt: StoreRuntime, id: string, credential: Pool
44
91
  id: string;
45
92
  credential: StoredCredential;
46
93
  }>;
47
- export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: Pick<RowOperationOptions, 'onFailure'>): Promise<{
94
+ /**
95
+ * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
96
+ * extra locks before the store locks, as the other row writes do, so it waits
97
+ * for a refresh of the row instead of landing during its provider call.
98
+ */
99
+ export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: RowToggleOptions): Promise<{
100
+ id: string;
101
+ }>;
102
+ /**
103
+ * Clears a row's `enabled: false` and its `disabledReason` in one config
104
+ * write. An OAuth row whose recorded identity another enabled OAuth row holds
105
+ * stays disabled and the call refuses (`duplicate-identity`): the same rule
106
+ * that makes `add` store such a row disabled. Enabling a row that is already
107
+ * enabled writes nothing.
108
+ */
109
+ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowToggleOptions): Promise<{
48
110
  id: string;
49
111
  }>;
112
+ /**
113
+ * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
114
+ * lives in the roster row) in one config write, then its credential and
115
+ * runtime fields in one state write. The config goes first, so a crash
116
+ * between the two leaves a row every reader already sees as removed, with
117
+ * only an orphaned state entry that no reader loads; calling `remove` again
118
+ * drops that entry (`completed`). As with every id the store drops, the id is
119
+ * not reused by `add` in this process.
120
+ */
121
+ export declare function removeRow(rt: StoreRuntime, id: string, options?: RemoveOptions): Promise<RemoveResult>;
50
122
  export declare function recordRowIdentity(rt: StoreRuntime, id: string, identity: string, options?: RowOperationOptions): Promise<{
51
123
  id: string;
52
124
  disabled: string[];
@@ -1,6 +1,6 @@
1
- import { assertNotInsideHook } from './hooks.js';
1
+ import { assertNotInsideHook, runInsideHook } from './hooks.js';
2
2
  import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
3
- import { runOperation, withTransaction } from './mutate.js';
3
+ import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
4
4
  import { readRow, refusal, rowLockSpec, unknownRow, } from './runtime.js';
5
5
  import { credentialProblem, fingerprintOf, idProblem, isRecord, rosterRowFor, rotationStamp, rowLockKey, stateFieldsFor, storedCredential, } from './schema.js';
6
6
  /** Fields of a state entry that belong to the credential it replaces. */
@@ -78,6 +78,8 @@ export async function addRow(rt, input, options = {}) {
78
78
  await locks.acquire(rowLockSpec(rt, { id, identity }));
79
79
  if (credential.type === 'oauth')
80
80
  await locks.acquire(options.providerLock ?? rt.providerLock);
81
+ for (const extra of options.extraLocks ?? [])
82
+ await locks.acquire(extra);
81
83
  return withTransaction(ctx, locks, progress, { operation: 'add', rowId: id }, async (tx) => {
82
84
  const rows = tx.rows();
83
85
  const fingerprint = fingerprintOf(credential);
@@ -150,6 +152,8 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
150
152
  await locks.acquire(rowLockSpec(rt, seen));
151
153
  if (credential.type === 'oauth')
152
154
  await locks.acquire(options.providerLock ?? rt.providerLock);
155
+ for (const extra of options.extraLocks ?? [])
156
+ await locks.acquire(extra);
153
157
  return withTransaction(ctx, locks, progress, { operation: 'replace', rowId: id }, async (tx) => {
154
158
  const row = requireUsableRow('replace', id, tx.row(id), credential);
155
159
  if (rowLockKey(row) !== rowLockKey(seen))
@@ -197,6 +201,8 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
197
201
  await locks.acquire(rowLockSpec(rt, seen));
198
202
  if (input.identity !== undefined && credential.type === 'oauth')
199
203
  await locks.acquire(options.providerLock ?? rt.providerLock);
204
+ for (const extra of options.extraLocks ?? [])
205
+ await locks.acquire(extra);
200
206
  return withTransaction(ctx, locks, progress, { operation: 'rotate', rowId: id }, async (tx) => {
201
207
  const row = requireUsableRow('rotate', id, tx.row(id), credential);
202
208
  if (rowLockKey(row) !== rowLockKey(seen))
@@ -220,15 +226,134 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
220
226
  });
221
227
  });
222
228
  }
229
+ /**
230
+ * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
231
+ * extra locks before the store locks, as the other row writes do, so it waits
232
+ * for a refresh of the row instead of landing during its provider call.
233
+ */
223
234
  export async function disableRow(rt, id, reason, options = {}) {
224
235
  assertNotInsideHook('disable');
225
- return runOperation(rt.ctx, 'disable', id, options.onFailure, async (locks, progress) => withTransaction(rt.ctx, locks, progress, { operation: 'disable', rowId: id }, async (tx) => {
226
- if (!tx.rosterRow(id))
227
- throw unknownRow('disable', id);
228
- disableIn(tx, id, reason);
229
- await tx.commitConfig();
230
- return { id };
231
- }));
236
+ return runOperation(rt.ctx, 'disable', id, options.onFailure, async (locks, progress) => {
237
+ const { row: seen } = await readRow(rt, 'disable', id);
238
+ await locks.acquire(rowLockSpec(rt, seen));
239
+ for (const extra of options.extraLocks ?? [])
240
+ await locks.acquire(extra);
241
+ return withTransaction(rt.ctx, locks, progress, { operation: 'disable', rowId: id }, async (tx) => {
242
+ const row = tx.row(id);
243
+ if (!row || !tx.rosterRow(id))
244
+ throw unknownRow('disable', id);
245
+ if (rowLockKey(row) !== rowLockKey(seen))
246
+ throw keyChanged('disable', id);
247
+ disableIn(tx, id, reason);
248
+ await tx.commitConfig();
249
+ return { id };
250
+ });
251
+ });
252
+ }
253
+ /**
254
+ * Clears a row's `enabled: false` and its `disabledReason` in one config
255
+ * write. An OAuth row whose recorded identity another enabled OAuth row holds
256
+ * stays disabled and the call refuses (`duplicate-identity`): the same rule
257
+ * that makes `add` store such a row disabled. Enabling a row that is already
258
+ * enabled writes nothing.
259
+ */
260
+ export async function enableRow(rt, id, options = {}) {
261
+ assertNotInsideHook('enable');
262
+ return runOperation(rt.ctx, 'enable', id, options.onFailure, async (locks, progress) => {
263
+ const { row: seen } = await readRow(rt, 'enable', id);
264
+ await locks.acquire(rowLockSpec(rt, seen));
265
+ for (const extra of options.extraLocks ?? [])
266
+ await locks.acquire(extra);
267
+ return withTransaction(rt.ctx, locks, progress, { operation: 'enable', rowId: id }, async (tx) => {
268
+ const row = requireUsableRow('enable', id, tx.row(id));
269
+ if (rowLockKey(row) !== rowLockKey(seen))
270
+ throw keyChanged('enable', id);
271
+ if (row.enabled && row.disabledReason === undefined)
272
+ return { id };
273
+ if (row.type === 'oauth' && row.identity !== undefined) {
274
+ const holder = tx
275
+ .rows()
276
+ .find((other) => other.id !== id &&
277
+ other.invalid === undefined &&
278
+ other.type === 'oauth' &&
279
+ other.enabled &&
280
+ other.identity === row.identity);
281
+ if (holder)
282
+ throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
283
+ }
284
+ const raw = tx.rosterRow(id);
285
+ raw.enabled = true;
286
+ const entry = tx.entry(id);
287
+ if (entry && 'disabledReason' in entry) {
288
+ const next = { ...entry };
289
+ delete next.disabledReason;
290
+ tx.setEntry(id, next);
291
+ }
292
+ await tx.commitConfig();
293
+ return { id };
294
+ });
295
+ });
296
+ }
297
+ /**
298
+ * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
299
+ * lives in the roster row) in one config write, then its credential and
300
+ * runtime fields in one state write. The config goes first, so a crash
301
+ * between the two leaves a row every reader already sees as removed, with
302
+ * only an orphaned state entry that no reader loads; calling `remove` again
303
+ * drops that entry (`completed`). As with every id the store drops, the id is
304
+ * not reused by `add` in this process.
305
+ */
306
+ export async function removeRow(rt, id, options = {}) {
307
+ assertNotInsideHook('remove');
308
+ const { ctx } = rt;
309
+ return runOperation(ctx, 'remove', id, options.onFailure, async (locks, progress) => {
310
+ // Only a non-string or empty id is refused: a roster row whose id the
311
+ // older readers would trim is invalid, and removing it is a repair.
312
+ if (typeof id !== 'string' || id.length === 0)
313
+ throw refusal('remove', id, 'invalid-input', 'id must be non-empty');
314
+ const result = await readPool(ctx);
315
+ if (result.status !== 'ready')
316
+ throw notReadyError(result, 'remove', id);
317
+ const seen = result.rows.find((row) => row.id === id);
318
+ if (!seen && !hasStateAccount(result.state, id))
319
+ throw unknownRow('remove', id);
320
+ const seenKey = rowLockKey(seen ?? { id });
321
+ await locks.acquire(rowLockSpec(rt, seen ?? { id }));
322
+ for (const extra of options.extraLocks ?? [])
323
+ await locks.acquire(extra);
324
+ return withTransaction(ctx, locks, progress, { operation: 'remove', rowId: id }, async (tx) => {
325
+ const row = tx.row(id);
326
+ const orphan = hasStateAccount(tx.state, id);
327
+ if (!row && !orphan)
328
+ throw unknownRow('remove', id);
329
+ if (rowLockKey(row ?? { id }) !== seenKey)
330
+ throw keyChanged('remove', id);
331
+ const protect = options.protect;
332
+ if (protect) {
333
+ const view = {
334
+ row,
335
+ config: tx.snapshot.config,
336
+ state: tx.snapshot.state,
337
+ };
338
+ const reason = await runInsideHook('remove', () => protect(id, view));
339
+ if (reason !== undefined)
340
+ throw refusal('remove', id, 'row-protected', reason);
341
+ }
342
+ if (row) {
343
+ tx.dropRosterRows(id);
344
+ await tx.commitConfig();
345
+ }
346
+ if (orphan) {
347
+ tx.dropStateAccount(id);
348
+ await tx.commitState();
349
+ }
350
+ return { id, outcome: row ? 'removed' : 'completed' };
351
+ });
352
+ });
353
+ }
354
+ function hasStateAccount(state, id) {
355
+ const accounts = state.accounts;
356
+ return isRecord(accounts) && Object.hasOwn(accounts, id);
232
357
  }
233
358
  export async function recordRowIdentity(rt, id, identity, options = {}) {
234
359
  assertNotInsideHook('recordIdentity');
@@ -238,6 +363,8 @@ export async function recordRowIdentity(rt, id, identity, options = {}) {
238
363
  const { row: seen } = await readRow(rt, 'recordIdentity', id);
239
364
  await locks.acquire(rowLockSpec(rt, seen));
240
365
  await locks.acquire(options.providerLock ?? rt.providerLock);
366
+ for (const extra of options.extraLocks ?? [])
367
+ await locks.acquire(extra);
241
368
  return withTransaction(rt.ctx, locks, progress, { operation: 'recordIdentity', rowId: id }, async (tx) => {
242
369
  const row = requireUsableRow('recordIdentity', id, tx.row(id));
243
370
  if (rowLockKey(row) !== rowLockKey(seen))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Shared plumbing for the CortexKit auth plugins: loopback RPC, file locks and atomic writes, logger, sidebar state file, TUI preferences and TUI build.",
5
5
  "license": "MIT",
6
6
  "repository": {