@cortexkit/common-auth 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
1
  import type { CaptureSink } from './capture-sink.js';
2
2
  import { type RedactionOptions } from './redact.js';
3
3
  export type Level = 'error' | 'warn' | 'info' | 'debug' | 'trace';
4
+ /** A logger that writes to a file, and to a capture sink when one is given. */
4
5
  export interface InitLoggerOptions extends RedactionOptions {
6
+ /** Receives every emitted record, scrubbed, alongside the file. */
5
7
  captureSink?: CaptureSink;
6
8
  /**
7
9
  * Path of the file lines are appended to, or a function returning it.
@@ -15,6 +17,25 @@ export interface InitLoggerOptions extends RedactionOptions {
15
17
  /** Level floor applied when no `setLogLevel` call has overridden it. */
16
18
  level?: Level | (() => Level | undefined);
17
19
  }
20
+ /**
21
+ * A logger with no file: the capture sink is its only destination. Nothing
22
+ * is written to disk or printed, for a host that forwards records to its own
23
+ * log.
24
+ */
25
+ export interface SinkOnlyLoggerOptions extends RedactionOptions {
26
+ /** Receives every emitted record, scrubbed. */
27
+ captureSink: CaptureSink;
28
+ file?: undefined;
29
+ /** Level floor applied when no `setLogLevel` call has overridden it. */
30
+ level?: Level | (() => Level | undefined);
31
+ }
32
+ /**
33
+ * What a logger is configured with: a file (`InitLoggerOptions`, the shape
34
+ * every release before this one accepted, kept as its own interface so a
35
+ * caller deriving a type from it with `Pick` or `extends` keeps compiling) or
36
+ * a capture sink alone.
37
+ */
38
+ export type LoggerOptions = InitLoggerOptions | SinkOnlyLoggerOptions;
18
39
  export interface ChannelLogger {
19
40
  error(message: string, data?: unknown): void;
20
41
  warn(message: string, data?: unknown): void;
@@ -37,7 +58,7 @@ export interface LoggerInstance {
37
58
  * alone, because it is the operator's explicit choice and outranks the
38
59
  * floor a host computed at start-up.
39
60
  */
40
- configure(options: InitLoggerOptions): void;
61
+ configure(options: LoggerOptions): void;
41
62
  /** Operator override of the level floor; undefined removes it. */
42
63
  setLogLevel(level: Level | undefined): void;
43
64
  /**
@@ -53,14 +74,14 @@ export interface LoggerInstance {
53
74
  * uses this instead of `initLogger`, so neither replaces the other's file,
54
75
  * level, redaction or capture sink.
55
76
  */
56
- export declare function createLoggerInstance(options: InitLoggerOptions): LoggerInstance;
77
+ export declare function createLoggerInstance(options: LoggerOptions): LoggerInstance;
57
78
  /**
58
79
  * Point the module's default logger at a host's file and level, and return
59
80
  * it. Idempotent: calling it again replaces both. A runtime level installed
60
81
  * by `setLogLevel` is deliberately left alone, because it is the operator's
61
82
  * explicit choice and outranks the floor a host computed at start-up.
62
83
  */
63
- export declare function initLogger(options: InitLoggerOptions): LoggerInstance;
84
+ export declare function initLogger(options: LoggerOptions): LoggerInstance;
64
85
  export declare function setLogLevel(l: Level | undefined): void;
65
86
  /**
66
87
  * Write whatever the default logger has buffered. Safe to call synchronously
@@ -126,7 +126,8 @@ function createEngine(options) {
126
126
  }, 500);
127
127
  }
128
128
  function emit(channel, level, message, data) {
129
- if (logFileSource === undefined)
129
+ // Unconfigured (neither a file nor a sink) stays a silent no-op.
130
+ if (logFileSource === undefined && captureSink === undefined)
130
131
  return;
131
132
  try {
132
133
  if (ORDER[level] > ORDER[configuredLevel()])
@@ -152,6 +153,9 @@ function createEngine(options) {
152
153
  });
153
154
  }
154
155
  catch { }
156
+ // A sink-only logger has no file to buffer lines for.
157
+ if (logFileSource === undefined)
158
+ return;
155
159
  buffer.push(line);
156
160
  if (buffer.length >= 50)
157
161
  flushLogs();
@@ -1,6 +1,6 @@
1
1
  export type { CaptureSink, LogTestRecord } from './capture-sink.js';
2
2
  export { createCaptureSink } from './capture-sink.js';
3
- export type { ChannelLogger, InitLoggerOptions, Level, LoggerInstance, } from './engine.js';
3
+ export type { ChannelLogger, InitLoggerOptions, Level, LoggerInstance, LoggerOptions, SinkOnlyLoggerOptions, } from './engine.js';
4
4
  export { createLogger, createLoggerInstance, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
5
5
  export type { RedactionOptions, Redactor } from './redact.js';
6
6
  export { createRedactor, redact, redactStrings } from './redact.js';
@@ -35,6 +35,24 @@ export interface NotificationScope {
35
35
  * session, which plugins that drain from one process-wide TUI rely on.
36
36
  */
37
37
  requireSession?: boolean;
38
+ /**
39
+ * What a drain with no session id returns, and what it acknowledges.
40
+ *
41
+ * - `'all'` (default): every queued notification, broadcasts and every
42
+ * session's targeted ones; a sessionless acknowledgement removes
43
+ * nothing. A plugin that drains from one process-wide TUI relies on this.
44
+ * - `'broadcast-only'`: broadcasts only (notifications pushed without a
45
+ * session), never a session's targeted ones, for a TUI that polls before
46
+ * it knows its session. A sessionless acknowledgement removes the
47
+ * acknowledged broadcasts and leaves every targeted notification queued.
48
+ * It also answers `isTuiConnected(scope, undefined)`: true while any
49
+ * drain on the queue, with or without a session, happened within the
50
+ * connection window.
51
+ *
52
+ * The option governs each call made with this scope; it does not split the
53
+ * queue, so a push through a scope without it lands in the same queue.
54
+ */
55
+ sessionlessDrain?: 'all' | 'broadcast-only';
38
56
  }
39
57
  /** A strict notification scope was used without a session id. */
40
58
  export declare class RpcSessionRequiredError extends Error {
@@ -45,5 +63,11 @@ export declare class RpcSessionRequiredError extends Error {
45
63
  export declare function isSessionId(value: unknown): value is string;
46
64
  export declare function pushNotification(scope: NotificationScope, payload: OpenDialogPayload, sessionId?: string): void;
47
65
  export declare function drainNotifications(scope: NotificationScope, lastReceivedId?: number, sessionId?: string): RpcNotification[];
48
- export declare function isTuiConnected(scope: NotificationScope, sessionId: string): boolean;
66
+ /**
67
+ * True when a TUI drained within the last 3000 ms. With a session id, that
68
+ * session drained. Without one, a broadcast-only scope answers whether any
69
+ * drain happened; a default scope answers false, since it only tracks
70
+ * sessions.
71
+ */
72
+ export declare function isTuiConnected(scope: NotificationScope, sessionId: string | undefined): boolean;
49
73
  export declare function resetNotificationsForTest(scope: NotificationScope): void;
@@ -21,7 +21,12 @@ function state(scope) {
21
21
  ]);
22
22
  let value = queues.get(key);
23
23
  if (!value) {
24
- value = { queue: [], nextId: 1, lastDrainAtBySession: new Map() };
24
+ value = {
25
+ queue: [],
26
+ nextId: 1,
27
+ lastDrainAtBySession: new Map(),
28
+ lastDrainAtAny: 0,
29
+ };
25
30
  queues.set(key, value);
26
31
  }
27
32
  return value;
@@ -49,25 +54,41 @@ export function drainNotifications(scope, lastReceivedId = 0, sessionId) {
49
54
  throw new RpcSessionRequiredError('drain');
50
55
  const value = state(scope);
51
56
  const now = Date.now();
57
+ value.lastDrainAtAny = now;
52
58
  if (sessionId !== undefined)
53
59
  value.lastDrainAtBySession.set(sessionId, now);
54
- const matches = (n) => sessionId === undefined ||
55
- n.sessionId === undefined ||
56
- n.sessionId === sessionId;
60
+ const broadcastOnly = scope.sessionlessDrain === 'broadcast-only';
61
+ const matches = (n) => sessionId === undefined
62
+ ? !broadcastOnly || n.sessionId === undefined
63
+ : n.sessionId === undefined || n.sessionId === sessionId;
57
64
  if (lastReceivedId > 0) {
58
65
  value.queue = value.queue.filter((n) => {
59
66
  if (n.id > lastReceivedId)
60
67
  return true;
68
+ // A sessionless ack in broadcast-only mode consumes the broadcasts it
69
+ // was shown; in the default mode it consumes nothing, since it was
70
+ // shown targeted notifications other sessions still have to receive.
61
71
  if (sessionId === undefined)
62
- return true;
72
+ return !broadcastOnly || n.sessionId !== undefined;
63
73
  return n.sessionId !== sessionId;
64
74
  });
65
75
  }
66
76
  return value.queue.filter((n) => n.id > lastReceivedId && matches(n));
67
77
  }
78
+ /**
79
+ * True when a TUI drained within the last 3000 ms. With a session id, that
80
+ * session drained. Without one, a broadcast-only scope answers whether any
81
+ * drain happened; a default scope answers false, since it only tracks
82
+ * sessions.
83
+ */
68
84
  export function isTuiConnected(scope, sessionId) {
69
85
  const now = Date.now();
70
- const at = state(scope).lastDrainAtBySession.get(sessionId) ?? 0;
86
+ const value = state(scope);
87
+ const at = sessionId !== undefined
88
+ ? (value.lastDrainAtBySession.get(sessionId) ?? 0)
89
+ : scope.sessionlessDrain === 'broadcast-only'
90
+ ? value.lastDrainAtAny
91
+ : 0;
71
92
  return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
72
93
  }
73
94
  export function resetNotificationsForTest(scope) {
@@ -75,4 +96,5 @@ export function resetNotificationsForTest(scope) {
75
96
  value.queue = [];
76
97
  value.nextId = 1;
77
98
  value.lastDrainAtBySession.clear();
99
+ value.lastDrainAtAny = 0;
78
100
  }
@@ -17,4 +17,13 @@ export declare function writePortFile(dir: string, entry: {
17
17
  beforeWrite?: () => void | Promise<void>;
18
18
  }): Promise<string>;
19
19
  export declare function sweepRpcState(root: string, activeDir: string, isManagedDir: (name: string) => boolean, log?: RpcLogChannel): Promise<void>;
20
- export declare function discoverPortFile(dir: string, expectedPid?: number): Promise<PortFileEntry | null>;
20
+ export interface DiscoverPortFileOptions {
21
+ /**
22
+ * Return only the expected PID's entry, or null; never fall back to
23
+ * another live server. Without an expected PID nothing matches, so the
24
+ * result is null. Off by default, when a missing or unmatched expected PID
25
+ * falls back to the newest live entry.
26
+ */
27
+ exactPid?: boolean;
28
+ }
29
+ export declare function discoverPortFile(dir: string, expectedPid?: number, options?: DiscoverPortFileOptions): Promise<PortFileEntry | null>;
@@ -1,7 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { chmod, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile, } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
+ /**
5
+ * A PID a liveness probe may address. `process.kill(0 | negative, 0)` signals
6
+ * a process group rather than a process, and an unsafe integer cannot name a
7
+ * real process, so neither is ever probed.
8
+ */
9
+ function isProbeablePid(pid) {
10
+ return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0;
11
+ }
4
12
  function pidAlive(pid) {
13
+ if (!isProbeablePid(pid))
14
+ return false;
5
15
  try {
6
16
  process.kill(pid, 0);
7
17
  return true;
@@ -23,14 +33,29 @@ export function getRpcDir(rpcRoot, directoryPrefix, projectDirectory) {
23
33
  return join(rpcRoot, directoryPrefix +
24
34
  createHash('sha256').update(projectDirectory).digest('hex').slice(0, 16));
25
35
  }
26
- function isUsablePortFileEntry(value) {
27
- return (value !== null &&
28
- typeof value === 'object' &&
29
- !Array.isArray(value) &&
30
- typeof value.pid === 'number' &&
31
- Number.isFinite(value.pid) &&
32
- typeof value.port === 'number' &&
33
- Number.isFinite(value.port));
36
+ /** The PID a port file's name claims, or undefined for a malformed name. */
37
+ function filenamePid(name) {
38
+ const match = /^port-(\d+)\.json$/.exec(name);
39
+ return match ? Number(match[1]) : undefined;
40
+ }
41
+ /**
42
+ * An entry a client may connect to: its PID is probeable and matches the PID
43
+ * in its file name (a mismatch means the file was not written by the server
44
+ * it names), its port is a real TCP port, and its token is non-empty (an
45
+ * absent token would otherwise be sent as `Bearer undefined`).
46
+ */
47
+ function isUsablePortFileEntry(value, name) {
48
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
49
+ return false;
50
+ const { pid, port, token } = value;
51
+ return (isProbeablePid(pid) &&
52
+ filenamePid(name) === pid &&
53
+ typeof port === 'number' &&
54
+ Number.isInteger(port) &&
55
+ port >= 1 &&
56
+ port <= 65_535 &&
57
+ typeof token === 'string' &&
58
+ token.length > 0);
34
59
  }
35
60
  async function removeCorruptPortFile(portFile, log) {
36
61
  log?.debug('rpc corrupt port file', { pid: process.pid, portFile });
@@ -53,11 +78,18 @@ export async function writePortFile(dir, entry, options = {}) {
53
78
  const full = { ...entry, startedAt: Date.now() };
54
79
  const target = join(dir, `port-${entry.pid}.json`);
55
80
  const tmp = `${target}.${process.pid}.tmp`;
56
- await writeFile(tmp, JSON.stringify(full), {
57
- encoding: 'utf8',
58
- mode: 0o600,
59
- });
60
- await rename(tmp, target);
81
+ try {
82
+ await writeFile(tmp, JSON.stringify(full), {
83
+ encoding: 'utf8',
84
+ mode: 0o600,
85
+ });
86
+ await rename(tmp, target);
87
+ }
88
+ catch (error) {
89
+ // The staged file carries the server token; never leave it behind.
90
+ await unlink(tmp).catch(() => { });
91
+ throw error;
92
+ }
61
93
  return target;
62
94
  };
63
95
  try {
@@ -116,7 +148,7 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
116
148
  await removeCorruptPortFile(portFile, log);
117
149
  continue;
118
150
  }
119
- if (!isUsablePortFileEntry(parsed)) {
151
+ if (!isUsablePortFileEntry(parsed, name)) {
120
152
  await removeCorruptPortFile(portFile, log);
121
153
  continue;
122
154
  }
@@ -128,7 +160,7 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
128
160
  await rmdir(dir).catch(() => { });
129
161
  }
130
162
  }
131
- export async function discoverPortFile(dir, expectedPid) {
163
+ export async function discoverPortFile(dir, expectedPid, options = {}) {
132
164
  let names;
133
165
  try {
134
166
  names = await readdir(dir);
@@ -142,7 +174,7 @@ export async function discoverPortFile(dir, expectedPid) {
142
174
  continue;
143
175
  try {
144
176
  const parsed = JSON.parse(await readFile(join(dir, name), 'utf8'));
145
- if (isUsablePortFileEntry(parsed)) {
177
+ if (isUsablePortFileEntry(parsed, name)) {
146
178
  if (pidAlive(parsed.pid))
147
179
  live.push(parsed);
148
180
  else
@@ -156,6 +188,8 @@ export async function discoverPortFile(dir, expectedPid) {
156
188
  const candidates = expectedPid !== undefined && expectedPid >= 1
157
189
  ? live.filter((entry) => entry.pid === expectedPid)
158
190
  : [];
191
+ if (options.exactPid === true && candidates.length === 0)
192
+ return null;
159
193
  const entries = candidates.length > 0 ? candidates : live;
160
194
  const sortTime = (entry) => typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt)
161
195
  ? entry.startedAt
@@ -1,8 +1,25 @@
1
1
  import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
2
- import { type PortFileEntry } from './port-file.js';
2
+ import { type DiscoverPortFileOptions, type PortFileEntry } from './port-file.js';
3
3
  export interface RpcClient {
4
4
  pending: (lastReceivedId: number, sessionId?: string) => Promise<RpcNotification[]>;
5
5
  apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>;
6
6
  }
7
7
  export declare const DEFAULT_RPC_TIMEOUT_MS = 2000;
8
- export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void): RpcClient;
8
+ /**
9
+ * `exactPid`: every call goes only to the expected PID's server. With no
10
+ * such server (or no expected PID) a call returns its fallback without
11
+ * opening a socket, on every call. Off by default, when a call falls back to
12
+ * the newest live server.
13
+ */
14
+ export type RpcClientOptions = DiscoverPortFileOptions;
15
+ /**
16
+ * A client for the server in `dir`, preferring `expectedPid`'s.
17
+ *
18
+ * `onSelected` reports the first selection: it is told which entry (or null)
19
+ * discovery chose, until one call of it returns normally. It is a report, not
20
+ * a gate: an observer that throws rejects that call before any request is
21
+ * sent and is asked again on the next call, but nothing stops a later call
22
+ * once an observer has returned. A caller that must never reach another
23
+ * server passes `{ exactPid: true }`.
24
+ */
25
+ export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void, options?: RpcClientOptions): RpcClient;
@@ -1,7 +1,7 @@
1
- import { discoverPortFile } from './port-file.js';
1
+ import { discoverPortFile, } from './port-file.js';
2
2
  export const DEFAULT_RPC_TIMEOUT_MS = 2_000;
3
- async function call(dir, expectedPid, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
4
- const entry = await discoverPortFile(dir, expectedPid);
3
+ async function call(dir, expectedPid, discoverOptions, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
4
+ const entry = await discoverPortFile(dir, expectedPid, discoverOptions);
5
5
  onSelected?.(entry);
6
6
  if (!entry)
7
7
  return null;
@@ -28,21 +28,36 @@ async function call(dir, expectedPid, onSelected, method, params, timeoutMs = DE
28
28
  clearTimeout(timer);
29
29
  }
30
30
  }
31
- export function createRpcClient(dir, expectedPid, onSelected) {
31
+ /**
32
+ * A client for the server in `dir`, preferring `expectedPid`'s.
33
+ *
34
+ * `onSelected` reports the first selection: it is told which entry (or null)
35
+ * discovery chose, until one call of it returns normally. It is a report, not
36
+ * a gate: an observer that throws rejects that call before any request is
37
+ * sent and is asked again on the next call, but nothing stops a later call
38
+ * once an observer has returned. A caller that must never reach another
39
+ * server passes `{ exactPid: true }`.
40
+ */
41
+ export function createRpcClient(dir, expectedPid, onSelected, options = {}) {
42
+ const discoverOptions = {
43
+ exactPid: options.exactPid,
44
+ };
32
45
  let reportedSelection = false;
33
46
  const reportSelected = (entry) => {
34
47
  if (reportedSelection)
35
48
  return;
36
- reportedSelection = true;
37
49
  onSelected?.(entry);
50
+ // Counted only after the observer returns, so a throwing one is not
51
+ // silently skipped on later calls.
52
+ reportedSelection = true;
38
53
  };
39
54
  return {
40
55
  async pending(lastReceivedId, sessionId) {
41
- const out = await call(dir, expectedPid, reportSelected, 'pending-notifications', { lastReceivedId, sessionId });
56
+ const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'pending-notifications', { lastReceivedId, sessionId });
42
57
  return out?.messages ?? [];
43
58
  },
44
59
  async apply(request, timeoutMs) {
45
- const out = await call(dir, expectedPid, reportSelected, 'apply', {
60
+ const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'apply', {
46
61
  ...request,
47
62
  }, timeoutMs);
48
63
  return out ?? { text: 'apply failed', knobs: {} };
@@ -23,5 +23,23 @@ export interface RpcServerOptions {
23
23
  requireSession?: boolean;
24
24
  timeoutMs?: number;
25
25
  receiptTimeoutMs?: number;
26
+ /**
27
+ * Answer an `apply` call whose handler is still running after this many
28
+ * milliseconds with 504 `{error: 'handler deadline exceeded'}`. The handler
29
+ * is not cancelled; its eventual result is discarded. Unset by default,
30
+ * when only the socket inactivity timeout (`timeoutMs`) bounds a handler,
31
+ * by destroying the socket.
32
+ */
33
+ applyDeadlineMs?: number;
34
+ }
35
+ /**
36
+ * Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
37
+ * status. Its message is sent on the wire as `{error: message}`, so it must
38
+ * be written for the client and never quote a credential. Any other error a
39
+ * handler throws answers 500 with a fixed code.
40
+ */
41
+ export declare class RpcRequestError extends Error {
42
+ readonly status: number;
43
+ constructor(status: number, message: string);
26
44
  }
27
45
  export declare function startRpcServer(options: RpcServerOptions): Promise<RpcServerHandle>;
@@ -4,15 +4,47 @@ import { createServer, } from 'node:http';
4
4
  import { join } from 'node:path';
5
5
  import { isSessionId, } from './notifications.js';
6
6
  import { sweepRpcState, writePortFile } from './port-file.js';
7
+ /**
8
+ * Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
9
+ * status. Its message is sent on the wire as `{error: message}`, so it must
10
+ * be written for the client and never quote a credential. Any other error a
11
+ * handler throws answers 500 with a fixed code.
12
+ */
13
+ export class RpcRequestError extends Error {
14
+ status;
15
+ constructor(status, message) {
16
+ if (!Number.isInteger(status) || status < 400 || status > 499)
17
+ throw new RangeError(`RpcRequestError status must be 4xx, got ${status}`);
18
+ super(message);
19
+ this.name = 'RpcRequestError';
20
+ this.status = status;
21
+ }
22
+ }
23
+ const MAX_BODY_BYTES = 1_000_000;
24
+ /** The request body exceeded the cap; answered 413. */
25
+ class BodyTooLargeError extends Error {
26
+ }
7
27
  function readBody(req) {
8
28
  return new Promise((resolve, reject) => {
29
+ const tooLarge = () => {
30
+ // Keep reading and discarding the rest, so the client finishes sending
31
+ // and can read the 413 instead of seeing a reset connection.
32
+ req.removeAllListeners('data');
33
+ req.on('data', () => { });
34
+ req.resume();
35
+ reject(new BodyTooLargeError('body too large'));
36
+ };
37
+ const declared = Number(req.headers['content-length']);
38
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
39
+ tooLarge();
40
+ return;
41
+ }
9
42
  const chunks = [];
10
43
  let size = 0;
11
44
  req.on('data', (chunk) => {
12
45
  size += chunk.length;
13
- if (size > 1_000_000) {
14
- req.destroy();
15
- reject(new Error('body too large'));
46
+ if (size > MAX_BODY_BYTES) {
47
+ tooLarge();
16
48
  return;
17
49
  }
18
50
  chunks.push(chunk);
@@ -21,6 +53,23 @@ function readBody(req) {
21
53
  req.on('error', reject);
22
54
  });
23
55
  }
56
+ class ApplyDeadlineError extends Error {
57
+ }
58
+ /** Settle with `work`, or reject at `ms` while `work` keeps running. */
59
+ async function withDeadline(work, ms) {
60
+ let timer;
61
+ const deadline = new Promise((_, reject) => {
62
+ timer = setTimeout(() => reject(new ApplyDeadlineError('apply deadline exceeded')), ms);
63
+ });
64
+ // A handler that fails after its deadline has nobody left to answer.
65
+ work.catch(() => { });
66
+ try {
67
+ return await Promise.race([work, deadline]);
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
24
73
  function tokenOk(header, token) {
25
74
  if (!header?.startsWith('Bearer '))
26
75
  return false;
@@ -45,25 +94,43 @@ export async function startRpcServer(options) {
45
94
  server.requestTimeout = receiptTimeoutMs;
46
95
  server.headersTimeout = receiptTimeoutMs;
47
96
  async function dispatch(req, res) {
48
- const json = (status, value) => {
49
- // Guard against writing to a socket that was destroyed (e.g. when
50
- // readBody rejected after req.destroy() on an oversized body).
97
+ const json = (status, value, headers = {}) => {
98
+ // Guard against writing to a socket that is already gone (the
99
+ // inactivity timeout destroys it).
51
100
  if (res.headersSent || res.writableEnded || res.destroyed)
52
101
  return;
53
- res.writeHead(status, { 'content-type': 'application/json' });
102
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
54
103
  res.end(JSON.stringify(value));
55
104
  };
56
105
  try {
57
- const url = req.url ?? '';
58
- if (req.method === 'GET' && url === '/health')
106
+ // Route on the pathname alone, so a query string does not turn a known
107
+ // method into a 404.
108
+ const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
109
+ if (req.method === 'GET' && path === '/health')
59
110
  return json(200, { ok: true });
60
- if (req.method !== 'POST' || !url.startsWith('/rpc/'))
111
+ if (req.method !== 'POST' || !path.startsWith('/rpc/'))
61
112
  return json(404, { error: 'not found' });
62
113
  if (!tokenOk(req.headers.authorization, token))
63
114
  return json(401, { error: 'unauthorized' });
64
- const method = url.slice('/rpc/'.length);
65
- const body = await readBody(req);
66
- const params = JSON.parse(body || '{}');
115
+ const method = path.slice('/rpc/'.length);
116
+ let body;
117
+ try {
118
+ body = await readBody(req);
119
+ }
120
+ catch (error) {
121
+ if (!(error instanceof BodyTooLargeError))
122
+ throw error;
123
+ // Close the connection after answering: the rest of the oversized
124
+ // body is not worth keeping the socket for.
125
+ return json(413, { error: 'body too large' }, { connection: 'close' });
126
+ }
127
+ let params;
128
+ try {
129
+ params = JSON.parse(body || '{}');
130
+ }
131
+ catch {
132
+ return json(400, { error: 'invalid json' });
133
+ }
67
134
  if (method === 'pending-notifications') {
68
135
  if (options.requireSession === true && !isSessionId(params.sessionId))
69
136
  return json(400, { error: 'session required' });
@@ -78,12 +145,29 @@ export async function startRpcServer(options) {
78
145
  return json(200, { messages });
79
146
  }
80
147
  if (method === 'apply') {
81
- const result = await options.apply(params);
148
+ const work = Promise.resolve(options.apply(params));
149
+ const result = options.applyDeadlineMs === undefined
150
+ ? await work
151
+ : await withDeadline(work, options.applyDeadlineMs);
82
152
  return json(200, result);
83
153
  }
84
154
  return json(404, { error: 'unknown method' });
85
155
  }
86
156
  catch (error) {
157
+ if (error instanceof RpcRequestError) {
158
+ log.debug('rpc request refused', {
159
+ pid: process.pid,
160
+ status: error.status,
161
+ });
162
+ return json(error.status, { error: error.message });
163
+ }
164
+ if (error instanceof ApplyDeadlineError) {
165
+ log.warn('rpc apply deadline exceeded', {
166
+ pid: process.pid,
167
+ deadlineMs: options.applyDeadlineMs,
168
+ });
169
+ return json(504, { error: 'handler deadline exceeded' });
170
+ }
87
171
  // A handler's exception can quote a request or a credential, so its
88
172
  // text goes to the plugin's log channel only; the wire gets a fixed code.
89
173
  log.warn('rpc request failed', {
@@ -68,6 +68,16 @@ export interface SidebarFileOptions<T> {
68
68
  * that the lock was lost. Without it such a write is only reported.
69
69
  */
70
70
  repair?: SidebarRepair<T>;
71
+ /**
72
+ * Name of the lock writers coordinate on, as `<path>.<lockName>.lock`.
73
+ * Defaults to `'sidebar-write'`. It names both the lock a write takes and
74
+ * the lock whose ownership is checked before the rename, so a takeover of
75
+ * this name refuses the write. A plugin keeping an existing writer's lock
76
+ * identity passes that writer's name; mutual exclusion with that writer is
77
+ * promised only while it holds a live lease of the same name in the same
78
+ * lock-file format, not for its stale-lock reclamation or renewal.
79
+ */
80
+ lockName?: string;
71
81
  }
72
82
  export interface SidebarFile<T> {
73
83
  read(): Promise<T>;
@@ -43,6 +43,7 @@ export function createSidebarFile(options) {
43
43
  };
44
44
  const lockOptions = {
45
45
  ...WRITER_LOCK_CONSTANTS.sidebar,
46
+ name: options.lockName ?? WRITER_LOCK_CONSTANTS.sidebar.name,
46
47
  timeoutMs: options.timeoutMs ?? WRITER_LOCK_CONSTANTS.sidebar.timeoutMs,
47
48
  };
48
49
  /**
@@ -2,8 +2,19 @@ import { type StoreRuntime } from './runtime.js';
2
2
  /**
3
3
  * What a pull or refresh captured about its row (named by id alongside) when
4
4
  * it was issued. A result applies only while the row with that id still has
5
- * this credential epoch and this recorded identity; a replaced credential bumps the epoch, so work issued
6
- * for the old one is discarded.
5
+ * this credential epoch and this recorded identity; a replaced credential
6
+ * bumps the epoch, so work issued for the old one is discarded.
7
+ *
8
+ * The epoch names one credential lineage of the row: a credential given by
9
+ * `add` or `replace`, through every refresh and `rotate` of it, which keep
10
+ * the epoch. Since 0.8.0 that holds across removal too: a row added under an
11
+ * id the pool held before starts past every epoch that id held (the store
12
+ * records them when it drops a row, in the config file, so every process
13
+ * sees it), so an attribution taken for a removed row is refused once the id
14
+ * is added again, even with the same identity. Writers older than 0.8.0
15
+ * start a re-added id at epoch 1 again, and a writer that does not know the
16
+ * pool can remove and re-add a row without the store seeing it; work
17
+ * attributed across either may still apply to the new credential.
7
18
  */
8
19
  export interface Attribution {
9
20
  credentialEpoch: number;
@@ -118,7 +118,11 @@ export declare class Transaction {
118
118
  * Writes the config: legacy `version: 1` and the legacy roster beside
119
119
  * `commonAuthPool`, every other top-level key and every unrecognised pool
120
120
  * key untouched. Entries for ids no longer in the roster are dropped here,
121
- * and remembered so the id is not reused in this process.
121
+ * and remembered so the id is not reused in this process. Every id the
122
+ * write drops (a roster row the files held when the transaction read them,
123
+ * or an entry left without one) has its epoch recorded in the config (see
124
+ * `retireEpochsIn`), which is what keeps a later `add` of the id, from any
125
+ * process, past every epoch an attribution could name.
122
126
  */
123
127
  commitConfig(options?: {
124
128
  counted?: boolean;
@@ -4,7 +4,7 @@ import { LockContentionError, LockOwnershipError } from '../fs/with-lock.js';
4
4
  import { PoolOperationError, } from './errors.js';
5
5
  import { callFailureHook } from './hooks.js';
6
6
  import { LockStack, } from './refresh-lock.js';
7
- import { classifyConfig, classifyState, ensureEntries, entryIn, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, rosterRowIn, setEntryIn, } from './schema.js';
7
+ import { classifyConfig, classifyState, ensureEntries, entryIn, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, retireEpochsIn, rosterOf, rosterRowIn, setEntryIn, } from './schema.js';
8
8
  import { completeTornRows, loadRows } from './torn.js';
9
9
  async function readJson(path) {
10
10
  let text;
@@ -169,7 +169,11 @@ export class Transaction {
169
169
  * Writes the config: legacy `version: 1` and the legacy roster beside
170
170
  * `commonAuthPool`, every other top-level key and every unrecognised pool
171
171
  * key untouched. Entries for ids no longer in the roster are dropped here,
172
- * and remembered so the id is not reused in this process.
172
+ * and remembered so the id is not reused in this process. Every id the
173
+ * write drops (a roster row the files held when the transaction read them,
174
+ * or an entry left without one) has its epoch recorded in the config (see
175
+ * `retireEpochsIn`), which is what keeps a later `add` of the id, from any
176
+ * process, past every epoch an attribution could name.
173
177
  */
174
178
  async commitConfig(options = {}) {
175
179
  const roster = this.roster();
@@ -177,6 +181,14 @@ export class Transaction {
177
181
  for (const raw of roster)
178
182
  if (isRecord(raw) && typeof raw.id === 'string')
179
183
  rosterIds.add(raw.id);
184
+ const dropped = new Set();
185
+ for (const raw of rosterOf(this.snapshot.config))
186
+ if (isRecord(raw) && typeof raw.id === 'string' && !rosterIds.has(raw.id))
187
+ dropped.add(raw.id);
188
+ for (const id of Object.keys(this.entries()))
189
+ if (!rosterIds.has(id))
190
+ dropped.add(id);
191
+ retireEpochsIn(this.config, dropped);
180
192
  const entries = this.entries();
181
193
  const kept = {};
182
194
  for (const [id, entry] of Object.entries(entries)) {
@@ -88,6 +88,13 @@ export interface PoolStore {
88
88
  }): Promise<{
89
89
  status: InitializeOutcome;
90
90
  }>;
91
+ /**
92
+ * Adds a row, or completes or rotates the row already holding the id or
93
+ * the secret. A new row starts at credential epoch 1; since 0.8.0 one
94
+ * whose id the pool held before starts one past the highest epoch that id
95
+ * held (see `Attribution`), and an id that held `Number.MAX_SAFE_INTEGER`
96
+ * refuses (`id-removed`) before writing.
97
+ */
91
98
  add(input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
92
99
  /**
93
100
  * Gives a row a new credential and a new credential epoch. Since 0.6.0 the
@@ -192,7 +192,9 @@ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowTra
192
192
  * between the two leaves a row every reader already sees as removed, with
193
193
  * only an orphaned state entry that no reader loads; calling `remove` again
194
194
  * drops that entry (`completed`). As with every id the store drops, the id is
195
- * not reused by `add` in this process.
195
+ * not reused by `add` in this process, and the config write records the
196
+ * row's credential epoch, so an `add` of the id in any other process starts
197
+ * past it (see `nextAddEpochIn`).
196
198
  */
197
199
  export declare function removeRow(rt: StoreRuntime, id: string, options?: RemoveOptions): Promise<RemoveResult>;
198
200
  /**
@@ -5,7 +5,7 @@ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enable
5
5
  import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
6
6
  import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
7
7
  import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
8
- import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
8
+ import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, nextAddEpochIn, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
9
9
  import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
10
10
  /** Fields of a state entry that belong to the credential it replaces. */
11
11
  const CREDENTIAL_STATE_FIELDS = [
@@ -259,6 +259,12 @@ export async function addRow(rt, input, options = {}) {
259
259
  await tx.commitConfig();
260
260
  return { id, outcome: 'completed', credential: stored };
261
261
  }
262
+ // An id the pool held before starts past every epoch it held, so
263
+ // work attributed to the earlier row's credential, from this
264
+ // process or another, never matches the new one.
265
+ const credentialEpoch = nextAddEpochIn(tx.config, id);
266
+ if (!isCredentialEpoch(credentialEpoch))
267
+ throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
262
268
  tx.roster().push(rosterRowFor({
263
269
  id,
264
270
  credential,
@@ -267,7 +273,7 @@ export async function addRow(rt, input, options = {}) {
267
273
  addedAt: ctx.now(),
268
274
  }));
269
275
  tx.setEntry(id, {
270
- credentialEpoch: 1,
276
+ credentialEpoch,
271
277
  needsFirstReading: credential.type === 'oauth',
272
278
  });
273
279
  let outcome = 'added';
@@ -577,7 +583,9 @@ export function enableRow(rt, id, options = {}) {
577
583
  * between the two leaves a row every reader already sees as removed, with
578
584
  * only an orphaned state entry that no reader loads; calling `remove` again
579
585
  * drops that entry (`completed`). As with every id the store drops, the id is
580
- * not reused by `add` in this process.
586
+ * not reused by `add` in this process, and the config write records the
587
+ * row's credential epoch, so an `add` of the id in any other process starts
588
+ * past it (see `nextAddEpochIn`).
581
589
  */
582
590
  export async function removeRow(rt, id, options = {}) {
583
591
  assertNotInsideHook('remove');
@@ -4,6 +4,15 @@ export declare const POOL_KEY = "commonAuthPool";
4
4
  export declare const POOL_SCHEMA_VERSION = 1;
5
5
  /** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
6
6
  export declare const POOL_ROWS_KEY = "rows";
7
+ /**
8
+ * Property of `commonAuthPool` (since 0.8.0) holding, per id, the highest
9
+ * credential epoch a row with that id held when the store last dropped it
10
+ * from the pool. A row added later under the same id starts past it (see
11
+ * `nextAddEpochIn`), so an attribution taken for the dropped row never
12
+ * matches the new one. Older readers ignore it, and older writers keep it
13
+ * as they keep every pool key they do not know.
14
+ */
15
+ export declare const POOL_RETIRED_EPOCHS_KEY = "retiredEpochs";
7
16
  /** The `version` older readers of the same files expect at the top level. */
8
17
  export declare const LEGACY_STORE_VERSION = 1;
9
18
  /**
@@ -348,6 +357,37 @@ export declare function ensureEntries(config: Record<string, unknown>): Record<s
348
357
  export declare function entryIn(config: Record<string, unknown>, id: string): Record<string, unknown> | undefined;
349
358
  /** Sets an entry as an own property, so an id such as `toString` is safe. */
350
359
  export declare function setEntryIn(config: Record<string, unknown>, id: string, entry: Record<string, unknown>): void;
360
+ /**
361
+ * The credential epoch recorded for a dropped id (see
362
+ * `POOL_RETIRED_EPOCHS_KEY`), or undefined when none is. A value that is not
363
+ * a credential epoch counts as none.
364
+ */
365
+ export declare function retiredEpochIn(config: Record<string, unknown>, id: string): number | undefined;
366
+ /**
367
+ * The credential epoch `add` gives a new row with this id: one past the
368
+ * highest epoch the id is known to have held, which is the epoch recorded
369
+ * when the store dropped it, or the epoch of an entry left behind by a writer
370
+ * that removed only its roster row; 1 for an id the pool never held.
371
+ *
372
+ * An attribution names a row by id and credential epoch (and identity), and
373
+ * an id is chosen by the plugin, so it is often the same one again (`main`).
374
+ * Were a re-added row to start at epoch 1 again, an attribution taken for the
375
+ * removed row's credential would match the new credential exactly, in this
376
+ * process or any other. Starting past every earlier epoch makes such an
377
+ * attribution fail as it does after a `replace`. The result may lie past the
378
+ * safe integers (an id whose last row was at `Number.MAX_SAFE_INTEGER`);
379
+ * `add` refuses such an id.
380
+ */
381
+ export declare function nextAddEpochIn(config: Record<string, unknown>, id: string): number;
382
+ /**
383
+ * Records, in a config being written, the epochs of the ids it drops: for
384
+ * each, the epoch its entry claims (1 for a row without one, the epoch such a
385
+ * row is at), kept only when above what is already recorded, so the record
386
+ * for an id never goes down. Valid recorded values of other ids are kept; a
387
+ * record that is not an object, or a value in it that is not an epoch, says
388
+ * nothing and is replaced. An entry whose epoch cannot be read records 1.
389
+ */
390
+ export declare function retireEpochsIn(config: Record<string, unknown>, dropped: Iterable<string>): void;
351
391
  /**
352
392
  * Builds the rows of a ready pool from the files exactly as they are, without
353
393
  * looking at credential stamps (see `loadRows` for the rows every reader
@@ -5,6 +5,15 @@ export const POOL_KEY = 'commonAuthPool';
5
5
  export const POOL_SCHEMA_VERSION = 1;
6
6
  /** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
7
7
  export const POOL_ROWS_KEY = 'rows';
8
+ /**
9
+ * Property of `commonAuthPool` (since 0.8.0) holding, per id, the highest
10
+ * credential epoch a row with that id held when the store last dropped it
11
+ * from the pool. A row added later under the same id starts past it (see
12
+ * `nextAddEpochIn`), so an attribution taken for the dropped row never
13
+ * matches the new one. Older readers ignore it, and older writers keep it
14
+ * as they keep every pool key they do not know.
15
+ */
16
+ export const POOL_RETIRED_EPOCHS_KEY = 'retiredEpochs';
8
17
  /** The `version` older readers of the same files expect at the top level. */
9
18
  export const LEGACY_STORE_VERSION = 1;
10
19
  /**
@@ -410,11 +419,15 @@ function credentialFor(raw, stateEntry) {
410
419
  export function rosterRowIn(config, id) {
411
420
  return rosterOf(config).find((raw) => isRecord(raw) && raw.id === id);
412
421
  }
413
- /** The per-row entries of a config, created (empty) when absent. */
414
- export function ensureEntries(config) {
422
+ /** The pool object of a config, created (empty) when absent. */
423
+ function ensurePool(config) {
415
424
  if (!isRecord(config[POOL_KEY]))
416
425
  config[POOL_KEY] = {};
417
- const pool = config[POOL_KEY];
426
+ return config[POOL_KEY];
427
+ }
428
+ /** The per-row entries of a config, created (empty) when absent. */
429
+ export function ensureEntries(config) {
430
+ const pool = ensurePool(config);
418
431
  if (!isRecord(pool[POOL_ROWS_KEY]))
419
432
  pool[POOL_ROWS_KEY] = {};
420
433
  return pool[POOL_ROWS_KEY];
@@ -433,6 +446,77 @@ export function setEntryIn(config, id, entry) {
433
446
  configurable: true,
434
447
  });
435
448
  }
449
+ /**
450
+ * The credential epoch recorded for a dropped id (see
451
+ * `POOL_RETIRED_EPOCHS_KEY`), or undefined when none is. A value that is not
452
+ * a credential epoch counts as none.
453
+ */
454
+ export function retiredEpochIn(config, id) {
455
+ const pool = config[POOL_KEY];
456
+ if (!isRecord(pool))
457
+ return undefined;
458
+ const retired = pool[POOL_RETIRED_EPOCHS_KEY];
459
+ if (!isRecord(retired) || !Object.hasOwn(retired, id))
460
+ return undefined;
461
+ const epoch = retired[id];
462
+ return isCredentialEpoch(epoch) ? epoch : undefined;
463
+ }
464
+ /**
465
+ * The epoch a per-row entry claims, read without validating the rest of the
466
+ * entry, or undefined when it names none a reader would accept.
467
+ */
468
+ function entryEpochIn(config, id) {
469
+ const epoch = entryIn(config, id)?.credentialEpoch;
470
+ return isCredentialEpoch(epoch) ? epoch : undefined;
471
+ }
472
+ /**
473
+ * The credential epoch `add` gives a new row with this id: one past the
474
+ * highest epoch the id is known to have held, which is the epoch recorded
475
+ * when the store dropped it, or the epoch of an entry left behind by a writer
476
+ * that removed only its roster row; 1 for an id the pool never held.
477
+ *
478
+ * An attribution names a row by id and credential epoch (and identity), and
479
+ * an id is chosen by the plugin, so it is often the same one again (`main`).
480
+ * Were a re-added row to start at epoch 1 again, an attribution taken for the
481
+ * removed row's credential would match the new credential exactly, in this
482
+ * process or any other. Starting past every earlier epoch makes such an
483
+ * attribution fail as it does after a `replace`. The result may lie past the
484
+ * safe integers (an id whose last row was at `Number.MAX_SAFE_INTEGER`);
485
+ * `add` refuses such an id.
486
+ */
487
+ export function nextAddEpochIn(config, id) {
488
+ return (Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 0) + 1);
489
+ }
490
+ /**
491
+ * Records, in a config being written, the epochs of the ids it drops: for
492
+ * each, the epoch its entry claims (1 for a row without one, the epoch such a
493
+ * row is at), kept only when above what is already recorded, so the record
494
+ * for an id never goes down. Valid recorded values of other ids are kept; a
495
+ * record that is not an object, or a value in it that is not an epoch, says
496
+ * nothing and is replaced. An entry whose epoch cannot be read records 1.
497
+ */
498
+ export function retireEpochsIn(config, dropped) {
499
+ const ids = [...dropped];
500
+ if (ids.length === 0)
501
+ return;
502
+ const pool = ensurePool(config);
503
+ const previous = isRecord(pool[POOL_RETIRED_EPOCHS_KEY])
504
+ ? pool[POOL_RETIRED_EPOCHS_KEY]
505
+ : {};
506
+ const next = {};
507
+ const define = (id, epoch) => Object.defineProperty(next, id, {
508
+ value: epoch,
509
+ enumerable: true,
510
+ writable: true,
511
+ configurable: true,
512
+ });
513
+ for (const [id, epoch] of Object.entries(previous))
514
+ if (isCredentialEpoch(epoch))
515
+ define(id, epoch);
516
+ for (const id of ids)
517
+ define(id, Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 1));
518
+ pool[POOL_RETIRED_EPOCHS_KEY] = next;
519
+ }
436
520
  /**
437
521
  * Builds the rows of a ready pool from the files exactly as they are, without
438
522
  * looking at credential stamps (see `loadRows` for the rows every reader
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {