@muthuishere/vsync 0.3.1 → 0.5.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.
package/src/audit.ts ADDED
@@ -0,0 +1,752 @@
1
+ // audit.ts — append-only CSV audit log at s3://<bucket>/<repo>/<env>/audit.csv.
2
+ //
3
+ // Wire format: SPEC-v0.4 §4. Columns are locked; readers parse by header.
4
+ // Append protocol: SPEC-v0.4 §5 — ETag-conditional PUT with up to 3 retries
5
+ // on 412 Precondition Failed, silent skip on 403, throw on any other error.
6
+ //
7
+ // NOTE on Bun.S3Client conditional writes
8
+ // ----------------------------------------
9
+ // Bun 1.3.0's S3Client surface exposes ETag on `stat()` but does NOT accept
10
+ // `If-Match` / `If-None-Match` on `write()` — there is no field in
11
+ // `S3Options` for it (checked node_modules/bun-types/s3.d.ts). The spec
12
+ // assumed it did. So this module uses the Bun client for the *reads*
13
+ // (`stat`, `text`) and falls back to a minimal AWS SigV4-signed `fetch`
14
+ // PUT for the *write* — that's the only place conditional headers are
15
+ // needed. Reads are unchanged; only the append path is hand-signed.
16
+ //
17
+ // If a future Bun release adds `ifMatch` / `ifNoneMatch` to S3Options,
18
+ // `signedPut` can be deleted and the call swapped for `client.file(k).write`.
19
+
20
+ import * as os from "node:os";
21
+ import type { S3Credentials } from "./s3";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+
26
+ export type AuditAction = "pull" | "push" | "import" | "export";
27
+
28
+ export type AuditRow = {
29
+ ts: string;
30
+ action: AuditAction;
31
+ version_ts: string;
32
+ hostname: string;
33
+ local_ip: string;
34
+ os_user: string;
35
+ git_email: string;
36
+ vsync_version: string;
37
+ bun_version: string;
38
+ /** Serialized JSON object, or empty string when no meta was supplied. */
39
+ meta: string;
40
+ };
41
+
42
+ /** CSV header — column order is locked. New columns go to the right. */
43
+ export const AUDIT_HEADER =
44
+ "ts,action,version_ts,hostname,local_ip,os_user,git_email,vsync_version,bun_version,meta";
45
+
46
+ const META_SIZE_LIMIT_BYTES = 2048;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Path helpers
50
+
51
+ /** S3 key for the audit log of a given (repo, env). env is lowercased. */
52
+ export function auditKey(repo: string, env: string): string {
53
+ if (!repo) throw new Error("repo is required");
54
+ if (!env) throw new Error("env is required");
55
+ return `${repo}/${env.toLowerCase()}/audit.csv`;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Row metadata gathering
60
+
61
+ /** Return the first non-loopback IPv4, else first non-loopback IPv6, else "". */
62
+ function firstUsableIp(): string {
63
+ let ifaces: NodeJS.Dict<os.NetworkInterfaceInfo[]>;
64
+ try {
65
+ ifaces = os.networkInterfaces();
66
+ } catch {
67
+ return "";
68
+ }
69
+ let ipv6Fallback = "";
70
+ for (const list of Object.values(ifaces)) {
71
+ if (!list) continue;
72
+ for (const i of list) {
73
+ if (i.internal) continue;
74
+ if (i.family === "IPv4" || (i as any).family === 4) {
75
+ return i.address;
76
+ }
77
+ if (!ipv6Fallback && (i.family === "IPv6" || (i as any).family === 6)) {
78
+ ipv6Fallback = i.address;
79
+ }
80
+ }
81
+ }
82
+ return ipv6Fallback;
83
+ }
84
+
85
+ function safeHostname(): string {
86
+ try {
87
+ return os.hostname() ?? "";
88
+ } catch {
89
+ return "";
90
+ }
91
+ }
92
+
93
+ /** Read `vsync_version` from package.json. Cached after first read. */
94
+ let _cachedVsyncVersion: string | null = null;
95
+ async function readVsyncVersion(): Promise<string> {
96
+ if (_cachedVsyncVersion !== null) return _cachedVsyncVersion;
97
+ try {
98
+ // The package.json sits two levels up from this module at install time
99
+ // (src/ → repo root). Use Bun.file which handles both source and bundle.
100
+ const url = new URL("../package.json", import.meta.url);
101
+ const text = await Bun.file(url.pathname).text();
102
+ const parsed = JSON.parse(text);
103
+ _cachedVsyncVersion = typeof parsed?.version === "string" ? parsed.version : "";
104
+ } catch {
105
+ _cachedVsyncVersion = "";
106
+ }
107
+ return _cachedVsyncVersion;
108
+ }
109
+
110
+ async function readGitEmail(): Promise<string> {
111
+ try {
112
+ const proc = Bun.spawn(["git", "config", "--get", "user.email"], {
113
+ stderr: "pipe",
114
+ stdout: "pipe",
115
+ });
116
+ const code = await proc.exited;
117
+ if (code !== 0) return "";
118
+ return (await new Response(proc.stdout).text()).trim();
119
+ } catch {
120
+ return "";
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Build an AuditRow with every field populated except `meta` (caller fills
126
+ * via buildMeta). `versionTs` is the bundle TS for pull/push; empty for
127
+ * import/export.
128
+ */
129
+ export async function gatherRowMetadata(
130
+ action: AuditAction,
131
+ versionTs: string = "",
132
+ ): Promise<AuditRow> {
133
+ return {
134
+ ts: new Date().toISOString(),
135
+ action,
136
+ version_ts: versionTs,
137
+ hostname: safeHostname(),
138
+ local_ip: firstUsableIp(),
139
+ os_user: process.env.USER ?? process.env.USERNAME ?? "",
140
+ git_email: await readGitEmail(),
141
+ vsync_version: await readVsyncVersion(),
142
+ bun_version: process.versions.bun ?? "",
143
+ meta: "",
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Meta merging (spec §4.1)
149
+
150
+ export type BuildMetaInput = {
151
+ /** Raw value of `$VSYNC_AUDIT_META` — a JSON object, or undefined. */
152
+ envMeta?: string;
153
+ /** Raw value of `$VSYNC_AUDIT_NOTE` — free text, or undefined. */
154
+ envNote?: string;
155
+ /** Repeated `--meta key=value` values, in order. */
156
+ flagMetaList?: string[];
157
+ /** Value of `--note=<text>`. */
158
+ flagNote?: string;
159
+ };
160
+
161
+ export type BuildMetaResult = {
162
+ /** Serialized JSON (or `""` when nothing was supplied). */
163
+ json: string;
164
+ /** Human-readable warnings to emit on stderr; empty when clean. */
165
+ warnings: string[];
166
+ };
167
+
168
+ /**
169
+ * Merge the four input sources per spec §4.1 priority order:
170
+ * envMeta < envNote < flagMetaList < flagNote
171
+ * Last writer wins per key. Returns `""` when no source supplied anything.
172
+ * Enforces the 2KB cap (over → returns `{"_truncated":true}` + warning).
173
+ */
174
+ export function buildMeta(opts: BuildMetaInput): BuildMetaResult {
175
+ const warnings: string[] = [];
176
+ const merged: Record<string, string> = {};
177
+ let touched = false;
178
+
179
+ // 1. $VSYNC_AUDIT_META — must parse to a JSON object
180
+ if (opts.envMeta !== undefined && opts.envMeta !== "") {
181
+ try {
182
+ const parsed = JSON.parse(opts.envMeta);
183
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
184
+ for (const [k, v] of Object.entries(parsed)) {
185
+ merged[k] = typeof v === "string" ? v : JSON.stringify(v);
186
+ }
187
+ touched = true;
188
+ } else {
189
+ warnings.push(
190
+ "warning: $VSYNC_AUDIT_META is not a JSON object, ignoring",
191
+ );
192
+ }
193
+ } catch (e) {
194
+ warnings.push(
195
+ `warning: $VSYNC_AUDIT_META is not valid JSON, ignoring (${(e as Error).message})`,
196
+ );
197
+ }
198
+ }
199
+
200
+ // 2. $VSYNC_AUDIT_NOTE — sugar for { note: <text> }
201
+ if (opts.envNote !== undefined && opts.envNote !== "") {
202
+ merged.note = opts.envNote;
203
+ touched = true;
204
+ }
205
+
206
+ // 3. --meta key=value (repeatable)
207
+ if (opts.flagMetaList && opts.flagMetaList.length > 0) {
208
+ for (const raw of opts.flagMetaList) {
209
+ const eq = raw.indexOf("=");
210
+ if (eq === -1) {
211
+ // Per spec §4.1: "--meta key (no =) is a usage error."
212
+ // We surface this as a thrown error so the caller can exit cleanly
213
+ // instead of silently logging.
214
+ throw new Error(`--meta requires key=value form, got: ${raw}`);
215
+ }
216
+ const k = raw.slice(0, eq);
217
+ const v = raw.slice(eq + 1);
218
+ if (!k) throw new Error(`--meta requires a non-empty key, got: ${raw}`);
219
+ merged[k] = v;
220
+ touched = true;
221
+ }
222
+ }
223
+
224
+ // 4. --note=<text> — sugar for `--meta note=<text>`, highest precedence
225
+ if (opts.flagNote !== undefined && opts.flagNote !== "") {
226
+ merged.note = opts.flagNote;
227
+ touched = true;
228
+ }
229
+
230
+ if (!touched) return { json: "", warnings };
231
+
232
+ const json = JSON.stringify(merged);
233
+ if (Buffer.byteLength(json, "utf8") > META_SIZE_LIMIT_BYTES) {
234
+ warnings.push(
235
+ `warning: audit meta exceeded ${META_SIZE_LIMIT_BYTES} bytes, replaced with {"_truncated":true}`,
236
+ );
237
+ return { json: JSON.stringify({ _truncated: true }), warnings };
238
+ }
239
+ return { json, warnings };
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // RFC 4180 CSV serialize / parse
244
+
245
+ function csvQuote(field: string): string {
246
+ if (field === "") return "";
247
+ if (/[",\n\r]/.test(field)) {
248
+ return '"' + field.replace(/"/g, '""') + '"';
249
+ }
250
+ return field;
251
+ }
252
+
253
+ /** Serialize a row to a single CSV line (no trailing newline). */
254
+ export function rowToCsv(row: AuditRow): string {
255
+ return [
256
+ row.ts,
257
+ row.action,
258
+ row.version_ts,
259
+ row.hostname,
260
+ row.local_ip,
261
+ row.os_user,
262
+ row.git_email,
263
+ row.vsync_version,
264
+ row.bun_version,
265
+ row.meta,
266
+ ]
267
+ .map(csvQuote)
268
+ .join(",");
269
+ }
270
+
271
+ /**
272
+ * Parse RFC 4180 CSV into rows (arrays of strings). Handles quoted fields,
273
+ * doubled quotes, embedded `,` and newlines.
274
+ */
275
+ function parseCsv(text: string): string[][] {
276
+ const rows: string[][] = [];
277
+ let row: string[] = [];
278
+ let field = "";
279
+ let inQuotes = false;
280
+ let i = 0;
281
+ while (i < text.length) {
282
+ const c = text[i];
283
+ if (inQuotes) {
284
+ if (c === '"') {
285
+ if (text[i + 1] === '"') {
286
+ field += '"';
287
+ i += 2;
288
+ continue;
289
+ }
290
+ inQuotes = false;
291
+ i++;
292
+ continue;
293
+ }
294
+ field += c;
295
+ i++;
296
+ continue;
297
+ }
298
+ if (c === '"') {
299
+ inQuotes = true;
300
+ i++;
301
+ continue;
302
+ }
303
+ if (c === ",") {
304
+ row.push(field);
305
+ field = "";
306
+ i++;
307
+ continue;
308
+ }
309
+ if (c === "\n" || c === "\r") {
310
+ // Eat \r\n as a single break
311
+ if (c === "\r" && text[i + 1] === "\n") i++;
312
+ row.push(field);
313
+ // Skip empty trailing rows (file ends with \n)
314
+ if (!(row.length === 1 && row[0] === "")) rows.push(row);
315
+ row = [];
316
+ field = "";
317
+ i++;
318
+ continue;
319
+ }
320
+ field += c;
321
+ i++;
322
+ }
323
+ // Last field if file lacks trailing newline
324
+ if (field !== "" || row.length > 0) {
325
+ row.push(field);
326
+ if (!(row.length === 1 && row[0] === "")) rows.push(row);
327
+ }
328
+ return rows;
329
+ }
330
+
331
+ /** Parse a full CSV (header + rows) into AuditRow[]. */
332
+ export function parseAuditCsv(text: string): AuditRow[] {
333
+ const grid = parseCsv(text);
334
+ if (grid.length === 0) return [];
335
+ const header = grid[0];
336
+ const cols = [
337
+ "ts",
338
+ "action",
339
+ "version_ts",
340
+ "hostname",
341
+ "local_ip",
342
+ "os_user",
343
+ "git_email",
344
+ "vsync_version",
345
+ "bun_version",
346
+ "meta",
347
+ ] as const;
348
+ const idx: Record<string, number> = {};
349
+ for (const c of cols) idx[c] = header.indexOf(c);
350
+ const out: AuditRow[] = [];
351
+ for (let r = 1; r < grid.length; r++) {
352
+ const row = grid[r];
353
+ const get = (k: (typeof cols)[number]) =>
354
+ idx[k] >= 0 ? (row[idx[k]] ?? "") : "";
355
+ out.push({
356
+ ts: get("ts"),
357
+ action: get("action") as AuditAction,
358
+ version_ts: get("version_ts"),
359
+ hostname: get("hostname"),
360
+ local_ip: get("local_ip"),
361
+ os_user: get("os_user"),
362
+ git_email: get("git_email"),
363
+ vsync_version: get("vsync_version"),
364
+ bun_version: get("bun_version"),
365
+ meta: get("meta"),
366
+ });
367
+ }
368
+ return out;
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Append protocol — abstract client surface (for tests + real Bun.S3Client)
373
+
374
+ /**
375
+ * Minimal surface the audit code uses. Real impl is a thin wrapper around
376
+ * Bun's S3 client + a SigV4 fetch for conditional PUT. Tests pass a fake.
377
+ */
378
+ export interface AuditClient {
379
+ /** Fetch object; null when 404. Returns text + ETag. */
380
+ read(key: string): Promise<{ text: string; etag: string } | null>;
381
+ /**
382
+ * Conditional PUT. `condition.ifMatch` for "must match this ETag";
383
+ * `condition.ifNoneMatch: "*"` for "object must not exist".
384
+ * Throws an Error whose `.status` is the HTTP status on failure.
385
+ */
386
+ conditionalPut(
387
+ key: string,
388
+ body: string,
389
+ condition: { ifMatch?: string; ifNoneMatch?: string },
390
+ ): Promise<void>;
391
+ }
392
+
393
+ /** Error carrying an HTTP-style status code, for retry classification. */
394
+ export class AuditHttpError extends Error {
395
+ status: number;
396
+ constructor(status: number, message: string) {
397
+ super(message);
398
+ this.name = "AuditHttpError";
399
+ this.status = status;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Append a single row using the ETag-conditional protocol from spec §5.
405
+ *
406
+ * 1. Read existing CSV (if any) + its ETag.
407
+ * 2. Build body (header+row on first write; existing+row on append).
408
+ * 3. PUT with If-None-Match: * (new) or If-Match: <etag> (append).
409
+ * 4. On 412 Precondition Failed → re-read, re-append. Up to 3 attempts.
410
+ * 5. On 403 → silently skip (caller has no write permission).
411
+ * 6. On any other failure → throw (the caller will catch + warn).
412
+ */
413
+ export async function appendAuditRow(
414
+ client: AuditClient,
415
+ repo: string,
416
+ env: string,
417
+ row: AuditRow,
418
+ ): Promise<void> {
419
+ const key = auditKey(repo, env);
420
+ const newLine = rowToCsv(row) + "\n";
421
+
422
+ for (let attempt = 1; attempt <= 3; attempt++) {
423
+ let existing: { text: string; etag: string } | null;
424
+ try {
425
+ existing = await client.read(key);
426
+ } catch (e) {
427
+ const status = (e as AuditHttpError).status;
428
+ if (status === 403) return; // read denied → skip silently
429
+ throw e;
430
+ }
431
+
432
+ let body: string;
433
+ let condition: { ifMatch?: string; ifNoneMatch?: string };
434
+ if (!existing) {
435
+ body = AUDIT_HEADER + "\n" + newLine;
436
+ condition = { ifNoneMatch: "*" };
437
+ } else {
438
+ // Ensure prior content ends with a newline so rows aren't joined.
439
+ const prior = existing.text.endsWith("\n")
440
+ ? existing.text
441
+ : existing.text + "\n";
442
+ body = prior + newLine;
443
+ condition = { ifMatch: existing.etag };
444
+ }
445
+
446
+ try {
447
+ await client.conditionalPut(key, body, condition);
448
+ return; // success
449
+ } catch (e) {
450
+ const status = (e as AuditHttpError).status;
451
+ if (status === 403) return; // write denied → skip silently
452
+ if (status === 412 && attempt < 3) continue; // conflict → retry
453
+ throw e;
454
+ }
455
+ }
456
+ throw new AuditHttpError(
457
+ 412,
458
+ "audit append: 3 conflicting writers in a row, giving up",
459
+ );
460
+ }
461
+
462
+ /** Fetch + parse the audit log. Returns `[]` when the object doesn't exist. */
463
+ export async function readAuditLog(
464
+ client: AuditClient,
465
+ repo: string,
466
+ env: string,
467
+ ): Promise<AuditRow[]> {
468
+ const key = auditKey(repo, env);
469
+ const existing = await client.read(key);
470
+ if (!existing) return [];
471
+ return parseAuditCsv(existing.text);
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Pretty-print
476
+
477
+ /** Raw CSV passthrough (header + each row + trailing newline). */
478
+ export function formatAuditCsv(rows: AuditRow[]): string {
479
+ const lines = [AUDIT_HEADER, ...rows.map(rowToCsv)];
480
+ return lines.join("\n") + "\n";
481
+ }
482
+
483
+ type FormatOpts = { limit?: number; all?: boolean };
484
+
485
+ /**
486
+ * Pretty table, newest first. Default limit 50; `--all` overrides. Unwraps
487
+ * `meta.note` into its own column; remaining meta keys collapse into a
488
+ * `k=v, k2=v2` summary in the `meta` column.
489
+ */
490
+ export function formatAuditTable(rows: AuditRow[], opts: FormatOpts = {}): string {
491
+ if (rows.length === 0) return "(no rows)";
492
+ const limit = opts.all ? rows.length : (opts.limit ?? 50);
493
+ // Newest first — `ts` is ISO 8601, so lexical sort works.
494
+ const sorted = [...rows].sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
495
+ const shown = sorted.slice(0, limit);
496
+
497
+ type Display = {
498
+ ts: string;
499
+ action: string;
500
+ version_ts: string;
501
+ user: string;
502
+ host: string;
503
+ note: string;
504
+ meta: string;
505
+ };
506
+ const display: Display[] = shown.map((r) => {
507
+ const { note, rest } = splitNote(r.meta);
508
+ return {
509
+ ts: r.ts,
510
+ action: r.action,
511
+ version_ts: r.version_ts,
512
+ user: r.os_user || r.git_email,
513
+ host: r.hostname || r.local_ip,
514
+ note,
515
+ meta: rest,
516
+ };
517
+ });
518
+
519
+ const cols: { key: keyof Display; label: string }[] = [
520
+ { key: "ts", label: "TS" },
521
+ { key: "action", label: "ACTION" },
522
+ { key: "version_ts", label: "VERSION" },
523
+ { key: "user", label: "USER" },
524
+ { key: "host", label: "HOST" },
525
+ { key: "note", label: "NOTE" },
526
+ { key: "meta", label: "META" },
527
+ ];
528
+ const widths: Record<string, number> = {};
529
+ for (const c of cols) {
530
+ widths[c.key] = c.label.length;
531
+ for (const d of display) widths[c.key] = Math.max(widths[c.key], d[c.key].length);
532
+ }
533
+ const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
534
+ const headerLine = cols.map((c) => pad(c.label, widths[c.key])).join(" ");
535
+ const sep = cols.map((c) => "-".repeat(widths[c.key])).join(" ");
536
+ const bodyLines = display.map((d) =>
537
+ cols.map((c) => pad(d[c.key], widths[c.key])).join(" "),
538
+ );
539
+ const omitted = rows.length - shown.length;
540
+ const footer =
541
+ omitted > 0
542
+ ? `\n(${omitted} older row${omitted === 1 ? "" : "s"} not shown; pass --all to see everything)`
543
+ : "";
544
+ return [headerLine, sep, ...bodyLines].join("\n") + footer;
545
+ }
546
+
547
+ function splitNote(metaCell: string): { note: string; rest: string } {
548
+ if (!metaCell) return { note: "", rest: "" };
549
+ let obj: unknown;
550
+ try {
551
+ obj = JSON.parse(metaCell);
552
+ } catch {
553
+ return { note: "", rest: metaCell };
554
+ }
555
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
556
+ return { note: "", rest: metaCell };
557
+ }
558
+ const o = obj as Record<string, unknown>;
559
+ const note = typeof o.note === "string" ? o.note : "";
560
+ const rest = Object.entries(o)
561
+ .filter(([k]) => k !== "note")
562
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
563
+ .join(", ");
564
+ return { note, rest };
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Real AuditClient implementation — Bun.S3Client reads + SigV4 fetch PUT
569
+
570
+ /**
571
+ * Build a real AuditClient backed by Bun.S3Client (reads) and a hand-signed
572
+ * `fetch` PUT (writes — needed for If-Match / If-None-Match which Bun's
573
+ * S3Options doesn't expose as of 1.3.0).
574
+ */
575
+ export function makeAuditClient(creds: S3Credentials): AuditClient {
576
+ return {
577
+ async read(key: string) {
578
+ const client = makeBunS3(creds);
579
+ const f = client.file(key);
580
+ let etag: string;
581
+ try {
582
+ const stat = await f.stat();
583
+ etag = stat.etag;
584
+ } catch (e: any) {
585
+ // Bun throws on 404; detect by name/status.
586
+ if (is404(e)) return null;
587
+ throw classify(e);
588
+ }
589
+ const text = await f.text();
590
+ return { text, etag };
591
+ },
592
+ async conditionalPut(key, body, condition) {
593
+ await sigv4Put(creds, key, body, condition);
594
+ },
595
+ };
596
+ }
597
+
598
+ function makeBunS3(creds: S3Credentials): Bun.S3Client {
599
+ const protocol = creds.useSsl ? "https://" : "http://";
600
+ const endpoint = creds.endpoint.startsWith("http")
601
+ ? creds.endpoint
602
+ : protocol + creds.endpoint;
603
+ return new Bun.S3Client({
604
+ accessKeyId: creds.accessKeyId,
605
+ secretAccessKey: creds.secretAccessKey,
606
+ region: creds.region,
607
+ bucket: creds.bucket,
608
+ endpoint,
609
+ });
610
+ }
611
+
612
+ function is404(e: any): boolean {
613
+ if (!e) return false;
614
+ const msg = String(e?.message ?? e);
615
+ const code = (e as any).code ?? (e as any).status;
616
+ if (code === 404 || code === "NoSuchKey") return true;
617
+ return /NoSuchKey|not found|404|does not exist/i.test(msg);
618
+ }
619
+
620
+ function classify(e: any): Error {
621
+ const msg = String(e?.message ?? e);
622
+ const m = msg.match(/\b(40[0-9]|41[0-9]|42[0-9]|5\d\d)\b/);
623
+ if (m) return new AuditHttpError(parseInt(m[1], 10), msg);
624
+ return e instanceof Error ? e : new Error(msg);
625
+ }
626
+
627
+ // --- SigV4 PUT (audit.csv only) -------------------------------------------
628
+
629
+ async function sigv4Put(
630
+ creds: S3Credentials,
631
+ key: string,
632
+ body: string,
633
+ condition: { ifMatch?: string; ifNoneMatch?: string },
634
+ ): Promise<void> {
635
+ const protocol = creds.useSsl ? "https" : "http";
636
+ // Endpoint may be host-only or full URL — normalize to a base URL.
637
+ const baseRaw = creds.endpoint.startsWith("http")
638
+ ? creds.endpoint
639
+ : `${protocol}://${creds.endpoint}`;
640
+ const base = new URL(baseRaw);
641
+
642
+ // path-style: <endpoint>/<bucket>/<key>
643
+ const url = new URL(
644
+ `/${creds.bucket}/${key.split("/").map(encodeURIComponent).join("/")}`,
645
+ base,
646
+ );
647
+
648
+ const now = new Date();
649
+ const amzDate = isoBasic(now); // 20260516T012233Z
650
+ const dateStamp = amzDate.slice(0, 8); // 20260516
651
+
652
+ const bodyBytes = new TextEncoder().encode(body);
653
+ const payloadHash = await sha256Hex(bodyBytes);
654
+
655
+ const headers: Record<string, string> = {
656
+ host: url.host,
657
+ "content-type": "text/csv",
658
+ "x-amz-content-sha256": payloadHash,
659
+ "x-amz-date": amzDate,
660
+ };
661
+ if (condition.ifMatch) {
662
+ // Strip surrounding quotes — Ceph RGW (Hetzner Object Storage) rejects
663
+ // the quoted form with 412 even when the ETag matches. AWS S3 and MinIO
664
+ // accept either. Bun.S3Client returns ETags pre-quoted, so we strip.
665
+ headers["if-match"] = condition.ifMatch.replace(/^"|"$/g, "");
666
+ }
667
+ if (condition.ifNoneMatch) headers["if-none-match"] = condition.ifNoneMatch;
668
+
669
+ const signedHeaderNames = Object.keys(headers).sort();
670
+ const canonicalHeaders =
671
+ signedHeaderNames.map((n) => `${n}:${headers[n].trim()}\n`).join("");
672
+ const signedHeaders = signedHeaderNames.join(";");
673
+
674
+ const canonicalRequest = [
675
+ "PUT",
676
+ url.pathname,
677
+ "", // canonical querystring (none)
678
+ canonicalHeaders,
679
+ signedHeaders,
680
+ payloadHash,
681
+ ].join("\n");
682
+
683
+ const region = creds.region;
684
+ const service = "s3";
685
+ const scope = `${dateStamp}/${region}/${service}/aws4_request`;
686
+ const stringToSign = [
687
+ "AWS4-HMAC-SHA256",
688
+ amzDate,
689
+ scope,
690
+ await sha256Hex(new TextEncoder().encode(canonicalRequest)),
691
+ ].join("\n");
692
+
693
+ const kDate = await hmac(`AWS4${creds.secretAccessKey}`, dateStamp);
694
+ const kRegion = await hmac(kDate, region);
695
+ const kService = await hmac(kRegion, service);
696
+ const kSigning = await hmac(kService, "aws4_request");
697
+ const signature = bytesToHex(await hmac(kSigning, stringToSign));
698
+
699
+ const authHeader =
700
+ `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, ` +
701
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
702
+
703
+ const fetchHeaders: Record<string, string> = { ...headers, authorization: authHeader };
704
+
705
+ const resp = await fetch(url.toString(), {
706
+ method: "PUT",
707
+ headers: fetchHeaders,
708
+ body: bodyBytes,
709
+ });
710
+ if (!resp.ok) {
711
+ const text = await resp.text().catch(() => "");
712
+ throw new AuditHttpError(resp.status, `S3 PUT ${resp.status}: ${text || resp.statusText}`);
713
+ }
714
+ }
715
+
716
+ function isoBasic(d: Date): string {
717
+ // 2026-05-16T01:22:33.123Z → 20260516T012233Z
718
+ return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
719
+ }
720
+
721
+ async function sha256Hex(data: Uint8Array | string): Promise<string> {
722
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
723
+ const buf = await crypto.subtle.digest("SHA-256", bytes);
724
+ return bytesToHex(new Uint8Array(buf));
725
+ }
726
+
727
+ async function hmac(
728
+ key: string | Uint8Array,
729
+ msg: string,
730
+ ): Promise<Uint8Array> {
731
+ const keyBytes =
732
+ typeof key === "string" ? new TextEncoder().encode(key) : key;
733
+ const cryptoKey = await crypto.subtle.importKey(
734
+ "raw",
735
+ keyBytes,
736
+ { name: "HMAC", hash: "SHA-256" },
737
+ false,
738
+ ["sign"],
739
+ );
740
+ const sig = await crypto.subtle.sign(
741
+ "HMAC",
742
+ cryptoKey,
743
+ new TextEncoder().encode(msg),
744
+ );
745
+ return new Uint8Array(sig);
746
+ }
747
+
748
+ function bytesToHex(b: Uint8Array): string {
749
+ let s = "";
750
+ for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
751
+ return s;
752
+ }