@cortexkit/common-auth 0.9.0 → 0.9.2

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.
@@ -271,18 +271,22 @@ async function writeStateAtomic(path, state) {
271
271
  }
272
272
  const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
273
273
  let descriptor;
274
+ let created = false;
274
275
  try {
275
276
  descriptor = await open(temporary, 'wx', 0o600);
277
+ created = true;
276
278
  await descriptor.writeFile(bytes, 'utf8');
279
+ await descriptor.chmod(0o600);
277
280
  await descriptor.sync();
278
281
  await descriptor.close();
279
282
  descriptor = undefined;
280
- await chmod(temporary, 0o600);
281
283
  await rename(temporary, path);
284
+ created = false;
282
285
  }
283
286
  finally {
284
287
  await descriptor?.close().catch(() => { });
285
- await unlink(temporary).catch(() => { });
288
+ if (created)
289
+ await unlink(temporary).catch(() => { });
286
290
  }
287
291
  }
288
292
  export async function readClaustrumEnrollmentStatus(paths, proposedName) {
@@ -59,6 +59,8 @@ export interface DumpOptions {
59
59
  * files that were written stay.
60
60
  */
61
61
  cleanupFailedDumps?: boolean;
62
+ /** Test seam for forcing response staging-name collisions. */
63
+ stageName?: () => string;
62
64
  }
63
65
  export interface DumpInput {
64
66
  /** The session the request belongs to; missing means `session-unknown`. */
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { chmod, lstat, mkdir, readdir, readFile, rename, unlink, writeFile, } from 'node:fs/promises';
2
+ import { chmod, lstat, mkdir, open, readdir, readFile, rename, unlink, writeFile, } from 'node:fs/promises';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { createRedactor } from '../logger/redact.js';
5
5
  const PREVIOUS_BODY_LIMIT = 100;
@@ -132,21 +132,23 @@ export async function sweepDumpDirectory(options) {
132
132
  * reader never sees half a file and a symlink planted at a predictable
133
133
  * staging name is never followed.
134
134
  */
135
- async function replaceFile(path, text) {
135
+ async function replaceFile(path, text, stageName) {
136
136
  for (let attempt = 0;; attempt += 1) {
137
- const staging = `${path}.${randomBytes(12).toString('hex')}.partial`;
137
+ const staging = `${path}.${stageName ? stageName() : randomBytes(12).toString('hex')}.partial`;
138
138
  let created = false;
139
+ let handle;
139
140
  try {
140
- await writeFile(staging, text, {
141
- encoding: 'utf8',
142
- mode: 0o600,
143
- flag: 'wx',
144
- });
141
+ handle = await open(staging, 'wx', 0o600);
145
142
  created = true;
143
+ await handle.writeFile(text, 'utf8');
144
+ await handle.chmod(0o600);
145
+ await handle.close();
146
+ handle = undefined;
146
147
  await rename(staging, path);
147
148
  return;
148
149
  }
149
150
  catch (error) {
151
+ await handle?.close().catch(() => { });
150
152
  if (created)
151
153
  await unlink(staging).catch(() => { });
152
154
  const code = error.code;
@@ -500,7 +502,7 @@ export function createDumper(options) {
500
502
  complete: input.complete ?? true,
501
503
  });
502
504
  try {
503
- await replaceFile(result.responseFile, `${JSON.stringify(artifact, null, 2)}\n`);
505
+ await replaceFile(result.responseFile, `${JSON.stringify(artifact, null, 2)}\n`, options.stageName);
504
506
  return result.responseFile;
505
507
  }
506
508
  catch (error) {
@@ -1,5 +1,7 @@
1
1
  export interface AtomicWriteOptions {
2
2
  serialize?: (value: unknown) => string;
3
3
  beforeRename?: () => Promise<void>;
4
+ /** Test seam for forcing staging-name collisions; defaults to randomUUID. */
5
+ stageName?: () => string;
4
6
  }
5
7
  export declare function writeJsonAtomic(path: string, value: unknown, options?: AtomicWriteOptions): Promise<void>;
@@ -1,22 +1,28 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, open, rename, rm } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  export async function writeJsonAtomic(path, value, options = {}) {
5
5
  await mkdir(dirname(path), { recursive: true });
6
- const tempPath = `${path}.${randomUUID()}.tmp`;
6
+ const tempPath = `${path}.${(options.stageName ?? randomUUID)()}.tmp`;
7
+ let handle;
8
+ let created = false;
7
9
  try {
8
- await writeFile(tempPath, options.serialize
10
+ handle = await open(tempPath, 'wx', 0o600);
11
+ created = true;
12
+ await handle.writeFile(options.serialize
9
13
  ? options.serialize(value)
10
- : `${JSON.stringify(value, null, 2)}\n`, {
11
- encoding: 'utf8',
12
- mode: 0o600,
13
- });
14
+ : `${JSON.stringify(value, null, 2)}\n`, 'utf8');
15
+ await handle.chmod(0o600);
16
+ await handle.close();
17
+ handle = undefined;
14
18
  await options.beforeRename?.();
15
19
  await rename(tempPath, path);
20
+ created = false;
16
21
  }
17
- catch (error) {
18
- // A failed write can leave partial staging bytes just like a failed rename.
19
- await rm(tempPath, { force: true }).catch(() => { });
20
- throw error;
22
+ finally {
23
+ await handle?.close().catch(() => { });
24
+ // A failed write can leave partial bytes, but a collision is not ours to remove.
25
+ if (created)
26
+ await rm(tempPath, { force: true }).catch(() => { });
21
27
  }
22
28
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { LockOwnershipError, lockPathFor } from './with-lock.js';
5
5
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
@@ -67,12 +67,22 @@ export async function acquireRefreshFileLock(options) {
67
67
  async function writeOwner() {
68
68
  // Readers must see a complete lease, even while renewal is writing.
69
69
  const tempPath = `${lockPath}.${randomUUID()}.tmp`;
70
+ let handle;
71
+ let created = false;
70
72
  try {
71
- await writeFile(tempPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
73
+ handle = await open(tempPath, 'wx', 0o600);
74
+ created = true;
75
+ await handle.writeFile(`${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, 'utf8');
76
+ await handle.chmod(0o600);
77
+ await handle.close();
78
+ handle = undefined;
72
79
  await rename(tempPath, lockPath);
80
+ created = false;
73
81
  }
74
82
  finally {
75
- await rm(tempPath, { force: true }).catch(() => { });
83
+ await handle?.close().catch(() => { });
84
+ if (created)
85
+ await rm(tempPath, { force: true }).catch(() => { });
76
86
  }
77
87
  }
78
88
  async function tryAcquire() {
@@ -15,6 +15,8 @@ export declare function writePortFile(dir: string, entry: {
15
15
  }, options?: {
16
16
  secureDir?: boolean;
17
17
  beforeWrite?: () => void | Promise<void>;
18
+ /** Test seam for forcing staging-name collisions; defaults to randomUUID. */
19
+ stageName?: () => string;
18
20
  }): Promise<string>;
19
21
  export declare function sweepRpcState(root: string, activeDir: string, isManagedDir: (name: string) => boolean, log?: RpcLogChannel): Promise<void>;
20
22
  export interface DiscoverPortFileOptions {
@@ -1,5 +1,5 @@
1
- import { createHash } from 'node:crypto';
2
- import { chmod, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile, } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, open, readdir, readFile, rename, rmdir, 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
@@ -63,7 +63,7 @@ async function removeCorruptPortFile(portFile, log) {
63
63
  }
64
64
  export async function writePortFile(dir, entry, options = {}) {
65
65
  // The directory can be removed by another project's sweep between our
66
- // mkdir and the first writeFile/rename, so the whole create-then-rename
66
+ // mkdir and the first open/rename, so the whole create-then-rename
67
67
  // unit is retried once on ENOENT. The retry recreates the directory; a
68
68
  // persistent ENOENT (e.g. permission, read-only parent) will surface on
69
69
  // the second attempt — failing fast beats an unbounded loop.
@@ -77,18 +77,24 @@ export async function writePortFile(dir, entry, options = {}) {
77
77
  await options.beforeWrite?.();
78
78
  const full = { ...entry, startedAt: Date.now() };
79
79
  const target = join(dir, `port-${entry.pid}.json`);
80
- const tmp = `${target}.${process.pid}.tmp`;
80
+ const tmp = `${target}.${(options.stageName ?? randomUUID)()}.tmp`;
81
+ let handle;
82
+ let created = false;
81
83
  try {
82
- await writeFile(tmp, JSON.stringify(full), {
83
- encoding: 'utf8',
84
- mode: 0o600,
85
- });
84
+ handle = await open(tmp, 'wx', 0o600);
85
+ created = true;
86
+ await handle.writeFile(JSON.stringify(full), 'utf8');
87
+ await handle.chmod(0o600);
88
+ await handle.close();
89
+ handle = undefined;
86
90
  await rename(tmp, target);
91
+ created = false;
87
92
  }
88
- catch (error) {
89
- // The staged file carries the server token; never leave it behind.
90
- await unlink(tmp).catch(() => { });
91
- throw error;
93
+ finally {
94
+ await handle?.close().catch(() => { });
95
+ // Remove token-bearing staging bytes only if this call created them.
96
+ if (created)
97
+ await unlink(tmp).catch(() => { });
92
98
  }
93
99
  return target;
94
100
  };
@@ -12,6 +12,12 @@ export interface RpcServerOptions {
12
12
  secureDir?: boolean;
13
13
  sweepRoot?: string;
14
14
  drain: (lastReceivedId: number, sessionId?: string) => RpcNotification[];
15
+ drainAsync?: never;
16
+ /** Replace default cursor parsing with plugin policy over the raw JSON value. */
17
+ parsePending?: (params: unknown) => {
18
+ lastReceivedId: number;
19
+ sessionId?: string;
20
+ };
15
21
  apply: (request: ApplyRequest) => Promise<ApplyResult>;
16
22
  /**
17
23
  * Refuse a `pending-notifications` drain whose `sessionId` is absent, not a
@@ -32,14 +38,21 @@ export interface RpcServerOptions {
32
38
  */
33
39
  applyDeadlineMs?: number;
34
40
  }
41
+ /** Async-only options keep the existing synchronous drain type unchanged. */
42
+ export interface RpcServerAsyncOptions extends Omit<RpcServerOptions, 'drain' | 'drainAsync'> {
43
+ drain?: never;
44
+ drainAsync: (lastReceivedId: number, sessionId?: string) => Promise<RpcNotification[]>;
45
+ }
35
46
  /**
36
- * Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
47
+ * Thrown by an `apply`, `drain`, or `parsePending` handler to refuse a request with a 4xx
37
48
  * 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.
49
+ * be written for the client and never quote a credential. Any other error an
50
+ * apply/drain handler throws answers 500 with a fixed code; other parser
51
+ * errors answer 400 invalid params. Async drain failures always answer 500.
40
52
  */
41
53
  export declare class RpcRequestError extends Error {
42
54
  readonly status: number;
43
55
  constructor(status: number, message: string);
44
56
  }
57
+ export declare function startRpcServer(options: RpcServerAsyncOptions): Promise<RpcServerHandle>;
45
58
  export declare function startRpcServer(options: RpcServerOptions): Promise<RpcServerHandle>;
@@ -5,10 +5,11 @@ import { join } from 'node:path';
5
5
  import { isSessionId, } from './notifications.js';
6
6
  import { sweepRpcState, writePortFile } from './port-file.js';
7
7
  /**
8
- * Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
8
+ * Thrown by an `apply`, `drain`, or `parsePending` handler to refuse a request with a 4xx
9
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.
10
+ * be written for the client and never quote a credential. Any other error an
11
+ * apply/drain handler throws answers 500 with a fixed code; other parser
12
+ * errors answer 400 invalid params. Async drain failures always answer 500.
12
13
  */
13
14
  export class RpcRequestError extends Error {
14
15
  status;
@@ -78,6 +79,9 @@ function tokenOk(header, token) {
78
79
  return got.length === want.length && timingSafeEqual(got, want);
79
80
  }
80
81
  export async function startRpcServer(options) {
82
+ if ((typeof options.drain === 'function') ===
83
+ (typeof options.drainAsync === 'function'))
84
+ throw new TypeError('exactly one of drain or drainAsync must be provided');
81
85
  const log = options.log ?? { warn() { }, debug() { } };
82
86
  const token = randomBytes(32).toString('hex');
83
87
  // The receipt timeout limits request delivery, not handler execution.
@@ -85,12 +89,17 @@ export async function startRpcServer(options) {
85
89
  const handlerTimeoutMs = options.timeoutMs ?? 90_000;
86
90
  const receiptTimeoutMs = options.receiptTimeoutMs ?? 2_000;
87
91
  let warnedMissingNotificationSession = false;
92
+ const connections = new Set();
88
93
  const server = createServer((req, res) => {
89
94
  req.setTimeout(handlerTimeoutMs, () => {
90
95
  req.socket.destroy();
91
96
  });
92
97
  void dispatch(req, res);
93
98
  });
99
+ server.on('connection', (socket) => {
100
+ connections.add(socket);
101
+ socket.once('close', () => connections.delete(socket));
102
+ });
94
103
  server.requestTimeout = receiptTimeoutMs;
95
104
  server.headersTimeout = receiptTimeoutMs;
96
105
  async function dispatch(req, res) {
@@ -132,16 +141,59 @@ export async function startRpcServer(options) {
132
141
  return json(400, { error: 'invalid json' });
133
142
  }
134
143
  if (method === 'pending-notifications') {
135
- if (options.requireSession === true && !isSessionId(params.sessionId))
144
+ let pending;
145
+ try {
146
+ if (options.parsePending) {
147
+ pending = options.parsePending(params);
148
+ }
149
+ else {
150
+ if (!params || typeof params !== 'object' || Array.isArray(params))
151
+ throw new Error('invalid params');
152
+ const raw = params;
153
+ if (options.requireSession === true && !isSessionId(raw.sessionId))
154
+ return json(400, { error: 'session required' });
155
+ if (('lastReceivedId' in raw &&
156
+ (typeof raw.lastReceivedId !== 'number' ||
157
+ !Number.isSafeInteger(raw.lastReceivedId) ||
158
+ raw.lastReceivedId < 0)) ||
159
+ ('sessionId' in raw && typeof raw.sessionId !== 'string'))
160
+ throw new Error('invalid params');
161
+ pending = {
162
+ lastReceivedId: Number(raw.lastReceivedId ?? 0),
163
+ sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : undefined,
164
+ };
165
+ }
166
+ }
167
+ catch (error) {
168
+ if (error instanceof RpcRequestError)
169
+ throw error;
170
+ return json(400, { error: 'invalid params' });
171
+ }
172
+ const { lastReceivedId, sessionId } = pending;
173
+ if (options.requireSession === true && !isSessionId(sessionId))
136
174
  return json(400, { error: 'session required' });
137
- const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
138
175
  if (sessionId === undefined && !warnedMissingNotificationSession) {
139
176
  warnedMissingNotificationSession = true;
140
177
  log.warn('rpc notification drain missing session id', {
141
178
  pid: process.pid,
142
179
  });
143
180
  }
144
- const messages = options.drain(Number(params.lastReceivedId ?? 0), sessionId);
181
+ let messages;
182
+ if (options.drainAsync) {
183
+ try {
184
+ messages = await options.drainAsync(lastReceivedId, sessionId);
185
+ }
186
+ catch (error) {
187
+ log.warn('rpc notification drain failed', {
188
+ pid: process.pid,
189
+ error: error instanceof Error ? error.message : String(error),
190
+ });
191
+ return json(500, { error: 'drain failed' });
192
+ }
193
+ }
194
+ else {
195
+ messages = options.drain?.(lastReceivedId, sessionId);
196
+ }
145
197
  return json(200, { messages });
146
198
  }
147
199
  if (method === 'apply') {
@@ -214,7 +266,14 @@ export async function startRpcServer(options) {
214
266
  port,
215
267
  token,
216
268
  async stop() {
217
- await new Promise((resolve) => server.close(() => resolve()));
269
+ await new Promise((resolve) => {
270
+ server.close(() => resolve());
271
+ // Stop accepting connections before ending requests that may never finish.
272
+ server.closeAllConnections?.();
273
+ // Bun exposes closeAllConnections but leaves partial requests open.
274
+ for (const socket of connections)
275
+ socket.destroy();
276
+ });
218
277
  const portFile = join(options.dir, `port-${process.pid}.json`);
219
278
  const current = await readFile(portFile, 'utf8')
220
279
  .then((raw) => JSON.parse(raw))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {