/** * AI Software Factory — MinIO/S3 bucket bootstrap (TASK-0101, Backend Changes). * * Idempotently creates the two PRIVATE file buckets if they do not exist and * verifies they reject anonymous access: * * S3_BUCKET_FILES (default `factory-files`) — uploaded objects, * `files/{ws}/{fileId}/{v}/{name}` * S3_BUCKET_THUMBS (default `factory-thumbs`) — thumbnail/preview artifacts, * `thumbs/{ws}/{fileId}/{size}/{name}` * * Both buckets are created with NO public access (S3 buckets default to * private; the script additionally verifies an anonymous GET is rejected * with 403 — TASK-0101 acceptance "two buckets verified private"). * * Uses `@factory/storage`'s SigV4 (`signS3Request` + `S3Client`-style * requests) against the raw endpoint — no SDK. Runs from the repo root: * * pnpm storage:bootstrap * * Env (all optional, defaults match local dev): S3_ENDPOINT * (http://localhost:9000), S3_REGION (us-east-1), S3_ACCESS_KEY / * S3_SECRET_KEY (minioadmin), S3_BUCKET_FILES, S3_BUCKET_THUMBS. A `.env` * file at the repo root is loaded when present (same keys). */ import { readFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { signS3Request, type S3Config } from "@factory/storage"; // ── env ───────────────────────────────────────────────────────────────────── const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); /** Minimal `.env` loader (KEY=VALUE lines, `#` comments) for this script. */ function loadDotEnv(): Record { const out: Record = {}; const path = join(REPO_ROOT, ".env"); if (!existsSync(path)) return out; for (const line of readFileSync(path, "utf8").split("\n")) { const t = line.trim(); if (t === "" || t.startsWith("#")) continue; const eq = t.indexOf("="); if (eq <= 0) continue; out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim(); } return out; } const fileEnv = loadDotEnv(); const env = (key: string, fallback: string): string => process.env[key] ?? fileEnv[key] ?? fallback; const config: S3Config = { endpoint: env("S3_ENDPOINT", "http://localhost:9000").replace(/\/+$/, ""), region: env("S3_REGION", "us-east-1"), bucket: "", // set per-request below accessKeyId: env("S3_ACCESS_KEY", "minioadmin"), secretAccessKey: env("S3_SECRET_KEY", "minioadmin"), forcePathStyle: true, }; const FILES_BUCKET = env("S3_BUCKET_FILES", "factory-files"); const THUMBS_BUCKET = env("S3_BUCKET_THUMBS", "factory-thumbs"); // ── signed request helper ─────────────────────────────────────────────────── const EMPTY_SHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; function sign(bucket: string, method: string, path: string): Record { const signed = signS3Request({ method, path, query: {}, headers: {}, payloadHash: EMPTY_SHA, config: { ...config, bucket }, now: new Date(), alwaysSignContentSha: true, }); return signed.headers; } async function rawReq( method: string, path: string, bucket: string, signedHeaders?: Record, ): Promise<{ status: number; body: string }> { const headers = signedHeaders ?? sign(bucket, method, path); const res = await fetch(`${config.endpoint}${path}`, { method, headers }); const text = await res.text(); return { status: res.status, body: text }; } // ── bucket ops ────────────────────────────────────────────────────────────── async function bucketState(bucket: string): Promise<"exists" | "missing" | "error"> { // ListAllMyBuckets (GET /) is the AUTHORITATIVE existence check. A plain // `HEAD /{bucket}` is NOT: MinIO answers 403 AccessDenied for BOTH a missing // bucket and a bucket the identity does not own, so a HEAD-based check // silently "finds" buckets that do not exist (observed against the local // MinIO: HEAD /factory-files → 403 while `GET /` listed zero buckets). const r = await rawReq("GET", "/", bucket); if (r.status !== 200) { console.error(` ! unexpected ListAllMyBuckets status ${r.status}: ${r.body.slice(0, 200)}`); return "error"; } const exists = new RegExp(`${bucket}`).test(r.body); return exists ? "exists" : "missing"; } async function ensureBucket(bucket: string): Promise { const state = await bucketState(bucket); if (state === "exists") { console.log(` ✓ ${bucket} exists`); } else if (state === "missing") { const r = await rawReq("PUT", `/${bucket}`, bucket); if (r.status === 200) { console.log(` + ${bucket} created`); } else { throw new Error(`PUT /${bucket} failed: ${r.status} ${r.body.slice(0, 300)}`); } } else { throw new Error(`could not determine state of ${bucket}`); } // Private verification: anonymous (UNSIGNED) GET on the bucket must be 403. const anon = await fetch(`${config.endpoint}/${bucket}`, { method: "GET" }); if (anon.status !== 403) { throw new Error( `bucket ${bucket} is NOT private: anonymous GET returned ${anon.status} (expected 403)`, ); } console.log(` ✓ ${bucket} verified private (anonymous GET → 403)`); } // ── main ──────────────────────────────────────────────────────────────────── async function main(): Promise { console.log(`minio-init: endpoint ${config.endpoint} (region ${config.region})`); for (const bucket of [FILES_BUCKET, THUMBS_BUCKET]) { await ensureBucket(bucket); } console.log(`minio-init: buckets ${FILES_BUCKET} + ${THUMBS_BUCKET} ready (private)`); } main().catch((err) => { console.error(`minio-init failed: ${(err as Error).message}`); process.exit(1); });