@cortexkit/common-auth 0.2.4 → 0.2.6

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 (48) hide show
  1. package/dist/auth-menu/accounts.d.ts +88 -0
  2. package/dist/auth-menu/accounts.js +401 -0
  3. package/dist/auth-menu/ansi.d.ts +24 -0
  4. package/dist/auth-menu/ansi.js +67 -0
  5. package/dist/auth-menu/confirm.d.ts +7 -0
  6. package/dist/auth-menu/confirm.js +21 -0
  7. package/dist/auth-menu/doctor.d.ts +52 -0
  8. package/dist/auth-menu/doctor.js +81 -0
  9. package/dist/auth-menu/index.d.ts +17 -1
  10. package/dist/auth-menu/index.js +12 -2
  11. package/dist/auth-menu/login.d.ts +47 -0
  12. package/dist/auth-menu/login.js +63 -0
  13. package/dist/auth-menu/menu.d.ts +59 -0
  14. package/dist/auth-menu/menu.js +73 -0
  15. package/dist/auth-menu/opencode-v1.d.ts +49 -0
  16. package/dist/auth-menu/opencode-v1.js +47 -0
  17. package/dist/auth-menu/select.d.ts +21 -0
  18. package/dist/auth-menu/select.js +153 -0
  19. package/dist/auth-menu/terminal.d.ts +43 -0
  20. package/dist/auth-menu/terminal.js +19 -0
  21. package/dist/cachekeep/index.d.ts +4 -1
  22. package/dist/cachekeep/index.js +2 -2
  23. package/dist/cachekeep/manager.d.ts +214 -0
  24. package/dist/cachekeep/manager.js +424 -0
  25. package/dist/cachekeep/window.d.ts +13 -0
  26. package/dist/cachekeep/window.js +33 -0
  27. package/dist/commands/builtins.d.ts +71 -0
  28. package/dist/commands/builtins.js +508 -0
  29. package/dist/commands/index.d.ts +10 -1
  30. package/dist/commands/index.js +5 -2
  31. package/dist/commands/menu.d.ts +39 -0
  32. package/dist/commands/menu.js +249 -0
  33. package/dist/commands/model.d.ts +188 -0
  34. package/dist/commands/model.js +16 -0
  35. package/dist/commands/pi.d.ts +22 -0
  36. package/dist/commands/pi.js +178 -0
  37. package/dist/commands/seam.d.ts +36 -0
  38. package/dist/commands/seam.js +185 -0
  39. package/dist/dump/index.d.ts +90 -1
  40. package/dist/dump/index.js +292 -2
  41. package/dist/store/errors.d.ts +1 -1
  42. package/dist/store/index.d.ts +2 -0
  43. package/dist/store/index.js +1 -0
  44. package/dist/store/pool.d.ts +12 -0
  45. package/dist/store/pool.js +3 -0
  46. package/dist/store/settings.d.ts +63 -0
  47. package/dist/store/settings.js +120 -0
  48. package/package.json +1 -1
