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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/ReventlessSeed.cmi +0 -0
  4. package/lib/bs/ReventlessSeed.cmt +0 -0
  5. package/lib/bs/ReventlessSeed.mlmap +1 -0
  6. package/lib/bs/compiler-info.json +1 -1
  7. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  8. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  9. package/lib/bs/src/Seed.ast +0 -0
  10. package/lib/bs/src/Seed.res +8 -2
  11. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  12. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  13. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  14. package/lib/bs/src/Seed_Client.ast +0 -0
  15. package/lib/bs/src/Seed_Client.res +169 -21
  16. package/lib/bs/src/Seed_Client.res.mjs +142 -7
  17. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  18. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
  19. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  20. package/lib/bs/src/Seed_Connect.ast +0 -0
  21. package/lib/bs/src/Seed_Connect.res +258 -19
  22. package/lib/bs/src/Seed_Connect.res.mjs +158 -8
  23. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
  24. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
  25. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
  26. package/lib/bs/src/Seed_Prompt.ast +0 -0
  27. package/lib/bs/src/Seed_Prompt.res +48 -23
  28. package/lib/bs/src/Seed_Prompt.res.mjs +33 -22
  29. package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
  30. package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
  31. package/lib/bs/src/Seed_Random.ast +0 -0
  32. package/lib/bs/src/Seed_Random.res +4 -2
  33. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  34. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  35. package/lib/bs/src/Seed_Runner.ast +0 -0
  36. package/lib/bs/src/Seed_Runner.res +7 -12
  37. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  38. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  39. package/lib/bs/src/Seed_Upload.ast +0 -0
  40. package/lib/bs/src/Seed_Upload.res +4 -2
  41. package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
  42. package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
  43. package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
  44. package/lib/bs/src/Seed_Users.ast +0 -0
  45. package/lib/bs/src/Seed_Users.res +30 -3
  46. package/lib/bs/src/Seed_Users.res.mjs +14 -1
  47. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  48. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  49. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  50. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  51. package/lib/bs/tests/Seed_ClientTest.res +146 -16
  52. package/lib/bs/tests/Seed_ClientTest.res.mjs +102 -1
  53. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  54. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  55. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  56. package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
  57. package/lib/bs/tests/Seed_ConnectTest.res +64 -0
  58. package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
  59. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  60. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  61. package/lib/bs/tests/Seed_PromptTest.ast +0 -0
  62. package/lib/bs/tests/Seed_PromptTest.res +8 -12
  63. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  64. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  65. package/lib/bs/tests/Seed_RandomTest.ast +0 -0
  66. package/lib/bs/tests/Seed_RandomTest.res +130 -90
  67. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  68. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  69. package/lib/bs/tests/Seed_UsersTest.ast +0 -0
  70. package/lib/bs/tests/Seed_UsersTest.res +77 -2
  71. package/lib/bs/tests/Seed_UsersTest.res.mjs +45 -2
  72. package/lib/ocaml/.compiler.log +2 -2
  73. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  74. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  75. package/lib/ocaml/ReventlessSeed.mlmap +1 -0
  76. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  77. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  78. package/lib/ocaml/Seed.ast +0 -0
  79. package/lib/ocaml/Seed.res +8 -2
  80. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  81. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  82. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  83. package/lib/ocaml/Seed_Client.ast +0 -0
  84. package/lib/ocaml/Seed_Client.res +169 -21
  85. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  86. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  87. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  88. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  89. package/lib/ocaml/Seed_ClientTest.res +146 -16
  90. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  91. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
  92. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  93. package/lib/ocaml/Seed_Connect.ast +0 -0
  94. package/lib/ocaml/Seed_Connect.res +258 -19
  95. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  96. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  97. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  98. package/lib/ocaml/Seed_ConnectTest.ast +0 -0
  99. package/lib/ocaml/Seed_ConnectTest.res +64 -0
  100. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
  101. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
  102. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  103. package/lib/ocaml/Seed_Prompt.ast +0 -0
  104. package/lib/ocaml/Seed_Prompt.res +48 -23
  105. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  106. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  107. package/lib/ocaml/Seed_PromptTest.ast +0 -0
  108. package/lib/ocaml/Seed_PromptTest.res +8 -12
  109. package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
  110. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  111. package/lib/ocaml/Seed_Random.ast +0 -0
  112. package/lib/ocaml/Seed_Random.res +4 -2
  113. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  114. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  115. package/lib/ocaml/Seed_RandomTest.ast +0 -0
  116. package/lib/ocaml/Seed_RandomTest.res +130 -90
  117. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  118. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  119. package/lib/ocaml/Seed_Runner.ast +0 -0
  120. package/lib/ocaml/Seed_Runner.res +7 -12
  121. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  122. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  123. package/lib/ocaml/Seed_Upload.ast +0 -0
  124. package/lib/ocaml/Seed_Upload.res +4 -2
  125. package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
  126. package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
  127. package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
  128. package/lib/ocaml/Seed_Users.ast +0 -0
  129. package/lib/ocaml/Seed_Users.res +30 -3
  130. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  131. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  132. package/lib/ocaml/Seed_UsersTest.ast +0 -0
  133. package/lib/ocaml/Seed_UsersTest.res +77 -2
  134. package/package.json +2 -2
  135. package/src/Seed.res +8 -2
  136. package/src/Seed_Client.res +169 -21
  137. package/src/Seed_Client.res.mjs +142 -7
  138. package/src/Seed_Connect.res +258 -19
  139. package/src/Seed_Connect.res.mjs +158 -8
  140. package/src/Seed_Prompt.res +48 -23
  141. package/src/Seed_Prompt.res.mjs +33 -22
  142. package/src/Seed_Random.res +4 -2
  143. package/src/Seed_Runner.res +7 -12
  144. package/src/Seed_Upload.res +4 -2
  145. package/src/Seed_Users.res +30 -3
  146. package/src/Seed_Users.res.mjs +14 -1
  147. package/tests/Seed_ClientTest.res +146 -16
  148. package/tests/Seed_ClientTest.res.mjs +102 -1
  149. package/tests/Seed_ConnectTest.res +64 -0
  150. package/tests/Seed_ConnectTest.res.mjs +56 -0
  151. package/tests/Seed_PromptTest.res +8 -12
  152. package/tests/Seed_RandomTest.res +130 -90
  153. package/tests/Seed_UsersTest.res +77 -2
  154. package/tests/Seed_UsersTest.res.mjs +45 -2
