@reventlessdev/reventless-spec 3.0.0-alpha.133 → 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 +33 -0
- package/package.json +4 -3
- package/run-check-lifecycle.mjs +2 -1
- package/run-prepare-accounts.mjs +3 -0
- package/src/AnsiStyle.res +0 -1
- package/src/LogPrefix.res +4 -13
- package/src/components/AutomationSlice.res +1 -5
- package/src/components/CapabilityManifest.res +6 -3
- package/src/components/CapabilityManifest.res.mjs +13 -2
- package/src/components/DcbDecode.res +6 -2
- package/src/components/DcbScopeInference.res +16 -12
- package/src/components/DcbTag.res +74 -46
- package/src/components/DcbValidation.res +114 -89
- package/src/components/DisplayName.res +4 -2
- package/src/components/ExtensionPoint.res +0 -1
- package/src/components/FieldDefault.res +2 -4
- package/src/components/InboundTranslationSlice.res +0 -2
- package/src/components/OutboundTranslationSlice.res +10 -8
- package/src/components/Owner.res +4 -4
- package/src/components/Plugin.res +10 -4
- package/src/components/ReadModel.res +0 -1
- package/src/components/Reference.res +2 -8
- package/src/components/Sensitive.res +4 -4
- package/src/components/StateAnnotations.res +4 -2
- package/src/components/StateChangeSlice.res +0 -2
- package/src/components/StateViewSlice.res +0 -2
- package/src/components/TaggedUnion.res +1 -2
- package/src/components/Task.res +0 -1
- package/src/components/TraitCertificate.res +2 -2
- package/src/components/TraitManifest.res +1 -1
- package/src/generator/CertifyTrait.res +2 -4
- package/src/generator/Codegen.res +77 -68
- package/src/generator/Discovery.res +29 -12
- package/src/generator/GraftTrait.res +17 -9
- package/src/generator/Pairing.res +39 -13
- package/src/generator/PlatformCodegen.res +14 -11
- package/src/generator/PlatformCodegen.res.mjs +5 -0
- package/src/generator/PlatformManifests.res +1 -3
- package/src/generator/PluginGenerator.res +0 -1
- package/src/generator/TraitManifestCli.res +7 -7
- package/src/lifecycle/CheckLifecycleModel.res +206 -148
- package/src/lifecycle/CheckLifecycleModel.res.mjs +23 -5
- package/src/semantic/Bytes.res +0 -1
- package/src/semantic/CalendarDate.res +2 -2
- package/src/semantic/Capabilities.res +21 -6
- package/src/semantic/Capabilities.res.mjs +18 -12
- package/src/semantic/CapabilityNeed.res +16 -5
- package/src/semantic/CapabilityNeed.res.mjs +9 -4
- package/src/semantic/CaptionedImage.res +0 -1
- package/src/semantic/Color.res +0 -1
- package/src/semantic/Currency.res +189 -170
- package/src/semantic/DateRange.res +3 -4
- package/src/semantic/DateTime.res +5 -5
- package/src/semantic/Duration.res +2 -2
- package/src/semantic/Email.res +0 -1
- package/src/semantic/FileRef.res +2 -2
- package/src/semantic/GeoPoint.res +17 -21
- package/src/semantic/Geocoding.res +8 -9
- package/src/semantic/Geolocation.res +6 -9
- package/src/semantic/IdentityProvider.res +127 -0
- package/src/semantic/IdentityProvider.res.mjs +65 -0
- package/src/semantic/ImageRef.res +2 -2
- package/src/semantic/MemberRef.res +0 -1
- package/src/semantic/Messaging.res +141 -21
- package/src/semantic/Messaging.res.mjs +51 -8
- package/src/semantic/Money.res +23 -20
- package/src/semantic/Offload.res +6 -4
- package/src/semantic/Percent.res +3 -3
- package/src/semantic/Phone.res +0 -1
- package/src/semantic/RowImage.res +4 -3
- package/src/semantic/Secrets.res +76 -0
- package/src/semantic/Secrets.res.mjs +49 -0
- package/src/semantic/Semantic.res +8 -11
- package/src/semantic/StorageRef.res +17 -17
- package/src/semantic/Template.res +5 -7
- package/src/semantic/UploadableFile.res +0 -1
- package/src/semantic/UploadableImage.res +0 -1
- package/src/semantic/Url.res +3 -3
- 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/Authorization.res +1 -2
- package/src/types/Handler.res +0 -1
- package/src/types/Identity.res +1 -3
- package/src/types/Lifecycle.res +7 -6
- package/src/types/Message.res +6 -4
- package/src/types/OwnerScope.res +28 -3
- 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/types/Projection.res +9 -2
- package/src/types/StoredEvent.res +2 -4
- package/src/types/Transition.res +8 -4
- package/src/util/Util_Password.res +60 -0
- package/src/util/Util_Password.res.mjs +53 -0
- package/src/util/Util_Sury.res +1 -4
package/src/semantic/FileRef.res
CHANGED
|
@@ -14,7 +14,6 @@ the pair exists and what the grammar refuses.
|
|
|
14
14
|
})
|
|
15
15
|
```
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
17
|
/** Transparent `string`; see `Email.t`. */
|
|
19
18
|
type t = string
|
|
20
19
|
|
|
@@ -25,4 +24,5 @@ let fromString = (raw: string): result<t, string> => Media_Ref.check(~what="file
|
|
|
25
24
|
|
|
26
25
|
/** The sury schema for a file-reference field.
|
|
27
26
|
Use with `@s.matches(Reventless.FileRef.schema)`. */
|
|
28
|
-
let schema: S.t<t> =
|
|
27
|
+
let schema: S.t<t> =
|
|
28
|
+
S.string->Semantic.refined(~id=Semantic.Id.fileRef, ~check=fromString)
|
|
@@ -64,7 +64,6 @@ type has, and it is the one most existing coordinate fields are on. It costs a
|
|
|
64
64
|
log something only if it *collapses* two flattened scalar fields back into one,
|
|
65
65
|
which rewrites that shape.
|
|
66
66
|
*/
|
|
67
|
-
|
|
68
67
|
/**
|
|
69
68
|
Validate a latitude, saying why when it is out of range.
|
|
70
69
|
|
|
@@ -76,8 +75,9 @@ let validateLat = (raw: float): result<float, string> =>
|
|
|
76
75
|
Error(`a latitude must be a finite number of degrees, got ${Float.toString(raw)}`)
|
|
77
76
|
} else if raw < -90.0 || raw > 90.0 {
|
|
78
77
|
Error(
|
|
79
|
-
`a latitude runs from -90 to 90 degrees, got ${Float.toString(
|
|
80
|
-
|
|
78
|
+
`a latitude runs from -90 to 90 degrees, got ${Float.toString(
|
|
79
|
+
raw,
|
|
80
|
+
)}. ` ++ `A value beyond ±90 is usually a longitude in the latitude's place.`,
|
|
81
81
|
)
|
|
82
82
|
} else {
|
|
83
83
|
Ok(raw)
|
|
@@ -99,25 +99,20 @@ let validateLng = (raw: float): result<float, string> =>
|
|
|
99
99
|
11-alpha miscompiles a refinement wrapping a *record* schema. Refining the
|
|
100
100
|
field is both the honest placement and the one that works. */
|
|
101
101
|
let latSchema: S.t<float> =
|
|
102
|
-
S.float->S.refine(
|
|
103
|
-
raw
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
~error="expected a latitude in -90…90",
|
|
109
|
-
)
|
|
102
|
+
S.float->S.refine(raw =>
|
|
103
|
+
switch validateLat(raw) {
|
|
104
|
+
| Ok(_) => true
|
|
105
|
+
| Error(_) => false
|
|
106
|
+
}
|
|
107
|
+
, ~error="expected a latitude in -90…90")
|
|
110
108
|
|
|
111
109
|
/** The longitude's own schema, for the same reason. */
|
|
112
|
-
let lngSchema: S.t<float> =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
},
|
|
119
|
-
~error="expected a longitude in -180…180",
|
|
120
|
-
)
|
|
110
|
+
let lngSchema: S.t<float> = S.float->S.refine(raw =>
|
|
111
|
+
switch validateLng(raw) {
|
|
112
|
+
| Ok(_) => true
|
|
113
|
+
| Error(_) => false
|
|
114
|
+
}
|
|
115
|
+
, ~error="expected a longitude in -180…180")
|
|
121
116
|
|
|
122
117
|
@schema
|
|
123
118
|
type t = {
|
|
@@ -131,7 +126,8 @@ type t = {
|
|
|
131
126
|
|
|
132
127
|
Shadows the schema sury-ppx derived from the type above: the derived one is
|
|
133
128
|
the shape, and this adds the marker the shape cannot carry. */
|
|
134
|
-
let schema: S.t<t> =
|
|
129
|
+
let schema: S.t<t> =
|
|
130
|
+
schema->Semantic.mark(~id=Semantic.Id.geoPoint)
|
|
135
131
|
|
|
136
132
|
/** Build a validated point. Both coordinates are checked, and the message names
|
|
137
133
|
which one is wrong — the common mistake is a swapped pair, where the
|
|
@@ -5,7 +5,6 @@ The transport is provider-specific and lives with its provider. What is here is
|
|
|
5
5
|
provider-neutral: the shape of an answer, the two ways a lookup fails, and the
|
|
6
6
|
confidence rule — decided once, so no transport invents its own.
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
8
|
/** One candidate a geocoder returned. */
|
|
10
9
|
type candidate = {
|
|
11
10
|
/** The provider's canonical rendering of the address it matched. */
|
|
@@ -19,10 +18,10 @@ type candidate = {
|
|
|
19
18
|
/** Why a lookup produced no usable point. Two constructors because the retry
|
|
20
19
|
decision turns on the distinction: an outage must not become a verdict. */
|
|
21
20
|
type failure =
|
|
22
|
-
|
|
23
|
-
Unavailable(string)
|
|
24
|
-
|
|
25
|
-
NoMatch
|
|
21
|
+
/** The provider could not be reached, or refused the call. Retry. */
|
|
22
|
+
| Unavailable(string)
|
|
23
|
+
/** The provider answered, and had nothing for this text. Do not retry. */
|
|
24
|
+
| NoMatch
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
27
|
The port a caller reaches a geocoder through, so swapping the implementation is a
|
|
@@ -51,11 +50,11 @@ let defaultAmbiguityMargin = 0.01
|
|
|
51
50
|
type assessment =
|
|
52
51
|
| Confident(candidate)
|
|
53
52
|
| NoCandidates
|
|
54
|
-
|
|
55
|
-
Unscored(candidate)
|
|
53
|
+
/** Unscored is not a low score, and must not read as a high one. */
|
|
54
|
+
| Unscored(candidate)
|
|
56
55
|
| LowRelevance({top: candidate, score: float, floor: float})
|
|
57
|
-
|
|
58
|
-
Ambiguous({top: candidate, runnerUp: candidate, margin: float})
|
|
56
|
+
/** Several matches about equally well. */
|
|
57
|
+
| Ambiguous({top: candidate, runnerUp: candidate, margin: float})
|
|
59
58
|
|
|
60
59
|
/** The confidence rule, stated once. Everything else here derives from it. */
|
|
61
60
|
let assess = (
|
|
@@ -5,14 +5,13 @@ Three arms rather than `option<GeoPoint.t>`, whose `None` means both "has not ru
|
|
|
5
5
|
and "ran and failed". Emitted as a GraphQL union (see `Reventless.TaggedUnion`).
|
|
6
6
|
Replacing a point/status/note trio with it is wire-breaking.
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
8
|
@schema
|
|
10
9
|
type t =
|
|
11
|
-
|
|
12
|
-
Pending({requestedFor: string})
|
|
10
|
+
/** `requestedFor` is the address asked about, so a stale answer is detectable. */
|
|
11
|
+
| Pending({requestedFor: string})
|
|
13
12
|
| Located({point: GeoPoint.t})
|
|
14
|
-
|
|
15
|
-
Unresolvable({reason: string})
|
|
13
|
+
/** Answered, with nothing storable unattended. A verdict for a human. */
|
|
14
|
+
| Unresolvable({reason: string})
|
|
16
15
|
|
|
17
16
|
/** Adds the two markers the shape cannot carry: the semantic, and the union name
|
|
18
17
|
the SDL and the `__typename` stamp share. */
|
|
@@ -59,8 +58,7 @@ let ofSearch = (
|
|
|
59
58
|
| Unscored(top) =>
|
|
60
59
|
Some(
|
|
61
60
|
Unresolvable({
|
|
62
|
-
reason: `the geocoder returned "${top.label}" for "${requestedFor}" without scoring it, ` ++
|
|
63
|
-
`and an unscored answer cannot be accepted unattended`,
|
|
61
|
+
reason: `the geocoder returned "${top.label}" for "${requestedFor}" without scoring it, ` ++ `and an unscored answer cannot be accepted unattended`,
|
|
64
62
|
}),
|
|
65
63
|
)
|
|
66
64
|
| LowRelevance({top, score, floor}) =>
|
|
@@ -73,8 +71,7 @@ let ofSearch = (
|
|
|
73
71
|
| Ambiguous({top, runnerUp}) =>
|
|
74
72
|
Some(
|
|
75
73
|
Unresolvable({
|
|
76
|
-
reason: `"${requestedFor}" matched "${top.label}" and "${runnerUp.label}" ` ++
|
|
77
|
-
`about equally well`,
|
|
74
|
+
reason: `"${requestedFor}" matched "${top.label}" and "${runnerUp.label}" ` ++ `about equally well`,
|
|
78
75
|
}),
|
|
79
76
|
)
|
|
80
77
|
}
|
|
@@ -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 */
|
|
@@ -30,7 +30,6 @@ a field that declares no store.
|
|
|
30
30
|
})
|
|
31
31
|
```
|
|
32
32
|
*/
|
|
33
|
-
|
|
34
33
|
/** Transparent `string`; see `Email.t`. */
|
|
35
34
|
type t = string
|
|
36
35
|
|
|
@@ -48,4 +47,5 @@ let fromString = (raw: string): result<t, string> => Media_Ref.check(~what="imag
|
|
|
48
47
|
|
|
49
48
|
/** The sury schema for an image-reference field.
|
|
50
49
|
Use with `@s.matches(Reventless.ImageRef.schema)`. */
|
|
51
|
-
let schema: S.t<t> =
|
|
50
|
+
let schema: S.t<t> =
|
|
51
|
+
S.string->Semantic.refined(~id=Semantic.Id.imageRef, ~check=fromString)
|
|
@@ -55,7 +55,6 @@ declares one there today: a view that carried a scalar *and* the set it was
|
|
|
55
55
|
drawn from needed a marker saying the two were one thing, and a view whose
|
|
56
56
|
primary is simply the first member has no second field to reconcile.
|
|
57
57
|
*/
|
|
58
|
-
|
|
59
58
|
/** Transparent `string`, as every ref-shaped semantic here is: the marker
|
|
60
59
|
refines an existing field rather than replacing it, so nothing stored
|
|
61
60
|
changes when a field adopts it. */
|
|
@@ -11,23 +11,75 @@ A `(channel, address)` pair can be built wrong: `Sms` beside an email address
|
|
|
11
11
|
compiles and fails at the provider. `recipient` fuses them, so the wrong pair
|
|
12
12
|
does not exist, and the channel is read back off the value that carries it.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
A delivery route. The selector a recipient chooses per notification kind.
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
Three arms, and push stays one of them even though it is provisioned three ways.
|
|
18
|
+
Splitting it would offer a person a choice between notification services, which is
|
|
19
|
+
not a choice anyone has: an app knows its own token, and nobody prefers APNs. The
|
|
20
|
+
service-level discrimination lives on the address and on what a provider
|
|
21
|
+
publishes — see `pushService`.
|
|
22
|
+
*/
|
|
17
23
|
type channel =
|
|
18
24
|
| Email
|
|
19
25
|
| Sms
|
|
20
26
|
| Push
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
How a push service names one install.
|
|
30
|
+
|
|
31
|
+
A variant rather than a string because the shapes differ: two services issue an
|
|
32
|
+
opaque token, and Web Push issues a subscription whose encryption keys are part of
|
|
33
|
+
the address. This applies the rule the file opens with one level down — the wrong
|
|
34
|
+
(service, address) pair stops being constructible for the same reason the wrong
|
|
35
|
+
(channel, address) pair already is.
|
|
36
|
+
*/
|
|
37
|
+
type pushAddress =
|
|
38
|
+
| Apns({deviceToken: string})
|
|
39
|
+
| Fcm({registrationToken: string})
|
|
40
|
+
/** The endpoint is where it goes; the keys are how it is sealed. Requiring them
|
|
41
|
+
here keeps an unsendable subscription unconstructible. */
|
|
42
|
+
| WebPush({endpoint: string, p256dh: string, auth: string})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
Which service issued an address — the granularity push is provisioned at.
|
|
46
|
+
|
|
47
|
+
Email needs no such type: one sender provisioning reaches every mailbox, because
|
|
48
|
+
SMTP routes off the address. Push has no routing layer, so which service can reach
|
|
49
|
+
a device is fixed by which credential the deployment holds, and there is nothing
|
|
50
|
+
in the address to route on.
|
|
51
|
+
*/
|
|
52
|
+
type pushService =
|
|
53
|
+
/** Apple Push Notification service. Provisioned with a signing key, the team id
|
|
54
|
+
that issued it and the app's bundle id. */
|
|
55
|
+
| ApnsService
|
|
56
|
+
/** Firebase Cloud Messaging, Google's. Provisioned with one service-account
|
|
57
|
+
credential. */
|
|
58
|
+
| FcmService
|
|
59
|
+
/** Standardised rather than vendor-run: the endpoint the browser hands out is
|
|
60
|
+
itself the service, so one VAPID keypair provisions all of them. */
|
|
61
|
+
| WebPushService
|
|
62
|
+
|
|
22
63
|
/** An addressed recipient: the channel and the address it needs, inseparable.
|
|
23
|
-
|
|
24
|
-
refused where it is built rather than by the provider
|
|
64
|
+
Email and SMS carry the branded scalar for their channel, so an unparseable one
|
|
65
|
+
is refused where it is built rather than by the provider; push carries the
|
|
66
|
+
variant its services issue, and the same property holds by construction. */
|
|
25
67
|
type recipient =
|
|
26
68
|
| ToEmail(Email.t)
|
|
27
69
|
| ToSms(Phone.t)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ToPush(
|
|
70
|
+
/** How one install is reached. Not a bare string: the two token services and
|
|
71
|
+
Web Push do not share a shape, and Web Push is not opaque. */
|
|
72
|
+
| ToPush(pushAddress)
|
|
73
|
+
|
|
74
|
+
/** The service that issued a push address. What a provider's published list is
|
|
75
|
+
checked against, so a token is never handed to the service that cannot use
|
|
76
|
+
it. */
|
|
77
|
+
let serviceOf = (address: pushAddress): pushService =>
|
|
78
|
+
switch address {
|
|
79
|
+
| Apns(_) => ApnsService
|
|
80
|
+
| Fcm(_) => FcmService
|
|
81
|
+
| WebPush(_) => WebPushService
|
|
82
|
+
}
|
|
31
83
|
|
|
32
84
|
/** The channel a recipient is addressed on. */
|
|
33
85
|
let channelOf = (recipient: recipient): channel =>
|
|
@@ -45,6 +97,15 @@ let channelToString = (channel: channel): string =>
|
|
|
45
97
|
| Push => "Push"
|
|
46
98
|
}
|
|
47
99
|
|
|
100
|
+
/** The service's name as its own documentation spells it, because that is the
|
|
101
|
+
word a support conversation about a missing notification is conducted with. */
|
|
102
|
+
let pushServiceToString = (service: pushService): string =>
|
|
103
|
+
switch service {
|
|
104
|
+
| ApnsService => "APNs"
|
|
105
|
+
| FcmService => "FCM"
|
|
106
|
+
| WebPushService => "Web Push"
|
|
107
|
+
}
|
|
108
|
+
|
|
48
109
|
/**
|
|
49
110
|
The `From:` header a deployment's email sender presents as: the bare address, or
|
|
50
111
|
a display name in front of it.
|
|
@@ -87,20 +148,28 @@ type receipt = {ref: string}
|
|
|
87
148
|
/**
|
|
88
149
|
Why a send produced no receipt.
|
|
89
150
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
151
|
+
The retry decision turns on the distinction, and getting it wrong is expensive in
|
|
152
|
+
both directions: retrying a refused address burns the budget on an outcome that
|
|
153
|
+
will not change, and abandoning a transient outage writes off a message that would
|
|
154
|
+
have gone.
|
|
155
|
+
|
|
156
|
+
Push needs its own refusal because it is provisioned per service. Once a
|
|
157
|
+
deployment can provision *some* push, `UnsupportedChannel(Push)` would have to
|
|
158
|
+
mean both "no push at all" and "not that service", and the two read the same to
|
|
159
|
+
someone debugging a half-provisioned deployment.
|
|
94
160
|
*/
|
|
95
161
|
type failure =
|
|
96
|
-
|
|
97
|
-
Unavailable(string)
|
|
98
|
-
|
|
162
|
+
/** The provider could not be reached, or refused the call. Retry. */
|
|
163
|
+
| Unavailable(string)
|
|
164
|
+
/** This deployment provisions nothing for this channel. Do not retry — no
|
|
99
165
|
number of attempts provisions one. */
|
|
100
|
-
UnsupportedChannel(channel)
|
|
101
|
-
|
|
166
|
+
| UnsupportedChannel(channel)
|
|
167
|
+
/** This deployment provisions push, but not the service that issued this
|
|
168
|
+
address. Do not retry — the device is reachable only through its own. */
|
|
169
|
+
| UnsupportedPushService(pushService)
|
|
170
|
+
/** The provider answered and will not take this message: an address it
|
|
102
171
|
rejects, a recipient it suppresses. Do not retry. */
|
|
103
|
-
Refused(string)
|
|
172
|
+
| Refused(string)
|
|
104
173
|
|
|
105
174
|
/** The retry rule, stated once. Everything that sweeps a failed send derives
|
|
106
175
|
from it rather than re-reading the constructors. */
|
|
@@ -108,6 +177,7 @@ let retriable = (failure: failure): bool =>
|
|
|
108
177
|
switch failure {
|
|
109
178
|
| Unavailable(_) => true
|
|
110
179
|
| UnsupportedChannel(_)
|
|
180
|
+
| UnsupportedPushService(_)
|
|
111
181
|
| Refused(_) => false
|
|
112
182
|
}
|
|
113
183
|
|
|
@@ -118,6 +188,8 @@ let failureReason = (failure: failure): string =>
|
|
|
118
188
|
| Unavailable(reason) => reason
|
|
119
189
|
| UnsupportedChannel(channel) =>
|
|
120
190
|
`this deployment provisions no ${channel->channelToString} channel`
|
|
191
|
+
| UnsupportedPushService(service) =>
|
|
192
|
+
`this deployment provisions push, but not ${service->pushServiceToString}`
|
|
121
193
|
| Refused(reason) => reason
|
|
122
194
|
}
|
|
123
195
|
|
|
@@ -138,14 +210,62 @@ because discovering a channel by failing on it costs a real message.
|
|
|
138
210
|
|
|
139
211
|
Empty means no channel at all — the shape `none` takes, and the one a deploy-time
|
|
140
212
|
gate exists to catch before it ships.
|
|
213
|
+
|
|
214
|
+
`pushServices` is the second answer, at the granularity push is actually
|
|
215
|
+
provisioned at. Both are published because the two granularities differ: a
|
|
216
|
+
preference centre reads `channels` because a person picks a channel, and `supports`
|
|
217
|
+
reads `pushServices` because a token is only reachable through its own service.
|
|
218
|
+
Build one through `makeProvider`, which derives the first from the second.
|
|
141
219
|
*/
|
|
142
220
|
type provider = {
|
|
143
221
|
channels: array<channel>,
|
|
222
|
+
pushServices: array<pushService>,
|
|
144
223
|
send: send,
|
|
145
224
|
}
|
|
146
225
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
226
|
+
/**
|
|
227
|
+
Build a provider whose two published answers cannot disagree.
|
|
228
|
+
|
|
229
|
+
`channels` is derived rather than given: `Push` appears exactly when a push service
|
|
230
|
+
is behind it, so no transport can publish a channel with nothing to reach it on.
|
|
231
|
+
The alternative is that invariant stated in a comment beside two independently
|
|
232
|
+
written fields, which is where invariants go to die.
|
|
233
|
+
|
|
234
|
+
`emailAndSms` carries the channels the channel itself settles reachability for. A
|
|
235
|
+
`Push` passed there is dropped — `pushServices` is the only thing that provisions
|
|
236
|
+
push — and the switch doing the dropping is exhaustive, so a fourth channel arrives
|
|
237
|
+
here as a compile error rather than as a silent omission.
|
|
238
|
+
*/
|
|
239
|
+
let makeProvider = (
|
|
240
|
+
~emailAndSms: array<channel>,
|
|
241
|
+
~pushServices: array<pushService>,
|
|
242
|
+
~send: send,
|
|
243
|
+
): provider => {
|
|
244
|
+
channels: emailAndSms
|
|
245
|
+
->Array.filter(channel =>
|
|
246
|
+
switch channel {
|
|
247
|
+
| Email
|
|
248
|
+
| Sms => true
|
|
249
|
+
| Push => false
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
->Array.concat(pushServices->Array.length == 0 ? [] : [Push]),
|
|
253
|
+
pushServices,
|
|
254
|
+
send,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
Whether this provider can attempt this recipient at all. The check a caller makes
|
|
259
|
+
before spending a send, and the same rule the provider applies internally, so the
|
|
260
|
+
two cannot disagree.
|
|
261
|
+
|
|
262
|
+
Push is checked against the issuing service, not against the channel. Answering off
|
|
263
|
+
`channels` would return `true` for an APNs token on a deployment that provisioned
|
|
264
|
+
FCM only — and inferring a channel by failing on it is exactly what publishing the
|
|
265
|
+
list exists to avoid, since a failed send costs a real message.
|
|
266
|
+
*/
|
|
150
267
|
let supports = (provider: provider, ~recipient: recipient): bool =>
|
|
151
|
-
|
|
268
|
+
switch recipient {
|
|
269
|
+
| ToPush(address) => provider.pushServices->Array.includes(address->serviceOf)
|
|
270
|
+
| recipient => provider.channels->Array.includes(recipient->channelOf)
|
|
271
|
+
}
|