@reventlessdev/reventless-spec 3.0.0-alpha.133 → 3.0.0-alpha.135
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 +33 -0
- package/package.json +4 -3
- package/run-check-lifecycle.mjs +2 -1
- package/run-prepare-accounts.mjs +3 -0
- package/src/AnsiStyle.res +0 -1
- package/src/LogPrefix.res +4 -13
- package/src/components/AutomationSlice.res +1 -5
- package/src/components/CapabilityManifest.res +6 -3
- package/src/components/CapabilityManifest.res.mjs +13 -2
- package/src/components/DcbDecode.res +6 -2
- package/src/components/DcbScopeInference.res +16 -12
- package/src/components/DcbTag.res +74 -46
- package/src/components/DcbValidation.res +114 -89
- package/src/components/DisplayName.res +4 -2
- package/src/components/ExtensionPoint.res +0 -1
- package/src/components/FieldDefault.res +2 -4
- package/src/components/InboundTranslationSlice.res +0 -2
- package/src/components/OutboundTranslationSlice.res +10 -8
- package/src/components/Owner.res +4 -4
- package/src/components/Plugin.res +10 -4
- package/src/components/ReadModel.res +0 -1
- package/src/components/Reference.res +2 -8
- package/src/components/Sensitive.res +4 -4
- package/src/components/StateAnnotations.res +4 -2
- package/src/components/StateChangeSlice.res +0 -2
- package/src/components/StateViewSlice.res +0 -2
- package/src/components/TaggedUnion.res +1 -2
- package/src/components/Task.res +0 -1
- package/src/components/TraitCertificate.res +2 -2
- package/src/components/TraitManifest.res +1 -1
- package/src/generator/CertifyTrait.res +2 -4
- package/src/generator/Codegen.res +77 -68
- package/src/generator/Discovery.res +29 -12
- package/src/generator/GraftTrait.res +17 -9
- package/src/generator/Pairing.res +39 -13
- package/src/generator/PlatformCodegen.res +14 -11
- package/src/generator/PlatformCodegen.res.mjs +5 -0
- package/src/generator/PlatformManifests.res +1 -3
- package/src/generator/PluginGenerator.res +0 -1
- package/src/generator/TraitManifestCli.res +7 -7
- package/src/lifecycle/CheckLifecycleModel.res +206 -148
- package/src/lifecycle/CheckLifecycleModel.res.mjs +23 -5
- package/src/semantic/Bytes.res +0 -1
- package/src/semantic/CalendarDate.res +2 -2
- package/src/semantic/Capabilities.res +21 -6
- package/src/semantic/Capabilities.res.mjs +18 -12
- package/src/semantic/CapabilityNeed.res +16 -5
- package/src/semantic/CapabilityNeed.res.mjs +9 -4
- package/src/semantic/CaptionedImage.res +0 -1
- package/src/semantic/Color.res +0 -1
- package/src/semantic/Currency.res +189 -170
- package/src/semantic/DateRange.res +3 -4
- package/src/semantic/DateTime.res +5 -5
- package/src/semantic/Duration.res +2 -2
- package/src/semantic/Email.res +0 -1
- package/src/semantic/FileRef.res +2 -2
- package/src/semantic/GeoPoint.res +17 -21
- package/src/semantic/Geocoding.res +8 -9
- package/src/semantic/Geolocation.res +6 -9
- package/src/semantic/IdentityProvider.res +127 -0
- package/src/semantic/IdentityProvider.res.mjs +65 -0
- package/src/semantic/ImageRef.res +2 -2
- package/src/semantic/MemberRef.res +0 -1
- package/src/semantic/Messaging.res +141 -21
- package/src/semantic/Messaging.res.mjs +51 -8
- package/src/semantic/Money.res +23 -20
- package/src/semantic/Offload.res +6 -4
- package/src/semantic/Percent.res +3 -3
- package/src/semantic/Phone.res +0 -1
- package/src/semantic/RowImage.res +4 -3
- package/src/semantic/Secrets.res +76 -0
- package/src/semantic/Secrets.res.mjs +49 -0
- package/src/semantic/Semantic.res +8 -11
- package/src/semantic/StorageRef.res +17 -17
- package/src/semantic/Template.res +5 -7
- package/src/semantic/UploadableFile.res +0 -1
- package/src/semantic/UploadableImage.res +0 -1
- package/src/semantic/Url.res +3 -3
- package/src/types/AccountsManifest.res +281 -0
- package/src/types/AccountsManifest.res.mjs +311 -0
- package/src/types/AdminGroup.res +27 -0
- package/src/types/AdminGroup.res.mjs +9 -0
- package/src/types/Authorization.res +1 -2
- package/src/types/Handler.res +0 -1
- package/src/types/Identity.res +1 -3
- package/src/types/Lifecycle.res +7 -6
- package/src/types/Message.res +6 -4
- package/src/types/OwnerScope.res +28 -3
- package/src/types/OwnerScope.res.mjs +12 -0
- package/src/types/PrepareAccounts.res +119 -0
- package/src/types/PrepareAccounts.res.mjs +148 -0
- package/src/types/Projection.res +9 -2
- package/src/types/StoredEvent.res +2 -4
- package/src/types/Transition.res +8 -4
- package/src/util/Util_Password.res +60 -0
- package/src/util/Util_Password.res.mjs +53 -0
- package/src/util/Util_Sury.res +1 -4
|
@@ -0,0 +1,281 @@
|
|
|
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
|
+
@schema
|
|
32
|
+
type entry = {
|
|
33
|
+
username: string,
|
|
34
|
+
password: string,
|
|
35
|
+
groups: array<string>,
|
|
36
|
+
userId?: string,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let entriesSchema = S.array(entrySchema)
|
|
40
|
+
|
|
41
|
+
@module("yaml") external parseYaml: string => JSON.t = "parse"
|
|
42
|
+
|
|
43
|
+
/** Parses a manifest document. Strict: a malformed entry refuses the whole file
|
|
44
|
+
rather than being skipped, because on both platforms a silently dropped account
|
|
45
|
+
reads as a login that stopped working for no stated reason. */
|
|
46
|
+
let parseString = (yamlText: string): result<array<entry>, string> =>
|
|
47
|
+
try {
|
|
48
|
+
Ok(S.parseOrThrow(parseYaml(yamlText), ~to=entriesSchema))
|
|
49
|
+
} catch {
|
|
50
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr("YAML parse error"))
|
|
51
|
+
| _ => Error("YAML parse error")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let parseFile = (path: string): result<array<entry>, string> =>
|
|
55
|
+
try {
|
|
56
|
+
parseString(NodeFs.readFileSync(path))
|
|
57
|
+
} catch {
|
|
58
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${path}`))
|
|
59
|
+
| _ => Error(`Cannot read ${path}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where both platforms keep it: beside the package the process runs from. */
|
|
63
|
+
let defaultPath = (): string => NodePath.join([NodeProcess.cwd(), ".reventless", "users.yaml"])
|
|
64
|
+
|
|
65
|
+
// ── Writing back ─────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
A value provisioning learned and the file does not know yet, addressed by the
|
|
69
|
+
entry's position in the document.
|
|
70
|
+
|
|
71
|
+
`password` is written **only into an empty field** — see [applyFills]. `userId` is
|
|
72
|
+
written whenever it differs, because on AWS the pool mints it and the file is only
|
|
73
|
+
ever a copy: a stale one is not a documentation slip but rows keyed to an id
|
|
74
|
+
nobody holds.
|
|
75
|
+
*/
|
|
76
|
+
type fill = {
|
|
77
|
+
index: int,
|
|
78
|
+
password: option<string>,
|
|
79
|
+
userId: option<string>,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What a fill actually did, so the caller can report it rather than guess. */
|
|
83
|
+
type filled = {
|
|
84
|
+
index: int,
|
|
85
|
+
passwordWritten: bool,
|
|
86
|
+
userIdWritten: bool,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type document
|
|
90
|
+
|
|
91
|
+
@module("yaml") external parseDocument: string => document = "parseDocument"
|
|
92
|
+
@send external getIn: (document, array<JSON.t>) => JSON.t = "getIn"
|
|
93
|
+
@send external setIn: (document, array<JSON.t>, JSON.t) => unit = "setIn"
|
|
94
|
+
@send external documentToString: document => string = "toString"
|
|
95
|
+
|
|
96
|
+
let path = (index: int, field: string): array<JSON.t> => [
|
|
97
|
+
JSON.Number(index->Int.toFloat),
|
|
98
|
+
JSON.String(field),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
let currentString = (doc: document, index: int, field: string): option<string> =>
|
|
102
|
+
switch doc->getIn(path(index, field)) {
|
|
103
|
+
| JSON.String(s) => Some(s)
|
|
104
|
+
| _ => None
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
Applies fills to a manifest document, returning the document's new text.
|
|
109
|
+
|
|
110
|
+
🚨 **Round-trips through `parseDocument` rather than re-serializing the parsed
|
|
111
|
+
entries**, so every comment survives. Both manifests carry more explanation than
|
|
112
|
+
data — which account is elevated and why, which pool these subs came from — and a
|
|
113
|
+
tool that silently deleted it would cost more than it saved.
|
|
114
|
+
|
|
115
|
+
🚨 **A password already in the file is never replaced.** A second run is the
|
|
116
|
+
normal case (an operator adding one account to four), and the failure mode of the
|
|
117
|
+
obvious implementation is overwriting a credential somebody is signed in with.
|
|
118
|
+
Empty means empty or whitespace; anything else is somebody's choice.
|
|
119
|
+
*/
|
|
120
|
+
let applyFills = (yamlText: string, fills: array<fill>): result<(string, array<filled>), string> =>
|
|
121
|
+
try {
|
|
122
|
+
let doc = parseDocument(yamlText)
|
|
123
|
+
let report = fills->Array.map(({index, password, userId}) => {
|
|
124
|
+
let passwordWritten = switch password {
|
|
125
|
+
| Some(generated)
|
|
126
|
+
if doc->currentString(index, "password")->Option.getOr("")->String.trim == "" =>
|
|
127
|
+
doc->setIn(path(index, "password"), JSON.String(generated))
|
|
128
|
+
true
|
|
129
|
+
| _ => false
|
|
130
|
+
}
|
|
131
|
+
let userIdWritten = switch userId {
|
|
132
|
+
| Some(minted) if doc->currentString(index, "userId") != Some(minted) =>
|
|
133
|
+
doc->setIn(path(index, "userId"), JSON.String(minted))
|
|
134
|
+
true
|
|
135
|
+
| _ => false
|
|
136
|
+
}
|
|
137
|
+
{index, passwordWritten, userIdWritten}
|
|
138
|
+
})
|
|
139
|
+
Ok((doc->documentToString, report))
|
|
140
|
+
} catch {
|
|
141
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr("Cannot rewrite the manifest"))
|
|
142
|
+
| _ => Error("Cannot rewrite the manifest")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** [applyFills] against a file on disk. Reads, rewrites, writes — and writes
|
|
146
|
+
nothing at all when no fill applied, so a fully-provisioned manifest keeps its
|
|
147
|
+
mtime and a second run is visibly a no-op. */
|
|
148
|
+
let fillFile = (~path as file: string, ~fills: array<fill>): result<array<filled>, string> =>
|
|
149
|
+
switch try Ok(NodeFs.readFileSync(file)) catch {
|
|
150
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${file}`))
|
|
151
|
+
| _ => Error(`Cannot read ${file}`)
|
|
152
|
+
} {
|
|
153
|
+
| Error(_) as e => e
|
|
154
|
+
| Ok(text) =>
|
|
155
|
+
switch applyFills(text, fills) {
|
|
156
|
+
| Error(_) as e => e
|
|
157
|
+
| Ok((_, report)) if !(report->Array.some(r => r.passwordWritten || r.userIdWritten)) =>
|
|
158
|
+
Ok(report)
|
|
159
|
+
| Ok((rewritten, report)) =>
|
|
160
|
+
try {
|
|
161
|
+
NodeFs.writeFileSync(file, rewritten)
|
|
162
|
+
Ok(report)
|
|
163
|
+
} catch {
|
|
164
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot write ${file}`))
|
|
165
|
+
| _ => Error(`Cannot write ${file}`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Finding it ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** The template each platform package keeps beside itself, committed, as the
|
|
173
|
+
declaration of its cast. Both examples spell it this way and so does the local
|
|
174
|
+
setup script, so it is a convention rather than a setting. */
|
|
175
|
+
let templateName = "users.example.yaml"
|
|
176
|
+
|
|
177
|
+
type located =
|
|
178
|
+
| Declared(string)
|
|
179
|
+
| SeededFrom(string, string)
|
|
180
|
+
|
|
181
|
+
let pathOf = (located: located): string =>
|
|
182
|
+
switch located {
|
|
183
|
+
| Declared(file) | SeededFrom(file, _) => file
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
The manifest to work from, copying the template into place when there is none.
|
|
188
|
+
|
|
189
|
+
`.reventless/` is gitignored on every platform, so a fresh clone has no manifest
|
|
190
|
+
and the first thing anyone met was `cp users.example.yaml .reventless/users.yaml`
|
|
191
|
+
— ceremony of the same kind as passing a pool id the deployment already knows.
|
|
192
|
+
`scripts/setup.mjs` has done this for the local platform all along; this generalises
|
|
193
|
+
it to any platform package, which is why it lives beside the format rather than
|
|
194
|
+
inside either platform's tooling.
|
|
195
|
+
|
|
196
|
+
🚨 **A named path is never seeded.** Passing one is a claim that it exists, and
|
|
197
|
+
copying a template over that claim would answer a typo by provisioning the wrong
|
|
198
|
+
cast. Only the default path is filled in.
|
|
199
|
+
*/
|
|
200
|
+
let locate = (~given: option<string>=?, ()): result<located, string> =>
|
|
201
|
+
switch given {
|
|
202
|
+
| Some(file) =>
|
|
203
|
+
NodeFs.existsSync(file)
|
|
204
|
+
? Ok(Declared(file))
|
|
205
|
+
: Error(`${file} does not exist — a named manifest is read, never created`)
|
|
206
|
+
| None =>
|
|
207
|
+
let file = defaultPath()
|
|
208
|
+
if NodeFs.existsSync(file) {
|
|
209
|
+
Ok(Declared(file))
|
|
210
|
+
} else {
|
|
211
|
+
let template = NodePath.join([NodeProcess.cwd(), templateName])
|
|
212
|
+
if !NodeFs.existsSync(template) {
|
|
213
|
+
Error(
|
|
214
|
+
`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`,
|
|
215
|
+
)
|
|
216
|
+
} else {
|
|
217
|
+
try {
|
|
218
|
+
NodeFs.mkdirSync(NodePath.dirname(file), {recursive: true})
|
|
219
|
+
NodeFs.cpSync(template, file, {})
|
|
220
|
+
Ok(SeededFrom(file, template))
|
|
221
|
+
} catch {
|
|
222
|
+
| JsExn(err) =>
|
|
223
|
+
Error(
|
|
224
|
+
`could not copy ${templateName} into place: ${JsExn.message(err)->Option.getOr(
|
|
225
|
+
"unknown error",
|
|
226
|
+
)}`,
|
|
227
|
+
)
|
|
228
|
+
| _ => Error(`could not copy ${templateName} into place`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Preparing ────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/** One account after preparation: what the file now declares, and whether this
|
|
237
|
+
run is what put the password there. */
|
|
238
|
+
type prepared = {
|
|
239
|
+
entry: entry,
|
|
240
|
+
passwordGenerated: bool,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
Validates a manifest and fills in what it leaves blank.
|
|
245
|
+
|
|
246
|
+
🚨 **This is the whole of provisioning on the local platform, and the first half
|
|
247
|
+
of it on AWS.** Locally the manifest *is* the user store — the auth adapter
|
|
248
|
+
hydrates from it at startup — so once every account has a password there is
|
|
249
|
+
nothing left to create. On AWS the accounts still have to be made in a pool, and
|
|
250
|
+
that half is Cognito's, in `reventless/aws`.
|
|
251
|
+
|
|
252
|
+
Which is why it lives here and takes no client, no region and no credentials: a
|
|
253
|
+
developer with an empty password field can run it on a laptop that has never held
|
|
254
|
+
an AWS key.
|
|
255
|
+
|
|
256
|
+
The passwords it mints are written straight back, because a generated credential
|
|
257
|
+
that is only printed is one the seed client cannot read.
|
|
258
|
+
*/
|
|
259
|
+
let prepare = (~path as file: string): result<array<prepared>, string> =>
|
|
260
|
+
switch parseFile(file) {
|
|
261
|
+
| Error(_) as e => e
|
|
262
|
+
| Ok(entries) =>
|
|
263
|
+
let minted =
|
|
264
|
+
entries->Array.map(entry =>
|
|
265
|
+
entry.password->String.trim == "" ? Some(Util_Password.generate()) : None
|
|
266
|
+
)
|
|
267
|
+
let fills = minted->Array.mapWithIndex((password, index) => {index, password, userId: None})
|
|
268
|
+
switch fillFile(~path=file, ~fills) {
|
|
269
|
+
| Error(_) as e => e
|
|
270
|
+
| Ok(report) =>
|
|
271
|
+
Ok(
|
|
272
|
+
entries->Array.mapWithIndex((entry, index) => {
|
|
273
|
+
let written = report->Array.get(index)->Option.mapOr(false, r => r.passwordWritten)
|
|
274
|
+
switch (written, minted->Array.getUnsafe(index)) {
|
|
275
|
+
| (true, Some(password)) => {entry: {...entry, password}, passwordGenerated: true}
|
|
276
|
+
| _ => {entry, passwordGenerated: false}
|
|
277
|
+
}
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Sury from "sury";
|
|
5
|
+
import * as Yaml from "yaml";
|
|
6
|
+
import * as Nodefs from "node:fs";
|
|
7
|
+
import * as Nodepath from "node:path";
|
|
8
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
9
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
10
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
11
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
12
|
+
import * as Util_Password$Reventless from "../util/Util_Password.res.mjs";
|
|
13
|
+
|
|
14
|
+
let entrySchema = Sury.$schema(s => ({
|
|
15
|
+
username: s.m(Sury.string),
|
|
16
|
+
password: s.m(Sury.string),
|
|
17
|
+
groups: s.m(Sury.array(Sury.string)),
|
|
18
|
+
userId: s.m(Sury.$option(Sury.string))
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
let entriesSchema = Sury.array(entrySchema);
|
|
22
|
+
|
|
23
|
+
function parseString(yamlText) {
|
|
24
|
+
try {
|
|
25
|
+
return {
|
|
26
|
+
TAG: "Ok",
|
|
27
|
+
_0: S.parseOrThrow(Yaml.parse(yamlText), entriesSchema)
|
|
28
|
+
};
|
|
29
|
+
} catch (raw_err) {
|
|
30
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
31
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
32
|
+
return {
|
|
33
|
+
TAG: "Error",
|
|
34
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "YAML parse error")
|
|
35
|
+
};
|
|
36
|
+
} else {
|
|
37
|
+
return {
|
|
38
|
+
TAG: "Error",
|
|
39
|
+
_0: "YAML parse error"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseFile(path) {
|
|
46
|
+
try {
|
|
47
|
+
return parseString(Nodefs.readFileSync(path, "utf8"));
|
|
48
|
+
} catch (raw_err) {
|
|
49
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
50
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
51
|
+
return {
|
|
52
|
+
TAG: "Error",
|
|
53
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), `Cannot read ` + path)
|
|
54
|
+
};
|
|
55
|
+
} else {
|
|
56
|
+
return {
|
|
57
|
+
TAG: "Error",
|
|
58
|
+
_0: `Cannot read ` + path
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultPath() {
|
|
65
|
+
return Nodepath.join(process.cwd(), ".reventless", "users.yaml");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function path(index, field) {
|
|
69
|
+
return [
|
|
70
|
+
index,
|
|
71
|
+
field
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function currentString(doc, index, field) {
|
|
76
|
+
let s = doc.getIn(path(index, field));
|
|
77
|
+
if (typeof s === "string") {
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function applyFills(yamlText, fills) {
|
|
83
|
+
try {
|
|
84
|
+
let doc = Yaml.parseDocument(yamlText);
|
|
85
|
+
let report = fills.map(param => {
|
|
86
|
+
let userId = param.userId;
|
|
87
|
+
let password = param.password;
|
|
88
|
+
let index = param.index;
|
|
89
|
+
let passwordWritten = password !== undefined && Stdlib_Option.getOr(currentString(doc, index, "password"), "").trim() === "" ? (doc.setIn(path(index, "password"), password), true) : false;
|
|
90
|
+
let userIdWritten = userId !== undefined && Primitive_object.notequal(currentString(doc, index, "userId"), userId) ? (doc.setIn(path(index, "userId"), userId), true) : false;
|
|
91
|
+
return {
|
|
92
|
+
index: index,
|
|
93
|
+
passwordWritten: passwordWritten,
|
|
94
|
+
userIdWritten: userIdWritten
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
TAG: "Ok",
|
|
99
|
+
_0: [
|
|
100
|
+
doc.toString(),
|
|
101
|
+
report
|
|
102
|
+
]
|
|
103
|
+
};
|
|
104
|
+
} catch (raw_err) {
|
|
105
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
106
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
107
|
+
return {
|
|
108
|
+
TAG: "Error",
|
|
109
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "Cannot rewrite the manifest")
|
|
110
|
+
};
|
|
111
|
+
} else {
|
|
112
|
+
return {
|
|
113
|
+
TAG: "Error",
|
|
114
|
+
_0: "Cannot rewrite the manifest"
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fillFile(file, fills) {
|
|
121
|
+
let e;
|
|
122
|
+
try {
|
|
123
|
+
e = {
|
|
124
|
+
TAG: "Ok",
|
|
125
|
+
_0: Nodefs.readFileSync(file, "utf8")
|
|
126
|
+
};
|
|
127
|
+
} catch (raw_err) {
|
|
128
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
129
|
+
e = err.RE_EXN_ID === "JsExn" ? ({
|
|
130
|
+
TAG: "Error",
|
|
131
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), `Cannot read ` + file)
|
|
132
|
+
}) : ({
|
|
133
|
+
TAG: "Error",
|
|
134
|
+
_0: `Cannot read ` + file
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (e.TAG !== "Ok") {
|
|
138
|
+
return e;
|
|
139
|
+
}
|
|
140
|
+
let e$1 = applyFills(e._0, fills);
|
|
141
|
+
if (e$1.TAG !== "Ok") {
|
|
142
|
+
return e$1;
|
|
143
|
+
}
|
|
144
|
+
let match = e$1._0;
|
|
145
|
+
let report = match[1];
|
|
146
|
+
if (!report.some(r => {
|
|
147
|
+
if (r.passwordWritten) {
|
|
148
|
+
return true;
|
|
149
|
+
} else {
|
|
150
|
+
return r.userIdWritten;
|
|
151
|
+
}
|
|
152
|
+
})) {
|
|
153
|
+
return {
|
|
154
|
+
TAG: "Ok",
|
|
155
|
+
_0: report
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
Nodefs.writeFileSync(file, match[0], "utf8");
|
|
160
|
+
return {
|
|
161
|
+
TAG: "Ok",
|
|
162
|
+
_0: report
|
|
163
|
+
};
|
|
164
|
+
} catch (raw_err$1) {
|
|
165
|
+
let err$1 = Primitive_exceptions.internalToException(raw_err$1);
|
|
166
|
+
if (err$1.RE_EXN_ID === "JsExn") {
|
|
167
|
+
return {
|
|
168
|
+
TAG: "Error",
|
|
169
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err$1._1), `Cannot write ` + file)
|
|
170
|
+
};
|
|
171
|
+
} else {
|
|
172
|
+
return {
|
|
173
|
+
TAG: "Error",
|
|
174
|
+
_0: `Cannot write ` + file
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let templateName = "users.example.yaml";
|
|
181
|
+
|
|
182
|
+
function pathOf(located) {
|
|
183
|
+
return located._0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function locate(given, param) {
|
|
187
|
+
if (given !== undefined) {
|
|
188
|
+
if (Nodefs.existsSync(given)) {
|
|
189
|
+
return {
|
|
190
|
+
TAG: "Ok",
|
|
191
|
+
_0: {
|
|
192
|
+
TAG: "Declared",
|
|
193
|
+
_0: given
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
} else {
|
|
197
|
+
return {
|
|
198
|
+
TAG: "Error",
|
|
199
|
+
_0: given + ` does not exist — a named manifest is read, never created`
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
let file = defaultPath();
|
|
204
|
+
if (Nodefs.existsSync(file)) {
|
|
205
|
+
return {
|
|
206
|
+
TAG: "Ok",
|
|
207
|
+
_0: {
|
|
208
|
+
TAG: "Declared",
|
|
209
|
+
_0: file
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
let template = Nodepath.join(process.cwd(), templateName);
|
|
214
|
+
if (!Nodefs.existsSync(template)) {
|
|
215
|
+
return {
|
|
216
|
+
TAG: "Error",
|
|
217
|
+
_0: `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`
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
Nodefs.mkdirSync(Nodepath.dirname(file), {
|
|
222
|
+
recursive: true
|
|
223
|
+
});
|
|
224
|
+
Nodefs.cpSync(template, file, {});
|
|
225
|
+
return {
|
|
226
|
+
TAG: "Ok",
|
|
227
|
+
_0: {
|
|
228
|
+
TAG: "SeededFrom",
|
|
229
|
+
_0: file,
|
|
230
|
+
_1: template
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
} catch (raw_err) {
|
|
234
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
235
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
236
|
+
return {
|
|
237
|
+
TAG: "Error",
|
|
238
|
+
_0: `could not copy ` + templateName + ` into place: ` + Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "unknown error")
|
|
239
|
+
};
|
|
240
|
+
} else {
|
|
241
|
+
return {
|
|
242
|
+
TAG: "Error",
|
|
243
|
+
_0: `could not copy ` + templateName + ` into place`
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function prepare(file) {
|
|
250
|
+
let e = parseFile(file);
|
|
251
|
+
if (e.TAG !== "Ok") {
|
|
252
|
+
return e;
|
|
253
|
+
}
|
|
254
|
+
let entries = e._0;
|
|
255
|
+
let minted = entries.map(entry => {
|
|
256
|
+
if (entry.password.trim() === "") {
|
|
257
|
+
return Util_Password$Reventless.generate();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
let fills = minted.map((password, index) => ({
|
|
261
|
+
index: index,
|
|
262
|
+
password: password,
|
|
263
|
+
userId: undefined
|
|
264
|
+
}));
|
|
265
|
+
let e$1 = fillFile(file, fills);
|
|
266
|
+
if (e$1.TAG !== "Ok") {
|
|
267
|
+
return e$1;
|
|
268
|
+
}
|
|
269
|
+
let report = e$1._0;
|
|
270
|
+
return {
|
|
271
|
+
TAG: "Ok",
|
|
272
|
+
_0: entries.map((entry, index) => {
|
|
273
|
+
let written = Stdlib_Option.mapOr(report[index], false, r => r.passwordWritten);
|
|
274
|
+
let match = minted[index];
|
|
275
|
+
if (!written) {
|
|
276
|
+
return {
|
|
277
|
+
entry: entry,
|
|
278
|
+
passwordGenerated: false
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (match === undefined) {
|
|
282
|
+
return {
|
|
283
|
+
entry: entry,
|
|
284
|
+
passwordGenerated: false
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
let newrecord = {...entry};
|
|
288
|
+
return {
|
|
289
|
+
entry: (newrecord.password = match, newrecord),
|
|
290
|
+
passwordGenerated: true
|
|
291
|
+
};
|
|
292
|
+
})
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export {
|
|
297
|
+
entrySchema,
|
|
298
|
+
entriesSchema,
|
|
299
|
+
parseString,
|
|
300
|
+
parseFile,
|
|
301
|
+
defaultPath,
|
|
302
|
+
path,
|
|
303
|
+
currentString,
|
|
304
|
+
applyFills,
|
|
305
|
+
fillFile,
|
|
306
|
+
templateName,
|
|
307
|
+
pathOf,
|
|
308
|
+
locate,
|
|
309
|
+
prepare,
|
|
310
|
+
}
|
|
311
|
+
/* entrySchema Not a pure module */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The group whose members administer a deployment.
|
|
3
|
+
|
|
4
|
+
One definition, because the name is decided **twice and independently**: an
|
|
5
|
+
authorization rule names it to allow a read, and [OwnerScope.elevatedGroups]
|
|
6
|
+
names it to exempt a caller from `@owner` narrowing. Nothing checks that the two
|
|
7
|
+
agree, and a deployment where they disagree is refused nowhere — the
|
|
8
|
+
administrator passes every authorization check and then reads *empty views*,
|
|
9
|
+
because every owner-scoped view filters them out. Empty-because-scoped and
|
|
10
|
+
empty-because-there-is-nothing look identical from outside.
|
|
11
|
+
|
|
12
|
+
In `spec` rather than in a provider package because **both platforms hard-code
|
|
13
|
+
it**: the AWS platform decorates its admin API with it, while the local platform
|
|
14
|
+
both wraps its `Platform_*` fields in it and hands it to the built-in developer
|
|
15
|
+
account. A constant only one of them could reach would leave the other exactly as
|
|
16
|
+
divergent as it is today.
|
|
17
|
+
|
|
18
|
+
🚨 **This buys agreement, not the freedom to rename.** `"Admin"` is written into
|
|
19
|
+
every example's `@@reventless.authorize` annotations, and a PPX annotation takes a
|
|
20
|
+
literal — so the consumers that matter most *cannot* read this constant. Changing
|
|
21
|
+
the value here would leave those annotations behind and silently split the two
|
|
22
|
+
mechanisms this exists to hold together, which is the failure above rather than a
|
|
23
|
+
compile error. Renaming the administrator group is a different and much larger
|
|
24
|
+
act. `AdminGroupTest` asserts that the constant and the platform's own annotation
|
|
25
|
+
still say the same thing, because that boundary can carry no other check.
|
|
26
|
+
*/
|
|
27
|
+
let name = "Admin"
|
|
@@ -18,6 +18,5 @@ let isAllowed = (rule: permission, identity: Identity.t): bool =>
|
|
|
18
18
|
| DenyAll => false
|
|
19
19
|
| AllowAnonymous => true
|
|
20
20
|
| AllowAuthenticated => identity.userId !== "anonymous"
|
|
21
|
-
| AllowGroups(groups) =>
|
|
22
|
-
groups->Array.some(group => identity.groups->Array.includes(group))
|
|
21
|
+
| AllowGroups(groups) => groups->Array.some(group => identity.groups->Array.includes(group))
|
|
23
22
|
}
|
package/src/types/Handler.res
CHANGED
package/src/types/Identity.res
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
/** Identity provider that authenticated the user. */
|
|
3
2
|
@schema
|
|
4
3
|
type provider = Cognito | InMemory | Custom(string)
|
|
@@ -25,8 +24,7 @@ let anonymous: t = {
|
|
|
25
24
|
provider: InMemory,
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
let hasGroup = (identity: t, group: string): bool =>
|
|
29
|
-
identity.groups->Array.includes(group)
|
|
27
|
+
let hasGroup = (identity: t, group: string): bool => identity.groups->Array.includes(group)
|
|
30
28
|
|
|
31
29
|
let getClaim = (identity: t, key: string): option<string> =>
|
|
32
30
|
switch identity.claims {
|
package/src/types/Lifecycle.res
CHANGED
|
@@ -6,10 +6,9 @@ consumer resolves it here rather than restating it. `Trail` is the one field a
|
|
|
6
6
|
view declares to have each state it reaches recorded with the instant it was
|
|
7
7
|
reached; the projection machinery fills it, not the domain.
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
// A wire enum: a union whose non-null members are all string constants. A
|
|
9
|
+
let // A wire enum: a union whose non-null members are all string constants. A
|
|
11
10
|
// tagged union's members are objects, so the same test excludes it.
|
|
12
|
-
|
|
11
|
+
isEnumSchema = (schema: S.t<unknown>): bool =>
|
|
13
12
|
switch schema {
|
|
14
13
|
| AnyOf({anyOf}) =>
|
|
15
14
|
let members = anyOf->Array.filter(v =>
|
|
@@ -80,8 +79,7 @@ module Trail = {
|
|
|
80
79
|
let schema = (stateSchema: S.t<'state>): S.t<t<'state>> =>
|
|
81
80
|
S.array(entrySchema(stateSchema))->Semantic.mark(~id=Semantic.Id.lifecycleTrail)
|
|
82
81
|
|
|
83
|
-
let isTrail = (schema: S.t<unknown>): bool =>
|
|
84
|
-
Semantic.has(schema, ~id=Semantic.Id.lifecycleTrail)
|
|
82
|
+
let isTrail = (schema: S.t<unknown>): bool => Semantic.has(schema, ~id=Semantic.Id.lifecycleTrail)
|
|
85
83
|
|
|
86
84
|
/** The trail field declared on a state record, if it declares one. */
|
|
87
85
|
let fieldName = (stateSchema: S.t<unknown>): option<string> =>
|
|
@@ -101,6 +99,9 @@ module Trail = {
|
|
|
101
99
|
| _ => []
|
|
102
100
|
}
|
|
103
101
|
let entry = Dict.fromArray([("state", state), ("at", JSON.Encode.string(at))])
|
|
104
|
-
stateDict->Dict.set(
|
|
102
|
+
stateDict->Dict.set(
|
|
103
|
+
field,
|
|
104
|
+
entries->Array.concat([entry->JSON.Encode.object])->JSON.Encode.array,
|
|
105
|
+
)
|
|
105
106
|
}
|
|
106
107
|
}
|