@reventlessdev/reventless-aws 3.0.0-alpha.229 → 3.0.0-alpha.230

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,156 +1,25 @@
1
1
  // Direct-to-S3 upload presign service behind a public Lambda Function URL.
2
2
  //
3
- // Deploy-time: `make` provisions a CallbackFunction (handler closure serialized
4
- // by Pulumi), an IAM execution role scoped to `s3:PutObject` on the target
5
- // bucket, and a Function URL (no auth) so a browser can request a presigned PUT.
3
+ // Deploy-time only: `make` provisions a compiled-EntryPoint Lambda (a plain
4
+ // `Lambda.Function` whose code archive re-exports `handler` from the compiled,
5
+ // type-checked runtime module `Upload_Presign_S3_Ops` and ships the shared ESM
6
+ // resolve-hook loader), an IAM execution role scoped to CloudWatch Logs +
7
+ // `s3:PutObject` on the target bucket, and a Function URL (no auth) so a browser
8
+ // can request a presigned PUT.
6
9
  //
7
- // Runtime: `handlePresign` reads a JSON body `{fileName, contentType}`, derives
8
- // a target key `uploads/<identity?>/<uuid>/<fileName>`, presigns a PUT to the
9
- // bucket named in `UPLOAD_BUCKET` (expires in 300s), and returns
10
- // `{uploadUrl, storageRef}`. A Bearer token, when present, is decoded (no
11
- // signature verification here that is the auth layer's job) and its `sub`
12
- // namespaces the key; anonymous callers are still served for the demo.
10
+ // Why an EntryPoint and not a Pulumi `CallbackFunction`: the handler needs the
11
+ // AWS SDK v3 S3 presigner. A serialized closure bakes the deploy machine's
12
+ // version-specific SDK internals into the archive but then resolves `@smithy/*`
13
+ // and `@aws-sdk/*` transitives from independently-versioned layer/runtime sources
14
+ // that disagree at cold start (an inlined `client-s3` against a newer
15
+ // `@smithy/smithy-client` base whose constructor no longer sets `middlewareStack`
16
+ // → `new S3Client()` throws). Shipping the compiled `_Ops` module with bare
17
+ // `@aws-sdk/*` imports, resolved through the resolve-hook (`@aws-sdk/*` from the
18
+ // runtime, `@smithy/*`/`@reventlessdev/*` from the layer), loads one internally
19
+ // consistent SDK instead. The runtime logic lives in [Upload_Presign_S3_Ops.res].
13
20
 
14
21
  open PulumiAws
15
22
 
