@cortexkit/common-auth 0.3.0 → 0.4.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.
Files changed (59) hide show
  1. package/dist/cachekeep/manager.d.ts +18 -6
  2. package/dist/cachekeep/manager.js +40 -10
  3. package/dist/claustrum/consumer.d.ts +13 -4
  4. package/dist/claustrum/consumer.js +11 -3
  5. package/dist/claustrum/custody.d.ts +47 -6
  6. package/dist/claustrum/custody.js +37 -7
  7. package/dist/claustrum/errors.d.ts +1 -1
  8. package/dist/claustrum/index.d.ts +3 -3
  9. package/dist/claustrum/index.js +2 -2
  10. package/dist/claustrum/interlock.d.ts +15 -17
  11. package/dist/claustrum/interlock.js +19 -26
  12. package/dist/claustrum/roster.d.ts +96 -6
  13. package/dist/claustrum/roster.js +209 -44
  14. package/dist/commands/builtins.d.ts +1 -1
  15. package/dist/commands/builtins.js +6 -1
  16. package/dist/commands/index.d.ts +2 -2
  17. package/dist/commands/index.js +1 -1
  18. package/dist/commands/menu.d.ts +8 -0
  19. package/dist/commands/menu.js +34 -13
  20. package/dist/commands/model.d.ts +9 -0
  21. package/dist/commands/seam.d.ts +40 -4
  22. package/dist/commands/seam.js +132 -19
  23. package/dist/dump/index.d.ts +94 -0
  24. package/dist/dump/index.js +236 -9
  25. package/dist/logger/engine.d.ts +52 -17
  26. package/dist/logger/engine.js +178 -135
  27. package/dist/logger/index.d.ts +2 -2
  28. package/dist/logger/index.js +1 -1
  29. package/dist/opencode2/install.d.ts +8 -3
  30. package/dist/opencode2/install.js +18 -9
  31. package/dist/opencode2/types.d.ts +22 -3
  32. package/dist/quota/projection.d.ts +11 -4
  33. package/dist/quota/projection.js +11 -4
  34. package/dist/routing/admission.js +3 -1
  35. package/dist/routing/index.d.ts +2 -2
  36. package/dist/routing/index.js +1 -1
  37. package/dist/routing/sticky.d.ts +19 -6
  38. package/dist/routing/sticky.js +34 -23
  39. package/dist/rpc/notifications.d.ts +20 -0
  40. package/dist/rpc/notifications.js +21 -0
  41. package/dist/rpc/rpc-server.d.ts +9 -1
  42. package/dist/rpc/rpc-server.js +8 -1
  43. package/dist/sidebar-file/index.d.ts +1 -1
  44. package/dist/sidebar-file/sidebar-file.d.ts +50 -2
  45. package/dist/sidebar-file/sidebar-file.js +92 -21
  46. package/dist/store/attribution.js +11 -2
  47. package/dist/store/errors.d.ts +6 -3
  48. package/dist/store/identity.d.ts +13 -4
  49. package/dist/store/mutate.d.ts +23 -3
  50. package/dist/store/mutate.js +43 -26
  51. package/dist/store/pool.d.ts +8 -1
  52. package/dist/store/pool.js +7 -2
  53. package/dist/store/rows.d.ts +17 -4
  54. package/dist/store/rows.js +82 -33
  55. package/dist/store/schema.d.ts +57 -4
  56. package/dist/store/schema.js +100 -7
  57. package/dist/store/torn.d.ts +29 -0
  58. package/dist/store/torn.js +113 -0
  59. package/package.json +1 -1
@@ -1,9 +1,161 @@
1
- import { createHash } from 'node:crypto';
2
- import { chmod, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, readdir, readFile, rename, unlink, writeFile, } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
4
  import { createRedactor } from '../logger/redact.js';
5
5
  const PREVIOUS_BODY_LIMIT = 100;
