@reventlessdev/reventless-aws 3.0.0-alpha.251 → 3.0.0-alpha.252

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,14 +1,21 @@
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.
1
+ // Runtime handler for the upload service — compiled, type-checked, and Pulumi-free
2
+ // so it can be shipped as an EntryPoint module (`Upload_Presign_S3` bundles it and
3
+ // attaches it as the platform API's `Upload_Presign`/`Upload_Release` Lambda data
4
+ // source). Keeping it out of the deploy-time module is what avoids both the
5
+ // serialized-closure SDK skew and the deploy-time Pulumi import leaking into the
6
+ // Lambda's cold-start graph.
6
7
  //
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.
8
+ // Invoked by an AppSync resolver, not a Function URL: the platform API's Cognito
9
+ // authorizer has already authenticated the caller, so the verified `sub` arrives in
10
+ // the resolver's identity context. There is no token to decode and no anonymous
11
+ // surface the earlier `decodeJwtSub` path is gone.
12
+ //
13
+ // Two operations dispatch on the `operation` field the resolver bakes in:
14
+ // presign → derive `{servedPrefix}/{sub}/{uuid}/{fileName}`, presign a PUT to the
15
+ // store's bucket (expires 300s), return `{uploadUrl, storageRef}`.
16
+ // release → apply the release rule (scope + age) to a `storageRef` and, if it
17
+ // passes, `DeleteObject`; return `{released, reason}`.
18
+ // Bytes never touch this Lambda — only presign metadata and release decisions do.
12
19
 
13
20
  // ── AWS SDK v3 bindings ─────────────────────────────────────────────────────
14
21
 
@@ -21,23 +28,40 @@ type putObjectInput = {
21
28
  }
22
29
  type putObjectCommand
23
30
 
31
+ type deleteObjectInput = {
32
+ @as("Bucket") bucket: string,
33
+ @as("Key") key: string,
34
+ }
35
+ type deleteObjectCommand
36
+
37
+ type headObjectInput = {
38
+ @as("Bucket") bucket: string,
39
+ @as("Key") key: string,
40
+ }
41
+ type headObjectCommand
42
+ type headObjectOutput = {@as("LastModified") lastModified?: Date.t}
43
+
24
44
  @module("@aws-sdk/client-s3") @new external makeS3Client: unit => s3Client = "S3Client"
25
45
 
26
46
  @module("@aws-sdk/client-s3") @new
27
47
  external makePutObjectCommand: putObjectInput => putObjectCommand = "PutObjectCommand"
28
48
 
49
+ @module("@aws-sdk/client-s3") @new
50
+ external makeDeleteObjectCommand: deleteObjectInput => deleteObjectCommand = "DeleteObjectCommand"
51
+
52
+ @module("@aws-sdk/client-s3") @new
53
+ external makeHeadObjectCommand: headObjectInput => headObjectCommand = "HeadObjectCommand"
54
+
55
+ @send external sendDelete: (s3Client, deleteObjectCommand) => promise<unit> = "send"
56
+ @send external sendHead: (s3Client, headObjectCommand) => promise<headObjectOutput> = "send"
57
+
29
58
  type presignOptions = {expiresIn: int}
30
59
 
31
60
  @module("@aws-sdk/s3-request-presigner")
32
61
  external getSignedUrl: (s3Client, putObjectCommand, presignOptions) => promise<string> =
33
62
  "getSignedUrl"
34
63
 
35
- // ── Node bindings (replacing the former `%raw` helpers with typed externals) ──
36
-
37
-
38
- type buffer
39
- @val @scope("Buffer") external bufferFromBase64: (string, string) => buffer = "from"
40
- @send external bufferToString: (buffer, string) => string = "toString"
64
+ // ── Environment ─────────────────────────────────────────────────────────────
41
65
 
42
66
  // Read an env var, mapping "" / unset to None.
43
67
  let getEnv = (k: string): option<string> =>
@@ -46,109 +70,208 @@ let getEnv = (k: string): option<string> =>
46
70
  | Some(v) => Some(v)
47
71
  }
48
72
 
