@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 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.233",
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-hash-object": "1.2.0-alpha.14",
32
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.7",
33
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.6",
35
- "@reventlessdev/rescript-node": "2.0.0-alpha.6",
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/rescript-ssh2": "2.0.0-alpha.6",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.140",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.113",
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 => Ok()
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(~pluginId, curated),
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
- let encodePluginStructureEntry = (~pluginId: string, def: pluginStructure): JSON.t =>
211
- Dict.fromArray([
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
- ])->JSON.Encode.object
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
- return Object.fromEntries([
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 @aws_auth directive operates at field
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 @aws_auth gated). `version` selects which known version to act on.
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([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
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([{plugin: "Ordering", views: None, commands: None}])->firstEntry
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([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
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([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["ImportOrders"])}])
151
+ bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["ImportOrders"])])
145
152
  ->firstEntry
146
153
  expect(entry->names("internalQueryables"))->toEqual([])
147
154
  })
148
155
 
149
- testSync("an Internal view is never selectable as a view", () =>
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([{plugin: "Ordering", views: Some(["AvailableProducts"]), commands: None}]),
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([{plugin: "Shipping", views: None, commands: None}]))->toEqual(
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([{plugin: "Ordering", views: None, commands: Some(["CancelOrder"])}]))->toEqual(
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([{plugin: "Ordering", views: None, commands: None}])->firstEntry
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
- {plugin: "Catalog", views: Some([]), commands: Some([])},
186
- {plugin: "Ordering", views: Some([]), commands: Some([])},
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([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
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
- globalThis.test("an Internal view is never selectable as a view", () => {
187
- globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
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: ["AvailableProducts"],
190
- commands: undefined
191
- }])).toEqual({
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
- plugin: "Catalog",
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"]`),