@cortexkit/common-auth 0.3.0 → 0.4.1

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.
Files changed (60) hide show
  1. package/dist/cachekeep/manager.d.ts +18 -6
  2. package/dist/cachekeep/manager.js +40 -10
  3. package/dist/claustrum/consumer.d.ts +13 -4
  4. package/dist/claustrum/consumer.js +11 -3
  5. package/dist/claustrum/custody.d.ts +47 -6
  6. package/dist/claustrum/custody.js +37 -7
  7. package/dist/claustrum/errors.d.ts +1 -1
  8. package/dist/claustrum/index.d.ts +3 -3
  9. package/dist/claustrum/index.js +2 -2
  10. package/dist/claustrum/interlock.d.ts +15 -17
  11. package/dist/claustrum/interlock.js +19 -26
  12. package/dist/claustrum/roster.d.ts +96 -6
  13. package/dist/claustrum/roster.js +237 -45
  14. package/dist/commands/builtins.d.ts +1 -1
  15. package/dist/commands/builtins.js +6 -1
  16. package/dist/commands/index.d.ts +2 -2
  17. package/dist/commands/index.js +1 -1
  18. package/dist/commands/menu.d.ts +8 -0
  19. package/dist/commands/menu.js +34 -13
  20. package/dist/commands/model.d.ts +9 -0
  21. package/dist/commands/seam.d.ts +40 -4
  22. package/dist/commands/seam.js +132 -19
  23. package/dist/dump/index.d.ts +94 -0
  24. package/dist/dump/index.js +236 -9
  25. package/dist/logger/engine.d.ts +52 -17
  26. package/dist/logger/engine.js +178 -135
  27. package/dist/logger/index.d.ts +2 -2
  28. package/dist/logger/index.js +1 -1
  29. package/dist/opencode2/install.d.ts +8 -3
  30. package/dist/opencode2/install.js +18 -9
  31. package/dist/opencode2/types.d.ts +22 -3
  32. package/dist/quota/projection.d.ts +11 -4
  33. package/dist/quota/projection.js +11 -4
  34. package/dist/routing/admission.js +3 -1
  35. package/dist/routing/index.d.ts +2 -2
  36. package/dist/routing/index.js +1 -1
  37. package/dist/routing/sticky.d.ts +19 -6
  38. package/dist/routing/sticky.js +34 -23
  39. package/dist/rpc/notifications.d.ts +20 -0
  40. package/dist/rpc/notifications.js +21 -0
  41. package/dist/rpc/rpc-server.d.ts +9 -1
  42. package/dist/rpc/rpc-server.js +8 -1
  43. package/dist/sidebar-file/index.d.ts +1 -1
  44. package/dist/sidebar-file/sidebar-file.d.ts +50 -2
  45. package/dist/sidebar-file/sidebar-file.js +92 -21
  46. package/dist/store/attribution.js +14 -2
  47. package/dist/store/errors.d.ts +6 -3
  48. package/dist/store/identity.d.ts +13 -4
  49. package/dist/store/index.d.ts +1 -1
  50. package/dist/store/mutate.d.ts +27 -4
  51. package/dist/store/mutate.js +43 -26
  52. package/dist/store/pool.d.ts +16 -3
  53. package/dist/store/pool.js +7 -2
  54. package/dist/store/rows.d.ts +20 -6
  55. package/dist/store/rows.js +141 -46
  56. package/dist/store/schema.d.ts +74 -5
  57. package/dist/store/schema.js +112 -10
  58. package/dist/store/torn.d.ts +29 -0
  59. package/dist/store/torn.js +113 -0
  60. package/package.json +1 -1
@@ -7,12 +7,6 @@ import { isPinValid } from './pins.js';
7
7
  export const QUOTA_STALENESS_MS = 15 * 60_000;
8
8
  export const MIN_RESET_HOURS = 1 / 60;
9
9
  export const MIN_WEIGHT = 1e-6;
10
- /**
11
- * How many window readings the selection primitives judge, as openai-auth's
12
- * primary and secondary slots did. The projection's first readings in its
13
- * order fill the slots; any further window is judged by admission only.
14
- */
15
- export const STICKY_WINDOW_SLOTS = 2;
16
10
  /** The projection's time, else the caller's cache-entry time. */
