@reventlessdev/reventless-core 3.0.0-alpha.233 → 3.0.0-alpha.235
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 +14 -0
- package/package.json +8 -8
- package/src/adapter/Auth/Auth_RefusalVocabulary.res +168 -0
- package/src/adapter/Auth/Auth_RefusalVocabulary.res.mjs +80 -0
- package/src/admin/Platform_BakedManifest.res +41 -2
- package/src/admin/Platform_BakedManifest.res.mjs +22 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res +21 -3
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +6 -2
- package/src/components/Dcb/Dcb_Builder.res +1 -1
- package/src/plugin/lifecycle/PluginSpec.res +1 -1
- package/tests/adapter/RefusalVocabularyTest.res +93 -0
- package/tests/adapter/RefusalVocabularyTest.res.mjs +79 -0
- package/tests/admin/Platform_BakedManifestTest.res +61 -16
- package/tests/admin/Platform_BakedManifestTest.res.mjs +60 -49
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +7 -7
- package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
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.235 (2026-08-15)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **core:** curate the pages a shell builds across a plugin's views ([589835a](https://github.com/ReventlessDev/reventless-core/commit/589835a1ad428c5e2dea8bf6e4c64e49d2f67e0d))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.234 (2026-08-14)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **core:** one contract for which refusal a platform gave ([7705c13](https://github.com/ReventlessDev/reventless-core/commit/7705c13ec63f41a3a659bc98cfb45b880fd5b222))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.233 (2026-08-14)
|
|
7
21
|
|
|
8
22
|
**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.235",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
"sury": "11.0.0-alpha.4",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
|
-
"@reventlessdev/rescript-
|
|
32
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.7",
|
|
33
33
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
34
|
-
"@reventlessdev/rescript-
|
|
35
|
-
"@reventlessdev/rescript-
|
|
34
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.7",
|
|
35
|
+
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
|
+
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.7",
|
|
37
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
38
|
-
"@reventlessdev/
|
|
39
|
-
"@reventlessdev/reventless-
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-interop": "3.0.0-alpha.31"
|
|
39
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.31",
|
|
40
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.114",
|
|
41
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.142"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The cross-provider contract for "which refusal did I just get" — see
|
|
2
|
+
// [docs/plans/appsync-refusal-vocabulary.md].
|
|
3
|
+
//
|
|
4
|
+
// An authorization gate has two ways to refuse and they ask the caller for
|
|
5
|
+
// opposite things. A caller whose credentials did not verify should present new
|
|
6
|
+
// ones. A caller who is simply not entitled will meet the same answer however
|
|
7
|
+
// many times they authenticate — asking them to sign in again ends a working
|
|
8
|
+
// session and fixes nothing.
|
|
9
|
+
//
|
|
10
|
+
// Both adapters draw that distinction. They express it in shapes that have
|
|
11
|
+
// nothing in common, because neither chose its own vocabulary: the local adapter
|
|
12
|
+
// sets `extensions.code` on a GraphQL error, and AppSync refuses at two
|
|
13
|
+
// different layers with `errorType` values the service picks. A client written
|
|
14
|
+
// against one is quietly wrong against the other — it reads for a key that is
|
|
15
|
+
// never present, finds nothing, and proceeds as though nothing was refused.
|
|
16
|
+
//
|
|
17
|
+
// So this module is a mapping, not an equality. It cannot make the two paths
|
|
18
|
+
// answer alike; `extensions` is not even reachable from an AppSync resolver.
|
|
19
|
+
// What it can do is let a caller ask the one question that matters — *was this
|
|
20
|
+
// about entitlement or about identity?* — and get an answer without knowing
|
|
21
|
+
// which adapter it is talking to.
|
|
22
|
+
//
|
|
23
|
+
// Every row here is an observed response, captured against a deployed API and
|
|
24
|
+
// the local server, not a reading of documentation.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
Which of the two refusals a response carries.
|
|
28
|
+
|
|
29
|
+
`Entitlement` — the caller was identified and is not permitted. Re-authenticating
|
|
30
|
+
changes nothing; the session is still good.
|
|
31
|
+
|
|
32
|
+
`Identity` — the caller was not identified. New credentials are the remedy, and
|
|
33
|
+
discarding the session is correct here and only here.
|
|
34
|
+
*/
|
|
35
|
+
type refusalKind =
|
|
36
|
+
| Entitlement
|
|
37
|
+
| Identity
|
|
38
|
+
|
|
39
|
+
/** The adapter a signal belongs to. */
|
|
40
|
+
type adapter =
|
|
41
|
+
| Local
|
|
42
|
+
| AppSync
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
One observed refusal shape.
|
|
46
|
+
|
|
47
|
+
`discriminator` names where a client looks; `value` is what it finds there.
|
|
48
|
+
Together with `httpStatus` they are sufficient to classify a response — see
|
|
49
|
+
[classify], which is what callers should use rather than re-deriving this.
|
|
50
|
+
*/
|
|
51
|
+
type signal = {
|
|
52
|
+
adapter: adapter,
|
|
53
|
+
kind: refusalKind,
|
|
54
|
+
httpStatus: int,
|
|
55
|
+
discriminator: string,
|
|
56
|
+
value: string,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
The `extensions.code` the local adapter sets for each refusal.
|
|
61
|
+
|
|
62
|
+
Exported so the adapter emits these rather than repeating the literals: a table
|
|
63
|
+
the emitting code does not read is documentation, and documentation drifts. The
|
|
64
|
+
AppSync values have no equivalent here — the service picks those, which is the
|
|
65
|
+
whole reason this module is a mapping rather than a shared constant.
|
|
66
|
+
*/
|
|
67
|
+
let localEntitlementCode = "FORBIDDEN"
|
|
68
|
+
|
|
69
|
+
/** See [localEntitlementCode]. */
|
|
70
|
+
let localIdentityCode = "UNAUTHORIZED"
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
The correspondence both adapters are held to.
|
|
74
|
+
|
|
75
|
+
🚨 **The two AppSync values differ by a suffix and mean opposite things.**
|
|
76
|
+
`Unauthorized` is the entitlement refusal; `UnauthorizedException` is the identity
|
|
77
|
+
one. A client matching on the substring `"Unauthorized"` — the obvious thing to
|
|
78
|
+
write — collapses exactly the distinction this table exists to preserve, and
|
|
79
|
+
collapses it in the dangerous direction: it treats "you lack the role" as "your
|
|
80
|
+
session is dead" and signs the caller out of a session that was working.
|
|
81
|
+
|
|
82
|
+
Use [classify]. It is the only reason this table is data rather than prose.
|
|
83
|
+
*/
|
|
84
|
+
let signals: array<signal> = [
|
|
85
|
+
{
|
|
86
|
+
adapter: Local,
|
|
87
|
+
kind: Entitlement,
|
|
88
|
+
httpStatus: 200,
|
|
89
|
+
discriminator: "extensions.code",
|
|
90
|
+
value: localEntitlementCode,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
adapter: Local,
|
|
94
|
+
kind: Identity,
|
|
95
|
+
httpStatus: 200,
|
|
96
|
+
discriminator: "extensions.code",
|
|
97
|
+
value: localIdentityCode,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
adapter: AppSync,
|
|
101
|
+
kind: Entitlement,
|
|
102
|
+
httpStatus: 200,
|
|
103
|
+
discriminator: "errors[].errorType",
|
|
104
|
+
value: "Unauthorized",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
// The request never becomes a GraphQL execution: the service rejects it
|
|
108
|
+
// before the schema is reached, so there is no field error to read and the
|
|
109
|
+
// status line is the whole answer.
|
|
110
|
+
adapter: AppSync,
|
|
111
|
+
kind: Identity,
|
|
112
|
+
httpStatus: 401,
|
|
113
|
+
discriminator: "x-amzn-errortype",
|
|
114
|
+
value: "UnauthorizedException",
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
Classify a refusal without knowing which adapter produced it.
|
|
120
|
+
|
|
121
|
+
Pass what the response actually carried: its status, the `errorType` of a GraphQL
|
|
122
|
+
error if there was one, and `extensions.code` if there was one. Returns `None`
|
|
123
|
+
when the response is not a refusal this contract recognises — which includes a
|
|
124
|
+
successful response, so a caller must not read `None` as "allowed".
|
|
125
|
+
|
|
126
|
+
Ordering matters and is the point of the function. The transport status is
|
|
127
|
+
checked first, because a 401 is the identity refusal on the AppSync path and its
|
|
128
|
+
`x-amzn-errortype` (`UnauthorizedException`) shares a prefix with the entitlement
|
|
129
|
+
value (`Unauthorized`). Matching the error type first would classify every
|
|
130
|
+
unidentified AppSync caller as merely unentitled.
|
|
131
|
+
*/
|
|
132
|
+
let classify = (
|
|
133
|
+
~httpStatus: int,
|
|
134
|
+
~errorType: option<string>=?,
|
|
135
|
+
~extensionsCode: option<string>=?,
|
|
136
|
+
): option<refusalKind> =>
|
|
137
|
+
if httpStatus == 401 {
|
|
138
|
+
Some(Identity)
|
|
139
|
+
} else {
|
|
140
|
+
switch extensionsCode {
|
|
141
|
+
| Some(c) if c == localEntitlementCode => Some(Entitlement)
|
|
142
|
+
| Some(c) if c == localIdentityCode => Some(Identity)
|
|
143
|
+
| _ =>
|
|
144
|
+
switch errorType {
|
|
145
|
+
// Exact match, never a prefix or `includes` — see the warning on [signals].
|
|
146
|
+
| Some("Unauthorized") => Some(Entitlement)
|
|
147
|
+
| Some("UnauthorizedException") => Some(Identity)
|
|
148
|
+
| _ => None
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The signals one adapter can produce. */
|
|
154
|
+
let signalsFor = (adapter: adapter): array<signal> =>
|
|
155
|
+
signals->Array.filter(s => s.adapter == adapter)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
Whether a refusal means the caller should be asked to authenticate again.
|
|
159
|
+
|
|
160
|
+
The one decision the distinction exists to drive, kept here so no client has to
|
|
161
|
+
re-derive it — and so the wrong answer cannot be arrived at independently in
|
|
162
|
+
several places.
|
|
163
|
+
*/
|
|
164
|
+
let warrantsReauthentication = (kind: refusalKind): bool =>
|
|
165
|
+
switch kind {
|
|
166
|
+
| Identity => true
|
|
167
|
+
| Entitlement => false
|
|
168
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
let localEntitlementCode = "FORBIDDEN";
|
|
5
|
+
|
|
6
|
+
let localIdentityCode = "UNAUTHORIZED";
|
|
7
|
+
|
|
8
|
+
let signals = [
|
|
9
|
+
{
|
|
10
|
+
adapter: "Local",
|
|
11
|
+
kind: "Entitlement",
|
|
12
|
+
httpStatus: 200,
|
|
13
|
+
discriminator: "extensions.code",
|
|
14
|
+
value: localEntitlementCode
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
adapter: "Local",
|
|
18
|
+
kind: "Identity",
|
|
19
|
+
httpStatus: 200,
|
|
20
|
+
discriminator: "extensions.code",
|
|
21
|
+
value: localIdentityCode
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
adapter: "AppSync",
|
|
25
|
+
kind: "Entitlement",
|
|
26
|
+
httpStatus: 200,
|
|
27
|
+
discriminator: "errors[].errorType",
|
|
28
|
+
value: "Unauthorized"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
adapter: "AppSync",
|
|
32
|
+
kind: "Identity",
|
|
33
|
+
httpStatus: 401,
|
|
34
|
+
discriminator: "x-amzn-errortype",
|
|
35
|
+
value: "UnauthorizedException"
|
|
36
|
+
}
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function classify(httpStatus, errorType, extensionsCode) {
|
|
40
|
+
if (httpStatus === 401) {
|
|
41
|
+
return "Identity";
|
|
42
|
+
}
|
|
43
|
+
if (extensionsCode !== undefined) {
|
|
44
|
+
if (extensionsCode === localEntitlementCode) {
|
|
45
|
+
return "Entitlement";
|
|
46
|
+
}
|
|
47
|
+
if (extensionsCode === localIdentityCode) {
|
|
48
|
+
return "Identity";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (errorType === undefined) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
switch (errorType) {
|
|
55
|
+
case "Unauthorized" :
|
|
56
|
+
return "Entitlement";
|
|
57
|
+
case "UnauthorizedException" :
|
|
58
|
+
return "Identity";
|
|
59
|
+
default:
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function signalsFor(adapter) {
|
|
65
|
+
return signals.filter(s => s.adapter === adapter);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function warrantsReauthentication(kind) {
|
|
69
|
+
return kind !== "Entitlement";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
localEntitlementCode,
|
|
74
|
+
localIdentityCode,
|
|
75
|
+
signals,
|
|
76
|
+
classify,
|
|
77
|
+
signalsFor,
|
|
78
|
+
warrantsReauthentication,
|
|
79
|
+
}
|
|
80
|
+
/* No side effect */
|
|
@@ -20,6 +20,22 @@ type error =
|
|
|
20
20
|
| UnknownPlugin(string)
|
|
21
21
|
| UnknownView({plugin: string, view: string})
|
|
22
22
|
| UnknownCommand({plugin: string, command: string})
|
|
23
|
+
| UnknownDerived({plugin: string, kind: string})
|
|
24
|
+
|
|
25
|
+
/** The pages a shell builds across a plugin's views rather than from any one of
|
|
26
|
+
them, as the vocabulary a deployment curates them by.
|
|
27
|
+
|
|
28
|
+
A closed set, and deliberately kinds rather than page names. A dashboard and
|
|
29
|
+
a lifecycle page are named by the framework, but a canvas is named by the
|
|
30
|
+
views it draws — from that deployment's hints and the view modes its shell
|
|
31
|
+
registered, neither of which this side can see. Curating by kind is the
|
|
32
|
+
question this side CAN answer, and it is also the question an author is
|
|
33
|
+
asking: "does this audience get calendars", not "does it get the one called
|
|
34
|
+
Deliveries".
|
|
35
|
+
|
|
36
|
+
Ordered as they appear in a menu (the dashboard tops the group, canvases and
|
|
37
|
+
schedulers tail it), so the vocabulary reads as the page set it curates. */
|
|
38
|
+
let derivedKinds = ["dashboard", "lifecycles", "canvas", "scheduler"]
|
|
23
39
|
|
|
24
40
|
let describe = (e: error): string =>
|
|
25
41
|
switch e {
|
|
@@ -28,16 +44,26 @@ let describe = (e: error): string =>
|
|
|
28
44
|
`baked manifest: plugin "${plugin}" has no view named "${view}"`
|
|
29
45
|
| UnknownCommand({plugin, command}) =>
|
|
30
46
|
`baked manifest: plugin "${plugin}" has no command named "${command}"`
|
|
47
|
+
| UnknownDerived({plugin, kind}) =>
|
|
48
|
+
`baked manifest: plugin "${plugin}" names derived page kind "${kind}" — ` ++
|
|
49
|
+
`known kinds: ${derivedKinds->Array.join(", ")}`
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
// Views are named by component name — a view IS the surface. Commands are named
|
|
34
53
|
// by command name, not by the write-side component that carries them: the
|
|
35
54
|
// command is what a user invokes, and which slice or aggregate it lives on is an
|
|
36
55
|
// implementation detail the author should not have to know to curate a shop.
|
|
56
|
+
//
|
|
57
|
+
// `derived` is neither: it names page KINDS, for the reason `derivedKinds`
|
|
58
|
+
// gives. Unlike the other two, what it selects is not filtered here — there is
|
|
59
|
+
// nothing in a `pluginStructure` to filter, because the pages do not exist until
|
|
60
|
+
// a shell builds them. It travels to the shell instead, which is the only place
|
|
61
|
+
// that can honour it.
|
|
37
62
|
type selection = {
|
|
38
63
|
plugin: string,
|
|
39
64
|
views: option<array<string>>,
|
|
40
65
|
commands: option<array<string>>,
|
|
66
|
+
derived: option<array<string>>,
|
|
41
67
|
}
|
|
42
68
|
|
|
43
69
|
let publicViews = (def: pluginStructure): array<queryableDef> =>
|
|
@@ -65,7 +91,16 @@ let validate = (~def: pluginStructure, sel: selection): result<unit, error> => {
|
|
|
65
91
|
| None =>
|
|
66
92
|
switch sel.commands->known(def->commandNames) {
|
|
67
93
|
| Some(command) => Error(UnknownCommand({plugin: sel.plugin, command}))
|
|
68
|
-
| None =>
|
|
94
|
+
| None =>
|
|
95
|
+
// Against the vocabulary rather than against this plugin: a kind that
|
|
96
|
+
// generates no page here is a fact about the plugin's schemas, not a
|
|
97
|
+
// mistake — a shop naming `lifecycles` for a plugin whose views gain a
|
|
98
|
+
// status field next month is stating an intention, and failing it would
|
|
99
|
+
// punish the deployment for being early.
|
|
100
|
+
switch sel.derived->known(derivedKinds) {
|
|
101
|
+
| Some(kind) => Error(UnknownDerived({plugin: sel.plugin, kind}))
|
|
102
|
+
| None => Ok()
|
|
103
|
+
}
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
106
|
}
|
|
@@ -182,7 +217,11 @@ let curate = (
|
|
|
182
217
|
| Some((pluginId, def)) =>
|
|
183
218
|
curateStructure(~pluginId, ~def, sel)->Result.map(curated => {
|
|
184
219
|
entries->Array.push(
|
|
185
|
-
Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
|
|
220
|
+
Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
|
|
221
|
+
~pluginId,
|
|
222
|
+
~derived=?sel.derived,
|
|
223
|
+
curated,
|
|
224
|
+
),
|
|
186
225
|
)
|
|
187
226
|
entries
|
|
188
227
|
})
|
|
@@ -6,6 +6,13 @@ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
|
6
6
|
import * as Plugin$ReventlessCore from "../plugin/component/Plugin.res.mjs";
|
|
7
7
|
import * as Platform_ComponentDefinitionsApi$ReventlessCore from "./Platform_ComponentDefinitionsApi.res.mjs";
|
|
8
8
|
|
|
9
|
+
let derivedKinds = [
|
|
10
|
+
"dashboard",
|
|
11
|
+
"lifecycles",
|
|
12
|
+
"canvas",
|
|
13
|
+
"scheduler"
|
|
14
|
+
];
|
|
15
|
+
|
|
9
16
|
function describe(e) {
|
|
10
17
|
switch (e.TAG) {
|
|
11
18
|
case "UnknownPlugin" :
|
|
@@ -14,6 +21,8 @@ function describe(e) {
|
|
|
14
21
|
return `baked manifest: plugin "` + e.plugin + `" has no view named "` + e.view + `"`;
|
|
15
22
|
case "UnknownCommand" :
|
|
16
23
|
return `baked manifest: plugin "` + e.plugin + `" has no command named "` + e.command + `"`;
|
|
24
|
+
case "UnknownDerived" :
|
|
25
|
+
return `baked manifest: plugin "` + e.plugin + `" names derived page kind "` + e.kind + `" — known kinds: ` + derivedKinds.join(", ");
|
|
17
26
|
}
|
|
18
27
|
}
|
|
19
28
|
|
|
@@ -52,6 +61,17 @@ function validate(def, sel) {
|
|
|
52
61
|
command: command
|
|
53
62
|
}
|
|
54
63
|
};
|
|
64
|
+
}
|
|
65
|
+
let kind = known(sel.derived, derivedKinds);
|
|
66
|
+
if (kind !== undefined) {
|
|
67
|
+
return {
|
|
68
|
+
TAG: "Error",
|
|
69
|
+
_0: {
|
|
70
|
+
TAG: "UnknownDerived",
|
|
71
|
+
plugin: sel.plugin,
|
|
72
|
+
kind: kind
|
|
73
|
+
}
|
|
74
|
+
};
|
|
55
75
|
} else {
|
|
56
76
|
return {
|
|
57
77
|
TAG: "Ok",
|
|
@@ -189,13 +209,14 @@ function curate(structures, selections) {
|
|
|
189
209
|
}
|
|
190
210
|
let pluginId = match[0];
|
|
191
211
|
return Stdlib_Result.map(curateStructure(pluginId, match[1], sel), curated => {
|
|
192
|
-
entries.push(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(pluginId, curated));
|
|
212
|
+
entries.push(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(pluginId, sel.derived, curated));
|
|
193
213
|
return entries;
|
|
194
214
|
});
|
|
195
215
|
})), prim => prim);
|
|
196
216
|
}
|
|
197
217
|
|
|
198
218
|
export {
|
|
219
|
+
derivedKinds,
|
|
199
220
|
describe,
|
|
200
221
|
publicViews,
|
|
201
222
|
writeSides,
|
|
@@ -207,8 +207,21 @@ let encodeExtensionDef = (e: extensionDef): JSON.t =>
|
|
|
207
207
|
("commandTypes", encodeStrings(e.commandTypes)),
|
|
208
208
|
])->JSON.Encode.object
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// `derived` is curation, not structure: it says which of the pages a shell
|
|
211
|
+
// builds across this plugin's views that shell may build. It therefore arrives
|
|
212
|
+
// as an argument rather than off `def` — nothing in a compiled plugin decides
|
|
213
|
+
// it, and the admin path, which curates nothing, passes none.
|
|
214
|
+
//
|
|
215
|
+
// Absent key ⇒ every kind, which is what an uncurated entry means and what
|
|
216
|
+
// every manifest written before the field says. A curated entry that wants none
|
|
217
|
+
// says so with `[]`, and the two are only distinguishable because the key is
|
|
218
|
+
// omitted rather than nulled.
|
|
219
|
+
let encodePluginStructureEntry = (
|
|
220
|
+
~pluginId: string,
|
|
221
|
+
~derived: option<array<string>>=?,
|
|
222
|
+
def: pluginStructure,
|
|
223
|
+
): JSON.t => {
|
|
224
|
+
let entry = Dict.fromArray([
|
|
212
225
|
("pluginId", JSON.Encode.string(Plugin.name(pluginId))),
|
|
213
226
|
(
|
|
214
227
|
"readModels",
|
|
@@ -247,4 +260,9 @@ let encodePluginStructureEntry = (~pluginId: string, def: pluginStructure): JSON
|
|
|
247
260
|
->JSON.Encode.array,
|
|
248
261
|
),
|
|
249
262
|
("extensions", def.extensions->Array.map(encodeExtensionDef)->JSON.Encode.array),
|
|
250
|
-
])
|
|
263
|
+
])
|
|
264
|
+
derived->Option.forEach(kinds =>
|
|
265
|
+
entry->Dict.set("derivedPages", kinds->Array.map(JSON.Encode.string)->JSON.Encode.array)
|
|
266
|
+
)
|
|
267
|
+
entry->JSON.Encode.object
|
|
268
|
+
}
|
|
@@ -339,8 +339,8 @@ function encodeExtensionDef(e) {
|
|
|
339
339
|
]);
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
function encodePluginStructureEntry(pluginId, def) {
|
|
343
|
-
|
|
342
|
+
function encodePluginStructureEntry(pluginId, derived, def) {
|
|
343
|
+
let entry = Object.fromEntries([
|
|
344
344
|
[
|
|
345
345
|
"pluginId",
|
|
346
346
|
Plugin$ReventlessCore.name(pluginId)
|
|
@@ -382,6 +382,10 @@ function encodePluginStructureEntry(pluginId, def) {
|
|
|
382
382
|
def.extensions.map(encodeExtensionDef)
|
|
383
383
|
]
|
|
384
384
|
]);
|
|
385
|
+
Stdlib_Option.forEach(derived, kinds => {
|
|
386
|
+
entry["derivedPages"] = kinds.map(prim => prim);
|
|
387
|
+
});
|
|
388
|
+
return entry;
|
|
385
389
|
}
|
|
386
390
|
|
|
387
391
|
let sdlQueryField = ` Platform_ComponentDefinitions: [Platform_ComponentDefinitionEntry!]!`;
|
|
@@ -895,7 +895,7 @@ module Make = (
|
|
|
895
895
|
//
|
|
896
896
|
// Stage E2: a DCB StateChangeSlice has a single GraphQL field but its
|
|
897
897
|
// command type may declare multiple constructors with different
|
|
898
|
-
// authorization rules. The
|
|
898
|
+
// authorization rules. The Cognito group directive operates at field
|
|
899
899
|
// granularity, so we read the auth for the first constructor (matching
|
|
900
900
|
// the existing dcbTags convention at line 549 above). When all
|
|
901
901
|
// constructors share the file-level default, this is exact; when they
|
|
@@ -17,7 +17,7 @@ type command =
|
|
|
17
17
|
| @noApi Connect(pluginDefinition)
|
|
18
18
|
| @noApi Disconnect(version)
|
|
19
19
|
// Admin lifecycle commands — API-exposed (auto-derived admin mutations,
|
|
20
|
-
// Cognito
|
|
20
|
+
// Cognito group-gated). `version` selects which known version to act on.
|
|
21
21
|
| Activate(version)
|
|
22
22
|
| Deactivate(version)
|
|
23
23
|
// Records a protocol-version incompatibility without changing connection state.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The contract in Auth_RefusalVocabulary is a mapping between two adapters that
|
|
4
|
+
// cannot answer alike. The AWS rows cannot be asserted from a checkout — they
|
|
5
|
+
// need a deployed API — so what is asserted here is the *table*: that it names
|
|
6
|
+
// both adapters, that entitlement stays distinguishable from identity in each,
|
|
7
|
+
// and that `classify` resolves the collision the two AppSync values create.
|
|
8
|
+
//
|
|
9
|
+
// Observed bodies behind these rows: docs/plans/appsync-group-authorization-unenforced.md §8.
|
|
10
|
+
|
|
11
|
+
open ReventlessCore.Auth_RefusalVocabulary
|
|
12
|
+
|
|
13
|
+
describe("Auth_RefusalVocabulary — the table", () => {
|
|
14
|
+
testSync("names both adapters", () => {
|
|
15
|
+
expect(signalsFor(Local)->Array.length)->toBeGreaterThan(0)
|
|
16
|
+
expect(signalsFor(AppSync)->Array.length)->toBeGreaterThan(0)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
testSync("each adapter can express both refusals", () => {
|
|
20
|
+
[Local, AppSync]->Array.forEach(adapter => {
|
|
21
|
+
let kinds = signalsFor(adapter)->Array.map(s => s.kind)
|
|
22
|
+
expect(kinds->Array.includes(Entitlement))->toBe(true)
|
|
23
|
+
expect(kinds->Array.includes(Identity))->toBe(true)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
testSync("within an adapter the two refusals are actually distinguishable", () => {
|
|
28
|
+
// A mapping whose two rows look identical to a client would be worthless —
|
|
29
|
+
// it has to differ in the status, the place to look, or the value found.
|
|
30
|
+
[Local, AppSync]->Array.forEach(adapter => {
|
|
31
|
+
let signals = signalsFor(adapter)
|
|
32
|
+
let entitlement = signals->Array.find(s => s.kind == Entitlement)
|
|
33
|
+
let identity = signals->Array.find(s => s.kind == Identity)
|
|
34
|
+
switch (entitlement, identity) {
|
|
35
|
+
| (Some(e), Some(i)) =>
|
|
36
|
+
let differs =
|
|
37
|
+
e.httpStatus != i.httpStatus || e.discriminator != i.discriminator || e.value != i.value
|
|
38
|
+
expect(differs)->toBe(true)
|
|
39
|
+
| _ => JsError.throwWithMessage("adapter is missing one of the two refusals")
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("Auth_RefusalVocabulary.classify", () => {
|
|
46
|
+
testSync("classifies every row of the table as that row's kind", () => {
|
|
47
|
+
signals->Array.forEach(s => {
|
|
48
|
+
let errorType = s.discriminator->String.includes("errorType") ? Some(s.value) : None
|
|
49
|
+
let extensionsCode = s.discriminator == "extensions.code" ? Some(s.value) : None
|
|
50
|
+
// The 401 row's discriminator is a response header; status alone carries it.
|
|
51
|
+
expect(classify(~httpStatus=s.httpStatus, ~errorType?, ~extensionsCode?))->toEqual(Some(s.kind))
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// The trap the table exists to defuse: on AppSync the entitlement value is a
|
|
56
|
+
// strict prefix of the identity one. A client matching by substring collapses
|
|
57
|
+
// them, and collapses them the dangerous way — signing out a caller whose
|
|
58
|
+
// session was fine.
|
|
59
|
+
testSync("Unauthorized and UnauthorizedException are opposite answers", () => {
|
|
60
|
+
expect(classify(~httpStatus=200, ~errorType="Unauthorized"))->toEqual(Some(Entitlement))
|
|
61
|
+
expect(classify(~httpStatus=401, ~errorType="UnauthorizedException"))->toEqual(Some(Identity))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
testSync("a 401 is identity even when a field error would say otherwise", () => {
|
|
65
|
+
// Status is checked first precisely so this cannot go the other way.
|
|
66
|
+
expect(classify(~httpStatus=401, ~errorType="Unauthorized"))->toEqual(Some(Identity))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
testSync("the local codes map to the same two kinds as the AppSync shapes", () => {
|
|
70
|
+
expect(classify(~httpStatus=200, ~extensionsCode="FORBIDDEN"))->toEqual(Some(Entitlement))
|
|
71
|
+
expect(classify(~httpStatus=200, ~extensionsCode="UNAUTHORIZED"))->toEqual(Some(Identity))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
testSync("an unrecognised response is None, which is not 'allowed'", () => {
|
|
75
|
+
expect(classify(~httpStatus=200))->toEqual(None)
|
|
76
|
+
expect(classify(~httpStatus=200, ~errorType="ValidationError"))->toEqual(None)
|
|
77
|
+
expect(classify(~httpStatus=500))->toEqual(None)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe("Auth_RefusalVocabulary.warrantsReauthentication", () => {
|
|
82
|
+
testSync("only an identity refusal asks the caller for new credentials", () => {
|
|
83
|
+
expect(warrantsReauthentication(Identity))->toBe(true)
|
|
84
|
+
expect(warrantsReauthentication(Entitlement))->toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
testSync("no adapter's entitlement refusal ends a session", () => {
|
|
88
|
+
// The regression this whole contract exists to prevent, stated once.
|
|
89
|
+
signals
|
|
90
|
+
->Array.filter(s => s.kind == Entitlement)
|
|
91
|
+
->Array.forEach(s => expect(warrantsReauthentication(s.kind))->toBe(false))
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
4
|
+
import * as Auth_RefusalVocabulary$ReventlessCore from "../../src/adapter/Auth/Auth_RefusalVocabulary.res.mjs";
|
|
5
|
+
|
|
6
|
+
globalThis.describe("Auth_RefusalVocabulary — the table", () => {
|
|
7
|
+
globalThis.test("names both adapters", () => {
|
|
8
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.signalsFor("Local").length).toBeGreaterThan(0);
|
|
9
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.signalsFor("AppSync").length).toBeGreaterThan(0);
|
|
10
|
+
});
|
|
11
|
+
globalThis.test("each adapter can express both refusals", () => {
|
|
12
|
+
[
|
|
13
|
+
"Local",
|
|
14
|
+
"AppSync"
|
|
15
|
+
].forEach(adapter => {
|
|
16
|
+
let kinds = Auth_RefusalVocabulary$ReventlessCore.signalsFor(adapter).map(s => s.kind);
|
|
17
|
+
globalThis.expect(kinds.includes("Entitlement")).toBe(true);
|
|
18
|
+
globalThis.expect(kinds.includes("Identity")).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
globalThis.test("within an adapter the two refusals are actually distinguishable", () => {
|
|
22
|
+
[
|
|
23
|
+
"Local",
|
|
24
|
+
"AppSync"
|
|
25
|
+
].forEach(adapter => {
|
|
26
|
+
let signals = Auth_RefusalVocabulary$ReventlessCore.signalsFor(adapter);
|
|
27
|
+
let entitlement = signals.find(s => s.kind === "Entitlement");
|
|
28
|
+
let identity = signals.find(s => s.kind === "Identity");
|
|
29
|
+
if (entitlement === undefined) {
|
|
30
|
+
return Stdlib_JsError.throwWithMessage("adapter is missing one of the two refusals");
|
|
31
|
+
}
|
|
32
|
+
if (identity === undefined) {
|
|
33
|
+
return Stdlib_JsError.throwWithMessage("adapter is missing one of the two refusals");
|
|
34
|
+
}
|
|
35
|
+
let differs = entitlement.httpStatus !== identity.httpStatus || entitlement.discriminator !== identity.discriminator || entitlement.value !== identity.value;
|
|
36
|
+
globalThis.expect(differs).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
globalThis.describe("Auth_RefusalVocabulary.classify", () => {
|
|
42
|
+
globalThis.test("classifies every row of the table as that row's kind", () => {
|
|
43
|
+
Auth_RefusalVocabulary$ReventlessCore.signals.forEach(s => {
|
|
44
|
+
let errorType = s.discriminator.includes("errorType") ? s.value : undefined;
|
|
45
|
+
let extensionsCode = s.discriminator === "extensions.code" ? s.value : undefined;
|
|
46
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(s.httpStatus, errorType, extensionsCode)).toEqual(s.kind);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
globalThis.test("Unauthorized and UnauthorizedException are opposite answers", () => {
|
|
50
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, "Unauthorized", undefined)).toEqual("Entitlement");
|
|
51
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(401, "UnauthorizedException", undefined)).toEqual("Identity");
|
|
52
|
+
});
|
|
53
|
+
globalThis.test("a 401 is identity even when a field error would say otherwise", () => {
|
|
54
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(401, "Unauthorized", undefined)).toEqual("Identity");
|
|
55
|
+
});
|
|
56
|
+
globalThis.test("the local codes map to the same two kinds as the AppSync shapes", () => {
|
|
57
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, undefined, "FORBIDDEN")).toEqual("Entitlement");
|
|
58
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, undefined, "UNAUTHORIZED")).toEqual("Identity");
|
|
59
|
+
});
|
|
60
|
+
globalThis.test("an unrecognised response is None, which is not 'allowed'", () => {
|
|
61
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, undefined, undefined)).toEqual(undefined);
|
|
62
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, "ValidationError", undefined)).toEqual(undefined);
|
|
63
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(500, undefined, undefined)).toEqual(undefined);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
globalThis.describe("Auth_RefusalVocabulary.warrantsReauthentication", () => {
|
|
68
|
+
globalThis.test("only an identity refusal asks the caller for new credentials", () => {
|
|
69
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.warrantsReauthentication("Identity")).toBe(true);
|
|
70
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.warrantsReauthentication("Entitlement")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
globalThis.test("no adapter's entitlement refusal ends a session", () => {
|
|
73
|
+
Auth_RefusalVocabulary$ReventlessCore.signals.filter(s => s.kind === "Entitlement").forEach(s => {
|
|
74
|
+
globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.warrantsReauthentication(s.kind)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/* Not a pure module */
|
|
@@ -89,6 +89,15 @@ let structures = [("Ordering@1.0.0", structure)]
|
|
|
89
89
|
let bake = selections =>
|
|
90
90
|
Platform_BakedManifest.curate(~structures, ~selections)
|
|
91
91
|
|
|
92
|
+
// Every case below varies one include-list and means "everything" by the rest,
|
|
93
|
+
// which is exactly what an omitted labelled argument says.
|
|
94
|
+
let sel = (~plugin, ~views=?, ~commands=?, ~derived=?): Platform_BakedManifest.selection => {
|
|
95
|
+
plugin,
|
|
96
|
+
views,
|
|
97
|
+
commands,
|
|
98
|
+
derived,
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
let entries = (json: JSON.t) => json->JSON.Decode.array->Option.getOr([])
|
|
93
102
|
|
|
94
103
|
let names = (entry: JSON.t, field: string) =>
|
|
@@ -110,19 +119,17 @@ let firstEntry = (r: result<JSON.t, Platform_BakedManifest.error>) =>
|
|
|
110
119
|
describe("curate", () => {
|
|
111
120
|
testSync("selects exactly the named components", () => {
|
|
112
121
|
let entry =
|
|
113
|
-
bake([
|
|
114
|
-
->firstEntry
|
|
122
|
+
bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["PlaceOrder"])])->firstEntry
|
|
115
123
|
expect((entry->names("stateViewSlices"), entry->names("readModels")))->toEqual((["Orders"], []))
|
|
116
124
|
})
|
|
117
125
|
|
|
118
126
|
testSync("drops a write side whose commands were all excluded", () => {
|
|
119
|
-
let entry =
|
|
120
|
-
bake([{plugin: "Ordering", views: Some([]), commands: Some(["PlaceOrder"])}])->firstEntry
|
|
127
|
+
let entry = bake([sel(~plugin="Ordering", ~views=[], ~commands=["PlaceOrder"])])->firstEntry
|
|
121
128
|
expect(entry->names("stateChangeSlices"))->toEqual(["PlaceOrder"])
|
|
122
129
|
})
|
|
123
130
|
|
|
124
131
|
testSync("unset views and commands include every public component", () => {
|
|
125
|
-
let entry = bake([
|
|
132
|
+
let entry = bake([sel(~plugin="Ordering")])->firstEntry
|
|
126
133
|
expect((
|
|
127
134
|
entry->names("readModels"),
|
|
128
135
|
entry->names("stateViewSlices"),
|
|
@@ -134,34 +141,72 @@ describe("curate", () => {
|
|
|
134
141
|
// from, so an Internal view a kept command points at has to survive the bake.
|
|
135
142
|
testSync("carries an Internal view referenced by an included command", () => {
|
|
136
143
|
let entry =
|
|
137
|
-
bake([
|
|
144
|
+
bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["PlaceOrder"])])
|
|
138
145
|
->firstEntry
|
|
139
146
|
expect(entry->names("internalQueryables"))->toEqual(["AvailableProducts"])
|
|
140
147
|
})
|
|
141
148
|
|
|
142
149
|
testSync("omits an Internal view no included command references", () => {
|
|
143
150
|
let entry =
|
|
144
|
-
bake([
|
|
151
|
+
bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["ImportOrders"])])
|
|
145
152
|
->firstEntry
|
|
146
153
|
expect(entry->names("internalQueryables"))->toEqual([])
|
|
147
154
|
})
|
|
148
155
|
|
|
149
|
-
|
|
156
|
+
// The pages a shell builds across a plugin's views do not exist on this side,
|
|
157
|
+
// so what the bake can do about them is state which kinds an audience gets and
|
|
158
|
+
// carry that to the shell. Absent and empty therefore have to stay two
|
|
159
|
+
// different answers all the way out to the JSON, which is what these three
|
|
160
|
+
// cases pin: nothing said ⇒ no key ⇒ every kind, and `[]` ⇒ a key ⇒ none.
|
|
161
|
+
let derivedOf = (entry: JSON.t) =>
|
|
162
|
+
entry
|
|
163
|
+
->JSON.Decode.object
|
|
164
|
+
->Option.flatMap(d => d->Dict.get("derivedPages"))
|
|
165
|
+
->Option.map(v => v->JSON.Decode.array->Option.getOr([])->Array.filterMap(JSON.Decode.string))
|
|
166
|
+
|
|
167
|
+
testSync("unset derived says nothing, leaving every kind to the shell", () =>
|
|
168
|
+
expect(bake([sel(~plugin="Ordering")])->firstEntry->derivedOf)->toEqual(None)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
testSync("an empty derived list is carried, and is not the same as unset", () =>
|
|
172
|
+
expect(bake([sel(~plugin="Ordering", ~derived=[])])->firstEntry->derivedOf)->toEqual(Some([]))
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
testSync("carries exactly the derived kinds named", () =>
|
|
176
|
+
expect(
|
|
177
|
+
bake([sel(~plugin="Ordering", ~derived=["lifecycles", "canvas"])])->firstEntry->derivedOf,
|
|
178
|
+
)->toEqual(Some(["lifecycles", "canvas"]))
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// Against the vocabulary, never against this plugin: a kind that generates no
|
|
182
|
+
// page for these schemas is a fact about the plugin, not a typo, and a shop
|
|
183
|
+
// that names `lifecycles` before its views carry a status is early, not wrong.
|
|
184
|
+
testSync("fails naming a derived kind outside the vocabulary", () =>
|
|
185
|
+
expect(bake([sel(~plugin="Ordering", ~derived=["dashboards"])]))->toEqual(
|
|
186
|
+
Error(Platform_BakedManifest.UnknownDerived({plugin: "Ordering", kind: "dashboards"})),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
testSync("accepts a kind this plugin generates no page for", () =>
|
|
150
191
|
expect(
|
|
151
|
-
bake([
|
|
152
|
-
)->toEqual(
|
|
192
|
+
bake([sel(~plugin="Ordering", ~derived=["scheduler"])])->firstEntry->derivedOf,
|
|
193
|
+
)->toEqual(Some(["scheduler"]))
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
testSync("an Internal view is never selectable as a view", () =>
|
|
197
|
+
expect(bake([sel(~plugin="Ordering", ~views=["AvailableProducts"])]))->toEqual(
|
|
153
198
|
Error(Platform_BakedManifest.UnknownView({plugin: "Ordering", view: "AvailableProducts"})),
|
|
154
199
|
)
|
|
155
200
|
)
|
|
156
201
|
|
|
157
202
|
testSync("fails naming an unknown plugin", () =>
|
|
158
|
-
expect(bake([
|
|
203
|
+
expect(bake([sel(~plugin="Shipping")]))->toEqual(
|
|
159
204
|
Error(Platform_BakedManifest.UnknownPlugin("Shipping")),
|
|
160
205
|
)
|
|
161
206
|
)
|
|
162
207
|
|
|
163
208
|
testSync("fails naming an unknown command", () =>
|
|
164
|
-
expect(bake([
|
|
209
|
+
expect(bake([sel(~plugin="Ordering", ~commands=["CancelOrder"])]))->toEqual(
|
|
165
210
|
Error(Platform_BakedManifest.UnknownCommand({plugin: "Ordering", command: "CancelOrder"})),
|
|
166
211
|
)
|
|
167
212
|
)
|
|
@@ -169,7 +214,7 @@ describe("curate", () => {
|
|
|
169
214
|
// The anti-drift assertion: include everything and the baked entry IS the
|
|
170
215
|
// served entry. Any future divergence in the encoder shows up here first.
|
|
171
216
|
testSync("an all-inclusive bake is byte-identical to the served entry", () => {
|
|
172
|
-
let baked = bake([
|
|
217
|
+
let baked = bake([sel(~plugin="Ordering")])->firstEntry
|
|
173
218
|
let served = Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
|
|
174
219
|
~pluginId="Ordering@1.0.0",
|
|
175
220
|
structure,
|
|
@@ -182,8 +227,8 @@ describe("curate", () => {
|
|
|
182
227
|
Platform_BakedManifest.curate(
|
|
183
228
|
~structures=[("Ordering@1.0.0", structure), ("Catalog@1.0.0", structure)],
|
|
184
229
|
~selections=[
|
|
185
|
-
|
|
186
|
-
|
|
230
|
+
sel(~plugin="Catalog", ~views=[], ~commands=[]),
|
|
231
|
+
sel(~plugin="Ordering", ~views=[], ~commands=[]),
|
|
187
232
|
],
|
|
188
233
|
)
|
|
189
234
|
let ids =
|
|
@@ -208,7 +253,7 @@ describe("curate", () => {
|
|
|
208
253
|
// makes this hold — the assertion guards that property, not a second code path.
|
|
209
254
|
testSync("a curated entry carries the owner field the served entry carries", () => {
|
|
210
255
|
let entry =
|
|
211
|
-
bake([
|
|
256
|
+
bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["PlaceOrder"])])
|
|
212
257
|
->firstEntry
|
|
213
258
|
let ownerOf = (key, name) =>
|
|
214
259
|
entry
|
|
@@ -109,6 +109,15 @@ function bake(selections) {
|
|
|
109
109
|
return Platform_BakedManifest$ReventlessCore.curate(structures, selections);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function sel(plugin, views, commands, derived) {
|
|
113
|
+
return {
|
|
114
|
+
plugin: plugin,
|
|
115
|
+
views: views,
|
|
116
|
+
commands: commands,
|
|
117
|
+
derived: derived
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
function entries(json) {
|
|
113
122
|
return Stdlib_Option.getOr(Stdlib_JSON.Decode.array(json), []);
|
|
114
123
|
}
|
|
@@ -127,11 +136,7 @@ function firstEntry(r) {
|
|
|
127
136
|
|
|
128
137
|
globalThis.describe("curate", () => {
|
|
129
138
|
globalThis.test("selects exactly the named components", () => {
|
|
130
|
-
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [
|
|
131
|
-
plugin: "Ordering",
|
|
132
|
-
views: ["Orders"],
|
|
133
|
-
commands: ["PlaceOrder"]
|
|
134
|
-
}]));
|
|
139
|
+
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["PlaceOrder"], undefined)]));
|
|
135
140
|
globalThis.expect([
|
|
136
141
|
names(entry, "stateViewSlices"),
|
|
137
142
|
names(entry, "readModels")
|
|
@@ -141,18 +146,15 @@ globalThis.describe("curate", () => {
|
|
|
141
146
|
]);
|
|
142
147
|
});
|
|
143
148
|
globalThis.test("drops a write side whose commands were all excluded", () => {
|
|
144
|
-
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [
|
|
145
|
-
plugin: "Ordering",
|
|
146
|
-
views: [],
|
|
147
|
-
commands: ["PlaceOrder"]
|
|
148
|
-
}]));
|
|
149
|
+
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", [], ["PlaceOrder"], undefined)]));
|
|
149
150
|
globalThis.expect(names(entry, "stateChangeSlices")).toEqual(["PlaceOrder"]);
|
|
150
151
|
});
|
|
151
152
|
globalThis.test("unset views and commands include every public component", () => {
|
|
152
153
|
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
|
|
153
154
|
plugin: "Ordering",
|
|
154
155
|
views: undefined,
|
|
155
|
-
commands: undefined
|
|
156
|
+
commands: undefined,
|
|
157
|
+
derived: undefined
|
|
156
158
|
}]));
|
|
157
159
|
globalThis.expect([
|
|
158
160
|
names(entry, "readModels"),
|
|
@@ -168,27 +170,49 @@ globalThis.describe("curate", () => {
|
|
|
168
170
|
]);
|
|
169
171
|
});
|
|
170
172
|
globalThis.test("carries an Internal view referenced by an included command", () => {
|
|
171
|
-
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [
|
|
172
|
-
plugin: "Ordering",
|
|
173
|
-
views: ["Orders"],
|
|
174
|
-
commands: ["PlaceOrder"]
|
|
175
|
-
}]));
|
|
173
|
+
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["PlaceOrder"], undefined)]));
|
|
176
174
|
globalThis.expect(names(entry, "internalQueryables")).toEqual(["AvailableProducts"]);
|
|
177
175
|
});
|
|
178
176
|
globalThis.test("omits an Internal view no included command references", () => {
|
|
179
|
-
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [
|
|
180
|
-
plugin: "Ordering",
|
|
181
|
-
views: ["Orders"],
|
|
182
|
-
commands: ["ImportOrders"]
|
|
183
|
-
}]));
|
|
177
|
+
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["ImportOrders"], undefined)]));
|
|
184
178
|
globalThis.expect(names(entry, "internalQueryables")).toEqual([]);
|
|
185
179
|
});
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
let derivedOf = entry => Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), d => d["derivedPages"]), v => Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(v), []), Stdlib_JSON.Decode.string));
|
|
181
|
+
globalThis.test("unset derived says nothing, leaving every kind to the shell", () => {
|
|
182
|
+
globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
|
|
188
183
|
plugin: "Ordering",
|
|
189
|
-
views:
|
|
190
|
-
commands: undefined
|
|
191
|
-
|
|
184
|
+
views: undefined,
|
|
185
|
+
commands: undefined,
|
|
186
|
+
derived: undefined
|
|
187
|
+
}])))).toEqual(undefined);
|
|
188
|
+
});
|
|
189
|
+
globalThis.test("an empty derived list is carried, and is not the same as unset", () => {
|
|
190
|
+
globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, [])])))).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
globalThis.test("carries exactly the derived kinds named", () => {
|
|
193
|
+
globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, [
|
|
194
|
+
"lifecycles",
|
|
195
|
+
"canvas"
|
|
196
|
+
])])))).toEqual([
|
|
197
|
+
"lifecycles",
|
|
198
|
+
"canvas"
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
globalThis.test("fails naming a derived kind outside the vocabulary", () => {
|
|
202
|
+
globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, ["dashboards"])])).toEqual({
|
|
203
|
+
TAG: "Error",
|
|
204
|
+
_0: {
|
|
205
|
+
TAG: "UnknownDerived",
|
|
206
|
+
plugin: "Ordering",
|
|
207
|
+
kind: "dashboards"
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
globalThis.test("accepts a kind this plugin generates no page for", () => {
|
|
212
|
+
globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, ["scheduler"])])))).toEqual(["scheduler"]);
|
|
213
|
+
});
|
|
214
|
+
globalThis.test("an Internal view is never selectable as a view", () => {
|
|
215
|
+
globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["AvailableProducts"], undefined, undefined)])).toEqual({
|
|
192
216
|
TAG: "Error",
|
|
193
217
|
_0: {
|
|
194
218
|
TAG: "UnknownView",
|
|
@@ -201,7 +225,8 @@ globalThis.describe("curate", () => {
|
|
|
201
225
|
globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
|
|
202
226
|
plugin: "Shipping",
|
|
203
227
|
views: undefined,
|
|
204
|
-
commands: undefined
|
|
228
|
+
commands: undefined,
|
|
229
|
+
derived: undefined
|
|
205
230
|
}])).toEqual({
|
|
206
231
|
TAG: "Error",
|
|
207
232
|
_0: {
|
|
@@ -211,11 +236,7 @@ globalThis.describe("curate", () => {
|
|
|
211
236
|
});
|
|
212
237
|
});
|
|
213
238
|
globalThis.test("fails naming an unknown command", () => {
|
|
214
|
-
globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
|
|
215
|
-
plugin: "Ordering",
|
|
216
|
-
views: undefined,
|
|
217
|
-
commands: ["CancelOrder"]
|
|
218
|
-
}])).toEqual({
|
|
239
|
+
globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, ["CancelOrder"], undefined)])).toEqual({
|
|
219
240
|
TAG: "Error",
|
|
220
241
|
_0: {
|
|
221
242
|
TAG: "UnknownCommand",
|
|
@@ -228,9 +249,10 @@ globalThis.describe("curate", () => {
|
|
|
228
249
|
let baked = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
|
|
229
250
|
plugin: "Ordering",
|
|
230
251
|
views: undefined,
|
|
231
|
-
commands: undefined
|
|
252
|
+
commands: undefined,
|
|
253
|
+
derived: undefined
|
|
232
254
|
}]));
|
|
233
|
-
let served = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering@1.0.0", structure);
|
|
255
|
+
let served = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering@1.0.0", undefined, structure);
|
|
234
256
|
globalThis.expect(JSON.stringify(baked)).toEqual(JSON.stringify(served));
|
|
235
257
|
});
|
|
236
258
|
globalThis.test("one entry per selection, in declaration order", () => {
|
|
@@ -244,16 +266,8 @@ globalThis.describe("curate", () => {
|
|
|
244
266
|
structure
|
|
245
267
|
]
|
|
246
268
|
], [
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
views: [],
|
|
250
|
-
commands: []
|
|
251
|
-
},
|
|
252
|
-
{
|
|
253
|
-
plugin: "Ordering",
|
|
254
|
-
views: [],
|
|
255
|
-
commands: []
|
|
256
|
-
}
|
|
269
|
+
sel("Catalog", [], [], undefined),
|
|
270
|
+
sel("Ordering", [], [], undefined)
|
|
257
271
|
]);
|
|
258
272
|
let ids;
|
|
259
273
|
ids = json.TAG === "Ok" ? Stdlib_Array.filterMap(entries(json._0), e => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(e), d => d["pluginId"]), Stdlib_JSON.Decode.string)) : [];
|
|
@@ -263,11 +277,7 @@ globalThis.describe("curate", () => {
|
|
|
263
277
|
]);
|
|
264
278
|
});
|
|
265
279
|
globalThis.test("a curated entry carries the owner field the served entry carries", () => {
|
|
266
|
-
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [
|
|
267
|
-
plugin: "Ordering",
|
|
268
|
-
views: ["Orders"],
|
|
269
|
-
commands: ["PlaceOrder"]
|
|
270
|
-
}]));
|
|
280
|
+
let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["PlaceOrder"], undefined)]));
|
|
271
281
|
let ownerOf = (key, name) => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), d => d[key]), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.object).find(d => Primitive_object.equal(Stdlib_Option.flatMap(d["name"], Stdlib_JSON.Decode.string), name)), d => d["ownerField"]), Stdlib_JSON.Decode.string);
|
|
272
282
|
let commandOwner = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Array.filterMap(Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), d => d["stateChangeSlices"]), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.object).flatMap(w => Stdlib_Option.getOr(Stdlib_Option.flatMap(w["commands"], Stdlib_JSON.Decode.array), [])), Stdlib_JSON.Decode.object)[0], d => d["ownerField"]), Stdlib_JSON.Decode.string);
|
|
273
283
|
globalThis.expect([
|
|
@@ -287,6 +297,7 @@ export {
|
|
|
287
297
|
structure,
|
|
288
298
|
structures,
|
|
289
299
|
bake,
|
|
300
|
+
sel,
|
|
290
301
|
entries,
|
|
291
302
|
names,
|
|
292
303
|
firstEntry,
|
|
@@ -147,7 +147,7 @@ let structure = {
|
|
|
147
147
|
requiredStoreDeclarations: undefined
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
-
let encoded = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", structure);
|
|
150
|
+
let encoded = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, structure);
|
|
151
151
|
|
|
152
152
|
let json = JSON.stringify(encoded);
|
|
153
153
|
|
|
@@ -255,7 +255,7 @@ globalThis.describe("translation-slice externalSystem round-trip", () => {
|
|
|
255
255
|
requiredStores: undefined,
|
|
256
256
|
requiredStoreDeclarations: undefined
|
|
257
257
|
};
|
|
258
|
-
let externalJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", externalStructure));
|
|
258
|
+
let externalJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, externalStructure));
|
|
259
259
|
globalThis.test("surfaces Some outbound externalSystem as its inner string", () => {
|
|
260
260
|
globalThis.expect(externalJson.includes("\"externalSystem\":\"ShipperGateway\"")).toEqual(true);
|
|
261
261
|
});
|
|
@@ -341,7 +341,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
|
|
|
341
341
|
requiredStores: undefined,
|
|
342
342
|
requiredStoreDeclarations: undefined
|
|
343
343
|
};
|
|
344
|
-
let mixedEntry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", mixed);
|
|
344
|
+
let mixedEntry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", undefined, mixed);
|
|
345
345
|
let namesAt = (entry, key) => Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), o => o[key]), Stdlib_JSON.Decode.array), []), c => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(c), o => o["name"]), Stdlib_JSON.Decode.string));
|
|
346
346
|
globalThis.test("excludes an Internal queryableDef from the encoded read-side", () => {
|
|
347
347
|
globalThis.expect(namesAt(mixedEntry, "readModels")).toEqual(["Products"]);
|
|
@@ -357,7 +357,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
|
|
|
357
357
|
]);
|
|
358
358
|
});
|
|
359
359
|
globalThis.test("encodes internalQueryables as [] for an all-Public entry, not a missing key", () => {
|
|
360
|
-
let entry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", {
|
|
360
|
+
let entry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", undefined, {
|
|
361
361
|
readModels: [qbl],
|
|
362
362
|
stateViewSlices: [],
|
|
363
363
|
stateChangeSlices: structure_stateChangeSlices,
|
|
@@ -459,7 +459,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
|
|
|
459
459
|
requiredStores: undefined,
|
|
460
460
|
requiredStoreDeclarations: undefined
|
|
461
461
|
};
|
|
462
|
-
let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Platform", structureWithStates));
|
|
462
|
+
let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Platform", undefined, structureWithStates));
|
|
463
463
|
globalThis.test("encodes populated allowedStates as a JSON array", () => {
|
|
464
464
|
globalThis.expect(json.includes("\"allowedStates\":[\"Inactive\"]")).toEqual(true);
|
|
465
465
|
});
|
|
@@ -481,7 +481,7 @@ globalThis.describe("singleQueryField reaches the caller", () => {
|
|
|
481
481
|
globalThis.expect(json.includes("\"singleQueryField\":\"Catalog_Product\"")).toEqual(true);
|
|
482
482
|
});
|
|
483
483
|
globalThis.test("encodes an unstated singular as null", () => {
|
|
484
|
-
let unstated = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", {
|
|
484
|
+
let unstated = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, {
|
|
485
485
|
readModels: [{
|
|
486
486
|
name: "Products",
|
|
487
487
|
queryField: "Catalog_Products",
|
|
@@ -526,7 +526,7 @@ globalThis.describe("idField / idFieldSource reach the caller", () => {
|
|
|
526
526
|
globalThis.expect(json.includes("\"idFieldSource\":\"convention\"")).toEqual(true);
|
|
527
527
|
});
|
|
528
528
|
globalThis.test("encodes an unresolved key as null on both fields", () => {
|
|
529
|
-
let unresolved = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", {
|
|
529
|
+
let unresolved = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, {
|
|
530
530
|
readModels: [{
|
|
531
531
|
name: "Products",
|
|
532
532
|
queryField: "Catalog_Products",
|
|
@@ -120,7 +120,7 @@ globalThis.describe("requiredAccess derived from the authorization rule", () =>
|
|
|
120
120
|
globalThis.expect(viewNamed("Orders")).toEqual(undefined);
|
|
121
121
|
});
|
|
122
122
|
globalThis.test("nothing to require encodes as null, not an empty list", () => {
|
|
123
|
-
let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("AccessPlugin", structure));
|
|
123
|
+
let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("AccessPlugin", undefined, structure));
|
|
124
124
|
globalThis.expect([
|
|
125
125
|
json.includes(`"requiredAccess":null`),
|
|
126
126
|
json.includes(`"requiredAccess":["Admin","Ops"]`),
|