@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.
- package/CHANGELOG.md +19 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/ReventlessSeed.cmi +0 -0
- package/lib/bs/ReventlessSeed.cmt +0 -0
- package/lib/bs/ReventlessSeed.mlmap +1 -0
- package/lib/bs/compiler-info.json +1 -1
- package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed.ast +0 -0
- package/lib/bs/src/Seed.res +8 -2
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Client.ast +0 -0
- package/lib/bs/src/Seed_Client.res +169 -21
- package/lib/bs/src/Seed_Client.res.mjs +142 -7
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Connect.ast +0 -0
- package/lib/bs/src/Seed_Connect.res +258 -19
- package/lib/bs/src/Seed_Connect.res.mjs +158 -8
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Prompt.ast +0 -0
- package/lib/bs/src/Seed_Prompt.res +48 -23
- package/lib/bs/src/Seed_Prompt.res.mjs +33 -22
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Random.ast +0 -0
- package/lib/bs/src/Seed_Random.res +4 -2
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Runner.ast +0 -0
- package/lib/bs/src/Seed_Runner.res +7 -12
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload.ast +0 -0
- package/lib/bs/src/Seed_Upload.res +4 -2
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Users.ast +0 -0
- package/lib/bs/src/Seed_Users.res +30 -3
- package/lib/bs/src/Seed_Users.res.mjs +14 -1
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ClientTest.ast +0 -0
- package/lib/bs/tests/Seed_ClientTest.res +146 -16
- package/lib/bs/tests/Seed_ClientTest.res.mjs +102 -1
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
- package/lib/bs/tests/Seed_ConnectTest.res +64 -0
- package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_PromptTest.ast +0 -0
- package/lib/bs/tests/Seed_PromptTest.res +8 -12
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_RandomTest.ast +0 -0
- package/lib/bs/tests/Seed_RandomTest.res +130 -90
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_UsersTest.ast +0 -0
- package/lib/bs/tests/Seed_UsersTest.res +77 -2
- package/lib/bs/tests/Seed_UsersTest.res.mjs +45 -2
- package/lib/ocaml/.compiler.log +2 -2
- package/lib/ocaml/ReventlessSeed.cmi +0 -0
- package/lib/ocaml/ReventlessSeed.cmt +0 -0
- package/lib/ocaml/ReventlessSeed.mlmap +1 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed.ast +0 -0
- package/lib/ocaml/Seed.res +8 -2
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client.ast +0 -0
- package/lib/ocaml/Seed_Client.res +169 -21
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ClientTest.ast +0 -0
- package/lib/ocaml/Seed_ClientTest.res +146 -16
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Connect.ast +0 -0
- package/lib/ocaml/Seed_Connect.res +258 -19
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ConnectTest.ast +0 -0
- package/lib/ocaml/Seed_ConnectTest.res +64 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Prompt.ast +0 -0
- package/lib/ocaml/Seed_Prompt.res +48 -23
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_PromptTest.ast +0 -0
- package/lib/ocaml/Seed_PromptTest.res +8 -12
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Random.ast +0 -0
- package/lib/ocaml/Seed_Random.res +4 -2
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_RandomTest.ast +0 -0
- package/lib/ocaml/Seed_RandomTest.res +130 -90
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Runner.ast +0 -0
- package/lib/ocaml/Seed_Runner.res +7 -12
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload.ast +0 -0
- package/lib/ocaml/Seed_Upload.res +4 -2
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Users.ast +0 -0
- package/lib/ocaml/Seed_Users.res +30 -3
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_UsersTest.ast +0 -0
- package/lib/ocaml/Seed_UsersTest.res +77 -2
- package/package.json +2 -2
- package/src/Seed.res +8 -2
- package/src/Seed_Client.res +169 -21
- package/src/Seed_Client.res.mjs +142 -7
- package/src/Seed_Connect.res +258 -19
- package/src/Seed_Connect.res.mjs +158 -8
- package/src/Seed_Prompt.res +48 -23
- package/src/Seed_Prompt.res.mjs +33 -22
- package/src/Seed_Random.res +4 -2
- package/src/Seed_Runner.res +7 -12
- package/src/Seed_Upload.res +4 -2
- package/src/Seed_Users.res +30 -3
- package/src/Seed_Users.res.mjs +14 -1
- package/tests/Seed_ClientTest.res +146 -16
- package/tests/Seed_ClientTest.res.mjs +102 -1
- package/tests/Seed_ConnectTest.res +64 -0
- package/tests/Seed_ConnectTest.res.mjs +56 -0
- package/tests/Seed_PromptTest.res +8 -12
- package/tests/Seed_RandomTest.res +130 -90
- package/tests/Seed_UsersTest.res +77 -2
- 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
|
package/lib/bs/.compiler.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#Start(
|
|
2
|
-
#Done(
|
|
1
|
+
#Start(1789592211625)
|
|
2
|
+
#Done(1789592211686)
|
|
Binary file
|
|
Binary file
|
|
@@ -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": "
|
|
7
|
+
"generated_at": "1789592211689"
|
|
8
8
|
}
|
|
Binary file
|
|
Binary file
|
package/lib/bs/src/Seed.ast
CHANGED
|
Binary file
|
package/lib/bs/src/Seed.res
CHANGED
|
@@ -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
|
|
20
|
-
// `Upload_Presign` mutation on the client (route B), so no upload
|
|
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
|
|
Binary file
|
|
Binary file
|
|
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> =>
|
|
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 =>
|
|
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 ${
|
|
182
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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 ${
|
|
467
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|