@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.
- package/dist/claustrum/enrollment.js +6 -2
- package/dist/dump/index.d.ts +2 -0
- package/dist/dump/index.js +11 -9
- package/dist/fs/atomic-write.d.ts +2 -0
- package/dist/fs/atomic-write.js +17 -11
- package/dist/fs/refresh-file-lock.js +13 -3
- package/dist/rpc/port-file.d.ts +2 -0
- package/dist/rpc/port-file.js +18 -12
- package/dist/rpc/rpc-server.d.ts +16 -3
- package/dist/rpc/rpc-server.js +66 -7
- package/package.json +1 -1
|
@@ -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
|
-
|
|
288
|
+
if (created)
|
|
289
|
+
await unlink(temporary).catch(() => { });
|
|
286
290
|
}
|
|
287
291
|
}
|
|
288
292
|
export async function readClaustrumEnrollmentStatus(paths, proposedName) {
|
package/dist/dump/index.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/dump/index.js
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/fs/atomic-write.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, rename, rm
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
83
|
+
await handle?.close().catch(() => { });
|
|
84
|
+
if (created)
|
|
85
|
+
await rm(tempPath, { force: true }).catch(() => { });
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
async function tryAcquire() {
|
package/dist/rpc/port-file.d.ts
CHANGED
|
@@ -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 {
|
package/dist/rpc/port-file.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readdir, readFile, rename, rmdir, unlink,
|
|
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
|
|
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}.${
|
|
80
|
+
const tmp = `${target}.${(options.stageName ?? randomUUID)()}.tmp`;
|
|
81
|
+
let handle;
|
|
82
|
+
let created = false;
|
|
81
83
|
try {
|
|
82
|
-
await
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
};
|
package/dist/rpc/rpc-server.d.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
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>;
|
package/dist/rpc/rpc-server.js
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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": {
|