@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
@@ -53,6 +53,10 @@ function envValue(key) {
53
53
  }
54
54
  }
55
55
 
56
+ function hasTty() {
57
+ return Stdlib_Option.getOr(process.stdin.isTTY, false);
58
+ }
59
+
56
60
  function requireTty() {
57
61
  if (Stdlib_Option.getOr(process.stdin.isTTY, false)) {
58
62
  return;
@@ -142,6 +146,15 @@ async function select(title, options, env, defaultIndex) {
142
146
  return await pick();
143
147
  }
144
148
 
149
+ function typedUser(username, password) {
150
+ return {
151
+ username: username,
152
+ password: password,
153
+ groups: [],
154
+ userId: undefined
155
+ };
156
+ }
157
+
145
158
  async function askCredentials(localDefaults, envUser) {
146
159
  let username;
147
160
  if (envUser !== undefined) {
@@ -160,10 +173,7 @@ async function askCredentials(localDefaults, envUser) {
160
173
  let entered$1 = await askHidden(localDefaults ? "Password [admin]: " : "Password: ");
161
174
  password = entered$1 === "" && localDefaults ? "admin" : entered$1;
162
175
  }
163
- return [
164
- username,
165
- password
166
- ];
176
+ return typedUser(username, password);
167
177
  }
168
178
 
169
179
  async function fromUsersFile(envUser) {
@@ -179,10 +189,10 @@ async function fromUsersFile(envUser) {
179
189
  ]), "SEED_USER", 0);
180
190
  return Stdlib_Option.map(chosen, u => {
181
191
  console.log(`Logging in as ` + u.username + ` (from ` + path + `)`);
182
- return [
183
- u.username,
184
- u.password
185
- ];
192
+ return {
193
+ caller: u,
194
+ accounts: users
195
+ };
186
196
  });
187
197
  }
188
198
 
@@ -190,33 +200,31 @@ async function credentials(localDefaultsOpt) {
190
200
  let localDefaults = localDefaultsOpt !== undefined ? localDefaultsOpt : false;
191
201
  let envUser = envValue("REVENTLESS_DEMO_USER");
192
202
  let match = envValue("REVENTLESS_DEMO_PASSWORD");
193
- let match$1;
203
+ let resolved;
194
204
  let exit = 0;
195
205
  if (envUser !== undefined && match !== undefined) {
196
- match$1 = [
197
- envUser,
198
- match
199
- ];
206
+ resolved = {
207
+ caller: typedUser(envUser, match),
208
+ accounts: []
209
+ };
200
210
  } else {
201
211
  exit = 1;
202
212
  }
203
213
  if (exit === 1) {
204
- let pair = await fromUsersFile(envUser);
205
- match$1 = pair !== undefined ? pair : await askCredentials(localDefaults, envUser);
214
+ let resolved$1 = await fromUsersFile(envUser);
215
+ resolved = resolved$1 !== undefined ? resolved$1 : ({
216
+ caller: await askCredentials(localDefaults, envUser),
217
+ accounts: []
218
+ });
206
219
  }
207
- let password = match$1[1];
208
- let username = match$1[0];
209
- if (username === "" || password === "") {
220
+ if (resolved.caller.username === "" || resolved.caller.password === "") {
210
221
  throw {
211
222
  RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
212
223
  _1: "username and password are required.",
213
224
  Error: new Error()
214
225
  };
215
226
  }
216
- return [
217
- username,
218
- password
219
- ];
227
+ return resolved;
220
228
  }
221
229
 
222
230
  export {
@@ -225,11 +233,13 @@ export {
225
233
  iface,
226
234
  close,
227
235
  envValue,
236
+ hasTty,
228
237
  requireTty,
229
238
  ask,
230
239
  askHidden,
231
240
  isAffirmative,
232
241
  select,
242
+ typedUser,
233
243
  askCredentials,
234
244
  fromUsersFile,
235
245
  credentials,
@@ -10,13 +10,15 @@ type t = {mutable state: int}
10
10
 
11
11
  let make = (~seed: int): t => {state: seed}
12
12
 
13
-
14
13
  /** mulberry32 — small, fast, and stable across JS engines and Node versions. */
15
14
  let float = (r: t): float => {
16
15
  r.state = (r.state + 0x6d2b79f5)->Int.bitwiseOr(0)
17
16
  let a = r.state
18
17
  let b = imul(a->Int.bitwiseXor(a->Int.shiftRightUnsigned(15)), a->Int.bitwiseOr(1))
19
- let c = b->Int.bitwiseXor(b + imul(b->Int.bitwiseXor(b->Int.shiftRightUnsigned(7)), b->Int.bitwiseOr(61)))
18
+ let c =
19
+ b->Int.bitwiseXor(
20
+ b + imul(b->Int.bitwiseXor(b->Int.shiftRightUnsigned(7)), b->Int.bitwiseOr(61)),
21
+ )
20
22
  let bits = c->Int.bitwiseXor(c->Int.shiftRightUnsigned(14))
21
23
  // `bits` is a signed 32-bit result; fold the sign bit back in to get the
22
24
  // unsigned value a `>>> 0` would produce.
@@ -2,7 +2,6 @@
2
2
 
3
3
  open Seed_Types
4
4
 
5
-
6
5
  let envOr = (key: string, fallback: string): string =>
7
6
  NodeProcess.env->Dict.get(key)->Option.getOr(fallback)
8
7
 
@@ -58,14 +57,12 @@ let verifyViews = async (client: Seed_Client.t, ~views: array<view>): dict<int>
58
57
  Console.log(` ${count->Int.toString->String.padStart(4, " ")} ${name}${note}`)
59
58
  })
60
59
 
61
- let empty =
62
- views
63
- ->Array.filterMap(v =>
64
- switch v {
65
- | Seeded(name) if counts->Dict.get(name)->Option.getOr(0) == 0 => Some(name)
66
- | _ => None
67
- }
68
- )
60
+ let empty = views->Array.filterMap(v =>
61
+ switch v {
62
+ | Seeded(name) if counts->Dict.get(name)->Option.getOr(0) == 0 => Some(name)
63
+ | _ => None
64
+ }
65
+ )
69
66
  if empty->Array.length > 0 {
70
67
  throw(Failed(`these views are still empty after seeding: ${empty->Array.join(", ")}`))
71
68
  }
@@ -138,9 +135,7 @@ let run = async (main: unit => promise<unit>): unit =>
138
135
  Console.error("")
139
136
  Console.error("Seeding aborted with an unexpected error:")
140
137
  Console.error("")
141
- Console.error(
142
- exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"),
143
- )
138
+ Console.error(exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"))
144
139
  NodeProcess.exit(1)
145
140
  }
146
141
 
@@ -64,7 +64,9 @@ let uploadAsset = async (
64
64
  // GraphQL string literals: JSON-quote the argument values (JSON string escaping is a
65
65
  // superset-safe subset of GraphQL's for these ASCII-ish inputs).
66
66
  let q = s => JSON.stringify(JSON.Encode.string(s))
67
- let query = `mutation { r: Upload_Presign(store: ${q(store)}, fileName: ${q(fileName)}, contentType: ${q(contentType)}) { uploadUrl storageRef } }`
67
+ let query = `mutation { r: Upload_Presign(store: ${q(store)}, fileName: ${q(
68
+ fileName,
69
+ )}, contentType: ${q(contentType)}) { uploadUrl storageRef } }`
68
70
 
69
71
  let presign = try Ok(await Seed_Client.gql(client, ~query, ~label="Upload_Presign")) catch {
70
72
  | Seed_Types.Failed(m) => Error(m)
@@ -89,7 +91,7 @@ let uploadAsset = async (
89
91
  | Ok(putRes) if putRes->responseOk => Ok(storageRef)
90
92
  | Ok(putRes) =>
91
93
  let detail = await putRes->responseText
92
- Error(`upload PUT failed with HTTP ${(putRes->responseStatus)->Int.toString}: ${detail}`)
94
+ Error(`upload PUT failed with HTTP ${putRes->responseStatus->Int.toString}: ${detail}`)
93
95
  }
94
96
  | _ => Error("Upload_Presign response missing uploadUrl/storageRef")
95
97
  }
@@ -11,8 +11,20 @@
11
11
 
12
12
  @module("yaml") external parseYaml: string => JSON.t = "parse"
13
13
 
14
- /** One account: what to log in as, and what to send with it. */
15
- type user = {username: string, password: string, groups: array<string>}
14
+ /**
15
+ * One account: what to log in as, what to send with it, and — when the file
16
+ * records one — the id the platform stamps on rows this account writes.
17
+ *
18
+ * `userId` is optional because absence is normal on both platforms: a local file
19
+ * may omit it and let the auth adapter default it to the username, and a
20
+ * hand-maintained AWS file records subs the pool minted, so it can lag.
21
+ */
22
+ type user = {
23
+ username: string,
24
+ password: string,
25
+ groups: array<string>,
26
+ userId: option<string>,
27
+ }
16
28
 
17
29
  let asString = (json: JSON.t): option<string> =>
18
30
  switch json {
@@ -32,7 +44,7 @@ let userOf = (json: JSON.t): option<user> =>
32
44
  | Some(JSON.Array(items)) => items->Array.filterMap(asString)
33
45
  | _ => []
34
46
  }
35
- Some({username, password, groups})
47
+ Some({username, password, groups, userId: obj->Dict.get("userId")->Option.flatMap(asString)})
36
48
  | _ => None
37
49
  }
38
50
  | _ => None
@@ -31,7 +31,8 @@ function userOf(json) {
31
31
  return {
32
32
  username: match,
33
33
  password: match$1,
34
- groups: groups
34
+ groups: groups,
35
+ userId: Stdlib_Option.flatMap(json["userId"], asString)
35
36
  };
36
37
  }
37
38
 
@@ -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", () => {
@@ -1,5 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
5
  import * as Seed_Client$ReventlessSeed from "../src/Seed_Client.res.mjs";
4
6
 
5
7
  function errors(entries) {
@@ -61,9 +63,11 @@ function b64url(payload) {
61
63
  return Buffer.from(payload, "utf8").toString("base64url");
62
64
  }
63
65
 
66
+ let joseHeader = Buffer.from(`{"kid":"abc123","alg":"RS256"}`, "utf8").toString("base64url");
67
+
64
68
  function jwt(claims) {
65
69
  let payload = JSON.stringify(Object.fromEntries(claims));
66
- return `header.` + Buffer.from(payload, "utf8").toString("base64url") + `.signature`;
70
+ return joseHeader + `.` + Buffer.from(payload, "utf8").toString("base64url") + `.signature`;
67
71
  }
68
72
 
69
73
  function localToken(claims) {
@@ -83,6 +87,21 @@ function clientWith(token) {
83
87
  return c;
84
88
  }
85
89
 
90
+ globalThis.describe("Seed_Client.claims:", () => {
91
+ globalThis.test("skips the JOSE header and reads the payload behind it", () => {
92
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Seed_Client$ReventlessSeed.claims(clientWith(jwt([[
93
+ "sub",
94
+ "real-sub"
95
+ ]]))), c => c["sub"]), Stdlib_JSON.Decode.string)).toEqual("real-sub");
96
+ });
97
+ globalThis.test("takes the first segment when there is no header", () => {
98
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Seed_Client$ReventlessSeed.claims(clientWith(localToken([[
99
+ "userId",
100
+ "local-shopper"
101
+ ]]))), c => c["userId"]), Stdlib_JSON.Decode.string)).toEqual("local-shopper");
102
+ });
103
+ });
104
+
86
105
  globalThis.describe("Seed_Client.effectiveGroups:", () => {
87
106
  globalThis.test("reads the payload wherever the provider put it", () => {
88
107
  globalThis.expect(Seed_Client$ReventlessSeed.effectiveGroups(clientWith(jwt([[
@@ -108,6 +127,50 @@ globalThis.describe("Seed_Client.effectiveGroups:", () => {
108
127
  });
109
128
  });
110
129
 
130
+ globalThis.describe("Seed_Client.callerId:", () => {
131
+ globalThis.test("reads the id under whichever name the provider gave it", () => {
132
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([[
133
+ "sub",
134
+ "4275e4a4-00c1-70e1"
135
+ ]])))).toEqual("4275e4a4-00c1-70e1");
136
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(localToken([[
137
+ "userId",
138
+ "local-shopper"
139
+ ]])))).toEqual("local-shopper");
140
+ });
141
+ globalThis.test("prefers sub when a token carries both names", () => {
142
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([
143
+ [
144
+ "sub",
145
+ "f215e4b4-30f1-700c"
146
+ ],
147
+ [
148
+ "userId",
149
+ "local-shopper"
150
+ ]
151
+ ])))).toEqual("f215e4b4-30f1-700c");
152
+ });
153
+ globalThis.test("says nothing about a token it cannot read, or one naming no id", () => {
154
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith("not-a-token"))).toEqual(undefined);
155
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([[
156
+ "cognito:groups",
157
+ ["Admin"].map(prim => prim)
158
+ ]])))).toEqual(undefined);
159
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(Seed_Client$ReventlessSeed.make({
160
+ endpoint: "http://example.invalid/graphql"
161
+ }))).toEqual(undefined);
162
+ });
163
+ globalThis.test("refuses a claim that is not a single string", () => {
164
+ globalThis.expect(Seed_Client$ReventlessSeed.callerId(clientWith(jwt([[
165
+ "sub",
166
+ [
167
+ "a",
168
+ "b"
169
+ ].map(prim => prim)
170
+ ]])))).toEqual(undefined);
171
+ });
172
+ });
173
+
111
174
  globalThis.describe("Seed_Client.identitySummary:", () => {
112
175
  globalThis.test("names the membership a narrowed token gave up", () => {
113
176
  globalThis.expect(Seed_Client$ReventlessSeed.identitySummary(clientWith(jwt([
@@ -138,6 +201,43 @@ globalThis.describe("Seed_Client.identitySummary:", () => {
138
201
  });
139
202
  });
140
203
 
204
+ globalThis.describe("Seed_Client.narrowedFrom:", () => {
205
+ globalThis.test("names the membership a narrowed token was reduced from", () => {
206
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([
207
+ [
208
+ "cognito:groups",
209
+ ["Shopper"].map(prim => prim)
210
+ ],
211
+ [
212
+ "availableRoles",
213
+ "Admin,Shopper"
214
+ ]
215
+ ])))).toEqual([
216
+ "Admin",
217
+ "Shopper"
218
+ ]);
219
+ });
220
+ globalThis.test("says nothing about an unnarrowed token", () => {
221
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([[
222
+ "cognito:groups",
223
+ ["Shopper"].map(prim => prim)
224
+ ]])))).toEqual(undefined);
225
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([[
226
+ "cognito:groups",
227
+ [
228
+ "Admin",
229
+ "Shopper"
230
+ ].map(prim => prim)
231
+ ]])))).toEqual(undefined);
232
+ });
233
+ globalThis.test("treats an empty availableRoles as not narrowed", () => {
234
+ globalThis.expect(Seed_Client$ReventlessSeed.narrowedFrom(clientWith(jwt([[
235
+ "availableRoles",
236
+ ""
237
+ ]])))).toEqual(undefined);
238
+ });
239
+ });
240
+
141
241
  globalThis.describe("Seed_Client.isDenied:", () => {
142
242
  globalThis.test("recognises a refusal in either platform's vocabulary", () => {
143
243
  let entries = [errorOfType("Unauthorized")];
@@ -159,6 +259,7 @@ export {
159
259
  errors,
160
260
  errorOfType,
161
261
  b64url,
262
+ joseHeader,
162
263
  jwt,
163
264
  localToken,
164
265
  strings,
@@ -0,0 +1,64 @@
1
+ open JestGlobals
2
+
3
+ // `SEED_ROLE` is how a run says what to act as when nobody can answer a prompt —
4
+ // a CI run wedged by a stored narrowing, or a deliberate restricted run.
5
+ //
6
+ // It carries two different intentions in one variable, which is why the parsing
7
+ // is worth pinning: naming a role narrows to it, and the "give me everything"
8
+ // answer has several spellings because an operator setting it by hand will write
9
+ // whichever one comes to mind. Getting that wrong is not a typo — `SEED_ROLE=full`
10
+ // read as a role named "full" would ask the platform to act as a group nobody
11
+ // holds, and be refused.
12
+
13
+ let withEnv = (value: option<string>, f: unit => unit) => {
14
+ let key = "SEED_ROLE"
15
+ let previous = NodeProcess.env->Dict.get(key)
16
+ switch value {
17
+ | Some(v) => NodeProcess.env->Dict.set(key, v)
18
+ | None => NodeProcess.env->Dict.delete(key)
19
+ }
20
+ f()
21
+ switch previous {
22
+ | Some(v) => NodeProcess.env->Dict.set(key, v)
23
+ | None => NodeProcess.env->Dict.delete(key)
24
+ }
25
+ }
26
+
27
+ describe("Seed_Connect.roleFromEnv:", () => {
28
+ testSync("is absent when nothing asked for a role", () =>
29
+ withEnv(None, () => expect(Seed_Connect.roleFromEnv())->Expect.toEqual(None))
30
+ )
31
+
32
+ testSync("reads a named role as a narrowing to it", () =>
33
+ withEnv(
34
+ Some("Shopper"),
35
+ () =>
36
+ expect(Seed_Connect.roleFromEnv())->Expect.toEqual(Some(Seed_Connect.Narrowed("Shopper"))),
37
+ )
38
+ )
39
+
40
+ // Every spelling of "everything" an operator might reach for. They all mean
41
+ // clear the stored choice, which is what seeding wants.
42
+ testSync("takes each spelling of full membership as a clear", () =>
43
+ ["full", "all", "none", "clear", "FULL", "All"]->Array.forEach(
44
+ v =>
45
+ withEnv(
46
+ Some(v),
47
+ () => expect(Seed_Connect.roleFromEnv())->Expect.toEqual(Some(Seed_Connect.Full)),
48
+ ),
49
+ )
50
+ )
51
+
52
+ // Role names are case-sensitive to the platform (`mayActAs` compares exactly),
53
+ // so a named role is passed through untouched — only the clear vocabulary is
54
+ // case-folded.
55
+ testSync("passes a role name through with its case intact", () =>
56
+ withEnv(
57
+ Some("Merchandiser"),
58
+ () =>
59
+ expect(Seed_Connect.roleFromEnv())->Expect.toEqual(
60
+ Some(Seed_Connect.Narrowed("Merchandiser")),
61
+ ),
62
+ )
63
+ )
64
+ })