@reventlessdev/reventless-seed 1.0.0-alpha.20 → 1.0.0-alpha.22

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/ReventlessSeed.cmi +0 -0
  4. package/lib/bs/ReventlessSeed.cmt +0 -0
  5. package/lib/bs/ReventlessSeed.mlmap +1 -0
  6. package/lib/bs/compiler-info.json +1 -1
  7. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  8. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  9. package/lib/bs/src/Seed.ast +0 -0
  10. package/lib/bs/src/Seed.res +8 -2
  11. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  12. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  13. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  14. package/lib/bs/src/Seed_Client.ast +0 -0
  15. package/lib/bs/src/Seed_Client.res +169 -21
  16. package/lib/bs/src/Seed_Client.res.mjs +142 -7
  17. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  18. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
  19. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  20. package/lib/bs/src/Seed_Connect.ast +0 -0
  21. package/lib/bs/src/Seed_Connect.res +258 -19
  22. package/lib/bs/src/Seed_Connect.res.mjs +158 -8
  23. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
  24. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
  25. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
  26. package/lib/bs/src/Seed_Prompt.ast +0 -0
  27. package/lib/bs/src/Seed_Prompt.res +48 -23
  28. package/lib/bs/src/Seed_Prompt.res.mjs +33 -22
  29. package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
  30. package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
  31. package/lib/bs/src/Seed_Random.ast +0 -0
  32. package/lib/bs/src/Seed_Random.res +4 -2
  33. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  34. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  35. package/lib/bs/src/Seed_Runner.ast +0 -0
  36. package/lib/bs/src/Seed_Runner.res +7 -12
  37. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  38. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  39. package/lib/bs/src/Seed_Upload.ast +0 -0
  40. package/lib/bs/src/Seed_Upload.res +4 -2
  41. package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
  42. package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
  43. package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
  44. package/lib/bs/src/Seed_Users.ast +0 -0
  45. package/lib/bs/src/Seed_Users.res +30 -3
  46. package/lib/bs/src/Seed_Users.res.mjs +14 -1
  47. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  48. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  49. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  50. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  51. package/lib/bs/tests/Seed_ClientTest.res +146 -16
  52. package/lib/bs/tests/Seed_ClientTest.res.mjs +102 -1
  53. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  54. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  55. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  56. package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
  57. package/lib/bs/tests/Seed_ConnectTest.res +64 -0
  58. package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
  59. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  60. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  61. package/lib/bs/tests/Seed_PromptTest.ast +0 -0
  62. package/lib/bs/tests/Seed_PromptTest.res +8 -12
  63. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  64. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  65. package/lib/bs/tests/Seed_RandomTest.ast +0 -0
  66. package/lib/bs/tests/Seed_RandomTest.res +130 -90
  67. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  68. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  69. package/lib/bs/tests/Seed_UsersTest.ast +0 -0
  70. package/lib/bs/tests/Seed_UsersTest.res +77 -2
  71. package/lib/bs/tests/Seed_UsersTest.res.mjs +45 -2
  72. package/lib/ocaml/.compiler.log +2 -2
  73. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  74. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  75. package/lib/ocaml/ReventlessSeed.mlmap +1 -0
  76. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  77. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  78. package/lib/ocaml/Seed.ast +0 -0
  79. package/lib/ocaml/Seed.res +8 -2
  80. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  81. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  82. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  83. package/lib/ocaml/Seed_Client.ast +0 -0
  84. package/lib/ocaml/Seed_Client.res +169 -21
  85. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  86. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  87. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  88. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  89. package/lib/ocaml/Seed_ClientTest.res +146 -16
  90. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  91. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
  92. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  93. package/lib/ocaml/Seed_Connect.ast +0 -0
  94. package/lib/ocaml/Seed_Connect.res +258 -19
  95. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  96. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  97. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  98. package/lib/ocaml/Seed_ConnectTest.ast +0 -0
  99. package/lib/ocaml/Seed_ConnectTest.res +64 -0
  100. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
  101. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
  102. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  103. package/lib/ocaml/Seed_Prompt.ast +0 -0
  104. package/lib/ocaml/Seed_Prompt.res +48 -23
  105. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  106. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  107. package/lib/ocaml/Seed_PromptTest.ast +0 -0
  108. package/lib/ocaml/Seed_PromptTest.res +8 -12
  109. package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
  110. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  111. package/lib/ocaml/Seed_Random.ast +0 -0
  112. package/lib/ocaml/Seed_Random.res +4 -2
  113. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  114. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  115. package/lib/ocaml/Seed_RandomTest.ast +0 -0
  116. package/lib/ocaml/Seed_RandomTest.res +130 -90
  117. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  118. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  119. package/lib/ocaml/Seed_Runner.ast +0 -0
  120. package/lib/ocaml/Seed_Runner.res +7 -12
  121. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  122. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  123. package/lib/ocaml/Seed_Upload.ast +0 -0
  124. package/lib/ocaml/Seed_Upload.res +4 -2
  125. package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
  126. package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
  127. package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
  128. package/lib/ocaml/Seed_Users.ast +0 -0
  129. package/lib/ocaml/Seed_Users.res +30 -3
  130. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  131. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  132. package/lib/ocaml/Seed_UsersTest.ast +0 -0
  133. package/lib/ocaml/Seed_UsersTest.res +77 -2
  134. package/package.json +2 -2
  135. package/src/Seed.res +8 -2
  136. package/src/Seed_Client.res +169 -21
  137. package/src/Seed_Client.res.mjs +142 -7
  138. package/src/Seed_Connect.res +258 -19
  139. package/src/Seed_Connect.res.mjs +158 -8
  140. package/src/Seed_Prompt.res +48 -23
  141. package/src/Seed_Prompt.res.mjs +33 -22
  142. package/src/Seed_Random.res +4 -2
  143. package/src/Seed_Runner.res +7 -12
  144. package/src/Seed_Upload.res +4 -2
  145. package/src/Seed_Users.res +30 -3
  146. package/src/Seed_Users.res.mjs +14 -1
  147. package/tests/Seed_ClientTest.res +146 -16
  148. package/tests/Seed_ClientTest.res.mjs +102 -1
  149. package/tests/Seed_ConnectTest.res +64 -0
  150. package/tests/Seed_ConnectTest.res.mjs +56 -0
  151. package/tests/Seed_PromptTest.res +8 -12
  152. package/tests/Seed_RandomTest.res +130 -90
  153. package/tests/Seed_UsersTest.res +77 -2
  154. package/tests/Seed_UsersTest.res.mjs +45 -2
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 1.0.0-alpha.22 (2026-09-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** make the demo accounts work on a pool that signs in by email ([97b036f](https://github.com/ReventlessDev/reventless-core/commit/97b036f86979ba55dbbf93d460fe8076c35abf1a))
11
+
12
+
13
+ # 1.0.0-alpha.21 (2026-09-09)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **seed:** read the claims behind a JWT's JOSE header, not the header ([7d3b4fd](https://github.com/ReventlessDev/reventless-core/commit/7d3b4fd8ae6dfca90f3c16f29b2be67e4e26c28f))
18
+ * **seed:** resolve the demo owners from the platform's own accounts ([7000bb3](https://github.com/ReventlessDev/reventless-core/commit/7000bb367cddbed532197c8b234cb3d0320c1c62))
19
+ ### Features
20
+
21
+ * **seed:** choose the role a run acts as, instead of inheriting a stored one ([edf41ca](https://github.com/ReventlessDev/reventless-core/commit/edf41ca80f84bf340679f200ebfeeeb4a590ae03))
22
+ * **seed:** let a caller ask whether a door opens, without walking through it ([342a142](https://github.com/ReventlessDev/reventless-core/commit/342a1429b6fba79ac46f395b801ce5a0f1e29567))
23
+
24
+
6
25
  # 1.0.0-alpha.20 (2026-09-08)
7
26
 
8
27
  ### Features
@@ -1,2 +1,2 @@
1
- #Start(1788869322834)
2
- #Done(1788869322875)
1
+ #Start(1789592211625)
2
+ #Done(1789592211686)
Binary file
Binary file
@@ -3,6 +3,7 @@ Seed
3
3
  Seed_Client
4
4
  Seed_ClientTest
5
5
  Seed_Connect
6
+ Seed_ConnectTest
6
7
  Seed_Prompt
7
8
  Seed_PromptTest
8
9
  Seed_Random
@@ -4,5 +4,5 @@
4
4
  "bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
5
5
  "rescript_config_hash": "25cfff836d200084949fdfdfec2f6875901bfee1f04d84634eca252ef1ca1ef1",
6
6
  "runtime_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/runtime",
7
- "generated_at": "1788869322878"
7
+ "generated_at": "1789592211689"
8
8
  }
Binary file
Binary file
Binary file
@@ -16,12 +16,18 @@ module Connect = Seed_Connect
16
16
  exception Failed = Seed_Types.Failed
17
17
 
18
18
  // A live target a data set seeds against: an authenticated client, whether uploads
19
- // are skipped this run, and a label. Uploads mint through the domain API's
20
- // `Upload_Presign` mutation on the client (route B), so no upload endpoint is carried.
19
+ // are skipped this run, a label, and who the run is acting as. Uploads mint through
20
+ // the domain API's `Upload_Presign` mutation on the client (route B), so no upload
21
+ // endpoint is carried.
21
22
  type connection = Seed_Connect.connection = {
22
23
  client: Seed_Client.t,
23
24
  uploadsSkipped: bool,
24
25
  label: string,
26
+ accounts: array<Seed_Users.user>,
27
+ caller: Seed_Users.user,
28
+ callerId: option<string>,
29
+ login: (~username: string, ~password: string) => promise<string>,
30
+ roleSwitch: option<Seed_Connect.roleSwitch>,
25
31
  }
26
32
 
27
33
  // A named, seedable data set. `seed` owns everything domain-specific.
Binary file
@@ -21,7 +21,8 @@ type response
21
21
  @val external setTimeout: (unit => unit, int) => unit = "setTimeout"
22
22
  @scope("Date") @val external nowMs: unit => float = "now"
23
23
 
24
- let sleep = (ms: int): promise<unit> => Promise.make((resolve, _) => setTimeout(() => resolve(), ms))
24
+ let sleep = (ms: int): promise<unit> =>
25
+ Promise.make((resolve, _) => setTimeout(() => resolve(), ms))
25
26
 
26
27
  // ── Configuration ───────────────────────────────────────────────────────────
27
28
 
@@ -68,6 +69,12 @@ let availableRolesClaim = "availableRoles"
68
69
  /** Group claim per provider, tried in order. */
69
70
  let groupClaimNames = ["cognito:groups", "groups"]
70
71
 
72
+ /** Claim carrying the caller's id, per provider, tried in order. A Cognito JWT
73
+ says `sub`; the local dev token is a base64url `Identity.t`, whose field is
74
+ `userId`. Same shape as `groupClaimNames`, for the same reason: this harness
75
+ depends on no framework package and must not learn who signed the token. */
76
+ let callerIdClaimNames = ["sub", "userId"]
77
+
71
78
  let decodeSegment = (segment: string): option<dict<JSON.t>> =>
72
79
  try {
73
80
  segment
@@ -79,10 +86,22 @@ let decodeSegment = (segment: string): option<dict<JSON.t>> =>
79
86
  | _ => None
80
87
  }
81
88
 
89
+ /**
90
+ * A JOSE header rather than a claims set.
91
+ *
92
+ * RFC 7515 requires `alg` on the header, and nothing puts it on a payload. The
93
+ * test matters because a JWT header is base64url JSON exactly as the payload is,
94
+ * so it decodes just as readily — and it comes first.
95
+ */
96
+ let isJoseHeader = (obj: dict<JSON.t>): bool => obj->Dict.get("alg")->Option.isSome
97
+
82
98
  /** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
83
99
  it second and a local dev token first, so trying each in turn saves this from
84
100
  having to know which platform signed the token. A segment that is not a JSON
85
- object (a signature) simply does not decode. */
101
+ object (a signature) simply does not decode, and a JOSE header is skipped:
102
+ it IS a decodable JSON object, so taking the first one that parsed read a
103
+ real Cognito token's `{kid, alg}` as its claims and found no groups and no
104
+ id in it — silently, since an unreadable token is not an error here. */
86
105
  let claims = (t: t): option<dict<JSON.t>> =>
87
106
  t.token->Option.flatMap(token =>
88
107
  token
@@ -90,7 +109,11 @@ let claims = (t: t): option<dict<JSON.t>> =>
90
109
  ->Array.reduce(None, (found, segment) =>
91
110
  switch found {
92
111
  | Some(_) => found
93
- | None => decodeSegment(segment)
112
+ | None =>
113
+ switch decodeSegment(segment) {
114
+ | Some(obj) if obj->isJoseHeader => None
115
+ | decoded => decoded
116
+ }
94
117
  }
95
118
  )
96
119
  )
@@ -118,6 +141,31 @@ let effectiveGroups = (t: t): option<array<string>> =>
118
141
  }
119
142
  )
120
143
 
144
+ /** The id the bearer presents — what an `@owner` field is stamped with, and so
145
+ the only id a seed can key owner-scoped rows to and have an account read them
146
+ back. `None` when the token is opaque to us or names no id. */
147
+ let callerId = (t: t): option<string> =>
148
+ callerIdClaimNames->Array.reduce(None, (found, name) =>
149
+ switch found {
150
+ | Some(_) => found
151
+ | None => t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(JSON.Decode.string)
152
+ }
153
+ )
154
+
155
+ /**
156
+ * The membership a narrowed token was reduced *from*, or `None` when the bearer
157
+ * is not narrowed.
158
+ *
159
+ * `availableRoles` is only written when a role was chosen, so its presence is
160
+ * the narrowing — the group claim alone cannot tell you, since a token carrying
161
+ * one group looks identical whether the account holds one or five.
162
+ */
163
+ let narrowedFrom = (t: t): option<array<string>> =>
164
+ switch t->claimStrings(availableRolesClaim) {
165
+ | Some(available) if available->Array.length > 0 => Some(available)
166
+ | _ => None
167
+ }
168
+
121
169
  /** One line naming the identity the bearer carries, and — when it was narrowed —
122
170
  the membership it was narrowed from. The second half is the point: without it
123
171
  a refusal looks like a misconfigured grant rather than a role switch. */
@@ -178,8 +226,9 @@ let login = async (t: t): unit => {
178
226
  let detail = await res->responseText
179
227
  throw(
180
228
  Failed(
181
- `login as "${username}" failed with HTTP ${(res->responseStatus)
182
- ->Int.toString}: ${detail}`,
229
+ `login as "${username}" failed with HTTP ${res
230
+ ->responseStatus
231
+ ->Int.toString}: ${detail}`,
183
232
  ),
184
233
  )
185
234
  }
@@ -254,7 +303,19 @@ let attempts = 4
254
303
  * fixed delay would have the whole batch retry in lockstep and rebuild the same
255
304
  * burst that provoked the fault.
256
305
  */
257
- let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
306
+ /** What a document came back as. `Errors` carries both the raw array — so a
307
+ caller can classify it, e.g. as a refusal — and the message `gql` would have
308
+ thrown, so the two paths cannot describe the same failure differently. */
309
+ type failure = {errors: JSON.t, message: string}
310
+ type outcome = Data(JSON.t) | Errors(failure)
311
+
312
+ /**
313
+ * Sends one document and reports what came back, resending a transient fault
314
+ * along the way. Returning the errors rather than throwing is what lets a caller
315
+ * treat a refusal as an *answer* — which the authorization probe needs and
316
+ * seeding must never do.
317
+ */
318
+ let gqlOutcome = async (t: t, ~query: string, ~label: string): outcome => {
258
319
  let headers = Dict.fromArray([("content-type", "application/json")])
259
320
  switch t.token {
260
321
  | Some(token) => headers->Dict.set("authorization", `Bearer ${token}`)
@@ -263,10 +324,9 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
263
324
  let body = JSON.stringify(
264
325
  JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
265
326
  )
266
- let rec attempt = async (n: int): JSON.t => {
327
+ let rec attempt = async (n: int): outcome => {
267
328
  let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
268
- | _ =>
269
- throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
329
+ | _ => throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
270
330
  }
271
331
  let json = await res->responseJson
272
332
  switch json->field("errors") {
@@ -283,20 +343,52 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
283
343
  | Some(summary) => `\n identity: ${summary}`
284
344
  | None => ""
285
345
  }
286
- throw(
287
- Failed(
288
- `${label} failed\n query: ${query}\n response: ${JSON.stringify(
289
- errors,
290
- )}${identity}${tried}`,
291
- ),
292
- )
346
+ Errors({
347
+ errors,
348
+ message: `${label} failed\n query: ${query}\n response: ${JSON.stringify(
349
+ errors,
350
+ )}${identity}${tried}`,
351
+ })
293
352
  }
294
- | None => json->field("data")->Option.getOr(JSON.Encode.null)
353
+ | None => Data(json->field("data")->Option.getOr(JSON.Encode.null))
295
354
  }
296
355
  }
297
356
  await attempt(1)
298
357
  }
299
358
 
359
+ let gql = async (t: t, ~query: string, ~label: string): JSON.t =>
360
+ switch await gqlOutcome(t, ~query, ~label) {
361
+ | Data(data) => data
362
+ | Errors({message}) => throw(Failed(message))
363
+ }
364
+
365
+ /**
366
+ * Whether the endpoint let this caller through, for a probe that means to find
367
+ * out rather than to succeed.
368
+ *
369
+ * Three outcomes and not two: "not refused" and "worked" are different claims,
370
+ * and collapsing them would let a misspelled field name read as a passing
371
+ * authorization check. `Granted` covers a domain rejection too — a
372
+ * `CommandRejected` arrives inside `data` with no GraphQL error, which is
373
+ * precisely how a probe confirms access without writing anything.
374
+ */
375
+ type access =
376
+ | Granted
377
+ | Refused
378
+ | // Served, and carrying nothing. Its own case because the two platforms deny a
379
+ // read differently: AppSync answers `Unauthorized`, while the local resolver's
380
+ // interceptor returns an empty connection — so on local an unauthorized read
381
+ // and an empty view are the same response, and no caller can tell them apart.
382
+ // Naming it lets a probe say "consistent with a denial" instead of guessing.
383
+ Empty
384
+ | Broke(string)
385
+
386
+ let checkAccess = async (t: t, ~query: string, ~label: string): access =>
387
+ switch await gqlOutcome(t, ~query, ~label) {
388
+ | Data(_) => Granted
389
+ | Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
390
+ }
391
+
300
392
  // ── Commands ────────────────────────────────────────────────────────────────
301
393
 
302
394
  let commandResultSelection = `__typename
@@ -328,6 +420,61 @@ let send = async (t: t, m: mutation, ~tolerate: array<string>=[]): option<string
328
420
  }
329
421
  }
330
422
 
423
+ /**
424
+ * Whether this caller may issue one command — asked with the very document
425
+ * `send` would have sent, so what the probe tests is what the seed actually
426
+ * uses rather than a hand-built lookalike.
427
+ *
428
+ * Pair it with a payload the domain would refuse anyway. Then `Granted` arrives
429
+ * as a `CommandRejected` inside `data`, which appends nothing: the probe learns
430
+ * that the door opened without walking through it, and a regression that wrongly
431
+ * opens a door still writes no events.
432
+ */
433
+ /** Refusal codes a command result carries. The two platforms answer a refused
434
+ command differently — AppSync rejects at the field and produces a GraphQL
435
+ `Unauthorized` error, while the local resolver runs the command handler and
436
+ answers `CommandRejected { errorCode: "Forbidden" }` inside `data`. Reading
437
+ only the first reports the second as allowed. */
438
+ let deniedCommandCodes = ["Forbidden", "Unauthorized", "AccessDenied"]
439
+
440
+ let isDeniedResult = (result: JSON.t): bool =>
441
+ switch (result->nodeString("errorCode"), result->nodeString("errorDetail")) {
442
+ | (Some(code), _) if deniedCommandCodes->Array.includes(code) => true
443
+ | (_, Some(detail)) => detail->String.toLowerCase->String.includes("not authorized")
444
+ | _ => false
445
+ }
446
+
447
+ let checkCommandAccess = async (t: t, m: mutation): access =>
448
+ switch await gqlOutcome(
449
+ t,
450
+ ~query=`mutation { r: ${m.field}(${m.args->renderArgs}) { ${commandResultSelection} } }`,
451
+ ~label=m->describe,
452
+ ) {
453
+ | Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
454
+ // No GraphQL error is not yet a grant: the local platform's refusal lives in
455
+ // the command result, so the domain's own answer has to be read too. Any other
456
+ // rejection IS a grant — the door opened and the domain refused what came
457
+ // through it, which is exactly the outcome a non-writing probe wants.
458
+ | Data(data) =>
459
+ data->field("r")->Option.getOr(JSON.Encode.null)->isDeniedResult ? Refused : Granted
460
+ }
461
+
462
+ /** Whether this caller may read one connection field. One row is enough: the
463
+ door is answered by the first request, not by the page behind it — but
464
+ whether a row came back is what separates a grant from a silent denial. */
465
+ let checkQueryAccess = async (t: t, ~field as fieldName: string): access => {
466
+ let query = `{ ${fieldName}(first: 1) { edges { node { id } } } }`
467
+ switch await gqlOutcome(t, ~query, ~label=fieldName) {
468
+ | Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
469
+ | Data(data) =>
470
+ let edges = switch data->field(fieldName)->Option.flatMap(c => c->field("edges")) {
471
+ | Some(Array(rows)) => rows->Array.length
472
+ | _ => 0
473
+ }
474
+ edges > 0 ? Granted : Empty
475
+ }
476
+ }
477
+
331
478
  let sendAll = async (t: t, mutations: array<mutation>): unit =>
332
479
  for i in 0 to mutations->Array.length - 1 {
333
480
  switch mutations->Array.get(i) {
@@ -342,12 +489,12 @@ let sendAll = async (t: t, mutations: array<mutation>): unit =>
342
489
  * Walks a Relay-style connection to the end. Connections page at 50 by default,
343
490
  * so any count taken from a single request silently truncates.
344
491
  */
345
- // `~args` is spliced into the connection's argument list — `"includeRetired: true"`
492
+ let // `~args` is spliced into the connection's argument list — `"includeRetired: true"`
346
493
  // is the case it exists for, and the only way to read rows the resolvers withhold
347
494
  // by default. Literal GraphQL rather than a typed argument list because a seed
348
495
  // writes the query it means; the alternative is a builder that has to grow a case
349
496
  // per argument the platform adds.
350
- let queryAllNodes = async (
497
+ queryAllNodes = async (
351
498
  t: t,
352
499
  ~field as fieldName: string,
353
500
  ~selection: string,
@@ -463,8 +610,9 @@ let waitForIds = async (
463
610
  missing->Array.length > 10 ? ` (+${(missing->Array.length - 10)->Int.toString} more)` : ""
464
611
  throw(
465
612
  Failed(
466
- `only ${(found->Array.length)->Int.toString}/${(ids->Array.length)
467
- ->Int.toString} ids reached ${fieldName} within ${timeoutMs->Int.toString}ms.\n missing: ${shown}${extra}`,
613
+ `only ${found->Array.length->Int.toString}/${ids
614
+ ->Array.length
615
+ ->Int.toString} ids reached ${fieldName} within ${timeoutMs->Int.toString}ms.\n missing: ${shown}${extra}`,
468
616
  ),
469
617
  )
470
618
  } else {
@@ -37,6 +37,11 @@ let groupClaimNames = [
37
37
  "groups"
38
38
  ];
39
39
 
40
+ let callerIdClaimNames = [
41
+ "sub",
42
+ "userId"
43
+ ];
44
+
40
45
  function decodeSegment(segment) {
41
46
  try {
42
47
  return Stdlib_JSON.Decode.object(JSON.parse(Buffer.from(segment, "base64url").toString("utf8")));
@@ -45,12 +50,20 @@ function decodeSegment(segment) {
45
50
  }
46
51
  }
47
52
 
53
+ function isJoseHeader(obj) {
54
+ return Stdlib_Option.isSome(obj["alg"]);
55
+ }
56
+
48
57
  function claims(t) {
49
58
  return Stdlib_Option.flatMap(t.token, token => Stdlib_Array.reduce(token.split("."), undefined, (found, segment) => {
50
59
  if (found !== undefined) {
51
60
  return found;
61
+ }
62
+ let obj = decodeSegment(segment);
63
+ if (obj !== undefined && Stdlib_Option.isSome(obj["alg"])) {
64
+ return;
52
65
  } else {
53
- return decodeSegment(segment);
66
+ return obj;
54
67
  }
55
68
  }));
56
69
  }
@@ -81,6 +94,23 @@ function effectiveGroups(t) {
81
94
  });
82
95
  }
83
96
 
97
+ function callerId(t) {
98
+ return Stdlib_Array.reduce(callerIdClaimNames, undefined, (found, name) => {
99
+ if (found !== undefined) {
100
+ return found;
101
+ } else {
102
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(claims(t), c => c[name]), Stdlib_JSON.Decode.string);
103
+ }
104
+ });
105
+ }
106
+
107
+ function narrowedFrom(t) {
108
+ let available = claimStrings(t, availableRolesClaim);
109
+ if (available !== undefined && available.length !== 0) {
110
+ return available;
111
+ }
112
+ }
113
+
84
114
  function identitySummary(t) {
85
115
  return Stdlib_Option.map(effectiveGroups(t), groups => {
86
116
  let held = groups.length === 0 ? "no groups" : groups.join(", ");
@@ -213,7 +243,7 @@ function isDenied(errors) {
213
243
  }
214
244
  }
215
245
 
216
- async function gql(t, query, label) {
246
+ async function gqlOutcome(t, query, label) {
217
247
  let headers = Object.fromEntries([[
218
248
  "content-type",
219
249
  "application/json"
@@ -244,7 +274,10 @@ async function gql(t, query, label) {
244
274
  let json = await res.json();
245
275
  let errors = field(json, "errors");
246
276
  if (errors === undefined) {
247
- return Stdlib_Option.getOr(field(json, "data"), null);
277
+ return {
278
+ TAG: "Data",
279
+ _0: Stdlib_Option.getOr(field(json, "data"), null)
280
+ };
248
281
  }
249
282
  if (isTransient(errors) && n < 4) {
250
283
  await sleep((250 * (1 << (n - 1 | 0)) | 0) + (Math.random() * 250 | 0) | 0);
@@ -253,15 +286,45 @@ async function gql(t, query, label) {
253
286
  let tried = n > 1 ? `\n gave up after ` + n.toString() + ` attempts` : "";
254
287
  let summary = isDenied(errors) ? identitySummary(t) : undefined;
255
288
  let identity = summary !== undefined ? `\n identity: ` + summary : "";
256
- throw {
257
- RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
258
- _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + identity + tried,
259
- Error: new Error()
289
+ return {
290
+ TAG: "Errors",
291
+ _0: {
292
+ errors: errors,
293
+ message: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + identity + tried
294
+ }
260
295
  };
261
296
  };
262
297
  return await attempt(1);
263
298
  }
264
299
 
300
+ async function gql(t, query, label) {
301
+ let data = await gqlOutcome(t, query, label);
302
+ if (data.TAG === "Data") {
303
+ return data._0;
304
+ }
305
+ throw {
306
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
307
+ _1: data._0.message,
308
+ Error: new Error()
309
+ };
310
+ }
311
+
312
+ async function checkAccess(t, query, label) {
313
+ let match = await gqlOutcome(t, query, label);
314
+ if (match.TAG === "Data") {
315
+ return "Granted";
316
+ }
317
+ let match$1 = match._0;
318
+ if (isDenied(match$1.errors)) {
319
+ return "Refused";
320
+ } else {
321
+ return {
322
+ TAG: "Broke",
323
+ _0: match$1.message
324
+ };
325
+ }
326
+ }
327
+
265
328
  let commandResultSelection = `__typename
266
329
  ... on CommandAccepted { eventCount }
267
330
  ... on CommandRejected { errorCode errorDetail }`;
@@ -291,6 +354,68 @@ async function send(t, m, tolerateOpt) {
291
354
  };
292
355
  }
293
356
 
357
+ let deniedCommandCodes = [
358
+ "Forbidden",
359
+ "Unauthorized",
360
+ "AccessDenied"
361
+ ];
362
+
363
+ function isDeniedResult(result) {
364
+ let match = nodeString(result, "errorCode");
365
+ let match$1 = nodeString(result, "errorDetail");
366
+ if (match !== undefined && deniedCommandCodes.includes(match)) {
367
+ return true;
368
+ }
369
+ if (match$1 !== undefined) {
370
+ return match$1.toLowerCase().includes("not authorized");
371
+ } else {
372
+ return false;
373
+ }
374
+ }
375
+
376
+ async function checkCommandAccess(t, m) {
377
+ let data = await gqlOutcome(t, `mutation { r: ` + m.field + `(` + Seed_Types$ReventlessSeed.renderArgs(m.args) + `) { ` + commandResultSelection + ` } }`, Seed_Types$ReventlessSeed.describe(m));
378
+ if (data.TAG === "Data") {
379
+ if (isDeniedResult(Stdlib_Option.getOr(field(data._0, "r"), null))) {
380
+ return "Refused";
381
+ } else {
382
+ return "Granted";
383
+ }
384
+ }
385
+ let match = data._0;
386
+ if (isDenied(match.errors)) {
387
+ return "Refused";
388
+ } else {
389
+ return {
390
+ TAG: "Broke",
391
+ _0: match.message
392
+ };
393
+ }
394
+ }
395
+
396
+ async function checkQueryAccess(t, fieldName) {
397
+ let query = `{ ` + fieldName + `(first: 1) { edges { node { id } } } }`;
398
+ let data = await gqlOutcome(t, query, fieldName);
399
+ if (data.TAG === "Data") {
400
+ let match = Stdlib_Option.flatMap(field(data._0, fieldName), c => field(c, "edges"));
401
+ let edges = Array.isArray(match) ? match.length : 0;
402
+ if (edges > 0) {
403
+ return "Granted";
404
+ } else {
405
+ return "Empty";
406
+ }
407
+ }
408
+ let match$1 = data._0;
409
+ if (isDenied(match$1.errors)) {
410
+ return "Refused";
411
+ } else {
412
+ return {
413
+ TAG: "Broke",
414
+ _0: match$1.message
415
+ };
416
+ }
417
+ }
418
+
294
419
  async function sendAll(t, mutations) {
295
420
  for (let i = 0, i_finish = mutations.length; i < i_finish; ++i) {
296
421
  let m = mutations[i];
@@ -407,11 +532,15 @@ export {
407
532
  activeRoleClaim,
408
533
  availableRolesClaim,
409
534
  groupClaimNames,
535
+ callerIdClaimNames,
410
536
  decodeSegment,
537
+ isJoseHeader,
411
538
  claims,
412
539
  asStrings,
413
540
  claimStrings,
414
541
  effectiveGroups,
542
+ callerId,
543
+ narrowedFrom,
415
544
  identitySummary,
416
545
  field,
417
546
  asString,
@@ -421,9 +550,15 @@ export {
421
550
  isTransient,
422
551
  isDenied,
423
552
  attempts,
553
+ gqlOutcome,
424
554
  gql,
555
+ checkAccess,
425
556
  commandResultSelection,
426
557
  send,
558
+ deniedCommandCodes,
559
+ isDeniedResult,
560
+ checkCommandAccess,
561
+ checkQueryAccess,
427
562
  sendAll,
428
563
  queryAllNodes,
429
564
  countNodes,
Binary file