@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/package.json +4 -3
  3. package/run-check-lifecycle.mjs +2 -1
  4. package/run-prepare-accounts.mjs +3 -0
  5. package/src/AnsiStyle.res +0 -1
  6. package/src/LogPrefix.res +4 -13
  7. package/src/components/AutomationSlice.res +1 -5
  8. package/src/components/CapabilityManifest.res +6 -3
  9. package/src/components/CapabilityManifest.res.mjs +13 -2
  10. package/src/components/DcbDecode.res +6 -2
  11. package/src/components/DcbScopeInference.res +16 -12
  12. package/src/components/DcbTag.res +74 -46
  13. package/src/components/DcbValidation.res +114 -89
  14. package/src/components/DisplayName.res +4 -2
  15. package/src/components/ExtensionPoint.res +0 -1
  16. package/src/components/FieldDefault.res +2 -4
  17. package/src/components/InboundTranslationSlice.res +0 -2
  18. package/src/components/OutboundTranslationSlice.res +10 -8
  19. package/src/components/Owner.res +4 -4
  20. package/src/components/Plugin.res +10 -4
  21. package/src/components/ReadModel.res +0 -1
  22. package/src/components/Reference.res +2 -8
  23. package/src/components/Sensitive.res +4 -4
  24. package/src/components/StateAnnotations.res +4 -2
  25. package/src/components/StateChangeSlice.res +0 -2
  26. package/src/components/StateViewSlice.res +0 -2
  27. package/src/components/TaggedUnion.res +1 -2
  28. package/src/components/Task.res +0 -1
  29. package/src/components/TraitCertificate.res +2 -2
  30. package/src/components/TraitManifest.res +1 -1
  31. package/src/generator/CertifyTrait.res +2 -4
  32. package/src/generator/Codegen.res +77 -68
  33. package/src/generator/Discovery.res +29 -12
  34. package/src/generator/GraftTrait.res +17 -9
  35. package/src/generator/Pairing.res +39 -13
  36. package/src/generator/PlatformCodegen.res +14 -11
  37. package/src/generator/PlatformCodegen.res.mjs +5 -0
  38. package/src/generator/PlatformManifests.res +1 -3
  39. package/src/generator/PluginGenerator.res +0 -1
  40. package/src/generator/TraitManifestCli.res +7 -7
  41. package/src/lifecycle/CheckLifecycleModel.res +206 -148
  42. package/src/lifecycle/CheckLifecycleModel.res.mjs +23 -5
  43. package/src/semantic/Bytes.res +0 -1
  44. package/src/semantic/CalendarDate.res +2 -2
  45. package/src/semantic/Capabilities.res +21 -6
  46. package/src/semantic/Capabilities.res.mjs +18 -12
  47. package/src/semantic/CapabilityNeed.res +16 -5
  48. package/src/semantic/CapabilityNeed.res.mjs +9 -4
  49. package/src/semantic/CaptionedImage.res +0 -1
  50. package/src/semantic/Color.res +0 -1
  51. package/src/semantic/Currency.res +189 -170
  52. package/src/semantic/DateRange.res +3 -4
  53. package/src/semantic/DateTime.res +5 -5
  54. package/src/semantic/Duration.res +2 -2
  55. package/src/semantic/Email.res +0 -1
  56. package/src/semantic/FileRef.res +2 -2
  57. package/src/semantic/GeoPoint.res +17 -21
  58. package/src/semantic/Geocoding.res +8 -9
  59. package/src/semantic/Geolocation.res +6 -9
  60. package/src/semantic/IdentityProvider.res +127 -0
  61. package/src/semantic/IdentityProvider.res.mjs +65 -0
  62. package/src/semantic/ImageRef.res +2 -2
  63. package/src/semantic/MemberRef.res +0 -1
  64. package/src/semantic/Messaging.res +141 -21
  65. package/src/semantic/Messaging.res.mjs +51 -8
  66. package/src/semantic/Money.res +23 -20
  67. package/src/semantic/Offload.res +6 -4
  68. package/src/semantic/Percent.res +3 -3
  69. package/src/semantic/Phone.res +0 -1
  70. package/src/semantic/RowImage.res +4 -3
  71. package/src/semantic/Secrets.res +76 -0
  72. package/src/semantic/Secrets.res.mjs +49 -0
  73. package/src/semantic/Semantic.res +8 -11
  74. package/src/semantic/StorageRef.res +17 -17
  75. package/src/semantic/Template.res +5 -7
  76. package/src/semantic/UploadableFile.res +0 -1
  77. package/src/semantic/UploadableImage.res +0 -1
  78. package/src/semantic/Url.res +3 -3
  79. package/src/types/AccountsManifest.res +281 -0
  80. package/src/types/AccountsManifest.res.mjs +311 -0
  81. package/src/types/AdminGroup.res +27 -0
  82. package/src/types/AdminGroup.res.mjs +9 -0
  83. package/src/types/Authorization.res +1 -2
  84. package/src/types/Handler.res +0 -1
  85. package/src/types/Identity.res +1 -3
  86. package/src/types/Lifecycle.res +7 -6
  87. package/src/types/Message.res +6 -4
  88. package/src/types/OwnerScope.res +28 -3
  89. package/src/types/OwnerScope.res.mjs +12 -0
  90. package/src/types/PrepareAccounts.res +119 -0
  91. package/src/types/PrepareAccounts.res.mjs +148 -0
  92. package/src/types/Projection.res +9 -2
  93. package/src/types/StoredEvent.res +2 -4
  94. package/src/types/Transition.res +8 -4
  95. package/src/util/Util_Password.res +60 -0
  96. package/src/util/Util_Password.res.mjs +53 -0
  97. package/src/util/Util_Sury.res +1 -4