17
11
  export function snapshotCheckedAt(quota, entryCheckedAt) {
18
12
  for (const checkedAt of [quota?.checkedAt, entryCheckedAt]) {
@@ -22,10 +16,33 @@ export function snapshotCheckedAt(quota, entryCheckedAt) {
22
16
  }
23
17
  return undefined;
24
18
  }
25
- function slotReadings(quota) {
26
- // Longest known window first, unknown lengths last, as openai-auth sorts.
27
- // Tombstones and absence records carry no capacity figure, so they occupy
28
- // no slot.
19
+ /**
20
+ * The status rule used when the adapter supplies none, carried from
21
+ * openai-auth: 401 and 403 are permanent; no response, 0, a non-finite
22
+ * status, 429 and 5xx are transient; anything else leaves the row healthy.
23
+ * A provider whose 403 can mean an organisation or model policy rather than
24
+ * a dead account supplies its own classifier and can fall back to this one
25
+ * for the statuses it does not single out.
26
+ */
27
+ export function defaultStickyStatusClass(status) {
28
+ if (status === 401 || status === 403)
29
+ return 'permanent';
30
+ if (status === undefined ||
31
+ status === 0 ||
32
+ !Number.isFinite(status) ||
33
+ (status >= 500 && status <= 599) ||
34
+ status === 429) {
35
+ return 'transient';
36
+ }
37
+ return 'healthy';
38
+ }
39
+ function windowReadings(quota) {
40
+ // Every reading is an independent constraint, so all of them are judged:
41
+ // a third window (for example a short window beside two weekly ones) can
42
+ // be the one that is nearly spent. Longest known window first, unknown
43
+ // lengths last, as openai-auth sorts, so a break decision with several
44
+ // spent windows names the longest. Tombstones and absence records carry no
45
+ // capacity figure and are left out.
29
46
  return quota.limits
30
47
  .filter((limit) => limit.kind === 'reading')
31
48
  .sort((left, right) => {
@@ -37,12 +54,12 @@ function slotReadings(quota) {
37
54
  return (right.windowMinutes ?? 0) - (left.windowMinutes ?? 0);
38
55
  }
39
56
  return 0;
40
- })
41
- .slice(0, STICKY_WINDOW_SLOTS);
57
+ });
42
58
  }
43
59
  /** Classifies whether a pinned session should leave its row after a failure. */