49
- // ── Function URL event / response shapes (payload format 2.0) ────────────────
50
-
51
- type functionUrlEvent = {
52
- body?: string,
53
- headers?: dict<string>,
73
+ // A store the service can presign into / release from: its physical bucket and the
74
+ // prefix its keys are rooted at. Threaded in as `UPLOAD_STORES`, a JSON object keyed
75
+ // by the qualified `{plugin}.{store}` the caller names, so one Lambda serves every
76
+ // declared store while its IAM stays scoped to their prefixes.
77
+ type storeConfig = {
78
+ bucket: string,
79
+ prefix: string,
54
80
  }
55
81
 
56
- type response = {
57
- statusCode: int,
58
- headers?: dict<string>,
59
- body: string,
82
+ let decodeStore = (json: JSON.t): option<storeConfig> =>
83
+ switch json {
84
+ | Object(obj) =>
85
+ switch (
86
+ obj->Dict.get("bucket")->Option.flatMap(JSON.Decode.string),
87
+ obj->Dict.get("prefix")->Option.flatMap(JSON.Decode.string),
88
+ ) {
89
+ | (Some(bucket), Some(prefix)) => Some({bucket, prefix})
90
+ | _ => None
91
+ }
92
+ | _ => None
93
+ }
94
+
95
+ let loadStores = (): dict<storeConfig> =>
96
+ switch getEnv("UPLOAD_STORES")->Option.map(s => JSON.parseOrThrow(s)) {
97
+ | Some(Object(obj)) =>
98
+ obj
99
+ ->Dict.toArray
100
+ ->Array.filterMap(((k, v)) => decodeStore(v)->Option.map(c => (k, c)))
101
+ ->Dict.fromArray
102
+ | _ => Dict.make()
103
+ }
104
+
105
+ // Release window: an object is releasable only while younger than this. A policy,
106
+ // not a guarantee (see the plan); defaults to 15 minutes.
107
+ let windowMs = (): float =>
108
+ getEnv("RELEASE_WINDOW_SECONDS")
109
+ ->Option.flatMap(Float.fromString)
110
+ ->Option.getOr(900.)
111
+ ->(s => s *. 1000.)
112
+
113
+ // ── The release rule, as a pure decision ────────────────────────────────────
114
+ //
115
+ // Split from S3 so it is testable without a running store (the plan's Step 4):
116
+ // `scopeCheck` and `ageOk` are the string- and clock-checkable halves, and
117
+ // `decideRelease` composes them. The handler runs `scopeCheck` first so it never
118
+ // heads a key that is not the caller's, then heads for `LastModified` and applies
119
+ // `decideRelease` as the single source of truth.
120
+
121
+ type releaseOutcome = Released | Refused(string)
122
+
123
+ // Key must sit under this store's served prefix (`not_in_store`) and under the
124
+ // caller's own identity segment within it (`not_yours`); an empty `sub` is
125
+ // `unauthenticated` (the authorizer should have refused first — this is a guard).
126
+ let scopeCheck = (~key: string, ~sub: string, ~servedPrefix: string): result<unit, string> =>
127
+ if sub == "" {
128
+ Error("unauthenticated")
129
+ } else if !(key->String.startsWith(`${servedPrefix}/`)) {
130
+ Error("not_in_store")
131
+ } else if !(key->String.startsWith(`${servedPrefix}/${sub}/`)) {
132
+ Error("not_yours")
133
+ } else {
134
+ Ok()
135
+ }
136
+
137
+ // `None` (object absent) passes: release is idempotent, so deleting what is already
138
+ // gone is success. A present object passes only inside the window.
139
+ let ageOk = (~lastModifiedMs: option<float>, ~nowMs: float, ~windowMs: float): bool =>
140
+ switch lastModifiedMs {
141
+ | None => true
142
+ | Some(lm) => nowMs -. lm <= windowMs
143
+ }
144
+
145
+ let decideRelease = (
146
+ ~key: string,
147
+ ~sub: string,
148
+ ~servedPrefix: string,
149
+ ~lastModifiedMs: option<float>,
150
+ ~nowMs: float,
151
+ ~windowMs: float,
152
+ ): releaseOutcome =>
153
+ switch scopeCheck(~key, ~sub, ~servedPrefix) {
154
+ | Error(reason) => Refused(reason)
155
+ | Ok() => ageOk(~lastModifiedMs, ~nowMs, ~windowMs) ? Released : Refused("too_old")
156
+ }
157
+
158
+ // ── AppSync resolver event / result shapes ──────────────────────────────────
159
+
160
+ type identity = {sub?: string}
161
+ type uploadArgs = {
162
+ store?: string,
163
+ fileName?: string,
164
+ contentType?: string,
165
+ storageRef?: string,
166
+ }
167
+ type appSyncEvent = {
168
+ operation?: string,
169
+ arguments?: uploadArgs,
170
+ identity?: identity,
60
171
  }
61
172
 
62
- let corsHeaders = () =>
173
+ let ticket = (~uploadUrl: string, ~storageRef: string): JSON.t =>
174
+ Dict.fromArray([
175
+ ("uploadUrl", JSON.Encode.string(uploadUrl)),
176
+ ("storageRef", JSON.Encode.string(storageRef)),
177
+ ])->JSON.Encode.object
178
+
179
+ let releaseResult = (~released: bool, ~reason: option<string>): JSON.t =>
63
180
  Dict.fromArray([
64
- ("content-type", "application/json"),
65
- ("access-control-allow-origin", "*"),
66
- ("access-control-allow-methods", "POST,OPTIONS"),
67
- ("access-control-allow-headers", "*"),
68
- ])
69
-
70
- // Decode a JWT payload's `sub` claim without verifying the signature.
71
- let decodeJwtSub = (header: string): option<string> =>
181
+ ("released", JSON.Encode.bool(released)),
182
+ ("reason", reason->Option.mapOr(JSON.Null, JSON.Encode.string)),
183
+ ])->JSON.Encode.object
184
+
185
+ // Strip the leading `/` a `storageRef` carries (`/{prefix}/{key}`) to recover the
186
+ // S3 object key.
187
+ let keyOfRef = (storageRef: string): string =>
188
+ storageRef->String.startsWith("/")
189
+ ? storageRef->String.slice(~start=1, ~end=storageRef->String.length)
190
+ : storageRef
191
+
192
+ // Head for `LastModified`, mapping a missing object (404) to `None` so the caller
193
+ // can treat it as the idempotent case. Any other S3 error propagates — a release
194
+ // must not report success when it could not even read the object's age.
195
+ let headLastModifiedMs = async (~client: s3Client, ~bucket: string, ~key: string): option<float> =>
72
196
  try {
73
- let token =
74
- header->String.startsWith("Bearer ")
75
- ? header->String.slice(~start=7, ~end=header->String.length)
76
- : header
77
- switch token->String.split(".")->Array.get(1) {
78
- | Some(payload) =>
79
- let base64 = payload->String.replaceAll("-", "+")->String.replaceAll("_", "/")
80
- switch JSON.parseOrThrow(bufferFromBase64(base64, "base64")->bufferToString("utf8")) {
81
- | Object(obj) => obj->Dict.get("sub")->Option.flatMap(JSON.Decode.string)
82
- | _ => None
83
- }
84
- | None => None
85
- }
197
+ let out = await sendHead(client, makeHeadObjectCommand({bucket, key}))
198
+ out.lastModified->Option.map(d => d->Date.getTime)
86
199
  } catch {
87
- | _ => None
200
+ | exn =>
201
+ switch exn->JsExn.fromException->Option.flatMap(JsExn.name) {
202
+ | Some("NotFound") | Some("NoSuchKey") => None
203
+ | _ => throw(exn)
204
+ }
88
205
  }
