@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.
- package/CHANGELOG.md +12 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/ReventlessSeed.cmi +0 -0
- package/lib/bs/ReventlessSeed.cmt +0 -0
- package/lib/bs/ReventlessSeed.mlmap +1 -0
- package/lib/bs/compiler-info.json +1 -1
- package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed.ast +0 -0
- package/lib/bs/src/Seed.res +8 -2
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Client.ast +0 -0
- package/lib/bs/src/Seed_Client.res +169 -21
- package/lib/bs/src/Seed_Client.res.mjs +142 -7
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Connect.ast +0 -0
- package/lib/bs/src/Seed_Connect.res +258 -19
- package/lib/bs/src/Seed_Connect.res.mjs +158 -8
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Prompt.ast +0 -0
- package/lib/bs/src/Seed_Prompt.res +47 -23
- package/lib/bs/src/Seed_Prompt.res.mjs +32 -22
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Random.ast +0 -0
- package/lib/bs/src/Seed_Random.res +4 -2
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Runner.ast +0 -0
- package/lib/bs/src/Seed_Runner.res +7 -12
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload.ast +0 -0
- package/lib/bs/src/Seed_Upload.res +4 -2
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Users.ast +0 -0
- package/lib/bs/src/Seed_Users.res +15 -3
- package/lib/bs/src/Seed_Users.res.mjs +2 -1
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ClientTest.ast +0 -0
- package/lib/bs/tests/Seed_ClientTest.res +146 -16
- package/lib/bs/tests/Seed_ClientTest.res.mjs +102 -1
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
- package/lib/bs/tests/Seed_ConnectTest.res +64 -0
- package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_PromptTest.ast +0 -0
- package/lib/bs/tests/Seed_PromptTest.res +8 -12
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_RandomTest.ast +0 -0
- package/lib/bs/tests/Seed_RandomTest.res +130 -90
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_UsersTest.ast +0 -0
- package/lib/bs/tests/Seed_UsersTest.res +39 -2
- package/lib/bs/tests/Seed_UsersTest.res.mjs +20 -2
- package/lib/ocaml/.compiler.log +2 -2
- package/lib/ocaml/ReventlessSeed.cmi +0 -0
- package/lib/ocaml/ReventlessSeed.cmt +0 -0
- package/lib/ocaml/ReventlessSeed.mlmap +1 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed.ast +0 -0
- package/lib/ocaml/Seed.res +8 -2
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client.ast +0 -0
- package/lib/ocaml/Seed_Client.res +169 -21
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ClientTest.ast +0 -0
- package/lib/ocaml/Seed_ClientTest.res +146 -16
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Connect.ast +0 -0
- package/lib/ocaml/Seed_Connect.res +258 -19
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ConnectTest.ast +0 -0
- package/lib/ocaml/Seed_ConnectTest.res +64 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Prompt.ast +0 -0
- package/lib/ocaml/Seed_Prompt.res +47 -23
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_PromptTest.ast +0 -0
- package/lib/ocaml/Seed_PromptTest.res +8 -12
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Random.ast +0 -0
- package/lib/ocaml/Seed_Random.res +4 -2
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_RandomTest.ast +0 -0
- package/lib/ocaml/Seed_RandomTest.res +130 -90
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Runner.ast +0 -0
- package/lib/ocaml/Seed_Runner.res +7 -12
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload.ast +0 -0
- package/lib/ocaml/Seed_Upload.res +4 -2
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Users.ast +0 -0
- package/lib/ocaml/Seed_Users.res +15 -3
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_UsersTest.ast +0 -0
- package/lib/ocaml/Seed_UsersTest.res +39 -2
- package/package.json +2 -2
- package/src/Seed.res +8 -2
- package/src/Seed_Client.res +169 -21
- package/src/Seed_Client.res.mjs +142 -7
- package/src/Seed_Connect.res +258 -19
- package/src/Seed_Connect.res.mjs +158 -8
- package/src/Seed_Prompt.res +47 -23
- package/src/Seed_Prompt.res.mjs +32 -22
- package/src/Seed_Random.res +4 -2
- package/src/Seed_Runner.res +7 -12
- package/src/Seed_Upload.res +4 -2
- package/src/Seed_Users.res +15 -3
- package/src/Seed_Users.res.mjs +2 -1
- package/tests/Seed_ClientTest.res +146 -16
- package/tests/Seed_ClientTest.res.mjs +102 -1
- package/tests/Seed_ConnectTest.res +64 -0
- package/tests/Seed_ConnectTest.res.mjs +56 -0
- package/tests/Seed_PromptTest.res +8 -12
- package/tests/Seed_RandomTest.res +130 -90
- package/tests/Seed_UsersTest.res +39 -2
- package/tests/Seed_UsersTest.res.mjs +20 -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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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) =>
|
|
57
|
-
~username: string,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
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
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
})
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 !(
|
|
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,30 @@ 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
|
+
}
|
|
201
|
+
|
|
186
202
|
// Typing both halves is the fallback, not the first offer: it is what happens
|
|
187
203
|
// when the platform keeps no accounts file. `envUser` is threaded in so a
|
|
188
204
|
// `REVENTLESS_DEMO_USER` that named nobody in the file still skips the username
|
|
189
205
|
// prompt rather than asking for a name that was already given.
|
|
190
|
-
let askCredentials = async (~localDefaults: bool, ~envUser: option<string>):
|
|
206
|
+
let askCredentials = async (~localDefaults: bool, ~envUser: option<string>): Seed_Users.user => {
|
|
191
207
|
let username = switch envUser {
|
|
192
208
|
| Some(u) => u
|
|
193
209
|
| None =>
|
|
@@ -202,7 +218,7 @@ let askCredentials = async (~localDefaults: bool, ~envUser: option<string>): (st
|
|
|
202
218
|
let entered = await askHidden(localDefaults ? "Password [admin]: " : "Password: ")
|
|
203
219
|
entered == "" && localDefaults ? "admin" : entered
|
|
204
220
|
}
|
|
205
|
-
(username, password)
|
|
221
|
+
typedUser(~username, ~password)
|
|
206
222
|
}
|
|
207
223
|
|
|
208
224
|
// The accounts file the platform already keeps (`.reventless/users.yaml`, or
|
|
@@ -212,7 +228,12 @@ let askCredentials = async (~localDefaults: bool, ~envUser: option<string>): (st
|
|
|
212
228
|
// A named `REVENTLESS_DEMO_USER` picks its entry directly; otherwise the file's
|
|
213
229
|
// accounts are offered in the order it defines them, first as the default.
|
|
214
230
|
// `None` means the file has nothing to offer and the caller should ask.
|
|
215
|
-
|
|
231
|
+
//
|
|
232
|
+
// The whole list travels back with the chosen account, not just the credentials
|
|
233
|
+
// it supplied: the file is also the only place a demo owner's id can be looked
|
|
234
|
+
// up, and reading it once to pick a login and never again is what let a data set
|
|
235
|
+
// seed rows under ids nobody on the platform holds.
|
|
236
|
+
let fromUsersFile = async (~envUser: option<string>): option<resolved> =>
|
|
216
237
|
switch Seed_Users.load(~path=?envValue("SEED_USERS_FILE")) {
|
|
217
238
|
| None => None
|
|
218
239
|
| Some((path, users)) =>
|
|
@@ -233,7 +254,7 @@ let fromUsersFile = async (~envUser: option<string>): option<(string, string)> =
|
|
|
233
254
|
// operator checks when owner-scoped rows turn up under the wrong account.
|
|
234
255
|
chosen->Option.map(u => {
|
|
235
256
|
Console.log(`Logging in as ${u.username} (from ${path})`)
|
|
236
|
-
|
|
257
|
+
{caller: u, accounts: users}
|
|
237
258
|
})
|
|
238
259
|
}
|
|
239
260
|
|
|
@@ -245,19 +266,22 @@ let fromUsersFile = async (~envUser: option<string>): option<(string, string)> =
|
|
|
245
266
|
* `.reventless/users.yaml` supplies the accounts to choose from, and only a
|
|
246
267
|
* platform without one falls back to typing both halves — where, with
|
|
247
268
|
* `localDefaults`, empty input means `admin`/`admin`.
|
|
269
|
+
*
|
|
270
|
+
* Returns the resolved account rather than a pair, and the accounts file
|
|
271
|
+
* alongside it when there was one — see `resolved`.
|
|
248
272
|
*/
|
|
249
|
-
let credentials = async (~localDefaults: bool=false):
|
|
273
|
+
let credentials = async (~localDefaults: bool=false): resolved => {
|
|
250
274
|
let envUser = envValue("REVENTLESS_DEMO_USER")
|
|
251
|
-
let
|
|
252
|
-
| (Some(
|
|
275
|
+
let resolved = switch (envUser, envValue("REVENTLESS_DEMO_PASSWORD")) {
|
|
276
|
+
| (Some(username), Some(password)) => {caller: typedUser(~username, ~password), accounts: []}
|
|
253
277
|
| _ =>
|
|
254
278
|
switch await fromUsersFile(~envUser) {
|
|
255
|
-
| Some(
|
|
256
|
-
| None => await askCredentials(~localDefaults, ~envUser)
|
|
279
|
+
| Some(resolved) => resolved
|
|
280
|
+
| None => {caller: await askCredentials(~localDefaults, ~envUser), accounts: []}
|
|
257
281
|
}
|
|
258
282
|
}
|
|
259
|
-
if username == "" || password == "" {
|
|
283
|
+
if resolved.caller.username == "" || resolved.caller.password == "" {
|
|
260
284
|
throw(Failed("username and password are required."))
|
|
261
285
|
}
|
|
262
|
-
|
|
286
|
+
resolved
|
|
263
287
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -15,22 +15,18 @@ open JestGlobals
|
|
|
15
15
|
|
|
16
16
|
describe("Seed_Prompt.isAffirmative:", () => {
|
|
17
17
|
testSync("takes the three spellings of yes", () => {
|
|
18
|
-
["y", "yes", "1"]->Array.forEach(v =>
|
|
19
|
-
expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(true)
|
|
20
|
-
)
|
|
18
|
+
["y", "yes", "1"]->Array.forEach(v => expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(true))
|
|
21
19
|
})
|
|
22
20
|
|
|
23
21
|
// A prompt that prints `[y/N]` invites a capital as readily as a lowercase
|
|
24
22
|
// one, and an env var set by hand is written however the hand felt.
|
|
25
23
|
testSync("ignores case, so Y and YES answer as y does", () => {
|
|
26
|
-
["Y", "Yes", "YES"]->Array.forEach(v =>
|
|
27
|
-
expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(true)
|
|
28
|
-
)
|
|
24
|
+
["Y", "Yes", "YES"]->Array.forEach(v => expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(true))
|
|
29
25
|
})
|
|
30
26
|
|
|
31
27
|
testSync("ignores surrounding space", () => {
|
|
32
|
-
[" y", "y ", " yes "]->Array.forEach(
|
|
33
|
-
expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(true)
|
|
28
|
+
[" y", "y ", " yes "]->Array.forEach(
|
|
29
|
+
v => expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(true),
|
|
34
30
|
)
|
|
35
31
|
})
|
|
36
32
|
|
|
@@ -38,14 +34,14 @@ describe("Seed_Prompt.isAffirmative:", () => {
|
|
|
38
34
|
// reads it as "no". None may be mistaken for "nothing was said", which is what
|
|
39
35
|
// sent the old code to a prompt it could not open.
|
|
40
36
|
testSync("declines every spelling of no, capital N included", () => {
|
|
41
|
-
["n", "N", "no", "No", "NO"]->Array.forEach(
|
|
42
|
-
expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(false)
|
|
37
|
+
["n", "N", "no", "No", "NO"]->Array.forEach(
|
|
38
|
+
v => expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(false),
|
|
43
39
|
)
|
|
44
40
|
})
|
|
45
41
|
|
|
46
42
|
testSync("declines anything it does not recognise", () => {
|
|
47
|
-
["", " ", "0", "true", "yep", "y.", "yesplease", "affirmative"]->Array.forEach(
|
|
48
|
-
expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(false)
|
|
43
|
+
["", " ", "0", "true", "yep", "y.", "yesplease", "affirmative"]->Array.forEach(
|
|
44
|
+
v => expect(Seed_Prompt.isAffirmative(v))->Expect.toBe(false),
|
|
49
45
|
)
|
|
50
46
|
})
|
|
51
47
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 =
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|