@cortexkit/common-auth 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
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": {