1import type {
2 AwsCredentialProviderError,
3 ResolvedCredentials,
4} from "@distilled.cloud/aws/Credentials";
5import * as Endpoint from "@distilled.cloud/aws/Endpoint";
6import { Region } from "@distilled.cloud/aws/Region";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Redacted from "effect/Redacted";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import { AlchemyContext } from "../../AlchemyContext.ts";
13import * as Binding from "../../Binding.ts";
14import type { Input } from "../../Input.ts";
15import type { PolicyStatement } from "../IAM/Policy.ts";
16import type { Output as OutputType } from "../../Output.ts";
17import type { ResourceLike } from "../../Resource.ts";
18import { DEFAULT_LOCAL_ENDPOINT } from "../AuthProvider.ts";
19import { Credentials, makeAssumeRoleResolver } from "../Credentials.ts";
20import { AccessKey } from "../IAM/AccessKey.ts";
21import { Role } from "../IAM/Role.ts";
22import { User } from "../IAM/User.ts";
23import type { Function } from "./Function.ts";
24import { isBindingHost } from "./Function.ts";
25
26/**
27 * Shared scaffolding for AWS Lambda control/data-plane HTTP bindings.
28 *
29 * NOT exported from `index.ts` — every near-identical `{Op}Http.ts` in this
30 * service is a thin `Layer.effect(Cap, make…HttpBinding({ … }))` over one of
31 * the builders below. Everything except the operation, the IAM action list,
32 * and the granted ARNs is boilerplate. The MicroVM family
33 * (`MicrovmBinding.ts`) shares only the cross-cloud host handling.
34 */
35
36// ---------------------------------------------------------------------------
37// Cross-cloud host: a Cloudflare Worker reaching AWS.
38//
39// A Lambda host grants on its execution role and its credentials are ambient.
40// A Worker has neither, so a binding on a Worker declares an IAM User +
41// AccessKey + a least-privilege Role (the engine de-dupes these by logical id,
42// so every binding on the host shares one identity), binds the key and role
43// ARN onto the Worker, and assumes the role at runtime through the
44// single-flight, expiry-aware resolver from `Credentials.ts`.
45// ---------------------------------------------------------------------------
46
47// The Worker is recognised structurally by its Type id so this module never
48// imports the Cloudflare module graph.
49const WORKER_TYPE_ID = "Cloudflare.Worker";
50export type WorkerHost = ResourceLike & {
51 bind: (
52 template: TemplateStringsArray,
53 ...args: unknown[]
54 ) => (binding: Input<{ bindings?: unknown[] }>) => Effect.Effect<void>;
55};
56export const isWorkerHost = (host: ResourceLike): host is WorkerHost =>
57 (host as { Type?: string }).Type === WORKER_TYPE_ID;
58
59/** Region segment of an AWS ARN (`arn:partition:service:<region>:...`). */
60export const regionFromArn = (arn: string): string =>
61 arn.split(":")[3] ?? "us-east-1";
62
63export interface WorkerAwsAccess {
64 /** The least-privilege Role each binding contributes statements to. */
65 readonly role: Role;
66 /** Assumed-role credentials, cached and refreshed near expiry. */
67 readonly credentials: Effect.Effect<
68 ResolvedCredentials,
69 AwsCredentialProviderError
70 >;
71}
72
73/**
74 * The IAM identity a Cloudflare Worker uses to reach AWS: `${id}User` holding
75 * `${id}AccessKey`, allowed to assume `${id}Role`, which every binding on the
76 * Worker grants its statements to. Yielding the key and role attributes
77 * registers them on the Worker at deploy and reads them from its env at
78 * runtime, so the declarations run in both phases.
79 */
80export const workerAwsAccess = Effect.fn(function* (host: WorkerHost) {
81 const id = host.LogicalId;
82 // Yielding a resource class gives a constructor whose providers are the
83 // host stack's (the same way the Cloudflare `*Http` bindings mint tokens).
84 const IamUser = yield* User;
85 const IamAccessKey = yield* AccessKey;
86 const IamRole = yield* Role;
87
88 // The user may assume any role that trusts it; the role's trust policy is
89 // what restricts assumption to this user, which avoids a User↔Role ARN
90 // dependency cycle.
91 const user = yield* IamUser(`${id}User`, {
92 inlinePolicies: {
93 AssumeRole: {
94 Version: "2012-10-17",
95 Statement: [
96 { Effect: "Allow", Action: ["sts:AssumeRole"], Resource: ["*"] },
97 ],
98 },
99 },
100 });
101 const accessKey = yield* IamAccessKey(`${id}AccessKey`, {
102 userName: user.userName,
103 });
104 const role = yield* IamRole(`${id}Role`, {
105 assumeRolePolicyDocument: {
106 Version: "2012-10-17",
107 Statement: [
108 {
109 Effect: "Allow",
110 Principal: { AWS: user.userArn },
111 Action: ["sts:AssumeRole"],
112 },
113 ],
114 },
115 });
116
117 // Local dev: STS and the bound operations run on the emulator, reached via
118 // the same `AWS_ENDPOINT_URL` the emulator injects into Lambda containers
119 // (read at runtime by `Endpoint.fromEnv()` below).
120 if (!globalThis.__ALCHEMY_RUNTIME__) {
121 const context = yield* Effect.serviceOption(AlchemyContext);
122 if (Option.isSome(context) && context.value.dev) {
123 yield* host.bind`${id}Endpoint`({
124 bindings: [
125 {
126 type: "plain_text",
127 name: "AWS_ENDPOINT_URL",
128 text: DEFAULT_LOCAL_ENDPOINT,
129 },
130 ],
131 });
132 }
133 }
134
135 const accessKeyId = yield* accessKey.accessKeyId;
136 const secretAccessKey = yield* accessKey.secretAccessKey;
137 const roleArn = yield* role.roleArn;
138
139 // The long-lived user credentials that sign `AssumeRole`, read from the
140 // Worker env on each refresh (not captured: at deploy the env is empty).
141 const base = Layer.succeed(
142 Credentials,
143 Effect.gen(function* () {
144 const id = yield* accessKeyId;
145 const secret = yield* secretAccessKey;
146 return {
147 accessKeyId: Redacted.make(id),
148 secretAccessKey: secret
149 ? Redacted.isRedacted(secret)
150 ? secret
151 : Redacted.make(secret)
152 : Redacted.make(""),
153 sessionToken: undefined,
154 // STS is global; operations provide their own Region per call.
155 region: "us-east-1",
156 } satisfies ResolvedCredentials;
157 }),
158 );
159 const credentials = yield* makeAssumeRoleResolver({
160 roleArn,
161 base,
162 region: "us-east-1",
163 });
164 return { role, credentials } satisfies WorkerAwsAccess;
165});
166
167/**
168 * What a binding grants its host: the IAM statements, under a label that
169 * names the grant. Built only at deploy.
170 */
171export interface HostGrant {
172 readonly label: string;
173 readonly policyStatements: Input<PolicyStatement>[];
174}
175
176/**
177 * Resolve how this binding's host reaches AWS and grant it `grant()`.
178 *
179 * A Lambda-like host has ambient credentials and takes the statements on its
180 * execution role, so the result is `undefined`. A Cloudflare Worker gets a
181 * {@link WorkerAwsAccess}, whose role takes the statements. The identity is
182 * needed at runtime (it is where the credentials come from); the grant is
183 * deploy-only and sits behind the one `__ALCHEMY_RUNTIME__` guard so the
184 * bundler drops it from the Worker.
185 */
186export const hostAwsAccess = Effect.fn(function* (
187 host: ResourceLike | undefined,
188 grant: () => HostGrant,
189) {
190 const access =
191 host !== undefined && isWorkerHost(host)
192 ? yield* workerAwsAccess(host)
193 : undefined;
194 if (!globalThis.__ALCHEMY_RUNTIME__) {
195 const grantee = isBindingHost(host) ? host : access?.role;
196 if (grantee !== undefined) {
197 const { label, policyStatements } = grant();
198 yield* grantee.bind`${label}`({ policyStatements });
199 }
200 }
201 return access;
202});
203
204/**
205 * Run an operation with host-appropriate AWS credentials: on a Lambda host
206 * they are ambient (`access` undefined); on a Worker host provide the assumed
207 * role, the operation's `Region`, the env endpoint override and an HttpClient.
208 */
209export const withRuntimeCredentials = <A, E>(
210 access: WorkerAwsAccess | undefined,
211 region: Effect.Effect<string>,
212 eff: Effect.Effect<A, E, any>,
213): Effect.Effect<A, E, any> =>
214 access
215 ? Effect.flatMap(region, (reg) =>
216 eff.pipe(
217 Effect.provide(
218 Layer.succeed(Credentials, access.credentials).pipe(
219 Layer.provideMerge(Layer.succeed(Region, Effect.succeed(reg))),
220 Layer.provideMerge(Endpoint.fromEnv()),
221 Layer.provideMerge(FetchHttpClient.layer),
222 ),
223 ),
224 ),
225 )
226 : eff;
227
228/**
229 * Build the impl Effect for a function-scoped operation (`Invoke`,
230 * `GetFunction`, `InvokeWithResponseStream`): the runtime callable injects
231 * the bound {@link Function}'s ARN as `FunctionName` and the deploy-time
232 * half grants `actions` on `resources` (default: the function ARN).
233 */
234export const makeFunctionHttpBinding = <
235 I extends { FunctionName?: string },
236 A,
237 E,
238 R,
239>(options: {
240 /** Fully-qualified binding tag, e.g. `AWS.Lambda.GetFunction`. */
241 tag: string;
242 /**
243 * The distilled operation, called per request; `FunctionName` is injected
244 * from the function. Its requirements (`Credentials`, `HttpClient`) are
245 * ambient on a Lambda host and provided around each call on a Worker host.
246 */
247 operation: (input: I) => Effect.Effect<A, E, R>;
248 /** IAM actions granted on `resources`. */
249 actions: readonly string[];
250 /** ARNs the actions are granted on. @default the function ARN */
251 resources?: (func: Function) => (string | OutputType<string>)[];
252}) =>
253 Effect.gen(function* () {
254 return Effect.fn(function* (func: Function) {
255 const FunctionArn = yield* func.functionArn;
256 const host = yield* Binding.Host;
257 const access = yield* hostAwsAccess(host, () => ({
258 label: `Allow(${host?.LogicalId}, ${options.tag}(${func.LogicalId}))`,
259 policyStatements: [
260 {
261 Effect: "Allow",
262 Action: [...options.actions],
263 Resource: options.resources?.(func) ?? [func.functionArn],
264 },
265 ],
266 }));
267 const region = Effect.map(FunctionArn, regionFromArn);
268 return Effect.fn(`${options.tag}(${func.LogicalId})`)(function* (
269 request?: Omit<I, "FunctionName">,
270 ) {
271 const FunctionName = yield* FunctionArn;
272 return yield* withRuntimeCredentials(
273 access,
274 region,
275 options.operation({ ...request, FunctionName } as I),
276 ) as Effect.Effect<A, E>;
277 });
278 });
279 });
280
281/**
282 * Build the impl Effect for an account-level operation
283 * (`GetAccountSettings`, `ListFunctions`): the runtime callable passes the
284 * caller's request through unchanged and the deploy-time half grants
285 * `actions` on `*` (these Lambda actions do not support resource-level
286 * permissions).
287 */
288export const makeLambdaAccountHttpBinding = <I, A, E, R>(options: {
289 /** Fully-qualified binding tag, e.g. `AWS.Lambda.ListFunctions`. */
290 tag: string;
291 /** The distilled operation, invoked with the caller's request as-is. */
292 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
293 /** IAM actions granted on `*`. */
294 actions: readonly string[];
295}) =>
296 Effect.gen(function* () {
297 const op = yield* options.operation;
298
299 return Effect.fn(function* () {
300 if (!globalThis.__ALCHEMY_RUNTIME__) {
301 const host = yield* Binding.Host;
302 if (isBindingHost(host)) {
303 yield* host.bind`Allow(${host}, ${options.tag}())`({
304 policyStatements: [
305 {
306 Effect: "Allow",
307 Action: [...options.actions],
308 Resource: ["*"],
309 },
310 ],
311 });
312 }
313 }
314 return Effect.fn(options.tag)(function* (request?: I) {
315 return yield* op((request ?? {}) as I);
316 });
317 });
318 });