@cortexkit/common-auth 0.2.4 → 0.2.5

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,2 +1,292 @@
1
- // Placeholder so the export resolves until this subpath is built.
2
- export {};
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { createRedactor } from '../logger/redact.js';
5
+ const PREVIOUS_BODY_LIMIT = 100;
6
+ const UNKNOWN_SESSION = 'session-unknown';
7
+ function shortSession(session) {
8
+ return session.length <= 16 ? session : `${session.slice(0, 12)}…`;
9
+ }
10
+ function fileSegment(value, fallback) {
11
+ const normalized = value
12
+ .trim()
13
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
14
+ .replace(/^-+|-+$/g, '');
15
+ if (!normalized)
16
+ return fallback;
17
+ return normalized.slice(0, 80);
18
+ }
19
+ function hashText(value) {
20
+ return createHash('sha256').update(value).digest('hex');
21
+ }
22
+ function normalizeKey(key) {
23
+ return key.toLowerCase().replace(/[-_]/g, '');
24
+ }
25
+ function isRecord(value) {
26
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
27
+ }
28
+ /**
29
+ * Where two bodies first and last differ, in string offsets into the dumped
30
+ * (redacted) text. Offsets count UTF-16 units, as the plugins' cache analysis
31
+ * always has.
32
+ */
33
+ export function diffDumpBodies(previous, current) {
34
+ if (previous === undefined)
35
+ return null;
36
+ if (previous === current) {
37
+ return {
38
+ changed: false,
39
+ firstByte: -1,
40
+ lastPreviousByte: -1,
41
+ lastCurrentByte: -1,
42
+ previousBytes: previous.length,
43
+ currentBytes: current.length,
44
+ };
45
+ }
46
+ let firstByte = 0;
47
+ while (firstByte < previous.length &&
48
+ firstByte < current.length &&
49
+ previous[firstByte] === current[firstByte]) {
50
+ firstByte++;
51
+ }
52
+ let previousTail = previous.length - 1;
53
+ let currentTail = current.length - 1;
54
+ while (previousTail >= firstByte &&
55
+ currentTail >= firstByte &&
56
+ previous[previousTail] === current[currentTail]) {
57
+ previousTail--;
58
+ currentTail--;
59
+ }
60
+ return {
61
+ changed: true,
62
+ firstByte,
63
+ lastPreviousByte: previousTail,
64
+ lastCurrentByte: currentTail,
65
+ changedPreviousBytes: previousTail - firstByte + 1,
66
+ changedCurrentBytes: currentTail - firstByte + 1,
67
+ previousBytes: previous.length,
68
+ currentBytes: current.length,
69
+ };
70
+ }
71
+ export function createDumper(options) {
72
+ let enabled = options.enabled === true;
73
+ let counter = 0;
74
+ const pid = options.pid ?? process.pid;
75
+ const now = options.now ?? Date.now;
76
+ const log = options.logger;
77
+ const extraKeys = new Set((options.secretKeys ?? []).map(normalizeKey));
78
+ const redactor = createRedactor({
79
+ extraSecretKeys: (normalized) => extraKeys.has(normalized),
80
+ extraValuePatterns: [...(options.secretPatterns ?? [])],
81
+ });
82
+ const schemaKeys = new Set(options.schemaKeys ?? ['tools']);
83
+ /**
84
+ * Last dumped (redacted) body per baseline key, in memory for this process.
85
+ * A key missing here, on its first dump or after eviction, is looked up on
86
+ * disk.
87
+ */
88
+ const previousBodies = new Map();
89
+ const resolveDir = () => typeof options.dir === 'function' ? options.dir() : options.dir;
90
+ /** The mask for a secret key name, or undefined for an ordinary key. */
91
+ function maskFor(key) {
92
+ // The shared redactor masks a secret key whatever its value, so probing it
93
+ // with null answers "is this key a secret" without duplicating its rules.
94
+ const masked = redactor.redact({ [key]: null })[key];
95
+ return masked === null ? undefined : masked;
96
+ }
97
+ /**
98
+ * Redact by key name and scrub token-shaped strings, except below a schema
99
+ * key, where only strings are scrubbed: a tool parameter named `api_key`
100
+ * declares what the tool accepts, and masking that node would leave a
101
+ * schema that no longer parses. Key order is kept because dumps are diffed
102
+ * against each other.
103
+ */
104
+ function scrub(value) {
105
+ if (Array.isArray(value))
106
+ return value.map(scrub);
107
+ if (isRecord(value)) {
108
+ const out = {};
109
+ for (const [key, entry] of Object.entries(value)) {
110
+ const mask = maskFor(key);
111
+ if (schemaKeys.has(key))
112
+ out[key] = redactor.redactStrings(entry);
113
+ else if (mask !== undefined)
114
+ out[key] = mask;
115
+ else
116
+ out[key] = scrub(entry);
117
+ }
118
+ return out;
119
+ }
120
+ return redactor.redactStrings(value);
121
+ }
122
+ function scrubBody(bodyText) {
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(bodyText);
126
+ }
127
+ catch {
128
+ // Not JSON: no keys to judge, but token-shaped strings still go.
129
+ return {
130
+ text: redactor.redactStrings(bodyText),
131
+ parsed: undefined,
132
+ };
133
+ }
134
+ const scrubbed = scrub(parsed);
135
+ const scrubbedText = JSON.stringify(scrubbed);
136
+ // Nothing redacted: keep the original bytes, whitespace included, so the
137
+ // dump is exactly what was sent.
138
+ const text = JSON.stringify(parsed) === scrubbedText ? bodyText : scrubbedText;
139
+ return { text, parsed: isRecord(scrubbed) ? scrubbed : undefined };
140
+ }
141
+ /**
142
+ * The newest earlier dump of this baseline key, read back from the dump
143
+ * directory. The in-memory baseline dies with the process, and the first
144
+ * request after a restart is the one most likely to break the prompt cache,
145
+ * so it is the one whose diff matters most. Reading the dumps themselves
146
+ * needs no extra state, and clearing the directory clears the baseline with
147
+ * it. Any failure is no baseline.
148
+ */
149
+ async function recoverBaseline(dir, session, channel, baselineKey) {
150
+ const marker = `-${fileSegment(session, UNKNOWN_SESSION)}-${fileSegment(channel, 'channel')}`;
151
+ let names;
152
+ try {
153
+ names = await readdir(dir);
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ // Names start with an ISO timestamp, so reverse lexicographic order is
159
+ // newest first. The metadata's key decides the match: a filename segment
160
+ // is truncated and sanitised, so two sessions can share one.
161
+ const candidates = names
162
+ .filter((name) => name.endsWith('.meta.json') && name.includes(marker))
163
+ .sort()
164
+ .reverse();
165
+ for (const name of candidates) {
166
+ try {
167
+ const metadata = JSON.parse(await readFile(join(dir, name), 'utf8'));
168
+ if (metadata?.baselineKey !== baselineKey)
169
+ continue;
170
+ return await readFile(join(dir, name.replace(/\.meta\.json$/, '.body.json')), 'utf8');
171
+ }
172
+ catch {
173
+ // A file removed or half-written by another process: try the next.
174
+ }
175
+ }
176
+ return undefined;
177
+ }
178
+ function remember(key, text) {
179
+ if (!previousBodies.has(key)) {
180
+ while (previousBodies.size >= PREVIOUS_BODY_LIMIT) {
181
+ const oldest = previousBodies.keys().next().value;
182
+ if (oldest === undefined)
183
+ break;
184
+ previousBodies.delete(oldest);
185
+ }
186
+ }
187
+ previousBodies.set(key, text);
188
+ }
189
+ async function dump(input) {
190
+ if (!enabled)
191
+ return undefined;
192
+ counter++;
193
+ const session = input.session?.trim() || UNKNOWN_SESSION;
194
+ const dir = resolveDir();
195
+ const createdAt = new Date(now()).toISOString();
196
+ // The pid makes names unique across processes: each process starts its
197
+ // counter at zero, so two processes dumping the same session in the same
198
+ // millisecond into a shared directory would otherwise pick the same name.
199
+ const id = [
200
+ createdAt.replace(/[:.]/g, '-'),
201
+ String(pid),
202
+ String(counter).padStart(6, '0'),
203
+ fileSegment(session, UNKNOWN_SESSION),
204
+ fileSegment(input.channel, 'channel'),
205
+ ...(input.phase ? [fileSegment(input.phase, 'phase')] : []),
206
+ ].join('-');
207
+ const prefix = join(dir, id);
208
+ const files = {
209
+ body: `${prefix}.body.json`,
210
+ metadata: `${prefix}.meta.json`,
211
+ request: `${prefix}.request.json`,
212
+ };
213
+ // Hashed so the metadata identifies the baseline without spelling out the
214
+ // full session id.
215
+ const baselineKey = hashText(`${input.channel}\u0000${session}`);
216
+ try {
217
+ await mkdir(dir, { recursive: true, mode: 0o700 });
218
+ // mkdir's mode does not apply to a directory that already exists.
219
+ await chmod(dir, 0o700);
220
+ let previous = previousBodies.get(baselineKey);
221
+ let baselineSource = previous === undefined ? undefined : 'memory';
222
+ if (previous === undefined) {
223
+ previous = await recoverBaseline(dir, session, input.channel, baselineKey);
224
+ if (previous !== undefined)
225
+ baselineSource = 'disk';
226
+ }
227
+ const body = scrubBody(input.bodyText);
228
+ const metadata = {
229
+ id,
230
+ createdAt,
231
+ pid,
232
+ session: shortSession(session),
233
+ channel: input.channel,
234
+ ...(input.phase ? { phase: input.phase } : {}),
235
+ accountId: input.accountId,
236
+ status: input.status,
237
+ error: input.error === undefined
238
+ ? undefined
239
+ : redactor.redactStrings(input.error),
240
+ baselineKey,
241
+ bodyBytes: Buffer.byteLength(input.bodyText, 'utf8'),
242
+ bodyHash: hashText(input.bodyText),
243
+ // Diffed on the redacted text that lands in the body file, so offsets
244
+ // index the file an operator can open, and a baseline read back from
245
+ // disk (also redacted) compares like with like.
246
+ diff: diffDumpBodies(previous, body.text),
247
+ baselineSource,
248
+ body: body.parsed
249
+ ? { parseable: true, ...options.summarize?.(body.parsed) }
250
+ : { parseable: false },
251
+ ...(input.meta ? { extra: scrub(input.meta) } : {}),
252
+ files,
253
+ };
254
+ const request = scrub({
255
+ url: input.url,
256
+ method: input.method,
257
+ accountId: input.accountId,
258
+ headers: input.headers === undefined
259
+ ? undefined
260
+ : Object.fromEntries(new Headers(input.headers).entries()),
261
+ });
262
+ // `wx`: a name collision fails loudly instead of overwriting a dump.
263
+ const write = (path, text) => writeFile(path, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
264
+ await Promise.all([
265
+ write(files.body, body.text),
266
+ write(files.metadata, `${JSON.stringify(metadata, null, 2)}\n`),
267
+ write(files.request, `${JSON.stringify(request, null, 2)}\n`),
268
+ ]);
269
+ remember(baselineKey, body.text);
270
+ log?.debug('dumped request', {
271
+ id,
272
+ session: shortSession(session),
273
+ body: files.body,
274
+ });
275
+ return { id, files };
276
+ }
277
+ catch (error) {
278
+ log?.warn('request dump failed', {
279
+ session: shortSession(session),
280
+ error: error instanceof Error ? error.message : String(error),
281
+ });
282
+ return undefined;
283
+ }
284
+ }
285
+ return {
286
+ isEnabled: () => enabled,
287
+ setEnabled: (value) => {
288
+ enabled = value;
289
+ },
290
+ dump,
291
+ };
292
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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": {