6
6
  const UNKNOWN_SESSION = 'session-unknown';
7
+ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
8
+ const DEFAULT_SWEEP_MIN_AGE_MS = 60 * 1000;
9
+ const DEFAULT_PARTIAL_STALE_MS = 10 * 60 * 1000;
10
+ /**
11
+ * The suffixes of the files one dump writes. A dump's files share the name
12
+ * before the suffix, which is how the sweep groups them.
13
+ */
14
+ const ARTIFACT_SUFFIX = /\.(body|meta|request|response)\.json$/;
15
+ /**
16
+ * A dump id: the ISO time with `:` and `.` replaced, the pid, a counter of
17
+ * at least six digits, then the sanitised session, channel and phase. Only
18
+ * names of this shape are swept, so a plugin may point dumps at a directory
19
+ * that also holds other files.
20
+ */
21
+ const ARTIFACT_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-\d{6,}-[a-zA-Z0-9._-]+$/;
22
+ /** A response staging file: the final name, a random nonce, `.partial`. */
23
+ const PARTIAL_SUFFIX = /\.[a-f0-9]{24}\.partial$/;
24
+ function artifactGroup(name) {
25
+ const stem = name.replace(ARTIFACT_SUFFIX, '');
26
+ if (stem === name || !ARTIFACT_ID.test(stem))
27
+ return undefined;
28
+ return stem;
29
+ }
30
+ function isPartialName(name) {
31
+ const finalName = name.replace(PARTIAL_SUFFIX, '');
32
+ return finalName !== name && artifactGroup(finalName) !== undefined;
33
+ }
34
+ /**
35
+ * Hold the dump artifacts in `dir` to `maxBytes`. Only file names a dumper
36
+ * writes are counted or removed, so unrelated files in a directory the user
37
+ * chose survive. A dump's files are evicted together, oldest dump first by
38
+ * its newest file, because a body without its metadata (or the reverse) is no
39
+ * use to anyone. Dumps younger than `minAgeMs`, protected paths and symlinks
40
+ * are kept, and a symlinked directory is refused outright. Response staging
41
+ * files left by a crash are reclaimed once stale, even under the cap.
42
+ * Best-effort: a failure removes less, and never throws.
43
+ */
44
+ export async function sweepDumpDirectory(options) {
45
+ const { dir, maxBytes } = options;
46
+ const now = options.now ?? Date.now();
47
+ const minAgeMs = options.minAgeMs ?? DEFAULT_SWEEP_MIN_AGE_MS;
48
+ const partialStaleMs = options.partialStaleMs ?? DEFAULT_PARTIAL_STALE_MS;
49
+ const empty = { removed: 0, freedBytes: 0 };
50
+ if (!(maxBytes > 0))
51
+ return empty;
52
+ try {
53
+ if ((await lstat(dir)).isSymbolicLink())
54
+ return empty;
55
+ const protectedPaths = new Set((options.protectedPaths ?? []).map((path) => resolve(path)));
56
+ const entries = await readdir(dir, { withFileTypes: true });
57
+ const files = [];
58
+ await Promise.all(entries.map(async (entry) => {
59
+ if (!entry.isFile())
60
+ return;
61
+ const partial = isPartialName(entry.name);
62
+ const group = partial ? undefined : artifactGroup(entry.name);
63
+ if (!partial && group === undefined)
64
+ return;
65
+ const path = join(dir, entry.name);
66
+ try {
67
+ const stats = await lstat(path);
68
+ if (!stats.isFile())
69
+ return;
70
+ files.push({ path, size: stats.size, mtimeMs: stats.mtimeMs, group });
71
+ }
72
+ catch {
73
+ // Another process removed it between the listing and the stat.
74
+ }
75
+ }));
76
+ let total = files.reduce((sum, file) => sum + file.size, 0);
77
+ let removed = 0;
78
+ let freedBytes = 0;
79
+ const remove = async (file) => {
80
+ try {
81
+ await unlink(file.path);
82
+ total -= file.size;
83
+ freedBytes += file.size;
84
+ removed += 1;
85
+ }
86
+ catch {
87
+ // Best-effort: what cannot be removed now is tried again next sweep.
88
+ }
89
+ };
90
+ // A staging file is never a usable dump, so a stale one goes whatever
91
+ // the total; a fresh one may still be renamed into place.
92
+ for (const file of files) {
93
+ if (file.group !== undefined)
94
+ continue;
95
+ if (protectedPaths.has(resolve(file.path)))
96
+ continue;
97
+ if (now - file.mtimeMs < partialStaleMs)
98
+ continue;
99
+ await remove(file);
100
+ }
101
+ const groups = new Map();
102
+ for (const file of files) {
103
+ if (file.group === undefined)
104
+ continue;
105
+ const group = groups.get(file.group) ?? { files: [], newest: 0 };
106
+ group.files.push(file);
107
+ group.newest = Math.max(group.newest, file.mtimeMs);
108
+ groups.set(file.group, group);
109
+ }
110
+ const oldestFirst = [...groups.entries()].sort(([leftName, left], [rightName, right]) => left.newest - right.newest || leftName.localeCompare(rightName));
111
+ for (const [, group] of oldestFirst) {
112
+ if (total <= maxBytes)
113
+ break;
114
+ if (now - group.newest < minAgeMs)
115
+ continue;
116
+ if (group.files.some((file) => protectedPaths.has(resolve(file.path))))
117
+ continue;
118
+ for (const file of group.files)
119
+ await remove(file);
120
+ }
121
+ if (removed > 0) {
122
+ options.logger?.debug('removed old dump files', { removed, freedBytes });
123
+ }
124
+ return { removed, freedBytes };
125
+ }
126
+ catch {
127
+ return empty;
128
+ }
129
+ }
130
+ /**
131
+ * Replace `path` through a staging file created exclusively next to it, so a
132
+ * reader never sees half a file and a symlink planted at a predictable
133
+ * staging name is never followed.
134
+ */
135
+ async function replaceFile(path, text) {
136
+ for (let attempt = 0;; attempt += 1) {
137
+ const staging = `${path}.${randomBytes(12).toString('hex')}.partial`;
138
+ let created = false;
139
+ try {
140
+ await writeFile(staging, text, {
141
+ encoding: 'utf8',
142
+ mode: 0o600,
143
+ flag: 'wx',
144
+ });
145
+ created = true;
146
+ await rename(staging, path);
147
+ return;
148
+ }
149
+ catch (error) {
150
+ if (created)
151
+ await unlink(staging).catch(() => { });
152
+ const code = error.code;
153
+ if (code === 'EEXIST' && attempt === 0)
154
+ continue;
155
+ throw error;
156
+ }
157
+ }
158
+ }
7
159
  function shortSession(session) {
8
160
  return session.length <= 16 ? session : `${session.slice(0, 12)}…`;
9
161
  }
