@reventlessdev/reventless-spec 3.0.0-alpha.134 → 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 +26 -0
- package/package.json +3 -2
- 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/generator/PlatformCodegen.res +1 -0
- package/src/generator/PlatformCodegen.res.mjs +5 -0
- package/src/lifecycle/CheckLifecycleModel.res +34 -4
- package/src/lifecycle/CheckLifecycleModel.res.mjs +6 -3
- 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 +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/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
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.135 (2026-09-11)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **spec:** a command that declines to act is not a command that was refused ([0b3d8a4](https://github.com/ReventlessDev/reventless-core/commit/0b3d8a40092ce7ed7801a2aae5d4d7bbed03c179))
|
|
11
|
+
* feat(spec)!: a secret is a capability, so a test can know it in advance ([71507c6](https://github.com/ReventlessDev/reventless-core/commit/71507c6459b49178746f00c5040cb479d7fdade8))
|
|
12
|
+
* feat(spec)!: making a principal is a capability, so the provider is replaceable ([3e59b9b](https://github.com/ReventlessDev/reventless-core/commit/3e59b9b665cc7e4b6b6f156b63b1b6bd0c6181ca))
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **aws:** the first administrator is one command, not the console ([5f1a846](https://github.com/ReventlessDev/reventless-core/commit/5f1a84603b21a9feb691a08a148751c6c6e8eca8))
|
|
16
|
+
* **aws:** the rest of the cast is a file, not a comment ([aa23581](https://github.com/ReventlessDev/reventless-core/commit/aa23581f09948a47fb451ec2ecf2fc2bdd8e6ee6))
|
|
17
|
+
* **spec:** preparing a manifest is not an AWS errand ([bd7f8e2](https://github.com/ReventlessDev/reventless-core/commit/bd7f8e2f6132c0cacac2ab839165f21fe91eaf6c))
|
|
18
|
+
|
|
19
|
+
### BREAKING CHANGES
|
|
20
|
+
|
|
21
|
+
* Capabilities.t gains a secrets member. A platform that builds
|
|
22
|
+
the record literally no longer compiles until it supplies one;
|
|
23
|
+
Capabilities.none.secrets is the refusing value. Code that spreads
|
|
24
|
+
Capabilities.none is unaffected.
|
|
25
|
+
* Capabilities.t gains an identityProvider member. A platform
|
|
26
|
+
that builds the record literally no longer compiles until it supplies one;
|
|
27
|
+
Capabilities.none.identityProvider is the refusing value to pass while no
|
|
28
|
+
backend exists. Code that spreads Capabilities.none is unaffected.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
6
32
|
# 3.0.0-alpha.134 (2026-09-09)
|
|
7
33
|
|
|
8
34
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-spec",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.135",
|
|
4
4
|
"description": "Specifications for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"graft-trait": "./run-graft-trait.mjs",
|
|
10
10
|
"certify-trait": "./run-certify-trait.mjs",
|
|
11
11
|
"trait-manifest": "./run-trait-manifest.mjs",
|
|
12
|
-
"check-lifecycle": "./run-check-lifecycle.mjs"
|
|
12
|
+
"check-lifecycle": "./run-check-lifecycle.mjs",
|
|
13
|
+
"prepare-accounts": "./run-prepare-accounts.mjs"
|
|
13
14
|
},
|
|
14
15
|
"jest": {
|
|
15
16
|
"testMatch": [
|
package/run-check-lifecycle.mjs
CHANGED
|
@@ -21,6 +21,9 @@ type kind =
|
|
|
21
21
|
/** Sending a message, reached through `Capabilities.messaging`. Slice-declared
|
|
22
22
|
like `Geocoding`, and for the same reason carries no `field`. */
|
|
23
23
|
| Messaging
|
|
24
|
+
/** Making, grouping and unmaking principals, reached through
|
|
25
|
+
`Capabilities.identityProvider`. Slice-declared, so no `field`. */
|
|
26
|
+
| IdentityProvider
|
|
24
27
|
|
|
25
28
|
/** The declaration site: the component's spec name, and — for a store — the field
|
|
26
29
|
carrying the `@storageRef` annotation plus the store exactly as that field
|
|
@@ -80,6 +83,7 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
|
|
|
80
83
|
kind: switch need {
|
|
81
84
|
| Geocoding => Geocoding
|
|
82
85
|
| Messaging => Messaging
|
|
86
|
+
| IdentityProvider => IdentityProvider
|
|
83
87
|
},
|
|
84
88
|
key,
|
|
85
89
|
declaredBy: needs->Array.filterMap(
|
|
@@ -10,7 +10,8 @@ import * as CapabilityNeed$Reventless from "../semantic/CapabilityNeed.res.mjs";
|
|
|
10
10
|
let kindSchema = Sury.union([
|
|
11
11
|
Sury.literal("ObjectStore"),
|
|
12
12
|
Sury.literal("Geocoding"),
|
|
13
|
-
Sury.literal("Messaging")
|
|
13
|
+
Sury.literal("Messaging"),
|
|
14
|
+
Sury.literal("IdentityProvider")
|
|
14
15
|
]);
|
|
15
16
|
|
|
16
17
|
let provenanceSchema = Sury.$schema(s => ({
|
|
@@ -48,7 +49,17 @@ function fromStructure(structure) {
|
|
|
48
49
|
let capabilityKeys = Belt_SetString.toArray(Belt_SetString.fromArray(needs.map(d => d.capability)));
|
|
49
50
|
let capabilities = Stdlib_Array.filterMap(capabilityKeys, key => Stdlib_Option.map(CapabilityNeed$Reventless.fromString(key), need => {
|
|
50
51
|
let tmp;
|
|
51
|
-
|
|
52
|
+
switch (need) {
|
|
53
|
+
case "Geocoding" :
|
|
54
|
+
tmp = "Geocoding";
|
|
55
|
+
break;
|
|
56
|
+
case "Messaging" :
|
|
57
|
+
tmp = "Messaging";
|
|
58
|
+
break;
|
|
59
|
+
case "IdentityProvider" :
|
|
60
|
+
tmp = "IdentityProvider";
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
52
63
|
return {
|
|
53
64
|
kind: tmp,
|
|
54
65
|
key: key,
|
|
@@ -125,6 +125,7 @@ let renderEntry = (entry: unionEntry): result<array<string>, string> =>
|
|
|
125
125
|
// declared it, with every declaring slice kept as provenance.
|
|
126
126
|
| Geocoding => Ok(renderComments(entry)->Array.concat([" Geocoding,"]))
|
|
127
127
|
| Messaging => Ok(renderComments(entry)->Array.concat([" Messaging,"]))
|
|
128
|
+
| IdentityProvider => Ok(renderComments(entry)->Array.concat([" IdentityProvider,"]))
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
/**
|
|
@@ -550,6 +550,18 @@ type derivedCommand = {
|
|
|
550
550
|
accepted and silent. Kept separately because it is what turns a declared
|
|
551
551
|
state the corpus disagrees with into a contradiction rather than a gap. */
|
|
552
552
|
inertStates: array<string>,
|
|
553
|
+
/** The strict subset of [inertStates] where the command was *refused* — an
|
|
554
|
+
error, not an accepted no-op.
|
|
555
|
+
|
|
556
|
+
Separate because the two refute different claims. A named from-set says the
|
|
557
|
+
command **takes effect** from those states, so anything inert there
|
|
558
|
+
contradicts it. `Unrestricted` says only that the command is **never
|
|
559
|
+
refused**, which an accepted `Ok([])` agrees with rather than contradicts —
|
|
560
|
+
returning no events for a command that would change nothing is this
|
|
561
|
+
codebase's idempotency convention, not evidence the state was illegal.
|
|
562
|
+
Reading one field for both questions made every idempotent
|
|
563
|
+
legal-in-every-state command look like a contradiction. */
|
|
564
|
+
refusedStates: array<string>,
|
|
553
565
|
/** Where the edges land. A relation, not a single value: a command observed
|
|
554
566
|
landing in two states is the signal that the published `targetState` cannot
|
|
555
567
|
carry the model, and that is worth seeing before spending the change. */
|
|
@@ -651,6 +663,9 @@ let deriveCommands = (
|
|
|
651
663
|
inertStates: sortedUnique(
|
|
652
664
|
mine->Array.filterMap(o => o.outcome == Emitted || o.from == noRow ? None : Some(o.from)),
|
|
653
665
|
),
|
|
666
|
+
refusedStates: sortedUnique(
|
|
667
|
+
mine->Array.filterMap(o => o.outcome == Refused && o.from != noRow ? Some(o.from) : None),
|
|
668
|
+
),
|
|
654
669
|
targets: sortedUnique(
|
|
655
670
|
effective->Array.filterMap(o => o.to == o.from || o.to == noRow ? None : Some(o.to)),
|
|
656
671
|
),
|
|
@@ -757,14 +772,24 @@ let compare = (
|
|
|
757
772
|
// effect somewhere agrees with that rather than narrowing it — the corpus
|
|
758
773
|
// covers the states somebody wrote a scenario for, and silence about the
|
|
759
774
|
// rest is not refusal. What DOES refute the claim is a state the command was
|
|
760
|
-
// exercised in and
|
|
775
|
+
// exercised in and **refused**: that is the switch and the behaviour
|
|
761
776
|
// disagreeing about the same row.
|
|
777
|
+
//
|
|
778
|
+
// 🚨 **Refused, not merely inert.** `Unrestricted` claims the command is
|
|
779
|
+
// never refused — it does not claim the command always emits. A state where a
|
|
780
|
+
// scenario shows it accepted and silent AGREES with that: returning `Ok([])`
|
|
781
|
+
// for a command that would change nothing is this codebase's idempotency
|
|
782
|
+
// convention, required because commands arrive at least once. Reading
|
|
783
|
+
// `inertStates` here marked every such command contradicted, which is the
|
|
784
|
+
// opposite of what the corpus showed — a verdict redelivered, or landing
|
|
785
|
+
// after deactivation, is precisely the case the switch declared legal so that
|
|
786
|
+
// the reporting slice would not retry forever.
|
|
762
787
|
| None if cmd.allowedStatesSource == Some("unrestricted") =>
|
|
763
|
-
derived.
|
|
788
|
+
derived.refusedStates->Array.forEach(state =>
|
|
764
789
|
add(
|
|
765
790
|
"contradicted",
|
|
766
791
|
[state],
|
|
767
|
-
`the switch declares it legal in every state, and a scenario from "${state}" ` ++ `shows it refused
|
|
792
|
+
`the switch declares it legal in every state, and a scenario from "${state}" ` ++ `shows it refused`,
|
|
768
793
|
)
|
|
769
794
|
)
|
|
770
795
|
| None =>
|
|
@@ -1378,4 +1403,9 @@ let main = async () => {
|
|
|
1378
1403
|
}
|
|
1379
1404
|
}
|
|
1380
1405
|
|
|
1381
|
-
|
|
1406
|
+
// 🚨 **No top-level call.** `../../run-check-lifecycle.mjs` invokes [main]; this
|
|
1407
|
+
// module only defines it. While the call was here, importing the module *ran the
|
|
1408
|
+
// whole check* and then called `NodeProcess.exit`, so nothing in this file could
|
|
1409
|
+
// be reached from a test — including [deriveCommands] and [compare], which are
|
|
1410
|
+
// pure and are where every verdict is actually decided. That is the same trap
|
|
1411
|
+
// `ProvisionIdentity` records having shipped a never-matching guard through.
|
|
@@ -469,6 +469,11 @@ function deriveCommands(component, observations, labelled) {
|
|
|
469
469
|
return o.from;
|
|
470
470
|
}
|
|
471
471
|
})),
|
|
472
|
+
refusedStates: sortedUnique(Stdlib_Array.filterMap(mine, o => {
|
|
473
|
+
if (o.outcome === "Refused" && o.from !== noRow) {
|
|
474
|
+
return o.from;
|
|
475
|
+
}
|
|
476
|
+
})),
|
|
472
477
|
targets: sortedUnique(Stdlib_Array.filterMap(effective, o => {
|
|
473
478
|
if (o.to === o.from || o.to === noRow) {
|
|
474
479
|
return;
|
|
@@ -531,7 +536,7 @@ function compare(plugin, writable, derived, findings) {
|
|
|
531
536
|
add("unverified", states, `the switch declares ` + states.length.toString() + ` state(s) and no scenario shows the command taking effect anywhere`);
|
|
532
537
|
}
|
|
533
538
|
} else if (Primitive_object.equal(declared.allowedStatesSource, "unrestricted")) {
|
|
534
|
-
derived.
|
|
539
|
+
derived.refusedStates.forEach(state => add("contradicted", [state], `the switch declares it legal in every state, and a scenario from "` + state + `" shows it refused`));
|
|
535
540
|
} else if (derived.allowedStates.length !== 0) {
|
|
536
541
|
add("undeclared", derived.allowedStates, `scenarios show it taking effect from ` + derived.allowedStates.join(", ") + `, and it declares no edge`);
|
|
537
542
|
}
|
|
@@ -1086,8 +1091,6 @@ async function main() {
|
|
|
1086
1091
|
}
|
|
1087
1092
|
}
|
|
1088
1093
|
|
|
1089
|
-
main();
|
|
1090
|
-
|
|
1091
1094
|
export {
|
|
1092
1095
|
repoRoot,
|
|
1093
1096
|
examplesDir,
|
|
@@ -27,6 +27,15 @@ type t = {
|
|
|
27
27
|
/** Send a message to a person, and say which channels this deployment can
|
|
28
28
|
attempt at all. See `Messaging.provider`. */
|
|
29
29
|
messaging: Messaging.provider,
|
|
30
|
+
/** Make, group and unmake principals, and say which of those this
|
|
31
|
+
deployment's provider can do at all. Administrative only — authenticating
|
|
32
|
+
one is `Auth_Adapter.Provider`'s. See `IdentityProvider.t`. */
|
|
33
|
+
identityProvider: IdentityProvider.t,
|
|
34
|
+
/** Unguessable material and a one-way function over it. Injected rather than
|
|
35
|
+
imported so a test can pin the secret a flow is about to use — a suite
|
|
36
|
+
cannot assert what a flow does with a value it cannot predict. See
|
|
37
|
+
`Secrets.t`. */
|
|
38
|
+
secrets: Secrets.t,
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -54,4 +63,12 @@ let none: t = {
|
|
|
54
63
|
~recipient as _,
|
|
55
64
|
~message as _,
|
|
56
65
|
) => Error(Unavailable("no messaging provider is configured for this platform"))),
|
|
66
|
+
identityProvider: IdentityProvider.unavailable(
|
|
67
|
+
~reason="no identity provider is configured for this platform",
|
|
68
|
+
),
|
|
69
|
+
// Refusing rather than falling back to a weak source. Every runtime has a
|
|
70
|
+
// cryptographic one, so a platform reaching this arm has skipped wiring rather
|
|
71
|
+
// than declined to provision — and a guessable token would be accepted
|
|
72
|
+
// everywhere while looking exactly like a real one.
|
|
73
|
+
secrets: Secrets.unavailable(~reason="no secret source is configured for this platform"),
|
|
57
74
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Secrets$Reventless from "./Secrets.res.mjs";
|
|
3
4
|
import * as Messaging$Reventless from "./Messaging.res.mjs";
|
|
5
|
+
import * as IdentityProvider$Reventless from "./IdentityProvider.res.mjs";
|
|
4
6
|
|
|
5
7
|
async function none_geocode(param) {
|
|
6
8
|
return {
|
|
@@ -20,9 +22,15 @@ let none_messaging = Messaging$Reventless.makeProvider([], [], async (param, par
|
|
|
20
22
|
}
|
|
21
23
|
}));
|
|
22
24
|
|
|
25
|
+
let none_identityProvider = IdentityProvider$Reventless.unavailable("no identity provider is configured for this platform");
|
|
26
|
+
|
|
27
|
+
let none_secrets = Secrets$Reventless.unavailable("no secret source is configured for this platform");
|
|
28
|
+
|
|
23
29
|
let none = {
|
|
24
30
|
geocode: none_geocode,
|
|
25
|
-
messaging: none_messaging
|
|
31
|
+
messaging: none_messaging,
|
|
32
|
+
identityProvider: none_identityProvider,
|
|
33
|
+
secrets: none_secrets
|
|
26
34
|
};
|
|
27
35
|
|
|
28
36
|
export {
|
|
@@ -24,6 +24,16 @@ type t =
|
|
|
24
24
|
declaration, because a plugin that named `Sms` would fail a deploy it
|
|
25
25
|
could have run on email. */
|
|
26
26
|
| Messaging
|
|
27
|
+
/** Making, grouping and unmaking principals, reached through
|
|
28
|
+
`Capabilities.identityProvider`. No payload, for the reason `Geocoding`
|
|
29
|
+
has none: one identity provider per deployment is a real answer, and a
|
|
30
|
+
deployment wanting a second pool is the `{alternatives, min}` question
|
|
31
|
+
`Messaging` already answers by publishing what it provisioned.
|
|
32
|
+
|
|
33
|
+
Not authentication. Token issuance and session handling are
|
|
34
|
+
`Auth_Adapter.Provider`'s, read at a different time — two seams over one
|
|
35
|
+
provider, the way this type and `Capabilities.t` are already two. */
|
|
36
|
+
| IdentityProvider
|
|
27
37
|
|
|
28
38
|
/** The spelling carried in `pluginStructure` and in `capabilities.json`.
|
|
29
39
|
Persisted structures hold strings, not enum members, so a plugin built
|
|
@@ -32,6 +42,7 @@ let toString = (need: t): string =>
|
|
|
32
42
|
switch need {
|
|
33
43
|
| Geocoding => "Geocoding"
|
|
34
44
|
| Messaging => "Messaging"
|
|
45
|
+
| IdentityProvider => "IdentityProvider"
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** The inverse, for readers of a persisted structure or a committed manifest.
|
|
@@ -41,6 +52,7 @@ let fromString = (name: string): option<t> =>
|
|
|
41
52
|
switch name {
|
|
42
53
|
| "Geocoding" => Some(Geocoding)
|
|
43
54
|
| "Messaging" => Some(Messaging)
|
|
55
|
+
| "IdentityProvider" => Some(IdentityProvider)
|
|
44
56
|
| _ => None
|
|
45
57
|
}
|
|
46
58
|
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
4
|
|
|
5
5
|
function toString(need) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
switch (need) {
|
|
7
|
+
case "Geocoding" :
|
|
8
|
+
return "Geocoding";
|
|
9
|
+
case "Messaging" :
|
|
10
|
+
return "Messaging";
|
|
11
|
+
case "IdentityProvider" :
|
|
12
|
+
return "IdentityProvider";
|
|
10
13
|
}
|
|
11
14
|
}
|
|
12
15
|
|
|
@@ -14,6 +17,8 @@ function fromString(name) {
|
|
|
14
17
|
switch (name) {
|
|
15
18
|
case "Geocoding" :
|
|
16
19
|
return "Geocoding";
|
|
20
|
+
case "IdentityProvider" :
|
|
21
|
+
return "IdentityProvider";
|
|
17
22
|
case "Messaging" :
|
|
18
23
|
return "Messaging";
|
|
19
24
|
default:
|
|
@@ -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 */
|