@cortexkit/common-auth 0.2.2 → 0.2.4

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 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // Placeholder so the export resolves until this subpath is built.
2
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // Placeholder so the export resolves until this subpath is built.
2
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // Placeholder so the export resolves until this subpath is built.
2
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // Placeholder so the export resolves until this subpath is built.
2
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // Placeholder so the export resolves until this subpath is built.
2
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // Placeholder so the export resolves until this subpath is built.
2
+ export {};
@@ -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' | 'reorder' | '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' | 'invalid-order' | '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
@@ -9,4 +9,4 @@ export interface PoolLogger {
9
9
  * Runs a failure hook. A hook that throws never replaces the failure it was
10
10
  * handed: its exception is logged and discarded.
11
11
  */
12
- export declare function callFailureHook<E>(operation: PoolOperation, hook: ((rowId: string, error: E) => void | Promise<void>) | undefined, rowId: string, error: E, logger: PoolLogger | undefined): Promise<void>;
12
+ export declare function callFailureHook<E, R extends string | undefined>(operation: PoolOperation, hook: ((rowId: R, error: E) => void | Promise<void>) | undefined, rowId: R, error: E, logger: PoolLogger | undefined): Promise<void>;
@@ -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, ReorderOptions, ReorderResult, 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
@@ -119,6 +127,7 @@ export declare function toFailure(error: unknown, operation: PoolOperation, rowI
119
127
  * The frame every lock-holding operation runs in: failures are mapped onto
120
128
  * the failure value, handed to the failure hook while the outer locks are
121
129
  * still held (the store locks are already released), and rethrown; every
122
- * lock is released afterwards.
130
+ * lock is released afterwards. `rowId` is undefined for an operation that
131
+ * names no row (`reorder`).
123
132
  */
124
- export declare function runOperation<T>(ctx: StoreContext, operation: PoolOperation, rowId: string, onFailure: ((rowId: string, error: PoolOperationError) => void | Promise<void>) | undefined, body: (locks: LockStack, progress: Progress) => Promise<T>): Promise<T>;
133
+ export declare function runOperation<T, R extends string | undefined = string>(ctx: StoreContext, operation: PoolOperation, rowId: R, onFailure: ((rowId: R, error: PoolOperationError) => void | Promise<void>) | undefined, body: (locks: LockStack, progress: Progress) => Promise<T>): Promise<T>;
@@ -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 = {};
@@ -319,7 +335,8 @@ export function toFailure(error, operation, rowId, progress) {
319
335
  * The frame every lock-holding operation runs in: failures are mapped onto
320
336
  * the failure value, handed to the failure hook while the outer locks are
321
337
  * still held (the store locks are already released), and rethrown; every
322
- * lock is released afterwards.
338
+ * lock is released afterwards. `rowId` is undefined for an operation that
339
+ * names no row (`reorder`).
323
340
  */
324
341
  export async function runOperation(ctx, operation, rowId, onFailure, body) {
325
342
  const locks = new LockStack(ctx.lockDefaults, ctx.lockEnv);
@@ -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 ReorderOptions, type ReorderResult, 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,34 @@ 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>;
104
+ /**
105
+ * Sets the roster order (since 0.2.4) in one config write. `ids` must name
106
+ * every roster id exactly once; anything else refuses with `invalid-order`
107
+ * and writes nothing. Takes `extraLocks`, then the store locks; no row or
108
+ * provider-wide lock. Roster rows and their entries are left unchanged.
109
+ */
110
+ reorder(ids: readonly string[], options?: ReorderOptions): Promise<ReorderResult>;
86
111
  recordIdentity(id: string, identity: string, options?: RowOperationOptions): Promise<{
87
112
  id: string;
88
113
  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, reorderRows, 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,9 @@ 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),
97
+ reorder: (ids, callOptions) => reorderRows(rt, ids, callOptions),
95
98
  recordIdentity: (id, identity, callOptions) => recordRowIdentity(rt, id, identity, callOptions),
96
99
  refresh: (id, provider, callOptions) => refreshRow(rt, id, provider, callOptions),
97
100
  recordQuota: (id, attribution, observation) => recordQuota(rt, id, attribution, observation),
@@ -1,8 +1,8 @@
1
- import type { PoolOperationError } from './errors.js';
1
+ import { 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. */
@@ -17,6 +17,63 @@ export interface RowOperationOptions {
17
17
  */
18
18
  extraLocks?: readonly PoolLockSpec[];
19
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
+ };
60
+ /**
61
+ * Options of `reorder`. It names no row, so its failure hook is handed only
62
+ * the failure; it takes no row lock and no provider-wide lock, so the extra
63
+ * locks are taken first, then the store locks.
64
+ */
65
+ export interface ReorderOptions {
66
+ /** Called once, awaited, on every non-success path, before the extra locks release. */
67
+ onFailure?: (error: PoolOperationError) => void | Promise<void>;
68
+ /** Locks taken, in this order, before the store locks. */
69
+ extraLocks?: readonly PoolLockSpec[];
70
+ }
71
+ export type ReorderResult = {
72
+ /** The roster order now on disk. */
73
+ ids: string[];
74
+ /** `unchanged` when the roster was already in this order; nothing was written. */
75
+ outcome: 'reordered' | 'unchanged';
76
+ };
20
77
  export interface AddInput {
21
78
  id: string;
22
79
  credential: PoolCredential;
@@ -51,9 +108,44 @@ export declare function rotateRow(rt: StoreRuntime, id: string, credential: Pool
51
108
  id: string;
52
109
  credential: StoredCredential;
53
110
  }>;
54
- export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: Pick<RowOperationOptions, 'onFailure'>): Promise<{
111
+ /**
112
+ * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
113
+ * extra locks before the store locks, as the other row writes do, so it waits
114
+ * for a refresh of the row instead of landing during its provider call.
115
+ */
116
+ export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: RowToggleOptions): Promise<{
55
117
  id: string;
56
118
  }>;
119
+ /**
120
+ * Clears a row's `enabled: false` and its `disabledReason` in one config
121
+ * write. An OAuth row whose recorded identity another enabled OAuth row holds
122
+ * stays disabled and the call refuses (`duplicate-identity`): the same rule
123
+ * that makes `add` store such a row disabled. Enabling a row that is already
124
+ * enabled writes nothing.
125
+ */
126
+ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowToggleOptions): Promise<{
127
+ id: string;
128
+ }>;
129
+ /**
130
+ * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
131
+ * lives in the roster row) in one config write, then its credential and
132
+ * runtime fields in one state write. The config goes first, so a crash
133
+ * between the two leaves a row every reader already sees as removed, with
134
+ * only an orphaned state entry that no reader loads; calling `remove` again
135
+ * drops that entry (`completed`). As with every id the store drops, the id is
136
+ * not reused by `add` in this process.
137
+ */
138
+ export declare function removeRow(rt: StoreRuntime, id: string, options?: RemoveOptions): Promise<RemoveResult>;
139
+ /**
140
+ * Sets the roster order in one config write. `ids` must name every roster id
141
+ * exactly once; anything else refuses (`invalid-order`) before writing. The
142
+ * roster rows, the per-row entries and the state file are left as they are:
143
+ * only the order of the legacy `accounts` array changes, which older readers
144
+ * load as is. It takes the extra locks, then the store locks, and no row or
145
+ * provider-wide lock, since no row's credential, identity or quota changes.
146
+ * An order equal to the current one writes nothing.
147
+ */
148
+ export declare function reorderRows(rt: StoreRuntime, ids: readonly string[], options?: ReorderOptions): Promise<ReorderResult>;
57
149
  export declare function recordRowIdentity(rt: StoreRuntime, id: string, identity: string, options?: RowOperationOptions): Promise<{
58
150
  id: string;
59
151
  disabled: string[];
@@ -1,6 +1,7 @@
1
- import { assertNotInsideHook } from './hooks.js';
1
+ import { PoolOperationError } from './errors.js';
2
+ import { assertNotInsideHook, runInsideHook } from './hooks.js';
2
3
  import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
3
- import { runOperation, withTransaction } from './mutate.js';
4
+ import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
4
5
  import { readRow, refusal, rowLockSpec, unknownRow, } from './runtime.js';
5
6
  import { credentialProblem, fingerprintOf, idProblem, isRecord, rosterRowFor, rotationStamp, rowLockKey, stateFieldsFor, storedCredential, } from './schema.js';
6
7
  /** Fields of a state entry that belong to the credential it replaces. */
@@ -226,15 +227,226 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
226
227
  });
227
228
  });
228
229
  }
