@reventlessdev/reventless-core 3.0.0-alpha.261 → 3.0.0-alpha.262
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 +16 -0
- package/package.json +7 -7
- package/src/adapter/Secrets/Secrets_Node_Backend.res +60 -0
- package/src/adapter/Secrets/Secrets_Node_Backend.res.mjs +69 -0
- package/tests/Secrets_Node_BackendTest.res +141 -0
- package/tests/Secrets_Node_BackendTest.res.mjs +152 -0
- package/tests/admin/Platform_Admin_StructureTest.res +12 -3
- package/tests/admin/Platform_Admin_StructureTest.res.mjs +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
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.262 (2026-09-11)
|
|
7
|
+
|
|
8
|
+
* feat(spec)!: a secret is a capability, so a test can know it in advance ([71507c6](https://github.com/ReventlessDev/reventless-core/commit/71507c6459b49178746f00c5040cb479d7fdade8))
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **aws:** the first administrator is one command, not the console ([5f1a846](https://github.com/ReventlessDev/reventless-core/commit/5f1a84603b21a9feb691a08a148751c6c6e8eca8))
|
|
12
|
+
|
|
13
|
+
### BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* Capabilities.t gains a secrets member. A platform that builds
|
|
16
|
+
the record literally no longer compiles until it supplies one;
|
|
17
|
+
Capabilities.none.secrets is the refusing value. Code that spreads
|
|
18
|
+
Capabilities.none is unaffected.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
# 3.0.0-alpha.261 (2026-09-09)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @reventlessdev/reventless-core
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.262",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-rc.2",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.33",
|
|
32
31
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.11",
|
|
33
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.11",
|
|
34
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
33
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
34
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.33",
|
|
35
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.11",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.20",
|
|
37
|
-
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
38
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.11",
|
|
39
|
-
"@reventlessdev/
|
|
38
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
40
39
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.36",
|
|
41
|
-
"@reventlessdev/reventless-
|
|
40
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.163",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.135"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The secret source both platforms use: `node:crypto`, which AWS Lambda and the
|
|
2
|
+
// local runtime both have. One implementation rather than two, for the same
|
|
3
|
+
// reason the log messaging transport is shared — a second copy is one edit away
|
|
4
|
+
// from differing on the part that matters.
|
|
5
|
+
|
|
6
|
+
let _bytes = (count: int): array<int> => {
|
|
7
|
+
// Read back as hex and re-split, because the binding treats a Buffer as opaque
|
|
8
|
+
// — it is only ever stringified or fed to an HMAC. Two hex characters is one
|
|
9
|
+
// byte, so this is a decode rather than a reinterpretation.
|
|
10
|
+
let hex = NodeCrypto.randomBytes(count)->NodeCrypto.bufferToString("hex")
|
|
11
|
+
Array.fromInitializer(~length=count, i =>
|
|
12
|
+
hex->String.substring(~start=i * 2, ~end=i * 2 + 2)->Int.fromString(~radix=16)->Option.getOr(0)
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
Whether a random byte may be folded into an alphabet of `size`, or must be drawn
|
|
18
|
+
again.
|
|
19
|
+
|
|
20
|
+
🚨 **`byte % size` is not uniform, and this predicate is the whole defence.** 256
|
|
21
|
+
is not a multiple of 10, so bytes 250–255 fold onto digits 0–5 and make those six
|
|
22
|
+
about four percent likelier each than the other four. An attacker guessing a
|
|
23
|
+
numeric code guesses the likelier half first. Discarding everything at or above
|
|
24
|
+
the largest whole multiple of `size` costs a few extra draws and makes every
|
|
25
|
+
character equally likely.
|
|
26
|
+
|
|
27
|
+
Separate and named because the skew it prevents is far too small to catch by
|
|
28
|
+
sampling the output — it is checked directly, over every byte value.
|
|
29
|
+
*/
|
|
30
|
+
let accepts = (~byte: int, ~size: int): bool => byte < 256 - mod(256, size)
|
|
31
|
+
|
|
32
|
+
let _draw = (~length: int, ~from: string): string => {
|
|
33
|
+
let size = from->String.length
|
|
34
|
+
let out = []
|
|
35
|
+
while out->Array.length < length {
|
|
36
|
+
// Over-draw: most bytes are kept, so one round is nearly always enough, and
|
|
37
|
+
// a short round simply goes again.
|
|
38
|
+
_bytes((length - out->Array.length) * 2 + 8)->Array.forEach(byte =>
|
|
39
|
+
if accepts(~byte, ~size) && out->Array.length < length {
|
|
40
|
+
out->Array.push(from->String.charAt(mod(byte, size)))
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
out->Array.join("")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
The provider both platforms pass.
|
|
49
|
+
|
|
50
|
+
`length` is refused rather than clamped when it is not positive: a caller asking
|
|
51
|
+
for a zero-length token has a bug, and answering `""` would hand back something
|
|
52
|
+
that compares equal to nothing and settles every challenge.
|
|
53
|
+
*/
|
|
54
|
+
let provider: Reventless.Secrets.t = {
|
|
55
|
+
randomToken: (~length, ~alphabet) =>
|
|
56
|
+
length <= 0
|
|
57
|
+
? Error(Unavailable(`a token needs a positive length, not ${length->Int.toString}`))
|
|
58
|
+
: Ok(_draw(~length, ~from=Reventless.Secrets.characters(alphabet))),
|
|
59
|
+
hash: input => Ok(NodeCrypto.sha256Hex(input)),
|
|
60
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as NodeCrypto from "@reventlessdev/rescript-node/src/NodeCrypto.res.mjs";
|
|
4
|
+
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
5
|
+
import * as Nodecrypto from "node:crypto";
|
|
6
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
7
|
+
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
8
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
9
|
+
import * as Secrets$Reventless from "@reventlessdev/reventless-spec/src/semantic/Secrets.res.mjs";
|
|
10
|
+
|
|
11
|
+
function _bytes(count) {
|
|
12
|
+
let hex = Nodecrypto.randomBytes(count).toString("hex");
|
|
13
|
+
return Stdlib_Array.fromInitializer(count, i => Stdlib_Option.getOr(Stdlib_Int.fromString(hex.substring((i << 1), (i << 1) + 2 | 0), 16), 0));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function accepts(byte, size) {
|
|
17
|
+
return byte < (256 - Primitive_int.mod_(256, size) | 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function _draw(length, from) {
|
|
21
|
+
let size = from.length;
|
|
22
|
+
let out = [];
|
|
23
|
+
while (out.length < length) {
|
|
24
|
+
_bytes(((length - out.length | 0) << 1) + 8 | 0).forEach(byte => {
|
|
25
|
+
if (accepts(byte, size) && out.length < length) {
|
|
26
|
+
out.push(from.charAt(Primitive_int.mod_(byte, size)));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
return out.join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function provider_randomToken(length, alphabet) {
|
|
35
|
+
if (length <= 0) {
|
|
36
|
+
return {
|
|
37
|
+
TAG: "Error",
|
|
38
|
+
_0: {
|
|
39
|
+
TAG: "Unavailable",
|
|
40
|
+
_0: `a token needs a positive length, not ` + length.toString()
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
} else {
|
|
44
|
+
return {
|
|
45
|
+
TAG: "Ok",
|
|
46
|
+
_0: _draw(length, Secrets$Reventless.characters(alphabet))
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function provider_hash(input) {
|
|
52
|
+
return {
|
|
53
|
+
TAG: "Ok",
|
|
54
|
+
_0: NodeCrypto.sha256Hex(input)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let provider = {
|
|
59
|
+
randomToken: provider_randomToken,
|
|
60
|
+
hash: provider_hash
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
_bytes,
|
|
65
|
+
accepts,
|
|
66
|
+
_draw,
|
|
67
|
+
provider,
|
|
68
|
+
}
|
|
69
|
+
/* NodeCrypto Not a pure module */
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The secret source. What matters here is not that it produces characters, but
|
|
4
|
+
// that it produces them uniformly — a token drawn from a skewed distribution is
|
|
5
|
+
// guessable in a way that no other test would notice.
|
|
6
|
+
|
|
7
|
+
module Secrets = Reventless.Secrets
|
|
8
|
+
module Backend = Secrets_Node_Backend
|
|
9
|
+
|
|
10
|
+
let token = (~length, ~alphabet) =>
|
|
11
|
+
Backend.provider.randomToken(~length, ~alphabet)->Result.getOr("")
|
|
12
|
+
|
|
13
|
+
describe("randomToken", () => {
|
|
14
|
+
testSync("draws the requested length", () =>
|
|
15
|
+
expect(
|
|
16
|
+
[1, 6, 32, 64]->Array.map(length => token(~length, ~alphabet=Digits)->String.length),
|
|
17
|
+
)->toEqual([1, 6, 32, 64])
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
testSync("draws only from the alphabet it was given", () => {
|
|
21
|
+
let allowed = Secrets.characters(UrlSafe)
|
|
22
|
+
expect(
|
|
23
|
+
token(~length=512, ~alphabet=UrlSafe)
|
|
24
|
+
->String.split("")
|
|
25
|
+
->Array.every(c => allowed->String.includes(c)),
|
|
26
|
+
)->toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
testSync("digits are digits", () =>
|
|
30
|
+
expect(
|
|
31
|
+
token(~length=512, ~alphabet=Digits)
|
|
32
|
+
->String.split("")
|
|
33
|
+
->Array.every(c => "0123456789"->String.includes(c)),
|
|
34
|
+
)->toBe(true)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// Not proof of randomness — it is proof the source is not a constant, which is
|
|
38
|
+
// the failure a fixed or broken binding would produce.
|
|
39
|
+
testSync("two draws differ", () =>
|
|
40
|
+
expect(token(~length=32, ~alphabet=UrlSafe) == token(~length=32, ~alphabet=UrlSafe))->toBe(
|
|
41
|
+
false,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// A zero-length token would compare equal to nothing and settle every
|
|
46
|
+
// challenge presented against it, so it is refused rather than clamped.
|
|
47
|
+
testSync("a non-positive length is refused, not clamped", () =>
|
|
48
|
+
expect(
|
|
49
|
+
[0, -1]->Array.map(
|
|
50
|
+
length => Backend.provider.randomToken(~length, ~alphabet=Digits)->Result.isError,
|
|
51
|
+
),
|
|
52
|
+
)->toEqual([true, true])
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// 🚨 Checked over every byte value rather than by sampling the output, and the
|
|
57
|
+
// first attempt here got that wrong. `byte % 10` skews the low six digits by
|
|
58
|
+
// about four percent each — roughly 0.609 of draws landing in the low half
|
|
59
|
+
// against 0.600 for a fair source. At any sample size a test can afford that
|
|
60
|
+
// gap is a couple of standard errors, so a sampled assertion either flakes or,
|
|
61
|
+
// as the first version did, passes happily with the defence deleted.
|
|
62
|
+
describe("accepts — the rejection rule", () => {
|
|
63
|
+
let rejected = size =>
|
|
64
|
+
Array.fromInitializer(~length=256, i => i)->Array.filter(byte => !Backend.accepts(~byte, ~size))
|
|
65
|
+
|
|
66
|
+
testSync("rejects exactly the bytes that would skew a 10-character alphabet", () =>
|
|
67
|
+
expect(rejected(10))->toEqual([250, 251, 252, 253, 254, 255])
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// 64 divides 256 exactly, so nothing needs discarding and a url-safe token
|
|
71
|
+
// costs no extra draws.
|
|
72
|
+
testSync("rejects nothing when the alphabet divides evenly", () =>
|
|
73
|
+
expect(rejected(64))->toEqual([])
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// What survives always folds onto a whole number of cycles, which is the
|
|
77
|
+
// property that makes every character equally likely.
|
|
78
|
+
testSync("what it accepts is a whole multiple of the alphabet", () =>
|
|
79
|
+
expect(
|
|
80
|
+
[2, 3, 7, 10, 16, 26, 62, 64]->Array.map(
|
|
81
|
+
size => mod(256 - rejected(size)->Array.length, size),
|
|
82
|
+
),
|
|
83
|
+
)->toEqual([0, 0, 0, 0, 0, 0, 0, 0])
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
testSync("never discards more than one cycle's worth", () =>
|
|
87
|
+
expect([3, 7, 10, 26, 62]->Array.map(size => rejected(size)->Array.length < size))->toEqual([
|
|
88
|
+
true,
|
|
89
|
+
true,
|
|
90
|
+
true,
|
|
91
|
+
true,
|
|
92
|
+
true,
|
|
93
|
+
])
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe("hash", () => {
|
|
98
|
+
testSync("is deterministic, which is what lets a proof be compared", () =>
|
|
99
|
+
expect(Backend.provider.hash("a-secret") == Backend.provider.hash("a-secret"))->toBe(true)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
testSync("separates different inputs", () =>
|
|
103
|
+
expect(Backend.provider.hash("a-secret") == Backend.provider.hash("a-secret "))->toBe(false)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// One-way: the digest must not carry the secret it was made from.
|
|
107
|
+
testSync("does not contain its input", () =>
|
|
108
|
+
expect(
|
|
109
|
+
Backend.provider.hash("a-secret")
|
|
110
|
+
->Result.getOr("")
|
|
111
|
+
->String.includes("a-secret"),
|
|
112
|
+
)->toBe(false)
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("Capabilities.none.secrets", () => {
|
|
117
|
+
// Refusing rather than degrading: there is no weaker token that would be safe
|
|
118
|
+
// to hand back, so an unwired platform must fail rather than produce one.
|
|
119
|
+
testSync("refuses both operations", () =>
|
|
120
|
+
expect((
|
|
121
|
+
Reventless.Capabilities.none.secrets.randomToken(
|
|
122
|
+
~length=32,
|
|
123
|
+
~alphabet=UrlSafe,
|
|
124
|
+
)->Result.isError,
|
|
125
|
+
Reventless.Capabilities.none.secrets.hash("x")->Result.isError,
|
|
126
|
+
))->toEqual((true, true))
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe("Secrets.fixed", () => {
|
|
131
|
+
// What a consumer's suite uses to know the secret before presenting it.
|
|
132
|
+
let provider = Secrets.fixed(~token="known", ~hash=input => `hashed:${input}`)
|
|
133
|
+
|
|
134
|
+
testSync("hands back the token it was built with", () =>
|
|
135
|
+
expect(provider.randomToken(~length=32, ~alphabet=UrlSafe))->toEqual(Ok("known"))
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
testSync("hashes through the function it was given", () =>
|
|
139
|
+
expect(provider.hash("known"))->toEqual(Ok("hashed:known"))
|
|
140
|
+
)
|
|
141
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
|
+
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
5
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
6
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
7
|
+
import * as Secrets$Reventless from "@reventlessdev/reventless-spec/src/semantic/Secrets.res.mjs";
|
|
8
|
+
import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
|
|
9
|
+
import * as Secrets_Node_Backend$ReventlessCore from "../src/adapter/Secrets/Secrets_Node_Backend.res.mjs";
|
|
10
|
+
|
|
11
|
+
function token(length, alphabet) {
|
|
12
|
+
return Stdlib_Result.getOr(Secrets_Node_Backend$ReventlessCore.provider.randomToken(length, alphabet), "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
globalThis.describe("randomToken", () => {
|
|
16
|
+
globalThis.test("draws the requested length", () => {
|
|
17
|
+
globalThis.expect([
|
|
18
|
+
1,
|
|
19
|
+
6,
|
|
20
|
+
32,
|
|
21
|
+
64
|
|
22
|
+
].map(length => token(length, "Digits").length)).toEqual([
|
|
23
|
+
1,
|
|
24
|
+
6,
|
|
25
|
+
32,
|
|
26
|
+
64
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
globalThis.test("draws only from the alphabet it was given", () => {
|
|
30
|
+
let allowed = Secrets$Reventless.characters("UrlSafe");
|
|
31
|
+
globalThis.expect(token(512, "UrlSafe").split("").every(c => allowed.includes(c))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
globalThis.test("digits are digits", () => {
|
|
34
|
+
globalThis.expect(token(512, "Digits").split("").every(c => "0123456789".includes(c))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
globalThis.test("two draws differ", () => {
|
|
37
|
+
globalThis.expect(token(32, "UrlSafe") === token(32, "UrlSafe")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
globalThis.test("a non-positive length is refused, not clamped", () => {
|
|
40
|
+
globalThis.expect([
|
|
41
|
+
0,
|
|
42
|
+
-1
|
|
43
|
+
].map(length => Stdlib_Result.isError(Secrets_Node_Backend$ReventlessCore.provider.randomToken(length, "Digits")))).toEqual([
|
|
44
|
+
true,
|
|
45
|
+
true
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
globalThis.describe("accepts — the rejection rule", () => {
|
|
51
|
+
let rejected = size => Stdlib_Array.fromInitializer(256, i => i).filter(byte => !Secrets_Node_Backend$ReventlessCore.accepts(byte, size));
|
|
52
|
+
globalThis.test("rejects exactly the bytes that would skew a 10-character alphabet", () => {
|
|
53
|
+
globalThis.expect(rejected(10)).toEqual([
|
|
54
|
+
250,
|
|
55
|
+
251,
|
|
56
|
+
252,
|
|
57
|
+
253,
|
|
58
|
+
254,
|
|
59
|
+
255
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
globalThis.test("rejects nothing when the alphabet divides evenly", () => {
|
|
63
|
+
globalThis.expect(rejected(64)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
globalThis.test("what it accepts is a whole multiple of the alphabet", () => {
|
|
66
|
+
globalThis.expect([
|
|
67
|
+
2,
|
|
68
|
+
3,
|
|
69
|
+
7,
|
|
70
|
+
10,
|
|
71
|
+
16,
|
|
72
|
+
26,
|
|
73
|
+
62,
|
|
74
|
+
64
|
|
75
|
+
].map(size => Primitive_int.mod_(256 - rejected(size).length | 0, size))).toEqual([
|
|
76
|
+
0,
|
|
77
|
+
0,
|
|
78
|
+
0,
|
|
79
|
+
0,
|
|
80
|
+
0,
|
|
81
|
+
0,
|
|
82
|
+
0,
|
|
83
|
+
0
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
globalThis.test("never discards more than one cycle's worth", () => {
|
|
87
|
+
globalThis.expect([
|
|
88
|
+
3,
|
|
89
|
+
7,
|
|
90
|
+
10,
|
|
91
|
+
26,
|
|
92
|
+
62
|
|
93
|
+
].map(size => rejected(size).length < size)).toEqual([
|
|
94
|
+
true,
|
|
95
|
+
true,
|
|
96
|
+
true,
|
|
97
|
+
true,
|
|
98
|
+
true
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
globalThis.describe("hash", () => {
|
|
104
|
+
globalThis.test("is deterministic, which is what lets a proof be compared", () => {
|
|
105
|
+
globalThis.expect(Primitive_object.equal(Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"), Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"))).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
globalThis.test("separates different inputs", () => {
|
|
108
|
+
globalThis.expect(Primitive_object.equal(Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"), Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret "))).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
globalThis.test("does not contain its input", () => {
|
|
111
|
+
globalThis.expect(Stdlib_Result.getOr(Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"), "").includes("a-secret")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
globalThis.describe("Capabilities.none.secrets", () => {
|
|
116
|
+
globalThis.test("refuses both operations", () => {
|
|
117
|
+
globalThis.expect([
|
|
118
|
+
Stdlib_Result.isError(Capabilities$Reventless.none.secrets.randomToken(32, "UrlSafe")),
|
|
119
|
+
Stdlib_Result.isError(Capabilities$Reventless.none.secrets.hash("x"))
|
|
120
|
+
]).toEqual([
|
|
121
|
+
true,
|
|
122
|
+
true
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
globalThis.describe("Secrets.fixed", () => {
|
|
128
|
+
let provider = Secrets$Reventless.fixed("known", input => `hashed:` + input);
|
|
129
|
+
globalThis.test("hands back the token it was built with", () => {
|
|
130
|
+
globalThis.expect(provider.randomToken(32, "UrlSafe")).toEqual({
|
|
131
|
+
TAG: "Ok",
|
|
132
|
+
_0: "known"
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
globalThis.test("hashes through the function it was given", () => {
|
|
136
|
+
globalThis.expect(provider.hash("known")).toEqual({
|
|
137
|
+
TAG: "Ok",
|
|
138
|
+
_0: "hashed:known"
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let Secrets;
|
|
144
|
+
|
|
145
|
+
let Backend;
|
|
146
|
+
|
|
147
|
+
export {
|
|
148
|
+
Secrets,
|
|
149
|
+
Backend,
|
|
150
|
+
token,
|
|
151
|
+
}
|
|
152
|
+
/* Not a pure module */
|
|
@@ -206,14 +206,23 @@ describe("the internal commands declare the edges they move rows along", () => {
|
|
|
206
206
|
// was open to everyone and the server refused anyone outside `Admin`. The
|
|
207
207
|
// mismatch is invisible until a non-admin clicks the page.
|
|
208
208
|
describe("the Plugins view publishes the rule the server enforces", () => {
|
|
209
|
-
|
|
209
|
+
// 🚨 **This is also the only check `Reventless.AdminGroup.name` can carry.** A
|
|
210
|
+
// PPX annotation takes a literal, so `@@reventless.authorize(AllowGroups(
|
|
211
|
+
// ["Admin"]))` above cannot reference the constant — the constant's job is to
|
|
212
|
+
// make everything *else* agree with the annotation, and nothing in the compiler
|
|
213
|
+
// spans that gap. The actual side here comes from the expanded annotation and
|
|
214
|
+
// the expected side from the constant, so the two are compared rather than
|
|
215
|
+
// restated. Writing the literal on both sides would pass while they diverged.
|
|
216
|
+
testSync("the spec declares the group, and it is the one the constant names", () =>
|
|
210
217
|
expect(PluginsReadModelSpec.authorization)->toEqual(
|
|
211
|
-
Reventless.Authorization.AllowGroups([
|
|
218
|
+
Reventless.Authorization.AllowGroups([Reventless.AdminGroup.name]),
|
|
212
219
|
)
|
|
213
220
|
)
|
|
214
221
|
|
|
215
222
|
testSync("and the published access keys are derived from it", () =>
|
|
216
|
-
expect(Platform_Admin_Structure.pluginReadModel.requiredAccess)->toEqual(
|
|
223
|
+
expect(Platform_Admin_Structure.pluginReadModel.requiredAccess)->toEqual(
|
|
224
|
+
Some([Reventless.AdminGroup.name]),
|
|
225
|
+
)
|
|
217
226
|
)
|
|
218
227
|
|
|
219
228
|
// The API entry reads the same binding rather than restating it, so the two
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
6
|
+
import * as AdminGroup$Reventless from "@reventlessdev/reventless-spec/src/types/AdminGroup.res.mjs";
|
|
6
7
|
import * as Plugin_Structure$ReventlessCore from "../../src/plugin/component/Plugin_Structure.res.mjs";
|
|
7
8
|
import * as PluginBaseFragment$ReventlessCore from "../../src/plugin/api/PluginBaseFragment.res.mjs";
|
|
8
9
|
import * as PluginsReadModelSpec$ReventlessCore from "../../src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
|
|
@@ -154,14 +155,14 @@ globalThis.describe("the internal commands declare the edges they move rows alon
|
|
|
154
155
|
});
|
|
155
156
|
|
|
156
157
|
globalThis.describe("the Plugins view publishes the rule the server enforces", () => {
|
|
157
|
-
globalThis.test("the spec declares the group", () => {
|
|
158
|
+
globalThis.test("the spec declares the group, and it is the one the constant names", () => {
|
|
158
159
|
globalThis.expect(PluginsReadModelSpec$ReventlessCore.authorization).toEqual({
|
|
159
160
|
TAG: "AllowGroups",
|
|
160
|
-
_0: [
|
|
161
|
+
_0: [AdminGroup$Reventless.name]
|
|
161
162
|
});
|
|
162
163
|
});
|
|
163
164
|
globalThis.test("and the published access keys are derived from it", () => {
|
|
164
|
-
globalThis.expect(Platform_Admin_Structure$ReventlessCore.pluginReadModel.requiredAccess).toEqual([
|
|
165
|
+
globalThis.expect(Platform_Admin_Structure$ReventlessCore.pluginReadModel.requiredAccess).toEqual([AdminGroup$Reventless.name]);
|
|
165
166
|
});
|
|
166
167
|
globalThis.test("the query entry carries the spec's rule and no hand-written pair", () => {
|
|
167
168
|
let entry = PluginBaseFragment$ReventlessCore.queryEntries[0];
|