@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.237 → 1.0.0-alpha.239

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +10 -10
  3. package/src/ContactVerification.res +101 -0
  4. package/src/ContactVerification.res.mjs +60 -0
  5. package/src/ContactVerification_Guards.res +176 -0
  6. package/src/ContactVerification_Guards.res.mjs +97 -0
  7. package/src/Customer/Aggregate/Customer.res +17 -0
  8. package/src/Customer/Aggregate/Customer.res.mjs +13 -1
  9. package/src/Customer/Aggregate/Customer_Behavior.res +53 -6
  10. package/src/Customer/Aggregate/Customer_Behavior.res.mjs +75 -11
  11. package/src/Customer/OutboundTranslation/VerifyCustomerEmail.res +44 -0
  12. package/src/Customer/OutboundTranslation/VerifyCustomerEmail.res.mjs +67 -0
  13. package/src/Customer/OutboundTranslation/VerifyCustomerEmail_Translation.res +79 -0
  14. package/src/Customer/OutboundTranslation/VerifyCustomerEmail_Translation.res.mjs +101 -0
  15. package/src/Customer/ReadModelStream/Customers.res +4 -0
  16. package/src/Customer/ReadModelStream/Customers.res.mjs +1 -0
  17. package/src/Customer/ReadModelStream/Customers_Projections.res +7 -1
  18. package/src/Customer/ReadModelStream/Customers_Projections.res.mjs +13 -0
  19. package/src/Customer/StateChange/EmailVerificationChallenges.res +73 -0
  20. package/src/Customer/StateChange/EmailVerificationChallenges.res.mjs +104 -0
  21. package/src/Customer/StateChange/EmailVerificationChallenges_Behavior.res +105 -0
  22. package/src/Customer/StateChange/EmailVerificationChallenges_Behavior.res.mjs +182 -0
  23. package/src/LifecycleModel.res +7 -0
  24. package/src/LifecycleModel.res.mjs +7 -0
  25. package/src/Order/StateChange/PlaceOrder.res +2 -2
  26. package/src/Order/StateChange/PlaceOrder.res.mjs +2 -2
  27. package/src/Order/StateChange/ShipOrder.res +1 -1
  28. package/src/Order/StateChange/ShipOrder.res.mjs +1 -1
  29. package/src/Plugin.res +27 -0
  30. package/src/Plugin.res.mjs +76 -8
  31. package/tests/ContactVerification_GuardsTest.res +231 -0
  32. package/tests/ContactVerification_GuardsTest.res.mjs +203 -0
  33. package/tests/Customer/Aggregate/Customer_GWT.res +60 -0
  34. package/tests/Customer/Aggregate/Customer_GWT.res.mjs +78 -0
  35. package/tests/Customer/OutboundTranslation/GeocodeCustomerAddress_GWT.res.mjs +5 -1
  36. package/tests/Customer/OutboundTranslation/VerifyCustomerEmail_GWT.res +144 -0
  37. package/tests/Customer/OutboundTranslation/VerifyCustomerEmail_GWT.res.mjs +220 -0
  38. package/tests/Customer/ReadModelStream/Customers_GWT.res +42 -0
  39. package/tests/Customer/ReadModelStream/Customers_GWT.res.mjs +52 -0
  40. package/tests/Customer/StateChange/EmailVerificationChallenges_GWT.res +166 -0
  41. package/tests/Customer/StateChange/EmailVerificationChallenges_GWT.res.mjs +195 -0
  42. package/tests/Notification/OutboundTranslation/SendNotification_GWT.res.mjs +5 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
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
+ # 1.0.0-alpha.239 (2026-09-16)
7
+
8
+ * feat(spec)!: infer the DCB partition key per slice, and drop @partitionTag where inference agrees ([6c10161](https://github.com/ReventlessDev/reventless-core/commit/6c101611d438beea4bc9175ae8ea0ea1f6f7493b))
9
+ ### Features
10
+
11
+ * **spec:** let a slice's chapter break a partition-key tie ([2b9ae77](https://github.com/ReventlessDev/reventless-core/commit/2b9ae77e98eb42177d2bdd4977bc62bb7c7911aa))
12
+
13
+ ### BREAKING CHANGES
14
+
15
+ * DcbTag.derivePartitionTag is removed; a boundary whose
16
+ partition cannot be inferred no longer deploys.
17
+
18
+
19
+
20
+ # 1.0.0-alpha.238 (2026-09-11)
21
+
22
+ ### Bug Fixes
23
+
24
+ * **spec:** a command that declines to act is not a command that was refused ([0b3d8a4](https://github.com/ReventlessDev/reventless-core/commit/0b3d8a40092ce7ed7801a2aae5d4d7bbed03c179))
25
+ ### Features
26
+
27
+ * **example:** a customer's email carries whether it has been proven ([04885a6](https://github.com/ReventlessDev/reventless-core/commit/04885a66e22ca255172f038b42c120c66c0a76bc))
28
+ * **example:** draw a secret, send it, and record only its hash ([a08cbe8](https://github.com/ReventlessDev/reventless-core/commit/a08cbe854516e68e97b0b81e7a53c55e1415bc29))
29
+ * **example:** one secret at a time, held where a snapshot cannot reach it ([f904c63](https://github.com/ReventlessDev/reventless-core/commit/f904c635858f33043dc1d01c968887fee2000047))
30
+ * **example:** proving an address, and dropping the proof when it changes ([89612ff](https://github.com/ReventlessDev/reventless-core/commit/89612ff338e9a39fddf1b2147b88fb961ee57601))
31
+
32
+
6
33
  # 1.0.0-alpha.237 (2026-09-09)
7
34
 
8
35
  **Note:** Version bump only for package @reventlessdev/online-shop-hybrid-ordering
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/online-shop-hybrid-ordering",
3
- "version": "1.0.0-alpha.237",
3
+ "version": "1.0.0-alpha.239",
4
4
  "description": "Online Shop Hybrid example — Ordering plugin (Customer aggregate + Order/CatalogProduct DCB)",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -21,15 +21,15 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "sury": "11.0.0-rc.2",
24
- "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.130",
25
- "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.130",
26
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.20",
27
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.208",
28
- "@reventlessdev/reventless-infra": "3.0.0-alpha.162",
29
- "@reventlessdev/reventless-local": "3.0.0-alpha.250",
30
- "@reventlessdev/reventless-spec": "3.0.0-alpha.134",
31
- "@reventlessdev/trait-address-geocoding": "1.0.0-alpha.10",
32
- "@reventlessdev/trait-notification": "1.0.0-alpha.10"
24
+ "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.132",
25
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.21",
26
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.164",
27
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.210",
28
+ "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.132",
29
+ "@reventlessdev/reventless-local": "3.0.0-alpha.252",
30
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.136",
31
+ "@reventlessdev/trait-notification": "1.0.0-alpha.12",
32
+ "@reventlessdev/trait-address-geocoding": "1.0.0-alpha.12"
33
33
  },
34
34
  "devDependencies": {
35
35
  "rescript": "12.3.0",
@@ -0,0 +1,101 @@
1
+ /**
2
+ Proving that a person controls a contact address.
3
+
4
+ One secret, expiring, non-replayable, and dropped if the address changes
5
+ underneath it. What a settled proof *entitles* is the host's business and
6
+ deliberately absent: that is the part that would not survive a host swap.
7
+ */
8
+ /** An address, not a channel. Narrower than `Messaging.recipient` on purpose —
9
+ a push token is not a claim anyone makes, so the wrong subject is not
10
+ constructible. */
11
+ type contact =
12
+ | ByEmail(Reventless.Email.t)
13
+ | ByPhone(Reventless.Phone.t)
14
+
15
+ /** Which channels an address is reachable over. One-to-many, which is why
16
+ address and channel stay separate types; intersected at runtime with the
17
+ channels the deployment's provider publishes. */
18
+ let channelsFor = (contact: contact): array<Reventless.Messaging.channel> =>
19
+ switch contact {
20
+ | ByEmail(_) => [Email]
21
+ | ByPhone(_) => [Sms]
22
+ }
23
+
24
+ /** Total, because being addressable is what makes something a subject. */
25
+ let toRecipient = (contact: contact): Reventless.Messaging.recipient =>
26
+ switch contact {
27
+ | ByEmail(email) => ToEmail(email)
28
+ | ByPhone(phone) => ToSms(phone)
29
+ }
30
+
31
+ /** Why the proof is being asked for. The same machinery serves a registration, a
32
+ contact change, an invitation and a recovery; without this the next four
33
+ consumers each rebuild it. */
34
+ type purpose =
35
+ | Registration
36
+ | ContactChange
37
+ | Invitation
38
+ | Recovery
39
+ | Custom(string)
40
+
41
+ type subject = {contact: contact, purpose: purpose}
42
+
43
+ /** How a challenge ended. `Superseded` is the address changing while it was
44
+ open — a fact about the host, not about the person. */
45
+ type outcome =
46
+ | Verified
47
+ | Expired
48
+ | Superseded
49
+ | Abandoned
50
+
51
+ /** What a secret is drawn from — the capability's own vocabulary rather than a
52
+ second copy of it. A link is followed and a code is typed off a screen, so
53
+ the two cannot share an alphabet, and the source that draws them is the one
54
+ place that should say which exist. */
55
+ type alphabet = Reventless.Secrets.alphabet
56
+
57
+ /** Keyed by channel — not globally, and not by contact. A 32-character link with
58
+ a 24-hour window is right for email and unusable over SMS, where a human
59
+ retypes the proof. Keying by contact would look identical today and quietly
60
+ hard-code one channel per address. */
61
+ type policy = {
62
+ tokenTtl: Reventless.Duration.t,
63
+ maxAttempts: int,
64
+ resendCooldown: Reventless.Duration.t,
65
+ proofLength: int,
66
+ proofAlphabet: alphabet,
67
+ }
68
+
69
+ /** A day to act on, and a long secret because nobody types it. */
70
+ let emailPolicy: policy = {
71
+ tokenTtl: 86400,
72
+ maxAttempts: 5,
73
+ resendCooldown: 60,
74
+ proofLength: 32,
75
+ proofAlphabet: UrlSafe,
76
+ }
77
+
78
+ /** Short-lived and short, because it is read off one screen and typed into
79
+ another — and fewer attempts, because six digits is a guessable space. */
80
+ let smsPolicy: policy = {
81
+ tokenTtl: 600,
82
+ maxAttempts: 3,
83
+ resendCooldown: 60,
84
+ proofLength: 6,
85
+ proofAlphabet: Digits,
86
+ }
87
+
88
+ /**
89
+ `None` for `Push`, and that is structural rather than unfinished.
90
+
91
+ A push token is issued to one app install and relayed over an already
92
+ authenticated session, so a challenge echoed back proves only what registering
93
+ the token proved. Push presupposes the session it would be bootstrapping. An
94
+ arm that merely had no policy yet would invite someone to supply one.
95
+ */
96
+ let policyFor = (channel: Reventless.Messaging.channel): option<policy> =>
97
+ switch channel {
98
+ | Email => Some(emailPolicy)
99
+ | Sms => Some(smsPolicy)
100
+ | Push => None
101
+ }
@@ -0,0 +1,60 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function channelsFor(contact) {
5
+ if (contact.TAG === "ByEmail") {
6
+ return ["Email"];
7
+ } else {
8
+ return ["Sms"];
9
+ }
10
+ }
11
+
12
+ function toRecipient(contact) {
13
+ if (contact.TAG === "ByEmail") {
14
+ return {
15
+ TAG: "ToEmail",
16
+ _0: contact._0
17
+ };
18
+ } else {
19
+ return {
20
+ TAG: "ToSms",
21
+ _0: contact._0
22
+ };
23
+ }
24
+ }
25
+
26
+ let emailPolicy = {
27
+ tokenTtl: 86400,
28
+ maxAttempts: 5,
29
+ resendCooldown: 60,
30
+ proofLength: 32,
31
+ proofAlphabet: "UrlSafe"
32
+ };
33
+
34
+ let smsPolicy = {
35
+ tokenTtl: 600,
36
+ maxAttempts: 3,
37
+ resendCooldown: 60,
38
+ proofLength: 6,
39
+ proofAlphabet: "Digits"
40
+ };
41
+
42
+ function policyFor(channel) {
43
+ switch (channel) {
44
+ case "Email" :
45
+ return emailPolicy;
46
+ case "Sms" :
47
+ return smsPolicy;
48
+ case "Push" :
49
+ return;
50
+ }
51
+ }
52
+
53
+ export {
54
+ channelsFor,
55
+ toRecipient,
56
+ emailPolicy,
57
+ smsPolicy,
58
+ policyFor,
59
+ }
60
+ /* No side effect */
@@ -0,0 +1,176 @@
1
+ /**
2
+ The graft's decision rules, compiled once and called by every host: a proof for
3
+ an address the entity has moved off settles nothing, a settled challenge settles
4
+ once, and expiry is decided when the proof arrives rather than on a timer.
5
+
6
+ The host keeps its own state and builds a `verification` per call from fields it
7
+ already holds. The challenge is *not* host state — an aggregate is snapshotted,
8
+ and a secret in a snapshot is a secret in every backup of one.
9
+ */
10
+ /** The two fields the rules read, as the host holds them. Its invariant, which
11
+ every host arm must preserve: `verifiedAddress` is `None` or equal to
12
+ `contact`. */
13
+ type verification = {
14
+ contact: ContactVerification.contact,
15
+ verifiedAddress: option<ContactVerification.contact>,
16
+ }
17
+
18
+ /** One challenge, as the trait's own ledger holds it. The secret itself is never
19
+ here — only whether a presented one matched, which the caller decides. */
20
+ type challenge = {
21
+ contact: ContactVerification.contact,
22
+ purpose: ContactVerification.purpose,
23
+ issuedAt: Reventless.DateTime.t,
24
+ attempts: int,
25
+ settled: bool,
26
+ }
27
+
28
+ /** What the host still owes a proof for, or `None` — the stand-down. A host
29
+ whose current address is already the verified one spends no capability call,
30
+ the same arm as a map picker having already supplied the point. */
31
+ let contactToVerify = (v: verification): option<ContactVerification.contact> =>
32
+ v.verifiedAddress == Some(v.contact) ? None : Some(v.contact)
33
+
34
+ let elapsedSeconds = (~from: Reventless.DateTime.t, ~to_: Reventless.DateTime.t): int =>
35
+ Int.fromFloat((Reventless.DateTime.millis(to_) -. Reventless.DateTime.millis(from)) /. 1000.)
36
+
37
+ /** Expiry is decided when the proof arrives, not by a scheduled fire. No
38
+ process-manager state to keep, and testable without controlling a clock. */
39
+ let hasExpired = (c: challenge, ~now, ~policy: ContactVerification.policy): bool =>
40
+ elapsedSeconds(~from=c.issuedAt, ~to_=now) >= policy.tokenTtl
41
+
42
+ /** Why a presented proof settles nothing. Named rather than one blank refusal,
43
+ because the host tells the subject which of these happened and tells an
44
+ anonymous caller nothing — that distinction is about who is asking, not about
45
+ whether the reason is known here. */
46
+ type refusal =
47
+ /** 🚨 The security control. The host's address changed while this challenge
48
+ was open, so the proof names an address its presenter may no longer hold.
49
+ Settling it would be "change the address, then present the old one's link",
50
+ which is an account takeover. */
51
+ | AddressSuperseded
52
+ | ChallengeExpired
53
+ | AlreadySettled
54
+ | AttemptsExhausted
55
+ | ProofMismatch
56
+
57
+ type proofVerdict =
58
+ | Settle
59
+ | Refuse(refusal)
60
+
61
+ /**
62
+ The order of these checks is a security decision, not a style one.
63
+
64
+ Supersession is tested before anything else, so a stale challenge is refused on
65
+ the fact that makes it dangerous rather than on whichever of its other
66
+ properties happened to lapse first — a superseded challenge that had also
67
+ expired must not be reported as merely expired. The proof itself is compared
68
+ last, so none of the structural refusals depend on the secret.
69
+
70
+ `hostContact` is an option because the two flows this competency serves differ
71
+ in whether there *is* a host. Changing an address has one, and supersession is
72
+ checkable. Registering has none — the subject the address will belong to does
73
+ not exist yet — so `None` means "nothing to be superseded by", not "skip the
74
+ check". A ledger that simply does not track the host also passes `None`, and
75
+ relies on the host's own write-back guard, which is why that one is not
76
+ optional.
77
+ */
78
+ let onProofPresented = (
79
+ c: challenge,
80
+ ~hostContact: option<ContactVerification.contact>,
81
+ ~proofMatches: bool,
82
+ ~now: Reventless.DateTime.t,
83
+ ~policy: ContactVerification.policy,
84
+ ): proofVerdict =>
85
+ if hostContact->Option.mapOr(false, held => c.contact != held) {
86
+ Refuse(AddressSuperseded)
87
+ } else if c.settled {
88
+ Refuse(AlreadySettled)
89
+ } else if hasExpired(c, ~now, ~policy) {
90
+ Refuse(ChallengeExpired)
91
+ } else if c.attempts >= policy.maxAttempts {
92
+ Refuse(AttemptsExhausted)
93
+ } else if !proofMatches {
94
+ Refuse(ProofMismatch)
95
+ } else {
96
+ Settle
97
+ }
98
+
99
+ type verdict = Append | Ignore
100
+
101
+ /**
102
+ The write-back rule, applied where the host records the outcome.
103
+
104
+ Two guards, and the first is the same security control `onProofPresented`
105
+ applies to the challenge — deliberately duplicated rather than trusted once. The
106
+ ledger refusing to settle and the host refusing to record are different
107
+ failures: a verdict can reach the host by redelivery, by a replayed command, or
108
+ from a ledger that is simply wrong, and only this one is inside the host's own
109
+ consistency boundary. The second is the ordinary redelivery no-op.
110
+ */
111
+ let onVerifiedReport = (v: verification, ~contact: ContactVerification.contact): verdict =>
112
+ if contact != v.contact {
113
+ Ignore
114
+ } else if v.verifiedAddress == Some(contact) {
115
+ Ignore
116
+ } else {
117
+ Append
118
+ }
119
+
120
+ type issuance =
121
+ /** Mint a secret and send it. */
122
+ | Issue
123
+ /** An open challenge already covers this address, so a redelivered request
124
+ mints nothing and sends nothing. Commands arrive at least once, and a slice
125
+ re-publishes until its work clears. */
126
+ | AlreadyOpen
127
+
128
+ /**
129
+ Whether a request to challenge an address produces a new secret.
130
+
131
+ Decided against the ledger's own state and nothing else. The stand-down —
132
+ whether an address is owed a proof at all — is `contactToVerify`, and it belongs
133
+ to whoever reads the host, because a ledger has no host to read. Folding the two
134
+ together read naturally with one host in view and stopped compiling the moment a
135
+ second flow had none.
136
+ */
137
+ let onIssueRequested = (
138
+ ~contact: ContactVerification.contact,
139
+ ~existing: option<challenge>,
140
+ ~now: Reventless.DateTime.t,
141
+ ~policy: ContactVerification.policy,
142
+ ): issuance =>
143
+ switch existing {
144
+ | Some(c) if c.contact == contact && !c.settled && !hasExpired(c, ~now, ~policy) => AlreadyOpen
145
+ | _ => Issue
146
+ }
147
+
148
+ type resend =
149
+ | Send
150
+ /** Asked for again too soon, carrying what is left of the cooldown so the host
151
+ can say when rather than only no. */
152
+ | TooSoon(Reventless.Duration.t)
153
+ /** Nothing open to send again. */
154
+ | NothingOpen
155
+
156
+ /**
157
+ A deliberate "send it again". The same secret goes out rather than a fresh one,
158
+ so a link already sitting in a mailbox keeps working — minting a new one would
159
+ invalidate the message the person is looking at while they read it.
160
+ */
161
+ let onResendRequested = (
162
+ ~contact: ContactVerification.contact,
163
+ ~existing: option<challenge>,
164
+ ~now: Reventless.DateTime.t,
165
+ ~policy: ContactVerification.policy,
166
+ ): resend =>
167
+ switch existing {
168
+ | None => NothingOpen
169
+ | Some(c) =>
170
+ if c.contact != contact || c.settled || hasExpired(c, ~now, ~policy) {
171
+ NothingOpen
172
+ } else {
173
+ let waited = elapsedSeconds(~from=c.issuedAt, ~to_=now)
174
+ waited >= policy.resendCooldown ? Send : TooSoon(policy.resendCooldown - waited)
175
+ }
176
+ }
@@ -0,0 +1,97 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
+ import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/semantic/DateTime.res.mjs";
6
+
7
+ function contactToVerify(v) {
8
+ if (Primitive_object.equal(v.verifiedAddress, v.contact)) {
9
+ return;
10
+ } else {
11
+ return v.contact;
12
+ }
13
+ }
14
+
15
+ function elapsedSeconds(from, to_) {
16
+ return (DateTime$Reventless.millis(to_) - DateTime$Reventless.millis(from)) / 1000 | 0;
17
+ }
18
+
19
+ function hasExpired(c, now, policy) {
20
+ return elapsedSeconds(c.issuedAt, now) >= policy.tokenTtl;
21
+ }
22
+
23
+ function onProofPresented(c, hostContact, proofMatches, now, policy) {
24
+ if (Stdlib_Option.mapOr(hostContact, false, held => Primitive_object.notequal(c.contact, held))) {
25
+ return {
26
+ TAG: "Refuse",
27
+ _0: "AddressSuperseded"
28
+ };
29
+ } else if (c.settled) {
30
+ return {
31
+ TAG: "Refuse",
32
+ _0: "AlreadySettled"
33
+ };
34
+ } else if (hasExpired(c, now, policy)) {
35
+ return {
36
+ TAG: "Refuse",
37
+ _0: "ChallengeExpired"
38
+ };
39
+ } else if (c.attempts >= policy.maxAttempts) {
40
+ return {
41
+ TAG: "Refuse",
42
+ _0: "AttemptsExhausted"
43
+ };
44
+ } else if (proofMatches) {
45
+ return "Settle";
46
+ } else {
47
+ return {
48
+ TAG: "Refuse",
49
+ _0: "ProofMismatch"
50
+ };
51
+ }
52
+ }
53
+
54
+ function onVerifiedReport(v, contact) {
55
+ if (Primitive_object.notequal(contact, v.contact) || Primitive_object.equal(v.verifiedAddress, contact)) {
56
+ return "Ignore";
57
+ } else {
58
+ return "Append";
59
+ }
60
+ }
61
+
62
+ function onIssueRequested(contact, existing, now, policy) {
63
+ if (existing !== undefined && Primitive_object.equal(existing.contact, contact) && !existing.settled && !hasExpired(existing, now, policy)) {
64
+ return "AlreadyOpen";
65
+ } else {
66
+ return "Issue";
67
+ }
68
+ }
69
+
70
+ function onResendRequested(contact, existing, now, policy) {
71
+ if (existing === undefined) {
72
+ return "NothingOpen";
73
+ }
74
+ if (Primitive_object.notequal(existing.contact, contact) || existing.settled || hasExpired(existing, now, policy)) {
75
+ return "NothingOpen";
76
+ }
77
+ let waited = elapsedSeconds(existing.issuedAt, now);
78
+ if (waited >= policy.resendCooldown) {
79
+ return "Send";
80
+ } else {
81
+ return {
82
+ TAG: "TooSoon",
83
+ _0: policy.resendCooldown - waited | 0
84
+ };
85
+ }
86
+ }
87
+
88
+ export {
89
+ contactToVerify,
90
+ elapsedSeconds,
91
+ hasExpired,
92
+ onProofPresented,
93
+ onVerifiedReport,
94
+ onIssueRequested,
95
+ onResendRequested,
96
+ }
97
+ /* DateTime-Reventless Not a pure module */
@@ -44,6 +44,15 @@ type command =
44
44
  // Spliced from the trait, `@noApi` and all: the exclusion is recorded on each
45
45
  // member, so it survives the spread and neither is published.
46
46
  | ...TraitAddressGeocoding.AddressGeocoding.reportCommands
47
+ // The verification graft's report back. `@noApi` for the same reason
48
+ // `SetLocation` is: a client never asserts that an address was proven — it
49
+ // presents a secret to the slice that issued one, and the slice reports the
50
+ // verdict here. A public command would let a caller mark any address verified,
51
+ // which is the whole property being protected.
52
+ //
53
+ // Carries the address it is a verdict about, so a verdict for an address the
54
+ // customer has since changed can be dropped rather than applied.
55
+ | @noApi MarkEmailVerified({email: string})
47
56
  | Deactivate
48
57
  // The way back. Deactivation withdraws a customer from ordinary use; it does
49
58
  // not erase them, so restoring one needs no payload — the profile is still in
@@ -60,6 +69,10 @@ type event =
60
69
  // They are matched unqualified below and in the projections, and sury splices
61
70
  // the schema flat, so the wire format is what hand-written arms produced.
62
71
  | ...TraitAddressGeocoding.AddressGeocoding.events
72
+ // Names the address that was proven, not just that one was. `EmailUpdated`
73
+ // drops it again, which is what keeps "is this address proven?" decidable
74
+ // instead of assumed.
75
+ | EmailVerified({email: string})
63
76
  | Deactivated
64
77
  | Reactivated
65
78
 
@@ -109,6 +122,10 @@ let commandTransition = (command: command): Reventless.Transition.t<lifecycleSta
109
122
  // geocode landing after deactivation returns `Ok([])` rather than refusing, so
110
123
  // it does not park a TODO row in Failed forever.
111
124
  | SetLocation(_) | MarkAddressUnresolvable(_) => Unrestricted
125
+ // Legal in every state for the same reason: a proof settled while the customer
126
+ // was being deactivated returns `Ok([])` rather than refusing, so the slice
127
+ // that reported it does not retry forever against a state it cannot change.
128
+ | MarkEmailVerified(_) => Unrestricted
112
129
  | Deactivate => Moves([Customers.Active], Customers.Deactivated)
113
130
  // Works because the view's retirement IS a state of its lifecycle rather than
114
131
  // a boolean beside one, so the states a command names and the states a row can
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Sury from "sury";
4
+ import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
4
5
  import * as AddressGeocoding$TraitAddressGeocoding from "@reventlessdev/trait-address-geocoding/src/AddressGeocoding.res.mjs";
5
6
 
6
7
  let commandSchema = Sury.union([
@@ -13,6 +14,10 @@ let commandSchema = Sury.union([
13
14
  TAG: "UpdateEmail",
14
15
  email: s.m(Sury.string)
15
16
  })),
17
+ Sury.$schema(s => ({
18
+ TAG: "MarkEmailVerified",
19
+ email: s.m(Sury.string)
20
+ })),
16
21
  Sury.literal("Deactivate"),
17
22
  Sury.literal("Reactivate")
18
23
  ].concat(AddressGeocoding$TraitAddressGeocoding.addressCommandsSchema.type === "anyOf" ? AddressGeocoding$TraitAddressGeocoding.addressCommandsSchema.anyOf : [AddressGeocoding$TraitAddressGeocoding.addressCommandsSchema]).concat(AddressGeocoding$TraitAddressGeocoding.reportCommandsSchema.type === "anyOf" ? AddressGeocoding$TraitAddressGeocoding.reportCommandsSchema.anyOf : [AddressGeocoding$TraitAddressGeocoding.reportCommandsSchema]));
@@ -27,6 +32,10 @@ let eventSchema = Sury.union([
27
32
  TAG: "EmailUpdated",
28
33
  email: s.m(Sury.string)
29
34
  })),
35
+ Sury.$schema(s => ({
36
+ TAG: "EmailVerified",
37
+ email: s.m(Sury.string)
38
+ })),
30
39
  Sury.literal("Deactivated"),
31
40
  Sury.literal("Reactivated")
32
41
  ].concat(AddressGeocoding$TraitAddressGeocoding.eventsSchema.type === "anyOf" ? AddressGeocoding$TraitAddressGeocoding.eventsSchema.anyOf : [AddressGeocoding$TraitAddressGeocoding.eventsSchema]));
@@ -61,6 +70,7 @@ function commandTransition(command) {
61
70
  };
62
71
  case "SetLocation" :
63
72
  case "MarkAddressUnresolvable" :
73
+ case "MarkEmailVerified" :
64
74
  return "Unrestricted";
65
75
  default:
66
76
  return {
@@ -72,6 +82,8 @@ function commandTransition(command) {
72
82
 
73
83
  let traits = [AddressGeocoding$TraitAddressGeocoding.declaration];
74
84
 
85
+ let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, ["MarkEmailVerified"]);
86
+
75
87
  function commandAuthorization(param) {
76
88
  return "AllowAuthenticated";
77
89
  }
@@ -85,11 +97,11 @@ let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/Customer/Aggrega
85
97
  export {
86
98
  name,
87
99
  Id,
88
- commandSchema,
89
100
  eventSchema,
90
101
  errorSchema,
91
102
  commandTransition,
92
103
  traits,
104
+ commandSchema$1 as commandSchema,
93
105
  moduleUrl,
94
106
  commandAuthorization,
95
107
  }