@reventlessdev/reventless-core 3.0.0-alpha.224 → 3.0.0-alpha.226

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +6 -6
  3. package/src/admin/Platform_Admin_Structure.res +6 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +9 -3
  5. package/src/admin/Platform_BakedManifest.res +154 -0
  6. package/src/admin/Platform_BakedManifest.res.mjs +156 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +9 -2
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
  9. package/src/components/Api/OwnerScopeDiagnostics.res +54 -0
  10. package/src/components/Api/OwnerScopeDiagnostics.res.mjs +27 -0
  11. package/src/components/Api/QueryDbListQuery.res +25 -1
  12. package/src/components/Api/QueryDbListQuery.res.mjs +10 -3
  13. package/src/components/Api/SuryToJsonSchema.res +19 -0
  14. package/src/components/Api/SuryToJsonSchema.res.mjs +18 -4
  15. package/src/components/CommandGenerator/CommandGenerator_Callback.res +48 -0
  16. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +27 -1
  17. package/src/plugin/component/Plugin_Structure.res +59 -3
  18. package/src/plugin/component/Plugin_Structure.res.mjs +29 -9
  19. package/tests/admin/Platform_BakedManifestTest.res +244 -0
  20. package/tests/admin/Platform_BakedManifestTest.res.mjs +294 -0
  21. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +10 -0
  22. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +24 -8
  23. package/tests/admin/Platform_PluginStructuresApiTest.res +2 -0
  24. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +6 -2
  25. package/tests/api/Api_IdsTest.res.mjs +1 -1
  26. package/tests/api/OwnerScopeDiagnosticsTest.res +109 -0
  27. package/tests/api/OwnerScopeDiagnosticsTest.res.mjs +91 -0
  28. package/tests/api/SuryToJsonSchemaTest.res +45 -0
  29. package/tests/api/SuryToJsonSchemaTest.res.mjs +23 -0
  30. package/tests/commandgenerator/OwnerStampingTest.res +174 -0
  31. package/tests/commandgenerator/OwnerStampingTest.res.mjs +195 -0
  32. package/tests/plugin/PluginStructureAccessTest.res +110 -0
  33. package/tests/plugin/PluginStructureAccessTest.res.mjs +144 -0
  34. package/tests/plugin/StateChangeSlice/PsGatedCommands.res +28 -0
  35. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +84 -0
  36. package/tests/plugin/StateViewSlice/PsGatedView.res +15 -0
  37. package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +60 -0
  38. package/tests/plugin/pluginDefinitionRequiredScalars.txt +4 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
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.226 (2026-08-12)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/reventless-core
9
+
10
+
11
+
12
+
13
+
14
+ # 3.0.0-alpha.225 (2026-08-12)
15
+
16
+ ### Features
17
+
18
+ * **commands:** stamp owner-marked fields from the authenticated caller ([5266655](https://github.com/ReventlessDev/reventless-core/commit/5266655b34de4d90c8148dec686cb0f336ac223d))
19
+ * **core:** warn when an owner-scoped view exempts nobody ([cafa7a8](https://github.com/ReventlessDev/reventless-core/commit/cafa7a8b2082aeb00e53a9ed9da5bb978158db2d))
20
+ * **platform:** bake a curated component manifest as a static asset ([4f30265](https://github.com/ReventlessDev/reventless-core/commit/4f30265a51fc2c59e69afd8074cf1b2534c06378))
21
+ * **plugin:** publish the access a component's authorization rule implies ([e0d0f09](https://github.com/ReventlessDev/reventless-core/commit/e0d0f096b72fe44b185ed28dc1c133364fa841b8))
22
+ * **plugin:** publish which field ties a component to its owner ([ca71289](https://github.com/ReventlessDev/reventless-core/commit/ca7128931ce525af0d7d3a4487b2b5c54b19bec0))
23
+ * **queries:** narrow owner-bearing reads to the caller ([ba9cc3d](https://github.com/ReventlessDev/reventless-core/commit/ba9cc3d58d7914a9e4827bda90a704a74b1b82dd))
24
+ * **spec:** let a record name the field that identifies its owner ([b69ee91](https://github.com/ReventlessDev/reventless-core/commit/b69ee9123e6beb38fcdd716519103ab9328213c6))
25
+ * **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
26
+
27
+
6
28
  # 3.0.0-alpha.224 (2026-08-11)
7
29
 
8
30
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.224",
3
+ "version": "3.0.0-alpha.226",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -30,20 +30,20 @@
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.4",
33
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
34
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
34
  "@reventlessdev/rescript-node": "2.0.0-alpha.4",
35
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.4",
38
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
39
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.134",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.108",
41
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0"
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.110",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.136"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.65"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.66"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "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
- passSearch && passPrefix && passIds && passPerField
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)