1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import * as Output from "../../Output.ts";
4import { defaultProviderMode } from "../../ProviderMode.ts";
5import { sha256 } from "../../Util/sha256.ts";
6import { AccountApiToken } from "../ApiToken/AccountApiToken.ts";
7import type { PermissionGroupRef } from "../ApiToken/Common.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Worker } from "../Workers/Worker.ts";
10import type { WorkerBinding } from "../Workers/WorkerBinding.ts";
11import type { Bucket } from "./Bucket.ts";
12import type {
13 S3Credentials,
14 S3CredentialsAccess,
15 S3CredentialsValue,
16} from "./S3Credentials.ts";
17
18/**
19 * Deploy-time half of `Cloudflare.R2.S3Credentials`, shared by async Worker
20 * `env` declarations and the Effect-native bindings (`S3Credentials`,
21 * `PresignGetObjectToken`, `PresignPutObjectToken`).
22 *
23 * Binds `bindingName` on the host Worker to the JSON-encoded
24 * {@link S3CredentialsValue} for `bucket`:
25 *
26 * - **live** — mints (or extends) the Worker's scoped
27 * {@link AccountApiToken} with the permission groups for `access` and
28 * injects a `secret_text` binding. R2 derives S3 credentials from any API
29 * token: the access key id is the token id and the secret access key is
30 * the SHA-256 of the token value.
31 * - **local** (`alchemy dev`, `dev:` bucket) — injects the dev-only
32 * `r2_s3_credentials` binding. The local runtime lowers it to the Worker's
33 * local S3 endpoint and fixed local credentials, and serves the bucket on
34 * that endpoint. No token or cloud call is involved.
35 *
36 * NOT exported from `index.ts`.
37 */
38export const bindS3Credentials = Effect.fn(function* (
39 host: Worker,
40 bindingName: string,
41 bucket: Bucket,
42 access: S3CredentialsAccess,
43) {
44 if (yield* isLocal(host, bucket)) {
45 yield* host.bind`${bindingName}`({
46 bindings: [
47 {
48 type: "r2_s3_credentials",
49 name: bindingName,
50 bucketName: bucket.bucketName,
51 },
52 ],
53 });
54 return;
55 }
56
57 const { accountId } = yield* yield* CloudflareEnvironment;
58 const token = yield* AccountApiToken(`${host.LogicalId}Token`);
59 // One binding id per bucket + access level: bindings sharing an id
60 // collapse to one, which would drop a write grant when a read binding
61 // targets the same bucket (and vice versa).
62 yield* token.bind`Cloudflare.R2.S3Credentials(${bucket.LogicalId}, ${access})`(
63 {
64 policies: [
65 {
66 effect: "allow",
67 permissionGroups: PERMISSION_GROUPS[access],
68 resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
69 },
70 ],
71 },
72 );
73 const binding = Output.all(
74 token.tokenId,
75 token.value,
76 bucket.bucketName,
77 bucket.jurisdiction,
78 ).pipe(
79 Output.mapEffect(([tokenId, value, bucketName, jurisdiction]) =>
80 sha256(Redacted.value(value)).pipe(
81 Effect.map((secretAccessKey): WorkerBinding => ({
82 type: "secret_text",
83 name: bindingName,
84 text: JSON.stringify({
85 endpoint: liveEndpoint(accountId, jurisdiction),
86 bucketName,
87 region: "auto",
88 accessKeyId: tokenId,
89 secretAccessKey,
90 } satisfies S3CredentialsValue),
91 })),
92 ),
93 ),
94 );
95 yield* host.bind`${bindingName}`({ bindings: [binding] });
96});
97
98/**
99 * A bucket, or its declaration (`Cloudflare.R2.Bucket("Uploads")`), which
100 * is resolved when the credentials are bound.
101 */
102export type BucketInput = Bucket | Effect.Effect<Bucket, never, any>;
103
104/** Resolve a {@link BucketInput} to the bucket resource. */
105export const resolveBucket = (bucket: BucketInput) =>
106 (Effect.isEffect(bucket)
107 ? bucket
108 : Effect.succeed(bucket)) as Effect.Effect<Bucket>;
109
110const PERMISSION_GROUPS: Record<S3CredentialsAccess, PermissionGroupRef[]> = {
111 read: ["Workers R2 Storage Read"],
112 write: ["Workers R2 Storage Write"],
113 "read-write": ["Workers R2 Storage Read", "Workers R2 Storage Write"],
114};
115
116const liveEndpoint = (
117 accountId: string,
118 jurisdiction: Bucket.Jurisdiction,
119): string =>
120 jurisdiction === "default"
121 ? `https://${accountId}.r2.cloudflarestorage.com`
122 : `https://${accountId}.${jurisdiction}.r2.cloudflarestorage.com`;
123
124/**
125 * Whether both the Worker and the bucket are emulated locally in this run.
126 * Each resolves its own mode: pinned via `Alchemy.remote()` → live;
127 * otherwise the run default (`alchemy dev` → local).
128 */
129const isLocal = (host: Worker, bucket: Bucket) =>
130 Effect.gen(function* () {
131 const runDefault = yield* defaultProviderMode;
132 return (
133 (host.Mode ?? runDefault) === "local" &&
134 (bucket.Mode ?? runDefault) === "local"
135 );
136 });
137
138/**
139 * Whether an env value is a `Cloudflare.R2.S3Credentials` declaration (keyed
140 * on the static `~alchemy/Kind` marker — the value is also an Effect, so env
141 * resolution must check this before yielding Effects). Lives here, free of
142 * value imports from `Worker.ts`, so async env resolution can use it without
143 * a module cycle.
144 */
145export const isS3Credentials = (value: unknown): value is S3Credentials =>
146 typeof value === "object" &&
147 value !== null &&
148 "~alchemy/Kind" in value &&
149 (value as S3Credentials)["~alchemy/Kind"] === "Cloudflare.R2.S3Credentials";