@reventlessdev/reventless-spec 3.0.0-alpha.134 → 3.0.0-alpha.136

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 (36) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/package.json +4 -3
  3. package/run-check-lifecycle.mjs +2 -1
  4. package/run-prepare-accounts.mjs +3 -0
  5. package/src/components/CapabilityManifest.res +4 -0
  6. package/src/components/CapabilityManifest.res.mjs +13 -2
  7. package/src/components/DcbScopeInference.res +224 -73
  8. package/src/components/DcbScopeInference.res.mjs +187 -49
  9. package/src/components/DcbTag.res +142 -186
  10. package/src/components/DcbTag.res.mjs +137 -128
  11. package/src/components/DcbValidation.res +50 -0
  12. package/src/components/DcbValidation.res.mjs +51 -0
  13. package/src/generator/Codegen.res +2 -1
  14. package/src/generator/Codegen.res.mjs +1 -1
  15. package/src/generator/PlatformCodegen.res +1 -0
  16. package/src/generator/PlatformCodegen.res.mjs +5 -0
  17. package/src/lifecycle/CheckLifecycleModel.res +148 -8
  18. package/src/lifecycle/CheckLifecycleModel.res.mjs +154 -7
  19. package/src/semantic/Capabilities.res +17 -0
  20. package/src/semantic/Capabilities.res.mjs +9 -1
  21. package/src/semantic/CapabilityNeed.res +12 -0
  22. package/src/semantic/CapabilityNeed.res.mjs +9 -4
  23. package/src/semantic/IdentityProvider.res +127 -0
  24. package/src/semantic/IdentityProvider.res.mjs +65 -0
  25. package/src/semantic/Secrets.res +76 -0
  26. package/src/semantic/Secrets.res.mjs +49 -0
  27. package/src/types/AccountsManifest.res +287 -0
  28. package/src/types/AccountsManifest.res.mjs +312 -0
  29. package/src/types/AdminGroup.res +27 -0
  30. package/src/types/AdminGroup.res.mjs +9 -0
  31. package/src/types/OwnerScope.res +27 -0
  32. package/src/types/OwnerScope.res.mjs +12 -0
  33. package/src/types/PrepareAccounts.res +119 -0
  34. package/src/types/PrepareAccounts.res.mjs +148 -0
  35. package/src/util/Util_Password.res +60 -0
  36. package/src/util/Util_Password.res.mjs +53 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ Making, grouping and unmaking principals, so the provider behind them is a
