1import { loadEnv } from "../../config/env.js"; 2import type { S3Client, S3Config } from "@factory/storage"; 3import { S3Client as StorageClient } from "@factory/storage"; 4import { m } from "@factory/observability"; 5 6/** 7 * TASK-0101 — S3 client factory (consolidated on `@factory/storage`). 8 * 9 * The previous `s3-presign.ts` (a local SigV4 signer + single-bucket 10 * config) is DELETED: presigning now lives in `packages/storage` 11 * (query-string SigV4, `presignPut`/`presignGet`) and bucket config comes 12 * from the spec env pair `S3_BUCKET_FILES` / `S3_BUCKET_THUMBS` 13 * (`S3_BUCKET` remains a legacy alias for the files bucket — AGENTS.md 6). 14 * 15 * Decision record: the API talks to the storage layer ONLY through 16 * `@factory/storage` (client + presign + keys + mime) — no second SigV4 17 * implementation in the app tree. 18 */ 19 20export type FilesS3 = Pick< 21 S3Client, 22 | "headObject" 23 | "readPrefix" 24 | "getObject" 25 | "deleteObject" 26 | "listObjects" 27 | "putObject" 28>; 29 30/** Config for the uploaded-objects bucket (files). */ 31export function filesBucketConfig(env = loadEnv()): S3Config { 32 return { 33 endpoint: env.S3_ENDPOINT.replace(/\/+$/, ""), 34 region: env.S3_REGION, 35 bucket: env.S3_BUCKET_FILES || env.S3_BUCKET, 36 accessKeyId: env.S3_ACCESS_KEY, 37 secretAccessKey: env.S3_SECRET_KEY, 38 forcePathStyle: true, 39 }; 40} 41 42/** Config for the thumbnails bucket. */ 43export function thumbsBucketConfig(env = loadEnv()): S3Config { 44 return { 45 endpoint: env.S3_ENDPOINT.replace(/\/+$/, ""), 46 region: env.S3_REGION, 47 bucket: env.S3_BUCKET_THUMBS, 48 accessKeyId: env.S3_ACCESS_KEY, 49 secretAccessKey: env.S3_SECRET_KEY, 50 forcePathStyle: true, 51 }; 52} 53 54/** Signed-URL lifetime (seconds) — spec default 900. */ 55export function signExpirySeconds(env = loadEnv()): number { 56 return env.S3_SIGN_EXPIRY_SECONDS; 57} 58 59/** 60 * TASK-0135: the spec's storage metrics (`storage_ops_total` / 61 * `storage_op_duration_seconds`, op = put|get|delete|head|list). 62 * 63 * Central, not per-call-site: `@factory/storage`'s S3Client already invokes an 64 * `onRequest` observability hook around EVERY actual S3 REST request (the 65 * hook is called once BEFORE and once AFTER the fetch — only the call with a 66 * numeric `durationMs` is a completion). We map the S3 REST verb to the spec's 67 * op label (a path-style LIST has no key → `list`; GET/PUT/DELETE/HEAD on a 68 * key → get/put/delete/head) and record once per completion. No-op when OTel 69 * is disabled (the instruments are no-op). Presigning (`presignPut`/`presignGet`) 70 * is local URL construction — it performs no S3 request, so it is NOT counted 71 * as an op (recorded decision). 72 */ 73function storageMetricsHook(info: { 74 method: string; 75 url: string; 76 status?: number; 77 ok?: boolean; 78 durationMs?: number; 79}): void { 80 if (info.durationMs === undefined) return; // the pre-request call — no duration yet 81 const u = new URL(info.url); 82 // LIST is a bucket-level GET carrying `list-type=2` (see S3Client.listObjects) 83 // — a plain GET on a key is an object download (get), even though object 84 // keys contain slashes. PUT/DELETE/HEAD are unambiguous. 85 const op = 86 info.method === "PUT" 87 ? "put" 88 : info.method === "DELETE" 89 ? "delete" 90 : info.method === "HEAD" 91 ? "head" 92 : u.searchParams.has("list-type") 93 ? "list" 94 : "get"; 95 m.storage.op({ op }, info.durationMs / 1000); 96} 97 98/** A fresh files-bucket client per call is cheap (stateless); DI-friendly. */ 99export function makeFilesClient(env = loadEnv()): S3Client { 100 return new StorageClient({ config: filesBucketConfig(env), onRequest: storageMetricsHook }); 101} 102 103/** A fresh thumbs-bucket client. */ 104export function makeThumbsClient(env = loadEnv()): S3Client { 105 return new StorageClient({ config: thumbsBucketConfig(env), onRequest: storageMetricsHook }); 106} 107 108/** 109 * Delete every object under `prefix` (list-then-delete, paginated past the 110 * 1000-key cap). Best-effort per key (a per-key delete failure does not abort 111 * the sweep — the orphan sweeper backstops any leak). Returns the number of 112 * keys deleted. 113 * 114 * TASK-0104: `File.thumbnailKey` records ONE artifact (the 480 variant), but a 115 * file can own SEVERAL thumbnail objects (160/480/1280 for an image, a first- 116 * page/frame for pdf/video, under `thumbs/{ws}/{fileId}/{size}/…`). Purge must 117 * remove the WHOLE set (spec §3 "…+ thumbnails + DB rows"), not just the single 118 * recorded key — otherwise the other variants become orphans. 119 */ 120export async function deletePrefix(s3: S3Client, prefix: string): Promise<number> { 121 let deleted = 0; 122 let token: string | undefined; 123 for (;;) { 124 const page = await s3.listObjects(prefix, 1000, token); 125 for (const key of page.keys) { 126 await s3.deleteObject(key).catch(() => undefined); 127 deleted += 1; 128 } 129 if (!page.truncated || !page.nextToken) break; 130 token = page.nextToken; 131 } 132 return deleted; 133} 134