/* S3-compatible R2 client wrapper for evidence sync and fetch commands (RFC-0651). Does not implement Iceberg REST catalog — deferred to a future RFC. Does not implement multipart uploads — individual files are under the 5 MB threshold. Does not implement retry logic — the operator re-runs the command on failure. RFC-0651: initial R2 client wrapper with putObject, getObject, listObjectsV2. RFC-0713: added envPrefix parameter to resolveR2ConfigFromEnv for per-bucket credential isolation. Default prefix changed from R2_ to R2_AXIOM_ for symmetry with R2_NACHWEIS_. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, type S3ClientConfig, } from "@aws-sdk/client-s3"; export interface R2ClientConfig { accountId: string; accessKeyId: string; secretAccessKey: string; bucketName: string; } export interface R2PutObjectInput { key: string; body: Uint8Array; contentType?: string; } export interface R2GetObjectOutput { key: string; body: Uint8Array; } export interface R2ListObject { key: string; size: number; } export class MissingEnvError extends Error { readonly diagnostic: string; readonly missingVar: string; constructor(missingVar: string) { super(`${missingVar} environment variable is required`); this.name = "MissingEnvError"; this.diagnostic = "MISSING_ENV"; this.missingVar = missingVar; } } export function resolveR2ConfigFromEnv( bucketName = "axiom-evidence", envPrefix = "R2_AXIOM", ): R2ClientConfig { const prefix = `${envPrefix}_`; const accountId = process.env[`${prefix}ACCOUNT_ID`]; if (!accountId) { throw new MissingEnvError(`${prefix}ACCOUNT_ID`); } const accessKeyId = process.env[`${prefix}ACCESS_KEY_ID`]; if (!accessKeyId) { throw new MissingEnvError(`${prefix}ACCESS_KEY_ID`); } const secretAccessKey = process.env[`${prefix}SECRET_ACCESS_KEY`]; if (!secretAccessKey) { throw new MissingEnvError(`${prefix}SECRET_ACCESS_KEY`); } return { accountId, accessKeyId, secretAccessKey, bucketName }; } export function createR2Client(config: R2ClientConfig): { putObject: (input: R2PutObjectInput) => Promise; getObject: (key: string) => Promise; listObjectsV2: (prefix: string) => Promise; rawClient: S3Client; } { const clientConfig: S3ClientConfig = { region: "auto", endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, }, }; const client = new S3Client(clientConfig); async function putObject(input: R2PutObjectInput): Promise { await client.send( new PutObjectCommand({ Bucket: config.bucketName, Key: input.key, Body: input.body, ContentType: input.contentType, }), ); } async function getObject(key: string): Promise { const response = await client.send( new GetObjectCommand({ Bucket: config.bucketName, Key: key, }), ); if (!response.Body) { throw new Error(`[r2-client] getObject: empty body for key '${key}'`); } const body = await response.Body.transformToByteArray(); return { key, body: new Uint8Array(body) }; } async function listObjectsV2(prefix: string): Promise { const objects: R2ListObject[] = []; let continuationToken: string | undefined; do { const response = await client.send( new ListObjectsV2Command({ Bucket: config.bucketName, Prefix: prefix, ContinuationToken: continuationToken, }), ); for (const obj of response.Contents ?? []) { if (obj.Key) { objects.push({ key: obj.Key, size: obj.Size ?? 0 }); } } continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; } while (continuationToken); return objects; } return { putObject, getObject, listObjectsV2, rawClient: client }; }