230
+ /**
231
+ * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
232
+ * extra locks before the store locks, as the other row writes do, so it waits
233
+ * for a refresh of the row instead of landing during its provider call.
234
+ */
229
235
  export async function disableRow(rt, id, reason, options = {}) {
230
236
  assertNotInsideHook('disable');
231
- return runOperation(rt.ctx, 'disable', id, options.onFailure, async (locks, progress) => withTransaction(rt.ctx, locks, progress, { operation: 'disable', rowId: id }, async (tx) => {
232
- if (!tx.rosterRow(id))
233
- throw unknownRow('disable', id);
234
- disableIn(tx, id, reason);
235
- await tx.commitConfig();
236
- return { id };
237
- }));
237
+ return runOperation(rt.ctx, 'disable', id, options.onFailure, async (locks, progress) => {
238
+ const { row: seen } = await readRow(rt, 'disable', id);
239
+ await locks.acquire(rowLockSpec(rt, seen));
240
+ for (const extra of options.extraLocks ?? [])
241
+ await locks.acquire(extra);
242
+ return withTransaction(rt.ctx, locks, progress, { operation: 'disable', rowId: id }, async (tx) => {
243
+ const row = tx.row(id);
244
+ if (!row || !tx.rosterRow(id))
245
+ throw unknownRow('disable', id);
246
+ if (rowLockKey(row) !== rowLockKey(seen))
247
+ throw keyChanged('disable', id);
248
+ disableIn(tx, id, reason);
249
+ await tx.commitConfig();
250
+ return { id };
251
+ });
252
+ });
253
+ }
254
+ /**
255
+ * Clears a row's `enabled: false` and its `disabledReason` in one config
256
+ * write. An OAuth row whose recorded identity another enabled OAuth row holds
257
+ * stays disabled and the call refuses (`duplicate-identity`): the same rule
258
+ * that makes `add` store such a row disabled. Enabling a row that is already
259
+ * enabled writes nothing.
260
+ */
261
+ export async function enableRow(rt, id, options = {}) {
262
+ assertNotInsideHook('enable');
263
+ return runOperation(rt.ctx, 'enable', id, options.onFailure, async (locks, progress) => {
264
+ const { row: seen } = await readRow(rt, 'enable', id);
265
+ await locks.acquire(rowLockSpec(rt, seen));
266
+ for (const extra of options.extraLocks ?? [])
267
+ await locks.acquire(extra);
268
+ return withTransaction(rt.ctx, locks, progress, { operation: 'enable', rowId: id }, async (tx) => {
269
+ const row = requireUsableRow('enable', id, tx.row(id));
270
+ if (rowLockKey(row) !== rowLockKey(seen))
271
+ throw keyChanged('enable', id);
272
+ if (row.enabled && row.disabledReason === undefined)
273
+ return { id };
274
+ if (row.type === 'oauth' && row.identity !== undefined) {
275
+ const holder = tx
276
+ .rows()
277
+ .find((other) => other.id !== id &&
278
+ other.invalid === undefined &&
279
+ other.type === 'oauth' &&
280
+ other.enabled &&
281
+ other.identity === row.identity);
282
+ if (holder)
283
+ throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
284
+ }
285
+ const raw = tx.rosterRow(id);
286
+ raw.enabled = true;
287
+ const entry = tx.entry(id);
288
+ if (entry && 'disabledReason' in entry) {
289
+ const next = { ...entry };
290
+ delete next.disabledReason;
291
+ tx.setEntry(id, next);
292
+ }
293
+ await tx.commitConfig();
294
+ return { id };
295
+ });
296
+ });
297
+ }
298
+ /**
299
+ * Deletes a row: its roster row and per-row entry (quota, epoch; the identity
300
+ * lives in the roster row) in one config write, then its credential and
301
+ * runtime fields in one state write. The config goes first, so a crash
302
+ * between the two leaves a row every reader already sees as removed, with
303
+ * only an orphaned state entry that no reader loads; calling `remove` again
304
+ * drops that entry (`completed`). As with every id the store drops, the id is
305
+ * not reused by `add` in this process.
306
+ */
307
+ export async function removeRow(rt, id, options = {}) {
308
+ assertNotInsideHook('remove');
309
+ const { ctx } = rt;
310
+ return runOperation(ctx, 'remove', id, options.onFailure, async (locks, progress) => {
311
+ // Only a non-string or empty id is refused: a roster row whose id the
312
+ // older readers would trim is invalid, and removing it is a repair.
313
+ if (typeof id !== 'string' || id.length === 0)
314
+ throw refusal('remove', id, 'invalid-input', 'id must be non-empty');
315
+ const result = await readPool(ctx);
316
+ if (result.status !== 'ready')
317
+ throw notReadyError(result, 'remove', id);
318
+ const seen = result.rows.find((row) => row.id === id);
319
+ if (!seen && !hasStateAccount(result.state, id))
320
+ throw unknownRow('remove', id);
321
+ const seenKey = rowLockKey(seen ?? { id });
322
+ await locks.acquire(rowLockSpec(rt, seen ?? { id }));
323
+ for (const extra of options.extraLocks ?? [])
324
+ await locks.acquire(extra);
325
+ return withTransaction(ctx, locks, progress, { operation: 'remove', rowId: id }, async (tx) => {
326
+ const row = tx.row(id);
327
+ const orphan = hasStateAccount(tx.state, id);
328
+ if (!row && !orphan)
329
+ throw unknownRow('remove', id);
330
+ if (rowLockKey(row ?? { id }) !== seenKey)
331
+ throw keyChanged('remove', id);
332
+ const protect = options.protect;
333
+ if (protect) {
334
+ const view = {
335
+ row,
336
+ config: tx.snapshot.config,
337
+ state: tx.snapshot.state,
338
+ };
339
+ const reason = await runInsideHook('remove', () => protect(id, view));
340
+ if (reason !== undefined)
341
+ throw refusal('remove', id, 'row-protected', reason);
342
+ }
343
+ if (row) {
344
+ tx.dropRosterRows(id);
345
+ await tx.commitConfig();
346
+ }
347
+ if (orphan) {
348
+ tx.dropStateAccount(id);
349
+ await tx.commitState();
350
+ }
351
+ return { id, outcome: row ? 'removed' : 'completed' };
352
+ });
353
+ });
354
+ }
355
+ function hasStateAccount(state, id) {
356
+ const accounts = state.accounts;
357
+ return isRecord(accounts) && Object.hasOwn(accounts, id);
358
+ }
359
+ /** The id a roster row carries, or undefined for a row that names none. */
360
+ function rosterIdOf(raw) {
361
+ return isRecord(raw) && typeof raw.id === 'string' ? raw.id : undefined;
362
+ }
363
+ /**
364
+ * Why `ids` is not an order of this roster: it must name every distinct
365
+ * roster id exactly once and nothing else.
366
+ */
367
+ function orderProblem(roster, ids) {
368
+ if (!Array.isArray(ids))
369
+ return 'ids must be an array of roster ids';
370
+ const rosterIds = new Set();
371
+ for (const raw of roster) {
372
+ const id = rosterIdOf(raw);
373
+ if (id !== undefined)
374
+ rosterIds.add(id);
375
+ }
376
+ const given = new Set();
377
+ for (const id of ids) {
378
+ if (typeof id !== 'string')
379
+ return 'ids must be an array of roster ids';
380
+ if (given.has(id))
381
+ return `id ${id} appears more than once`;
382
+ if (!rosterIds.has(id))
383
+ return `id ${id} is not in the roster`;
384
+ given.add(id);
385
+ }
386
+ const missing = [...rosterIds].filter((id) => !given.has(id));
387
+ if (missing.length > 0)
388
+ return `the order leaves out roster id(s) ${missing.join(', ')}`;
389
+ return undefined;
390
+ }
391
+ /**
392
+ * The roster in the new order. Every roster row is kept as the same object,
393
+ * so its serialized bytes are unchanged. Rows that carry an id fill the
394
+ * positions such rows held before, in the order of `ids`; a second row with
395
+ * an already-seen id (invalid, but preserved) travels right after the first.
396
+ * A row that names no id cannot be ordered by id, so it keeps its position.
397
+ */
398
+ function reorderedRoster(roster, ids) {
399
+ const byId = new Map();
400
+ for (const raw of roster) {
401
+ const id = rosterIdOf(raw);
402
+ if (id === undefined)
403
+ continue;
404
+ const group = byId.get(id);
405
+ if (group)
406
+ group.push(raw);
407
+ else
408
+ byId.set(id, [raw]);
409
+ }
410
+ const sequence = ids.flatMap((id) => byId.get(id) ?? []);
411
+ let next = 0;
412
+ return roster.map((raw) => rosterIdOf(raw) === undefined ? raw : sequence[next++]);
413
+ }
414
+ /**
415
+ * Sets the roster order in one config write. `ids` must name every roster id
416
+ * exactly once; anything else refuses (`invalid-order`) before writing. The
417
+ * roster rows, the per-row entries and the state file are left as they are:
418
+ * only the order of the legacy `accounts` array changes, which older readers
419
+ * load as is. It takes the extra locks, then the store locks, and no row or
420
+ * provider-wide lock, since no row's credential, identity or quota changes.
421
+ * An order equal to the current one writes nothing.
422
+ */
423
+ export async function reorderRows(rt, ids, options = {}) {
424
+ assertNotInsideHook('reorder');
425
+ const { ctx } = rt;
426
+ const onFailure = options.onFailure;
427
+ return runOperation(ctx, 'reorder', undefined, onFailure && ((_rowId, error) => onFailure(error)), async (locks, progress) => {
428
+ for (const extra of options.extraLocks ?? [])
429
+ await locks.acquire(extra);
430
+ return withTransaction(ctx, locks, progress, { operation: 'reorder', rowId: undefined }, async (tx) => {
431
+ const roster = tx.roster();
432
+ const problem = orderProblem(roster, ids);
433
+ if (problem)
434
+ throw new PoolOperationError({
435
+ operation: 'reorder',
436
+ phase: 'before-first-write',
437
+ retryable: false,
438
+ kind: 'invalid-order',
439
+ message: problem,
440
+ });
441
+ const order = [...ids];
442
+ const next = reorderedRoster(roster, order);
443
+ if (next.every((raw, index) => raw === roster[index]))
444
+ return { ids: order, outcome: 'unchanged' };
445
+ tx.config.accounts = next;
446
+ await tx.commitConfig();
447
+ return { ids: order, outcome: 'reordered' };
448
+ });
449
+ });
238
450
  }
