1import * as Effect from "effect/Effect";
2import * as CoreBinding from "../../Binding.ts";
3import type { RuntimeContext } from "../../RuntimeContext.ts";
4import { sanitizeKey } from "../../RuntimeContext.ts";
5import { isWorker, type Worker, WorkerEnvironment } from "../Workers/Worker.ts";
6import type { Bucket } from "./Bucket.ts";
7import {
8 bindS3Credentials,
9 type BucketInput,
10 resolveBucket,
11} from "./S3CredentialsBinding.ts";
12
13export { isS3Credentials } from "./S3CredentialsBinding.ts";
14
15/** Which object operations the credentials' API token is granted. */
16export type S3CredentialsAccess = "read" | "write" | "read-write";
17
18export interface S3CredentialsOptions {
19 /**
20 * Object operations the credentials may perform. Deployed, this selects
21 * the permission groups of the scoped API token they are derived from
22 * (`Workers R2 Storage Read` and/or `Workers R2 Storage Write`).
23 */
24 access: S3CredentialsAccess;
25}
26
27/**
28 * S3 API credentials and endpoint for one R2 bucket, as delivered to the
29 * Worker. Use them with any S3 client (`aws4fetch`, `@aws-sdk/client-s3`)
30 * with path-style addressing: `{endpoint}/{bucketName}/{key}`.
31 */
32export interface S3CredentialsValue {
33 /**
34 * S3 endpoint, without the bucket: `https://{accountId}.r2.cloudflarestorage.com`
35 * when deployed, `{worker url}/cdn-cgi/local/r2/s3` under `alchemy dev`.
36 */
37 endpoint: string;
38 /** Bucket name — the path segment after the endpoint. */
39 bucketName: string;
40 /** SigV4 signing region. */
41 region: "auto";
42 accessKeyId: string;
43 secretAccessKey: string;
44}
45
46/**
47 * The value returned by {@link S3Credentials}: declarable on an async
48 * Worker's `env` (where it arrives as a JSON string) and `yield*`-able in an
49 * Effect-native Worker (where it resolves to a deferred accessor).
50 */
51export interface S3Credentials extends Effect.Effect<
52 Effect.Effect<S3CredentialsValue, never, RuntimeContext>,
53 never,
54 WorkerEnvironment
55> {
56 readonly "~alchemy/Kind": "Cloudflare.R2.S3Credentials";
57 readonly bucket: BucketInput;
58 readonly access: S3CredentialsAccess;
59}
60
61/**
62 * S3 API credentials for an R2 bucket that work unchanged in `alchemy dev`
63 * and when deployed — for presigned URLs or any S3 client.
64 *
65 * The native R2 binding cannot presign or speak the S3 API: that requires S3
66 * credentials and R2's S3 endpoint. `S3Credentials` provides both, resolved
67 * per mode:
68 *
69 * - **Deployed** — Alchemy mints a scoped account API token for the Worker
70 * and derives S3 credentials from it (access key id = token id, secret =
71 * SHA-256 of the token value). The endpoint is
72 * `https://{accountId}.r2.cloudflarestorage.com` (`.eu.`/`.fedramp.` for
73 * jurisdictional buckets). The value is injected as a secret.
74 * - **`alchemy dev`** — a locally-emulated bucket is served on the Worker's
75 * local S3 endpoint (`{worker url}/cdn-cgi/local/r2/s3`) with fixed local
76 * credentials. No token or cloud call is involved, and objects are shared
77 * with the Worker's native bindings of the bucket.
78 *
79 * The Effect-native presign bindings (`PresignGetObjectToken`,
80 * `PresignPutObjectToken`) are built on it.
81 *
82 * ### Effect-native Workers
83 * **Example:** Read the credentials at runtime
84 * ```typescript
85 * const credentials = yield* Cloudflare.R2.S3Credentials(Uploads, {
86 * access: "read-write",
87 * });
88 * // inside a handler:
89 * const { endpoint, bucketName, accessKeyId, secretAccessKey } =
90 * yield* credentials;
91 * ```
92 *
93 * ### Async Workers
94 * **Example:** Presign an upload with aws4fetch
95 * ```typescript
96 * // alchemy.run.ts
97 * export const Uploads = Cloudflare.R2.Bucket("Uploads");
98 * export const Api = Cloudflare.Worker("Api", {
99 * main: "./src/worker.ts",
100 * env: {
101 * UPLOADS_S3: Cloudflare.R2.S3Credentials(Uploads, { access: "write" }),
102 * },
103 * });
104 * export type ApiEnv = Cloudflare.InferEnv<typeof Api>;
105 *
106 * // src/worker.ts
107 * import { AwsClient } from "aws4fetch";
108 * import type * as Cloudflare from "alchemy/Cloudflare";
109 * import type { ApiEnv } from "../alchemy.run.ts";
110 *
111 * export default {
112 * async fetch(request: Request, env: ApiEnv) {
113 * // `UPLOADS_S3` is a JSON string (a secret when deployed)
114 * const s3: Cloudflare.R2.S3CredentialsValue = JSON.parse(env.UPLOADS_S3);
115 * const client = new AwsClient({ ...s3, service: "s3" });
116 * const url = new URL(`${s3.endpoint}/${encodeURIComponent(s3.bucketName)}/avatar.png`);
117 * url.searchParams.set("X-Amz-Expires", "900");
118 * const signed = await client.sign(url.toString(), {
119 * method: "PUT",
120 * aws: { signQuery: true },
121 * });
122 * return Response.json({ url: signed.url });
123 * },
124 * };
125 * ```
126 *
127 * @binding
128 * @product R2
129 * @category Storage & Databases
130 */
131export const S3Credentials = (
132 bucket: BucketInput,
133 options: S3CredentialsOptions,
134): S3Credentials => {
135 const { access } = options;
136 return Object.assign(
137 Effect.gen(function* () {
138 const host = yield* CoreBinding.Host;
139 return yield* makeS3Credentials(
140 isWorker(host) ? host : undefined,
141 yield* WorkerEnvironment,
142 yield* resolveBucket(bucket),
143 access,
144 );
145 }),
146 {
147 "~alchemy/Kind": "Cloudflare.R2.S3Credentials" as const,
148 bucket,
149 access,
150 },
151 );
152};
153
154/**
155 * Bind {@link S3Credentials} for `bucket` on `host` (deploy time) and return
156 * the runtime accessor. `host` is only read at deploy time; it is
157 * `undefined` inside the deployed Worker.
158 *
159 * NOT exported from `index.ts`.
160 */
161export const makeS3Credentials = (
162 host: Worker | undefined,
163 env: WorkerEnvironment["Service"],
164 bucket: Bucket,
165 access: S3CredentialsAccess,
166) =>
167 Effect.gen(function* () {
168 const bindingName = sanitizeKey(
169 `R2_S3_CREDENTIALS_${bucket.LogicalId}_${access}`,
170 ).toUpperCase();
171 if (!globalThis.__ALCHEMY_RUNTIME__) {
172 if (host === undefined) {
173 return yield* Effect.die(
174 new Error(
175 `Cloudflare.R2.S3Credentials(${bucket.LogicalId}) can only be bound to a Cloudflare Worker.`,
176 ),
177 );
178 }
179 // Deploy-time only; its requirements (CloudflareEnvironment, the
180 // token provider) are ambient wherever a Worker is evaluated, so they
181 // are erased like `Binding.Host`'s.
182 yield* bindS3Credentials(
183 host,
184 bindingName,
185 bucket,
186 access,
187 ) as Effect.Effect<void>;
188 }
189 // Captured at init; read at exec phase, where it is populated.
190 return Effect.suspend(() => {
191 const raw = (env as Record<string, unknown>)[bindingName];
192 return typeof raw === "string"
193 ? Effect.succeed(JSON.parse(raw) as S3CredentialsValue)
194 : Effect.die(
195 new Error(
196 `Cloudflare.R2.S3Credentials(${bucket.LogicalId}) is not bound to this Worker: missing "${bindingName}".`,
197 ),
198 );
199 }) as Effect.Effect<S3CredentialsValue, never, RuntimeContext>;
200 });