@reventlessdev/reventless-local 3.0.0-alpha.295 → 3.0.0-alpha.296

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.296 (2026-10-22)
7
+
8
+ ### Features
9
+
10
+ * **local:** the local platform provisions an identity provider over the accounts file ([1a46867](https://github.com/ReventlessDev/reventless-core/commit/1a46867ac391b3b4a2d6973606d782547190dcb8))
11
+
12
+
6
13
  # 3.0.0-alpha.295 (2026-10-21)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.295",
3
+ "version": "3.0.0-alpha.296",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -37,21 +37,21 @@
37
37
  "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
38
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
39
39
  "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
40
- "@reventlessdev/rescript-node": "2.0.0-alpha.23",
41
40
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
41
+ "@reventlessdev/rescript-node": "2.0.0-alpha.23",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.152",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.252",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.201",
46
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.168",
47
- "@reventlessdev/reventless-spec": "3.0.0-alpha.173",
48
- "@reventlessdev/reventless-core": "3.0.0-alpha.304",
49
- "@reventlessdev/reventless-seed": "1.0.0-alpha.36"
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.305",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.153",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.253",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.169",
47
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.202",
48
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.36",
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.174"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.1",
53
53
  "sury-ppx": "11.0.0",
54
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.106"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.107"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "^12.3.1"
package/src/Platform.res CHANGED
@@ -1000,6 +1000,13 @@ module MakeWithConfig = (
1000
1000
  ReventlessCore.StorePrefixCollision.collisionMessage(c),
1001
1001
  ),
1002
1002
  )
1003
+ // The deployed platform refuses a declared need it does not
1004
+ // provision; here it is a warning, for the reason `unmetWarning` gives.
1005
+ LocalCapabilities.unmetWarning(
1006
+ ~declared=def.requiredCapabilities
1007
+ ->Option.getOr([])
1008
+ ->Array.map(d => (d.capability, d.component)),
1009
+ )->Option.forEach(message => log.warn(~comp="Platform:plugins", message))
1003
1010
  | None => ()
1004
1011
  }
1005
1012
  })
@@ -1545,6 +1552,10 @@ module MakeWithConfig = (
1545
1552
  // ignores them.
1546
1553
  geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
1547
1554
  messagingSender?: ReventlessInfra.Platform.messagingSender,
1555
+ // Local only, until the AWS platform has a provider to pass the same way:
1556
+ // `Capability_IdentityProvider_UserStore.make()`, writing the accounts file
1557
+ // the auth adapter loads.
1558
+ identityProvider?: Reventless.IdentityProvider.t,
1548
1559
  uploadBucket?: ReventlessInfra.Platform.objectStore,
1549
1560
  // Still ignored: a mode is a shell *build* input locally, turned on in the
1550
1561
  // host-shell package's own `public/config.json`, where the AWS deploy has to
@@ -1586,6 +1597,12 @@ module MakeWithConfig = (
1586
1597
  log.info(~comp="Platform", `messaging: log transport as ${address} (nothing is sent)`)
1587
1598
  | _ => ()
1588
1599
  }
1600
+ switch hostUiBundle->Option.flatMap(cfg => cfg.identityProvider) {
1601
+ | Some(provider) =>
1602
+ LocalCapabilities.registerIdentityProvider(provider)
1603
+ log.info(~comp="Platform", `identity provider: the accounts file`)
1604
+ | None => ()
1605
+ }
1589
1606
 
1590
1607
  // Runtime extension seam. Fired before anything is built, so an extension's
1591
1608
  // interception and publish hooks are registered before the first command or
@@ -899,7 +899,10 @@ function MakeWithConfig(Config) {
899
899
  qualified: param[0],
900
900
  prefix: param[1]
901
901
  }))).forEach(c => log.warn("Platform:plugins", undefined, StorePrefixCollision$ReventlessCore.collisionMessage(c)));
902
- return;
902
+ return Stdlib_Option.forEach(LocalCapabilities$ReventlessLocal.unmetWarning(Stdlib_Option.getOr(ps.requiredCapabilities, []).map(d => [
903
+ d.capability,
904
+ d.component
905
+ ])), message => log.warn("Platform:plugins", undefined, message));
903
906
  }
904
907
  });
905
908
  });
@@ -1264,6 +1267,11 @@ function MakeWithConfig(Config) {
1264
1267
  log.info("Platform", undefined, `messaging: log transport as ` + sender + ` (nothing is sent)`);
1265
1268
  }
1266
1269
  }
1270
+ let provider = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.identityProvider);
1271
+ if (provider !== undefined) {
1272
+ LocalCapabilities$ReventlessLocal.registerIdentityProvider(provider);
1273
+ log.info("Platform", undefined, `identity provider: the accounts file`);
1274
+ }
1267
1275
  RuntimeExtension$ReventlessCore.notifyColdStart("Platform", "LocalPlatform", ResourceAttribution$ReventlessCore.current.contents.plugin, ResourceAttribution$ReventlessCore.current.contents.platform);
1268
1276
  let projectionCatchup = Stdlib_Option.map(BackendState$ReventlessLocal.getSqliteDb(), db => [
1269
1277
  db,
@@ -2660,7 +2668,10 @@ function Make($star) {
2660
2668
  qualified: param[0],
2661
2669
  prefix: param[1]
2662
2670
  }))).forEach(c => log.warn("Platform:plugins", undefined, StorePrefixCollision$ReventlessCore.collisionMessage(c)));
2663
- return;
2671
+ return Stdlib_Option.forEach(LocalCapabilities$ReventlessLocal.unmetWarning(Stdlib_Option.getOr(ps.requiredCapabilities, []).map(d => [
2672
+ d.capability,
2673
+ d.component
2674
+ ])), message => log.warn("Platform:plugins", undefined, message));
2664
2675
  }
2665
2676
  });
2666
2677
  });
@@ -3020,6 +3031,11 @@ function Make($star) {
3020
3031
  log.info("Platform", undefined, `messaging: log transport as ` + sender + ` (nothing is sent)`);
3021
3032
  }