@@ -9,6 +9,45 @@
9
9
 
10
10
  open Seed_Types
11
11
 
12
+ // ── Active role ───────────────────────────────────────────────────────────────
13
+ //
14
+ // Both platforms narrow a token to the single role the caller last chose, and on
15
+ // Cognito that choice is STORED — so an operator who acted as one role in the
16
+ // host shell gets a narrowed token from the next seed login, days later, with
17
+ // nothing in the seed's own environment to explain it. That is a refusal on the
18
+ // first gated call and no way out of the terminal.
19
+
20
+ /**
21
+ * Applies an active-role choice, if the platform offers one. `None` clears the
22
+ * choice and widens back to the caller's full membership.
23
+ *
24
+ * Returns the fresh bearer when the platform mints one itself, and `None` when
25
+ * the choice is merely stored and a re-authentication is what picks it up. The
26
+ * two platforms genuinely differ here: the local server re-issues on the spot,
27
+ * while Cognito's pre-token-generation trigger reads the stored row on the next
28
+ * login, so nothing changes until the seed authenticates again.
29
+ *
30
+ * Supplied by the platform adapter for the reason `~login` is: this package must
31
+ * not learn which provider signed the token, and the routes are not alike — a
32
+ * GraphQL mutation on one, an HTTP endpoint on the other.
33
+ */
34
+ type roleSwitch = (~client: Seed_Client.t, ~role: option<string>) => promise<option<string>>
35
+
36
+ /** What a run should act as. `Full` is the default because seeding wants every
37
+ right the account has; `Narrowed` exists so a run can deliberately act as one
38
+ role — the only way to exercise what a restricted caller is refused. */
39
+ type roleChoice = Full | Narrowed(string)
40
+
41
+ // `SEED_ROLE`: a role name, or `full`/`all` to clear. Honoured whether or not the
42
+ // token arrived narrowed, so a deliberate narrowed run needs no TTY.
43
+ let roleFromEnv = (): option<roleChoice> =>
44
+ Seed_Prompt.envValue("SEED_ROLE")->Option.map(v =>
45
+ switch v->String.toLowerCase {
46
+ | "full" | "all" | "none" | "clear" => Full
47
+ | _ => Narrowed(v)
48
+ }
49
+ )
50
+
12
51
  // Under route B the client mints uploads through the domain API's `Upload_Presign`
13
52
  // mutation (authenticated by the same bearer as commands), so a connection needs no
14
53
  // upload endpoint — just the authenticated client and whether uploads are skipped this
@@ -17,35 +56,168 @@ type connection = {
17
56
  client: Seed_Client.t,
18
57
  uploadsSkipped: bool,
19
58
  label: string,
59
+ // Every account the platform's accounts file declares — `[]` when there was no
60
+ // file, since the `REVENTLESS_DEMO_USER`/`REVENTLESS_DEMO_PASSWORD` path
61
+ // bypasses it entirely. A data set resolves a demo owner through this, so the
62
+ // id it seeds is the id the platform stamps rather than a literal that only
63
+ // happens to match one platform's accounts.
64
+ accounts: array<Seed_Users.user>,
65
+ // The account this run authenticated as, and the id its bearer actually
66
+ // carries — which is the only id available when there is no accounts file.
67
+ caller: Seed_Users.user,
68
+ callerId: option<string>,
69
+ // The login this run authenticated with, kept so a data set can mint a second
70
+ // client for another account. An owner-scoped read is only ever verified by
71
+ // the account the rows belong to: the seeding client is elevated, and an
72
+ // elevated token answers for every owner at once.
73
+ login: (~username: string, ~password: string) => promise<string>,
74
+ // How this platform stores an active-role choice, when it offers one at all.
75
+ // `None` means the deployment has no such door (a unified-mode AWS API does
76
+ // not carry `Platform_SetActiveRole`), and a narrowing can then only be
77
+ // reported, not fixed from here.
78
+ roleSwitch: option<roleSwitch>,
20
79
  }
