@reventlessdev/reventless-seed 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +149 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/.sourcedirs.json +1 -0
- package/lib/bs/ReventlessSeed.cmi +0 -0
- package/lib/bs/ReventlessSeed.cmt +0 -0
- package/lib/bs/ReventlessSeed.mlmap +6 -0
- package/lib/bs/compiler-info.json +2 -2
- 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 +12 -3
- package/lib/bs/src/Seed.res.mjs +3 -0
- 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 +382 -21
- package/lib/bs/src/Seed_Client.res.mjs +332 -24
- 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 +281 -33
- package/lib/bs/src/Seed_Connect.res.mjs +162 -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 +140 -41
- package/lib/bs/src/Seed_Prompt.res.mjs +88 -13
- 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.cmj +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 +45 -19
- package/lib/bs/src/Seed_Runner.res.mjs +19 -1
- package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmj +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 +43 -43
- package/lib/bs/src/Seed_Upload.res.mjs +65 -69
- 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 +92 -0
- package/lib/bs/src/Seed_Users.res.mjs +91 -0
- 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 +287 -0
- package/lib/bs/tests/Seed_ClientTest.res.mjs +268 -0
- 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.cmj +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 +55 -0
- package/lib/bs/tests/Seed_PromptTest.res.mjs +63 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmj +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 +200 -0
- package/lib/bs/tests/Seed_RandomTest.res.mjs +208 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmj +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 +129 -0
- package/lib/bs/tests/Seed_UsersTest.res.mjs +113 -0
- 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 +6 -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 +12 -3
- 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 +382 -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 +287 -0
- 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 +281 -33
- 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 +140 -41
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmj +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 +55 -0
- 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.cmj +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 +200 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmj +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 +45 -19
- package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmj +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 +43 -43
- 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 +92 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmj +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 +129 -0
- package/package.json +7 -2
- package/rescript.json +9 -1
- package/src/Seed.res +12 -3
- package/src/Seed.res.mjs +3 -0
- package/src/Seed_Client.res +382 -21
- package/src/Seed_Client.res.mjs +332 -24
- package/src/Seed_Connect.res +281 -33
- package/src/Seed_Connect.res.mjs +162 -8
- package/src/Seed_Prompt.res +140 -41
- package/src/Seed_Prompt.res.mjs +88 -13
- package/src/Seed_Random.res +4 -2
- package/src/Seed_Runner.res +45 -19
- package/src/Seed_Runner.res.mjs +19 -1
- package/src/Seed_Upload.res +43 -43
- package/src/Seed_Upload.res.mjs +65 -69
- package/src/Seed_Users.res +92 -0
- package/src/Seed_Users.res.mjs +91 -0
- package/tests/Seed_ClientTest.res +287 -0
- package/tests/Seed_ClientTest.res.mjs +268 -0
- package/tests/Seed_ConnectTest.res +64 -0
- package/tests/Seed_ConnectTest.res.mjs +56 -0
- package/tests/Seed_PromptTest.res +55 -0
- package/tests/Seed_PromptTest.res.mjs +63 -0
- package/tests/Seed_RandomTest.res +200 -0
- package/tests/Seed_RandomTest.res.mjs +208 -0
- package/tests/Seed_UsersTest.res +129 -0
- package/tests/Seed_UsersTest.res.mjs +113 -0
|
@@ -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
|
|
|
@@ -43,11 +44,143 @@ let make = (~config: config): t => {config, token: None}
|
|
|
43
44
|
Seed.Upload) can authenticate side-channel requests with the same identity. */
|
|
44
45
|
let currentToken = (t: t): option<string> => t.token
|
|
45
46
|
|
|
47
|
+
/** The GraphQL endpoint this client targets — so `Seed.Upload` can resolve a
|
|
48
|
+
relative presigned `uploadUrl` against the same origin. */
|
|
49
|
+
let endpoint = (t: t): string => t.config.endpoint
|
|
50
|
+
|
|
46
51
|
/** Injects a bearer obtained out-of-band (e.g. a Cognito id token), instead of
|
|
47
52
|
minting one via `login`. Provider-agnostic: the harness only cares that the
|
|
48
53
|
token is a valid bearer for `endpoint`. */
|
|
49
54
|
let useToken = (t: t, token: string): unit => t.token = Some(token)
|
|
50
55
|
|
|
56
|
+
// ── What the bearer actually grants ─────────────────────────────────────────
|
|
57
|
+
//
|
|
58
|
+
// An account's listed groups are not necessarily the ones its token carries:
|
|
59
|
+
// both platforms narrow a token to the single role the caller last chose to act
|
|
60
|
+
// as. So a seed can log in as an account that looks eligible and be refused, and
|
|
61
|
+
// the account list cannot explain it — only the token can.
|
|
62
|
+
|
|
63
|
+
/** Claim names a narrowed token carries. These mirror the cross-provider
|
|
64
|
+
contract in `ReventlessCore.Auth_ActiveRole`; spelled out rather than
|
|
65
|
+
imported because this harness deliberately depends on no framework package. */
|
|
66
|
+
let activeRoleClaim = "activeRole"
|
|
67
|
+
let availableRolesClaim = "availableRoles"
|
|
68
|
+
|
|
69
|
+
/** Group claim per provider, tried in order. */
|
|
70
|
+
let groupClaimNames = ["cognito:groups", "groups"]
|
|
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
|
+
|
|
78
|
+
let decodeSegment = (segment: string): option<dict<JSON.t>> =>
|
|
79
|
+
try {
|
|
80
|
+
segment
|
|
81
|
+
->NodeBuffer.fromStringBase64Url
|
|
82
|
+
->NodeBuffer.toStringUtf8
|
|
83
|
+
->JSON.parseOrThrow
|
|
84
|
+
->JSON.Decode.object
|
|
85
|
+
} catch {
|
|
86
|
+
| _ => None
|
|
87
|
+
}
|
|
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
|
+
|
|
98
|
+
/** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
|
|
99
|
+
it second and a local dev token first, so trying each in turn saves this from
|
|
100
|
+
having to know which platform signed the token. A segment that is not a JSON
|
|
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. */
|
|
105
|
+
let claims = (t: t): option<dict<JSON.t>> =>
|
|
106
|
+
t.token->Option.flatMap(token =>
|
|
107
|
+
token
|
|
108
|
+
->String.split(".")
|
|
109
|
+
->Array.reduce(None, (found, segment) =>
|
|
110
|
+
switch found {
|
|
111
|
+
| Some(_) => found
|
|
112
|
+
| None =>
|
|
113
|
+
switch decodeSegment(segment) {
|
|
114
|
+
| Some(obj) if obj->isJoseHeader => None
|
|
115
|
+
| decoded => decoded
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
// Arrays on the group claim, comma-joined on `availableRoles`.
|
|
122
|
+
let asStrings = (value: JSON.t): option<array<string>> =>
|
|
123
|
+
switch value {
|
|
124
|
+
| Array(entries) => Some(entries->Array.filterMap(JSON.Decode.string))
|
|
125
|
+
| String(joined) =>
|
|
126
|
+
Some(joined->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""))
|
|
127
|
+
| _ => None
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let claimStrings = (t: t, name: string): option<array<string>> =>
|
|
131
|
+
t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(asStrings)
|
|
132
|
+
|
|
133
|
+
/** The groups the bearer actually presents — what every enforcement point reads.
|
|
134
|
+
`None` when the token is opaque to us, which is not an error: it just means
|
|
135
|
+
this cannot add anything. */
|
|
136
|
+
let effectiveGroups = (t: t): option<array<string>> =>
|
|
137
|
+
groupClaimNames->Array.reduce(None, (found, name) =>
|
|
138
|
+
switch found {
|
|
139
|
+
| Some(_) => found
|
|
140
|
+
| None => t->claimStrings(name)
|
|
141
|
+
}
|
|
142
|
+
)
|
|
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
|
+
|
|
169
|
+
/** One line naming the identity the bearer carries, and — when it was narrowed —
|
|
170
|
+
the membership it was narrowed from. The second half is the point: without it
|
|
171
|
+
a refusal looks like a misconfigured grant rather than a role switch. */
|
|
172
|
+
let identitySummary = (t: t): option<string> =>
|
|
173
|
+
t
|
|
174
|
+
->effectiveGroups
|
|
175
|
+
->Option.map(groups => {
|
|
176
|
+
let held = groups->Array.length == 0 ? "no groups" : groups->Array.join(", ")
|
|
177
|
+
switch t->claimStrings(availableRolesClaim) {
|
|
178
|
+
| Some(available) if available->Array.length > 0 =>
|
|
179
|
+
`${held} — narrowed to this role from ${available->Array.join(", ")}`
|
|
180
|
+
| _ => held
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
51
184
|
// ── JSON helpers ────────────────────────────────────────────────────────────
|
|
52
185
|
|
|
53
186
|
let field = (json: JSON.t, key: string): option<JSON.t> =>
|
|
@@ -93,8 +226,9 @@ let login = async (t: t): unit => {
|
|
|
93
226
|
let detail = await res->responseText
|
|
94
227
|
throw(
|
|
95
228
|
Failed(
|
|
96
|
-
`login as "${username}" failed with HTTP ${
|
|
97
|
-
|
|
229
|
+
`login as "${username}" failed with HTTP ${res
|
|
230
|
+
->responseStatus
|
|
231
|
+
->Int.toString}: ${detail}`,
|
|
98
232
|
),
|
|
99
233
|
)
|
|
100
234
|
}
|
|
@@ -102,12 +236,86 @@ let login = async (t: t): unit => {
|
|
|
102
236
|
t.token = json->field("token")->Option.flatMap(asString)
|
|
103
237
|
}
|
|
104
238
|
|
|
239
|
+
/** Error types the endpoint returns for a fault on its own side rather than a
|
|
240
|
+
problem with the document. They say nothing about the request, so the same
|
|
241
|
+
request sent again can succeed — and across the few hundred commands a seed
|
|
242
|
+
issues, one of these is ordinary rather than exceptional.
|
|
243
|
+
|
|
244
|
+
Everything else — a validation error, an unknown field, a refused token — is
|
|
245
|
+
an answer, and the same answer comes back however many times it is asked. */
|
|
246
|
+
let transientErrorTypes = [
|
|
247
|
+
"InternalFailure",
|
|
248
|
+
"ServiceUnavailable",
|
|
249
|
+
"ServiceUnavailableError",
|
|
250
|
+
"Throttling",
|
|
251
|
+
"ThrottlingException",
|
|
252
|
+
"TooManyRequestsException",
|
|
253
|
+
"RequestTimeout",
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
/** Every entry must be transient for the document to be worth resending: one
|
|
257
|
+
real error alongside a transient one still means the request was wrong. An
|
|
258
|
+
empty `errors` array is not a reason to retry either. */
|
|
259
|
+
let isTransient = (errors: JSON.t): bool =>
|
|
260
|
+
switch errors->JSON.Decode.array {
|
|
261
|
+
| None | Some([]) => false
|
|
262
|
+
| Some(entries) =>
|
|
263
|
+
entries->Array.every(e =>
|
|
264
|
+
switch e->nodeString("errorType") {
|
|
265
|
+
| Some(errorType) => transientErrorTypes->Array.includes(errorType)
|
|
266
|
+
| None => false
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** An authorization refusal, in either platform's vocabulary — AppSync answers
|
|
272
|
+
`errorType: "Unauthorized"`, the local server carries it in the message. */
|
|
273
|
+
let isDenied = (errors: JSON.t): bool =>
|
|
274
|
+
switch errors->JSON.Decode.array {
|
|
275
|
+
| None => false
|
|
276
|
+
| Some(entries) =>
|
|
277
|
+
entries->Array.some(e =>
|
|
278
|
+
switch (e->nodeString("errorType"), e->nodeString("message")) {
|
|
279
|
+
| (Some(errorType), _) if errorType->String.includes("Unauthorized") => true
|
|
280
|
+
| (_, Some(message)) => message->String.includes("Not Authorized")
|
|
281
|
+
| _ => false
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let attempts = 4
|
|
287
|
+
|
|
105
288
|
/**
|
|
106
289
|
* Sends one GraphQL document and returns its `data`. A GraphQL-level error
|
|
107
290
|
* aborts the run: a half-seeded store is worse than an empty one, because it
|
|
108
291
|
* looks like a working dataset.
|
|
292
|
+
*
|
|
293
|
+
* Which is exactly why a fault on the endpoint's own side must not abort it. A
|
|
294
|
+
* seed issues hundreds of commands, many of them concurrently and all of them
|
|
295
|
+
* against runtimes that a preceding reset left cold, so a single
|
|
296
|
+
* `InternalFailure` somewhere in the batch is a normal event. Treating it as
|
|
297
|
+
* fatal threw away every command that had already landed and demanded a full
|
|
298
|
+
* wipe and re-run — for a request that would have succeeded on being asked
|
|
299
|
+
* again. So resend those, backing off between tries, and keep failing fast on
|
|
300
|
+
* everything that is an answer rather than a fault.
|
|
301
|
+
*
|
|
302
|
+
* The backoff is jittered because the commands go out in concurrent batches: a
|
|
303
|
+
* fixed delay would have the whole batch retry in lockstep and rebuild the same
|
|
304
|
+
* burst that provoked the fault.
|
|
109
305
|
*/
|
|
110
|
-
|
|
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 => {
|
|
111
319
|
let headers = Dict.fromArray([("content-type", "application/json")])
|
|
112
320
|
switch t.token {
|
|
113
321
|
| Some(token) => headers->Dict.set("authorization", `Bearer ${token}`)
|
|
@@ -116,20 +324,71 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
|
|
|
116
324
|
let body = JSON.stringify(
|
|
117
325
|
JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
|
|
118
326
|
)
|
|
119
|
-
let
|
|
120
|
-
|
|
121
|
-
throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
327
|
+
let rec attempt = async (n: int): outcome => {
|
|
328
|
+
let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
|
|
329
|
+
| _ => throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
|
|
330
|
+
}
|
|
331
|
+
let json = await res->responseJson
|
|
332
|
+
switch json->field("errors") {
|
|
333
|
+
| Some(errors) =>
|
|
334
|
+
if errors->isTransient && n < attempts {
|
|
335
|
+
await sleep(250 * Int.shiftLeft(1, n - 1) + Int.fromFloat(Math.random() *. 250.))
|
|
336
|
+
await attempt(n + 1)
|
|
337
|
+
} else {
|
|
338
|
+
let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
|
|
339
|
+
// A refusal is the one failure the response cannot explain on its own:
|
|
340
|
+
// it names the field, never the caller. The account list can't either —
|
|
341
|
+
// it holds membership, and a narrowed token carries less.
|
|
342
|
+
let identity = switch isDenied(errors) ? t->identitySummary : None {
|
|
343
|
+
| Some(summary) => `\n identity: ${summary}`
|
|
344
|
+
| None => ""
|
|
345
|
+
}
|
|
346
|
+
Errors({
|
|
347
|
+
errors,
|
|
348
|
+
message: `${label} failed\n query: ${query}\n response: ${JSON.stringify(
|
|
349
|
+
errors,
|
|
350
|
+
)}${identity}${tried}`,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
| None => Data(json->field("data")->Option.getOr(JSON.Encode.null))
|
|
354
|
+
}
|
|
130
355
|
}
|
|
356
|
+
await attempt(1)
|
|
131
357
|
}
|
|
132
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
|
+
|
|
133
392
|
// ── Commands ────────────────────────────────────────────────────────────────
|
|
134
393
|
|
|
135
394
|
let commandResultSelection = `__typename
|
|
@@ -161,6 +420,61 @@ let send = async (t: t, m: mutation, ~tolerate: array<string>=[]): option<string
|
|
|
161
420
|
}
|
|
162
421
|
}
|
|
163
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
|
+
|
|
164
478
|
let sendAll = async (t: t, mutations: array<mutation>): unit =>
|
|
165
479
|
for i in 0 to mutations->Array.length - 1 {
|
|
166
480
|
switch mutations->Array.get(i) {
|
|
@@ -175,18 +489,27 @@ let sendAll = async (t: t, mutations: array<mutation>): unit =>
|
|
|
175
489
|
* Walks a Relay-style connection to the end. Connections page at 50 by default,
|
|
176
490
|
* so any count taken from a single request silently truncates.
|
|
177
491
|
*/
|
|
178
|
-
let
|
|
179
|
-
|
|
180
|
-
|
|
492
|
+
let // `~args` is spliced into the connection's argument list — `"includeRetired: true"`
|
|
493
|
+
// is the case it exists for, and the only way to read rows the resolvers withhold
|
|
494
|
+
// by default. Literal GraphQL rather than a typed argument list because a seed
|
|
495
|
+
// writes the query it means; the alternative is a builder that has to grow a case
|
|
496
|
+
// per argument the platform adds.
|
|
497
|
+
queryAllNodes = async (
|
|
498
|
+
t: t,
|
|
499
|
+
~field as fieldName: string,
|
|
500
|
+
~selection: string,
|
|
501
|
+
~args: string="",
|
|
502
|
+
): array<JSON.t> => {
|
|
181
503
|
let nodes = []
|
|
182
504
|
let after = ref(None)
|
|
183
505
|
let more = ref(true)
|
|
506
|
+
let extra = args == "" ? "" : `, ${args}`
|
|
184
507
|
while more.contents {
|
|
185
508
|
let cursor = switch after.contents {
|
|
186
509
|
| Some(c) => `, after: ${quote(c)}`
|
|
187
510
|
| None => ""
|
|
188
511
|
}
|
|
189
|
-
let query = `{ ${fieldName}(first: 100${cursor}) { edges { node { ${selection} } } pageInfo { hasNextPage endCursor } } }`
|
|
512
|
+
let query = `{ ${fieldName}(first: 100${extra}${cursor}) { edges { node { ${selection} } } pageInfo { hasNextPage endCursor } } }`
|
|
190
513
|
let data = await gql(t, ~query, ~label=fieldName)
|
|
191
514
|
let connection = data->field(fieldName)->Option.getOr(JSON.Encode.null)
|
|
192
515
|
switch connection->field("edges") {
|
|
@@ -216,6 +539,43 @@ let queryAllNodes = async (t: t, ~field as fieldName: string, ~selection: string
|
|
|
216
539
|
let countNodes = async (t: t, ~field: string): int =>
|
|
217
540
|
(await queryAllNodes(t, ~field, ~selection="id"))->Array.length
|
|
218
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Polls a connection field until the collected nodes satisfy `satisfied`, or the
|
|
544
|
+
* timeout elapses. Read models are eventually consistent: a view queried the
|
|
545
|
+
* instant after the writes that feed it can still read empty or partial (a
|
|
546
|
+
* DynamoDB connection scan is eventually consistent by default, and the write and
|
|
547
|
+
* read-back can land on different replicas), so a cross-check that asserts on a
|
|
548
|
+
* single immediate read races the projection. Same rationale as `waitForIds`,
|
|
549
|
+
* for the "observe the whole connection" case rather than a fixed id set.
|
|
550
|
+
*
|
|
551
|
+
* On timeout, throws `Failed(onTimeout(finalNodes))` so the caller can report
|
|
552
|
+
* what the view actually settled on — distinguishing a still-empty view from a
|
|
553
|
+
* genuine count mismatch — instead of a bare assertion.
|
|
554
|
+
*/
|
|
555
|
+
let queryAllNodesUntil = async (
|
|
556
|
+
t: t,
|
|
557
|
+
~field: string,
|
|
558
|
+
~selection: string,
|
|
559
|
+
~args: string="",
|
|
560
|
+
~satisfied: array<JSON.t> => bool,
|
|
561
|
+
~onTimeout: array<JSON.t> => string,
|
|
562
|
+
~timeoutMs: int=60000,
|
|
563
|
+
): array<JSON.t> => {
|
|
564
|
+
let deadline = nowMs() +. Int.toFloat(timeoutMs)
|
|
565
|
+
let result = ref(None)
|
|
566
|
+
while result.contents == None {
|
|
567
|
+
let nodes = await queryAllNodes(t, ~field, ~selection, ~args)
|
|
568
|
+
if satisfied(nodes) {
|
|
569
|
+
result := Some(nodes)
|
|
570
|
+
} else if nowMs() > deadline {
|
|
571
|
+
throw(Failed(onTimeout(nodes)))
|
|
572
|
+
} else {
|
|
573
|
+
await sleep(500)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
result.contents->Option.getOr([])
|
|
577
|
+
}
|
|
578
|
+
|
|
219
579
|
/**
|
|
220
580
|
* Polls a `<View>ByIds` field until every id is present.
|
|
221
581
|
*
|
|
@@ -250,8 +610,9 @@ let waitForIds = async (
|
|
|
250
610
|
missing->Array.length > 10 ? ` (+${(missing->Array.length - 10)->Int.toString} more)` : ""
|
|
251
611
|
throw(
|
|
252
612
|
Failed(
|
|
253
|
-
`only ${
|
|
254
|
-
|
|
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}`,
|
|
255
616
|
),
|
|
256
617
|
)
|
|
257
618
|
} else {
|