| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Redacted from "effect/Redacted"; |
| 3 | import * as Output from "../../Output.ts"; |
| 4 | import { defaultProviderMode } from "../../ProviderMode.ts"; |
| 5 | import { sha256 } from "../../Util/sha256.ts"; |
| 6 | import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; |
| 7 | import type { PermissionGroupRef } from "../ApiToken/Common.ts"; |
| 8 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 9 | import type { Worker } from "../Workers/Worker.ts"; |
| 10 | import type { WorkerBinding } from "../Workers/WorkerBinding.ts"; |
| 11 | import type { Bucket } from "./Bucket.ts"; |
| 12 | import 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 | */ |
| 38 | export 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 | */ |
| 102 | export type BucketInput = Bucket | Effect.Effect<Bucket, never, any>; |
| 103 | |
| 104 | /** Resolve a {@link BucketInput} to the bucket resource. */ |
| 105 | export const resolveBucket = (bucket: BucketInput) => |
| 106 | (Effect.isEffect(bucket) |
| 107 | ? bucket |
| 108 | : Effect.succeed(bucket)) as Effect.Effect<Bucket>; |
| 109 | |
| 110 | const 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 | |
| 116 | const 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 | */ |
| 129 | const 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 | */ |
| 145 | export 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"; |