@reventlessdev/reventless-core 3.0.0-alpha.224 → 3.0.0-alpha.225
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 +6 -6
- package/src/admin/Platform_Admin_Structure.res +6 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +9 -3
- package/src/admin/Platform_BakedManifest.res +154 -0
- package/src/admin/Platform_BakedManifest.res.mjs +156 -0
- package/src/admin/Platform_ComponentDefinitionsApi.res +9 -2
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
- package/src/components/Api/OwnerScopeDiagnostics.res +54 -0
- package/src/components/Api/OwnerScopeDiagnostics.res.mjs +27 -0
- package/src/components/Api/QueryDbListQuery.res +25 -1
- package/src/components/Api/QueryDbListQuery.res.mjs +10 -3
- package/src/components/Api/SuryToJsonSchema.res +19 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +18 -4
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +48 -0
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +27 -1
- package/src/plugin/component/Plugin_Structure.res +59 -3
- package/src/plugin/component/Plugin_Structure.res.mjs +29 -9
- package/tests/admin/Platform_BakedManifestTest.res +244 -0
- package/tests/admin/Platform_BakedManifestTest.res.mjs +294 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +10 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +24 -8
- package/tests/admin/Platform_PluginStructuresApiTest.res +2 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +6 -2
- package/tests/api/Api_IdsTest.res.mjs +1 -1
- package/tests/api/OwnerScopeDiagnosticsTest.res +109 -0
- package/tests/api/OwnerScopeDiagnosticsTest.res.mjs +91 -0
- package/tests/api/SuryToJsonSchemaTest.res +45 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +23 -0
- package/tests/commandgenerator/OwnerStampingTest.res +174 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +195 -0
- package/tests/plugin/PluginStructureAccessTest.res +110 -0
- package/tests/plugin/PluginStructureAccessTest.res.mjs +144 -0
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res +28 -0
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +84 -0
- package/tests/plugin/StateViewSlice/PsGatedView.res +15 -0
- package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +60 -0
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +4 -0
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.225 (2026-08-12)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **commands:** stamp owner-marked fields from the authenticated caller ([5266655](https://github.com/ReventlessDev/reventless-core/commit/5266655b34de4d90c8148dec686cb0f336ac223d))
|
|
11
|
+
* **core:** warn when an owner-scoped view exempts nobody ([cafa7a8](https://github.com/ReventlessDev/reventless-core/commit/cafa7a8b2082aeb00e53a9ed9da5bb978158db2d))
|
|
12
|
+
* **platform:** bake a curated component manifest as a static asset ([4f30265](https://github.com/ReventlessDev/reventless-core/commit/4f30265a51fc2c59e69afd8074cf1b2534c06378))
|
|
13
|
+
* **plugin:** publish the access a component's authorization rule implies ([e0d0f09](https://github.com/ReventlessDev/reventless-core/commit/e0d0f096b72fe44b185ed28dc1c133364fa841b8))
|
|
14
|
+
* **plugin:** publish which field ties a component to its owner ([ca71289](https://github.com/ReventlessDev/reventless-core/commit/ca7128931ce525af0d7d3a4487b2b5c54b19bec0))
|
|
15
|
+
* **queries:** narrow owner-bearing reads to the caller ([ba9cc3d](https://github.com/ReventlessDev/reventless-core/commit/ba9cc3d58d7914a9e4827bda90a704a74b1b82dd))
|
|
16
|
+
* **spec:** let a record name the field that identifies its owner ([b69ee91](https://github.com/ReventlessDev/reventless-core/commit/b69ee9123e6beb38fcdd716519103ab9328213c6))
|
|
17
|
+
* **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.224 (2026-08-11)
|
|
7
21
|
|
|
8
22
|
### Bug Fixes
|
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.225",
|
|
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-fast-csv": "2.0.0-alpha.4",
|
|
33
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
34
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
33
|
"@reventlessdev/rescript-node": "2.0.0-alpha.4",
|
|
34
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.4",
|
|
35
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
37
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
37
38
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.4",
|
|
38
39
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
39
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
40
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
41
|
-
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0"
|
|
40
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.135",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.109"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -76,6 +76,8 @@ let activateCommand: commandDef = {
|
|
|
76
76
|
allowedStates: Some(["Inactive"]),
|
|
77
77
|
targetState: None,
|
|
78
78
|
apiExposed: Some(true),
|
|
79
|
+
requiredAccess: None,
|
|
80
|
+
ownerField: None,
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
let deactivateCommand: commandDef = {
|
|
@@ -88,6 +90,8 @@ let deactivateCommand: commandDef = {
|
|
|
88
90
|
allowedStates: Some(["Connected", "Disconnected"]),
|
|
89
91
|
targetState: None,
|
|
90
92
|
apiExposed: Some(true),
|
|
93
|
+
requiredAccess: None,
|
|
94
|
+
ownerField: None,
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
let pluginAggregate: writableDef = {
|
|
@@ -131,6 +135,8 @@ let pluginReadModel: queryableDef = {
|
|
|
131
135
|
// with no `*Id` field.
|
|
132
136
|
idField: None,
|
|
133
137
|
idFieldSource: None,
|
|
138
|
+
requiredAccess: None,
|
|
139
|
+
ownerField: None,
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
let structure: pluginStructure = {
|
|
@@ -64,7 +64,9 @@ let activateCommand = {
|
|
|
64
64
|
references: activateCommand_references,
|
|
65
65
|
allowedStates: activateCommand_allowedStates,
|
|
66
66
|
targetState: undefined,
|
|
67
|
-
apiExposed: activateCommand_apiExposed
|
|
67
|
+
apiExposed: activateCommand_apiExposed,
|
|
68
|
+
requiredAccess: undefined,
|
|
69
|
+
ownerField: undefined
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
let deactivateCommand_schema = encodeSchema(idArgsSchema);
|
|
@@ -91,7 +93,9 @@ let deactivateCommand = {
|
|
|
91
93
|
references: deactivateCommand_references,
|
|
92
94
|
allowedStates: deactivateCommand_allowedStates,
|
|
93
95
|
targetState: undefined,
|
|
94
|
-
apiExposed: deactivateCommand_apiExposed
|
|
96
|
+
apiExposed: deactivateCommand_apiExposed,
|
|
97
|
+
requiredAccess: undefined,
|
|
98
|
+
ownerField: undefined
|
|
95
99
|
};
|
|
96
100
|
|
|
97
101
|
let pluginAggregate_commands = [
|
|
@@ -147,11 +151,13 @@ let pluginReadModel = {
|
|
|
147
151
|
searchableFields: pluginReadModel_searchableFields,
|
|
148
152
|
labelFieldSource: pluginReadModel_labelFieldSource,
|
|
149
153
|
statusField: pluginReadModel_statusField,
|
|
154
|
+
ownerField: undefined,
|
|
150
155
|
visibility: undefined,
|
|
151
156
|
chapter: undefined,
|
|
152
157
|
singleQueryField: pluginReadModel_singleQueryField,
|
|
153
158
|
idField: undefined,
|
|
154
|
-
idFieldSource: undefined
|
|
159
|
+
idFieldSource: undefined,
|
|
160
|
+
requiredAccess: undefined
|
|
155
161
|
};
|
|
156
162
|
|
|
157
163
|
let structure_readModels = [pluginReadModel];
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Curation for the baked component manifest: a deployment's include-list applied
|
|
2
|
+
// to the registered plugin structures, encoded through the very same encoder
|
|
3
|
+
// `Platform_ComponentDefinitions` uses.
|
|
4
|
+
//
|
|
5
|
+
// Curation is not authorization. This module decides WHAT EXISTS for a
|
|
6
|
+
// deployment's audience — deployment data, settled before anyone logs in. What a
|
|
7
|
+
// caller may do stays a server-side decision, enforced per query and per
|
|
8
|
+
// mutation, and nothing here relaxes it. That split is the reason a curated file
|
|
9
|
+
// can be static at all.
|
|
10
|
+
//
|
|
11
|
+
// The filtering happens on the `pluginStructure` record, never on the encoded
|
|
12
|
+
// JSON: whatever survives is then handed to
|
|
13
|
+
// `Platform_ComponentDefinitionsApi.encodePluginStructureEntry`, so a baked entry
|
|
14
|
+
// and a served entry cannot drift in shape, and an include-list that names
|
|
15
|
+
// everything produces byte-identical output.
|
|
16
|
+
|
|
17
|
+
open Reventless.Plugin
|
|
18
|
+
|
|
19
|
+
type error =
|
|
20
|
+
| UnknownPlugin(string)
|
|
21
|
+
| UnknownView({plugin: string, view: string})
|
|
22
|
+
| UnknownCommand({plugin: string, command: string})
|
|
23
|
+
|
|
24
|
+
let describe = (e: error): string =>
|
|
25
|
+
switch e {
|
|
26
|
+
| UnknownPlugin(plugin) => `baked manifest: no registered plugin named "${plugin}"`
|
|
27
|
+
| UnknownView({plugin, view}) =>
|
|
28
|
+
`baked manifest: plugin "${plugin}" has no view named "${view}"`
|
|
29
|
+
| UnknownCommand({plugin, command}) =>
|
|
30
|
+
`baked manifest: plugin "${plugin}" has no command named "${command}"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Views are named by component name — a view IS the surface. Commands are named
|
|
34
|
+
// by command name, not by the write-side component that carries them: the
|
|
35
|
+
// command is what a user invokes, and which slice or aggregate it lives on is an
|
|
36
|
+
// implementation detail the author should not have to know to curate a shop.
|
|
37
|
+
type selection = {
|
|
38
|
+
plugin: string,
|
|
39
|
+
views: option<array<string>>,
|
|
40
|
+
commands: option<array<string>>,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let publicViews = (def: pluginStructure): array<queryableDef> =>
|
|
44
|
+
Array.concat(def.readModels, def.stateViewSlices)->Array.filter(
|
|
45
|
+
Platform_ComponentDefinitionsApi.isPublicQueryable,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
let writeSides = (def: pluginStructure): array<writableDef> =>
|
|
49
|
+
Array.concat(def.stateChangeSlices, def.aggregates)
|
|
50
|
+
|
|
51
|
+
let commandNames = (def: pluginStructure): array<string> =>
|
|
52
|
+
def->writeSides->Array.flatMap(w => w.commands->Array.map(c => c.name))
|
|
53
|
+
|
|
54
|
+
// Every name in the selection has to resolve. A silent no-op here ships a shell
|
|
55
|
+
// with a page missing for a reason no log explains — the same class of failure as
|
|
56
|
+
// a malformed hints file, and it gets the same treatment: fail the deploy.
|
|
57
|
+
let validate = (~def: pluginStructure, sel: selection): result<unit, error> => {
|
|
58
|
+
let known = (names, candidates) =>
|
|
59
|
+
names
|
|
60
|
+
->Option.getOr([])
|
|
61
|
+
->Array.find(name => !(candidates->Array.includes(name)))
|
|
62
|
+
|
|
63
|
+
switch sel.views->known(def->publicViews->Array.map(q => q.name)) {
|
|
64
|
+
| Some(view) => Error(UnknownView({plugin: sel.plugin, view}))
|
|
65
|
+
| None =>
|
|
66
|
+
switch sel.commands->known(def->commandNames) {
|
|
67
|
+
| Some(command) => Error(UnknownCommand({plugin: sel.plugin, command}))
|
|
68
|
+
| None => Ok()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let isSelected = (selected: option<array<string>>, name: string): bool =>
|
|
74
|
+
switch selected {
|
|
75
|
+
| None => true
|
|
76
|
+
| Some(names) => names->Array.includes(name)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// An Internal view survives the bake only when an included command references it.
|
|
80
|
+
// It is carried for one reason — to be a reference target — and a shell reading a
|
|
81
|
+
// baked manifest has no admin API to recover a missing one from, so leaving it out
|
|
82
|
+
// breaks the picker permanently in the one deployment shape with no fallback.
|
|
83
|
+
// Naming it discloses nothing: the domain API already serves that view to the same
|
|
84
|
+
// caller (Internal is a menu hint, not a boundary).
|
|
85
|
+
let referencedEntities = (~pluginName: string, kept: array<writableDef>): array<string> =>
|
|
86
|
+
kept
|
|
87
|
+
->Array.flatMap(w => w.commands)
|
|
88
|
+
->Array.flatMap(c => c.references)
|
|
89
|
+
->Array.filterMap(r =>
|
|
90
|
+
switch r.plugin {
|
|
91
|
+
| Some(p) if Plugin.name(p) !== pluginName => None
|
|
92
|
+
| _ => Some(r.entity)
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
let curateStructure = (
|
|
97
|
+
~pluginId: string,
|
|
98
|
+
~def: pluginStructure,
|
|
99
|
+
sel: selection,
|
|
100
|
+
): result<pluginStructure, error> =>
|
|
101
|
+
validate(~def, sel)->Result.map(() => {
|
|
102
|
+
let pluginName = Plugin.name(pluginId)
|
|
103
|
+
let keepCommands = (ws: array<writableDef>) =>
|
|
104
|
+
ws
|
|
105
|
+
->Array.map(w => {...w, commands: w.commands->Array.filter(c => sel.commands->isSelected(c.name))})
|
|
106
|
+
// A write side left with no command contributes no surface. Dropping it
|
|
107
|
+
// keeps the curated entry a description of what the app offers rather than
|
|
108
|
+
// a list of components whose commands were all removed.
|
|
109
|
+
->Array.filter(w => w.commands->Array.length > 0)
|
|
110
|
+
|
|
111
|
+
let stateChangeSlices = def.stateChangeSlices->keepCommands
|
|
112
|
+
let aggregates = def.aggregates->keepCommands
|
|
113
|
+
let referenced = referencedEntities(
|
|
114
|
+
~pluginName,
|
|
115
|
+
Array.concat(stateChangeSlices, aggregates),
|
|
116
|
+
)
|
|
117
|
+
let keepViews = (qs: array<queryableDef>) =>
|
|
118
|
+
qs->Array.filter(q =>
|
|
119
|
+
if Platform_ComponentDefinitionsApi.isPublicQueryable(q) {
|
|
120
|
+
sel.views->isSelected(q.name)
|
|
121
|
+
} else {
|
|
122
|
+
referenced->Array.includes(q.name)
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
...def,
|
|
128
|
+
readModels: def.readModels->keepViews,
|
|
129
|
+
stateViewSlices: def.stateViewSlices->keepViews,
|
|
130
|
+
stateChangeSlices,
|
|
131
|
+
aggregates,
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// The whole file: one entry per selection, in the order the deployment declared
|
|
136
|
+
// them, so the include-list also states the order a consumer reads plugins in.
|
|
137
|
+
let curate = (
|
|
138
|
+
~structures: array<(string, pluginStructure)>,
|
|
139
|
+
~selections: array<selection>,
|
|
140
|
+
): result<JSON.t, error> =>
|
|
141
|
+
selections->Array.reduce(Ok([]), (acc, sel) =>
|
|
142
|
+
acc->Result.flatMap(entries =>
|
|
143
|
+
switch structures->Array.find(((pluginId, _)) => Plugin.name(pluginId) === sel.plugin) {
|
|
144
|
+
| None => Error(UnknownPlugin(sel.plugin))
|
|
145
|
+
| Some((pluginId, def)) =>
|
|
146
|
+
curateStructure(~pluginId, ~def, sel)->Result.map(curated => {
|
|
147
|
+
entries->Array.push(
|
|
148
|
+
Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, curated),
|
|
149
|
+
)
|
|
150
|
+
entries
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
)->Result.map(JSON.Encode.array)
|
|
@@ -0,0 +1,156 @@
|
|
|
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 Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
6
|
+
import * as Plugin$ReventlessCore from "../plugin/component/Plugin.res.mjs";
|
|
7
|
+
import * as Platform_ComponentDefinitionsApi$ReventlessCore from "./Platform_ComponentDefinitionsApi.res.mjs";
|
|
8
|
+
|
|
9
|
+
function describe(e) {
|
|
10
|
+
switch (e.TAG) {
|
|
11
|
+
case "UnknownPlugin" :
|
|
12
|
+
return `baked manifest: no registered plugin named "` + e._0 + `"`;
|
|
13
|
+
case "UnknownView" :
|
|
14
|
+
return `baked manifest: plugin "` + e.plugin + `" has no view named "` + e.view + `"`;
|
|
15
|
+
case "UnknownCommand" :
|
|
16
|
+
return `baked manifest: plugin "` + e.plugin + `" has no command named "` + e.command + `"`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function publicViews(def) {
|
|
21
|
+
return def.readModels.concat(def.stateViewSlices).filter(Platform_ComponentDefinitionsApi$ReventlessCore.isPublicQueryable);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeSides(def) {
|
|
25
|
+
return def.stateChangeSlices.concat(def.aggregates);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function commandNames(def) {
|
|
29
|
+
return writeSides(def).flatMap(w => w.commands.map(c => c.name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validate(def, sel) {
|
|
33
|
+
let known = (names, candidates) => Stdlib_Option.getOr(names, []).find(name => !candidates.includes(name));
|
|
34
|
+
let view = known(sel.views, publicViews(def).map(q => q.name));
|
|
35
|
+
if (view !== undefined) {
|
|
36
|
+
return {
|
|
37
|
+
TAG: "Error",
|
|
38
|
+
_0: {
|
|
39
|
+
TAG: "UnknownView",
|
|
40
|
+
plugin: sel.plugin,
|
|
41
|
+
view: view
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
let command = known(sel.commands, commandNames(def));
|
|
46
|
+
if (command !== undefined) {
|
|
47
|
+
return {
|
|
48
|
+
TAG: "Error",
|
|
49
|
+
_0: {
|
|
50
|
+
TAG: "UnknownCommand",
|
|
51
|
+
plugin: sel.plugin,
|
|
52
|
+
command: command
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
} else {
|
|
56
|
+
return {
|
|
57
|
+
TAG: "Ok",
|
|
58
|
+
_0: undefined
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isSelected(selected, name) {
|
|
64
|
+
if (selected !== undefined) {
|
|
65
|
+
return selected.includes(name);
|
|
66
|
+
} else {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function referencedEntities(pluginName, kept) {
|
|
72
|
+
return Stdlib_Array.filterMap(kept.flatMap(w => w.commands).flatMap(c => c.references), r => {
|
|
73
|
+
let p = r.plugin;
|
|
74
|
+
if (p !== undefined && Plugin$ReventlessCore.name(p) !== pluginName) {
|
|
75
|
+
return;
|
|
76
|
+
} else {
|
|
77
|
+
return r.entity;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function curateStructure(pluginId, def, sel) {
|
|
83
|
+
return Stdlib_Result.map(validate(def, sel), () => {
|
|
84
|
+
let pluginName = Plugin$ReventlessCore.name(pluginId);
|
|
85
|
+
let keepCommands = ws => ws.map(w => ({
|
|
86
|
+
name: w.name,
|
|
87
|
+
commands: w.commands.filter(c => isSelected(sel.commands, c.name)),
|
|
88
|
+
producedEventTypes: w.producedEventTypes,
|
|
89
|
+
consumedEventTypes: w.consumedEventTypes,
|
|
90
|
+
linkedViews: w.linkedViews,
|
|
91
|
+
consistencyRead: w.consistencyRead,
|
|
92
|
+
events: w.events,
|
|
93
|
+
errors: w.errors,
|
|
94
|
+
chapter: w.chapter
|
|
95
|
+
})).filter(w => w.commands.length !== 0);
|
|
96
|
+
let stateChangeSlices = keepCommands(def.stateChangeSlices);
|
|
97
|
+
let aggregates = keepCommands(def.aggregates);
|
|
98
|
+
let referenced = referencedEntities(pluginName, stateChangeSlices.concat(aggregates));
|
|
99
|
+
let keepViews = qs => qs.filter(q => {
|
|
100
|
+
if (Platform_ComponentDefinitionsApi$ReventlessCore.isPublicQueryable(q)) {
|
|
101
|
+
return isSelected(sel.views, q.name);
|
|
102
|
+
} else {
|
|
103
|
+
return referenced.includes(q.name);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
readModels: keepViews(def.readModels),
|
|
108
|
+
stateViewSlices: keepViews(def.stateViewSlices),
|
|
109
|
+
stateChangeSlices: stateChangeSlices,
|
|
110
|
+
aggregates: aggregates,
|
|
111
|
+
automationSlices: def.automationSlices,
|
|
112
|
+
outboundTranslationSlices: def.outboundTranslationSlices,
|
|
113
|
+
inboundTranslationSlices: def.inboundTranslationSlices,
|
|
114
|
+
extensions: def.extensions,
|
|
115
|
+
extensionPoints: def.extensionPoints,
|
|
116
|
+
requiredStores: def.requiredStores,
|
|
117
|
+
requiredStoreDeclarations: def.requiredStoreDeclarations
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function curate(structures, selections) {
|
|
123
|
+
return Stdlib_Result.map(Stdlib_Array.reduce(selections, {
|
|
124
|
+
TAG: "Ok",
|
|
125
|
+
_0: []
|
|
126
|
+
}, (acc, sel) => Stdlib_Result.flatMap(acc, entries => {
|
|
127
|
+
let match = structures.find(param => Plugin$ReventlessCore.name(param[0]) === sel.plugin);
|
|
128
|
+
if (match === undefined) {
|
|
129
|
+
return {
|
|
130
|
+
TAG: "Error",
|
|
131
|
+
_0: {
|
|
132
|
+
TAG: "UnknownPlugin",
|
|
133
|
+
_0: sel.plugin
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
let pluginId = match[0];
|
|
138
|
+
return Stdlib_Result.map(curateStructure(pluginId, match[1], sel), curated => {
|
|
139
|
+
entries.push(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(pluginId, curated));
|
|
140
|
+
return entries;
|
|
141
|
+
});
|
|
142
|
+
})), prim => prim);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export {
|
|
146
|
+
describe,
|
|
147
|
+
publicViews,
|
|
148
|
+
writeSides,
|
|
149
|
+
commandNames,
|
|
150
|
+
validate,
|
|
151
|
+
isSelected,
|
|
152
|
+
referencedEntities,
|
|
153
|
+
curateStructure,
|
|
154
|
+
curate,
|
|
155
|
+
}
|
|
156
|
+
/* No side effect */
|
|
@@ -18,7 +18,7 @@ open Reventless.Plugin
|
|
|
18
18
|
// consumers long after they were being encoded.
|
|
19
19
|
let sdlTypes: array<string> = [
|
|
20
20
|
`type Platform_FieldReference {\n fieldName: String!\n entity: String!\n plugin: String\n}`,
|
|
21
|
-
`type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
|
|
21
|
+
`type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n requiredAccess: [String!]\n ownerField: String\n}`,
|
|
22
22
|
`type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
|
|
23
23
|
// Same fields as Platform_EventDef, kept a distinct type because a refusal is not
|
|
24
24
|
// a fact: a caller selecting `errors` is asking what a command can be rejected
|
|
@@ -29,7 +29,7 @@ let sdlTypes: array<string> = [
|
|
|
29
29
|
// structures persisted before the fields existed decode as `None`, a hand-rolled
|
|
30
30
|
// def may decline to state them, and a state whose key cannot be resolved has no
|
|
31
31
|
// `idField` to report.
|
|
32
|
-
`type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n}`,
|
|
32
|
+
`type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n}`,
|
|
33
33
|
`type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
|
|
34
34
|
`type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
35
35
|
`type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
@@ -81,6 +81,11 @@ let encodeCommandDef = (c: commandDef): JSON.t =>
|
|
|
81
81
|
),
|
|
82
82
|
("targetState", c.targetState->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
83
83
|
("apiExposed", c.apiExposed->Option.mapOr(JSON.Encode.null, JSON.Encode.bool)),
|
|
84
|
+
// Null, not `[]`: a rule asking for nothing checkable and a def written
|
|
85
|
+
// before the field existed are the same answer to a client — "no key gates
|
|
86
|
+
// this" — and neither should read as "gated by an empty set".
|
|
87
|
+
("requiredAccess", c.requiredAccess->Option.mapOr(JSON.Encode.null, encodeStrings)),
|
|
88
|
+
("ownerField", c.ownerField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
84
89
|
])->JSON.Encode.object
|
|
85
90
|
|
|
86
91
|
// Internal ReadModels / StateViewSlices are carried in pluginStructure for developer
|
|
@@ -121,6 +126,8 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
|
|
|
121
126
|
),
|
|
122
127
|
("idField", r.idField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
123
128
|
("idFieldSource", r.idFieldSource->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
129
|
+
("requiredAccess", r.requiredAccess->Option.mapOr(JSON.Encode.null, encodeStrings)),
|
|
130
|
+
("ownerField", r.ownerField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
124
131
|
])->JSON.Encode.object
|
|
125
132
|
|
|
126
133
|
let encodeEventDef = (e: eventDef): JSON.t =>
|
|
@@ -6,11 +6,11 @@ import * as Plugin$ReventlessCore from "../plugin/component/Plugin.res.mjs";
|
|
|
6
6
|
|
|
7
7
|
let sdlTypes = [
|
|
8
8
|
`type Platform_FieldReference {\n fieldName: String!\n entity: String!\n plugin: String\n}`,
|
|
9
|
-
`type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
|
|
9
|
+
`type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n requiredAccess: [String!]\n ownerField: String\n}`,
|
|
10
10
|
`type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
|
|
11
11
|
`type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
|
|
12
12
|
`type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
|
|
13
|
-
`type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n}`,
|
|
13
|
+
`type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n}`,
|
|
14
14
|
`type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
|
|
15
15
|
`type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
16
16
|
`type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
|
|
@@ -79,6 +79,14 @@ function encodeCommandDef(c) {
|
|
|
79
79
|
[
|
|
80
80
|
"apiExposed",
|
|
81
81
|
Stdlib_Option.mapOr(c.apiExposed, null, prim => prim)
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
"requiredAccess",
|
|
85
|
+
Stdlib_Option.mapOr(c.requiredAccess, null, encodeStrings)
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
"ownerField",
|
|
89
|
+
Stdlib_Option.mapOr(c.ownerField, null, prim => prim)
|
|
82
90
|
]
|
|
83
91
|
]);
|
|
84
92
|
}
|
|
@@ -144,6 +152,14 @@ function encodeQueryableDef(r) {
|
|
|
144
152
|
[
|
|
145
153
|
"idFieldSource",
|
|
146
154
|
Stdlib_Option.mapOr(r.idFieldSource, null, prim => prim)
|
|
155
|
+
],
|
|
156
|
+
[
|
|
157
|
+
"requiredAccess",
|
|
158
|
+
Stdlib_Option.mapOr(r.requiredAccess, null, encodeStrings)
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
"ownerField",
|
|
162
|
+
Stdlib_Option.mapOr(r.ownerField, null, prim => prim)
|
|
147
163
|
]
|
|
148
164
|
]);
|
|
149
165
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The one thing a deployment can get wrong about `@owner` without anything
|
|
2
|
+
// failing.
|
|
3
|
+
//
|
|
4
|
+
// `OwnerScope.elevatedGroups` defaults to `[]`, which is the safe default: it
|
|
5
|
+
// scopes everybody rather than exempting somebody by accident. But a deployment
|
|
6
|
+
// that never calls `setElevatedGroups` gets that default silently, and then
|
|
7
|
+
// *administrators are scoped too* — an operator opening an owner-scoped view
|
|
8
|
+
// sees only the rows they happen to own.
|
|
9
|
+
//
|
|
10
|
+
// Nothing errors, and it is invisible from outside: an operator whose elevated
|
|
11
|
+
// list is empty and an operator who genuinely owns nothing produce the identical
|
|
12
|
+
// empty page. That is the same unfalsifiability that makes a denied read
|
|
13
|
+
// indistinguishable from an empty one, arriving by a different route — so it is
|
|
14
|
+
// worth a line at registration rather than a support ticket later.
|
|
15
|
+
//
|
|
16
|
+
// Lives in core rather than in each adapter so the rule and its wording are
|
|
17
|
+
// stated once. Local and AWS both register owner-scoped views; two copies of
|
|
18
|
+
// this check would be two chances to word the consequence differently, or to
|
|
19
|
+
// fix one and not the other.
|
|
20
|
+
|
|
21
|
+
let log = Logger.fromEnv()
|
|
22
|
+
|
|
23
|
+
// Warned once per read model. Resolver registration can run more than once in a
|
|
24
|
+
// process (tests build several platforms; a deploy walks every plugin), and a
|
|
25
|
+
// warning repeated per construction is a warning people learn to scroll past.
|
|
26
|
+
let warned: Set.t<string> = Set.make()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
Warn when `view` declares an `@owner` field but no group is exempt from scoping.
|
|
30
|
+
|
|
31
|
+
`~comp` names the calling adapter so the line points at the transport it came
|
|
32
|
+
from. Silent when the view declares no owner, and silent once the deployment has
|
|
33
|
+
configured any elevated group — this is a "you probably forgot" signal, not a
|
|
34
|
+
policy about which groups are correct.
|
|
35
|
+
*/
|
|
36
|
+
let warnIfNoElevatedGroups = (~comp: string, ~view: string, ~ownerField: option<string>) =>
|
|
37
|
+
switch ownerField {
|
|
38
|
+
| Some(field) if Reventless.OwnerScope.elevatedGroups()->Array.length == 0 =>
|
|
39
|
+
if !(warned->Set.has(view)) {
|
|
40
|
+
warned->Set.add(view)
|
|
41
|
+
log.warn(
|
|
42
|
+
~comp,
|
|
43
|
+
`${view}: "${field}" is declared @owner, but no elevated groups are configured. ` ++
|
|
44
|
+
"Every caller — administrators included — will see only their own rows. " ++
|
|
45
|
+
"Call OwnerScope.setElevatedGroups([...]) before components are built, or set " ++
|
|
46
|
+
"REVENTLESS_ELEVATED_GROUPS — the environment is what reaches a deployment's " ++
|
|
47
|
+
"separate function runtimes, which a call in the deploy program does not.",
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
| _ => ()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test seam: forget what has already been warned about. */
|
|
54
|
+
let resetWarnings = () => warned->Set.clear
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
4
|
+
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
5
|
+
|
|
6
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
7
|
+
|
|
8
|
+
let warned = new Set();
|
|
9
|
+
|
|
10
|
+
function warnIfNoElevatedGroups(comp, view, ownerField) {
|
|
11
|
+
if (ownerField !== undefined && OwnerScope$Reventless.elevatedGroups().length === 0 && !warned.has(view)) {
|
|
12
|
+
warned.add(view);
|
|
13
|
+
return log.warn(comp, undefined, view + `: "` + ownerField + `" is declared @owner, but no elevated groups are configured. ` + "Every caller — administrators included — will see only their own rows. Call OwnerScope.setElevatedGroups([...]) before components are built, or set REVENTLESS_ELEVATED_GROUPS — the environment is what reaches a deployment's separate function runtimes, which a call in the deploy program does not.");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resetWarnings() {
|
|
18
|
+
warned.clear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
log,
|
|
23
|
+
warned,
|
|
24
|
+
warnIfNoElevatedGroups,
|
|
25
|
+
resetWarnings,
|
|
26
|
+
}
|
|
27
|
+
/* log Not a pure module */
|
|
@@ -75,12 +75,28 @@ let buildConnection = (
|
|
|
75
75
|
// it defaults to the shared rule so every caller applies the same one — the AWS
|
|
76
76
|
// Lambda used to pass a no-op here, which silently made `filter.ids` reject a
|
|
77
77
|
// global id on that provider only. Everything else is pure over the item array.
|
|
78
|
+
// `ownerScope` is `(fieldName, requiredValue)`, and it is deliberately NOT read
|
|
79
|
+
// out of `argsDict`. Two reasons, both fatal to the obvious alternative of
|
|
80
|
+
// injecting `filter.<owner>Eq`:
|
|
81
|
+
//
|
|
82
|
+
// - the owner field usually is not in `capability.filterFields` at all (it is
|
|
83
|
+
// admitted only by `@id` / `@index` / `@scan` and friends), so there is no
|
|
84
|
+
// filter input to inject into; and
|
|
85
|
+
// - `filter` is the caller's own argument. A predicate that decides what the
|
|
86
|
+
// caller may see must arrive on a channel the caller cannot name or
|
|
87
|
+
// overwrite.
|
|
88
|
+
//
|
|
89
|
+
// It joins the same `filtered` pass as everything else, which puts it BEFORE
|
|
90
|
+
// ordering and pagination. Narrowing a materialised page afterwards would return
|
|
91
|
+
// short pages with valid-looking cursors — a bug that reads as "the list
|
|
92
|
+
// sometimes ends early" rather than as an access-control fault.
|
|
78
93
|
let run = (
|
|
79
94
|
~items: array<JSON.t>,
|
|
80
95
|
~argsDict: Dict.t<JSON.t>,
|
|
81
96
|
~capability: GraphQL_FragmentGenerator.serverCapability,
|
|
82
97
|
~labelField: string,
|
|
83
98
|
~decodeLocalId: string => option<string>=Api_Ids.toLocalId,
|
|
99
|
+
~ownerScope: option<(string, string)>=?,
|
|
84
100
|
): JSON.t => {
|
|
85
101
|
let filterDict =
|
|
86
102
|
argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
@@ -161,7 +177,15 @@ let run = (
|
|
|
161
177
|
| _ => true
|
|
162
178
|
}
|
|
163
179
|
let passPerField = perFieldChecks->Array.every(check => check(item))
|
|
164
|
-
|
|
180
|
+
// A row that does not state an owner never matches a scoped read. Treating a
|
|
181
|
+
// missing field as "belongs to everyone" would expose exactly the rows that
|
|
182
|
+
// predate the annotation, which are the ones nobody remembers to check.
|
|
183
|
+
let passOwner = switch ownerScope {
|
|
184
|
+
| Some((field, required)) =>
|
|
185
|
+
getFieldString(item, field)->Option.mapOr(false, v => v == required)
|
|
186
|
+
| None => true
|
|
187
|
+
}
|
|
188
|
+
passSearch && passPrefix && passIds && passPerField && passOwner
|
|
165
189
|
})
|
|
166
190
|
|
|
167
191
|
let orderByDict = argsDict->Dict.get("orderBy")->Option.flatMap(JSON.Decode.object)
|
|
@@ -48,7 +48,7 @@ function buildConnection(pageItems, hasNextPage, hasPreviousPage, cursorValueOf)
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function run(items, argsDict, capability, labelField, decodeLocalIdOpt) {
|
|
51
|
+
function run(items, argsDict, capability, labelField, decodeLocalIdOpt, ownerScope) {
|
|
52
52
|
let decodeLocalId = decodeLocalIdOpt !== undefined ? decodeLocalIdOpt : Api_Ids$ReventlessCore.toLocalId;
|
|
53
53
|
let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
|
|
54
54
|
let search = Stdlib_Option.flatMap(filterDict["search"], Stdlib_JSON.Decode.string);
|
|
@@ -95,8 +95,15 @@ function run(items, argsDict, capability, labelField, decodeLocalIdOpt) {
|
|
|
95
95
|
passIds = true;
|
|
96
96
|
}
|
|
97
97
|
let passPerField = perFieldChecks.every(check => check(item));
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
let passOwner;
|
|
99
|
+
if (ownerScope !== undefined) {
|
|
100
|
+
let required = ownerScope[1];
|
|
101
|
+
passOwner = Stdlib_Option.mapOr(getFieldString(item, ownerScope[0]), false, v => v === required);
|
|
102
|
+
} else {
|
|
103
|
+
passOwner = true;
|
|
104
|
+
}
|
|
105
|
+
if (passSearch && passPrefix && passIds && passPerField) {
|
|
106
|
+
return passOwner;
|
|
100
107
|
} else {
|
|
101
108
|
return false;
|
|
102
109
|
}
|