@reventlessdev/reventless-seed 1.0.0-alpha.20 → 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.
Files changed (152) hide show
  1. package/CHANGELOG.md +12 -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 +47 -23
  28. package/lib/bs/src/Seed_Prompt.res.mjs +32 -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.cmt +0 -0
  43. package/lib/bs/src/Seed_Users.ast +0 -0
  44. package/lib/bs/src/Seed_Users.res +15 -3
  45. package/lib/bs/src/Seed_Users.res.mjs +2 -1
  46. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  47. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  48. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  49. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  50. package/lib/bs/tests/Seed_ClientTest.res +146 -16
  51. package/lib/bs/tests/Seed_ClientTest.res.mjs +102 -1
  52. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  53. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  54. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  55. package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
  56. package/lib/bs/tests/Seed_ConnectTest.res +64 -0
  57. package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
  58. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  59. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  60. package/lib/bs/tests/Seed_PromptTest.ast +0 -0
  61. package/lib/bs/tests/Seed_PromptTest.res +8 -12
  62. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  63. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  64. package/lib/bs/tests/Seed_RandomTest.ast +0 -0
  65. package/lib/bs/tests/Seed_RandomTest.res +130 -90
  66. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  67. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  68. package/lib/bs/tests/Seed_UsersTest.ast +0 -0
  69. package/lib/bs/tests/Seed_UsersTest.res +39 -2
  70. package/lib/bs/tests/Seed_UsersTest.res.mjs +20 -2
  71. package/lib/ocaml/.compiler.log +2 -2
  72. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  73. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  74. package/lib/ocaml/ReventlessSeed.mlmap +1 -0
  75. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  76. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  77. package/lib/ocaml/Seed.ast +0 -0
  78. package/lib/ocaml/Seed.res +8 -2
  79. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  80. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  81. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  82. package/lib/ocaml/Seed_Client.ast +0 -0
  83. package/lib/ocaml/Seed_Client.res +169 -21
  84. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  85. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  86. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  87. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  88. package/lib/ocaml/Seed_ClientTest.res +146 -16
  89. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  90. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
  91. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  92. package/lib/ocaml/Seed_Connect.ast +0 -0
  93. package/lib/ocaml/Seed_Connect.res +258 -19
  94. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  95. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  96. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  97. package/lib/ocaml/Seed_ConnectTest.ast +0 -0
  98. package/lib/ocaml/Seed_ConnectTest.res +64 -0
  99. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
  100. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
  101. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  102. package/lib/ocaml/Seed_Prompt.ast +0 -0
  103. package/lib/ocaml/Seed_Prompt.res +47 -23
  104. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  105. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  106. package/lib/ocaml/Seed_PromptTest.ast +0 -0
  107. package/lib/ocaml/Seed_PromptTest.res +8 -12
  108. package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
  109. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  110. package/lib/ocaml/Seed_Random.ast +0 -0
  111. package/lib/ocaml/Seed_Random.res +4 -2
  112. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  113. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  114. package/lib/ocaml/Seed_RandomTest.ast +0 -0
  115. package/lib/ocaml/Seed_RandomTest.res +130 -90
  116. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  117. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  118. package/lib/ocaml/Seed_Runner.ast +0 -0
  119. package/lib/ocaml/Seed_Runner.res +7 -12
  120. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  121. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  122. package/lib/ocaml/Seed_Upload.ast +0 -0
  123. package/lib/ocaml/Seed_Upload.res +4 -2
  124. package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
  125. package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
  126. package/lib/ocaml/Seed_Users.ast +0 -0
  127. package/lib/ocaml/Seed_Users.res +15 -3
  128. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  129. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  130. package/lib/ocaml/Seed_UsersTest.ast +0 -0
  131. package/lib/ocaml/Seed_UsersTest.res +39 -2
  132. package/package.json +2 -2
  133. package/src/Seed.res +8 -2
  134. package/src/Seed_Client.res +169 -21
  135. package/src/Seed_Client.res.mjs +142 -7
  136. package/src/Seed_Connect.res +258 -19
  137. package/src/Seed_Connect.res.mjs +158 -8
  138. package/src/Seed_Prompt.res +47 -23
  139. package/src/Seed_Prompt.res.mjs +32 -22
  140. package/src/Seed_Random.res +4 -2
  141. package/src/Seed_Runner.res +7 -12
  142. package/src/Seed_Upload.res +4 -2
  143. package/src/Seed_Users.res +15 -3
  144. package/src/Seed_Users.res.mjs +2 -1
  145. package/tests/Seed_ClientTest.res +146 -16
  146. package/tests/Seed_ClientTest.res.mjs +102 -1
  147. package/tests/Seed_ConnectTest.res +64 -0
  148. package/tests/Seed_ConnectTest.res.mjs +56 -0
  149. package/tests/Seed_PromptTest.res +8 -12
  150. package/tests/Seed_RandomTest.res +130 -90
  151. package/tests/Seed_UsersTest.res +39 -2
  152. package/tests/Seed_UsersTest.res.mjs +20 -2
