1import * as SigV4 from "@distilled.cloud/aws/SigV4";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { Worker, WorkerEnvironment } from "../Workers/Worker.ts";
5import type { Bucket } from "./Bucket.ts";
6import type { PresignError } from "./BucketTypes.ts";
7import {
8 makeS3Credentials,
9 type S3CredentialsAccess,
10 type S3CredentialsValue,
11} from "./S3Credentials.ts";
12
13/**
14 * Shared scaffolding for the R2 presign bindings (`PresignGetObjectToken`,
15 * `PresignPutObjectToken`): resolve the bucket's `S3Credentials` and
16 * sign each request locally with SigV4. `makeS3Credentials` handles the
17 * per-mode wiring (a scoped API token when deployed, the Worker's local S3
18 * endpoint under `alchemy dev`).
19 *
20 * NOT exported from `index.ts`.
21 */
22export const makePresignBinding = <Request>(options: {
23 name: string;
24 access: S3CredentialsAccess;
25 presign: (
26 credentials: S3CredentialsValue,
27 request: Request,
28 ) => Effect.Effect<string, PresignError>;
29}) =>
30 Effect.gen(function* () {
31 // Resolved once at layer construction and provided to each bind, so the
32 // binding callable carries no Worker requirements.
33 const host = yield* Worker;
34 const env = yield* WorkerEnvironment;
35
36 return Effect.fn(function* (bucket: Bucket) {
37 const credentials = yield* makeS3Credentials(
38 host,
39 env,
40 bucket,
41 options.access,
42 );
43 return Effect.fn(`${options.name}(${bucket.LogicalId})`)(function* (
44 request: Request,
45 ) {
46 return yield* options.presign(yield* credentials, request);
47 });
48 });
49 });
50
51export interface PresignR2UrlOptions {
52 method: "GET" | "PUT";
53 key: string;
54 /** @default 900 */
55 expiresIn?: number;
56 /** Headers the consumer must send verbatim (signed into the URL). */
57 headers?: Record<string, string>;
58 /** Extra query parameters signed into the URL (e.g. `response-content-type`). */
59 query?: Record<string, string>;
60}
61
62/**
63 * Presign a path-style R2 object URL (`{endpoint}/{bucket}/{key}`) with the
64 * given {@link S3CredentialsValue}. Pure SigV4 — no request is made.
65 */
66export const presignR2Url = (
67 credentials: S3CredentialsValue,
68 options: PresignR2UrlOptions,
69): Effect.Effect<string, PresignError> =>
70 Effect.gen(function* () {
71 const key = options.key.split("/").map(encodeURIComponent).join("/");
72 const url = new URL(
73 `${credentials.endpoint.replace(/\/+$/, "")}/${encodeURIComponent(credentials.bucketName)}/${key}`,
74 );
75 for (const [name, value] of Object.entries(options.query ?? {})) {
76 url.searchParams.set(name, value);
77 }
78 url.searchParams.set("X-Amz-Expires", String(options.expiresIn ?? 900));
79 const signed = yield* SigV4.sign({
80 method: options.method,
81 url: url.toString(),
82 headers: options.headers,
83 accessKeyId: credentials.accessKeyId,
84 secretAccessKey: Redacted.make(credentials.secretAccessKey),
85 service: "s3",
86 region: credentials.region,
87 signQuery: true,
88 // Pin explicitly passed headers (e.g. content-type) into the signature
89 allHeaders: options.headers !== undefined,
90 });
91 return signed.url;
92 });