@cortexkit/common-auth 0.11.3 → 0.11.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.
package/README.md CHANGED
@@ -11,14 +11,14 @@ Shared libraries for the CortexKit auth plugins for OpenCode and Pi: openai-auth
11
11
 
12
12
  A passing safety test proves little until it has been seen to fail. [`mutations.toml`](mutations.toml) is the checked-in catalogue: each row deliberately breaks one guard in `src/` (or a CI scan script) and names the Bun test that must fail because of it, so a guard cannot silently stop guarding. A row earns its place by guarding a silent, costly failure: lock exclusion, crash safety, credential loss or leakage, a single-use token used twice, misattributed quota, a wire contract, data loss. Style and cosmetics do not.
13
13
 
14
- The pinned [`ck-mutate`](https://github.com/cortexkit/commons/tree/7d08e73722fa3e79bbcc2607753978ab768f1c6b/crates/cortexkit-mutate) runner replays it from a clean tree. It installs and builds first (fixtures import `dist/`), checks each named test passes, applies the break, rebuilds, requires the test to fail, and restores the source byte for byte:
14
+ The pinned [`ckdev-mutate`](https://github.com/cortexkit/commons/tree/46cc166b0df2edcfd14b3eb54ed6eeac588fed69/crates/cortexkit-mutate) runner replays it from a clean tree. It installs and builds first (fixtures import `dist/`), checks each named test passes, applies the break, rebuilds, requires the test to fail, and restores the source byte for byte:
15
15
 
16
16
  ```sh
17
- cargo install --locked --git https://github.com/cortexkit/commons --rev 7d08e73722fa3e79bbcc2607753978ab768f1c6b cortexkit-mutate
18
- ck-mutate check
19
- ck-mutate run --all
20
- ck-mutate run --diff origin/main
21
- ck-mutate run --only fs-lock-try-once-refuses-held-lock
17
+ cargo install --locked --git https://github.com/cortexkit/commons --rev 46cc166b0df2edcfd14b3eb54ed6eeac588fed69 cortexkit-mutate
18
+ ckdev-mutate check
19
+ ckdev-mutate run --all
20
+ ckdev-mutate run --diff origin/main
21
+ ckdev-mutate run --only fs-lock-try-once-refuses-held-lock
22
22
  ```
23
23
 
24
24
  Every row must report CAUGHT. Replay with the Bun version CI pins (1.3.14): the row `rpc-declared-oversize-413-half-closes` guards the RPC server's workaround for Bun 1.3.14 reusing a connection the server refused with 413. Bun 1.4.2 never reuses it, so that row survives there. Rows are command rows: `{test}` is a Bun `-t` filter, a regular expression over the full test name (describe names and test name joined by spaces), so ids are anchored, escaped, and spell each space `\s`. CI replays the rows a pull request's diff selects, the full catalogue on every push to main, and the full catalogue with `--broad` nightly. Never `git checkout` a file while a replay runs: it removes the mutation and fakes a survivor.
@@ -28,4 +28,6 @@ export interface DiscoverPortFileOptions {
28
28
  */
29
29
  exactPid?: boolean;
30
30
  }
31
+ /** Internal cache validation: probe only the selected file and its PID. */
32
+ export declare function portFileIdentity(dir: string, entry: PortFileEntry): Promise<string | null>;
31
33
  export declare function discoverPortFile(dir: string, expectedPid?: number, options?: DiscoverPortFileOptions): Promise<PortFileEntry | null>;
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { chmod, mkdir, open, readdir, readFile, rename, rmdir, unlink, } from 'node:fs/promises';
2
+ import { chmod, mkdir, open, readdir, readFile, rename, rmdir, stat, unlink, } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
4
  /**
5
5
  * A PID a liveness probe may address. `process.kill(0 | negative, 0)` signals
@@ -166,6 +166,19 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
166
166
  await rmdir(dir).catch(() => { });
167
167
  }
168
168
  }
169
+ /** Internal cache validation: probe only the selected file and its PID. */
170
+ export async function portFileIdentity(dir, entry) {
171
+ if (!pidAlive(entry.pid))
172
+ return null;
173
+ const path = join(resolve(dir), `port-${entry.pid}.json`);
174
+ try {
175
+ const info = await stat(path);
176
+ return `${path}:${info.dev}:${info.ino}:${info.mtimeMs}:${info.size}`;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
169
182
  export async function discoverPortFile(dir, expectedPid, options = {}) {
170
183
  let names;
171
184
  try {
@@ -1,5 +1,5 @@
1
1
  import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
2
- import { type DiscoverPortFileOptions, type PortFileEntry } from './port-file.js';
2
+ import { type DiscoverPortFileOptions, discoverPortFile, type PortFileEntry } from './port-file.js';
3
3
  export interface RpcClient {
4
4
  pending: (lastReceivedId: number, sessionId?: string, timeoutMs?: number) => Promise<RpcNotification[]>;
5
5
  apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>;
@@ -11,7 +11,10 @@ export declare const DEFAULT_RPC_TIMEOUT_MS = 2000;
11
11
  * opening a socket, on every call. Off by default, when a call falls back to
12
12
  * the newest live server.
13
13
  */
14
- export type RpcClientOptions = DiscoverPortFileOptions;
14
+ export type RpcClientOptions = DiscoverPortFileOptions & {
15
+ /** Internal discovery-count seam. */
16
+ discover?: typeof discoverPortFile;
17
+ };
15
18
  /**
16
19
  * A client for the server in `dir`, preferring `expectedPid`'s.
17
20
  *
@@ -20,6 +23,8 @@ export type RpcClientOptions = DiscoverPortFileOptions;
20
23
  * a gate: an observer that throws rejects that call before any request is
21
24
  * sent and is asked again on the next call, but nothing stops a later call
22
25
  * once an observer has returned. A caller that must never reach another
23
- * server passes `{ exactPid: true }`.
26
+ * server passes `{ exactPid: true }`. Validated selections are shared by
27
+ * directory, expected PID and exactPid until their file identity changes or a
28
+ * connect/auth/stale-server failure triggers one rediscovery in the same call.
24
29
  */
25
30
  export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void, options?: RpcClientOptions): RpcClient;
@@ -1,30 +1,87 @@
1
+ import { open } from 'node:fs/promises';
1
2
  import { connect } from 'node:net';
2
- import { discoverPortFile, } from './port-file.js';
3
+ import { join, resolve } from 'node:path';
4
+ import { discoverPortFile, portFileIdentity, } from './port-file.js';
3
5
  export const DEFAULT_RPC_TIMEOUT_MS = 2_000;
4
- async function call(dir, expectedPid, discoverOptions, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
5
- const entry = await discoverPortFile(dir, expectedPid, discoverOptions);
6
- onSelected?.(entry);
7
- if (!entry)
6
+ const selections = new Map();
7
+ const discoveries = new Map();
8
+ // The identity a cached selection is checked against must belong to the bytes
9
+ // that named this server. Statting the path after discovery is not enough: the
10
+ // file can be replaced in between, which would pin the old server to the new
11
+ // file's identity. So read and fstat one open descriptor, and bind only when
12
+ // those bytes still name the discovered entry.
13
+ async function boundIdentity(dir, entry) {
14
+ const path = join(resolve(dir), `port-${entry.pid}.json`);
15
+ let handle;
16
+ try {
17
+ handle = await open(path, 'r');
18
+ const info = await handle.stat();
19
+ const current = JSON.parse(await handle.readFile('utf8'));
20
+ if (current.pid !== entry.pid ||
21
+ current.port !== entry.port ||
22
+ current.token !== entry.token)
23
+ return null;
24
+ return `${path}:${info.dev}:${info.ino}:${info.mtimeMs}:${info.size}`;
25
+ }
26
+ catch {
8
27
  return null;
28
+ }
29
+ finally {
30
+ await handle?.close().catch(() => { });
31
+ }
32
+ }
33
+ async function select(key, dir, expectedPid, options) {
34
+ const cached = selections.get(key);
35
+ if (cached && (await portFileIdentity(dir, cached.entry)) === cached.identity)
36
+ return cached;
37
+ selections.delete(key);
38
+ const pending = discoveries.get(key);
39
+ if (pending)
40
+ return pending;
41
+ const discovery = (async () => {
42
+ const entry = await (options.discover ?? discoverPortFile)(dir, expectedPid, options);
43
+ if (!entry)
44
+ return null;
45
+ const identity = await boundIdentity(dir, entry);
46
+ // The file changed under discovery: use what was discovered for this
47
+ // call, as an uncached selection, so the next call discovers again.
48
+ if (!identity)
49
+ return { entry, identity: '' };
50
+ const selected = { entry, identity };
51
+ selections.set(key, selected);
52
+ return selected;
53
+ })();
54
+ discoveries.set(key, discovery);
55
+ try {
56
+ return await discovery;
57
+ }
58
+ finally {
59
+ if (discoveries.get(key) === discovery)
60
+ discoveries.delete(key);
61
+ }
62
+ }
63
+ async function request(entry, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
9
64
  // A raw loopback socket never consults runtime HTTP proxy settings, which
10
65
  // otherwise can expose the bearer token to a configured proxy. timeoutMs is
11
66
  // a total deadline for connect, request and the full response, not idle time.
12
67
  return new Promise((resolve) => {
13
68
  let socket;
14
69
  let settled = false;
15
- const done = (value) => {
70
+ let connected = false;
71
+ const done = (value, stale = false) => {
16
72
  if (settled)
17
73
  return;
18
74
  settled = true;
19
75
  clearTimeout(timer);
20
76
  socket?.destroy();
21
- resolve(value);
77
+ resolve({ value, stale });
22
78
  };
23
79
  const timer = setTimeout(() => done(null), timeoutMs);
24
80
  try {
25
81
  const body = JSON.stringify(params);
26
82
  socket = connect({ host: '127.0.0.1', port: entry.port });
27
83
  socket.on('connect', () => {
84
+ connected = true;
28
85
  // HTTP/1.0 avoids chunked responses; explicitly request connection close
29
86
  // because older Bun servers can keep delayed HTTP/1.0 replies open.
30
87
  socket?.write(`POST /rpc/${method} HTTP/1.0\r\n` +
@@ -69,8 +126,15 @@ async function call(dir, expectedPid, discoverOptions, onSelected, method, param
69
126
  .toString('latin1')
70
127
  .split('\r\n');
71
128
  const status = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(headers[0] ?? '');
72
- if (!status || Number(status[1]) < 200 || Number(status[1]) >= 300)
129
+ if (!status)
73
130
  return done(null);
131
+ const code = Number(status[1]);
132
+ if (code < 200 || code >= 300)
133
+ // Only statuses meaning "this is not the server you meant" may
134
+ // rediscover and resend. A 504 is this server's apply deadline:
135
+ // the handler can still be running, so resending it to another
136
+ // server could run the command twice. 5xx is never retried.
137
+ return done(null, [401, 403, 404, 410].includes(code));
74
138
  for (const header of headers.slice(1)) {
75
139
  const colon = header.indexOf(':');
76
140
  const name = header.slice(0, colon).toLowerCase();
@@ -107,14 +171,47 @@ async function call(dir, expectedPid, discoverOptions, onSelected, method, param
107
171
  return done(null);
108
172
  finishBody();
109
173
  });
110
- socket.on('error', () => done(null));
174
+ socket.on('error', () => done(null, !connected));
111
175
  socket.on('close', () => done(null));
112
176
  }
113
177
  catch {
114
- done(null);
178
+ done(null, !connected);
115
179
  }
116
180
  });
117
181
  }
182
+ async function call(dir, expectedPid, options, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
183
+ const key = JSON.stringify([
184
+ resolve(dir),
185
+ expectedPid ?? null,
186
+ options.exactPid === true,
187
+ ]);
188
+ const selected = await select(key, dir, expectedPid, options);
189
+ onSelected(selected?.entry ?? null);
190
+ if (!selected)
191
+ return null;
192
+ const deadline = Date.now() + timeoutMs;
193
+ const out = await request(selected.entry, method, params, timeoutMs);
194
+ if (!out.stale)
195
+ return out.value;
196
+ if (selections.get(key) === selected)
197
+ selections.delete(key);
198
+ // Retry only definite pre-dispatch/stale-server failures, never an ambiguous
199
+ // response or timeout that could replay an already executed apply command.
200
+ const replacement = await select(key, dir, expectedPid, options);
201
+ if (!replacement || Date.now() >= deadline)
202
+ return null;
203
+ if (replacement.entry.port === selected.entry.port &&
204
+ replacement.entry.pid === selected.entry.pid &&
205
+ replacement.entry.token === selected.entry.token) {
206
+ if (selections.get(key) === replacement)
207
+ selections.delete(key);
208
+ return null;
209
+ }
210
+ const retry = await request(replacement.entry, method, params, deadline - Date.now());
211
+ if (retry.stale && selections.get(key) === replacement)
212
+ selections.delete(key);
213
+ return retry.value;
214
+ }
118
215
  /**
119
216
  * A client for the server in `dir`, preferring `expectedPid`'s.
120
217
  *
@@ -123,11 +220,14 @@ async function call(dir, expectedPid, discoverOptions, onSelected, method, param
123
220
  * a gate: an observer that throws rejects that call before any request is
124
221
  * sent and is asked again on the next call, but nothing stops a later call
125
222
  * once an observer has returned. A caller that must never reach another
126
- * server passes `{ exactPid: true }`.
223
+ * server passes `{ exactPid: true }`. Validated selections are shared by
224
+ * directory, expected PID and exactPid until their file identity changes or a
225
+ * connect/auth/stale-server failure triggers one rediscovery in the same call.
127
226
  */
128
227
  export function createRpcClient(dir, expectedPid, onSelected, options = {}) {
129
228
  const discoverOptions = {
130
229
  exactPid: options.exactPid,
230
+ discover: options.discover,
131
231
  };
132
232
  let reportedSelection = false;
133
233
  const reportSelected = (entry) => {
@@ -1,6 +1,22 @@
1
- import { watch } from 'node:fs';
1
+ import { type Stats, watch } from 'node:fs';
2
+ type Metadata = Pick<Stats, 'mtimeMs' | 'size' | 'ino' | 'dev'>;
2
3
  export interface TuiPreferencesWatchOptions {
3
4
  watchDirectory?: typeof watch;
5
+ /** Internal filesystem seam; the first subscriber supplies a shared watcher's I/O. */
6
+ fs?: {
7
+ readFileSync?: (file: string) => string;
8
+ readFile?: (file: string) => Promise<string>;
9
+ statSync?: (file: string) => Metadata;
10
+ stat?: (file: string) => Promise<Metadata>;
11
+ };
4
12
  }
5
- /** Watch the directory so atomic file replacement does not detach the watcher. */
13
+ /**
14
+ * Share one directory watcher and metadata poll per canonical path, including
15
+ * across bundled copies. Native events use a 150 ms debounce; missed events
16
+ * recover through a 1,000 ms stat probe (formerly a 100 ms full-file poll).
17
+ * Watching the directory preserves atomic replacement and missing-file recovery.
18
+ * Only changed raw text notifies; each subscriber is isolated and independently
19
+ * disposable. The last unsubscribe stops both the native watcher and the poll.
20
+ */
6
21
  export declare function watchTuiPreferences(file: string, onChange: () => void, options?: TuiPreferencesWatchOptions): () => void;
22
+ export {};
@@ -1,38 +1,111 @@
1
- import { readFileSync, watch } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
3
- import { basename, dirname } from 'node:path';
1
+ import { readFileSync, realpathSync, statSync, watch, } from 'node:fs';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { clearTimeout, setTimeout } from 'node:timers';
5
- /** Watch the directory so atomic file replacement does not detach the watcher. */
5
+ function canonicalPath(file) {
6
+ const path = resolve(file);
7
+ try {
8
+ return realpathSync(path);
9
+ }
10
+ catch {
11
+ // Resolve existing ancestors even when the preferences file is not yet present.
12
+ const parent = dirname(path);
13
+ return parent === path ? path : join(canonicalPath(parent), basename(path));
14
+ }
15
+ }
16
+ function identity(info) {
17
+ return info && `${info.dev}:${info.ino}:${info.mtimeMs}:${info.size}`;
18
+ }
19
+ /**
20
+ * Share one directory watcher and metadata poll per canonical path, including
21
+ * across bundled copies. Native events use a 150 ms debounce; missed events
22
+ * recover through a 1,000 ms stat probe (formerly a 100 ms full-file poll).
23
+ * Watching the directory preserves atomic replacement and missing-file recovery.
24
+ * Only changed raw text notifies; each subscriber is isolated and independently
25
+ * disposable. The last unsubscribe stops both the native watcher and the poll.
26
+ */
6
27
  export function watchTuiPreferences(file, onChange, options = {}) {
7
- const name = basename(file);
28
+ const globals = globalThis;
29
+ const key = Symbol.for('cortexkit.common-auth.tui-prefs.watchers.v1');
30
+ const registry = globals[key] ?? new Map();
31
+ globals[key] = registry;
32
+ const path = canonicalPath(file);
33
+ const shared = registry.get(path);
34
+ if (shared)
35
+ return shared.subscribe(onChange);
36
+ const name = basename(path);
37
+ const read = options.fs?.readFile ?? ((file) => readFile(file, 'utf8'));
38
+ const probe = options.fs?.stat ?? stat;
8
39
  let timer = null;
9
- // An asynchronous seed could absorb a write made immediately after returning.
40
+ let pollTimer = null;
41
+ let lastMetadata = null;
10
42
  let lastSeen = null;
43
+ // Seed synchronously so an immediate write cannot be absorbed as the baseline.
44
+ // The metadata is recorded only together with contents that were read, as in
45
+ // the probe below: if the first read fails, the probe keeps trying.
11
46
  try {
12
- lastSeen = readFileSync(file, 'utf8');
47
+ const metadata = identity((options.fs?.statSync ?? statSync)(path));
48
+ lastSeen = (options.fs?.readFileSync ?? ((file) => readFileSync(file, 'utf8')))(path);
49
+ lastMetadata = metadata;
13
50
  }
14
51
  catch {
15
- // Missing files can be created later.
52
+ // Missing or unreadable files are picked up by the probe later.
16
53
  }
17
54
  let disposed = false;
18
- const checkForChange = async () => {
19
- const text = await readFile(file, 'utf8').catch(() => null);
20
- if (disposed || text === null || text === lastSeen)
21
- return;
22
- lastSeen = text;
23
- onChange();
55
+ const subscribers = new Set();
56
+ let checking = Promise.resolve();
57
+ const checkForChange = (native) => {
58
+ // Serialize native and poll checks so an older read cannot roll back state.
59
+ checking = checking.then(async () => {
60
+ if (disposed)
61
+ return;
62
+ const metadata = identity(await probe(path).catch(() => null));
63
+ if (!native && metadata === lastMetadata)
64
+ return;
65
+ // A missing file has nothing to read; remember that so the probe stays
66
+ // quiet until it reappears.
67
+ if (metadata === null) {
68
+ lastMetadata = null;
69
+ return;
70
+ }
71
+ const text = await read(path).catch(() => null);
72
+ if (disposed)
73
+ return;
74
+ // Record the metadata only once its contents were read. A failed read
75
+ // leaves the old value, so the next probe sees a change and retries
76
+ // instead of skipping a file whose new contents were never observed.
77
+ if (text === null)
78
+ return;
79
+ lastMetadata = metadata;
80
+ if (text === lastSeen)
81
+ return;
82
+ lastSeen = text;
83
+ for (const subscriber of [...subscribers]) {
84
+ if (!subscribers.has(subscriber))
85
+ continue;
86
+ try {
87
+ subscriber.notify();
88
+ }
89
+ catch {
90
+ // A broken subscriber must not prevent the others from observing a change.
91
+ }
92
+ }
93
+ });
94
+ return checking;
24
95
  };
25
96
  const scheduleCheck = () => {
97
+ if (disposed)
98
+ return;
26
99
  if (timer)
27
100
  clearTimeout(timer);
28
101
  timer = setTimeout(() => {
29
102
  timer = null;
30
- void checkForChange();
103
+ void checkForChange(true);
31
104
  }, 150);
32
105
  };
33
106
  let watcher = null;
34
107
  try {
35
- watcher = (options.watchDirectory ?? watch)(dirname(file), (_event, filename) => {
108
+ watcher = (options.watchDirectory ?? watch)(dirname(path), (_event, filename) => {
36
109
  const eventName = filename?.toString();
37
110
  const isOurs = eventName === name ||
38
111
  (eventName?.startsWith(`${name}.`) && eventName.endsWith('.tmp'));
@@ -43,14 +116,12 @@ export function watchTuiPreferences(file, onChange, options = {}) {
43
116
  watcher.on('error', () => {
44
117
  // Polling continues if the native watcher fails after construction.
45
118
  watcher?.close();
119
+ watcher = null;
46
120
  });
47
121
  }
48
122
  catch {
49
- if (lastSeen === null)
50
- return () => { };
123
+ // The probe still recovers a later creation, even with no native watcher.
51
124
  }
52
- // Poll independently: directory watchers can miss rename events.
53
- let pollTimer = null;
54
125
  const schedulePoll = () => {
55
126
  if (disposed || pollTimer)
56
127
  return;
@@ -58,17 +129,29 @@ export function watchTuiPreferences(file, onChange, options = {}) {
58
129
  pollTimer = null;
59
130
  if (disposed)
60
131
  return;
61
- void checkForChange().finally(schedulePoll);
62
- }, 100);
132
+ void checkForChange(false).finally(schedulePoll);
133
+ }, 1_000);
63
134
  pollTimer.unref();
64
135
  };
65
- schedulePoll();
66
- return () => {
67
- disposed = true;
68
- if (timer)
69
- clearTimeout(timer);
70
- if (pollTimer)
71
- clearTimeout(pollTimer);
72
- watcher?.close();
136
+ const entry = {
137
+ subscribe: (notify) => {
138
+ const subscriber = { notify };
139
+ subscribers.add(subscriber);
140
+ return () => {
141
+ if (!subscribers.delete(subscriber) || subscribers.size > 0)
142
+ return;
143
+ disposed = true;
144
+ if (timer)
145
+ clearTimeout(timer);
146
+ if (pollTimer)
147
+ clearTimeout(pollTimer);
148
+ watcher?.close();
149
+ if (registry.get(path) === entry)
150
+ registry.delete(path);
151
+ };
152
+ },
73
153
  };
154
+ registry.set(path, entry);
155
+ schedulePoll();
156
+ return entry.subscribe(onChange);
74
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
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": {