@@ -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 {
Binary file
@@ -15,15 +15,16 @@ open JestGlobals
15
15
 
16
16
  let errors = entries => JSON.Encode.array(entries->Array.map(JSON.Encode.object))
17
17
 
18
- let errorOfType = errorType =>
19
- Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
18
+ let errorOfType = errorType => Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
20
19
 
21
20
  describe("Seed_Client.isTransient:", () => {
22
21
  testSync("resends a fault on the endpoint's own side", () =>
23
- ["InternalFailure", "ServiceUnavailable", "Throttling", "TooManyRequestsException"]
24
- ->Array.forEach(t =>
25
- expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true)
26
- )
22
+ [
23
+ "InternalFailure",
24
+ "ServiceUnavailable",
25
+ "Throttling",
26
+ "TooManyRequestsException",
27
+ ]->Array.forEach(t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true))
27
28
  )
28
29
 
29
30
  // The exact payload the seed aborted on.
@@ -33,8 +34,8 @@ describe("Seed_Client.isTransient:", () => {
33
34
  })
34
35
 
35
36
  testSync("fails fast on an error that is an answer", () =>
36
- ["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(t =>
37
- expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false)
37
+ ["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(
38
+ t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false),
38
39
  )
39
40
  )
40
41
 
@@ -52,8 +53,9 @@ describe("Seed_Client.isTransient:", () => {
52
53
  // endpoint said errors but named none" into an endless resend.
53
54
  testSync("treats an empty or untyped error list as final", () => {
54
55
  expect(Seed_Client.isTransient(errors([])))->toBe(false)
55
- expect(Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])))
56
- ->toBe(false)
56
+ expect(
57
+ Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])),
58
+ )->toBe(false)
57
59
  expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
58
60
  })
59
61
  })
@@ -70,8 +72,15 @@ let b64url = payload =>
70
72
  ->NodeBuffer.fromStringUtf8
71
73
  ->NodeBuffer.toStringBase64Url
72
74
 
75
+ // A REAL JOSE header, not the literal string "header". The difference is the
76
+ // whole bug: `{"kid":…,"alg":"RS256"}` is base64url JSON and decodes exactly as
77
+ // the payload does, so `claims` took it as the claims set and found no groups
78
+ // and no id in any Cognito token ever issued. A fake header that happened not to
79
+ // decode is what let every assertion below pass against broken code.
80
+ let joseHeader = b64url(`{"kid":"abc123","alg":"RS256"}`)
81
+
73
82
  let jwt = claims =>