@@ -265,18 +417,28 @@ export function createDumper(options) {
265
417
  });
266
418
  // `wx`: a name collision fails loudly instead of overwriting a dump.
267
419
  const write = (path, text) => writeFile(path, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
268
- await Promise.all([
269
- write(files.body, body.text),
270
- write(files.metadata, `${JSON.stringify(metadata, null, 2)}\n`),
271
- write(files.request, `${JSON.stringify(request, null, 2)}\n`),
272
- ]);
420
+ const writes = [
421
+ [files.body, body.text],
422
+ [files.metadata, `${JSON.stringify(metadata, null, 2)}\n`],
423
+ [files.request, `${JSON.stringify(request, null, 2)}\n`],
424
+ ];
425
+ // Settle every write before judging the group, so a cleanup never
426
+ // races a write that is still in flight.
427
+ const settled = await Promise.allSettled(writes.map(([path, text]) => write(path, text)));
428
+ const failed = settled.find((result) => result.status === 'rejected');
429
+ if (failed) {
430
+ if (options.cleanupFailedDumps)
431
+ await removeGroup(writes, settled);
432
+ throw failed.reason;
433
+ }
273
434
  remember(baselineKey, body.text);
274
435
  log?.debug('dumped request', {
275
436
  id,
276
437
  session: shortSession(session),
277
438
  body: files.body,
278
439
  });
279
- return { id, files };
440
+ scheduleSweep(dir, Object.values(files));
441
+ return { id, files, responseFile: `${prefix}.response.json` };
280
442
  }
