@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.
- package/CHANGELOG.md +11 -0
- package/package.json +5 -5
- package/src/Platform.res +58 -68
- package/src/Platform.res.mjs +60 -102
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res +13 -11
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res.mjs +16 -21
- package/src/adapter/Upload/Upload_Presign_S3.res +179 -92
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +129 -39
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res +227 -104
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +195 -75
- package/src/util/Util_DeadLetterQueue.res +26 -0
- package/src/util/Util_DeadLetterQueue.res.mjs +9 -0
- package/tests/Upload_ReleaseRuleTest.res +90 -0
- package/tests/Upload_ReleaseRuleTest.res.mjs +110 -0
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
// Runtime handler for the upload
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the deploy-time module is what avoids both the
|
|
5
|
-
// the deploy-time Pulumi import leaking into the
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
("
|
|
65
|
-
("
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
|
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
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
}
|