@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.
- package/CHANGELOG.md +27 -0
- package/package.json +10 -10
- package/src/ContactVerification.res +101 -0
- package/src/ContactVerification.res.mjs +60 -0
- package/src/ContactVerification_Guards.res +176 -0
- package/src/ContactVerification_Guards.res.mjs +97 -0
- package/src/Customer/Aggregate/Customer.res +17 -0
- package/src/Customer/Aggregate/Customer.res.mjs +13 -1
- package/src/Customer/Aggregate/Customer_Behavior.res +53 -6
- package/src/Customer/Aggregate/Customer_Behavior.res.mjs +75 -11
- package/src/Customer/OutboundTranslation/VerifyCustomerEmail.res +44 -0
- package/src/Customer/OutboundTranslation/VerifyCustomerEmail.res.mjs +67 -0
- package/src/Customer/OutboundTranslation/VerifyCustomerEmail_Translation.res +79 -0
- package/src/Customer/OutboundTranslation/VerifyCustomerEmail_Translation.res.mjs +101 -0
- package/src/Customer/ReadModelStream/Customers.res +4 -0
- package/src/Customer/ReadModelStream/Customers.res.mjs +1 -0
- package/src/Customer/ReadModelStream/Customers_Projections.res +7 -1
- package/src/Customer/ReadModelStream/Customers_Projections.res.mjs +13 -0
- package/src/Customer/StateChange/EmailVerificationChallenges.res +73 -0
- package/src/Customer/StateChange/EmailVerificationChallenges.res.mjs +104 -0
- package/src/Customer/StateChange/EmailVerificationChallenges_Behavior.res +105 -0
- package/src/Customer/StateChange/EmailVerificationChallenges_Behavior.res.mjs +182 -0
- package/src/LifecycleModel.res +7 -0
- package/src/LifecycleModel.res.mjs +7 -0
- package/src/Order/StateChange/PlaceOrder.res +2 -2
- package/src/Order/StateChange/PlaceOrder.res.mjs +2 -2
- package/src/Order/StateChange/ShipOrder.res +1 -1
- package/src/Order/StateChange/ShipOrder.res.mjs +1 -1
- package/src/Plugin.res +27 -0
- package/src/Plugin.res.mjs +76 -8
- package/tests/ContactVerification_GuardsTest.res +231 -0
- package/tests/ContactVerification_GuardsTest.res.mjs +203 -0
- package/tests/Customer/Aggregate/Customer_GWT.res +60 -0
- package/tests/Customer/Aggregate/Customer_GWT.res.mjs +78 -0
- package/tests/Customer/OutboundTranslation/GeocodeCustomerAddress_GWT.res.mjs +5 -1
- package/tests/Customer/OutboundTranslation/VerifyCustomerEmail_GWT.res +144 -0
- package/tests/Customer/OutboundTranslation/VerifyCustomerEmail_GWT.res.mjs +220 -0
- package/tests/Customer/ReadModelStream/Customers_GWT.res +42 -0
- package/tests/Customer/ReadModelStream/Customers_GWT.res.mjs +52 -0
- package/tests/Customer/StateChange/EmailVerificationChallenges_GWT.res +166 -0
- package/tests/Customer/StateChange/EmailVerificationChallenges_GWT.res.mjs +195 -0
- 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.
|
|
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-
|
|
25
|
-
"@reventlessdev/
|
|
26
|
-
"@reventlessdev/
|
|
27
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
28
|
-
"@reventlessdev/
|
|
29
|
-
"@reventlessdev/reventless-local": "3.0.0-alpha.
|
|
30
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
31
|
-
"@reventlessdev/trait-
|
|
32
|
-
"@reventlessdev/trait-
|
|
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
|
}
|