281
443
  catch (error) {
282
444
  log?.warn('request dump failed', {
@@ -286,11 +448,76 @@ export function createDumper(options) {
286
448
  return undefined;
287
449
  }
288
450
  }
451
+ /**
452
+ * Remove what a failed dump wrote. A write that failed with EEXIST found
453
+ * someone else's file at that name and must leave it; any other failure
454
+ * either created nothing or left a partial file of this dump.
455
+ */
456
+ async function removeGroup(writes, settled) {
457
+ await Promise.all(writes.map(async ([path], index) => {
458
+ const result = settled[index];
459
+ if (result?.status === 'rejected' &&
460
+ result.reason?.code === 'EEXIST') {
461
+ return;
462
+ }
463
+ await unlink(path).catch(() => { });
464
+ }));
465
+ }
466
+ const maxBytes = options.maxBytes ?? 0;
467
+ const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
468
+ // Zero, so the first dump of a process sweeps whatever earlier processes
469
+ // left behind.
470
+ let lastSweepAt = 0;
471
+ function sweepOptions(dir, protectedPaths) {
472
+ return {
473
+ dir,
474
+ maxBytes,
475
+ protectedPaths,
476
+ now: now(),
477
+ minAgeMs: options.sweepMinAgeMs,
478
+ partialStaleMs: options.partialStaleMs,
479
+ logger: log,
480
+ };
481
+ }
482
+ /** Sweep in the background, off the request path, at most once per interval. */
483
+ function scheduleSweep(dir, protectedPaths) {
484
+ if (!(maxBytes > 0))
485
+ return;
486
+ const at = now();
487
+ if (at - lastSweepAt < sweepIntervalMs)
488
+ return;
489
+ lastSweepAt = at;
490
+ void sweepDumpDirectory(sweepOptions(dir, protectedPaths));
491
+ }
492
+ async function dumpResponse(result, input) {
493
+ if (!result)
494
+ return undefined;
495
+ const artifact = scrub({
496
+ status: input.status,
497
+ requestId: input.requestId,
498
+ usage: input.usage,
499
+ ...input.fields,
500
+ complete: input.complete ?? true,
501
+ });
502
+ try {
503
+ await replaceFile(result.responseFile, `${JSON.stringify(artifact, null, 2)}\n`);
504
+ return result.responseFile;
505
+ }
506
+ catch (error) {
507
+ log?.warn('response dump failed', {
508
+ id: result.id,
509
+ error: error instanceof Error ? error.message : String(error),
510
+ });
511
+ return undefined;
512
+ }
513
+ }
289
514
  return {
290
515
  isEnabled: () => enabled,
291
516
  setEnabled: (value) => {
292
517
  enabled = value;
293
518
  },
294
519
  dump,
520
+ dumpResponse,
521
+ sweep: (protectedPaths) => sweepDumpDirectory(sweepOptions(resolveDir(), protectedPaths)),
295
522
  };
296
523
  }
@@ -15,30 +15,65 @@ export interface InitLoggerOptions extends RedactionOptions {
15
15
  /** Level floor applied when no `setLogLevel` call has overridden it. */
16
16
  level?: Level | (() => Level | undefined);
17
17
  }
18
+ export interface ChannelLogger {
19
+ error(message: string, data?: unknown): void;
20
+ warn(message: string, data?: unknown): void;
21
+ info(message: string, data?: unknown): void;
22
+ debug(message: string, data?: unknown): void;
23
+ trace(message: string, data?: unknown): void;
24
+ }
25
+ /**
26
+ * A logger with its own file, level, redaction, capture sink and buffer.
27
+ * Two instances in one process never touch each other's settings or lines,
28
+ * so two plugins that load one shared copy of this module each keep their own
29
+ * log by holding their own instance.
30
+ */
31
+ export interface LoggerInstance {
32
+ /** A logger whose lines carry `channel` and go to this instance's file. */
33
+ createLogger(channel: string): ChannelLogger;
34
+ /**
35
+ * Point this instance at a host's file and level. Idempotent: calling it
36
+ * again replaces both. A runtime level installed by `setLogLevel` is left
37
+ * alone, because it is the operator's explicit choice and outranks the
38
+ * floor a host computed at start-up.
39
+ */
40
+ configure(options: InitLoggerOptions): void;
41
+ /** Operator override of the level floor; undefined removes it. */
42
+ setLogLevel(level: Level | undefined): void;
43
+ /**
44
+ * Write whatever is buffered. Safe to call synchronously from a
45
+ * process-exit handler, which is how each host drains the buffer on
46
+ * shutdown.
47
+ */
48
+ flushLogs(): void;
49
+ }
50
+ /**
51
+ * A logger instance of its own, configured with `options`. A plugin whose
52
+ * copy of this module may be shared with another plugin in the same process
53
+ * uses this instead of `initLogger`, so neither replaces the other's file,
54
+ * level, redaction or capture sink.
55
+ */
56
+ export declare function createLoggerInstance(options: InitLoggerOptions): LoggerInstance;
18
57
  /**
19
- * Point the logger at a host's file and level. Idempotent: calling it again
20
- * replaces both. A runtime level installed by `setLogLevel` is deliberately
21
- * left alone, because it is the operator's explicit choice and outranks the
22
- * floor a host computed at start-up.
58
+ * Point the module's default logger at a host's file and level, and return
59
+ * it. Idempotent: calling it again replaces both. A runtime level installed
60
+ * by `setLogLevel` is deliberately left alone, because it is the operator's
61
+ * explicit choice and outranks the floor a host computed at start-up.
23
62
  */
24
- export declare function initLogger(options: InitLoggerOptions): void;
63
+ export declare function initLogger(options: InitLoggerOptions): LoggerInstance;
25
64
  export declare function setLogLevel(l: Level | undefined): void;
26
65
  /**
27
- * Write whatever is buffered. Safe to call synchronously from a process-exit
28
- * handler, which is how each host drains the buffer on shutdown.
66
+ * Write whatever the default logger has buffered. Safe to call synchronously
67
+ * from a process-exit handler, which is how each host drains the buffer on
68
+ * shutdown.
29
69
  */
30
70
  export declare function flushLogs(): void;
31
- export declare function createLogger(channel: string): {
32
- error: (m: string, d?: unknown) => void;
33
- warn: (m: string, d?: unknown) => void;
34
- info: (m: string, d?: unknown) => void;
35
- debug: (m: string, d?: unknown) => void;
36
- trace: (m: string, d?: unknown) => void;
37
- };
71
+ export declare function createLogger(channel: string): ChannelLogger;
38
72
  export declare function flushForTest(): Promise<void>;
39
73
  /**
40
- * Return the logger to its uninitialised state. Only a test needs this: a
41
- * single process runs every test file, so a file path left over from one test
42
- * would keep a later "logger was never initialised" case writing lines.
74
+ * Return the default logger to its uninitialised state. Only a test needs
75
+ * this: a single process runs every test file, so a file path left over from
76
+ * one test would keep a later "logger was never initialised" case writing
77
+ * lines.
43
78
  */
44
79
  export declare function resetLoggerForTest(): void;