@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 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.134",
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": [
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import "./src/lifecycle/CheckLifecycleModel.res.mjs"
2
+ import { main } from "./src/lifecycle/CheckLifecycleModel.res.mjs"
3
+ await main()
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./src/types/PrepareAccounts.res.mjs"
3
+ await main()
@@ -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
- tmp = need === "Geocoding" ? "Geocoding" : "Messaging";
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
  /**
@@ -86,6 +86,11 @@ function renderEntry(entry) {
86
86
  TAG: "Ok",
87
87
  _0: renderComments(entry).concat([" Messaging,"])
88
88
  };
89
+ case "IdentityProvider" :
90
+ return {
91
+ TAG: "Ok",
92
+ _0: renderComments(entry).concat([" IdentityProvider,"])
93
+ };
89
94
  }
90
95
  }
91
96
 
@@ -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 did nothing: that is the switch and the behaviour
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.inertStates->Array.forEach(state =>
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 or producing nothing`,
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
- let _ = main()
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.inertStates.forEach(state => add("contradicted", [state], `the switch declares it legal in every state, and a scenario from "` + state + `" shows it refused or producing nothing`));
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
- if (need === "Geocoding") {
7
- return "Geocoding";
8
- } else {
9
- return "Messaging";
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 */