16
- // ── AWS SDK v3 bindings ─────────────────────────────────────────────────────
17
-
18
- type s3Client
19
-
20
- type putObjectInput = {
21
- @as("Bucket") bucket: string,
22
- @as("Key") key: string,
23
- @as("ContentType") contentType?: string,
24
- }
25
- type putObjectCommand
26
-
27
- @module("@aws-sdk/client-s3") @new external makeS3Client: unit => s3Client = "S3Client"
28
-
29
- @module("@aws-sdk/client-s3") @new
30
- external makePutObjectCommand: putObjectInput => putObjectCommand = "PutObjectCommand"
31
-
32
- type presignOptions = {expiresIn: int}
33
-
34
- @module("@aws-sdk/s3-request-presigner")
35
- external getSignedUrl: (s3Client, putObjectCommand, presignOptions) => promise<string> =
36
- "getSignedUrl"
37
-
38
- // ── Function URL event / response shapes (payload format 2.0) ────────────────
39
-
40
- type functionUrlEvent = {
41
- body?: string,
42
- headers?: dict<string>,
43
- }
44
-
45
- type response = {
46
- statusCode: int,
47
- headers?: dict<string>,
48
- body: string,
49
- }
50
-
51
- let getEnv: string => option<string> = %raw(`
52
- function(k) { var v = process.env[k]; return (v === undefined || v === null || v === "") ? undefined : v; }
53
- `)
54
-
55
- // Node 22 exposes globalThis.crypto.randomUUID; fall back to node:crypto.
56
- let genUuid: unit => string = %raw(`
57
- function() {
58
- return (globalThis.crypto && globalThis.crypto.randomUUID)
59
- ? globalThis.crypto.randomUUID()
60
- : require("crypto").randomUUID();
61
- }
62
- `)
63
-
64
- // Decode a JWT payload's `sub` claim without verifying the signature (the auth
65
- // layer verifies; here we only namespace the storage key).
66
- let decodeJwtSub: string => option<string> = %raw(`
67
- function(header) {
68
- try {
69
- var token = header.indexOf("Bearer ") === 0 ? header.slice(7) : header;
70
- var parts = token.split(".");
71
- if (parts.length < 2) return undefined;
72
- var base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
73
- var json = Buffer.from(base64, "base64").toString("utf8");
74
- var claims = JSON.parse(json);
75
- return (claims && claims.sub) ? String(claims.sub) : undefined;
76
- } catch (_) { return undefined; }
77
- }
78
- `)
79
-
80
- let corsHeaders = () =>
81
- Dict.fromArray([
82
- ("content-type", "application/json"),
83
- ("access-control-allow-origin", "*"),
84
- ("access-control-allow-methods", "POST,OPTIONS"),
85
- ("access-control-allow-headers", "*"),
86
- ])
87
-
88
- // Key prefix from the caller identity when a Bearer token is present.
89
- let identityPrefix = (event: functionUrlEvent): string => {
90
- let authHeader =
91
- event.headers->Option.flatMap(h =>
92
- h->Dict.get("authorization")->Option.orElse(h->Dict.get("Authorization"))
93
- )
94
- switch authHeader->Option.flatMap(decodeJwtSub) {
95
- | Some(sub) => `${sub}/`
96
- | None => ""
97
- }
98
- }
99
-
100
- // ── Runtime handler ─────────────────────────────────────────────────────────
101
-
102
- let handlePresign = async (event: functionUrlEvent, _context: Lambda.context): response => {
103
- try {
104
- let bucket = getEnv("UPLOAD_BUCKET")->Option.getOr("")
105
- let parsed =
106
- event.body
107
- ->Option.getOr("{}")
108
- ->JSON.parseOrThrow
109
- ->JSON.Decode.object
110
- ->Option.getOr(Dict.make())
111
- let fileName =
112
- parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
113
- let contentType = parsed->Dict.get("contentType")->Option.flatMap(JSON.Decode.string)
114
-
115
- // The object key doubles as the served path segment: it is rooted at the
116
- // served prefix (`SERVED_PREFIX`, e.g. `uploads`) so the CloudFront
117
- // `{prefix}/*` behavior fronts it. Callers PUT to this exact key.
118
- let servedPrefix = getEnv("SERVED_PREFIX")->Option.getOr("uploads")
119
- let key = `${servedPrefix}/${identityPrefix(event)}${genUuid()}/${fileName}`
120
- let client = makeS3Client()
121
- let command = makePutObjectCommand({bucket, key, contentType: ?contentType})
122
- let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
123
-
124
- // The stored ref is a same-origin relative URL `/{key}`: the served bucket is
125
- // fronted read-only by the UI's own CloudFront distribution under
126
- // `{prefix}/*`, so a command stores this directly-renderable value and an
127
- // `image`-semantic field thumbnails it with no post-processing and no public
128
- // bucket. See [docs/plans/done/ui-served-buckets.md].
129
- let storageRef = `/${key}`
130
-
131
- {
132
- statusCode: 200,
133
- headers: corsHeaders(),
134
- body: Dict.fromArray([
135
- ("uploadUrl", JSON.Encode.string(uploadUrl)),
136
- ("storageRef", JSON.Encode.string(storageRef)),
137
- ])
138
- ->JSON.Encode.object
139
- ->JSON.stringify,
140
- }
141
- } catch {
142
- | _ => {
143
- statusCode: 400,
144
- headers: corsHeaders(),
145
- body: Dict.fromArray([("error", JSON.Encode.string("presign_failed"))])
146
- ->JSON.Encode.object
147
- ->JSON.stringify,
148
- }
149
- }
150
- }
151
-
152
- // ── Deploy-time factory ─────────────────────────────────────────────────────
153
-
154
23
  type serviceOutputs = {
155
24
  url: Pulumi.Output.t<string>,
156
25
  resources: array<Pulumi.Output.t<string>>,
@@ -181,7 +50,8 @@ let make = (
181
50
  ~opts?,
182
51
  )
183
52
 
184
- // Least-privilege: PutObject on any key under the target bucket.
53
+ // CloudWatch Logs (so failures are observable) plus least-privilege
54
+ // `s3:PutObject` on any key under the target bucket.
185
55
  let _policy =
186
56
  bucketName
187
57
  ->Pulumi.Output.fromInput
@@ -193,6 +63,12 @@ let make = (
193
63
  policy: PolicyDocument.make(
194
64
  ~id=`${serviceName}Policy`,
195
65
  ~statements=[
66
+ {
67
+ sid: "AllowLambdaLogging",
68
+ effect: Allow,
69
+ actions: Actions(["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]),
70
+ resources: Resource("arn:aws:logs:*:*:*"),
71
+ },
196
72
  {
197
73
  sid: "AllowUploadPut",
198
74
  effect: Allow,
@@ -209,29 +85,54 @@ let make = (
209
85
  )
210
86
  })
211
87
 
212
- let environment: Lambda.CallbackFunction.Args.functionEnvironment = {
213
- // CallbackFunction under-types env values as `dict<string>`; Pulumi resolves
214
- // Output-valued variables at deploy time, so bridge the Input here.
215
- variables: Dict.fromArray([
216
- ("UPLOAD_BUCKET", bucketName),
217
- ("SERVED_PREFIX", Pulumi.Input.make(servedPrefix)),
218
- ])->Obj.magic,
219
- }
88
+ // Bundle reventless-aws (the compiled `_Ops` handler lives inside it) and
89
+ // re-export its `handler`; buildCodeArchive also ships the ESM resolve-hook.
90
+ let packageDirs = Dict.fromArray([
91
+ (
92
+ "@reventlessdev/reventless-aws",
93
+ Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-aws"),
94
+ ),
95
+ ])
96
+ let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
97
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs",
98
+ ~packageDirs,
99
+ )
100
+
101
+ let layers =
102
+ Lambda.reventlessLayerArn
103
+ ->Option.map(arn => [arn->Pulumi.Input.make])
104
+ ->Option.getOr([])
105
+ ->Pulumi.Input.make
220
106
 
221
- let lambda = Lambda.CallbackFunction.make(
107
+ let lambda = Lambda.Function.make(
222
108
  ~name=serviceName,
223
- ~args=Lambda.CallbackFunction.Args.make(
224
- ~callback=handlePresign,
225
- ~role=lambdaRole,
226
- ~environment,
227
- ~timeout=30->Pulumi.Input.make,
228
- ~tags=AWS.Tags.make(
109
+ ~args={
110
+ handler: "index.handler"->Pulumi.Input.make,
111
+ runtime: "nodejs22.x"->Pulumi.Input.make,
112
+ code: code->Pulumi.Input.make,
113
+ sourceCodeHash: sourceCodeHash->Pulumi.Input.make,
114
+ role: lambdaRole.arn->Pulumi.Output.asInput,
115
+ memorySize: 256->Pulumi.Input.make,
116
+ timeout: 30->Pulumi.Input.make,
117
+ layers,
118
+ tags: AWS.Tags.make(
229
119
  ~name=serviceName,
230
120
  ~kind=ReventlessCore.ComponentType.Platform,
231
121
  ~role=Runtime,
232
122
  ~scope=Platform,
233
123
  ),
234
- ),
124
+ environment: (
125
+ {
126
+ Lambda.Function.variables: Dict.fromArray([
127
+ ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
128
+ ("UPLOAD_BUCKET", bucketName),
129
+ ("SERVED_PREFIX", Pulumi.Input.make(servedPrefix)),
130
+ ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
131
+ ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
132
+ ]),
133
+ }: Lambda.Function.functionEnvironment
134
+ )->Pulumi.Input.make,
135
+ },
235
136
  ~opts?,
236
137
  )
237
138
 
@@ -1,113 +1,16 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
- import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
4
  import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
6
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
6
  import * as Pulumi from "@pulumi/pulumi";
8
7
  import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
9
8
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
10
9
  import * as AWS$ReventlessAws from "../AWS.res.mjs";
11
- import * as ClientS3 from "@aws-sdk/client-s3";
12
10
  import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
13
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
14
13
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
15
- import * as S3RequestPresigner from "@aws-sdk/s3-request-presigner";
16
-
17
- let getEnv = (function(k) { var v = process.env[k]; return (v === undefined || v === null || v === "") ? undefined : v; });
18
-
19
- let genUuid = (function() {
20
- return (globalThis.crypto && globalThis.crypto.randomUUID)
21
- ? globalThis.crypto.randomUUID()
22
- : require("crypto").randomUUID();
23
- });
24
-
25
- let decodeJwtSub = (function(header) {
26
- try {
27
- var token = header.indexOf("Bearer ") === 0 ? header.slice(7) : header;
28
- var parts = token.split(".");
29
- if (parts.length < 2) return undefined;
30
- var base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
31
- var json = Buffer.from(base64, "base64").toString("utf8");
32
- var claims = JSON.parse(json);
33
- return (claims && claims.sub) ? String(claims.sub) : undefined;
34
- } catch (_) { return undefined; }
35
- });
36
-
37
- function corsHeaders() {
38
- return Object.fromEntries([
39
- [
40
- "content-type",
41
- "application/json"
42
- ],
43
- [
44
- "access-control-allow-origin",
45
- "*"
46
- ],
47
- [
48
- "access-control-allow-methods",
49
- "POST,OPTIONS"
50
- ],
51
- [
52
- "access-control-allow-headers",
53
- "*"
54
- ]
55
- ]);
56
- }
57
-
58
- function identityPrefix(event) {
59
- let authHeader = Stdlib_Option.flatMap(event.headers, h => Stdlib_Option.orElse(h["authorization"], h["Authorization"]));
60
- let sub = Stdlib_Option.flatMap(authHeader, decodeJwtSub);
61
- if (sub !== undefined) {
62
- return sub + `/`;
63
- } else {
64
- return "";
65
- }
66
- }
67
-
68
- async function handlePresign(event, _context) {
69
- try {
70
- let bucket = Stdlib_Option.getOr(getEnv("UPLOAD_BUCKET"), "");
71
- let parsed = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(Stdlib_Option.getOr(event.body, "{}"))), {});
72
- let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(parsed["fileName"], Stdlib_JSON.Decode.string), "upload");
73
- let contentType = Stdlib_Option.flatMap(parsed["contentType"], Stdlib_JSON.Decode.string);
74
- let servedPrefix = Stdlib_Option.getOr(getEnv("SERVED_PREFIX"), "uploads");
75
- let key = servedPrefix + `/` + identityPrefix(event) + genUuid() + `/` + fileName;
76
- let client = new ClientS3.S3Client();
77
- let command = new ClientS3.PutObjectCommand({
78
- Bucket: bucket,
79
- Key: key,
80
- ContentType: contentType
81
- });
82
- let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
83
- expiresIn: 300
84
- });
85
- let storageRef = `/` + key;
86
- return {
87
- statusCode: 200,
88
- headers: corsHeaders(),
89
- body: JSON.stringify(Object.fromEntries([
90
- [
91
- "uploadUrl",
92
- uploadUrl
93
- ],
94
- [
95
- "storageRef",
96
- storageRef
97
- ]
98
- ]))
99
- };
100
- } catch (exn) {
101
- return {
102
- statusCode: 400,
103
- headers: corsHeaders(),
104
- body: JSON.stringify(Object.fromEntries([[
105
- "error",
106
- "presign_failed"
107
- ]]))
108
- };
109
- }
110
- }
111
14
 
112
15
  function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
113
16
  let corsOrigins = corsOriginsOpt !== undefined ? corsOriginsOpt : ["*"];
@@ -118,28 +21,68 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
118
21
  bucketName.apply(b => {
119
22
  let arn = `arn:aws:s3:::` + b + `/*`;
120
23
  new (Aws.iam.RolePolicy)(serviceName + `Policy`, {
121
- policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, serviceName + `Policy`, [{
24
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, serviceName + `Policy`, [
25
+ {
26
+ Sid: "AllowLambdaLogging",
27
+ Effect: "Allow",
28
+ Action: [
29
+ "logs:CreateLogGroup",
30
+ "logs:CreateLogStream",
31
+ "logs:PutLogEvents"
32
+ ],
33
+ Resource: "arn:aws:logs:*:*:*"
34
+ },
35
+ {
122
36
  Sid: "AllowUploadPut",
123
37
  Effect: "Allow",
124
38
  Action: "s3:PutObject",
125
39
  Resource: arn
126
- }])),
40
+ }
41
+ ])),
127
42
  role: lambdaRole.id
128
43
  }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