3022
3033
  }
3034
+ let provider = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.identityProvider);
3035
+ if (provider !== undefined) {
3036
+ LocalCapabilities$ReventlessLocal.registerIdentityProvider(provider);
3037
+ log.info("Platform", undefined, `identity provider: the accounts file`);
3038
+ }
3023
3039
  RuntimeExtension$ReventlessCore.notifyColdStart("Platform", "LocalPlatform", ResourceAttribution$ReventlessCore.current.contents.plugin, ResourceAttribution$ReventlessCore.current.contents.platform);
3024
3040
  let projectionCatchup = Stdlib_Option.map(BackendState$ReventlessLocal.getSqliteDb(), db => [
3025
3041
  db,
@@ -100,6 +100,16 @@ module Login = {
100
100
  store := Dict.make()
101
101
  }
102
102
 
103
+ /** The credentials behind a username, for a caller that rewrites them. */
104
+ let credentialsOf = (~username: string): option<credentials> => store.contents->Dict.get(username)
105
+
106
+ /** Forget an account on both sides, so neither a login nor an `X-User`
107
+ header resolves it afterwards. */
108
+ let removeCredentials = (~username: string): unit => {
109
+ store.contents->Dict.delete(username)
110
+ users.contents->Dict.delete(username)
111
+ }
112
+
103
113
  // ── HMAC secret (lazy) ──
104
114
  //
105
115
  // Resolved once on first use, from three sources in order:
@@ -3,6 +3,7 @@
3
3
  import * as Sury from "sury";
4
4
  import * as Nodefs from "node:fs";
5
5
  import * as Nodepath from "node:path";
6
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
6
7
  import * as Nodecrypto from "node:crypto";
7
8
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
9
  import * as Pulumi from "@pulumi/pulumi";
@@ -88,6 +89,15 @@ function resetStore() {
88
89
  store.contents = {};
89
90
  }
90
91
 
92
+ function credentialsOf(username) {
93
+ return store.contents[username];
94
+ }
95
+
96
+ function removeCredentials(username) {
97
+ Stdlib_Dict.$$delete(store.contents, username);
98
+ Stdlib_Dict.$$delete(users.contents, username);
99
+ }
100
+
91
101
  let _secretFileName = "token-secret";
92
102
 
93
103
  function _secretDir() {
@@ -286,6 +296,8 @@ let Login = {
286
296
  store: store,
287
297
  setCredentials: setCredentials,
288
298
  resetStore: resetStore,
299
+ credentialsOf: credentialsOf,
300
+ removeCredentials: removeCredentials,
289
301
  _secretFileName: _secretFileName,
290
302
  _secretDir: _secretDir,
291
303
  _mint: _mint,
@@ -24,13 +24,62 @@ let messagingSenderRef = ref("")
24
24
  `PluginRuntime_Builder.registerMessagingSender`. */
25
25
  let registerMessagingSender = (sender: string) => messagingSenderRef := sender
26
26
 
27
+ // Refusing until a root provides one: the seam exists so a deployment can be
28
+ // asked what it provisions, and answering `Unavailable` leaves the work queued
29
+ // and visible instead of recording a verdict against a person who has done
30
+ // nothing wrong.
31
+ let identityProviderRef = ref(Reventless.Capabilities.none.identityProvider)
32
+
33
+ /** Provision the identity provider. Called by `makePlatform` from the handle a
34
+ root declared, `Capability_IdentityProvider_UserStore.make()`. */
35
+ let registerIdentityProvider = (provider: Reventless.IdentityProvider.t) =>
36
+ identityProviderRef := provider
37
+
38
+ /** What this platform provisions, in the terms a plugin declares its needs:
39
+ read off the same refs `capabilities()` reads, so it cannot disagree with
40
+ what a slice is handed. Geocoding is never here — no local geocoder exists. */
41
+ let provisioned = (): array<Reventless.CapabilityNeed.t> =>
42
+ Array.concat(
43
+ messagingSenderRef.contents == "" ? [] : [Reventless.CapabilityNeed.Messaging],
44
+ identityProviderRef.contents.operations == []
45
+ ? []
46
+ : [Reventless.CapabilityNeed.IdentityProvider],
47
+ )
48
+
49
+ /**
50
+ The warning for a plugin declaring needs this platform does not provision, or
51
+ `None` when every need is met.
52
+
53
+ A warning where the AWS deploy refuses, and the difference is deliberate: no
54
+ local geocoder exists, so a refusal would stop every plugin carrying the
55
+ geocoding trait from running locally at all. What the developer loses without
56
+ this is the explanation — the slice's items stay retried and nothing says why.
57
+ */
58
+ let unmetWarning = (~declared: array<(string, string)>): option<string> =>
59
+ switch Reventless.CapabilityNeed.unmet(~declared, ~provisioned=provisioned()) {
60
+ | [] => None
61
+ | missing =>
62
+ Some(
63
+ Reventless.CapabilityNeed.unmetMessage(missing) ++
64
+ missing
65
+ ->Array.map(u =>
66
+ switch u.need {
67
+ | Geocoding => `\n Geocoding has no local provider; the slice's items are retried and stay queued.`
68
+ | Messaging =>
69
+ `\n Messaging is \`Capability_Messaging_Log.make()\`, passed to the platform as ` ++ `\`~messagingSender\`.`
70
+ | IdentityProvider =>
71
+ `\n IdentityProvider is \`Capability_IdentityProvider_UserStore.make()\`, passed to the ` ++ `platform as \`~identityProvider\`.`
72
+ }
73
+ )
74
+ ->Belt.Set.String.fromArray
75
+ ->Belt.Set.String.toArray
76
+ ->Array.join(""),
77
+ )
78
+ }
79
+
27
80
  let capabilities = (): Reventless.Capabilities.t => {
28
81
  geocode: Reventless.Capabilities.none.geocode,
29
82
  messaging: ReventlessCore.Messaging_Log_Backend.provider(~sender=messagingSenderRef.contents),
30
- // Refusing rather than absent: the seam exists so a deployment can be asked
31
- // what it provisions. Nothing writes back to the local user store yet, and
32
- // answering `Unavailable` leaves the work queued and visible instead of
33
- // recording a verdict against a person who has done nothing wrong.
34
- identityProvider: Reventless.Capabilities.none.identityProvider,
83
+ identityProvider: identityProviderRef.contents,
35
84
  secrets: ReventlessCore.Secrets_Node_Backend.provider,
36
85
  }
@@ -1,6 +1,9 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
4
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
3
5
  import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
6
+ import * as CapabilityNeed$Reventless from "@reventlessdev/reventless-spec/src/semantic/CapabilityNeed.res.mjs";
4
7
  import * as Secrets_Node_Backend$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Secrets/Secrets_Node_Backend.res.mjs";
5
8
  import * as Messaging_Log_Backend$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Messaging/Messaging_Log_Backend.res.mjs";
6
9
 
@@ -12,11 +15,42 @@ function registerMessagingSender(sender) {
12
15
  messagingSenderRef.contents = sender;
13
16
  }
14
17
 
18
+ let identityProviderRef = {
19
+ contents: Capabilities$Reventless.none.identityProvider
20
+ };
21
+
22
+ function registerIdentityProvider(provider) {
23
+ identityProviderRef.contents = provider;
24
+ }
25
+
26
+ function provisioned() {
27
+ return (
28
+ messagingSenderRef.contents === "" ? [] : ["Messaging"]
29
+ ).concat(Primitive_object.equal(identityProviderRef.contents.operations, []) ? [] : ["IdentityProvider"]);
30
+ }
31
+
32
+ function unmetWarning(declared) {
33
+ let missing = CapabilityNeed$Reventless.unmet(declared, provisioned());
34
+ if (missing.length !== 0) {
35
+ return CapabilityNeed$Reventless.unmetMessage(missing) + Belt_SetString.toArray(Belt_SetString.fromArray(missing.map(u => {
36
+ let match = u.need;
37
+ switch (match) {
38
+ case "Geocoding" :
39
+ return `\n Geocoding has no local provider; the slice's items are retried and stay queued.`;
40
+ case "Messaging" :
41
+ return `\n Messaging is \`Capability_Messaging_Log.make()\`, passed to the platform as \`~messagingSender\`.`;
42
+ case "IdentityProvider" :
43
+ return `\n IdentityProvider is \`Capability_IdentityProvider_UserStore.make()\`, passed to the platform as \`~identityProvider\`.`;
44
+ }
45
+ }))).join("");
46
+ }
47
+ }
48
+
15
49
  function capabilities() {
16
50
  return {
17
51
  geocode: Capabilities$Reventless.none.geocode,
18
52
  messaging: Messaging_Log_Backend$ReventlessCore.provider(messagingSenderRef.contents),
19
- identityProvider: Capabilities$Reventless.none.identityProvider,
53
+ identityProvider: identityProviderRef.contents,
20
54
  secrets: Secrets_Node_Backend$ReventlessCore.provider
21
55
  };
22
56
  }
@@ -24,6 +58,10 @@ function capabilities() {
24
58
  export {
25
59
  messagingSenderRef,
26
60
  registerMessagingSender,
61
+ identityProviderRef,
62
+ registerIdentityProvider,
63
+ provisioned,
64
+ unmetWarning,
27
65
  capabilities,
28
66
  }
29
67
  /* Capabilities-Reventless Not a pure module */
@@ -0,0 +1,129 @@
1
+ // The identity provider a dev platform provisions — the local counterpart to
2
+ // the Cognito provider the AWS platform does not have yet, and reached the same
3
+ // way `Capability_Messaging_Log` is: a root calls `make`, passes the handle as
4
+ // `~identityProvider` in `~hostUiBundle`, and the platform puts it on the
5
+ // capability record every slice's `translate` receives.
6
+ //
7
+ // Its store is `.reventless/users.yaml`, the file the in-memory auth adapter
8
+ // already loads at startup, so an account made here survives a restart with no
9
+ // second store to keep in step. Every write goes to the file and to the live
10
+ // `LocalAuth.Login` store together, so the running process and the next one
11
+ // agree.
12
+ //
13
+ // 🚨 **The file is shared and unlocked.** The seed runner reads it and
14
+ // `provision-accounts` writes it; two processes editing at once can lose one
15
+ // edit. Recorded rather than solved: the loss is a dev account, and a lock
16
+ // would be more machinery than the file is worth.
17
+ //
18
+ // The principal's handle is the username, and the username is the address the
19
+ // person is reached at — what a pool signing in on an email address does.
20
+
21
+ open Reventless
22
+
23
+ let usernameOf = (contact: Messaging.recipient): result<string, IdentityProvider.failure> =>
24
+ switch contact {
25
+ | ToEmail(email) => Ok(Email.toString(email))
26
+ | ToSms(phone) => Ok(Phone.toString(phone))
27
+ | ToPush(_) => Error(Refused("a push address cannot be signed in with"))
28
+ }
29
+
30
+ let passwordOf = (credential: IdentityProvider.credential): string =>
31
+ switch credential {
32
+ | Password(password) => password
33
+ | NoCredential => ""
34
+ }
35
+
36
+ // A file the provider cannot read or write is a gap in the deployment, so the
37
+ // answer is retryable; a manifest refusing an edit is a verdict on the request.
38
+ let storeFailure = (message: string): IdentityProvider.failure => Unavailable(message)
39
+
40
+ let make = (~path: option<string>=?, ()): IdentityProvider.t => {
41
+ let file = () => path->Option.getOr(AccountsManifest.defaultPath())
42
+ let identityOf = (~username, ~groups): Identity.t => {
43
+ userId: username,
44
+ username,
45
+ groups,
46
+ provider: InMemory,
47
+ }
48
+
49
+ let createPrincipal = async (
50
+ ~contact: Messaging.recipient,
51
+ ~credential: IdentityProvider.credential,
52
+ ~groups: array<string>,
53
+ ): result<IdentityProvider.principal, IdentityProvider.failure> =>
54
+ switch usernameOf(contact) {
55
+ | Error(_) as e => e
56
+ | Ok(username) if LocalAuth.Login.credentialsOf(~username)->Option.isSome => Error(Conflict)
57
+ | Ok(username) =>
58
+ let password = passwordOf(credential)
59
+ switch AccountsManifest.editFile(
60
+ ~path=file(),
61
+ ~edits=[Append({username, password, groups})],
62
+ ) {
63
+ | Error(message) if message->String.endsWith("already declared in the manifest") =>
64
+ Error(Conflict)
65
+ | Error(message) => Error(storeFailure(message))
66
+ | Ok() =>
67
+ LocalAuth.Login.setCredentials(
68
+ ~username,
69
+ ~password,
70
+ ~identity=identityOf(~username, ~groups),
71
+ )
72
+ Ok({IdentityProvider.providerId: username})
73
+ }
74
+ }
75
+
76
+ let setGroups = async (~username, ~groups): result<unit, IdentityProvider.failure> =>
77
+ switch LocalAuth.Login.credentialsOf(~username) {
78
+ | None => Error(Refused(`no principal ${username}`))
79
+ | Some({identity}) if identity.groups == groups => Ok()
80
+ | Some({password, identity}) =>
81
+ switch AccountsManifest.editFile(~path=file(), ~edits=[SetGroups({username, groups})]) {
82
+ | Error(message) => Error(storeFailure(message))
83
+ | Ok() =>
84
+ LocalAuth.Login.setCredentials(~username, ~password, ~identity={...identity, groups})
85
+ Ok()
86
+ }
87
+ }
88
+
89
+ let addToGroup = (~principal: IdentityProvider.principal, ~group: string) => {
90
+ let username = principal.providerId
91
+ let current = LocalAuth.Login.credentialsOf(~username)->Option.mapOr([], c => c.identity.groups)
92
+ setGroups(
93
+ ~username,
94
+ ~groups=current->Array.includes(group) ? current : Array.concat(current, [group]),
95
+ )
96
+ }
97
+
98
+ let removeFromGroup = (~principal: IdentityProvider.principal, ~group: string) => {
99
+ let username = principal.providerId
100
+ let current = LocalAuth.Login.credentialsOf(~username)->Option.mapOr([], c => c.identity.groups)
101
+ setGroups(~username, ~groups=current->Array.filter(g => g != group))
102
+ }
103
+
104
+ // Deleting what is not there is the retry of a delete that happened.
105
+ let deletePrincipal = async (~principal: IdentityProvider.principal): result<
106
+ unit,
107
+ IdentityProvider.failure,
108
+ > => {
109
+ let username = principal.providerId
110
+ switch LocalAuth.Login.credentialsOf(~username) {
111
+ | None => Ok()
112
+ | Some(_) =>
113
+ switch AccountsManifest.editFile(~path=file(), ~edits=[Remove(username)]) {
114
+ | Error(message) => Error(storeFailure(message))
115
+ | Ok() =>
116
+ LocalAuth.Login.removeCredentials(~username)
117
+ Ok()
118
+ }
119
+ }
120
+ }
121
+
122
+ IdentityProvider.make(
123
+ ~createPrincipal,
124
+ ~addToGroup,
125
+ ~removeFromGroup,
126
+ ~deletePrincipal,
127
+ ~operations=[CreatePrincipal, AddToGroup, RemoveFromGroup, DeletePrincipal],
128
+ )
129
+ }
@@ -0,0 +1,187 @@
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 LocalAuth$ReventlessLocal from "../adapter/Auth/LocalAuth.res.mjs";
6
+ import * as AccountsManifest$Reventless from "@reventlessdev/reventless-spec/src/types/AccountsManifest.res.mjs";
7
+ import * as IdentityProvider$Reventless from "@reventlessdev/reventless-spec/src/semantic/IdentityProvider.res.mjs";
8
+
9
+ function usernameOf(contact) {
10
+ switch (contact.TAG) {
11
+ case "ToEmail" :
12
+ case "ToSms" :
13
+ return {
14
+ TAG: "Ok",
15
+ _0: contact._0
16
+ };
17
+ case "ToPush" :
18
+ return {
19
+ TAG: "Error",
20
+ _0: {
21
+ TAG: "Refused",
22
+ _0: "a push address cannot be signed in with"
23
+ }
24
+ };
25
+ }
26
+ }
27
+
28
+ function passwordOf(credential) {
29
+ if (typeof credential !== "object") {
30
+ return "";
31
+ } else {
32
+ return credential._0;
33
+ }
34
+ }
35
+
36
+ function storeFailure(message) {
37
+ return {
38
+ TAG: "Unavailable",
39
+ _0: message
40
+ };
41
+ }
42
+
43
+ function make(path, param) {
44
+ let identityOf = (username, groups) => ({
45
+ userId: username,
46
+ username: username,
47
+ groups: groups,
48
+ provider: "InMemory"
49
+ });
50
+ let createPrincipal = async (contact, credential, groups) => {
51
+ let e = usernameOf(contact);
52
+ if (e.TAG !== "Ok") {
53
+ return e;
54
+ }
55
+ let username = e._0;
56
+ if (Stdlib_Option.isSome(LocalAuth$ReventlessLocal.Login.credentialsOf(username))) {
57
+ return {
58
+ TAG: "Error",
59
+ _0: "Conflict"
60
+ };
61
+ }
62
+ let password = passwordOf(credential);
63
+ let message = AccountsManifest$Reventless.editFile(Stdlib_Option.getOr(path, AccountsManifest$Reventless.defaultPath()), [{
64
+ TAG: "Append",
65
+ _0: {
66
+ username: username,
67
+ password: password,
68
+ groups: groups
69
+ }
70
+ }]);
71
+ if (message.TAG === "Ok") {
72
+ LocalAuth$ReventlessLocal.Login.setCredentials(username, password, identityOf(username, groups));
73
+ return {
74
+ TAG: "Ok",
75
+ _0: {
76
+ providerId: username
77
+ }
78
+ };
79
+ }
80
+ let message$1 = message._0;
81
+ if (message$1.endsWith("already declared in the manifest")) {
82
+ return {
83
+ TAG: "Error",
84
+ _0: "Conflict"
85
+ };
86
+ } else {
87
+ return {
88
+ TAG: "Error",
89
+ _0: {
90
+ TAG: "Unavailable",
91
+ _0: message$1
92
+ }
93
+ };
94
+ }
95
+ };
96
+ let setGroups = async (username, groups) => {
97
+ let match = LocalAuth$ReventlessLocal.Login.credentialsOf(username);
98
+ if (match === undefined) {
99
+ return {
100
+ TAG: "Error",
101
+ _0: {
102
+ TAG: "Refused",
103
+ _0: `no principal ` + username
104
+ }
105
+ };
106
+ }
107
+ let identity = match.identity;
108
+ if (Primitive_object.equal(identity.groups, groups)) {
109
+ return {
110
+ TAG: "Ok",
111
+ _0: undefined
112
+ };
113
+ }
114
+ let message = AccountsManifest$Reventless.editFile(Stdlib_Option.getOr(path, AccountsManifest$Reventless.defaultPath()), [{
115
+ TAG: "SetGroups",
116
+ username: username,
117
+ groups: groups
118
+ }]);
119
+ if (message.TAG !== "Ok") {
120
+ return {
121
+ TAG: "Error",
122
+ _0: {
123
+ TAG: "Unavailable",
124
+ _0: message._0
125
+ }
126
+ };
127
+ }
128
+ let newrecord = {...identity};
129
+ LocalAuth$ReventlessLocal.Login.setCredentials(username, match.password, (newrecord.groups = groups, newrecord));
130
+ return {
131
+ TAG: "Ok",
132
+ _0: undefined
133
+ };
134
+ };
135
+ let addToGroup = (principal, group) => {
136
+ let username = principal.providerId;
137
+ let current = Stdlib_Option.mapOr(LocalAuth$ReventlessLocal.Login.credentialsOf(username), [], c => c.identity.groups);
138
+ return setGroups(username, current.includes(group) ? current : current.concat([group]));
139
+ };
140
+ let removeFromGroup = (principal, group) => {
141
+ let username = principal.providerId;
142
+ let current = Stdlib_Option.mapOr(LocalAuth$ReventlessLocal.Login.credentialsOf(username), [], c => c.identity.groups);
143
+ return setGroups(username, current.filter(g => g !== group));
144
+ };
145
+ let deletePrincipal = async principal => {
146
+ let username = principal.providerId;
147
+ let match = LocalAuth$ReventlessLocal.Login.credentialsOf(username);
148
+ if (match === undefined) {
149
+ return {
150
+ TAG: "Ok",
151
+ _0: undefined
152
+ };
153
+ }
154
+ let message = AccountsManifest$Reventless.editFile(Stdlib_Option.getOr(path, AccountsManifest$Reventless.defaultPath()), [{
155
+ TAG: "Remove",
156
+ _0: username
157
+ }]);
158
+ if (message.TAG !== "Ok") {
159
+ return {
160
+ TAG: "Error",
161
+ _0: {
162
+ TAG: "Unavailable",
163
+ _0: message._0
164
+ }
165
+ };
166
+ }
167
+ LocalAuth$ReventlessLocal.Login.removeCredentials(username);
168
+ return {
169
+ TAG: "Ok",
170
+ _0: undefined
171
+ };
172
+ };
173
+ return IdentityProvider$Reventless.make(createPrincipal, addToGroup, removeFromGroup, deletePrincipal, [
174
+ "CreatePrincipal",
175
+ "AddToGroup",
176
+ "RemoveFromGroup",
177
+ "DeletePrincipal"
178
+ ]);
179
+ }
180
+
181
+ export {
182
+ usernameOf,
183
+ passwordOf,
184
+ storeFailure,
185
+ make,
186
+ }
187
+ /* LocalAuth-ReventlessLocal Not a pure module */
@@ -0,0 +1,173 @@
1
+ open JestGlobals
2
+
3
+ // The local identity provider over the accounts file. What matters is that the
4
+ // file and the running login store say the same thing after every operation,
5
+ // and that a process starting afresh from the file sees the account.
6
+
7
+ let email = address => Reventless.Messaging.ToEmail(Reventless.Email.unsafe(address))
8
+
9
+ let freshFile = (): string => {
10
+ let dir = NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), "reventless-identity-"]))
11
+ NodePath.join([dir, ".reventless", "users.yaml"])
12
+ }
13
+
14
+ let resetAll = () => {
15
+ LocalAuth.resetUsers()
16
+ LocalAuth.Login.resetStore()
17
+ UserStore.resetResolution()
18
+ }
19
+
20
+ let restartFrom = (file: string): unit => {
21
+ resetAll()
22
+ switch UserStore.load(~usersFile=file, ()) {
23
+ | Ok(_) => ()
24
+ | Error(message) => JsError.throwWithMessage(`reload failed: ${message}`)
25
+ }
26
+ }
27
+
28
+ describe("Capability_IdentityProvider_UserStore", () => {
29
+ testSync("publishes all four operations", () =>
30
+ expect(Capability_IdentityProvider_UserStore.make(~path=freshFile(), ()).operations)->toEqual([
31
+ Reventless.IdentityProvider.CreatePrincipal,
32
+ AddToGroup,
33
+ RemoveFromGroup,
34
+ DeletePrincipal,
35
+ ])
36
+ )
37
+
38
+ testPromise("a created principal can log in now and after a restart", async () => {
39
+ resetAll()
40
+ let file = freshFile()
41
+ let provider = Capability_IdentityProvider_UserStore.make(~path=file, ())
42
+ let created = await provider.createPrincipal(
43
+ ~contact=email("new@example.com"),
44
+ ~credential=Password("secret-1"),
45
+ ~groups=["Shopper"],
46
+ )
47
+ expect(created)->toEqual(Ok({Reventless.IdentityProvider.providerId: "new@example.com"}))
48
+ expect(
49
+ (await LocalAuth.Login.issue(~username="new@example.com", ~password="secret-1"))->Result.isOk,
50
+ )->toBe(true)
51
+
52
+ restartFrom(file)
53
+ expect(
54
+ (await LocalAuth.Login.issue(~username="new@example.com", ~password="secret-1"))->Result.isOk,
55
+ )->toBe(true)
56
+ expect(LocalAuth.lookupUser("new@example.com")->Option.map(i => i.groups))->toEqual(
57
+ Some(["Shopper"]),
58
+ )
59
+ })
60
+
61
+ testPromise("the same contact twice is a Conflict, not a second account", async () => {
62
+ resetAll()
63
+ let provider = Capability_IdentityProvider_UserStore.make(~path=freshFile(), ())
64
+ let create = () =>
65
+ provider.createPrincipal(
66
+ ~contact=email("twice@example.com"),
67
+ ~credential=NoCredential,
68
+ ~groups=[],
69
+ )
70
+ let _ = await create()
71
+ expect(await create())->toEqual(Error(Reventless.IdentityProvider.Conflict))
72
+ })
73
+
74
+ // An empty password is the manifest's "authenticates nowhere", so the account
75
+ // exists for the next provisioning step without a credential anyone holds.
76
+ testPromise("NoCredential writes an account no password opens", async () => {
77
+ resetAll()
78
+ let provider = Capability_IdentityProvider_UserStore.make(~path=freshFile(), ())
79
+ let _ = await provider.createPrincipal(
80
+ ~contact=email("later@example.com"),
81
+ ~credential=NoCredential,
82
+ ~groups=[],
83
+ )
84
+ expect(LocalAuth.lookupUser("later@example.com")->Option.isSome)->toBe(true)
85
+ expect(
86
+ (await LocalAuth.Login.issue(~username="later@example.com", ~password=""))->Result.isOk,
87
+ )->toBe(true)
88
+ })
89
+
90
+ testPromise("a push address is refused, since nothing signs in with one", async () => {
91
+ let provider = Capability_IdentityProvider_UserStore.make(~path=freshFile(), ())
92
+ let answer = await provider.createPrincipal(
93
+ ~contact=ToPush(Fcm({registrationToken: "tok"})),
94
+ ~credential=NoCredential,
95
+ ~groups=[],
96
+ )
97
+ expect(
98
+ switch answer {
99
+ | Error(Refused(_)) => true
100
+ | _ => false
101
+ },
102
+ )->toBe(true)
103
+ })
104
+
105
+ testPromise("groups round-trip through the file", async () => {
106
+ resetAll()
107
+ let file = freshFile()
108
+ let provider = Capability_IdentityProvider_UserStore.make(~path=file, ())
109
+ let principal: Reventless.IdentityProvider.principal = {providerId: "grp@example.com"}
110
+ let _ = await provider.createPrincipal(
111
+ ~contact=email("grp@example.com"),
112
+ ~credential=Password("pw"),
113
+ ~groups=["Shopper"],
114
+ )
115
+ expect(await provider.addToGroup(~principal, ~group="Reviewer"))->toEqual(Ok())
116
+ expect(await provider.addToGroup(~principal, ~group="Reviewer"))->toEqual(Ok())
117
+ restartFrom(file)
118
+ expect(LocalAuth.lookupUser("grp@example.com")->Option.map(i => i.groups))->toEqual(
119
+ Some(["Shopper", "Reviewer"]),
120
+ )
121
+ expect(await provider.removeFromGroup(~principal, ~group="Shopper"))->toEqual(Ok())
122
+ restartFrom(file)
123
+ expect(LocalAuth.lookupUser("grp@example.com")->Option.map(i => i.groups))->toEqual(
124
+ Some(["Reviewer"]),
125
+ )
126
+ })
127
+
128
+ testPromise("grouping a principal that does not exist is refused", async () => {
129
+ resetAll()
130
+ let provider = Capability_IdentityProvider_UserStore.make(~path=freshFile(), ())
131
+ let answer = await provider.addToGroup(~principal={providerId: "ghost"}, ~group="Shopper")
132
+ expect(answer)->toEqual(Error(Reventless.IdentityProvider.Refused("no principal ghost")))
133
+ })
134
+
135
+ testPromise("deleting removes the account from the file and the login, twice", async () => {
136
+ resetAll()
137
+ let file = freshFile()
138
+ let provider = Capability_IdentityProvider_UserStore.make(~path=file, ())
139
+ let principal: Reventless.IdentityProvider.principal = {providerId: "gone@example.com"}
140
+ let _ = await provider.createPrincipal(
141
+ ~contact=email("gone@example.com"),
142
+ ~credential=Password("pw"),
143
+ ~groups=[],
144
+ )
145
+ expect(await provider.deletePrincipal(~principal))->toEqual(Ok())
146
+ expect(LocalAuth.lookupUser("gone@example.com"))->toEqual(None)
147
+ expect(await provider.deletePrincipal(~principal))->toEqual(Ok())
148
+ restartFrom(file)
149
+ expect(LocalAuth.lookupUser("gone@example.com"))->toEqual(None)
150
+ })
151
+
152
+ testPromise("the file's own comments survive every operation", async () => {
153
+ resetAll()
154
+ let file = freshFile()
155
+ NodeFs.mkdirSync(NodePath.dirname(file), {recursive: true})
156
+ NodeFs.writeFileSync(
157
+ file,
158
+ `- username: admin # keep this one
159
+ password: pw
160
+ groups: [Admin]
161
+ `,
162
+ )
163
+ let provider = Capability_IdentityProvider_UserStore.make(~path=file, ())
164
+ let _ = await provider.createPrincipal(
165
+ ~contact=email("c@example.com"),
166
+ ~credential=Password("pw"),
167
+ ~groups=[],
168
+ )
169
+ let _ = await provider.addToGroup(~principal={providerId: "c@example.com"}, ~group="Shopper")
170
+ let _ = await provider.deletePrincipal(~principal={providerId: "c@example.com"})
171
+ expect(NodeFs.readFileSync(file)->String.includes("# keep this one"))->toBe(true)
172
+ })
173
+ })
@@ -0,0 +1,221 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeos from "node:os";
5
+ import * as Nodepath from "node:path";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
8
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
+ import * as LocalAuth$ReventlessLocal from "../../src/adapter/Auth/LocalAuth.res.mjs";
10
+ import * as UserStore$ReventlessLocal from "../../src/adapter/Auth/UserStore.res.mjs";
11
+ import * as Capability_IdentityProvider_UserStore$ReventlessLocal from "../../src/capability/Capability_IdentityProvider_UserStore.res.mjs";
12
+
13
+ function email(address) {
14
+ return {
15
+ TAG: "ToEmail",
16
+ _0: address
17
+ };
18
+ }
19
+
20
+ function freshFile() {
21
+ let dir = Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-identity-"));
22
+ return Nodepath.join(dir, ".reventless", "users.yaml");
23
+ }
24
+
25
+ function resetAll() {
26
+ LocalAuth$ReventlessLocal.resetUsers();
27
+ LocalAuth$ReventlessLocal.Login.resetStore();
28
+ UserStore$ReventlessLocal.resetResolution();
29
+ }
30
+
31
+ function restartFrom(file) {
32
+ resetAll();
33
+ let message = UserStore$ReventlessLocal.load(undefined, file, undefined);
34
+ if (message.TAG === "Ok") {
35
+ return;
36
+ } else {
37
+ return Stdlib_JsError.throwWithMessage(`reload failed: ` + message._0);
38
+ }
39
+ }
40
+
41
+ globalThis.describe("Capability_IdentityProvider_UserStore", () => {
42
+ globalThis.test("publishes all four operations", () => {
43
+ globalThis.expect(Capability_IdentityProvider_UserStore$ReventlessLocal.make(freshFile(), undefined).operations).toEqual([
44
+ "CreatePrincipal",
45
+ "AddToGroup",
46
+ "RemoveFromGroup",
47
+ "DeletePrincipal"
48
+ ]);
49
+ });
50
+ globalThis.test("a created principal can log in now and after a restart", async () => {
51
+ resetAll();
52
+ let file = freshFile();
53
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(file, undefined);
54
+ let created = await provider.createPrincipal({
55
+ TAG: "ToEmail",
56
+ _0: "new@example.com"
57
+ }, {
58
+ TAG: "Password",
59
+ _0: "secret-1"
60
+ }, ["Shopper"]);
61
+ globalThis.expect(created).toEqual({
62
+ TAG: "Ok",
63
+ _0: {
64
+ providerId: "new@example.com"
65
+ }
66
+ });
67
+ globalThis.expect(Stdlib_Result.isOk(await LocalAuth$ReventlessLocal.Login.issue("new@example.com", "secret-1", undefined))).toBe(true);
68
+ restartFrom(file);
69
+ globalThis.expect(Stdlib_Result.isOk(await LocalAuth$ReventlessLocal.Login.issue("new@example.com", "secret-1", undefined))).toBe(true);
70
+ globalThis.expect(Stdlib_Option.map(LocalAuth$ReventlessLocal.lookupUser("new@example.com"), i => i.groups)).toEqual(["Shopper"]);
71
+ });
72
+ globalThis.test("the same contact twice is a Conflict, not a second account", async () => {
73
+ resetAll();
74
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(freshFile(), undefined);
75
+ let create = () => provider.createPrincipal({
76
+ TAG: "ToEmail",
77
+ _0: "twice@example.com"
78
+ }, "NoCredential", []);
79
+ await create();
80
+ globalThis.expect(await create()).toEqual({
81
+ TAG: "Error",
82
+ _0: "Conflict"
83
+ });
84
+ });
85
+ globalThis.test("NoCredential writes an account no password opens", async () => {
86
+ resetAll();
87
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(freshFile(), undefined);
88
+ await provider.createPrincipal({
89
+ TAG: "ToEmail",
90
+ _0: "later@example.com"
91
+ }, "NoCredential", []);
92
+ globalThis.expect(Stdlib_Option.isSome(LocalAuth$ReventlessLocal.lookupUser("later@example.com"))).toBe(true);
93
+ globalThis.expect(Stdlib_Result.isOk(await LocalAuth$ReventlessLocal.Login.issue("later@example.com", "", undefined))).toBe(true);
94
+ });
95
+ globalThis.test("a push address is refused, since nothing signs in with one", async () => {
96
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(freshFile(), undefined);
97
+ let answer = await provider.createPrincipal({
98
+ TAG: "ToPush",
99
+ _0: {
100
+ TAG: "Fcm",
101
+ registrationToken: "tok"
102
+ }
103
+ }, "NoCredential", []);
104
+ let tmp;
105
+ if (answer.TAG === "Ok") {
106
+ tmp = false;
107
+ } else {
108
+ let tmp$1 = answer._0;
109
+ tmp = typeof tmp$1 !== "object" ? false : tmp$1.TAG !== "Unavailable";
110
+ }
111
+ globalThis.expect(tmp).toBe(true);
112
+ });
113
+ globalThis.test("groups round-trip through the file", async () => {
114
+ resetAll();
115
+ let file = freshFile();
116
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(file, undefined);
117
+ let principal = {
118
+ providerId: "grp@example.com"
119
+ };
120
+ await provider.createPrincipal({
121
+ TAG: "ToEmail",
122
+ _0: "grp@example.com"
123
+ }, {
124
+ TAG: "Password",
125
+ _0: "pw"
126
+ }, ["Shopper"]);
127
+ globalThis.expect(await provider.addToGroup(principal, "Reviewer")).toEqual({
128
+ TAG: "Ok",
129
+ _0: undefined
130
+ });
131
+ globalThis.expect(await provider.addToGroup(principal, "Reviewer")).toEqual({
132
+ TAG: "Ok",
133
+ _0: undefined
134
+ });
135
+ restartFrom(file);
136
+ globalThis.expect(Stdlib_Option.map(LocalAuth$ReventlessLocal.lookupUser("grp@example.com"), i => i.groups)).toEqual([
137
+ "Shopper",
138
+ "Reviewer"
139
+ ]);
140
+ globalThis.expect(await provider.removeFromGroup(principal, "Shopper")).toEqual({
141
+ TAG: "Ok",
142
+ _0: undefined
143
+ });
144
+ restartFrom(file);
145
+ globalThis.expect(Stdlib_Option.map(LocalAuth$ReventlessLocal.lookupUser("grp@example.com"), i => i.groups)).toEqual(["Reviewer"]);
146
+ });
147
+ globalThis.test("grouping a principal that does not exist is refused", async () => {
148
+ resetAll();
149
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(freshFile(), undefined);
150
+ let answer = await provider.addToGroup({
151
+ providerId: "ghost"
152
+ }, "Shopper");
153
+ globalThis.expect(answer).toEqual({
154
+ TAG: "Error",
155
+ _0: {
156
+ TAG: "Refused",
157
+ _0: "no principal ghost"
158
+ }
159
+ });
160
+ });
161
+ globalThis.test("deleting removes the account from the file and the login, twice", async () => {
162
+ resetAll();
163
+ let file = freshFile();
164
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(file, undefined);
165
+ let principal = {
166
+ providerId: "gone@example.com"
167
+ };
168
+ await provider.createPrincipal({
169
+ TAG: "ToEmail",
170
+ _0: "gone@example.com"
171
+ }, {
172
+ TAG: "Password",
173
+ _0: "pw"
174
+ }, []);
175
+ globalThis.expect(await provider.deletePrincipal(principal)).toEqual({
176
+ TAG: "Ok",
177
+ _0: undefined
178
+ });
179
+ globalThis.expect(LocalAuth$ReventlessLocal.lookupUser("gone@example.com")).toEqual(undefined);
180
+ globalThis.expect(await provider.deletePrincipal(principal)).toEqual({
181
+ TAG: "Ok",
182
+ _0: undefined
183
+ });
184
+ restartFrom(file);
185
+ globalThis.expect(LocalAuth$ReventlessLocal.lookupUser("gone@example.com")).toEqual(undefined);
186
+ });
187
+ globalThis.test("the file's own comments survive every operation", async () => {
188
+ resetAll();
189
+ let file = freshFile();
190
+ Nodefs.mkdirSync(Nodepath.dirname(file), {
191
+ recursive: true
192
+ });
193
+ Nodefs.writeFileSync(file, `- username: admin # keep this one
194
+ password: pw
195
+ groups: [Admin]
196
+ `, "utf8");
197
+ let provider = Capability_IdentityProvider_UserStore$ReventlessLocal.make(file, undefined);
198
+ await provider.createPrincipal({
199
+ TAG: "ToEmail",
200
+ _0: "c@example.com"
201
+ }, {
202
+ TAG: "Password",
203
+ _0: "pw"
204
+ }, []);
205
+ await provider.addToGroup({
206
+ providerId: "c@example.com"
207
+ }, "Shopper");
208
+ await provider.deletePrincipal({
209
+ providerId: "c@example.com"
210
+ });
211
+ globalThis.expect(Nodefs.readFileSync(file, "utf8").includes("# keep this one")).toBe(true);
212
+ });
213
+ });
214
+
215
+ export {
216
+ email,
217
+ freshFile,
218
+ resetAll,
219
+ restartFrom,
220
+ }
221
+ /* Not a pure module */
@@ -0,0 +1,49 @@
1
+ open JestGlobals
2
+
3
+ // The local platform's answer to "what do you provision", and the warning a
4
+ // plugin gets for declaring more. Read off the same refs the slices are handed,
5
+ // so the two cannot disagree.
6
+
7
+ let declared = [
8
+ ("Geocoding", "GeocodeAddress"),
9
+ ("Messaging", "SendNotification"),
10
+ ("IdentityProvider", "CreateAccount"),
11
+ ]
12
+
13
+ let reset = () => {
14
+ LocalCapabilities.registerMessagingSender("")
15
+ LocalCapabilities.registerIdentityProvider(Reventless.Capabilities.none.identityProvider)
16
+ }
17
+
18
+ describe("LocalCapabilities.unmetWarning", () => {
19
+ testSync("a bare platform provisions nothing, and the warning names each need once", () => {
20
+ reset()
21
+ expect(LocalCapabilities.provisioned())->toEqual([])
22
+ let message = LocalCapabilities.unmetWarning(~declared)->Option.getOr("")
23
+ expect(message->String.includes("Geocoding (GeocodeAddress)"))->toBe(true)
24
+ expect(message->String.includes("Capability_Messaging_Log.make()"))->toBe(true)
25
+ expect(message->String.includes("Capability_IdentityProvider_UserStore.make()"))->toBe(true)
26
+ })
27
+
28
+ testSync("a registered provider leaves the warning", () => {
29
+ reset()
30
+ LocalCapabilities.registerMessagingSender("Shop <shop@example.test>")
31
+ LocalCapabilities.registerIdentityProvider(
32
+ Capability_IdentityProvider_UserStore.make(~path="/nonexistent/users.yaml", ()),
33
+ )
34
+ expect(LocalCapabilities.provisioned())->toEqual([
35
+ Reventless.CapabilityNeed.Messaging,
36
+ IdentityProvider,
37
+ ])
38
+ let message = LocalCapabilities.unmetWarning(~declared)->Option.getOr("")
39
+ expect(message->String.includes("Messaging"))->toBe(false)
40
+ expect(message->String.includes("IdentityProvider"))->toBe(false)
41
+ expect(message->String.includes("Geocoding"))->toBe(true)
42
+ reset()
43
+ })
44
+
45
+ testSync("a plugin declaring nothing gets no warning", () => {
46
+ reset()
47
+ expect(LocalCapabilities.unmetWarning(~declared=[]))->toEqual(None)
48
+ })
49
+ })