// 🤖 The IO half of the ledger streams (issue #1224): fetch HEAD, append // entries, put blobs and derived views. Reads ride the bucket's public https // door (no credentials); writes shell out to the AWS CLI (the repo's // standing convention — no SDK dependency, human-runnable one command at a // time). Credentials come from the ambient AWS profile/env: the scoped // status-ledger-writer key (PutObject on ledgers/* only — infra/prod/ // status.tf), or an operator's own. import { execFileSync } from "node:child_process"; import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildEntry, sha256hex } from "./ledgerchain.mjs"; export const BUCKET = process.env.STATUS_BUCKET || "eleven-status-427880590669"; export const REGION = process.env.STATUS_REGION || "eu-west-1"; const publicUrl = (key) => "https://" + BUCKET + ".s3." + REGION + ".amazonaws.com/" + key; export async function fetchPublic(key) { const r = await fetch(publicUrl(key)); if (r.status === 404 || r.status === 403) return null; // absent (403 = no such key without list rights) if (!r.ok) throw new Error("GET " + key + " → " + r.status); return Buffer.from(await r.arrayBuffer()); } export function putObject(key, bytes, contentType) { const dir = mkdtempSync(join(tmpdir(), "ledger-")); const f = join(dir, "body"); try { writeFileSync(f, bytes); execFileSync("aws", ["s3api", "put-object", "--bucket", BUCKET, "--key", key, "--body", f, "--content-type", contentType, "--region", REGION], { stdio: ["ignore", "ignore", "inherit"] }); } finally { rmSync(dir, { recursive: true, force: true }); } } export const headKey = (ledger) => "ledgers/" + ledger + "/HEAD.json"; export async function getHead(ledger) { const bytes = await fetchPublic(headKey(ledger)); return bytes ? JSON.parse(bytes.toString("utf8")) : null; } // Append one entry and advance HEAD. Returns the new head descriptor. export async function append(ledger, writer, ts, payload) { const head = await getHead(ledger); const prev = head ? { key: head.head_key, sha256: head.head_sha256 } : null; const { bytes, sha256, key } = buildEntry({ ledger, writer, ts, payload, prev }); putObject(key, bytes, "application/json"); const next = { updated_at: ts, head_key: key, head_sha256: sha256, count: (head ? head.count : 0) + 1, }; putObject(headKey(ledger), Buffer.from(JSON.stringify(next, null, 1) + "\n"), "application/json"); return next; } // Content-addressed blob beside the stream (album art etc.): write-once by // construction — the key IS the hash, so re-putting identical bytes is a // no-op and different bytes get a different key. Returns the page-relative // path the derived views embed (same-origin: the no-external-hosts rule). export async function putBlob(ledger, bytes, ext, contentType) { const key = "ledgers/" + ledger + "/blobs/" + sha256hex(bytes) + "." + ext; if (!(await fetchPublic(key))) putObject(key, bytes, contentType); return "ledgers/" + ledger + "/blobs/" + sha256hex(bytes) + "." + ext; } // Derived views (an index, month manifests): rebuildable conveniences the // pages read in one fetch — the entries remain the canonical record. export function putDerived(ledger, name, obj) { putObject("ledgers/" + ledger + "/" + name, Buffer.from(JSON.stringify(obj, null, 1) + "\n"), "application/json"); }