@@ -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> = S.string->Semantic.refined(~id=Semantic.Id.fileRef, ~check=fromString)
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(raw)}. ` ++
80
- `A value beyond ±90 is usually a longitude in the latitude's place.`,
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
- switch validateLat(raw) {
105
- | Ok(_) => true
106
- | Error(_) => false
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
- S.float->S.refine(
114
- raw =>
115
- switch validateLng(raw) {
116
- | Ok(_) => true
117
- | Error(_) => false
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> = schema->Semantic.mark(~id=Semantic.Id.geoPoint)
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
- | /** The provider could not be reached, or refused the call. Retry. */
23
- Unavailable(string)
24
- | /** The provider answered, and had nothing for this text. Do not retry. */
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
- | /** Unscored is not a low score, and must not read as a high one. */
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
- | /** Several matches about equally well. */
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
- | /** `requestedFor` is the address asked about, so a stale answer is detectable. */
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
- | /** Answered, with nothing storable unattended. A verdict for a human. */
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> = S.string->Semantic.refined(~id=Semantic.Id.imageRef, ~check=fromString)
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
- /** A delivery route. The selector a recipient chooses per notification kind, and
16
- the granularity a platform provisions at. */
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
- Each address is the branded scalar for its channel, so an unparseable one is
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
- | /** The token the device registered with the push service. Opaque and
29
- provider-shaped — unlike an address, nobody else has a grammar for it. */
30
- ToPush({deviceToken: string})
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
- Three constructors because the retry decision turns on the distinction, and
91
- getting it wrong is expensive in both directions: retrying a refused address
92
- burns the budget on an outcome that will not change, and abandoning a transient
93
- outage writes off a message that would have gone.
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
- | /** The provider could not be reached, or refused the call. Retry. */
97
- Unavailable(string)
98
- | /** This deployment provisions nothing for this channel. Do not retry — no
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
- | /** The provider answered and will not take this message: an address it
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
- /** Whether this provider can attempt a recipient's channel at all. The check a
148
- caller makes before spending a send, and the same rule the provider applies
149
- internally, so the two cannot disagree. */
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
- provider.channels->Array.includes(recipient->channelOf)
268
+ switch recipient {
269
+ | ToPush(address) => provider.pushServices->Array.includes(address->serviceOf)
270
+ | recipient => provider.channels->Array.includes(recipient->channelOf)
271
+ }