@@ -0,0 +1,185 @@
1
+ // The one way out of the process for anything the command menu shows. Every
2
+ // payload a renderer receives (the dialog payload and every apply result) is
3
+ // produced here: each part is projected field by field from its definition,
4
+ // so action bodies and stray properties never travel, and the whole payload
5
+ // is then scrubbed of credential-shaped property names as a backstop. Nothing
6
+ // outside this module can build a payload without passing through
7
+ // `dialogPayload` or `applyResult`.
8
+ /** The confirmation shown when an irreversible action names none. */
9
+ export const DEFAULT_IRREVERSIBLE_CONFIRMATION = 'This cannot be undone. Continue?';
10
+ const CREDENTIAL_NAMES = new Set([
11
+ 'access',
12
+ 'refresh',
13
+ 'apikey',
14
+ 'password',
15
+ 'authheader',
16
+ 'credential',
17
+ 'credentials',
18
+ ]);
19
+ /**
20
+ * True for a property name that looks like it holds a secret. Case, `-` and
21
+ * `_` are ignored, so `api_key`, `API-Key` and `apiKey` all match. Any name
22
+ * ending in `token`, `key` or `secret` matches: no menu field is named that
23
+ * way, so a match is a leak whatever the rest of the name says.
24
+ */
25
+ function isCredentialName(name) {
26
+ const normalized = name.toLowerCase().replace(/[-_]/g, '');
27
+ return (CREDENTIAL_NAMES.has(normalized) ||
28
+ normalized.endsWith('token') ||
29
+ normalized.endsWith('key') ||
30
+ normalized.endsWith('secret'));
31
+ }
32
+ function isPlainRecord(value) {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+ /** A copy of `value` without credential-shaped properties; their paths go to `found`. */
36
+ function scrub(value, path, found) {
37
+ if (Array.isArray(value))
38
+ return value.map((entry, index) => scrub(entry, `${path}[${index}]`, found));
39
+ if (!isPlainRecord(value))
40
+ return value;
41
+ const out = {};
42
+ for (const [name, entry] of Object.entries(value)) {
43
+ if (isCredentialName(name)) {
44
+ found.push(`${path}.${name}`);
45
+ continue;
46
+ }
47
+ Object.defineProperty(out, name, {
48
+ value: scrub(entry, `${path}.${name}`, found),
49
+ enumerable: true,
50
+ writable: true,
51
+ configurable: true,
52
+ });
53
+ }
54
+ return out;
55
+ }
56
+ function sealed(payload, command, logger) {
57
+ const found = [];
58
+ const clean = scrub(payload, 'payload', found);
59
+ if (found.length > 0)
60
+ logger.warn('credential-shaped field dropped from a command payload', {
61
+ command,
62
+ fields: found,
63
+ });
64
+ return clean;
65
+ }
66
+ function projectChoice(choice) {
67
+ return { value: String(choice.value), label: String(choice.label) };
68
+ }
69
+ function projectKnob(knob) {
70
+ switch (knob.kind) {
71
+ case 'choice':
72
+ return {
73
+ kind: 'choice',
74
+ id: knob.id,
75
+ label: knob.label,
76
+ choices: (knob.choices ?? []).map(projectChoice),
77
+ ...(knob.value !== undefined ? { value: knob.value } : {}),
78
+ };
79
+ case 'toggle':
80
+ return {
81
+ kind: 'toggle',
82
+ id: knob.id,
83
+ label: knob.label,
84
+ value: knob.value === true,
85
+ };
86
+ case 'number':
87
+ return {
88
+ kind: 'number',
89
+ id: knob.id,
90
+ label: knob.label,
91
+ ...(knob.value !== undefined ? { value: knob.value } : {}),
92
+ ...(knob.min !== undefined ? { min: knob.min } : {}),
93
+ ...(knob.max !== undefined ? { max: knob.max } : {}),
94
+ ...(knob.required ? { required: true } : {}),
95
+ };
96
+ case 'text':
97
+ return {
98
+ kind: 'text',
99
+ id: knob.id,
100
+ label: knob.label,
101
+ ...(knob.value !== undefined ? { value: knob.value } : {}),
102
+ ...(knob.placeholder !== undefined
103
+ ? { placeholder: knob.placeholder }
104
+ : {}),
105
+ ...(knob.masked ? { masked: true } : {}),
106
+ ...(knob.required ? { required: true } : {}),
107
+ };
108
+ }
109
+ }
110
+ /**
111
+ * The confirmation an action must pass before it runs, or undefined. An
112
+ * irreversible action always has one, even when its definition (built
113
+ * outside the type checker) names none.
114
+ */
115
+ export function confirmationOf(action) {
116
+ if (action.irreversible === true)
117
+ return {
118
+ message: action.confirm || DEFAULT_IRREVERSIBLE_CONFIRMATION,
119
+ irreversible: true,
120
+ };
121
+ if (action.confirm)
122
+ return { message: action.confirm, irreversible: false };
123
+ return undefined;
124
+ }
125
+ function projectAction(action) {
126
+ const confirm = confirmationOf(action);
127
+ return {
128
+ id: action.id,
129
+ label: action.label,
130
+ ...(action.description !== undefined
131
+ ? { description: action.description }
132
+ : {}),
133
+ knobs: (action.knobs ?? []).map(projectKnob),
134
+ ...(confirm ? { confirm } : {}),
135
+ };
136
+ }
137
+ /** The account fields a renderer may show; see `MenuAccount`. */
138
+ export function projectAccount(account) {
139
+ return {
140
+ id: account.id,
141
+ ...(account.label !== undefined ? { label: account.label } : {}),
142
+ enabled: account.enabled,
143
+ type: account.type,
144
+ ...(account.identity !== undefined ? { identity: account.identity } : {}),
145
+ };
146
+ }
147
+ function projectItem(item) {
148
+ return {
149
+ id: item.id,
150
+ label: item.label,
151
+ ...(item.detail !== undefined ? { detail: item.detail } : {}),
152
+ ...(item.account ? { account: projectAccount(item.account) } : {}),
153
+ ...(item.facts !== undefined ? { facts: item.facts } : {}),
154
+ actions: (item.actions ?? []).map(projectAction),
155
+ };
156
+ }
157
+ function projectSection(section) {
158
+ const { content } = section;
159
+ return {
160
+ id: section.id,
161
+ slot: section.slot,
162
+ title: section.title,
163
+ lines: (content.lines ?? []).map(String),
164
+ items: (content.items ?? []).map(projectItem),
165
+ actions: (content.actions ?? []).map(projectAction),
166
+ ...(content.facts !== undefined ? { facts: content.facts } : {}),
167
+ };
168
+ }
169
+ function projectMenu(command, title, sections) {
170
+ return { command, title, sections: sections.map(projectSection) };
171
+ }
172
+ /** The payload a host's TUI receives when the slash command opens. */
173
+ export function dialogPayload(command, title, sections, logger) {
174
+ return sealed({ command, menu: projectMenu(command, title, sections) }, command, logger);
175
+ }
176
+ /** An apply's result: the message and the refreshed menu. */
177
+ export function applyResult(command, title, sections, outcome, logger) {
178
+ return sealed({
179
+ command,
180
+ ok: outcome.ok,
181
+ text: outcome.text,
182
+ ...(outcome.needsConfirmation ? { needsConfirmation: true } : {}),
183
+ menu: projectMenu(command, title, sections),
184
+ }, command, logger);
185
+ }
@@ -1 +1,90 @@
1
- export {};
1
+ export interface DumpLogger {
2
+ debug(message: string, data?: unknown): void;
3
+ warn(message: string, data?: unknown): void;
4
+ }
5
+ export interface DumpOptions {
6
+ /** Directory dumps are written to; a function is read on every dump. */
7
+ dir: string | (() => string);
8
+ /** Initial state; `setEnabled` switches it at runtime. Default off. */
9
+ enabled?: boolean;
10
+ /** Written into every filename. Default the current process id. */
11
+ pid?: number;
12
+ /** Clock for filenames and `createdAt`. Default `Date.now`. */
13
+ now?: () => number;
14
+ logger?: DumpLogger;
15
+ /**
16
+ * Key names redacted wherever they appear in headers, request metadata and
17
+ * the body, on top of the common credential set. Compared lower-cased with
18
+ * `-` and `_` removed, so `ChatGPT-Account-Id` and `chatgptAccountId` match
19
+ * `chatgptaccountid`.
20
+ */
21
+ secretKeys?: readonly string[];
22
+ /** Value patterns scrubbed from every string, on top of the common token patterns. */
23
+ secretPatterns?: readonly RegExp[];
24
+ /**
25
+ * Keys, at any depth of the body, whose values are declarations rather than
26
+ * data: their strings are scrubbed but their key names are never redacted.
27
+ * Default `['tools']`.
28
+ */
29
+ schemaKeys?: readonly string[];
30
+ /**
31
+ * Provider summary of the parsed body, stored in the metadata as `body`.
32
+ * It receives the redacted body.
33
+ */
34
+ summarize?: (body: Record<string, unknown>) => Record<string, unknown>;
35
+ }
36
+ export interface DumpInput {
37
+ /** The session the request belongs to; missing means `session-unknown`. */
38
+ session?: string | null;
39
+ /**
40
+ * Diff baseline group: a dump is diffed against the previous dump of the
41
+ * same session and channel (for example, one per transport).
42
+ */
43
+ channel: string;
44
+ /** Finer label for the filename, e.g. prewarm or main; it does not split the baseline. */
45
+ phase?: string;
46
+ bodyText: string;
47
+ accountId?: string;
48
+ url?: string;
49
+ method?: string;
50
+ headers?: ConstructorParameters<typeof Headers>[0];
51
+ status?: number;
52
+ error?: string;
53
+ /** Extra metadata, redacted like the body. */
54
+ meta?: Record<string, unknown>;
55
+ }
56
+ export interface DumpDiff {
57
+ changed: boolean;
58
+ firstByte: number;
59
+ lastPreviousByte: number;
60
+ lastCurrentByte: number;
61
+ changedPreviousBytes?: number;
62
+ changedCurrentBytes?: number;
63
+ previousBytes: number;
64
+ currentBytes: number;
65
+ }
66
+ export interface DumpResult {
67
+ id: string;
68
+ files: {
69
+ body: string;
70
+ metadata: string;
71
+ request: string;
72
+ };
73
+ }
74
+ export interface Dumper {
75
+ isEnabled(): boolean;
76
+ setEnabled(enabled: boolean): void;
77
+ /**
78
+ * Write `<id>.body.json`, `<id>.meta.json` and `<id>.request.json`. Returns
79
+ * undefined when dumps are off or the write failed; dumping is diagnostic
80
+ * and never throws into the request path.
81
+ */
82
+ dump(input: DumpInput): Promise<DumpResult | undefined>;
83
+ }
84
+ /**
85
+ * Where two bodies first and last differ, in string offsets into the dumped
86
+ * (redacted) text. Offsets count UTF-16 units, as the plugins' cache analysis
87
+ * always has.
88
+ */
89
+ export declare function diffDumpBodies(previous: string | undefined, current: string): DumpDiff | null;
90
+ export declare function createDumper(options: DumpOptions): Dumper;
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import type { StoredCredential } from './schema.js';
2
2
  /** Every library operation that can fail, as named in the failure value. */
3
- export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'recordIdentity' | 'refresh' | 'pull';
3
+ export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'updateSettings' | 'recordIdentity' | 'refresh' | 'pull';
4
4
  /**
5
5
  * How far an operation got before it failed.
6
6
  *
@@ -14,3 +14,5 @@ export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, Rem
14
14
  export type { PullReason } from './runtime.js';
15
15
  export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, StoredCredential, } from './schema.js';
16
16
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
17
+ export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
18
+ export { POOL_OWNED_KEYS } from './settings.js';
@@ -3,3 +3,4 @@ export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity
3
3
  export { openPoolStore } from './pool.js';
4
4
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
5
5
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
6
+ export { POOL_OWNED_KEYS } from './settings.js';
@@ -7,6 +7,7 @@ import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from '
7
7
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
8
8
  import { type AddInput, type AddResult, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
9
9
  import { type PoolCredential, type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
10
+ import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
10
11
  export interface OpenPoolStoreOptions {
11
12
  /** The provider every row of this pool belongs to; keys the provider-wide lock. */
12
13
  provider: string;
@@ -108,6 +109,17 @@ export interface PoolStore {
108
109
  * provider-wide lock. Roster rows and their entries are left unchanged.
109
110
  */
110
111
  reorder(ids: readonly string[], options?: ReorderOptions): Promise<ReorderResult>;
112
+ /**
113
+ * The plugin's settings (since 0.2.6): every top-level key of the config
114
+ * file except the pool-owned ones (`POOL_OWNED_KEYS`). Never writes.
115
+ */
116
+ readSettings(): Promise<SettingsRead>;
117
+ /**
118
+ * One locked write of the plugin's settings (since 0.2.6) beside the pool,
119
+ * in the config file. Refuses a result that sets a pool-owned key
120
+ * (`invalid-input`). Takes `extraLocks`, then the store locks.
121
+ */
122
+ updateSettings(mutator: SettingsMutator, options?: UpdateSettingsOptions): Promise<UpdateSettingsResult>;
111
123
  recordIdentity(id: string, identity: string, options?: RowOperationOptions): Promise<{
112
124
  id: string;
113
125
  disabled: string[];
@@ -5,6 +5,7 @@ import { refreshRow, } from './refresh.js';
5
5
  import { POOL_LOCK_DEFAULTS, } from './refresh-lock.js';
6
6
  import { addRow, disableRow, enableRow, recordRowIdentity, removeRow, reorderRows, replaceRow, rotateRow, } from './rows.js';
7
7
  import { POOL_SCHEMA_VERSION, } from './schema.js';
8
+ import { readPoolSettings, updatePoolSettings, } from './settings.js';
8
9
  /**
9
10
  * Process-wide memory per config file: ids whose per-row entry a library
10
11
  * write dropped (not reused in this process), and rows a load-time pull
@@ -95,6 +96,8 @@ export function openPoolStore(options) {
95
96
  enable: (id, callOptions) => enableRow(rt, id, callOptions),
96
97
  remove: (id, callOptions) => removeRow(rt, id, callOptions),
97
98
  reorder: (ids, callOptions) => reorderRows(rt, ids, callOptions),
99
+ readSettings: () => readPoolSettings(rt),
100
+ updateSettings: (mutator, callOptions) => updatePoolSettings(rt, mutator, callOptions),
98
101
  recordIdentity: (id, identity, callOptions) => recordRowIdentity(rt, id, identity, callOptions),
99
102
  refresh: (id, provider, callOptions) => refreshRow(rt, id, provider, callOptions),
100
103
  recordQuota: (id, attribution, observation) => recordQuota(rt, id, attribution, observation),