| 1 | // π€ The IO half of the ledger streams (issue #1224): fetch HEAD, append |
| 2 | // entries, put blobs and derived views. Reads ride the bucket's public https |
| 3 | // door (no credentials); writes shell out to the AWS CLI (the repo's |
| 4 | // standing convention β no SDK dependency, human-runnable one command at a |
| 5 | // time). Credentials come from the ambient AWS profile/env: the scoped |
| 6 | // status-ledger-writer key (PutObject on ledgers/* only β infra/prod/ |
| 7 | // status.tf), or an operator's own. |
| 8 | import { execFileSync } from "node:child_process"; |
| 9 | import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; |
| 10 | import { tmpdir } from "node:os"; |
| 11 | import { join } from "node:path"; |
| 12 | import { buildEntry, sha256hex } from "./ledgerchain.mjs"; |
| 13 | |
| 14 | export const BUCKET = process.env.STATUS_BUCKET || "eleven-status-427880590669"; |
| 15 | export const REGION = process.env.STATUS_REGION || "eu-west-1"; |
| 16 | const publicUrl = (key) => "https://" + BUCKET + ".s3." + REGION + ".amazonaws.com/" + key; |
| 17 | |
| 18 | export async function fetchPublic(key) { |
| 19 | const r = await fetch(publicUrl(key)); |
| 20 | if (r.status === 404 || r.status === 403) return null; // absent (403 = no such key without list rights) |
| 21 | if (!r.ok) throw new Error("GET " + key + " β " + r.status); |
| 22 | return Buffer.from(await r.arrayBuffer()); |
| 23 | } |
| 24 | |
| 25 | export function putObject(key, bytes, contentType) { |
| 26 | const dir = mkdtempSync(join(tmpdir(), "ledger-")); |
| 27 | const f = join(dir, "body"); |
| 28 | try { |
| 29 | writeFileSync(f, bytes); |
| 30 | execFileSync("aws", ["s3api", "put-object", |
| 31 | "--bucket", BUCKET, "--key", key, "--body", f, |
| 32 | "--content-type", contentType, "--region", REGION], { stdio: ["ignore", "ignore", "inherit"] }); |
| 33 | } finally { |
| 34 | rmSync(dir, { recursive: true, force: true }); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | export const headKey = (ledger) => "ledgers/" + ledger + "/HEAD.json"; |
| 39 | |
| 40 | export async function getHead(ledger) { |
| 41 | const bytes = await fetchPublic(headKey(ledger)); |
| 42 | return bytes ? JSON.parse(bytes.toString("utf8")) : null; |
| 43 | } |
| 44 | |
| 45 | // Append one entry and advance HEAD. Returns the new head descriptor. |
| 46 | export async function append(ledger, writer, ts, payload) { |
| 47 | const head = await getHead(ledger); |
| 48 | const prev = head ? { key: head.head_key, sha256: head.head_sha256 } : null; |
| 49 | const { bytes, sha256, key } = buildEntry({ ledger, writer, ts, payload, prev }); |
| 50 | putObject(key, bytes, "application/json"); |
| 51 | const next = { |
| 52 | updated_at: ts, |
| 53 | head_key: key, |
| 54 | head_sha256: sha256, |
| 55 | count: (head ? head.count : 0) + 1, |
| 56 | }; |
| 57 | putObject(headKey(ledger), Buffer.from(JSON.stringify(next, null, 1) + "\n"), "application/json"); |
| 58 | return next; |
| 59 | } |
| 60 | |
| 61 | // Content-addressed blob beside the stream (album art etc.): write-once by |
| 62 | // construction β the key IS the hash, so re-putting identical bytes is a |
| 63 | // no-op and different bytes get a different key. Returns the page-relative |
| 64 | // path the derived views embed (same-origin: the no-external-hosts rule). |
| 65 | export async function putBlob(ledger, bytes, ext, contentType) { |
| 66 | const key = "ledgers/" + ledger + "/blobs/" + sha256hex(bytes) + "." + ext; |
| 67 | if (!(await fetchPublic(key))) putObject(key, bytes, contentType); |
| 68 | return "ledgers/" + ledger + "/blobs/" + sha256hex(bytes) + "." + ext; |
| 69 | } |
| 70 | |
| 71 | // Derived views (an index, month manifests): rebuildable conveniences the |
| 72 | // pages read in one fetch β the entries remain the canonical record. |
| 73 | export function putDerived(ledger, name, obj) { |
| 74 | putObject("ledgers/" + ledger + "/" + name, Buffer.from(JSON.stringify(obj, null, 1) + "\n"), "application/json"); |
| 75 | } |
| 76 | |