239
451
  export async function recordRowIdentity(rt, id, identity, options = {}) {
240
452
  assertNotInsideHook('recordIdentity');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.2.2",
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.",
3
+ "version": "0.2.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": {
7
7
  "type": "git",
@@ -51,6 +51,30 @@
51
51
  "./routing": {
52
52
  "types": "./dist/routing/index.d.ts",
53
53
  "import": "./dist/routing/index.js"
54
+ },
55
+ "./commands": {
56
+ "types": "./dist/commands/index.d.ts",
57
+ "import": "./dist/commands/index.js"
58
+ },
59
+ "./auth-menu": {
60
+ "types": "./dist/auth-menu/index.d.ts",
61
+ "import": "./dist/auth-menu/index.js"
62
+ },
63
+ "./opencode2": {
64
+ "types": "./dist/opencode2/index.d.ts",
65
+ "import": "./dist/opencode2/index.js"
66
+ },
67
+ "./claustrum": {
68
+ "types": "./dist/claustrum/index.d.ts",
69
+ "import": "./dist/claustrum/index.js"
70
+ },
71
+ "./cachekeep": {
72
+ "types": "./dist/cachekeep/index.d.ts",
73
+ "import": "./dist/cachekeep/index.js"
74
+ },
75
+ "./dump": {
76
+ "types": "./dist/dump/index.d.ts",
77
+ "import": "./dist/dump/index.js"
54
78
  }
55
79
  },
56
80
  "files": [
@@ -73,7 +97,9 @@
73
97
  "peerDependencies": {
74
98
  "@opentui/core": ">=0.5.12",
75
99
  "@opentui/solid": ">=0.5.12",
76
- "solid-js": "1.9.12"
100
+ "solid-js": "1.9.12",
101
+ "@cortexkit/claustrum-client": ">=0.5.0",
102
+ "@opencode/plugin": ">=2.0.21"
77
103
  },
78
104
  "peerDependenciesMeta": {
79
105
  "@opentui/core": {
@@ -84,10 +110,18 @@
84
110
  },
85
111
  "solid-js": {
86
112
  "optional": true
113
+ },
114
+ "@cortexkit/claustrum-client": {
115
+ "optional": true
116
+ },
117
+ "@opencode/plugin": {
118
+ "optional": true
87
119
  }
88
120
  },
89
121
  "devDependencies": {
90
122
  "@biomejs/biome": "2.5.14",
123
+ "@cortexkit/claustrum-client": "0.5.0",
124
+ "@opencode/plugin": "2.0.21",
91
125
  "@opentui/core": ">=0.5.12",
92
126
  "@opentui/solid": ">=0.5.12",
93
127
  "@tsconfig/bun": "1.0.11",