1import * as Effect from "effect/Effect";
2import * as Binding from "../../Binding.ts";
3import * as Output from "../../Output.ts";
4import {
5 hostAwsAccess,
6 withRuntimeCredentials,
7} from "../Lambda/BindingHttp.ts";
8import type { Bucket } from "./Bucket.ts";
9
10/**
11 * Shared scaffolding for AWS S3 HTTP bindings.
12 *
13 * NOT exported from `index.ts` — every single-operation `{Op}Http.ts` in this
14 * service is a thin `Layer.effect(Cap, makeBucketHttpBinding({ … }))` over
15 * this builder. Everything except the operation, the IAM action(s), and the
16 * IAM resource shape is boilerplate:
17 *
18 * - the deploy-time half registers `Allow(host, tag(bucket))` with the
19 * requested actions on the bound bucket (object-level `${arn}/*` or the
20 * bucket ARN itself, per `iamResources`). On a Lambda host the statements
21 * go on the function's role; on a Cloudflare Worker host they go on the
22 * least-privilege role the Worker assumes (see `workerAwsAccess`);
23 * - the runtime callable injects the resolved bucket name as `Bucket` and,
24 * on a Worker host, signs with the assumed role in the bucket's region.
25 *
26 * Genuinely-different bindings stay bespoke: `PresignGetObject` /
27 * `PresignPutObject` (SigV4 presigners, not API operations).
28 */
29export const makeBucketHttpBinding = <
30 I extends { Bucket?: string },
31 A,
32 E,
33 R,
34>(options: {
35 /** Fully-qualified binding tag, e.g. `AWS.S3.GetObject`. */
36 tag: string;
37 /**
38 * The distilled operation, called per request; `Bucket` is injected from
39 * the bound bucket. Its requirements (`Credentials`, `Region`,
40 * `HttpClient`) are ambient on a Lambda host and provided around each
41 * call on a Worker host.
42 */
43 operation: (input: I) => Effect.Effect<A, E, R>;
44 /** IAM actions granted on the bound bucket. */
45 actions: readonly string[];
46 /**
47 * Which ARNs the actions are granted on:
48 * - `"objects"` (default): `${bucketArn}/*` — object-level actions
49 * (`s3:GetObject`, `s3:PutObject`, …);
50 * - `"bucket"`: the bucket ARN — bucket-level actions (`s3:ListBucket`,
51 * `s3:ListBucketVersions`, `s3:ListBucketMultipartUploads`).
52 */
53 iamResources?: "objects" | "bucket";
54 /**
55 * Additionally grant `s3:ListBucket` on the bucket ARN. Required by
56 * object-read actions so a missing key surfaces as `NoSuchKey`/`NotFound`
57 * (404) instead of `AccessDenied` (403).
58 * @see https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html
59 * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-with-s3-policy-actions.html
60 */
61 listBucket?: boolean;
62}) =>
63 Effect.gen(function* () {
64 return Effect.fn(function* <B extends Bucket>(bucket: B) {
65 const BucketName = yield* bucket.bucketName;
66 const BucketRegion = yield* bucket.region;
67 const host = yield* Binding.Host;
68 const access = yield* hostAwsAccess(host, () => ({
69 label: `Allow(${host?.LogicalId}, ${options.tag}(${bucket.LogicalId}))`,
70 policyStatements: [
71 {
72 Effect: "Allow",
73 Action: [...options.actions],
74 Resource:
75 options.iamResources === "bucket"
76 ? [bucket.bucketArn]
77 : [Output.interpolate`${bucket.bucketArn}/*`],
78 },
79 ...(options.listBucket
80 ? [
81 {
82 Effect: "Allow" as const,
83 Action: ["s3:ListBucket"],
84 Resource: [bucket.bucketArn],
85 },
86 ]
87 : []),
88 ],
89 }));
90 return Effect.fn(`${options.tag}(${bucket.LogicalId})`)(function* (
91 request?: Omit<I, "Bucket">,
92 ) {
93 const Bucket = yield* BucketName;
94 return yield* withRuntimeCredentials(
95 access,
96 BucketRegion,
97 options.operation({ ...request, Bucket } as I),
98 ) as Effect.Effect<A, E>;
99 });
100 });
101 });