21
80
 
81
+ let announceIdentity = (client: Seed_Client.t, ~prefix: string="Acting as"): unit =>
82
+ switch Seed_Client.identitySummary(client) {
83
+ | Some(summary) => Console.log(`${prefix}: ${summary}`)
84
+ | None => ()
85
+ }
86
+
87
+ /**
88
+ * Applies a role choice and leaves `client` holding a bearer that reflects it.
89
+ *
90
+ * The re-authentication is not optional bookkeeping: on Cognito the stored row
91
+ * is read at token generation, so a run that set a role and kept its old bearer
92
+ * would carry on with exactly the token it just tried to change.
93
+ */
94
+ let applyRole = async (
95
+ ~client: Seed_Client.t,
96
+ ~roleSwitch: roleSwitch,
97
+ ~login: (~username: string, ~password: string) => promise<string>,
98
+ ~caller: Seed_Users.user,
99
+ ~role: option<string>,
100
+ ): unit => {
101
+ let minted = await roleSwitch(~client, ~role)
102
+ let token = switch minted {
103
+ | Some(fresh) => fresh
104
+ | None => await login(~username=caller.username, ~password=caller.password)
105
+ }
106
+ client->Seed_Client.useToken(token)
107
+ // A second line rather than a replacement, and worded so the pair reads as a
108
+ // change: the first says what the login gave, this says what the run proceeds
109
+ // under, and a reader needs both to see that the switch actually took.
110
+ client->announceIdentity(~prefix="Now acting as")
111
+ }
112
+
113
+ /**
114
+ * Chooses what the run acts as, when the platform lets it choose.
115
+ *
116
+ * Deliberately quiet on a healthy run: an unnarrowed token has nothing to ask
117
+ * about, so the common case gains no prompt. The menu appears only when the
118
+ * bearer arrived narrowed — the case where the account list says one thing and
119
+ * the token another, which is unreadable from the terminal without it.
120
+ *
121
+ * `SEED_ROLE` answers non-interactively and is honoured even when the token is
122
+ * NOT narrowed, which is what makes a deliberate restricted run possible: acting
123
+ * as one role is the only way to exercise what a restricted caller is refused.
124
+ *
125
+ * Without a TTY and without `SEED_ROLE` it proceeds untouched rather than
126
+ * throwing. A narrowing is not always fatal — narrowed to Admin is fine — so
127
+ * refusing to start would break runs that would have worked.
128
+ */
129
+ let resolveRole = async (
130
+ ~client: Seed_Client.t,
131
+ ~roleSwitch: option<roleSwitch>,
132
+ ~login: (~username: string, ~password: string) => promise<string>,
133
+ ~caller: Seed_Users.user,
134
+ ): unit =>
135
+ switch roleSwitch {
136
+ | None => ()
137
+ | Some(roleSwitch) =>
138
+ let narrowedFrom = Seed_Client.narrowedFrom(client)
139
+ let chosen = switch roleFromEnv() {
140
+ | Some(choice) => Some(choice)
141
+ | None =>
142
+ switch narrowedFrom {
143
+ | Some(available) if available->Array.length > 1 && Seed_Prompt.hasTty() =>
144
+ Console.log("")
145
+ Console.log(
146
+ "This token is narrowed to one role. Seeding needs every right the account has;",
147
+ )
148
+ Console.log("clearing also widens your host-shell session, which shares the stored choice.")
149
+ let options = Array.concat(
150
+ [(`full membership (${available->Array.join(", ")})`, Full)],
151
+ available->Array.map(r => (r, Narrowed(r))),
152
+ )
153
+ Some(await Seed_Prompt.select(~title="Act as:", ~options, ~defaultIndex=0))
154
+ | _ => None
155
+ }
156
+ }
157
+ switch chosen {
158
+ | None => ()
159
+ | Some(Full) => await applyRole(~client, ~roleSwitch, ~login, ~caller, ~role=None)
160
+ | Some(Narrowed(role)) =>
161
+ await applyRole(~client, ~roleSwitch, ~login, ~caller, ~role=Some(role))
162
+ }
163
+ }
164
+
22
165
  /**
23
166
  * Prompts for credentials, calls `login` to mint a bearer, and returns a
24
167
  * connection with that bearer already applied. `login` returns the bearer so a
25
168
  * single `useToken` path serves every provider.
169
+ *
170
+ * `roleSwitch` is the platform's active-role door, when it has one; supplying it
171
+ * is what lets a narrowed token be widened here instead of in a browser.
26
172
  */