74
- `header.${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
83
+ `${joseHeader}.${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
75
84
 
76
85
  let localToken = claims =>
77
86
  `${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
@@ -84,12 +93,41 @@ let clientWith = token => {
84
93
  c
85
94
  }
86
95
 
96
+ describe("Seed_Client.claims:", () => {
97
+ // The regression this file could not previously express. A JWT header is a
98
+ // decodable JSON object sitting in front of the payload, so "the first segment
99
+ // that parses" is the header on every real token — and the seed then reported
100
+ // no groups, printed no `Acting as:` line, and attached no identity to a
101
+ // refusal, which is exactly the diagnosis it exists to give.
102
+ testSync("skips the JOSE header and reads the payload behind it", () =>
103
+ expect(
104
+ clientWith(jwt([("sub", JSON.Encode.string("real-sub"))]))
105
+ ->Seed_Client.claims
106
+ ->Option.flatMap(c => c->Dict.get("sub"))
107
+ ->Option.flatMap(JSON.Decode.string),
108
+ )->toEqual(Some("real-sub"))
109
+ )
110
+
111
+ // The header is identified by `alg`, not by position — a two-segment local dev
112
+ // token has no header at all, and its payload must not be mistaken for one.
113
+ testSync("takes the first segment when there is no header", () =>
114
+ expect(
115
+ clientWith(localToken([("userId", JSON.Encode.string("local-shopper"))]))
116
+ ->Seed_Client.claims
117
+ ->Option.flatMap(c => c->Dict.get("userId"))
118
+ ->Option.flatMap(JSON.Decode.string),
119
+ )->toEqual(Some("local-shopper"))
120
+ )
121
+ })
122
+
87
123
  describe("Seed_Client.effectiveGroups:", () => {
88
124
  // A Cognito id token carries its payload in the second segment, a local dev
89
125
  // token in the first. Neither platform should have to announce which it is.
90
126
  testSync("reads the payload wherever the provider put it", () => {
91
127
  expect(
92
- clientWith(jwt([("cognito:groups", strings(["Admin", "Shopper"]))]))->Seed_Client.effectiveGroups,
128
+ clientWith(
129
+ jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
130
+ )->Seed_Client.effectiveGroups,
93
131
  )->toEqual(Some(["Admin", "Shopper"]))
94
132
  expect(
95
133
  clientWith(localToken([("groups", strings(["Merchandiser"]))]))->Seed_Client.effectiveGroups,
@@ -101,12 +139,64 @@ describe("Seed_Client.effectiveGroups:", () => {
101
139
  testSync("says nothing about a token it cannot read", () => {
102
140
  expect(clientWith("not-a-token")->Seed_Client.effectiveGroups)->toEqual(None)
103
141
  expect(
104
- Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})
105
- ->Seed_Client.effectiveGroups,
142
+ Seed_Client.make(
143
+ ~config={endpoint: "http://example.invalid/graphql"},
144
+ )->Seed_Client.effectiveGroups,
106
145
  )->toEqual(None)
107
146
  })
108
147
  })
109
148
 
149
+ // The id an `@owner` field is stamped with. A seed that cannot read it can only
150
+ // key owner-scoped rows to a literal, and a literal matches one platform's
151
+ // accounts at most — which is how a demo shopper came to be registered on a
152
+ // deployment where nobody can ever be that shopper.
153
+
154
+ describe("Seed_Client.callerId:", () => {
155
+ // A Cognito id token says `sub`; the local dev token is a base64url
156
+ // `Identity.t`, whose field is `userId`. Neither platform announces which.
157
+ testSync("reads the id under whichever name the provider gave it", () => {
158
+ expect(
159
+ clientWith(jwt([("sub", JSON.Encode.string("4275e4a4-00c1-70e1"))]))->Seed_Client.callerId,
160
+ )->toEqual(Some("4275e4a4-00c1-70e1"))
161
+ expect(
162
+ clientWith(
163
+ localToken([("userId", JSON.Encode.string("local-shopper"))]),
164
+ )->Seed_Client.callerId,
165
+ )->toEqual(Some("local-shopper"))
166
+ })
167
+
168
+ // `sub` first, so a token carrying both is read the way the pool that minted
169
+ // it means it.
170
+ testSync("prefers sub when a token carries both names", () =>
171
+ expect(
172
+ clientWith(
173
+ jwt([
174
+ ("sub", JSON.Encode.string("f215e4b4-30f1-700c")),
175
+ ("userId", JSON.Encode.string("local-shopper")),
176
+ ]),
177
+ )->Seed_Client.callerId,
178
+ )->toEqual(Some("f215e4b4-30f1-700c"))
179
+ )
180
+
181
+ // `None` is not an error — it means this cannot add anything, and the caller
182
+ // falls back to whatever the accounts file declared.
183
+ testSync("says nothing about a token it cannot read, or one naming no id", () => {
184
+ expect(clientWith("not-a-token")->Seed_Client.callerId)->toEqual(None)
185
+ expect(
186
+ clientWith(jwt([("cognito:groups", strings(["Admin"]))]))->Seed_Client.callerId,
187
+ )->toEqual(None)
188
+ expect(
189
+ Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})->Seed_Client.callerId,
190
+ )->toEqual(None)
191
+ })
192
+
193
+ // A claim of the wrong shape is not an id. Taking the array's first element
194
+ // would be a guess, and a guessed owner id seeds rows nobody can read.
195
+ testSync("refuses a claim that is not a single string", () =>
196
+ expect(clientWith(jwt([("sub", strings(["a", "b"]))]))->Seed_Client.callerId)->toEqual(None)
197
+ )
198
+ })
199
+
110
200
  describe("Seed_Client.identitySummary:", () => {
111
201
  // The case that cost the debugging: eligible in the user list, refused by the
112
202
  // token. Naming what it was narrowed FROM is what points at the role switch.
@@ -124,8 +214,9 @@ describe("Seed_Client.identitySummary:", () => {
124
214
  // An ordinary login is not narrowed and has nothing to explain.
125
215
  testSync("stays quiet about an unnarrowed token", () =>
126
216
  expect(
127
- clientWith(jwt([("cognito:groups", strings(["Admin", "Shopper"]))]))
128
- ->Seed_Client.identitySummary,
217
+ clientWith(
218
+ jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
219
+ )->Seed_Client.identitySummary,
129
220
  )->toEqual(Some("Admin, Shopper"))
130
221
  )
131
222
 
@@ -137,6 +228,45 @@ describe("Seed_Client.identitySummary:", () => {
137
228
  )
138
229
  })
139
230
 
231
+ // Whether a token is narrowed cannot be read off the group claim: a bearer
232
+ // carrying one group looks identical whether the account holds one role or five.
233
+ // `availableRoles` is written only when a role was chosen, so its presence IS
234
+ // the narrowing — and that is what decides whether the seed has anything to ask.
235
+
236
+ describe("Seed_Client.narrowedFrom:", () => {
237
+ testSync("names the membership a narrowed token was reduced from", () =>
238
+ expect(
239
+ clientWith(
240
+ jwt([
241
+ ("cognito:groups", strings(["Shopper"])),
242
+ ("availableRoles", JSON.Encode.string("Admin,Shopper")),
243
+ ]),
244
+ )->Seed_Client.narrowedFrom,
245
+ )->toEqual(Some(["Admin", "Shopper"]))
246
+ )
247
+
248
+ // The case that must NOT prompt: a single-role account is not narrowed, it is
249
+ // simply small. Asking it to choose would put a question in every run.
250
+ testSync("says nothing about an unnarrowed token", () => {
251
+ expect(
252
+ clientWith(jwt([("cognito:groups", strings(["Shopper"]))]))->Seed_Client.narrowedFrom,
253
+ )->toEqual(None)
254
+ expect(
255
+ clientWith(
256
+ jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
257
+ )->Seed_Client.narrowedFrom,
258
+ )->toEqual(None)
259
+ })
260
+
261
+ // An empty claim is not a narrowing to nothing — it is a claim carrying no
262
+ // information, and offering a menu of no roles would be a dead end.
263
+ testSync("treats an empty availableRoles as not narrowed", () =>
264
+ expect(
265
+ clientWith(jwt([("availableRoles", JSON.Encode.string(""))]))->Seed_Client.narrowedFrom,
266
+ )->toEqual(None)
267
+ )
268
+ })
269
+
140
270
  describe("Seed_Client.isDenied:", () => {
141
271
  // AppSync types it; the local server carries it in the message.
142
272
  testSync("recognises a refusal in either platform's vocabulary", () => {
Binary file