@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.
@@ -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 corsHeaders() {
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
- "content-type",
21
- "application/json"
114
+ "uploadUrl",
115
+ uploadUrl
22
116
  ],
23
117
  [
24
- "access-control-allow-origin",
25
- "*"
26
- ],
118
+ "storageRef",
119
+ storageRef
120
+ ]
121
+ ]);
122
+ }
123
+
124
+ function releaseResult(released, reason) {
125
+ return Object.fromEntries([
27
126
  [
28
- "access-control-allow-methods",
29
- "POST,OPTIONS"
127
+ "released",
128
+ released
30
129
  ],
31
130
  [
32
- "access-control-allow-headers",
33
- "*"
131
+ "reason",
132
+ Stdlib_Option.mapOr(reason, null, prim => prim)
34
133
  ]
35
134
  ]);
36
135
  }
37
136
 
38
- function decodeJwtSub(header) {
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 token = header.startsWith("Bearer ") ? header.slice(7, header.length) : header;
41
- let payload = token.split(".")[1];
42
- if (payload === undefined) {
43
- return;
44
- }
45
- let base64 = payload.replaceAll("-", "+").replaceAll("_", "/");
46
- let obj = JSON.parse(Buffer.from(base64, "base64").toString("utf8"));
47
- if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
48
- return Stdlib_Option.flatMap(obj["sub"], Stdlib_JSON.Decode.string);
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
- return;
164
+ throw exn;
51
165
  }
52
- } catch (exn) {
53
- return;
54
166
  }
55
167
  }
56
168
 
57
- function identityPrefix(event) {
58
- let authHeader = Stdlib_Option.flatMap(event.headers, h => Stdlib_Option.orElse(h["authorization"], h["Authorization"]));
59
- let sub = Stdlib_Option.flatMap(authHeader, decodeJwtSub);
60
- if (sub !== undefined) {
61
- return sub + `/`;
62
- } else {
63
- return "";
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
- try {
69
- let bucket = Stdlib_Option.getOr(getEnv("UPLOAD_BUCKET"), "");
70
- let parsed = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(Stdlib_Option.getOr(event.body, "{}"))), {});
71
- let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(parsed["fileName"], Stdlib_JSON.Decode.string), "upload");
72
- let contentType = Stdlib_Option.flatMap(parsed["contentType"], Stdlib_JSON.Decode.string);
73
- let servedPrefix = Stdlib_Option.getOr(getEnv("SERVED_PREFIX"), "uploads");
74
- let key = servedPrefix + `/` + identityPrefix(event) + Nodecrypto.randomUUID() + `/` + fileName;
75
- let client = new ClientS3.S3Client();
76
- let command = new ClientS3.PutObjectCommand({
77
- Bucket: bucket,
78
- Key: key,
79
- ContentType: contentType
80
- });
81
- let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
82
- expiresIn: 300
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
- corsHeaders,
116
- decodeJwtSub,
117
- identityPrefix,
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 — the store name, in **both**
112
+ The prefix a store's object keys are rooted at — `{plugin}/{store}`, in **both**
113
113
  layouts.
114
114
 
115
- This is the property that keeps the two layouts one model rather than a fork.
116
- The presign service mints `{store}/{identity}{uuid}/{file}` either way, so the
117
- stored ref is `/{store}/…` regardless of which bucket sits behind it, and only
118
- the CDN origin differs. Refs live in an append-only event log: one that encoded
119
- its bucket layout would be environment-specific and unrewritable, so a prod dump
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 */