27
173
  let make = async (
28
174
  ~label: string,
29
175
  ~endpoint: string,
30
176
  ~login: (~username: string, ~password: string) => promise<string>,
177
+ ~roleSwitch: option<roleSwitch>=?,
31
178
  ~localDefaults: bool=false,
32
179
  ): connection => {
33
180
  // `SEED_SKIP_UPLOADS` forces the upload phase to no-op — seed domain data fast, or
34
181
  // skip a broken/absent upload path without editing the data set. The data set reads
35
182
  // `connection.uploadsSkipped` and reports the skip.
36
183
  let uploadsSkipped = Seed_Upload.uploadsSkipped()
37
- let (username, password) = await Seed_Prompt.credentials(~localDefaults)
38
- let token = await login(~username, ~password)
184
+ let {caller, accounts} = await Seed_Prompt.credentials(~localDefaults)
185
+ let token = await login(~username=caller.username, ~password=caller.password)
39
186
  let client = Seed_Client.make(~config={endpoint: endpoint})
40
187
  client->Seed_Client.useToken(token)
41
188
  // What the bearer grants, which is not always what the account list showed: a
42
189
  // token narrowed to one role presents that role alone. Said at login so the
43
190
  // run starts from the identity it will actually be refused or served under.
44
- switch Seed_Client.identitySummary(client) {
45
- | Some(summary) => Console.log(`Acting as: ${summary}`)
46
- | None => ()
191
+ announceIdentity(client)
192
+ // Offered before anything is probed or written, so a narrowing is settled
193
+ // while the run can still be redirected rather than after a refusal.
194
+ await resolveRole(~client, ~roleSwitch, ~login, ~caller)
195
+ {
196
+ client,
197
+ uploadsSkipped,
198
+ label,
199
+ accounts,
200
+ caller,
201
+ callerId: Seed_Client.callerId(client),
202
+ login,
203
+ roleSwitch,
47
204
  }
48
- {client, uploadsSkipped, label}
205
+ }
206
+
207
+ /**
208
+ * A second authenticated client, for one of the accounts the platform's accounts
209
+ * file declares. Same endpoint, same login route, a different bearer.
210
+ *
211
+ * It exists so a data set can read back what it seeded as the account the rows
212
+ * belong to. The seeding client cannot answer that question: it is elevated, so
213
+ * an owner-scoped view counts every owner's rows for it and reads non-empty
214
+ * whether or not a single row is reachable by the person it was seeded for.
215
+ */
216
+ let clientFor = async (c: connection, ~account: Seed_Users.user): Seed_Client.t => {
217
+ let token = await c.login(~username=account.username, ~password=account.password)
218
+ let client = Seed_Client.make(~config={endpoint: Seed_Client.endpoint(c.client)})
219
+ client->Seed_Client.useToken(token)
220
+ client
49
221
  }
50
222
 
51
223
  /**
@@ -53,19 +225,77 @@ let make = async (
53
225
  * `/__inmemory/login` shape: POST `{username, password}` → `{token}`). Reuses
54
226
  * `Seed_Client.login` but hands the token back for `useToken`.
55
227
  */
56
- let viaLoginEndpoint = (~loginEndpoint: string) => async (
57
- ~username: string,
58
- ~password: string,
59
- ): string => {
60
- let client = Seed_Client.make(
61
- ~config={endpoint: loginEndpoint, loginEndpoint, username, password},
62
- )
63
- await Seed_Client.login(client)
64
- switch Seed_Client.currentToken(client) {
65
- | Some(token) => token
66
- | None => throw(Failed(`login at ${loginEndpoint} returned no token`))
228
+ let viaLoginEndpoint = (~loginEndpoint: string) =>
229
+ async (~username: string, ~password: string): string => {
230
+ let client = Seed_Client.make(
231
+ ~config={endpoint: loginEndpoint, loginEndpoint, username, password},
232
+ )
233
+ await Seed_Client.login(client)
234
+ switch Seed_Client.currentToken(client) {
235
+ | Some(token) => token
236
+ | None => throw(Failed(`login at ${loginEndpoint} returned no token`))
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The local dev platform's active-role door: POST `/__inmemory/switch-role` with
242
+ * `{activeRole}` (null to clear) and the current bearer, which answers with a
243
+ * freshly minted one.
244
+ *
245
+ * The local server re-issues on the spot rather than storing a choice, so this
246
+ * returns the new token and no re-authentication follows. Its narrowing is also
247
+ * per-session, which is why a local seed never arrives narrowed on its own —
248
+ * this exists for a *deliberate* `SEED_ROLE` run, which is where a restricted
249
+ * caller's refusals can be exercised without a deployed stack.
250
+ */
251
+ let viaSwitchRoleEndpoint = (~switchRoleEndpoint: string): roleSwitch =>
252
+ async (~client: Seed_Client.t, ~role: option<string>) => {
253
+ let token = switch Seed_Client.currentToken(client) {
254
+ | Some(t) => t
255
+ | None => throw(Failed("switch-role: no bearer to present — log in first"))
256
+ }
257
+ // Clearing OMITS the key rather than sending `null`. The local handler reads
258
+ // its body through `Obj.magic` into an `option<string>`, and ReScript spells
259
+ // absence `undefined` — so a JSON `null` arrives as a present value and the
260
+ // server tries to act as a role literally named "null", refusing with
261
+ // `Cannot act as "null": not a group this user holds`. An absent key is the
262
+ // only thing it reads as "no role", which is what widens back to full.
263
+ let body = JSON.stringify(
264
+ JSON.Encode.object(
265
+ switch role {
266
+ | Some(r) => Dict.fromArray([("activeRole", JSON.Encode.string(r))])
267
+ | None => Dict.make()
268
+ },
269
+ ),
270
+ )
271
+ let res = try await Seed_Client.fetch(
272
+ switchRoleEndpoint,
273
+ {
274
+ method: "POST",
275
+ headers: Dict.fromArray([
276
+ ("content-type", "application/json"),
277
+ ("authorization", `Bearer ${token}`),
278
+ ]),
279
+ body,
280
+ },
281
+ ) catch {
282
+ | _ => throw(Failed(`switch-role: cannot reach ${switchRoleEndpoint}`))
283
+ }
284
+ let json = await res->Seed_Client.responseJson
285
+ if !(res->Seed_Client.responseOk) {
286
+ throw(
287
+ Failed(
288
+ `switch-role at ${switchRoleEndpoint} failed with HTTP ${res
289
+ ->Seed_Client.responseStatus
290
+ ->Int.toString}: ${JSON.stringify(json)}`,
291
+ ),
292
+ )
293
+ }
294
+ switch json->Seed_Client.field("token")->Option.flatMap(Seed_Client.asString) {
295
+ | Some(fresh) => Some(fresh)
296
+ | None => throw(Failed(`switch-role at ${switchRoleEndpoint} returned no token`))
297
+ }
67
298
  }
68
- }
69
299
 
70
300
  let envOr = (key: string, fallback: string): string =>
71
301
  Seed_Prompt.envValue(key)->Option.getOr(fallback)
@@ -82,12 +312,21 @@ let local = (~graphql=?, ~login=?, ()): (unit => promise<connection>) => {
82
312
  let endpoint =
83
313
  graphql->Option.getOr(envOr("REVENTLESS_GRAPHQL_ENDPOINT", "http://localhost:4000/graphql"))
84
314
  let loginEndpoint =
85
- login->Option.getOr(envOr("REVENTLESS_LOGIN_ENDPOINT", "http://localhost:4000/__inmemory/login"))
315
+ login->Option.getOr(
316
+ envOr("REVENTLESS_LOGIN_ENDPOINT", "http://localhost:4000/__inmemory/login"),
317
+ )
318
+ // The switch-role route sits beside the login one on the same server, so it is
319
+ // derived rather than configured — a second env var could name a different
320
+ // host from the login it is supposed to re-issue, which is the mismatch
321
+ // `LocalSeedTarget.loginFor` exists to prevent one level up.
322
+ let switchRoleEndpoint =
323
+ loginEndpoint->String.replace("/__inmemory/login", "/__inmemory/switch-role")
86
324
  () =>
87
325
  make(
88
326
  ~label="local",
89
327
  ~endpoint,
90
328
  ~login=viaLoginEndpoint(~loginEndpoint),
329
+ ~roleSwitch=viaSwitchRoleEndpoint(~switchRoleEndpoint),
91
330
  ~localDefaults=true,
92
331
  )
93
332
  }
@@ -6,26 +6,109 @@ import * as Seed_Client$ReventlessSeed from "./Seed_Client.res.mjs";
6
6
  import * as Seed_Prompt$ReventlessSeed from "./Seed_Prompt.res.mjs";
7
7
  import * as Seed_Upload$ReventlessSeed from "./Seed_Upload.res.mjs";
8
8
 
9
- async function make(label, endpoint, login, localDefaultsOpt) {
9
+ function roleFromEnv() {
10
+ return Stdlib_Option.map(Seed_Prompt$ReventlessSeed.envValue("SEED_ROLE"), v => {
11
+ let match = v.toLowerCase();
12
+ switch (match) {
13
+ case "all" :
14
+ case "clear" :
15
+ case "full" :
16
+ case "none" :
17
+ return "Full";
18
+ default:
19
+ return {
20
+ TAG: "Narrowed",
21
+ _0: v
22
+ };
23
+ }
24
+ });
25
+ }
26
+
27
+ function announceIdentity(client, prefixOpt) {
28
+ let prefix = prefixOpt !== undefined ? prefixOpt : "Acting as";
29
+ let summary = Seed_Client$ReventlessSeed.identitySummary(client);
30
+ if (summary !== undefined) {
31
+ console.log(prefix + `: ` + summary);
32
+ return;
33
+ }
34
+ }
35
+
36
+ async function applyRole(client, roleSwitch, login, caller, role) {
37
+ let minted = await roleSwitch(client, role);
38
+ let token = minted !== undefined ? minted : await login(caller.username, caller.password);
39
+ Seed_Client$ReventlessSeed.useToken(client, token);
40
+ return announceIdentity(client, "Now acting as");
41
+ }
42
+
43
+ async function resolveRole(client, roleSwitch, login, caller) {
44
+ if (roleSwitch === undefined) {
45
+ return;
46
+ }
47
+ let narrowedFrom = Seed_Client$ReventlessSeed.narrowedFrom(client);
48
+ let choice = roleFromEnv();
49
+ let chosen;
50
+ if (choice !== undefined) {
51
+ chosen = choice;
52
+ } else if (narrowedFrom !== undefined && narrowedFrom.length > 1 && Seed_Prompt$ReventlessSeed.hasTty()) {
53
+ console.log("");
54
+ console.log("This token is narrowed to one role. Seeding needs every right the account has;");
55
+ console.log("clearing also widens your host-shell session, which shares the stored choice.");
56
+ let options = [[
57
+ `full membership (` + narrowedFrom.join(", ") + `)`,
58
+ "Full"
59
+ ]].concat(narrowedFrom.map(r => [
60
+ r,
61
+ {
62
+ TAG: "Narrowed",
63
+ _0: r
64
+ }
65
+ ]));
66
+ chosen = await Seed_Prompt$ReventlessSeed.select("Act as:", options, undefined, 0);
67
+ } else {
68
+ chosen = undefined;
69
+ }
70
+ if (chosen !== undefined) {
71
+ if (typeof chosen !== "object") {
72
+ return await applyRole(client, roleSwitch, login, caller, undefined);
73
+ } else {
74
+ return await applyRole(client, roleSwitch, login, caller, chosen._0);
75
+ }
76
+ }
77
+ }
78
+
79
+ async function make(label, endpoint, login, roleSwitch, localDefaultsOpt) {
10
80
  let localDefaults = localDefaultsOpt !== undefined ? localDefaultsOpt : false;
11
81
  let uploadsSkipped = Seed_Upload$ReventlessSeed.uploadsSkipped();
12
82
  let match = await Seed_Prompt$ReventlessSeed.credentials(localDefaults);
13
- let token = await login(match[0], match[1]);
83
+ let caller = match.caller;
84
+ let token = await login(caller.username, caller.password);
14
85
  let client = Seed_Client$ReventlessSeed.make({
15
86
  endpoint: endpoint
16
87
  });
17
88
  Seed_Client$ReventlessSeed.useToken(client, token);
18
- let summary = Seed_Client$ReventlessSeed.identitySummary(client);
19
- if (summary !== undefined) {
20
- console.log(`Acting as: ` + summary);
21
- }
89
+ announceIdentity(client, undefined);
90
+ await resolveRole(client, roleSwitch, login, caller);
22
91
  return {
23
92
  client: client,
24
93
  uploadsSkipped: uploadsSkipped,
25
- label: label
94
+ label: label,
95
+ accounts: match.accounts,
96
+ caller: caller,
97
+ callerId: Seed_Client$ReventlessSeed.callerId(client),
98
+ login: login,
99
+ roleSwitch: roleSwitch
26
100
  };
27
101
  }
28
102
 
103
+ async function clientFor(c, account) {
104
+ let token = await c.login(account.username, account.password);
105
+ let client = Seed_Client$ReventlessSeed.make({
106
+ endpoint: Seed_Client$ReventlessSeed.endpoint(c.client)
107
+ });
108
+ Seed_Client$ReventlessSeed.useToken(client, token);
109
+ return client;
110
+ }
111
+
29
112
  function viaLoginEndpoint(loginEndpoint) {
30
113
  return async (username, password) => {
31
114
  let client = Seed_Client$ReventlessSeed.make({
@@ -47,6 +130,66 @@ function viaLoginEndpoint(loginEndpoint) {
47
130
  };
48
131
  }
49
132
 
133
+ function viaSwitchRoleEndpoint(switchRoleEndpoint) {
134
+ return async (client, role) => {
135
+ let t = Seed_Client$ReventlessSeed.currentToken(client);
136
+ let token;
137
+ if (t !== undefined) {
138
+ token = t;
139
+ } else {
140
+ throw {
141
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
142
+ _1: "switch-role: no bearer to present — log in first",
143
+ Error: new Error()
144
+ };
145
+ }
146
+ let body = JSON.stringify(role !== undefined ? Object.fromEntries([[
147
+ "activeRole",
148
+ role
149
+ ]]) : ({}));
150
+ let res;
151
+ try {
152
+ res = await fetch(switchRoleEndpoint, {
153
+ method: "POST",
154
+ headers: Object.fromEntries([
155
+ [
156
+ "content-type",
157
+ "application/json"
158
+ ],
159
+ [
160
+ "authorization",
161
+ `Bearer ` + token
162
+ ]
163
+ ]),
164
+ body: body
165
+ });
166
+ } catch (exn) {
167
+ throw {
168
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
169
+ _1: `switch-role: cannot reach ` + switchRoleEndpoint,
170
+ Error: new Error()
171
+ };
172
+ }
173
+ let json = await res.json();
174
+ if (!res.ok) {
175
+ throw {
176
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
177
+ _1: `switch-role at ` + switchRoleEndpoint + ` failed with HTTP ` + res.status.toString() + `: ` + JSON.stringify(json),
178
+ Error: new Error()
179
+ };
180
+ }
181
+ let fresh = Stdlib_Option.flatMap(Seed_Client$ReventlessSeed.field(json, "token"), Seed_Client$ReventlessSeed.asString);
182
+ if (fresh !== undefined) {
183
+ return fresh;
184
+ }
185
+ throw {
186
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
187
+ _1: `switch-role at ` + switchRoleEndpoint + ` returned no token`,
188
+ Error: new Error()
189
+ };
190
+ };
191
+ }
192
+
50
193
  function envOr(key, fallback) {
51
194
  return Stdlib_Option.getOr(Seed_Prompt$ReventlessSeed.envValue(key), fallback);
52
195
  }
@@ -54,12 +197,19 @@ function envOr(key, fallback) {
54
197
  function local(graphql, login, param) {
55
198
  let endpoint = Stdlib_Option.getOr(graphql, Stdlib_Option.getOr(Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_GRAPHQL_ENDPOINT"), "http://localhost:4000/graphql"));
56
199
  let loginEndpoint = Stdlib_Option.getOr(login, Stdlib_Option.getOr(Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_LOGIN_ENDPOINT"), "http://localhost:4000/__inmemory/login"));
57
- return () => make("local", endpoint, viaLoginEndpoint(loginEndpoint), true);
200
+ let switchRoleEndpoint = loginEndpoint.replace("/__inmemory/login", "/__inmemory/switch-role");
201
+ return () => make("local", endpoint, viaLoginEndpoint(loginEndpoint), viaSwitchRoleEndpoint(switchRoleEndpoint), true);
58
202
  }
59
203
 
60
204
  export {
205
+ roleFromEnv,
206
+ announceIdentity,
207
+ applyRole,
208
+ resolveRole,
61
209
  make,
210
+ clientFor,
62
211
  viaLoginEndpoint,
212
+ viaSwitchRoleEndpoint,
63
213
  envOr,
64
214
  local,
65
215
  }
@@ -63,12 +63,16 @@ let envValue = (key: string): option<string> =>
63
63
  | _ => None
64
64
  }
65
65
 
66
+ /** Whether an interactive prompt is possible at all. Callers that can carry on
67
+ without an answer ask this first; a prompt nobody can answer is worse than a
68
+ default, because it turns a workable run into a hang or a throw. */
69
+ let hasTty = (): bool => NodeProcess.stdin->NodeProcess.isTTY->Option.getOr(false)
70
+
66
71
  let requireTty = (): unit =>
67
- if !(NodeProcess.stdin->NodeProcess.isTTY->Option.getOr(false)) {
72
+ if !hasTty() {
68
73
  throw(
69
74
  Failed(
70
- "no TTY for an interactive prompt — set the documented SEED_* / REVENTLESS_DEMO_* " ++
71
- "env vars to run non-interactively.",
75
+ "no TTY for an interactive prompt — set the documented SEED_* / REVENTLESS_DEMO_* " ++ "env vars to run non-interactively.",
72
76
  ),
73
77
  )
74
78
  }
@@ -125,12 +129,7 @@ let isAffirmative = (answer: string): bool =>
125
129
  * `defaultIndex` makes Enter a valid answer, selecting that 0-based option;
126
130
  * without one every reply must name a choice.
127
131
  */
128
- let select = async (
129
- ~title: string,
130
- ~options: array<(string, 'a)>,
131
- ~env=?,
132
- ~defaultIndex=?,
133
- ): 'a =>
132
+ let select = async (~title: string, ~options: array<(string, 'a)>, ~env=?, ~defaultIndex=?): 'a =>
134
133
  switch options {
135
134
  | [(_, only)] => only
136
135
  | _ =>
@@ -163,9 +162,7 @@ let select = async (
163
162
  | _ => ""
164
163
  }
165
164
  let rec pick = async (): 'a => {
166
- let answer = await ask(
167
- `\nSelect [1-${(options->Array.length)->Int.toString}]${hint}: `,
168
- )
165
+ let answer = await ask(`\nSelect [1-${options->Array.length->Int.toString}]${hint}: `)
169
166
  switch (answer, fallback) {
170
167
  | ("", Some((_, value))) => value
171
168
  | _ =>
@@ -183,11 +180,31 @@ let select = async (
183
180
  }
184
181
  }
185
182
 
183
+ /**
184
+ * The account a run authenticated as, alongside every account the platform's
185
+ * accounts file declares.
186
+ *
187
+ * `accounts` is `[]` whenever no file was read — the
188
+ * `REVENTLESS_DEMO_USER`/`REVENTLESS_DEMO_PASSWORD` path bypasses it entirely,
189
+ * and so does typing both halves — so a consumer must handle empty rather than
190
+ * assume the file was there. `caller` is always populated, but carries no
191
+ * `groups` and no `userId` on those paths: nothing read them off a file.
192
+ */
193
+ type resolved = {caller: Seed_Users.user, accounts: array<Seed_Users.user>}
194
+
195
+ let typedUser = (~username: string, ~password: string): Seed_Users.user => {
196
+ username,
197
+ password,
198
+ groups: [],
199
+ userId: None,
200
+ demoOwner: None,
201
+ }
202
+
186
203
  // Typing both halves is the fallback, not the first offer: it is what happens
187
204
  // when the platform keeps no accounts file. `envUser` is threaded in so a
188
205
  // `REVENTLESS_DEMO_USER` that named nobody in the file still skips the username
189
206
  // prompt rather than asking for a name that was already given.
190
- let askCredentials = async (~localDefaults: bool, ~envUser: option<string>): (string, string) => {
207
+ let askCredentials = async (~localDefaults: bool, ~envUser: option<string>): Seed_Users.user => {
191
208
  let username = switch envUser {
192
209
  | Some(u) => u
193
210
  | None =>
@@ -202,7 +219,7 @@ let askCredentials = async (~localDefaults: bool, ~envUser: option<string>): (st
202
219
  let entered = await askHidden(localDefaults ? "Password [admin]: " : "Password: ")
203
220
  entered == "" && localDefaults ? "admin" : entered
204
221
  }
205
- (username, password)
222
+ typedUser(~username, ~password)
206
223
  }
207
224
 
208
225
  // The accounts file the platform already keeps (`.reventless/users.yaml`, or
@@ -212,7 +229,12 @@ let askCredentials = async (~localDefaults: bool, ~envUser: option<string>): (st
212
229
  // A named `REVENTLESS_DEMO_USER` picks its entry directly; otherwise the file's
213
230
  // accounts are offered in the order it defines them, first as the default.
214
231
  // `None` means the file has nothing to offer and the caller should ask.
215
- let fromUsersFile = async (~envUser: option<string>): option<(string, string)> =>
232
+ //
233
+ // The whole list travels back with the chosen account, not just the credentials
234
+ // it supplied: the file is also the only place a demo owner's id can be looked
235
+ // up, and reading it once to pick a login and never again is what let a data set
236
+ // seed rows under ids nobody on the platform holds.
237
+ let fromUsersFile = async (~envUser: option<string>): option<resolved> =>
216
238
  switch Seed_Users.load(~path=?envValue("SEED_USERS_FILE")) {
217
239
  | None => None
218
240
  | Some((path, users)) =>
@@ -233,7 +255,7 @@ let fromUsersFile = async (~envUser: option<string>): option<(string, string)> =
233
255
  // operator checks when owner-scoped rows turn up under the wrong account.
234
256
  chosen->Option.map(u => {
235
257
  Console.log(`Logging in as ${u.username} (from ${path})`)
236
- (u.username, u.password)
258
+ {caller: u, accounts: users}
237
259
  })
238
260
  }
239
261
 
@@ -245,19 +267,22 @@ let fromUsersFile = async (~envUser: option<string>): option<(string, string)> =
245
267
  * `.reventless/users.yaml` supplies the accounts to choose from, and only a
246
268
  * platform without one falls back to typing both halves — where, with
247
269
  * `localDefaults`, empty input means `admin`/`admin`.
270
+ *
271
+ * Returns the resolved account rather than a pair, and the accounts file
272
+ * alongside it when there was one — see `resolved`.
248
273
  */
249
- let credentials = async (~localDefaults: bool=false): (string, string) => {
274
+ let credentials = async (~localDefaults: bool=false): resolved => {
250
275
  let envUser = envValue("REVENTLESS_DEMO_USER")
251
- let (username, password) = switch (envUser, envValue("REVENTLESS_DEMO_PASSWORD")) {
252
- | (Some(u), Some(p)) => (u, p)
276
+ let resolved = switch (envUser, envValue("REVENTLESS_DEMO_PASSWORD")) {
277
+ | (Some(username), Some(password)) => {caller: typedUser(~username, ~password), accounts: []}
253
278
  | _ =>
254
279
  switch await fromUsersFile(~envUser) {
255
- | Some(pair) => pair
256
- | None => await askCredentials(~localDefaults, ~envUser)
280
+ | Some(resolved) => resolved
281
+ | None => {caller: await askCredentials(~localDefaults, ~envUser), accounts: []}
257
282
  }
258
283
  }
259
- if username == "" || password == "" {
284
+ if resolved.caller.username == "" || resolved.caller.password == "" {
260
285
  throw(Failed("username and password are required."))
261
286
  }
262
- (username, password)
287
+ resolved
263
288
  }