@cortexkit/common-auth 0.6.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;
@@ -22,6 +22,11 @@ export declare function countUnknownIdentityRows(rows: readonly PoolRow[]): numb
22
22
  * without an entry gets one at epoch 1. Nothing is ever deleted.
23
23
  */
24
24
  export declare function disableIn(tx: RowEditor, id: string, reason: string): void;
25
+ /**
26
+ * Marks a row enabled: `enabled: true` in the roster row and no
27
+ * `disabledReason` in its entry. A row without an entry is not given one.
28
+ */
29
+ export declare function enableIn(tx: RowEditor, id: string): void;
25
30
  /**
26
31
  * Two enabled OAuth rows with one wire identity are the same account: the
27
32
  * earlier row in roster order stays enabled and every later one is disabled