@reventlessdev/reventless-aws 3.0.0-alpha.251 → 3.0.0-alpha.253
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 +31 -0
- package/package.json +7 -7
- package/src/Platform.res +83 -69
- package/src/Platform.res.mjs +85 -104
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res +13 -11
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Ops.res.mjs +16 -21
- package/src/adapter/Task/TaskBucket_S3.res +11 -0
- package/src/adapter/Task/TaskBucket_S3.res.mjs +3 -0
- 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 +231 -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/src/util/Util_StoreLayout.res +25 -9
- package/src/util/Util_StoreLayout.res.mjs +2 -2
- package/tests/Upload_ReleaseRuleTest.res +90 -0
- package/tests/Upload_ReleaseRuleTest.res.mjs +110 -0
- package/tests/Util_StoreLayoutTest.res +16 -3
- package/tests/Util_StoreLayoutTest.res.mjs +5 -2
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
4
|
import * as Nodecrypto from "node:crypto";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Stdlib_Float from "@rescript/runtime/lib/es6/Stdlib_Float.js";
|
|
7
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
5
8
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
9
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
6
10
|
import * as ClientS3 from "@aws-sdk/client-s3";
|
|
7
11
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
8
12
|
import * as S3RequestPresigner from "@aws-sdk/s3-request-presigner";
|
|
@@ -14,107 +18,223 @@ function getEnv(k) {
|
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
function
|
|
21
|
+
function decodeStore(json) {
|
|
22
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let match = Stdlib_Option.flatMap(json["bucket"], Stdlib_JSON.Decode.string);
|
|
26
|
+
let match$1 = Stdlib_Option.flatMap(json["prefix"], Stdlib_JSON.Decode.string);
|
|
27
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
28
|
+
return {
|
|
29
|
+
bucket: match,
|
|
30
|
+
prefix: match$1
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadStores() {
|
|
36
|
+
let match = Stdlib_Option.map(getEnv("UPLOAD_STORES"), s => JSON.parse(s));
|
|
37
|
+
if (match !== undefined) {
|
|
38
|
+
if (typeof match === "object" && match !== null && !Array.isArray(match)) {
|
|
39
|
+
return Object.fromEntries(Stdlib_Array.filterMap(Object.entries(match), param => {
|
|
40
|
+
let k = param[0];
|
|
41
|
+
return Stdlib_Option.map(decodeStore(param[1]), c => [
|
|
42
|
+
k,
|
|
43
|
+
c
|
|
44
|
+
]);
|
|
45
|
+
}));
|
|
46
|
+
} else {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function windowMs() {
|
|
55
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(getEnv("RELEASE_WINDOW_SECONDS"), Stdlib_Float.fromString), 900) * 1000;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scopeCheck(key, sub, servedPrefix) {
|
|
59
|
+
if (sub === "") {
|
|
60
|
+
return {
|
|
61
|
+
TAG: "Error",
|
|
62
|
+
_0: "unauthenticated"
|
|
63
|
+
};
|
|
64
|
+
} else if (key.startsWith(servedPrefix + `/`)) {
|
|
65
|
+
if (key.startsWith(servedPrefix + `/` + sub + `/`)) {
|
|
66
|
+
return {
|
|
67
|
+
TAG: "Ok",
|
|
68
|
+
_0: undefined
|
|
69
|
+
};
|
|
70
|
+
} else {
|
|
71
|
+
return {
|
|
72
|
+
TAG: "Error",
|
|
73
|
+
_0: "not_yours"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
return {
|
|
78
|
+
TAG: "Error",
|
|
79
|
+
_0: "not_in_store"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ageOk(lastModifiedMs, nowMs, windowMs) {
|
|
85
|
+
if (lastModifiedMs !== undefined) {
|
|
86
|
+
return nowMs - lastModifiedMs <= windowMs;
|
|
87
|
+
} else {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function decideRelease(key, sub, servedPrefix, lastModifiedMs, nowMs, windowMs) {
|
|
93
|
+
let reason = scopeCheck(key, sub, servedPrefix);
|
|
94
|
+
if (reason.TAG === "Ok") {
|
|
95
|
+
if (ageOk(lastModifiedMs, nowMs, windowMs)) {
|
|
96
|
+
return "Released";
|
|
97
|
+
} else {
|
|
98
|
+
return {
|
|
99
|
+
TAG: "Refused",
|
|
100
|
+
_0: "too_old"
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
return {
|
|
105
|
+
TAG: "Refused",
|
|
106
|
+
_0: reason._0
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ticket(uploadUrl, storageRef) {
|
|
18
112
|
return Object.fromEntries([
|
|
19
113
|
[
|
|
20
|
-
"
|
|
21
|
-
|
|
114
|
+
"uploadUrl",
|
|
115
|
+
uploadUrl
|
|
22
116
|
],
|
|
23
117
|
[
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
]
|
|
118
|
+
"storageRef",
|
|
119
|
+
storageRef
|
|
120
|
+
]
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function releaseResult(released, reason) {
|
|
125
|
+
return Object.fromEntries([
|
|
27
126
|
[
|
|
28
|
-
"
|
|
29
|
-
|
|
127
|
+
"released",
|
|
128
|
+
released
|
|
30
129
|
],
|
|
31
130
|
[
|
|
32
|
-
"
|
|
33
|
-
|
|
131
|
+
"reason",
|
|
132
|
+
Stdlib_Option.mapOr(reason, null, prim => prim)
|
|
34
133
|
]
|
|
35
134
|
]);
|
|
36
135
|
}
|
|
37
136
|
|
|
38
|
-
function
|
|
137
|
+
function keyOfRef(storageRef) {
|
|
138
|
+
if (storageRef.startsWith("/")) {
|
|
139
|
+
return storageRef.slice(1, storageRef.length);
|
|
140
|
+
} else {
|
|
141
|
+
return storageRef;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function headLastModifiedMs(client, bucket, key) {
|
|
39
146
|
try {
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
147
|
+
let out = await client.send(new ClientS3.HeadObjectCommand({
|
|
148
|
+
Bucket: bucket,
|
|
149
|
+
Key: key
|
|
150
|
+
}));
|
|
151
|
+
return Stdlib_Option.map(out.LastModified, d => d.getTime());
|
|
152
|
+
} catch (raw_exn) {
|
|
153
|
+
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
154
|
+
let match = Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.name);
|
|
155
|
+
if (match !== undefined) {
|
|
156
|
+
switch (match) {
|
|
157
|
+
case "NoSuchKey" :
|
|
158
|
+
case "NotFound" :
|
|
159
|
+
return;
|
|
160
|
+
default:
|
|
161
|
+
throw exn;
|
|
162
|
+
}
|
|
49
163
|
} else {
|
|
50
|
-
|
|
164
|
+
throw exn;
|
|
51
165
|
}
|
|
52
|
-
} catch (exn) {
|
|
53
|
-
return;
|
|
54
166
|
}
|
|
55
167
|
}
|
|
56
168
|
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
169
|
+
async function handlePresign(client, bucket, servedPrefix, sub, args) {
|
|
170
|
+
if (sub === "") {
|
|
171
|
+
Stdlib_JsError.throwWithMessage("unauthenticated");
|
|
172
|
+
}
|
|
173
|
+
let fileName = Stdlib_Option.getOr(args.fileName, "upload");
|
|
174
|
+
let key = servedPrefix + `/` + sub + `/` + Nodecrypto.randomUUID() + `/` + fileName;
|
|
175
|
+
let command = new ClientS3.PutObjectCommand({
|
|
176
|
+
Bucket: bucket,
|
|
177
|
+
Key: key,
|
|
178
|
+
ContentType: args.contentType
|
|
179
|
+
});
|
|
180
|
+
let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
|
|
181
|
+
expiresIn: 300
|
|
182
|
+
});
|
|
183
|
+
return ticket(uploadUrl, `/` + key);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function handleRelease(client, bucket, servedPrefix, sub, args) {
|
|
187
|
+
let key = keyOfRef(Stdlib_Option.getOr(args.storageRef, ""));
|
|
188
|
+
let reason = scopeCheck(key, sub, servedPrefix);
|
|
189
|
+
if (reason.TAG !== "Ok") {
|
|
190
|
+
return releaseResult(false, reason._0);
|
|
191
|
+
}
|
|
192
|
+
let lastModifiedMs = await headLastModifiedMs(client, bucket, key);
|
|
193
|
+
let reason$1 = decideRelease(key, sub, servedPrefix, lastModifiedMs, Date.now(), windowMs());
|
|
194
|
+
if (typeof reason$1 === "object") {
|
|
195
|
+
return releaseResult(false, reason$1._0);
|
|
64
196
|
}
|
|
197
|
+
await client.send(new ClientS3.DeleteObjectCommand({
|
|
198
|
+
Bucket: bucket,
|
|
199
|
+
Key: key
|
|
200
|
+
}));
|
|
201
|
+
return releaseResult(true, undefined);
|
|
65
202
|
}
|
|
66
203
|
|
|
67
204
|
async function handler(event) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let storageRef = `/` + key;
|
|
85
|
-
return {
|
|
86
|
-
statusCode: 200,
|
|
87
|
-
headers: corsHeaders(),
|
|
88
|
-
body: JSON.stringify(Object.fromEntries([
|
|
89
|
-
[
|
|
90
|
-
"uploadUrl",
|
|
91
|
-
uploadUrl
|
|
92
|
-
],
|
|
93
|
-
[
|
|
94
|
-
"storageRef",
|
|
95
|
-
storageRef
|
|
96
|
-
]
|
|
97
|
-
]))
|
|
98
|
-
};
|
|
99
|
-
} catch (raw_exn) {
|
|
100
|
-
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
101
|
-
console.error("UploadPresign: presign failed", exn);
|
|
102
|
-
return {
|
|
103
|
-
statusCode: 400,
|
|
104
|
-
headers: corsHeaders(),
|
|
105
|
-
body: JSON.stringify(Object.fromEntries([[
|
|
106
|
-
"error",
|
|
107
|
-
"presign_failed"
|
|
108
|
-
]]))
|
|
109
|
-
};
|
|
205
|
+
let stores = loadStores();
|
|
206
|
+
let args = Stdlib_Option.getOr(event.arguments, {});
|
|
207
|
+
let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.identity, i => i.sub), "");
|
|
208
|
+
let storeKey = Stdlib_Option.getOr(args.store, "");
|
|
209
|
+
let match = stores[storeKey];
|
|
210
|
+
if (match === undefined) {
|
|
211
|
+
return Stdlib_JsError.throwWithMessage("unknown_store");
|
|
212
|
+
}
|
|
213
|
+
let prefix = match.prefix;
|
|
214
|
+
let bucket = match.bucket;
|
|
215
|
+
let client = new ClientS3.S3Client();
|
|
216
|
+
let match$1 = Stdlib_Option.getOr(event.operation, "presign");
|
|
217
|
+
if (match$1 === "release") {
|
|
218
|
+
return await handleRelease(client, bucket, prefix, sub, args);
|
|
219
|
+
} else {
|
|
220
|
+
return await handlePresign(client, bucket, prefix, sub, args);
|
|
110
221
|
}
|
|
111
222
|
}
|
|
112
223
|
|
|
113
224
|
export {
|
|
114
225
|
getEnv,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
226
|
+
decodeStore,
|
|
227
|
+
loadStores,
|
|
228
|
+
windowMs,
|
|
229
|
+
scopeCheck,
|
|
230
|
+
ageOk,
|
|
231
|
+
decideRelease,
|
|
232
|
+
ticket,
|
|
233
|
+
releaseResult,
|
|
234
|
+
keyOfRef,
|
|
235
|
+
headLastModifiedMs,
|
|
236
|
+
handlePresign,
|
|
237
|
+
handleRelease,
|
|
118
238
|
handler,
|
|
119
239
|
}
|
|
120
240
|
/* node:crypto Not a pure module */
|
|
@@ -12,10 +12,16 @@ let queueTags = queueName =>
|
|
|
12
12
|
~scope=Plugin,
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
+
// 14 days, the SQS maximum, stated rather than inherited. A dead letter is the
|
|
16
|
+
// only surviving evidence of a message the system could not process, and the
|
|
17
|
+
// 4-day default can expire it over a long weekend before anyone reads it.
|
|
18
|
+
let retentionSeconds = 14 * 24 * 60 * 60
|
|
19
|
+
|
|
15
20
|
let queue = SQS.Queue.make(
|
|
16
21
|
~name,
|
|
17
22
|
~args={
|
|
18
23
|
SQS.Queue.visibilityTimeoutSeconds: 180->Pulumi.Input.make,
|
|
24
|
+
messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
|
|
19
25
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
20
26
|
tags: queueTags(name),
|
|
21
27
|
},
|
|
@@ -27,6 +33,7 @@ let fifoQueue = SQS.Queue.make(
|
|
|
27
33
|
SQS.Queue.fifoQueue: true->Pulumi.Input.make,
|
|
28
34
|
contentBasedDeduplication: true->Pulumi.Input.make,
|
|
29
35
|
visibilityTimeoutSeconds: 180->Pulumi.Input.make,
|
|
36
|
+
messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
|
|
30
37
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
31
38
|
tags: queueTags(nameFifo),
|
|
32
39
|
},
|
|
@@ -45,8 +52,27 @@ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
|
|
|
45
52
|
~opts,
|
|
46
53
|
)
|
|
47
54
|
|
|
55
|
+
// Logs what arrived, then FAILS the invocation on purpose.
|
|
56
|
+
//
|
|
57
|
+
// Returning success here would let SQS delete the message, which is what this
|
|
58
|
+
// handler used to do — and it made a dead letter unobservable twice over: the
|
|
59
|
+
// queue's depth returned to 0, so a depth alarm could never fire, and the
|
|
60
|
+
// function's `Errors` metric stayed at 0, so neither could an error alarm. A
|
|
61
|
+
// plugin whose commands failed every 5 minutes for two days produced 217 dead
|
|
62
|
+
// letters and no signal at all; it was found by a person noticing a stale UI.
|
|
63
|
+
//
|
|
64
|
+
// Failing instead keeps the message on the queue (this queue has no redrive
|
|
65
|
+
// target of its own, so it stays until retention expires) and keeps `Errors`
|
|
66
|
+
// non-zero for as long as the condition lasts. Both are conventional alarm
|
|
67
|
+
// subjects, and a monitoring backend attached through the `DeadLetterSink` seam
|
|
68
|
+
// below now has something to attach to. Re-delivery re-logs the payload; on a
|
|
69
|
+
// queue that is empty in normal operation, that repetition is the alert.
|
|
48
70
|
let entryPointCode = `export const handler = async (event) => {
|
|
49
71
|
console.error("DEAD LETTER ITEM:", JSON.stringify(event));
|
|
72
|
+
throw new Error(
|
|
73
|
+
"Dead-lettered " + (event?.Records?.length ?? 0) +
|
|
74
|
+
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
75
|
+
);
|
|
50
76
|
};`
|
|
51
77
|
|
|
52
78
|
let archiveContents: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
|
|
@@ -22,6 +22,7 @@ function queueTags(queueName) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
25
|
+
messageRetentionSeconds: 1209600,
|
|
25
26
|
tags: queueTags(name),
|
|
26
27
|
visibilityTimeoutSeconds: 180,
|
|
27
28
|
sqsManagedSseEnabled: false
|
|
@@ -30,6 +31,7 @@ let queue = new (Aws.sqs.Queue)(name, {
|
|
|
30
31
|
let fifoQueue = new (Aws.sqs.Queue)(nameFifo, {
|
|
31
32
|
contentBasedDeduplication: true,
|
|
32
33
|
fifoQueue: true,
|
|
34
|
+
messageRetentionSeconds: 1209600,
|
|
33
35
|
tags: queueTags(nameFifo),
|
|
34
36
|
visibilityTimeoutSeconds: 180,
|
|
35
37
|
sqsManagedSseEnabled: false
|
|
@@ -45,6 +47,10 @@ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AW
|
|
|
45
47
|
|
|
46
48
|
let entryPointCode = `export const handler = async (event) => {
|
|
47
49
|
console.error("DEAD LETTER ITEM:", JSON.stringify(event));
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Dead-lettered " + (event?.Records?.length ?? 0) +
|
|
52
|
+
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
53
|
+
);
|
|
48
54
|
};`;
|
|
49
55
|
|
|
50
56
|
let archiveContents = {};
|
|
@@ -150,10 +156,13 @@ Pulumi.all([
|
|
|
150
156
|
}, opts);
|
|
151
157
|
});
|
|
152
158
|
|
|
159
|
+
let retentionSeconds = 1209600;
|
|
160
|
+
|
|
153
161
|
export {
|
|
154
162
|
name,
|
|
155
163
|
nameFifo,
|
|
156
164
|
queueTags,
|
|
165
|
+
retentionSeconds,
|
|
157
166
|
queue,
|
|
158
167
|
fifoQueue,
|
|
159
168
|
opts,
|
|
@@ -109,20 +109,36 @@ let bucketNameFor = (~layout: t, ~stack: string, ~plugin: string, ~store: string
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
The prefix a store's object keys are rooted at —
|
|
112
|
+
The prefix a store's object keys are rooted at — `{plugin}/{store}`, in **both**
|
|
113
113
|
layouts.
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
The presign service mints `{
|
|
117
|
-
stored ref is `/{
|
|
118
|
-
the CDN origin differs. Refs live in an append-only event log:
|
|
119
|
-
its bucket layout would be environment-specific and unrewritable, so a
|
|
120
|
-
restored into a PR stack would carry refs that cannot resolve.
|
|
115
|
+
Layout-invariance is what keeps the two layouts one model rather than a fork.
|
|
116
|
+
The presign service mints `{prefix}/{identity}/{uuid}/{file}` either way, so the
|
|
117
|
+
stored ref is `/{prefix}/…` regardless of which bucket sits behind it, and only
|
|
118
|
+
the CDN origin differs. Refs live in an append-only event log: a prefix that
|
|
119
|
+
encoded its bucket layout would be environment-specific and unrewritable, so a
|
|
120
|
+
prod dump restored into a PR stack would carry refs that cannot resolve. Plugin
|
|
121
|
+
and store are stack-invariant, so qualifying by plugin costs none of that.
|
|
122
|
+
|
|
123
|
+
Qualified because the prefix is a **platform-global** namespace: one distribution
|
|
124
|
+
fronts every store bucket and takes one cache behavior per prefix, so a bare
|
|
125
|
+
store name made two plugins declaring `productImages` unroutable — in either
|
|
126
|
+
layout, since the per-store layout still lands both prefixes on the one
|
|
127
|
+
distribution. Qualifying narrows uniqueness from "per platform" to "per plugin",
|
|
128
|
+
which is what plugin isolation wants and what a platform composing plugins it
|
|
129
|
+
did not author needs.
|
|
121
130
|
|
|
122
131
|
The cost is a slightly redundant prefix inside a dedicated bucket
|
|
123
|
-
(`catalog-productImages/productImages/…`). Take the redundancy.
|
|
132
|
+
(`catalog-productImages/Catalog/productImages/…`). Take the redundancy.
|
|
133
|
+
|
|
134
|
+
**Changing this string is breaking.** Minted refs are `/{prefix}/…` in an
|
|
135
|
+
append-only event log, so objects written under an earlier prefix become
|
|
136
|
+
unreachable and their refs unresolvable. There is deliberately no grandfathering
|
|
137
|
+
machinery: a stack that predates a change to this function empties its stores
|
|
138
|
+
(`seed:reset`, which wipes per plugin) and re-seeds. Carrying a permanent prefix
|
|
139
|
+
set to spare a disposable stack one wipe is the worse trade.
|
|
124
140
|
*/
|
|
125
|
-
let keyPrefixFor = (~store: string): string => store
|
|
141
|
+
let keyPrefixFor = (~plugin: string, ~store: string): string => `${plugin}/${store}`
|
|
126
142
|
|
|
127
143
|
/**
|
|
128
144
|
Who serves the declared stores — and the answer is never "both".
|
|
@@ -28,8 +28,8 @@ function bucketNameFor(layout, stack, plugin, store) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function keyPrefixFor(store) {
|
|
32
|
-
return store;
|
|
31
|
+
function keyPrefixFor(plugin, store) {
|
|
32
|
+
return plugin + `/` + store;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function servingFor(hasHostUiBundle, declaredBucketCount) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The release rule as a pure decision — no S3, no AppSync (plan Step 4).
|
|
2
|
+
//
|
|
3
|
+
// `Upload_Presign_S3_Ops` splits the rule into `scopeCheck` (the string-checkable
|
|
4
|
+
// half: authenticated, in-store, owned) and `ageOk` (the clock half), composed by
|
|
5
|
+
// `decideRelease`. These tests pin every allow/deny reason so a release that should
|
|
6
|
+
// refuse can never silently succeed — the failure mode the plan exists to remove.
|
|
7
|
+
|
|
8
|
+
open JestGlobals
|
|
9
|
+
|
|
10
|
+
module Ops = Upload_Presign_S3_Ops
|
|
11
|
+
|
|
12
|
+
let prefix = "uploads"
|
|
13
|
+
let sub = "user-1"
|
|
14
|
+
let ownKey = "uploads/user-1/abc/file.png"
|
|
15
|
+
let otherKey = "uploads/user-2/abc/file.png"
|
|
16
|
+
let outsideKey = "other/user-1/abc/file.png"
|
|
17
|
+
|
|
18
|
+
let nowMs = 1_000_000.0
|
|
19
|
+
let windowMs = 900_000.0 // 15 minutes
|
|
20
|
+
let fresh = Some(nowMs -. 1_000.0) // 1s old
|
|
21
|
+
let stale = Some(nowMs -. 1_000_000.0) // ~16.6min old, beyond the window
|
|
22
|
+
|
|
23
|
+
describe("Upload_Presign_S3_Ops.scopeCheck", () => {
|
|
24
|
+
testSync("rejects an empty sub as unauthenticated", () =>
|
|
25
|
+
expect(Ops.scopeCheck(~key=ownKey, ~sub="", ~servedPrefix=prefix))->toEqual(
|
|
26
|
+
Error("unauthenticated"),
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
testSync("rejects a key outside the store prefix as not_in_store", () =>
|
|
31
|
+
expect(Ops.scopeCheck(~key=outsideKey, ~sub, ~servedPrefix=prefix))->toEqual(
|
|
32
|
+
Error("not_in_store"),
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
testSync("rejects another identity's key as not_yours", () =>
|
|
37
|
+
expect(Ops.scopeCheck(~key=otherKey, ~sub, ~servedPrefix=prefix))->toEqual(Error("not_yours"))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
testSync("allows the caller's own in-store key", () =>
|
|
41
|
+
expect(Ops.scopeCheck(~key=ownKey, ~sub, ~servedPrefix=prefix))->toEqual(Ok())
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("Upload_Presign_S3_Ops.ageOk", () => {
|
|
46
|
+
testSync("treats an absent object as releasable (idempotent)", () =>
|
|
47
|
+
expect(Ops.ageOk(~lastModifiedMs=None, ~nowMs, ~windowMs))->toBe(true)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
testSync("allows an object younger than the window", () =>
|
|
51
|
+
expect(Ops.ageOk(~lastModifiedMs=fresh, ~nowMs, ~windowMs))->toBe(true)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
testSync("refuses an object older than the window", () =>
|
|
55
|
+
expect(Ops.ageOk(~lastModifiedMs=stale, ~nowMs, ~windowMs))->toBe(false)
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("Upload_Presign_S3_Ops.decideRelease", () => {
|
|
60
|
+
let decide = (~key, ~sub, ~lastModifiedMs) =>
|
|
61
|
+
Ops.decideRelease(~key, ~sub, ~servedPrefix=prefix, ~lastModifiedMs, ~nowMs, ~windowMs)
|
|
62
|
+
|
|
63
|
+
testSync("releases the caller's own fresh object", () =>
|
|
64
|
+
expect(decide(~key=ownKey, ~sub, ~lastModifiedMs=fresh))->toEqual(Ops.Released)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
testSync("releases an absent object (idempotent retry)", () =>
|
|
68
|
+
expect(decide(~key=ownKey, ~sub, ~lastModifiedMs=None))->toEqual(Ops.Released)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
testSync("refuses another identity's object with not_yours", () =>
|
|
72
|
+
expect(decide(~key=otherKey, ~sub, ~lastModifiedMs=fresh))->toEqual(Ops.Refused("not_yours"))
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
testSync("refuses an object older than the window with too_old", () =>
|
|
76
|
+
expect(decide(~key=ownKey, ~sub, ~lastModifiedMs=stale))->toEqual(Ops.Refused("too_old"))
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
testSync("refuses a key outside the store with not_in_store", () =>
|
|
80
|
+
expect(decide(~key=outsideKey, ~sub, ~lastModifiedMs=fresh))->toEqual(
|
|
81
|
+
Ops.Refused("not_in_store"),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
testSync("refuses an unauthenticated caller before any age check", () =>
|
|
86
|
+
expect(decide(~key=ownKey, ~sub="", ~lastModifiedMs=stale))->toEqual(
|
|
87
|
+
Ops.Refused("unauthenticated"),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Upload_Presign_S3_Ops$ReventlessAws from "../src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs";
|
|
4
|
+
|
|
5
|
+
let prefix = "uploads";
|
|
6
|
+
|
|
7
|
+
let sub = "user-1";
|
|
8
|
+
|
|
9
|
+
let ownKey = "uploads/user-1/abc/file.png";
|
|
10
|
+
|
|
11
|
+
let otherKey = "uploads/user-2/abc/file.png";
|
|
12
|
+
|
|
13
|
+
let outsideKey = "other/user-1/abc/file.png";
|
|
14
|
+
|
|
15
|
+
let fresh = 1000000.0 - 1000.0;
|
|
16
|
+
|
|
17
|
+
let stale = 1000000.0 - 1000000.0;
|
|
18
|
+
|
|
19
|
+
globalThis.describe("Upload_Presign_S3_Ops.scopeCheck", () => {
|
|
20
|
+
globalThis.test("rejects an empty sub as unauthenticated", () => {
|
|
21
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(ownKey, "", prefix)).toEqual({
|
|
22
|
+
TAG: "Error",
|
|
23
|
+
_0: "unauthenticated"
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
globalThis.test("rejects a key outside the store prefix as not_in_store", () => {
|
|
27
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(outsideKey, sub, prefix)).toEqual({
|
|
28
|
+
TAG: "Error",
|
|
29
|
+
_0: "not_in_store"
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
globalThis.test("rejects another identity's key as not_yours", () => {
|
|
33
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(otherKey, sub, prefix)).toEqual({
|
|
34
|
+
TAG: "Error",
|
|
35
|
+
_0: "not_yours"
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
globalThis.test("allows the caller's own in-store key", () => {
|
|
39
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.scopeCheck(ownKey, sub, prefix)).toEqual({
|
|
40
|
+
TAG: "Ok",
|
|
41
|
+
_0: undefined
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
globalThis.describe("Upload_Presign_S3_Ops.ageOk", () => {
|
|
47
|
+
globalThis.test("treats an absent object as releasable (idempotent)", () => {
|
|
48
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.ageOk(undefined, 1000000.0, 900000.0)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
globalThis.test("allows an object younger than the window", () => {
|
|
51
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.ageOk(fresh, 1000000.0, 900000.0)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
globalThis.test("refuses an object older than the window", () => {
|
|
54
|
+
globalThis.expect(Upload_Presign_S3_Ops$ReventlessAws.ageOk(stale, 1000000.0, 900000.0)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
globalThis.describe("Upload_Presign_S3_Ops.decideRelease", () => {
|
|
59
|
+
let decide = (key, sub, lastModifiedMs) => Upload_Presign_S3_Ops$ReventlessAws.decideRelease(key, sub, prefix, lastModifiedMs, 1000000.0, 900000.0);
|
|
60
|
+
globalThis.test("releases the caller's own fresh object", () => {
|
|
61
|
+
globalThis.expect(decide(ownKey, sub, fresh)).toEqual("Released");
|
|
62
|
+
});
|
|
63
|
+
globalThis.test("releases an absent object (idempotent retry)", () => {
|
|
64
|
+
globalThis.expect(decide(ownKey, sub, undefined)).toEqual("Released");
|
|
65
|
+
});
|
|
66
|
+
globalThis.test("refuses another identity's object with not_yours", () => {
|
|
67
|
+
globalThis.expect(decide(otherKey, sub, fresh)).toEqual({
|
|
68
|
+
TAG: "Refused",
|
|
69
|
+
_0: "not_yours"
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
globalThis.test("refuses an object older than the window with too_old", () => {
|
|
73
|
+
globalThis.expect(decide(ownKey, sub, stale)).toEqual({
|
|
74
|
+
TAG: "Refused",
|
|
75
|
+
_0: "too_old"
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
globalThis.test("refuses a key outside the store with not_in_store", () => {
|
|
79
|
+
globalThis.expect(decide(outsideKey, sub, fresh)).toEqual({
|
|
80
|
+
TAG: "Refused",
|
|
81
|
+
_0: "not_in_store"
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
globalThis.test("refuses an unauthenticated caller before any age check", () => {
|
|
85
|
+
globalThis.expect(decide(ownKey, "", stale)).toEqual({
|
|
86
|
+
TAG: "Refused",
|
|
87
|
+
_0: "unauthenticated"
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let Ops;
|
|
93
|
+
|
|
94
|
+
let nowMs = 1000000.0;
|
|
95
|
+
|
|
96
|
+
let windowMs = 900000.0;
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
Ops,
|
|
100
|
+
prefix,
|
|
101
|
+
sub,
|
|
102
|
+
ownKey,
|
|
103
|
+
otherKey,
|
|
104
|
+
outsideKey,
|
|
105
|
+
nowMs,
|
|
106
|
+
windowMs,
|
|
107
|
+
fresh,
|
|
108
|
+
stale,
|
|
109
|
+
}
|
|
110
|
+
/* Not a pure module */
|