44
60
  export function decideStickyBreak(input) {
45
- if (input.status === 401 || input.status === 403) {
61
+ const statusClass = (input.classifyStatus ?? defaultStickyStatusClass)(input.status);
62
+ if (statusClass === 'permanent') {
46
63
  return { action: 'migrate', reason: 'permanent' };
47
64
  }
48
65
  if (!input.quota)
@@ -58,7 +75,7 @@ export function decideStickyBreak(input) {
58
75
  if (input.killswitchPasses === false) {
59
76
  return { action: 'migrate', reason: 'killswitch' };
60
77
  }
61
- for (const limit of slotReadings(input.quota)) {
78
+ for (const limit of windowReadings(input.quota)) {
62
79
  const remaining = limit.remainingPercent;
63
80
  if (typeof remaining === 'number' &&
64
81
  Number.isFinite(remaining) &&
@@ -83,14 +100,7 @@ export function decideStickyBreak(input) {
83
100
  resetsAt: budgetReset.resetsAt,
84
101
  };
85
102
  }
86
- if (input.status === undefined ||
87
- input.status === 0 ||
88
- !Number.isFinite(input.status) ||
89
- (input.status >= 500 && input.status <= 599) ||
90
- input.status === 429) {
91
- return { action: 'retain', reason: 'transient' };
92
- }
93
- return { action: 'retain', reason: 'healthy' };
103
+ return { action: 'retain', reason: statusClass };
94
104
  }
95
105
  export function sustainableWindowWeight(window, reservePercent, now) {
96
106
  const spendable = Math.max(0, window.remainingPercent - reservePercent);
@@ -124,7 +134,8 @@ function candidateWeight(candidate, now) {
124
134
  }
125
135
  // Missing reserve data must leave a window usable rather than silently
126
136
  // excluding its account.
127
- const weights = slotReadings(candidate.quota).map((limit) => sustainableWindowWeight({
137
+ // The account is as constrained as its tightest window, whichever it is.
138
+ const weights = windowReadings(candidate.quota).map((limit) => sustainableWindowWeight({
128
139
  remainingPercent: limit.remainingPercent ?? Number.NaN,
129
140
  ...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
130
141
  }, candidate.reservePercent[limit.label] ?? 0, now));
@@ -22,7 +22,27 @@ export interface NotificationScope {
22
22
  rpcRoot: string;
23
23
  directoryPrefix: string;
24
24
  registrationSessionId: string;
25
+ /**
26
+ * Strict session isolation. When true, `pushNotification` and
27
+ * `drainNotifications` refuse an absent or empty session id (throwing
28
+ * `RpcSessionRequiredError`) and a drain returns only that session's own
29
+ * notifications: no notification reaches every session. A strict scope is
30
+ * a separate queue from the same scope without the flag, so a lenient
31
+ * caller cannot push into it or drain it.
32
+ *
33
+ * Off by default: without it, a drain with no session returns every
34
+ * session's notifications and a push with no session reaches every
35
+ * session, which plugins that drain from one process-wide TUI rely on.
36
+ */
37
+ requireSession?: boolean;
25
38
  }
39
+ /** A strict notification scope was used without a session id. */
40
+ export declare class RpcSessionRequiredError extends Error {
41
+ readonly operation: 'push' | 'drain';
42
+ constructor(operation: 'push' | 'drain');
43
+ }
44
+ /** True for a session id a strict scope accepts: a non-empty string. */
45
+ export declare function isSessionId(value: unknown): value is string;
26
46
  export declare function pushNotification(scope: NotificationScope, payload: OpenDialogPayload, sessionId?: string): void;
27
47
  export declare function drainNotifications(scope: NotificationScope, lastReceivedId?: number, sessionId?: string): RpcNotification[];
28
48
  export declare function isTuiConnected(scope: NotificationScope, sessionId: string): boolean;
@@ -1,9 +1,23 @@
1
+ /** A strict notification scope was used without a session id. */
2
+ export class RpcSessionRequiredError extends Error {
3
+ operation;
4
+ constructor(operation) {
5
+ super(`a ${operation} on a strict notification scope needs a session id`);
6
+ this.name = 'RpcSessionRequiredError';
7
+ this.operation = operation;
8
+ }
9
+ }
10
+ /** True for a session id a strict scope accepts: a non-empty string. */
11
+ export function isSessionId(value) {
12
+ return typeof value === 'string' && value.length > 0;
13
+ }
1
14
  const queues = new Map();
2
15
  function state(scope) {
3
16
  const key = JSON.stringify([
4
17
  scope.rpcRoot,
5
18
  scope.directoryPrefix,
6
19
  scope.registrationSessionId,
20
+ ...(scope.requireSession === true ? ['strict'] : []),
7
21
  ]);
8
22
  let value = queues.get(key);
9
23
  if (!value) {
@@ -15,6 +29,8 @@ function state(scope) {
15
29
  const QUEUE_CAP = 100;
16
30
  const TUI_CONNECTED_WINDOW_MS = 3_000;
17
31
  export function pushNotification(scope, payload, sessionId) {
32
+ if (scope.requireSession === true && !isSessionId(sessionId))
33
+ throw new RpcSessionRequiredError('push');
18
34
  const value = state(scope);
19
35
  value.queue.push({
20
36
  id: value.nextId++,
@@ -26,6 +42,11 @@ export function pushNotification(scope, payload, sessionId) {
26
42
  value.queue = value.queue.slice(-QUEUE_CAP);
27
43
  }
28
44
  export function drainNotifications(scope, lastReceivedId = 0, sessionId) {
45
+ // A strict queue holds only session-scoped notifications (its push refuses
46
+ // the rest), so the ordinary match below already gives a strict drain
47
+ // nothing but its own session's notifications.
48
+ if (scope.requireSession === true && !isSessionId(sessionId))
49
+ throw new RpcSessionRequiredError('drain');
29
50
  const value = state(scope);
30
51
  const now = Date.now();
31
52
  if (sessionId !== undefined)
@@ -1,5 +1,5 @@
1
1
  import type { RpcLogChannel } from './index.js';
2
- import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
2
+ import { type ApplyRequest, type ApplyResult, type RpcNotification } from './notifications.js';
3
3
  export interface RpcServerHandle {
4
4
  port: number;
5
5
  token: string;
@@ -13,6 +13,14 @@ export interface RpcServerOptions {
13
13
  sweepRoot?: string;
14
14
  drain: (lastReceivedId: number, sessionId?: string) => RpcNotification[];
15
15
  apply: (request: ApplyRequest) => Promise<ApplyResult>;
16
+ /**
17
+ * Refuse a `pending-notifications` drain whose `sessionId` is absent, not a
18
+ * string or empty, with 400 and without calling `drain`. Pair it with a
19
+ * strict notification scope (`requireSession`) so neither the wire nor
20
+ * the queue can hand one session's notifications to another. Off by
21
+ * default, when a session-less drain is passed to `drain` as undefined.
22
+ */
23
+ requireSession?: boolean;
16
24
  timeoutMs?: number;
17
25
  receiptTimeoutMs?: number;
18
26
  }
@@ -2,6 +2,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { readFile, unlink } from 'node:fs/promises';
3
3
  import { createServer, } from 'node:http';
4
4
  import { join } from 'node:path';
5
+ import { isSessionId, } from './notifications.js';
5
6
  import { sweepRpcState, writePortFile } from './port-file.js';
6
7
  function readBody(req) {
7
8
  return new Promise((resolve, reject) => {
@@ -64,6 +65,8 @@ export async function startRpcServer(options) {
64
65
  const body = await readBody(req);
65
66
  const params = JSON.parse(body || '{}');
66
67
  if (method === 'pending-notifications') {
68
+ if (options.requireSession === true && !isSessionId(params.sessionId))
69
+ return json(400, { error: 'session required' });
67
70
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
68
71
  if (sessionId === undefined && !warnedMissingNotificationSession) {
69
72
  warnedMissingNotificationSession = true;
@@ -81,9 +84,13 @@ export async function startRpcServer(options) {
81
84
  return json(404, { error: 'unknown method' });
82
85
  }
83
86
  catch (error) {
84
- json(500, {
87
+ // A handler's exception can quote a request or a credential, so its
88
+ // text goes to the plugin's log channel only; the wire gets a fixed code.
89
+ log.warn('rpc request failed', {
90
+ pid: process.pid,
85
91
  error: error instanceof Error ? error.message : String(error),
86
92
  });
93
+ json(500, { error: 'internal error' });
87
94
  }
88
95
  }
89
96
  const port = await new Promise((resolve, reject) => {
@@ -1,2 +1,2 @@
1
- export type { SidebarFile, SidebarFileHooks, SidebarFileOptions, } from './sidebar-file.js';
1
+ export type { SidebarFile, SidebarFileHooks, SidebarFileOptions, SidebarRepair, SidebarWriteOptions, SidebarWriteResult, } from './sidebar-file.js';
2
2
  export { createSidebarFile } from './sidebar-file.js';
@@ -3,7 +3,50 @@ export interface SidebarFileHooks {
3
3
  beforeRecheck?: () => void | Promise<void>;
4
4
  /** @internal Test seam after staging, before the ownership fence. */
5
5
  beforeCommit?: () => Promise<void>;
6
+ /** @internal Test seam after the rename, before ownership is checked again. */
7
+ afterRename?: () => Promise<void>;
6
8
  }
9
+ /**
10
+ * Rebuild a write after the lock was lost while it was being renamed into
11
+ * place. `current` is the file as it is now, under a freshly taken lock: a
12
+ * successor may have written it after taking over the lease. `written` is the
13
+ * value this write committed. Return the value to write instead, typically
14
+ * the successor's state with only the fields this write is the authority on
15
+ * carried over, or undefined to leave the file as it is.
16
+ */
17
+ export type SidebarRepair<T> = (current: T, written: T) => T | undefined;
18
+ export interface SidebarWriteOptions<T> extends SidebarFileHooks {
19
+ /** Repair for this write; overrides the file's `repair` option. */
20
+ repair?: SidebarRepair<T>;
21
+ /**
22
+ * Told how this write ended, before its promise resolves. A callback
23
+ * rather than a resolved value, so `write` and `update` keep resolving to
24
+ * nothing for callers that pass them on as `Promise<void>`.
25
+ */
26
+ onResult?: (result: SidebarWriteResult) => void;
27
+ }
28
+ /**
29
+ * How a write ended.
30
+ *
31
+ * - `skipped`: the merge returned undefined, so nothing was written.
32
+ * - `written`: the value was renamed into place and the lock was still held
33
+ * afterwards, so no other writer can have taken over during the rename.
34
+ * - `lost-after-rename`: the value was renamed into place, but by then the
35
+ * lock had been taken over; the value may have replaced a successor's
36
+ * state. `repair` says what was done about it: `none` (no repair was
37
+ * supplied), `written` (the repaired value was committed and the lock held),
38
+ * `skipped` (the repair returned undefined), `lock-unavailable` (the lock
39
+ * could not be retaken in time) or `lost-again` (the repaired value was
40
+ * committed but the lock was lost again; no further repair is tried).
41
+ */
42
+ export type SidebarWriteResult = {
43
+ status: 'skipped';
44
+ } | {
45
+ status: 'written';
46
+ } | {
47
+ status: 'lost-after-rename';
48
+ repair: 'none' | 'written' | 'skipped' | 'lock-unavailable' | 'lost-again';
49
+ };
7
50
  export interface SidebarFileOptions<T> {
8
51
  path: string;
9
52
  defaultValue: T;
@@ -20,11 +63,16 @@ export interface SidebarFileOptions<T> {
20
63
  warn: (message: string, payload?: unknown) => void;
21
64
  debug: (message: string, payload?: unknown) => void;
22
65
  };
66
+ /**
67
+ * Runs once, under a newly taken lock, when a write finds after its rename
68
+ * that the lock was lost. Without it such a write is only reported.
69
+ */
70
+ repair?: SidebarRepair<T>;
23
71
  }
24
72
  export interface SidebarFile<T> {
25
73
  read(): Promise<T>;
26
- write(value: T, hooks?: SidebarFileHooks): Promise<void>;
27
- update(merge: (latest: T) => T | undefined, hooks?: SidebarFileHooks): Promise<void>;
74
+ write(value: T, options?: SidebarWriteOptions<T>): Promise<void>;
75
+ update(merge: (latest: T) => T | undefined, options?: SidebarWriteOptions<T>): Promise<void>;
28
76
  }
29
77
  /** Binds generic persistence to a caller's path and normalization policy. */
30
78
  export declare function createSidebarFile<T>(options: SidebarFileOptions<T>): SidebarFile<T>;
@@ -1,6 +1,6 @@
1
1
  import { chmod, mkdir, readFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { WRITER_LOCK_CONSTANTS, withLock, writeJsonAtomic, } from '../fs/index.js';
3
+ import { LockContentionError, LockOwnershipError, WRITER_LOCK_CONSTANTS, withLock, writeJsonAtomic, } from '../fs/index.js';
4
4
  /** Binds generic persistence to a caller's path and normalization policy. */
5
5
  export function createSidebarFile(options) {
6
6
  const { path, defaultValue, normalize } = options;
@@ -33,13 +33,79 @@ export function createSidebarFile(options) {
33
33
  return defaultValue;
34
34
  }
35
35
  };
36
- const enqueue = (operation) => {
37
- const result = chain.then(operation);
36
+ const enqueue = (operation, writeOptions) => {
37
+ const result = chain
38
+ .then(operation)
39
+ .then((outcome) => writeOptions?.onResult?.(outcome));
38
40
  // Keep the next operation runnable without hiding this caller's rejection.
39
41
  chain = result.catch(() => { });
40
42
  return result;
41
43
  };
42
- const persist = async (merge, hooks) => {
44
+ const lockOptions = {
45
+ ...WRITER_LOCK_CONSTANTS.sidebar,
46
+ timeoutMs: options.timeoutMs ?? WRITER_LOCK_CONSTANTS.sidebar.timeoutMs,
47
+ };
48
+ /**
49
+ * Rename `value` into place under `lock`, then report whether the lock was
50
+ * still held. The fence before the rename stops a write whose lock is
51
+ * already gone; the check after it catches a lock lost while the rename
52
+ * itself was in flight, which no check before it can see.
53
+ */
54
+ const commitUnder = async (lock, value, hooks) => {
55
+ await writeJsonAtomic(path, value, {
56
+ serialize: JSON.stringify,
57
+ beforeRename: async () => {
58
+ await hooks?.beforeCommit?.();
59
+ await lock.assertOwned();
60
+ },
61
+ });
62
+ await hooks?.afterRename?.();
63
+ try {
64
+ await lock.assertOwned();
65
+ return true;
66
+ }
67
+ catch (error) {
68
+ if (error instanceof LockOwnershipError)
69
+ return false;
70
+ throw error;
71
+ }
72
+ };
73
+ /**
74
+ * One repair, under a new lock, of a write that lost its lock during the
75
+ * rename. Bounded to a single attempt: if the repair loses its lock too,
76
+ * the newer holder is writing and its state stands.
77
+ */
78
+ const repairLostWrite = async (repair, written, hooks) => {
79
+ options.logger?.warn('sidebar lock lost after rename; repairing write', {
80
+ path,
81
+ });
82
+ try {
83
+ const outcome = await withLock(path, lockOptions, async (lock) => {
84
+ const next = repair(await read(), written);
85
+ if (next === undefined)
86
+ return 'skipped';
87
+ return (await commitUnder(lock, next, hooks))
88
+ ? 'written'
89
+ : 'lost-again';
90
+ });
91
+ if (outcome === 'lost-again') {
92
+ options.logger?.warn('sidebar repair lost its lock after rename', {
93
+ path,
94
+ });
95
+ }
96
+ return { status: 'lost-after-rename', repair: outcome };
97
+ }
98
+ catch (error) {
99
+ if (!(error instanceof LockContentionError))
100
+ throw error;
101
+ options.logger?.warn('sidebar repair lock unavailable; repair skipped', {
102
+ path,
103
+ });
104
+ return { status: 'lost-after-rename', repair: 'lock-unavailable' };
105
+ }
106
+ };
107
+ const persist = async (merge, writeOptions) => {
108
+ const hooks = writeOptions;
43
109
  const parent = dirname(path);
44
110
  const secureDir = options.secureDir ?? true;
45
111
  await mkdir(parent, { recursive: true, mode: 0o700 });
@@ -48,38 +114,43 @@ export function createSidebarFile(options) {
48
114
  options.logger?.warn('sidebar directory permission remediation failed', { error: error instanceof Error ? error.message : String(error) });
49
115
  });
50
116
  }
51
- await withLock(path, {
52
- ...WRITER_LOCK_CONSTANTS.sidebar,
53
- timeoutMs: options.timeoutMs ?? WRITER_LOCK_CONSTANTS.sidebar.timeoutMs,
54
- }, async (lock) => {
55
- const commit = (value) => writeJsonAtomic(path, value, {
56
- serialize: JSON.stringify,
57
- beforeRename: async () => {
58
- await hooks?.beforeCommit?.();
59
- await lock.assertOwned();
60
- },
117
+ const committed = await withLock(path, lockOptions, async (lock) => {
118
+ const commit = async (value) => ({
119
+ value,
120
+ held: await commitUnder(lock, value, hooks),
61
121
  });
62
122
  // Older processes may ignore the lock; remerge if their bytes changed.
63
123
  for (let attempt = 0; attempt < 3; attempt += 1) {
64
124
  const raw = await readRaw();
65
125
  const next = merge(parse(raw));
66
126
  if (next === undefined)
67
- return;
127
+ return undefined;
68
128
  if (attempt === 0)
69
129
  await hooks?.beforeRecheck?.();
70
130
  if ((await readRaw()) !== raw)
71
131
  continue;
72
- await commit(next);
73
- return;
132
+ return await commit(next);
74
133
  }
75
134
  const next = merge(await read());
76
- if (next !== undefined)
77
- await commit(next);
135
+ return next === undefined ? undefined : await commit(next);
78
136
  });
137
+ if (committed === undefined)
138
+ return { status: 'skipped' };
139
+ if (committed.held)
140
+ return { status: 'written' };
141
+ // The first lock is released by now; the repair takes its own.
142
+ const repair = writeOptions?.repair ?? options.repair;
143
+ if (!repair) {
144
+ options.logger?.warn('sidebar lock lost after rename; write not repaired', {
145
+ path,
146
+ });
147
+ return { status: 'lost-after-rename', repair: 'none' };
148
+ }
149
+ return await repairLostWrite(repair, committed.value, hooks);
79
150
  };
80
151
  return {
81
152
  read,
82
- write: (value, hooks) => enqueue(() => persist(() => value, hooks)),
83
- update: (merge, hooks) => enqueue(() => persist(merge, hooks)),
153
+ write: (value, writeOptions) => enqueue(() => persist(() => value, writeOptions), writeOptions),
154
+ update: (merge, writeOptions) => enqueue(() => persist(merge, writeOptions), writeOptions),
84
155
  };
85
156
  }
@@ -2,6 +2,7 @@ import { PoolOperationError } from './errors.js';
2
2
  import { toFailure, withTransaction } from './mutate.js';
3
3
  import { LockStack } from './refresh-lock.js';
4
4
  import { refusal, unknownRow } from './runtime.js';
5
+ import { isCredentialEpoch } from './schema.js';
5
6
  /**
6
7
  * Merges a quota observation into a row's stored map under the store locks,
7
8
  * after attribution passes. Needs no row lock, so it is permitted from
@@ -12,14 +13,22 @@ export async function recordQuota(rt, id, attribution, observation) {
12
13
  const locks = new LockStack(ctx.lockDefaults, ctx.lockEnv);
13
14
  const progress = { writes: 0 };
14
15
  try {
16
+ if (!isCredentialEpoch(attribution?.credentialEpoch))
17
+ throw refusal('pull', id, 'invalid-input', 'the credential epoch the reading was issued for must be a positive safe integer');
15
18
  await withTransaction(ctx, locks, progress, { operation: 'pull', rowId: id }, async (tx) => {
16
19
  const row = tx.row(id);
17
20
  if (!row)
18
21
  throw unknownRow('pull', id);
19
22
  if (row.invalid)
20
23
  throw refusal('pull', id, 'invalid-row', `row ${id} is invalid`);
24
+ // A reading belongs to one (epoch, identity, credential). A row
25
+ // holding no credential, or torn between the writes of a replace,
26
+ // has no such triple on disk, so no reading is recorded for it.
27
+ if (!row.credential)
28
+ throw refusal('pull', id, 'no-credential', `row ${id} holds no credential`);
21
29
  const entry = tx.entry(id);
22
- if (!entry ||
30
+ if (row.torn ||
31
+ !entry ||
23
32
  entry.credentialEpoch !== attribution.credentialEpoch ||
24
33
  row.identity !== attribution.identity)
25
34
  throw new PoolOperationError({
@@ -35,7 +44,10 @@ export async function recordQuota(rt, id, attribution, observation) {
35
44
  throw refusal('pull', id, 'invalid-quota', 'the quota codec rejected the merged map');
36
45
  tx.setEntry(id, { ...entry, quota: merged, needsFirstReading: false });
37
46
  await tx.commitConfig();
38
- });
47
+ },
48
+ // Recording a reading never completes a torn row: the fence above
49
+ // refuses it, and the row's own next write completes it.
50
+ { completeTorn: false });
39
51
  }
40
52
  catch (error) {
41
53
  throw toFailure(error, 'pull', id, progress);
@@ -7,8 +7,11 @@ export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disab
7
7
  * `before-first-write`: nothing was written; both files are as they were.
8
8
  * `after-first-write`: the operation's first file write landed and a later one
9
9
  * did not; what that first write left is on disk and is never rolled back
10
- * (add: a row with no credential; replace: the bumped epoch beside the prior
11
- * credential; rotate: the rotated credential beside the old per-row entry). `pull`: a quota pull, or the recording of its result, failed.
10
+ * (add: a state entry no roster row names, which no reader loads; replace:
11
+ * the new credential stamped ahead of the config, which readers show as a
12
+ * torn row and the next store write on it completes; rotate: the rotated
13
+ * credential beside the old per-row entry). `pull`: a quota pull, or the
14
+ * recording of its result, failed.
12
15
  */
13
16
  export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pull';
14
17
  /**
@@ -16,7 +19,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
16
19
  * lock outcomes (a wait that ran out, and a lease found lost); the rest are
17
20
  * refusals and failures of the operation itself.
18
21
  */
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';
22
+ 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' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
20
23
  /**
21
24
  * The single failure value of every store operation. `committed` is present
22
25
  * only when the operation had already written a credential to the state file
@@ -1,5 +1,14 @@
1
- import type { Transaction } from './mutate.js';
2
1
  import type { PoolRow } from './schema.js';
2
+ /**
3
+ * What the identity rules edit: a transaction, or a config being completed
4
+ * in memory.
5
+ */
6
+ export interface RowEditor {
7
+ rows(): PoolRow[];
8
+ rosterRow(id: string): Record<string, unknown> | undefined;
9
+ entry(id: string): Record<string, unknown> | undefined;
10
+ setEntry(id: string, entry: Record<string, unknown>): void;
11
+ }
3
12
  /** The reason recorded on a row disabled because an earlier row is the same account. */
4
13
  export declare const DUPLICATE_IDENTITY_REASON = "duplicate-identity";
5
14
  /**
@@ -12,12 +21,12 @@ export declare function countUnknownIdentityRows(rows: readonly PoolRow[]): numb
12
21
  * which older readers honour, and the reason in the per-row entry. A row
13
22
  * without an entry gets one at epoch 1. Nothing is ever deleted.
14
23
  */
15
- export declare function disableIn(tx: Transaction, id: string, reason: string): void;
24
+ export declare function disableIn(tx: RowEditor, id: string, reason: string): void;
16
25
  /**
17
26
  * Two enabled OAuth rows with one wire identity are the same account: the
18
27
  * earlier row in roster order stays enabled and every later one is disabled
19
28
  * with a reason. Returns the ids it disabled.
20
29
  */
21
- export declare function disableIdentityDuplicates(tx: Transaction, identity: string): string[];
30
+ export declare function disableIdentityDuplicates(tx: RowEditor, identity: string): string[];
22
31
  /** Records a row's wire identity in its roster row, then applies dedupe. */
23
- export declare function recordIdentityIn(tx: Transaction, id: string, identity: string): string[];
32
+ export declare function recordIdentityIn(tx: RowEditor, id: string, identity: string): string[];
@@ -12,7 +12,7 @@ export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.j
12
12
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
13
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
- export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, StoredCredential, } from './schema.js';
15
+ export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, 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';
17
17
  export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
18
18
  export { POOL_OWNED_KEYS } from './settings.js';
@@ -64,13 +64,21 @@ export declare class Transaction {
64
64
  readonly snapshot: Snapshot;
65
65
  private readonly locks;
66
66
  private readonly progress;
67
- private readonly info;
67
+ readonly info: {
68
+ operation: PoolOperation;
69
+ rowId: string | undefined;
70
+ };
68
71
  config: Record<string, unknown>;
69
72
  state: Record<string, unknown>;
70
73
  constructor(ctx: StoreContext, snapshot: Snapshot, locks: LockStack, progress: Progress, info: {
71
74
  operation: PoolOperation;
72
75
  rowId: string | undefined;
73
76
  });
77
+ /**
78
+ * The rows as every reader loads them: a row torn between the writes of a
79
+ * replace is shown as that replace leaves it once completed (see
80
+ * `PoolRow.torn`).
81
+ */
74
82
  rows(): PoolRow[];
75
83
  row(id: string): PoolRow | undefined;
76
84
  roster(): unknown[];
@@ -85,6 +93,14 @@ export declare class Transaction {
85
93
  entries(): Record<string, unknown>;
86
94
  entry(id: string): Record<string, unknown> | undefined;
87
95
  setEntry(id: string, entry: Record<string, unknown>): void;
96
+ /**
97
+ * Writes the config of every row torn between the writes of a replace as
98
+ * that replace would have left it (see `completeTornRows`), in one config
99
+ * write ahead of the operation's own. The write is counted apart from the
100
+ * operation's: it is setup, like a pull giving a row its entry, so a later
101
+ * refusal still reports `before-first-write`.
102
+ */
103
+ completeTorn(): Promise<void>;
88
104
  stateAccount(id: string): Record<string, unknown> | undefined;
89
105
  /** Drops the row's credential and runtime fields from the state file's accounts. */
90
106
  dropStateAccount(id: string): void;
@@ -95,7 +111,9 @@ export declare class Transaction {
95
111
  * key untouched. Entries for ids no longer in the roster are dropped here,
96
112
  * and remembered so the id is not reused in this process.
97
113
  */
98
- commitConfig(): Promise<void>;
114
+ commitConfig(options?: {
115
+ counted?: boolean;
116
+ }): Promise<void>;
99
117
  /** Writes the state: every unrecognised top-level and per-row key kept. */
100
118
  commitState(committed?: StoredCredential): Promise<void>;
101
119
  private write;
@@ -115,12 +133,17 @@ export type InitializeOutcome = 'initialized' | 'already-ready';
115
133
  export declare function initializePool(ctx: StoreContext, dropKeys: readonly string[]): Promise<InitializeOutcome>;
116
134
  /**
117
135
  * Runs `fn` under the store-lock list. The pool must be ready: a pending
118
- * migration or a load error refuses before anything is written.
136
+ * migration or a load error refuses before anything is written. Unless
137
+ * `completeTorn` is false, rows torn between the writes of a replace are
138
+ * completed first, so `fn` never sees one; the writes that only record
139
+ * readings or reorder the roster opt out and leave such rows as they are.
119
140
  */
120
141
  export declare function withTransaction<T>(ctx: StoreContext, locks: LockStack, progress: Progress, info: {
121
142
  operation: PoolOperation;
122
143
  rowId: string | undefined;
123
- }, fn: (tx: Transaction) => Promise<T>): Promise<T>;
144
+ }, fn: (tx: Transaction) => Promise<T>, options?: {
145
+ completeTorn?: boolean;
146
+ }): Promise<T>;
124
147
  /** Maps anything thrown inside an operation onto the one failure value. */
125
148
  export declare function toFailure(error: unknown, operation: PoolOperation, rowId: string | undefined, progress: Progress): PoolOperationError;
126
149
  /**