@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.
- package/CHANGELOG.md +44 -0
- package/package.json +4 -3
- package/run-check-lifecycle.mjs +2 -1
- package/run-prepare-accounts.mjs +3 -0
- package/src/components/CapabilityManifest.res +4 -0
- package/src/components/CapabilityManifest.res.mjs +13 -2
- package/src/components/DcbScopeInference.res +224 -73
- package/src/components/DcbScopeInference.res.mjs +187 -49
- package/src/components/DcbTag.res +142 -186
- package/src/components/DcbTag.res.mjs +137 -128
- package/src/components/DcbValidation.res +50 -0
- package/src/components/DcbValidation.res.mjs +51 -0
- package/src/generator/Codegen.res +2 -1
- package/src/generator/Codegen.res.mjs +1 -1
- package/src/generator/PlatformCodegen.res +1 -0
- package/src/generator/PlatformCodegen.res.mjs +5 -0
- package/src/lifecycle/CheckLifecycleModel.res +148 -8
- package/src/lifecycle/CheckLifecycleModel.res.mjs +154 -7
- package/src/semantic/Capabilities.res +17 -0
- package/src/semantic/Capabilities.res.mjs +9 -1
- package/src/semantic/CapabilityNeed.res +12 -0
- package/src/semantic/CapabilityNeed.res.mjs +9 -4
- package/src/semantic/IdentityProvider.res +127 -0
- package/src/semantic/IdentityProvider.res.mjs +65 -0
- package/src/semantic/Secrets.res +76 -0
- package/src/semantic/Secrets.res.mjs +49 -0
- package/src/types/AccountsManifest.res +287 -0
- package/src/types/AccountsManifest.res.mjs +312 -0
- package/src/types/AdminGroup.res +27 -0
- package/src/types/AdminGroup.res.mjs +9 -0
- package/src/types/OwnerScope.res +27 -0
- 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/util/Util_Password.res +60 -0
- 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
|
+
}
|