129
44
  });
130
- let environment = {
131
- variables: Object.fromEntries([
132
- [
133
- "UPLOAD_BUCKET",
134
- bucketName
135
- ],
136
- [
137
- "SERVED_PREFIX",
138
- servedPrefix
139
- ]
140
- ])
141
- };
142
- let lambda = new (Aws.lambda.CallbackFunction)(serviceName, Lambda$PulumiAws.CallbackFunction.Args.make(handlePresign, lambdaRole, undefined, undefined, undefined, undefined, 30, undefined, undefined, undefined, AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined), environment), opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
45
+ let packageDirs = Object.fromEntries([[
46
+ "@reventlessdev/reventless-aws",
47
+ Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-aws")
48
+ ]]);
49
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs", packageDirs, undefined);
50
+ let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
51
+ let lambda = new (Aws.lambda.Function)(serviceName, {
52
+ handler: "index.handler",
53
+ runtime: "nodejs22.x",
54
+ code: match.code,
55
+ role: lambdaRole.arn,
56
+ memorySize: 256,
57
+ timeout: 30,
58
+ layers: layers,
59
+ tags: AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined),
60
+ environment: {
61
+ variables: Object.fromEntries([
62
+ [
63
+ "Environment",
64
+ Pulumi.getStack()
65
+ ],
66
+ [
67
+ "UPLOAD_BUCKET",
68
+ bucketName
69
+ ],
70
+ [
71
+ "SERVED_PREFIX",
72
+ servedPrefix
73
+ ],
74
+ [
75
+ "NODE_OPTIONS",
76
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
77
+ ],
78
+ [
79
+ "ESM_FALLBACK_DIRS",
80
+ Util_Bundle$ReventlessAws.esmFallbackDirs
81
+ ]
82
+ ])
83
+ },
84
+ sourceCodeHash: match.sourceCodeHash
85
+ }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
143
86
  let functionUrl = new (Aws.lambda.FunctionUrl)(serviceName + `Url`, {
144
87
  authorizationType: "NONE",
145
88
  functionName: lambda.name,
@@ -159,12 +102,6 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
159
102
  }
160
103
 
161
104
  export {
162
- getEnv,
163
- genUuid,
164
- decodeJwtSub,
165
- corsHeaders,
166
- identityPrefix,
167
- handlePresign,
168
105
  make,
169
106
  }
170
107
  /* @pulumi/aws Not a pure module */
@@ -0,0 +1,157 @@
1
+ // Runtime handler for the upload presign service — compiled, type-checked, and
2
+ // Pulumi-free so it can be shipped as an EntryPoint module (`Upload_Presign_S3`
3
+ // bundles it and re-exports `handler` from the code archive). Keeping it out of
4
+ // the deploy-time module is what avoids both the serialized-closure SDK skew and
5
+ // the deploy-time Pulumi import leaking into the Lambda's cold-start graph.
6
+ //
7
+ // Reads a JSON body `{fileName, contentType}`, derives a target key
8
+ // `uploads/<identity?>/<uuid>/<fileName>`, presigns a PUT to `UPLOAD_BUCKET`
9
+ // (expires in 300s), and returns `{uploadUrl, storageRef}`. A Bearer token, when
10
+ // present, is decoded (no signature verification here — the auth layer verifies;
11
+ // here we only namespace the storage key) and its `sub` namespaces the key.
12
+
13
+ // ── AWS SDK v3 bindings ─────────────────────────────────────────────────────
14
+
15
+ type s3Client
16
+
17
+ type putObjectInput = {
18
+ @as("Bucket") bucket: string,
19
+ @as("Key") key: string,
20
+ @as("ContentType") contentType?: string,
21
+ }
22
+ type putObjectCommand
23
+
24
+ @module("@aws-sdk/client-s3") @new external makeS3Client: unit => s3Client = "S3Client"
25
+
26
+ @module("@aws-sdk/client-s3") @new
27
+ external makePutObjectCommand: putObjectInput => putObjectCommand = "PutObjectCommand"
28
+
29
+ type presignOptions = {expiresIn: int}
30
+
31
+ @module("@aws-sdk/s3-request-presigner")
32
+ external getSignedUrl: (s3Client, putObjectCommand, presignOptions) => promise<string> =
33
+ "getSignedUrl"
34
+
35
+ // ── Node bindings (replacing the former `%raw` helpers with typed externals) ──
36
+
37
+ @val @scope("process") external processEnv: dict<string> = "env"
38
+
39
+ @module("node:crypto") external randomUUID: unit => string = "randomUUID"
40
+
41
+ type buffer
42
+ @val @scope("Buffer") external bufferFromBase64: (string, string) => buffer = "from"
43
+ @send external bufferToString: (buffer, string) => string = "toString"
44
+
45
+ // Read an env var, mapping "" / unset to None.
46
+ let getEnv = (k: string): option<string> =>
47
+ switch processEnv->Dict.get(k) {
48
+ | Some("") | None => None
49
+ | Some(v) => Some(v)
50
+ }
51
+
52
+ // ── Function URL event / response shapes (payload format 2.0) ────────────────
53
+
54
+ type functionUrlEvent = {
55
+ body?: string,
56
+ headers?: dict<string>,
57
+ }
58
+
59
+ type response = {
60
+ statusCode: int,
61
+ headers?: dict<string>,
62
+ body: string,
63
+ }
64
+
65
+ let corsHeaders = () =>
66
+ Dict.fromArray([
67
+ ("content-type", "application/json"),
68
+ ("access-control-allow-origin", "*"),
69
+ ("access-control-allow-methods", "POST,OPTIONS"),
70
+ ("access-control-allow-headers", "*"),
71
+ ])
72
+
73
+ // Decode a JWT payload's `sub` claim without verifying the signature.
74
+ let decodeJwtSub = (header: string): option<string> =>
75
+ try {
76
+ let token =
77
+ header->String.startsWith("Bearer ")
78
+ ? header->String.slice(~start=7, ~end=header->String.length)
79
+ : header
80
+ switch token->String.split(".")->Array.get(1) {
81
+ | Some(payload) =>
82
+ let base64 = payload->String.replaceAll("-", "+")->String.replaceAll("_", "/")
83
+ switch JSON.parseOrThrow(bufferFromBase64(base64, "base64")->bufferToString("utf8")) {
84
+ | Object(obj) => obj->Dict.get("sub")->Option.flatMap(JSON.Decode.string)
85
+ | _ => None
86
+ }
87
+ | None => None
88
+ }
89
+ } catch {
90
+ | _ => None
91
+ }
92
+
93
+ // Key prefix from the caller identity when a Bearer token is present.
94
+ let identityPrefix = (event: functionUrlEvent): string => {
95
+ let authHeader =
96
+ event.headers->Option.flatMap(h =>
97
+ h->Dict.get("authorization")->Option.orElse(h->Dict.get("Authorization"))
98
+ )
99
+ switch authHeader->Option.flatMap(decodeJwtSub) {
100
+ | Some(sub) => `${sub}/`
101
+ | None => ""
102
+ }
103
+ }
104
+
105
+ // ── Runtime handler ─────────────────────────────────────────────────────────
106
+
107
+ let handler = async (event: functionUrlEvent): response => {
108
+ try {
109
+ let bucket = getEnv("UPLOAD_BUCKET")->Option.getOr("")
110
+ let parsed =
111
+ event.body
112
+ ->Option.getOr("{}")
113
+ ->JSON.parseOrThrow
114
+ ->JSON.Decode.object
115
+ ->Option.getOr(Dict.make())
116
+ let fileName =
117
+ parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
118
+ let contentType = parsed->Dict.get("contentType")->Option.flatMap(JSON.Decode.string)
119
+
120
+ // The object key doubles as the served path segment: it is rooted at the
121
+ // served prefix (`SERVED_PREFIX`, e.g. `uploads`) so the CloudFront
122
+ // `{prefix}/*` behavior fronts it. Callers PUT to this exact key.
123
+ let servedPrefix = getEnv("SERVED_PREFIX")->Option.getOr("uploads")
124
+ let key = `${servedPrefix}/${identityPrefix(event)}${randomUUID()}/${fileName}`
125
+ let client = makeS3Client()
126
+ let command = makePutObjectCommand({bucket, key, contentType: ?contentType})
127
+ let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
128
+
129
+ // The stored ref is a same-origin relative URL `/{key}`: the served bucket is
130
+ // fronted read-only by the UI's own CloudFront distribution under
131
+ // `{prefix}/*`, so a command stores this directly-renderable value and an
132
+ // `image`-semantic field thumbnails it with no post-processing and no public
133
+ // bucket. See [docs/plans/done/ui-served-buckets.md].
134
+ let storageRef = `/${key}`
135
+
136
+ {
137
+ statusCode: 200,
138
+ headers: corsHeaders(),
139
+ body: Dict.fromArray([
140
+ ("uploadUrl", JSON.Encode.string(uploadUrl)),
141
+ ("storageRef", JSON.Encode.string(storageRef)),
142
+ ])
143
+ ->JSON.Encode.object
144
+ ->JSON.stringify,
145
+ }
146
+ } catch {
147
+ | exn =>
148
+ Console.error2("UploadPresign: presign failed", exn)
149
+ {
150
+ statusCode: 400,
151
+ headers: corsHeaders(),
152
+ body: Dict.fromArray([("error", JSON.Encode.string("presign_failed"))])
153
+ ->JSON.Encode.object
154
+ ->JSON.stringify,
155
+ }
156
+ }
157
+ }