3
+ supplier rather than a call site.
4
+
5
+ The *administrative* half of identity only. Authentication — token issuance, JWT
6
+ verification, session handling — is `Auth_Adapter.Provider`'s, and stays there:
7
+ the two are read at different times over one provider, exactly as this and
8
+ `CapabilityNeed.t` are.
9
+
10
+ 🚨 **Nothing here is a domain event's business.** `principal` is the provider's
11
+ own handle and must never reach an event log: a log full of one provider's ids
12
+ cannot be migrated to another, which would make the replaceability this
13
+ capability exists for a fiction. What the log holds is the domain's own opaque
14
+ user id; the mapping between the two lives in this capability's store.
15
+ */
16
+ /** The provider's own handle for a principal. Opaque on purpose — a caller that
17
+ can read structure out of it is a caller coupled to the provider. */
18
+ type principal = {providerId: string}
19
+
20
+ /** How a new principal proves it is them. A variant rather than a bare password
21
+ so a provider offering only federated or passwordless enrolment can refuse
22
+ the arm it does not implement instead of being handed a secret it will
23
+ discard. */
24
+ type credential =
25
+ | Password(string)
26
+ /** Enrol with no secret held here: the principal sets one through the
27
+ provider's own flow, or signs in federated. */
28
+ | NoCredential
29
+
30
+ /**
31
+ Why an operation did not happen.
32
+
33
+ Three arms rather than two, and the split is the one geocoding taught: **a
34
+ provider outage is not a verdict.** `Unavailable` must be retried, because a
35
+ caller reaching `createPrincipal` has already recorded that the address was
36
+ proven and the person is entitled to an account. `Refused` is permanent and must
37
+ surface. Collapsing them either loses accounts to a transient blip or retries
38
+ forever against a password policy.
39
+ */
40
+ type failure =
41
+ /** The contact is already a principal. A modelled answer, not an exception —
42
+ which is what a silent idempotent sign-up throws away. */
43
+ | Conflict
44
+ /** The provider is down or unreachable. The domain fact stands; retry. */
45
+ | Unavailable(string)
46
+ /** Permanently rejected — policy, password rules, a refused attribute. */
47
+ | Refused(string)
48
+
49
+ /**
50
+ One thing a provider can be asked to do.
51
+
52
+ Published rather than fixed, because providers genuinely differ: one bound
53
+ read-only to a corporate directory can create nothing, and one with no group
54
+ model cannot be asked about groups. A caller reads this and degrades, instead of
55
+ discovering the gap as a `Refused` on a live registration.
56
+
57
+ **`setActiveRole` is deliberately absent.** Narrowing a caller's claims to the
58
+ role they chose happens when a token is *minted* — on Cognito, in a
59
+ pre-token-generation trigger. That is token issuance, which this capability's
60
+ non-goal hands to the auth seam. Admitting it here would grow the surface along
61
+ an axis that has nothing to do with whether the principal store is replaceable,
62
+ which is the one thing this type exists to protect.
63
+ */
64
+ type operation =
65
+ | CreatePrincipal
66
+ | AddToGroup
67
+ | RemoveFromGroup
68
+ | DeletePrincipal
69
+
70
+ let operationToString = (operation: operation): string =>
71
+ switch operation {
72
+ | CreatePrincipal => "CreatePrincipal"
73
+ | AddToGroup => "AddToGroup"
74
+ | RemoveFromGroup => "RemoveFromGroup"
75
+ | DeletePrincipal => "DeletePrincipal"
76
+ }
77
+
78
+ /**
79
+ The port a caller reaches an identity provider through.
80
+
81
+ `operations` is the provisioned set this deployment's provider actually
82
+ supports, published rather than inferred. Build one through `make`, so a
83
+ provider cannot claim an operation and omit the function that performs it.
84
+ */
85
+ type t = {
86
+ createPrincipal: (
87
+ ~contact: Messaging.recipient,
88
+ ~credential: credential,
89
+ ~groups: array<string>,
90
+ ) => promise<result<principal, failure>>,
91
+ addToGroup: (~principal: principal, ~group: string) => promise<result<unit, failure>>,
92
+ removeFromGroup: (~principal: principal, ~group: string) => promise<result<unit, failure>>,
93
+ deletePrincipal: (~principal: principal) => promise<result<unit, failure>>,
94
+ operations: array<operation>,
95
+ }
96
+
97
+ let make = (~createPrincipal, ~addToGroup, ~removeFromGroup, ~deletePrincipal, ~operations): t => {
98
+ createPrincipal,
99
+ addToGroup,
100
+ removeFromGroup,
101
+ deletePrincipal,
102
+ operations,
103
+ }
104
+
105
+ let supports = (provider: t, ~operation: operation): bool =>
106
+ provider.operations->Array.includes(operation)
107
+
108
+ /**
109
+ A provider that performs nothing, answering `Unavailable` on every operation
110
+ with an empty `operations`.
111
+
112
+ The two say different true things and both are needed. The empty list is what a
113
+ caller reads before offering a flow that cannot complete. The refusal stays
114
+ `Unavailable` rather than `Refused` because a caller that got this far is looking
115
+ at a deployment gap, not at a verdict on the person — and `Refused` would strand
116
+ someone who has already proven their address.
117
+ */
118
+ let unavailable = (~reason: string): t =>
119
+ make(
120
+ ~createPrincipal=async (~contact as _, ~credential as _, ~groups as _) => Error(
121
+ Unavailable(reason),
122
+ ),
123
+ ~addToGroup=async (~principal as _, ~group as _) => Error(Unavailable(reason)),
124
+ ~removeFromGroup=async (~principal as _, ~group as _) => Error(Unavailable(reason)),
125
+ ~deletePrincipal=async (~principal as _) => Error(Unavailable(reason)),
126
+ ~operations=[],
127
+ )
@@ -0,0 +1,65 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function operationToString(operation) {
5
+ switch (operation) {
6
+ case "CreatePrincipal" :
7
+ return "CreatePrincipal";
8
+ case "AddToGroup" :
9
+ return "AddToGroup";
10
+ case "RemoveFromGroup" :
11
+ return "RemoveFromGroup";
12
+ case "DeletePrincipal" :
13
+ return "DeletePrincipal";
14
+ }
15
+ }
16
+
17
+ function make(createPrincipal, addToGroup, removeFromGroup, deletePrincipal, operations) {
18
+ return {
19
+ createPrincipal: createPrincipal,
20
+ addToGroup: addToGroup,
21
+ removeFromGroup: removeFromGroup,
22
+ deletePrincipal: deletePrincipal,
23
+ operations: operations
24
+ };
25
+ }
26
+
27
+ function supports(provider, operation) {
28
+ return provider.operations.includes(operation);
29
+ }
30
+
31
+ function unavailable(reason) {
32
+ return make(async (param, param$1, param$2) => ({
33
+ TAG: "Error",
34
+ _0: {
35
+ TAG: "Unavailable",
36
+ _0: reason
37
+ }
38
+ }), async (param, param$1) => ({
39
+ TAG: "Error",
40
+ _0: {
41
+ TAG: "Unavailable",
42
+ _0: reason
43
+ }
44
+ }), async (param, param$1) => ({
45
+ TAG: "Error",
46
+ _0: {
47
+ TAG: "Unavailable",
48
+ _0: reason
49
+ }
50
+ }), async param => ({
51
+ TAG: "Error",
52
+ _0: {
53
+ TAG: "Unavailable",
54
+ _0: reason
55
+ }
56
+ }), []);
57
+ }
58
+
59
+ export {
60
+ operationToString,
61
+ make,
62
+ supports,
63
+ unavailable,
64
+ }
65
+ /* No side effect */
@@ -0,0 +1,76 @@
1
+ /**
2
+ Unguessable material, and a one-way function over it.
3
+
4
+ Injected rather than imported, and that is the whole point. A runtime global
5
+ would work in production and leave every consumer's *issuance* untestable: a
6
+ suite cannot assert what a flow does with a secret it cannot predict. Handing
7
+ the generator in is what lets a test pin it, exactly as an injected geocoder
8
+ lets a test pin a coordinate.
9
+
10
+ Not a portability seam like the others — every target runtime has a
11
+ cryptographic source, so this is not a provisioning choice anyone makes. It is
12
+ here because the domain must not reach for one directly, and because a test must
13
+ be able to replace it.
14
+ */
15
+ /** What a secret is drawn from. Named rather than a charset string so a caller
16
+ cannot ask for an alphabet nothing checks — and so the two real cases stay
17
+ distinguishable: one is followed as a link, the other is retyped by a human
18
+ off a screen. */
19
+ type alphabet =
20
+ /** `0`–`9`. For a code someone reads and types back. */
21
+ | Digits
22
+ /** `A`–`Z`, `a`–`z`, `0`–`9`, `-`, `_`. Survives a URL untouched. */
23
+ | UrlSafe
24
+
25
+ /** The one way this fails: nothing here is a verdict about the caller. */
26
+ type failure = Unavailable(string)
27
+
28
+ /**
29
+ The port a caller reaches a cryptographic source through.
30
+
31
+ Both members answer `result` rather than a bare string, so a platform that
32
+ provisioned nothing refuses instead of returning something weak. A token is the
33
+ one value where a silent fallback is worse than an error: it would be accepted
34
+ everywhere and guessable by anyone.
35
+ */
36
+ type t = {
37
+ /** `length` characters drawn uniformly from `alphabet`. */
38
+ randomToken: (~length: int, ~alphabet: alphabet) => result<string, failure>,
39
+ /** One-way, so a challenge can be stored without storing what would answer
40
+ it. Deterministic: the same input always gives the same digest, which is
41
+ what lets a presented secret be compared against a held one. */
42
+ hash: string => result<string, failure>,
43
+ }
44
+
45
+ /** The characters each alphabet draws from. Exposed so an implementation and a
46
+ test agree on the set rather than each spelling it. */
47
+ let characters = (alphabet: alphabet): string =>
48
+ switch alphabet {
49
+ | Digits => "0123456789"
50
+ | UrlSafe => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
51
+ }
52
+
53
+ /**
54
+ A source that produces nothing, refusing both operations.
55
+
56
+ Named so a platform passing it is making a statement rather than filling in a
57
+ blank — and refusing rather than degrading, because there is no weaker token
58
+ that would be safe to hand back.
59
+ */
60
+ let unavailable = (~reason: string): t => {
61
+ randomToken: (~length as _, ~alphabet as _) => Error(Unavailable(reason)),
62
+ hash: _ => Error(Unavailable(reason)),
63
+ }
64
+
65
+ /**
66
+ A source that returns what it was given, for a test that needs to know the
67
+ secret it is about to present.
68
+
69
+ Here rather than in each suite because a consumer testing issuance needs exactly
70
+ this and should not be inventing it — and because a fixed generator written per
71
+ suite is one that eventually differs from the real one's alphabet.
72
+ */
73
+ let fixed = (~token: string, ~hash as hashOf: string => string): t => {
74
+ randomToken: (~length as _, ~alphabet as _) => Ok(token),
75
+ hash: input => Ok(hashOf(input)),
76
+ }
@@ -0,0 +1,49 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function characters(alphabet) {
5
+ if (alphabet === "Digits") {
6
+ return "0123456789";
7
+ } else {
8
+ return "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
9
+ }
10
+ }
11
+
12
+ function unavailable(reason) {
13
+ return {
14
+ randomToken: (param, param$1) => ({
15
+ TAG: "Error",
16
+ _0: {
17
+ TAG: "Unavailable",
18
+ _0: reason
19
+ }
20
+ }),
21
+ hash: param => ({
22
+ TAG: "Error",
23
+ _0: {
24
+ TAG: "Unavailable",
25
+ _0: reason
26
+ }
27
+ })
28
+ };
29
+ }
30
+
31
+ function fixed(token, hashOf) {
32
+ return {
33
+ randomToken: (param, param$1) => ({
34
+ TAG: "Ok",
35
+ _0: token
36
+ }),
37
+ hash: input => ({
38
+ TAG: "Ok",
39
+ _0: hashOf(input)
40
+ })
41
+ };
42
+ }
43
+
44
+ export {
45
+ characters,
46
+ unavailable,
47
+ fixed,
48
+ }
49
+ /* No side effect */
@@ -0,0 +1,287 @@
1
+ /***
2
+ The accounts a deployment is operated as, as a file: `.reventless/users.yaml`.
3
+
4
+ Two platforms read it and they must not disagree about its shape. Locally it *is*
5
+ the identity store — the in-memory auth adapter loads it at startup. On AWS it is
6
+ the record of what was created in a Cognito pool, and `provision-accounts` both
7
+ reads it and writes back into it.
8
+
9
+ Here rather than in either platform package because a manifest that works on one
10
+ platform and silently fails on the other is the whole cost being avoided. It sits
11
+ beside [Identity] and [AdminGroup] for the same reason those do: the vocabulary
12
+ the two platforms share about who is signed in.
13
+
14
+ 🚨 **The password field is a bootstrap credential, not a stored secret.** The file
15
+ is gitignored on every platform, and on AWS the passwords in it are generated per
16
+ deployment rather than committed — see `users.example.yaml` beside each platform.
17
+ */
18
+
19
+ /**
20
+ One account. `groups` is required (`[]` for an unprivileged account).
21
+
22
+ `password` is required but may be empty: an AWS manifest is written before the
23
+ accounts exist, and `provision-accounts` fills the empty ones in. An empty
24
+ password authenticates nowhere, which is the correct reading of "not yet
25
+ provisioned" on both platforms.
26
+
27
+ `userId` is what the platform stamps on rows this account writes. Locally it
28
+ defaults to the username; on AWS it is the `sub` the pool minted, so it can only
29
+ be filled in after the account exists.
30
+
31
+ `demoOwner` names the person this account plays in a demo's data, for a seed to
32
+ find it by. It exists because the name a pool accepts is not always the demo's:
33
+ a pool that signs in on an email address takes `shopper@example.com`, not
34
+ `shopper`.
35
+ */
36
+ @schema
37
+ type entry = {
38
+ username: string,
39
+ password: string,
40
+ groups: array<string>,
41
+ userId?: string,
42
+ demoOwner?: string,
43
+ }
44
+
45
+ let entriesSchema = S.array(entrySchema)
46
+
47
+ @module("yaml") external parseYaml: string => JSON.t = "parse"
48
+
49
+ /** Parses a manifest document. Strict: a malformed entry refuses the whole file
50
+ rather than being skipped, because on both platforms a silently dropped account
51
+ reads as a login that stopped working for no stated reason. */
52
+ let parseString = (yamlText: string): result<array<entry>, string> =>
53
+ try {
54
+ Ok(S.parseOrThrow(parseYaml(yamlText), ~to=entriesSchema))
55
+ } catch {
56
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr("YAML parse error"))
57
+ | _ => Error("YAML parse error")
58
+ }
59
+
60
+ let parseFile = (path: string): result<array<entry>, string> =>
61
+ try {
62
+ parseString(NodeFs.readFileSync(path))
63
+ } catch {
64
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${path}`))
65
+ | _ => Error(`Cannot read ${path}`)
66
+ }
67
+
68
+ /** Where both platforms keep it: beside the package the process runs from. */
69
+ let defaultPath = (): string => NodePath.join([NodeProcess.cwd(), ".reventless", "users.yaml"])
70
+
71
+ // ── Writing back ─────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ A value provisioning learned and the file does not know yet, addressed by the
75
+ entry's position in the document.
76
+
77
+ `password` is written **only into an empty field** — see [applyFills]. `userId` is
78
+ written whenever it differs, because on AWS the pool mints it and the file is only
79
+ ever a copy: a stale one is not a documentation slip but rows keyed to an id
80
+ nobody holds.
81
+ */
82
+ type fill = {
83
+ index: int,
84
+ password: option<string>,
85
+ userId: option<string>,
86
+ }
87
+
88
+ /** What a fill actually did, so the caller can report it rather than guess. */
89
+ type filled = {
90
+ index: int,
91
+ passwordWritten: bool,
92
+ userIdWritten: bool,
93
+ }
94
+
95
+ type document
96
+
97
+ @module("yaml") external parseDocument: string => document = "parseDocument"
98
+ @send external getIn: (document, array<JSON.t>) => JSON.t = "getIn"
99
+ @send external setIn: (document, array<JSON.t>, JSON.t) => unit = "setIn"
100
+ @send external documentToString: document => string = "toString"
101
+
102
+ let path = (index: int, field: string): array<JSON.t> => [
103
+ JSON.Number(index->Int.toFloat),
104
+ JSON.String(field),
105
+ ]
106
+
107
+ let currentString = (doc: document, index: int, field: string): option<string> =>
108
+ switch doc->getIn(path(index, field)) {
109
+ | JSON.String(s) => Some(s)
110
+ | _ => None
111
+ }
112
+
113
+ /**
114
+ Applies fills to a manifest document, returning the document's new text.
115
+
116
+ 🚨 **Round-trips through `parseDocument` rather than re-serializing the parsed
117
+ entries**, so every comment survives. Both manifests carry more explanation than
118
+ data — which account is elevated and why, which pool these subs came from — and a
119
+ tool that silently deleted it would cost more than it saved.
120
+
121
+ 🚨 **A password already in the file is never replaced.** A second run is the
122
+ normal case (an operator adding one account to four), and the failure mode of the
123
+ obvious implementation is overwriting a credential somebody is signed in with.
124
+ Empty means empty or whitespace; anything else is somebody's choice.
125
+ */
126
+ let applyFills = (yamlText: string, fills: array<fill>): result<(string, array<filled>), string> =>
127
+ try {
128
+ let doc = parseDocument(yamlText)
129
+ let report = fills->Array.map(({index, password, userId}) => {
130
+ let passwordWritten = switch password {
131
+ | Some(generated)
132
+ if doc->currentString(index, "password")->Option.getOr("")->String.trim == "" =>
133
+ doc->setIn(path(index, "password"), JSON.String(generated))
134
+ true
135
+ | _ => false
136
+ }
137
+ let userIdWritten = switch userId {
138
+ | Some(minted) if doc->currentString(index, "userId") != Some(minted) =>
139
+ doc->setIn(path(index, "userId"), JSON.String(minted))
140
+ true
141
+ | _ => false
142
+ }
143
+ {index, passwordWritten, userIdWritten}
144
+ })
145
+ Ok((doc->documentToString, report))
146
+ } catch {
147
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr("Cannot rewrite the manifest"))
148
+ | _ => Error("Cannot rewrite the manifest")
149
+ }
150
+
151
+ /** [applyFills] against a file on disk. Reads, rewrites, writes — and writes
152
+ nothing at all when no fill applied, so a fully-provisioned manifest keeps its
153
+ mtime and a second run is visibly a no-op. */
154
+ let fillFile = (~path as file: string, ~fills: array<fill>): result<array<filled>, string> =>
155
+ switch try Ok(NodeFs.readFileSync(file)) catch {
156
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${file}`))
157
+ | _ => Error(`Cannot read ${file}`)
158
+ } {
159
+ | Error(_) as e => e
160
+ | Ok(text) =>
161
+ switch applyFills(text, fills) {
162
+ | Error(_) as e => e
163
+ | Ok((_, report)) if !(report->Array.some(r => r.passwordWritten || r.userIdWritten)) =>
164
+ Ok(report)
165
+ | Ok((rewritten, report)) =>
166
+ try {
167
+ NodeFs.writeFileSync(file, rewritten)
168
+ Ok(report)
169
+ } catch {
170
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot write ${file}`))
171
+ | _ => Error(`Cannot write ${file}`)
172
+ }
173
+ }
174
+ }
175
+
176
+ // ── Finding it ───────────────────────────────────────────────────────────────
177
+
178
+ /** The template each platform package keeps beside itself, committed, as the
179
+ declaration of its cast. Both examples spell it this way and so does the local
180
+ setup script, so it is a convention rather than a setting. */
181
+ let templateName = "users.example.yaml"
182
+
183
+ type located =
184
+ | Declared(string)
185
+ | SeededFrom(string, string)
186
+
187
+ let pathOf = (located: located): string =>
188
+ switch located {
189
+ | Declared(file) | SeededFrom(file, _) => file
190
+ }
191
+
192
+ /**
193
+ The manifest to work from, copying the template into place when there is none.
194
+
195
+ `.reventless/` is gitignored on every platform, so a fresh clone has no manifest
196
+ and the first thing anyone met was `cp users.example.yaml .reventless/users.yaml`
197
+ — ceremony of the same kind as passing a pool id the deployment already knows.
198
+ `scripts/setup.mjs` has done this for the local platform all along; this generalises
199
+ it to any platform package, which is why it lives beside the format rather than
200
+ inside either platform's tooling.
201
+
202
+ 🚨 **A named path is never seeded.** Passing one is a claim that it exists, and
203
+ copying a template over that claim would answer a typo by provisioning the wrong
204
+ cast. Only the default path is filled in.
205
+ */
206
+ let locate = (~given: option<string>=?, ()): result<located, string> =>
207
+ switch given {
208
+ | Some(file) =>
209
+ NodeFs.existsSync(file)
210
+ ? Ok(Declared(file))
211
+ : Error(`${file} does not exist — a named manifest is read, never created`)
212
+ | None =>
213
+ let file = defaultPath()
214
+ if NodeFs.existsSync(file) {
215
+ Ok(Declared(file))
216
+ } else {
217
+ let template = NodePath.join([NodeProcess.cwd(), templateName])
218
+ if !NodeFs.existsSync(template) {
219
+ Error(
220
+ `no ${file} and no ${templateName} here to start one from. Declare the accounts in .reventless/users.yaml — each entry a username, a password (empty to have one generated) and the groups it belongs to`,
221
+ )
222
+ } else {
223
+ try {
224
+ NodeFs.mkdirSync(NodePath.dirname(file), {recursive: true})
225
+ NodeFs.cpSync(template, file, {})
226
+ Ok(SeededFrom(file, template))
227
+ } catch {
228
+ | JsExn(err) =>
229
+ Error(
230
+ `could not copy ${templateName} into place: ${JsExn.message(err)->Option.getOr(
231
+ "unknown error",
232
+ )}`,
233
+ )
234
+ | _ => Error(`could not copy ${templateName} into place`)
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ // ── Preparing ────────────────────────────────────────────────────────────────
241
+
242
+ /** One account after preparation: what the file now declares, and whether this
243
+ run is what put the password there. */
244
+ type prepared = {
245
+ entry: entry,
246
+ passwordGenerated: bool,
247
+ }
248
+
249
+ /**
250
+ Validates a manifest and fills in what it leaves blank.
251
+
252
+ 🚨 **This is the whole of provisioning on the local platform, and the first half
253
+ of it on AWS.** Locally the manifest *is* the user store — the auth adapter
254
+ hydrates from it at startup — so once every account has a password there is
255
+ nothing left to create. On AWS the accounts still have to be made in a pool, and
256
+ that half is Cognito's, in `reventless/aws`.
257
+
258
+ Which is why it lives here and takes no client, no region and no credentials: a
259
+ developer with an empty password field can run it on a laptop that has never held
260
+ an AWS key.
261
+
262
+ The passwords it mints are written straight back, because a generated credential
263
+ that is only printed is one the seed client cannot read.
264
+ */
265
+ let prepare = (~path as file: string): result<array<prepared>, string> =>
266
+ switch parseFile(file) {
267
+ | Error(_) as e => e
268
+ | Ok(entries) =>
269
+ let minted =
270
+ entries->Array.map(entry =>
271
+ entry.password->String.trim == "" ? Some(Util_Password.generate()) : None
272
+ )
273
+ let fills = minted->Array.mapWithIndex((password, index) => {index, password, userId: None})
274
+ switch fillFile(~path=file, ~fills) {
275
+ | Error(_) as e => e
276
+ | Ok(report) =>
277
+ Ok(
278
+ entries->Array.mapWithIndex((entry, index) => {
279
+ let written = report->Array.get(index)->Option.mapOr(false, r => r.passwordWritten)
280
+ switch (written, minted->Array.getUnsafe(index)) {
281
+ | (true, Some(password)) => {entry: {...entry, password}, passwordGenerated: true}
282
+ | _ => {entry, passwordGenerated: false}
283
+ }
284
+ }),
285
+ )
286
+ }
287
+ }