/*
RFC-0865: R2 durable storage adapter implementing CertificationStorageAdapterV1 via Cloudflare R2 REST API using fetch() with Bearer token auth. No SDK dependency — engine is stack-agnostic.
- Do not implement retention GC, dossier events, or root hash recomputation — those live in repository.ts and retention.ts.
- Do not depend on @aws-sdk/* or any external SDK — use fetch() with Bearer token.
- Do not log, echo, or serialize secret values.
- RFC-0865: initial R2 durable storage adapter for propagate-alt and promote-main gates.
- ADR-0032: Switch from S3 SigV4 signing to Cloudflare R2 REST API with Bearer token — simpler, no SigV4 bugs.
*/
import type { Sha256Digest } from "@warpgogol/werkstatt-shared/fingerprint";
import type {
CertificationStorageAdapterV1,
StoragePutInputV1,
StoragePutResultV1,
StorageHeadResultV1,
} from "./adapter.ts";
export interface R2StorageConfig {
accountId: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
apiToken?: string;
}
const R2_API_BASE = (accountId: string) =>
`https://api.cloudflare.com/client/v4/accounts/${accountId}/r2`;
async function r2ApiFetch(
config: R2StorageConfig,
method: string,
key: string,
extraHeaders: Record = {},
body?: Buffer,
): Promise {
const encodedKey = encodeURIComponent(key);
const url = `${R2_API_BASE(config.accountId)}/buckets/${config.bucketName}/objects/${encodedKey}`;
const headers: Record = {
Authorization: `Bearer ${config.apiToken}`,
...extraHeaders,
};
if (body) {
headers["Content-Length"] = String(body.byteLength);
}
return fetch(url, {
method,
headers,
body: body ? new Uint8Array(body) : undefined,
});
}
export function createR2StorageAdapter(config: R2StorageConfig): CertificationStorageAdapterV1 {
const adapterId = `r2:${config.bucketName}`;
return {
id: adapterId,
async putObject(input: StoragePutInputV1): Promise {
const body = Buffer.from(input.bytes);
const response = await r2ApiFetch(
config,
"PUT",
input.digest,
{
"Content-Type": input.mediaType,
},
body,
);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`CERT-R2-01: putObject failed for digest "${input.digest}" — HTTP ${response.status}: ${text.slice(0, 500)}`,
);
}
const etag = response.headers.get("etag") ?? "";
return {
locator: `r2://${config.bucketName}/${input.digest}#${etag}`,
sizeBytes: body.byteLength,
};
},
async headObject(digest: Sha256Digest): Promise {
const response = await r2ApiFetch(config, "GET", digest, {
Range: "bytes=0-0",
});
if (response.status === 404) {
return { exists: false };
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`CERT-R2-02: headObject failed for digest "${digest}" — HTTP ${response.status}: ${text.slice(0, 500)}`,
);
}
const contentRange = response.headers.get("content-range") ?? "";
const sizeMatch = contentRange.match(/\/(\d+)$/);
const contentLength = response.headers.get("content-length");
const etag = response.headers.get("etag") ?? "";
return {
exists: true,
sizeBytes: sizeMatch
? Number(sizeMatch[1])
: contentLength
? Number(contentLength)
: undefined,
digest,
locator: `r2://${config.bucketName}/${digest}#${etag}`,
};
},
async getObject(digest: Sha256Digest): Promise {
const response = await r2ApiFetch(config, "GET", digest);
if (response.status === 404) {
throw new Error(`CERT-STORAGE-01: object "${digest}" not found in adapter "${adapterId}"`);
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`CERT-R2-03: getObject failed for digest "${digest}" — HTTP ${response.status}: ${text.slice(0, 500)}`,
);
}
const buf = Buffer.from(await response.arrayBuffer());
return new Uint8Array(buf);
},
async appendAuditRecord(record: Uint8Array): Promise<{ locator: string }> {
const timestamp = Date.now();
const key = `audit/${timestamp}-${Math.random().toString(36).slice(2, 10)}`;
const body = Buffer.from(record);
const response = await r2ApiFetch(
config,
"PUT",
key,
{
"Content-Type": "application/octet-stream",
},
body,
);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`CERT-R2-04: appendAuditRecord failed — HTTP ${response.status}: ${text.slice(0, 500)}`,
);
}
const etag = response.headers.get("etag") ?? "";
return {
locator: `r2://${config.bucketName}/${key}#${etag}`,
};
},
};
}