@reventlessdev/reventless-local 3.0.0-alpha.294 → 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 +15 -0
- package/package.json +9 -9
- package/src/Platform.res +17 -0
- package/src/Platform.res.mjs +18 -2
- package/src/adapter/Auth/LocalAuth.res +10 -0
- package/src/adapter/Auth/LocalAuth.res.mjs +12 -0
- package/src/adapter/LocalCapabilities.res +54 -5
- package/src/adapter/LocalCapabilities.res.mjs +39 -1
- package/src/capability/Capability_IdentityProvider_UserStore.res +129 -0
- package/src/capability/Capability_IdentityProvider_UserStore.res.mjs +187 -0
- package/tests/capability/Capability_IdentityProvider_UserStoreTest.res +173 -0
- package/tests/capability/Capability_IdentityProvider_UserStoreTest.res.mjs +221 -0
- package/tests/capability/LocalCapabilitiesTest.res +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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
|
+
|
|
13
|
+
# 3.0.0-alpha.295 (2026-10-21)
|
|
14
|
+
|
|
15
|
+
**Note:** Version bump only for package @reventlessdev/reventless-local
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.294 (2026-10-21)
|
|
7
22
|
|
|
8
23
|
* feat!: a rejected command says whether the model, the access check or the platform said no ([6a8a6b0](https://github.com/ReventlessDev/reventless-core/commit/6a8a6b0f2f31e766ae26a944c23b0904e51a5865))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
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": {
|
|
@@ -38,20 +38,20 @@
|
|
|
38
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
|
|
39
39
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.13",
|
|
40
40
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
|
|
41
|
-
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
42
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.303",
|
|
43
41
|
"@reventlessdev/rescript-node": "2.0.0-alpha.23",
|
|
44
|
-
"@reventlessdev/
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
42
|
+
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
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
48
|
"@reventlessdev/reventless-seed": "1.0.0-alpha.36",
|
|
49
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
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.
|
|
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
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
+
})
|