89
206
 
90
- // Key prefix from the caller identity when a Bearer token is present.
91
- let identityPrefix = (event: functionUrlEvent): string => {
92
- let authHeader =
93
- event.headers->Option.flatMap(h =>
94
- h->Dict.get("authorization")->Option.orElse(h->Dict.get("Authorization"))
95
- )
96
- switch authHeader->Option.flatMap(decodeJwtSub) {
97
- | Some(sub) => `${sub}/`
98
- | None => ""
207
+ // ── Operations ──────────────────────────────────────────────────────────────
208
+
209
+ let handlePresign = async (
210
+ ~client: s3Client,
211
+ ~bucket: string,
212
+ ~servedPrefix: string,
213
+ ~sub: string,
214
+ ~args: uploadArgs,
215
+ ): JSON.t => {
216
+ if sub == "" {
217
+ JsError.throwWithMessage("unauthenticated")
218
+ }
219
+ let fileName = args.fileName->Option.getOr("upload")
220
+ // The object key doubles as the served path segment: rooted at the store's
221
+ // served prefix so the CloudFront `{prefix}/*` behavior fronts it, and namespaced
222
+ // by the verified `sub` so the release rule can tell one caller's objects apart.
223
+ let key = `${servedPrefix}/${sub}/${NodeCrypto.randomUUID()}/${fileName}`
224
+ let command = makePutObjectCommand({bucket, key, contentType: ?args.contentType})
225
+ let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
226
+ // Same-origin relative ref `/{key}`: the served bucket is fronted read-only by the
227
+ // UI's own CloudFront distribution under `{prefix}/*`, so a command stores this
228
+ // directly-renderable value. See [docs/plans/done/served-buckets.md].
229
+ ticket(~uploadUrl, ~storageRef=`/${key}`)
230
+ }
231
+
232
+ let handleRelease = async (
233
+ ~client: s3Client,
234
+ ~bucket: string,
235
+ ~servedPrefix: string,
236
+ ~sub: string,
237
+ ~args: uploadArgs,
238
+ ): JSON.t => {
239
+ let key = args.storageRef->Option.getOr("")->keyOfRef
240
+ switch scopeCheck(~key, ~sub, ~servedPrefix) {
241
+ | Error(reason) => releaseResult(~released=false, ~reason=Some(reason))
242
+ | Ok() =>
243
+ let lastModifiedMs = await headLastModifiedMs(~client, ~bucket, ~key)
244
+ switch decideRelease(
245
+ ~key,
246
+ ~sub,
247
+ ~servedPrefix,
248
+ ~lastModifiedMs,
249
+ ~nowMs=Date.now(),
250
+ ~windowMs=windowMs(),
251
+ ) {
252
+ | Released =>
253
+ // Idempotent: DeleteObject succeeds whether or not the key exists.
254
+ let _ = await sendDelete(client, makeDeleteObjectCommand({bucket, key}))
255
+ releaseResult(~released=true, ~reason=None)
256
+ | Refused(reason) => releaseResult(~released=false, ~reason=Some(reason))
257
+ }
99
258
  }
100
259
  }
101
260
 
102
261
  // ── Runtime handler ─────────────────────────────────────────────────────────
103
262
 
104
- let handler = async (event: functionUrlEvent): response => {
105
- try {
106
- let bucket = getEnv("UPLOAD_BUCKET")->Option.getOr("")
107
- let parsed =
108
- event.body
109
- ->Option.getOr("{}")
110
- ->JSON.parseOrThrow
111
- ->JSON.Decode.object
112
- ->Option.getOr(Dict.make())
113
- let fileName =
114
- parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
115
- let contentType = parsed->Dict.get("contentType")->Option.flatMap(JSON.Decode.string)
116
-
117
- // The object key doubles as the served path segment: it is rooted at the
118
- // served prefix (`SERVED_PREFIX`, e.g. `uploads`) so the CloudFront
119
- // `{prefix}/*` behavior fronts it. Callers PUT to this exact key.
120
- let servedPrefix = getEnv("SERVED_PREFIX")->Option.getOr("uploads")
121
- let key = `${servedPrefix}/${identityPrefix(event)}${NodeCrypto.randomUUID()}/${fileName}`
263
+ let handler = async (event: appSyncEvent): JSON.t => {
264
+ let stores = loadStores()
265
+ let args = event.arguments->Option.getOr({})
266
+ let sub = event.identity->Option.flatMap(i => i.sub)->Option.getOr("")
267
+ let storeKey = args.store->Option.getOr("")
268
+ switch stores->Dict.get(storeKey) {
269
+ | None => JsError.throwWithMessage("unknown_store")
270
+ | Some({bucket, prefix}) =>
122
271
  let client = makeS3Client()
123
- let command = makePutObjectCommand({bucket, key, contentType: ?contentType})
124
- let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
125
-
126
- // The stored ref is a same-origin relative URL `/{key}`: the served bucket is
127
- // fronted read-only by the UI's own CloudFront distribution under
128
- // `{prefix}/*`, so a command stores this directly-renderable value and an
129
- // `image`-semantic field thumbnails it with no post-processing and no public
130
- // bucket. See [docs/plans/done/ui-served-buckets.md].
131
- let storageRef = `/${key}`
132
-
133
- {
134
- statusCode: 200,
135
- headers: corsHeaders(),
136
- body: Dict.fromArray([
137
- ("uploadUrl", JSON.Encode.string(uploadUrl)),
138
- ("storageRef", JSON.Encode.string(storageRef)),
139
- ])
140
- ->JSON.Encode.object
141
- ->JSON.stringify,
142
- }
143
- } catch {
144
- | exn =>
145
- Console.error2("UploadPresign: presign failed", exn)
146
- {
147
- statusCode: 400,
148
- headers: corsHeaders(),
149
- body: Dict.fromArray([("error", JSON.Encode.string("presign_failed"))])
150
- ->JSON.Encode.object
151
- ->JSON.stringify,
272
+ switch event.operation->Option.getOr("presign") {
273
+ | "release" => await handleRelease(~client, ~bucket, ~servedPrefix=prefix, ~sub, ~args)
274
+ | _ => await handlePresign(~client, ~bucket, ~servedPrefix=prefix, ~sub, ~args)
152
275
  }
153
276
  }
154
277
  }