@reventlessdev/reventless-seed 1.0.0-alpha.2 → 1.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/.sourcedirs.json +1 -0
- package/lib/bs/ReventlessSeed.cmi +0 -0
- package/lib/bs/ReventlessSeed.cmt +0 -0
- package/lib/bs/ReventlessSeed.mlmap +6 -0
- package/lib/bs/compiler-info.json +2 -2
- package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed.ast +0 -0
- package/lib/bs/src/Seed.res +12 -3
- package/lib/bs/src/Seed.res.mjs +3 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Client.ast +0 -0
- package/lib/bs/src/Seed_Client.res +382 -21
- package/lib/bs/src/Seed_Client.res.mjs +332 -24
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Connect.ast +0 -0
- package/lib/bs/src/Seed_Connect.res +281 -33
- package/lib/bs/src/Seed_Connect.res.mjs +162 -8
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Prompt.ast +0 -0
- package/lib/bs/src/Seed_Prompt.res +140 -41
- package/lib/bs/src/Seed_Prompt.res.mjs +88 -13
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Random.ast +0 -0
- package/lib/bs/src/Seed_Random.res +4 -2
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Runner.ast +0 -0
- package/lib/bs/src/Seed_Runner.res +45 -19
- package/lib/bs/src/Seed_Runner.res.mjs +19 -1
- package/lib/bs/src/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload.ast +0 -0
- package/lib/bs/src/Seed_Upload.res +43 -43
- package/lib/bs/src/Seed_Upload.res.mjs +65 -69
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Users.ast +0 -0
- package/lib/bs/src/Seed_Users.res +92 -0
- package/lib/bs/src/Seed_Users.res.mjs +91 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ClientTest.ast +0 -0
- package/lib/bs/tests/Seed_ClientTest.res +287 -0
- package/lib/bs/tests/Seed_ClientTest.res.mjs +268 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
- package/lib/bs/tests/Seed_ConnectTest.res +64 -0
- package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_PromptTest.ast +0 -0
- package/lib/bs/tests/Seed_PromptTest.res +55 -0
- package/lib/bs/tests/Seed_PromptTest.res.mjs +63 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_RandomTest.ast +0 -0
- package/lib/bs/tests/Seed_RandomTest.res +200 -0
- package/lib/bs/tests/Seed_RandomTest.res.mjs +208 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_UsersTest.ast +0 -0
- package/lib/bs/tests/Seed_UsersTest.res +129 -0
- package/lib/bs/tests/Seed_UsersTest.res.mjs +113 -0
- package/lib/ocaml/.compiler.log +2 -2
- package/lib/ocaml/ReventlessSeed.cmi +0 -0
- package/lib/ocaml/ReventlessSeed.cmt +0 -0
- package/lib/ocaml/ReventlessSeed.mlmap +6 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed.ast +0 -0
- package/lib/ocaml/Seed.res +12 -3
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client.ast +0 -0
- package/lib/ocaml/Seed_Client.res +382 -21
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ClientTest.ast +0 -0
- package/lib/ocaml/Seed_ClientTest.res +287 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Connect.ast +0 -0
- package/lib/ocaml/Seed_Connect.res +281 -33
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ConnectTest.ast +0 -0
- package/lib/ocaml/Seed_ConnectTest.res +64 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Prompt.ast +0 -0
- package/lib/ocaml/Seed_Prompt.res +140 -41
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_PromptTest.ast +0 -0
- package/lib/ocaml/Seed_PromptTest.res +55 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Random.ast +0 -0
- package/lib/ocaml/Seed_Random.res +4 -2
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_RandomTest.ast +0 -0
- package/lib/ocaml/Seed_RandomTest.res +200 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Runner.ast +0 -0
- package/lib/ocaml/Seed_Runner.res +45 -19
- package/lib/ocaml/Seed_Types-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload.ast +0 -0
- package/lib/ocaml/Seed_Upload.res +43 -43
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Users.ast +0 -0
- package/lib/ocaml/Seed_Users.res +92 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_UsersTest.ast +0 -0
- package/lib/ocaml/Seed_UsersTest.res +129 -0
- package/package.json +7 -2
- package/rescript.json +9 -1
- package/src/Seed.res +12 -3
- package/src/Seed.res.mjs +3 -0
- package/src/Seed_Client.res +382 -21
- package/src/Seed_Client.res.mjs +332 -24
- package/src/Seed_Connect.res +281 -33
- package/src/Seed_Connect.res.mjs +162 -8
- package/src/Seed_Prompt.res +140 -41
- package/src/Seed_Prompt.res.mjs +88 -13
- package/src/Seed_Random.res +4 -2
- package/src/Seed_Runner.res +45 -19
- package/src/Seed_Runner.res.mjs +19 -1
- package/src/Seed_Upload.res +43 -43
- package/src/Seed_Upload.res.mjs +65 -69
- package/src/Seed_Users.res +92 -0
- package/src/Seed_Users.res.mjs +91 -0
- package/tests/Seed_ClientTest.res +287 -0
- package/tests/Seed_ClientTest.res.mjs +268 -0
- package/tests/Seed_ConnectTest.res +64 -0
- package/tests/Seed_ConnectTest.res.mjs +56 -0
- package/tests/Seed_PromptTest.res +55 -0
- package/tests/Seed_PromptTest.res.mjs +63 -0
- package/tests/Seed_RandomTest.res +200 -0
- package/tests/Seed_RandomTest.res.mjs +208 -0
- package/tests/Seed_UsersTest.res +129 -0
- package/tests/Seed_UsersTest.res.mjs +113 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// A seed issues hundreds of commands against runtimes a preceding reset left
|
|
4
|
+
// cold, so the endpoint faulting on one of them is a normal event rather than an
|
|
5
|
+
// exceptional one. Before the retry, a single `InternalFailure` in that batch
|
|
6
|
+
// aborted the run and left a half-seeded store that could only be recovered by a
|
|
7
|
+
// full wipe and a re-run — for a request that would have succeeded on being
|
|
8
|
+
// asked again.
|
|
9
|
+
//
|
|
10
|
+
// What makes the retry safe is the classifier, so that is what these pin. A
|
|
11
|
+
// fault on the endpoint's side carries no information about the document and is
|
|
12
|
+
// worth resending; anything that is an *answer* — a validation error, an unknown
|
|
13
|
+
// field, a refused token — comes back identical however many times it is asked,
|
|
14
|
+
// and resending it only delays the failure the operator needs to see.
|
|
15
|
+
|
|
16
|
+
let errors = entries => JSON.Encode.array(entries->Array.map(JSON.Encode.object))
|
|
17
|
+
|
|
18
|
+
let errorOfType = errorType => Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
|
|
19
|
+
|
|
20
|
+
describe("Seed_Client.isTransient:", () => {
|
|
21
|
+
testSync("resends a fault on the endpoint's own side", () =>
|
|
22
|
+
[
|
|
23
|
+
"InternalFailure",
|
|
24
|
+
"ServiceUnavailable",
|
|
25
|
+
"Throttling",
|
|
26
|
+
"TooManyRequestsException",
|
|
27
|
+
]->Array.forEach(t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true))
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// The exact payload the seed aborted on.
|
|
31
|
+
testSync("resends the shape AppSync actually returned", () => {
|
|
32
|
+
let payload = JSON.parseOrThrow(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`)
|
|
33
|
+
expect(Seed_Client.isTransient(payload))->toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
testSync("fails fast on an error that is an answer", () =>
|
|
37
|
+
["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(
|
|
38
|
+
t => expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// A real error sitting alongside a transient one still means the request was
|
|
43
|
+
// wrong, and resending it cannot make it right.
|
|
44
|
+
testSync("refuses to resend a batch that mixes a real error in", () =>
|
|
45
|
+
expect(
|
|
46
|
+
Seed_Client.isTransient(
|
|
47
|
+
errors([errorOfType("InternalFailure"), errorOfType("ValidationException")]),
|
|
48
|
+
),
|
|
49
|
+
)->toBe(false)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// `Array.every` is vacuously true on an empty array, which would turn "the
|
|
53
|
+
// endpoint said errors but named none" into an endless resend.
|
|
54
|
+
testSync("treats an empty or untyped error list as final", () => {
|
|
55
|
+
expect(Seed_Client.isTransient(errors([])))->toBe(false)
|
|
56
|
+
expect(
|
|
57
|
+
Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])),
|
|
58
|
+
)->toBe(false)
|
|
59
|
+
expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// An account's listed groups are not what authorizes it — the token's are. Both
|
|
64
|
+
// platforms narrow a token to the single role the caller last chose, so a seed
|
|
65
|
+
// can log in as an account the user list shows as `[Admin, Shopper]` and present
|
|
66
|
+
// a token carrying `Shopper` alone. The refusal that follows names the field and
|
|
67
|
+
// not the caller, and the account list cannot explain it either. These pin the
|
|
68
|
+
// reading-back that turns that into a sentence.
|
|
69
|
+
|
|
70
|
+
let b64url = payload =>
|
|
71
|
+
payload
|
|
72
|
+
->NodeBuffer.fromStringUtf8
|
|
73
|
+
->NodeBuffer.toStringBase64Url
|
|
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
|
+
|
|
82
|
+
let jwt = claims =>
|
|
83
|
+
`${joseHeader}.${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
|
|
84
|
+
|
|
85
|
+
let localToken = claims =>
|
|
86
|
+
`${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
|
|
87
|
+
|
|
88
|
+
let strings = values => JSON.Encode.array(values->Array.map(JSON.Encode.string))
|
|
89
|
+
|
|
90
|
+
let clientWith = token => {
|
|
91
|
+
let c = Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})
|
|
92
|
+
c->Seed_Client.useToken(token)
|
|
93
|
+
c
|
|
94
|
+
}
|
|
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
|
+
|
|
123
|
+
describe("Seed_Client.effectiveGroups:", () => {
|
|
124
|
+
// A Cognito id token carries its payload in the second segment, a local dev
|
|
125
|
+
// token in the first. Neither platform should have to announce which it is.
|
|
126
|
+
testSync("reads the payload wherever the provider put it", () => {
|
|
127
|
+
expect(
|
|
128
|
+
clientWith(
|
|
129
|
+
jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
|
|
130
|
+
)->Seed_Client.effectiveGroups,
|
|
131
|
+
)->toEqual(Some(["Admin", "Shopper"]))
|
|
132
|
+
expect(
|
|
133
|
+
clientWith(localToken([("groups", strings(["Merchandiser"]))]))->Seed_Client.effectiveGroups,
|
|
134
|
+
)->toEqual(Some(["Merchandiser"]))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// An opaque bearer is not an error — it just means this can add nothing, and
|
|
138
|
+
// the failure reports what it always did.
|
|
139
|
+
testSync("says nothing about a token it cannot read", () => {
|
|
140
|
+
expect(clientWith("not-a-token")->Seed_Client.effectiveGroups)->toEqual(None)
|
|
141
|
+
expect(
|
|
142
|
+
Seed_Client.make(
|
|
143
|
+
~config={endpoint: "http://example.invalid/graphql"},
|
|
144
|
+
)->Seed_Client.effectiveGroups,
|
|
145
|
+
)->toEqual(None)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
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
|
+
|
|
200
|
+
describe("Seed_Client.identitySummary:", () => {
|
|
201
|
+
// The case that cost the debugging: eligible in the user list, refused by the
|
|
202
|
+
// token. Naming what it was narrowed FROM is what points at the role switch.
|
|
203
|
+
testSync("names the membership a narrowed token gave up", () =>
|
|
204
|
+
expect(
|
|
205
|
+
clientWith(
|
|
206
|
+
jwt([
|
|
207
|
+
("cognito:groups", strings(["Shopper"])),
|
|
208
|
+
("availableRoles", JSON.Encode.string("Admin,Shopper")),
|
|
209
|
+
]),
|
|
210
|
+
)->Seed_Client.identitySummary,
|
|
211
|
+
)->toEqual(Some("Shopper — narrowed to this role from Admin, Shopper"))
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
// An ordinary login is not narrowed and has nothing to explain.
|
|
215
|
+
testSync("stays quiet about an unnarrowed token", () =>
|
|
216
|
+
expect(
|
|
217
|
+
clientWith(
|
|
218
|
+
jwt([("cognito:groups", strings(["Admin", "Shopper"]))]),
|
|
219
|
+
)->Seed_Client.identitySummary,
|
|
220
|
+
)->toEqual(Some("Admin, Shopper"))
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// A token granting nothing reads as a sentence rather than an empty string.
|
|
224
|
+
testSync("says so when the token carries no groups at all", () =>
|
|
225
|
+
expect(
|
|
226
|
+
clientWith(jwt([("cognito:groups", strings([]))]))->Seed_Client.identitySummary,
|
|
227
|
+
)->toEqual(Some("no groups"))
|
|
228
|
+
)
|
|
229
|
+
})
|
|
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
|
+
|
|
270
|
+
describe("Seed_Client.isDenied:", () => {
|
|
271
|
+
// AppSync types it; the local server carries it in the message.
|
|
272
|
+
testSync("recognises a refusal in either platform's vocabulary", () => {
|
|
273
|
+
expect(Seed_Client.isDenied(errors([errorOfType("Unauthorized")])))->toBe(true)
|
|
274
|
+
expect(
|
|
275
|
+
Seed_Client.isDenied(
|
|
276
|
+
errors([Dict.fromArray([("message", JSON.Encode.string("Not Authorized to access X"))])]),
|
|
277
|
+
),
|
|
278
|
+
)->toBe(true)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// Attaching an identity to an unrelated failure would point the reader at the
|
|
282
|
+
// wrong thing.
|
|
283
|
+
testSync("leaves every other failure alone", () => {
|
|
284
|
+
expect(Seed_Client.isDenied(errors([errorOfType("ValidationException")])))->toBe(false)
|
|
285
|
+
expect(Seed_Client.isDenied(errors([])))->toBe(false)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,40 +1,223 @@
|
|
|
1
1
|
// Turns endpoints plus a login function into a ready `connection`.
|
|
2
2
|
//
|
|
3
3
|
// A connection is what a data set seeds against: an authenticated GraphQL
|
|
4
|
-
// client, the upload
|
|
5
|
-
// a human label for the target. `make` is provider-agnostic — it prompts for
|
|
4
|
+
// client, the deployment's upload endpoints (empty when it serves no uploads),
|
|
5
|
+
// and a human label for the target. `make` is provider-agnostic — it prompts for
|
|
6
6
|
// credentials, calls the supplied `login` to obtain a bearer, and wires it onto
|
|
7
7
|
// the client via `useToken`. Where the bearer comes from (a local `/login`
|
|
8
8
|
// round-trip, a Cognito id token) is the caller's concern.
|
|
9
9
|
|
|
10
10
|
open Seed_Types
|
|
11
11
|
|
|
12
|
-
|
|
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
|
+
|
|
51
|
+
// Under route B the client mints uploads through the domain API's `Upload_Presign`
|
|
52
|
+
// mutation (authenticated by the same bearer as commands), so a connection needs no
|
|
53
|
+
// upload endpoint — just the authenticated client and whether uploads are skipped this
|
|
54
|
+
// run (`SEED_SKIP_UPLOADS`). Which store an asset uploads into is the caller's `~store`.
|
|
55
|
+
type connection = {
|
|
56
|
+
client: Seed_Client.t,
|
|
57
|
+
uploadsSkipped: bool,
|
|
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>,
|
|
79
|
+
}
|
|
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
|
+
}
|
|
13
164
|
|
|
14
165
|
/**
|
|
15
166
|
* Prompts for credentials, calls `login` to mint a bearer, and returns a
|
|
16
167
|
* connection with that bearer already applied. `login` returns the bearer so a
|
|
17
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.
|
|
18
172
|
*/
|
|
19
173
|
let make = async (
|
|
20
174
|
~label: string,
|
|
21
175
|
~endpoint: string,
|
|
22
|
-
~uploadEndpoint: string,
|
|
23
176
|
~login: (~username: string, ~password: string) => promise<string>,
|
|
177
|
+
~roleSwitch: option<roleSwitch>=?,
|
|
24
178
|
~localDefaults: bool=false,
|
|
25
179
|
): connection => {
|
|
26
|
-
// `SEED_SKIP_UPLOADS` forces the
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
let uploadEndpoint = Seed_Prompt.envValue("SEED_SKIP_UPLOADS")->Option.isSome ? "" : uploadEndpoint
|
|
33
|
-
let (username, password) = await Seed_Prompt.credentials(~localDefaults)
|
|
34
|
-
let token = await login(~username, ~password)
|
|
180
|
+
// `SEED_SKIP_UPLOADS` forces the upload phase to no-op — seed domain data fast, or
|
|
181
|
+
// skip a broken/absent upload path without editing the data set. The data set reads
|
|
182
|
+
// `connection.uploadsSkipped` and reports the skip.
|
|
183
|
+
let uploadsSkipped = Seed_Upload.uploadsSkipped()
|
|
184
|
+
let {caller, accounts} = await Seed_Prompt.credentials(~localDefaults)
|
|
185
|
+
let token = await login(~username=caller.username, ~password=caller.password)
|
|
35
186
|
let client = Seed_Client.make(~config={endpoint: endpoint})
|
|
36
187
|
client->Seed_Client.useToken(token)
|
|
37
|
-
|
|
188
|
+
// What the bearer grants, which is not always what the account list showed: a
|
|
189
|
+
// token narrowed to one role presents that role alone. Said at login so the
|
|
190
|
+
// run starts from the identity it will actually be refused or served under.
|
|
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,
|
|
204
|
+
}
|
|
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
|
|
38
221
|
}
|
|
39
222
|
|
|
40
223
|
/**
|
|
@@ -42,19 +225,77 @@ let make = async (
|
|
|
42
225
|
* `/__inmemory/login` shape: POST `{username, password}` → `{token}`). Reuses
|
|
43
226
|
* `Seed_Client.login` but hands the token back for `useToken`.
|
|
44
227
|
*/
|
|
45
|
-
let viaLoginEndpoint = (~loginEndpoint: string) =>
|
|
46
|
-
~username: string,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
}
|
|
56
298
|
}
|
|
57
|
-
}
|
|
58
299
|
|
|
59
300
|
let envOr = (key: string, fallback: string): string =>
|
|
60
301
|
Seed_Prompt.envValue(key)->Option.getOr(fallback)
|
|
@@ -63,22 +304,29 @@ let envOr = (key: string, fallback: string): string =>
|
|
|
63
304
|
* A ready `connect` thunk for the local dev platform, with the localhost
|
|
64
305
|
* defaults baked in and overridable per endpoint (arg, then env var, then
|
|
65
306
|
* default). Empty input at the credential prompt falls back to `admin`/`admin`.
|
|
307
|
+
*
|
|
308
|
+
* No upload endpoint: uploads mint through the domain API's `Upload_Presign` mutation
|
|
309
|
+
* on the same `graphql` endpoint, with the store passed per asset.
|
|
66
310
|
*/
|
|
67
|
-
let local = (~graphql=?, ~
|
|
311
|
+
let local = (~graphql=?, ~login=?, ()): (unit => promise<connection>) => {
|
|
68
312
|
let endpoint =
|
|
69
313
|
graphql->Option.getOr(envOr("REVENTLESS_GRAPHQL_ENDPOINT", "http://localhost:4000/graphql"))
|
|
70
|
-
let uploadEndpoint =
|
|
71
|
-
upload->Option.getOr(
|
|
72
|
-
envOr("REVENTLESS_UPLOAD_ENDPOINT", "http://localhost:4000/__inmemory/upload"),
|
|
73
|
-
)
|
|
74
314
|
let loginEndpoint =
|
|
75
|
-
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")
|
|
76
324
|
() =>
|
|
77
325
|
make(
|
|
78
326
|
~label="local",
|
|
79
327
|
~endpoint,
|
|
80
|
-
~uploadEndpoint,
|
|
81
328
|
~login=viaLoginEndpoint(~loginEndpoint),
|
|
329
|
+
~roleSwitch=viaSwitchRoleEndpoint(~switchRoleEndpoint),
|
|
82
330
|
~localDefaults=true,
|
|
83
331
|
)
|
|
84
332
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|