@reventlessdev/reventless-aws 3.0.0-alpha.283 → 3.0.0-alpha.284

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,15 @@
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.284 (2026-08-12)
7
+
8
+ ### Features
9
+
10
+ * **platform:** bake a curated component manifest as a static asset ([4f30265](https://github.com/ReventlessDev/reventless-core/commit/4f30265a51fc2c59e69afd8074cf1b2534c06378))
11
+ * **queries:** narrow owner-bearing reads to the caller ([ba9cc3d](https://github.com/ReventlessDev/reventless-core/commit/ba9cc3d58d7914a9e4827bda90a704a74b1b82dd))
12
+ * **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
13
+
14
+
6
15
  # 3.0.0-alpha.283 (2026-08-11)
7
16
 
8
17
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.283",
3
+ "version": "3.0.0-alpha.284",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -14,16 +14,16 @@
14
14
  "uuid": "^13.0.0",
15
15
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.7",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.70",
18
- "@reventlessdev/rescript-node": "2.0.0-alpha.4",
19
17
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
20
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.224",
22
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
23
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.88",
24
- "@reventlessdev/reventless-spec": "3.0.0-alpha.108",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.71",
19
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4",
25
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
26
- "@reventlessdev/reventless-infra": "3.0.0-alpha.134"
21
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.225",
23
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.135",
24
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.89",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.109",
26
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.30"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -1124,18 +1124,6 @@ module MakeWithConfig = (
1124
1124
 
1125
1125
  // In split mode, create a dedicated platform AppSync API and push the platform schema.
1126
1126
  // In unified mode, makePlatform is a no-op (schema stitching handled by events).
1127
- let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
1128
- log.info(~comp="Platform", `v${version}`)
1129
- let _ = plugins
1130
- // Retired with the merged-API cutover: on AWS the platform deploys with
1131
- // deployPlatform (staged) and each plugin with deployPlugin — makePlatform
1132
- // predates the merge path and never gained merged wiring.
1133
- failwith(
1134
- "makePlatform is not supported on AWS — deploy the platform with deployPlatform and " ++
1135
- "each plugin with deployPlugin (merged-API composition).",
1136
- )
1137
- }
1138
-
1139
1127
  // Optional host UI shell bundle: a static SPA (e.g. reventless-ui's host-shell)
1140
1128
  // hosted on the platform's CloudFront-fronted S3 bucket. The platform writes a
1141
1129
  // `config.json` next to `index.html` so the shell discovers `apiEndpoint` and
@@ -1154,6 +1142,10 @@ module MakeWithConfig = (
1154
1142
  // written; the shell treats the resulting 404 as "no hints" and boots
1155
1143
  // unchanged. Single-mode shape: one origin-relative file per deployment.
1156
1144
  uiHintsFile?: string,
1145
+ // Optional baked component manifest. Declared here so a deployment can state
1146
+ // it once; the emission that writes the file is not wired on this platform
1147
+ // yet, and a declaration without it writes nothing.
1148
+ bakedManifest?: ReventlessInfra.Platform.bakedManifest,
1157
1149
  // Optional geocoding place index, as returned by
1158
1150
  // `Capability_Geocoding_AwsLocation.make`. When set, the deploy provisions the
1159
1151
  // capability's client door — a Cognito-authenticated `Query.geocode` resolver
@@ -1198,6 +1190,23 @@ module MakeWithConfig = (
1198
1190
  shellConfig?: dict<JSON.t>,
1199
1191
  }
1200
1192
 
1193
+ let makePlatform = (
1194
+ ~version,
1195
+ ~plugins: array<module(PluginMaker)>,
1196
+ ~hostUiBundle as _: option<hostUiBundleConfig>=?,
1197
+ ) => {
1198
+ log.info(~comp="Platform", `v${version}`)
1199
+ let _ = plugins
1200
+ // Retired with the merged-API cutover: on AWS the platform deploys with
1201
+ // deployPlatform (staged) and each plugin with deployPlugin — makePlatform
1202
+ // predates the merge path and never gained merged wiring.
1203
+ failwith(
1204
+ "makePlatform is not supported on AWS — deploy the platform with deployPlatform and " ++
1205
+ "each plugin with deployPlugin (merged-API composition).",
1206
+ )
1207
+ }
1208
+
1209
+
1201
1210
  // Merged-mode outputs of deployPlatform — the merged API(s), plus the
1202
1211
  // deploy-time merge gates (Outputs that resolve on MERGE_SUCCESS and fail
1203
1212
  // the deploy on MERGE_FAILED; folded into the ARN exports so they are
@@ -753,7 +753,7 @@ function MakeWithConfig(Config) {
753
753
  return;
754
754
  }
755
755
  };
756
- let makePlatform = (version, plugins) => {
756
+ let makePlatform = (version, plugins, param) => {
757
757
  log.info("Platform", undefined, `v` + version);
758
758
  return Pervasives.failwith("makePlatform is not supported on AWS — deploy the platform with deployPlatform and each plugin with deployPlugin (merged-API composition).");
759
759
  };
@@ -2009,7 +2009,7 @@ function Make($star) {
2009
2009
  return;
2010
2010
  }
2011
2011
  };
2012
- let makePlatform = (version, plugins) => {
2012
+ let makePlatform = (version, plugins, param) => {
2013
2013
  log.info("Platform", undefined, `v` + version);
2014
2014
  return Pervasives.failwith("makePlatform is not supported on AWS — deploy the platform with deployPlatform and each plugin with deployPlugin (merged-API composition).");
2015
2015
  };
@@ -67,6 +67,7 @@ type pushdowns = {
67
67
  ~argsDict: dict<JSON.t>,
68
68
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
69
69
  ~labelField: string,
70
+ ~ownerScope: (string, string)=?,
70
71
  ) => promise<option<JSON.t>>,
71
72
  // Sub-id connection ({single}Items) — keyset over sub_key within a partition.
72
73
  itemsPage: (
@@ -74,6 +75,7 @@ type pushdowns = {
74
75
  ~subIdField: string,
75
76
  ~id: string,
76
77
  ~argsDict: dict<JSON.t>,
78
+ ~ownerScope: (string, string)=?,
77
79
  ) => promise<JSON.t>,
78
80
  // Full materialisation for the list fallback (shapes listPage declines).
79
81
  scanAll: (~readModelName: string) => promise<array<JSON.t>>,
@@ -89,6 +91,10 @@ type binding = {
89
91
  labelField: string,
90
92
  includeIdParam: bool,
91
93
  authorization: Reventless.Authorization.permission,
94
+ /** The state's `@owner` field, when it declares one. Derived at Lambda init
95
+ from the same schema `capability` comes from, so the two cannot disagree
96
+ about which fields this read model has. */
97
+ ownerField: option<string>,
92
98
  }
93
99
 
94
100
  // -- arg helpers -------------------------------------------------------------
@@ -162,6 +168,16 @@ let dispatch = async (
162
168
  | _ => JSON.Encode.array([])
163
169
  }
164
170
  | Allow =>
171
+ // Post-read narrowing for the doors that hand back rows rather than a page.
172
+ // Same decision as the list door takes; applied per row because there is no
173
+ // query to push it into.
174
+ let ownerAllows = (item: JSON.t) =>
175
+ switch payload.identity->Reventless.OwnerScope.decide(~ownerField=binding.ownerField) {
176
+ | Unscoped => true
177
+ | RefuseOwned => false
178
+ | ScopeTo(field, required) =>
179
+ item->argStr(field)->Option.mapOr(false, v => v == required)
180
+ }
165
181
  switch payload.kind {
166
182
  | "getById" =>
167
183
  let id = payload.arguments->argStr("id")->Option.getOr("")
@@ -181,6 +197,9 @@ let dispatch = async (
181
197
  | (found, _) => (id, found)
182
198
  }
183
199
  switch found {
200
+ // A row the caller does not own answers as "not found". Saying "not yours"
201
+ // instead would make this door an oracle for which ids exist.
202
+ | Some(item) if !ownerAllows(item) => JSON.Encode.null
184
203
  | Some(item) => binding.includeIdParam ? withId(item, resolvedKey) : item
185
204
  | None => JSON.Encode.null
186
205
  }
@@ -197,7 +216,7 @@ let dispatch = async (
197
216
  let extra = missing->Array.length > 0
198
217
  ? await binding.pushdowns.byIds(~readModelName=rm, missing)
199
218
  : []
200
- JSON.Encode.array(Array.concat(found, extra))
219
+ JSON.Encode.array(Array.concat(found, extra)->Array.filter(ownerAllows))
201
220
 
202
221
  | "items" =>
203
222
  // Sub-id connection: {single}Items(id, filter, first/after/last/before).
@@ -205,12 +224,17 @@ let dispatch = async (
205
224
  switch binding.subIdField {
206
225
  | Some(subIdField) =>
207
226
  let id = payload.arguments->argStr("id")->Option.getOr("")
208
- await binding.pushdowns.itemsPage(
209
- ~readModelName=rm,
210
- ~subIdField,
211
- ~id,
212
- ~argsDict=payload.arguments->argObj,
213
- )
227
+ switch payload.identity->Reventless.OwnerScope.decide(~ownerField=binding.ownerField) {
228
+ | RefuseOwned => emptyConnection()
229
+ | decision =>
230
+ await binding.pushdowns.itemsPage(
231
+ ~readModelName=rm,
232
+ ~subIdField,
233
+ ~id,
234
+ ~argsDict=payload.arguments->argObj,
235
+ ~ownerScope=?Reventless.OwnerScope.scopeOf(decision),
236
+ )
237
+ }
214
238
  | None => emptyConnection()
215
239
  }
216
240
 
@@ -248,31 +272,42 @@ let dispatch = async (
248
272
  | _ => true
249
273
  }
250
274
  if authorized {
251
- JSON.Encode.array(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))
275
+ JSON.Encode.array(
276
+ (await binding.pushdowns.indexLookup(~readModelName=rm, field, value))->Array.filter(
277
+ ownerAllows,
278
+ ),
279
+ )
252
280
  } else {
253
281
  JSON.Encode.array([])
254
282
  }
255
283
 
256
284
  | "list" =>
257
285
  let argsDict = payload.arguments->argObj
258
- switch await binding.pushdowns.listPage(
259
- ~readModelName=rm,
260
- ~argsDict,
261
- ~capability=binding.capability,
262
- ~labelField=binding.labelField,
263
- ) {
264
- | Some(conn) => conn
265
- | None =>
266
- // Shapes listPage declines (search/searchPrefix/ids/backward) → run the
267
- // shared spec over the materialised model, which decodes Relay global
268
- // ids the same way every other door now does.
269
- let items = await binding.pushdowns.scanAll(~readModelName=rm)
270
- ReventlessCore.QueryDbListQuery.run(
271
- ~items,
286
+ switch payload.identity->Reventless.OwnerScope.decide(~ownerField=binding.ownerField) {
287
+ | RefuseOwned => emptyConnection()
288
+ | decision =>
289
+ let ownerScope = Reventless.OwnerScope.scopeOf(decision)
290
+ switch await binding.pushdowns.listPage(
291
+ ~readModelName=rm,
272
292
  ~argsDict,
273
293
  ~capability=binding.capability,
274
294
  ~labelField=binding.labelField,
275
- )
295
+ ~ownerScope?,
296
+ ) {
297
+ | Some(conn) => conn
298
+ | None =>
299
+ // Shapes listPage declines (search/searchPrefix/ids/backward) → run the
300
+ // shared spec over the materialised model, which decodes Relay global
301
+ // ids the same way every other door now does.
302
+ let items = await binding.pushdowns.scanAll(~readModelName=rm)
303
+ ReventlessCore.QueryDbListQuery.run(
304
+ ~items,
305
+ ~argsDict,
306
+ ~capability=binding.capability,
307
+ ~labelField=binding.labelField,
308
+ ~ownerScope?,
309
+ )
310
+ }
276
311
  }
277
312
 
278
313
  // Cross-table single-ID field resolver (@resolves). `binding` is the TARGET
@@ -6,6 +6,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
7
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
8
8
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
9
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
9
10
  import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
10
11
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
11
12
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
@@ -71,6 +72,14 @@ async function dispatch(binding, lookupBindingOpt, payload) {
71
72
  let rm = payload.readModelName;
72
73
  let match = await runInterceptor(binding, payload);
73
74
  if (typeof match !== "object") {
75
+ let ownerAllows = item => {
76
+ let match = OwnerScope$Reventless.decide(payload.identity, binding.ownerField, undefined);
77
+ if (typeof match !== "object") {
78
+ return match === "Unscoped";
79
+ }
80
+ let required = match._1;
81
+ return Stdlib_Option.mapOr(argStr(item, match._0), false, v => v === required);
82
+ };
74
83
  let other = payload.kind;
75
84
  switch (other) {
76
85
  case "byIds" :
@@ -78,7 +87,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
78
87
  let found = await binding.pushdowns.byIds(rm, ids);
79
88
  let missing = found.length < ids.length ? Stdlib_Array.filterMap(ids, Api_Ids$ReventlessCore.alternateKey) : [];
80
89
  let extra = missing.length !== 0 ? await binding.pushdowns.byIds(rm, missing) : [];
81
- return found.concat(extra);
90
+ return found.concat(extra).filter(ownerAllows);
82
91
  case "getById" :
83
92
  let id = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
84
93
  let loadKey = async key => {
@@ -97,7 +106,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
97
106
  await loadKey(match$2)
98
107
  ];
99
108
  let found$1 = match$3[1];
100
- if (found$1 !== undefined) {
109
+ if (found$1 !== undefined && ownerAllows(found$1)) {
101
110
  if (binding.includeIdParam) {
102
111
  return withId(found$1, match$3[0]);
103
112
  } else {
@@ -125,7 +134,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
125
134
  authorized = true;
126
135
  }
127
136
  if (authorized) {
128
- return await binding.pushdowns.indexLookup(rm, field, value);
137
+ return (await binding.pushdowns.indexLookup(rm, field, value)).filter(ownerAllows);
129
138
  } else {
130
139
  return [];
131
140
  }
@@ -135,15 +144,26 @@ async function dispatch(binding, lookupBindingOpt, payload) {
135
144
  return emptyConnection();
136
145
  }
137
146
  let id$1 = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
138
- return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments));
147
+ let decision = OwnerScope$Reventless.decide(payload.identity, binding.ownerField, undefined);
148
+ if (typeof decision !== "object" && decision !== "Unscoped") {
149
+ return emptyConnection();
150
+ }
151
+ return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments), OwnerScope$Reventless.scopeOf(decision));
152
+ break;
139
153
  case "list" :
140
154
  let argsDict = argObj(payload.arguments);
141
- let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField);
155
+ let decision$1 = OwnerScope$Reventless.decide(payload.identity, binding.ownerField, undefined);
156
+ if (typeof decision$1 !== "object" && decision$1 !== "Unscoped") {
157
+ return emptyConnection();
158
+ }
159
+ let ownerScope = OwnerScope$Reventless.scopeOf(decision$1);
160
+ let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField, ownerScope);
142
161
  if (conn !== undefined) {
143
162
  return conn;
144
163
  }
145
164
  let items$1 = await binding.pushdowns.scanAll(rm);
146
- return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined);
165
+ return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined, ownerScope);
166
+ break;
147
167
  case "resolveMany" :
148
168
  let target = Stdlib_Option.getOr(payload.target, rm);
149
169
  let source = Stdlib_Option.getOr(payload.source, null);
@@ -238,6 +238,39 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
238
238
  // (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
239
239
  // See docs/plans/platform-plugins-admin-connection-null-rows.md.
240
240
  let requireAttribute = internalRowRequiredAttr(name)
241
+ // Derived from the same schema `capability` came from, so the two cannot
242
+ // disagree about this read model's fields.
243
+ let ownerField = switch stateSchemaOpt {
244
+ | Some(s) => Reventless.Owner.fieldNames(s)->Array.get(0)
245
+ | None => None
246
+ }
247
+ // A DynamoDB FilterExpression is applied AFTER the page is read, so a scoped
248
+ // list over a table with no index on the owner field returns short pages —
249
+ // correct, but pathological once a caller owns a small fraction of the rows.
250
+ // Warned rather than refused: the resolver does serve the query, and a
251
+ // deployment may legitimately accept the cost on a small table. Mirrors the
252
+ // `@scanSort` alignment warning above, which exists for the same class of
253
+ // "works, but scans" mistake.
254
+ ReventlessCore.OwnerScopeDiagnostics.warnIfNoElevatedGroups(
255
+ ~comp="QueryDbResolvers_AppSync",
256
+ ~view=name,
257
+ ~ownerField,
258
+ )
259
+ switch ownerField {
260
+ | Some(f) =>
261
+ let indexed =
262
+ indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
263
+ subIdField->Option.getOr("") == f
264
+ if !indexed {
265
+ log.warn(
266
+ ~comp="QueryDbResolvers_AppSync",
267
+ `${name}: @owner field "${f}" is not the key of any index on this table. ` ++
268
+ "Owner-scoped reads will Scan and filter, so pages shrink as the caller's " ++
269
+ "share of the rows falls. Add an @index on that field before this read model grows.",
270
+ )
271
+ }
272
+ | None => ()
273
+ }
241
274
  let resolverAll = makeQueryResolver(
242
275
  ~resolverName=fieldNameForAll->String.capitalize,
243
276
  ~field=fieldNameForAll->Pulumi.Input.make,
@@ -248,6 +281,8 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
248
281
  ~rangeFields=rangeFieldNames,
249
282
  ~sortFields=sortFieldNames,
250
283
  ~requireAttribute?,
284
+ ~ownerField?,
285
+ ~elevatedGroups=Reventless.OwnerScope.elevatedGroups(),
251
286
  )
252
287
  } else {
253
288
  Resolver.Functions.listAllItems
@@ -3,7 +3,9 @@
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
6
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
6
7
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
7
9
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
8
10
  import * as AppSync_Function$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Function.res.mjs";
9
11
  import * as Util_AppSync$ReventlessAws from "../../util/Util_AppSync.res.mjs";
@@ -12,6 +14,7 @@ import * as Util_QueryDb$ReventlessCore from "@reventlessdev/reventless-core/src
12
14
  import * as AppSync_DataSource$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_DataSource.res.mjs";
13
15
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
14
16
  import * as AppSync_Resolver_Functions$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Resolver_Functions.res.mjs";
17
+ import * as OwnerScopeDiagnostics$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/OwnerScopeDiagnostics.res.mjs";
15
18
  import * as AppSync_Resolver_Retrying$ReventlessAws from "../Api/AppSync_Resolver_Retrying.res.mjs";
16
19
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
17
20
  import * as QueryInterceptor_Provisioning$ReventlessAws from "./QueryInterceptor_Provisioning.res.mjs";
@@ -106,7 +109,15 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
106
109
  GraphQL_FragmentGenerator$ReventlessCore.validateScanSortAlignment(stateSchemaOpt, name$1, knownSortFields).forEach(msg => log.warn("QueryDbResolvers_AppSync", undefined, msg));
107
110
  }
108
111
  let requireAttribute = internalRowRequiredAttr(name$1);
109
- let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
112
+ let ownerField = stateSchemaOpt !== undefined ? Owner$Reventless.fieldNames(stateSchemaOpt)[0] : undefined;
113
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("QueryDbResolvers_AppSync", name$1, ownerField);
114
+ if (ownerField !== undefined) {
115
+ let indexed = indexes.some(ic => Stdlib_Option.getOr(ic.idField, ic.index) === ownerField) || Stdlib_Option.getOr(subIdField, "") === ownerField;
116
+ if (!indexed) {
117
+ log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" is not the key of any index on this table. ` + "Owner-scoped reads will Scan and filter, so pages shrink as the caller's share of the rows falls. Add an @index on that field before this read model grows.");
118
+ }
119
+ }
120
+ let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, OwnerScope$Reventless.elevatedGroups()) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
110
121
  let resolversByIndex = indexes.map(indexConfig => {
111
122
  let index = indexConfig.index;
112
123
  let stripLeadingBy = s => {
@@ -150,6 +150,9 @@ let registerBinding = (
150
150
  labelField: entry.labelField,
151
151
  includeIdParam: entry.includeIdParam,
152
152
  authorization: spec.authorization,
153
+ // From the same schema `capability` is derived from, one line above, so the
154
+ // two cannot end up disagreeing about this read model's fields.
155
+ ownerField: Reventless.Owner.fieldNames(spec.stateSchema)->Array.get(0),
153
156
  },
154
157
  )
155
158
  logDebug("registered resolver binding for " ++ entry.readModelName, {comp: "PgQueryResolver"})
@@ -3,6 +3,7 @@
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
6
7
  import * as PgRuntime$ReventlessAws from "../Postgres/PgRuntime.res.mjs";
7
8
  import * as HandlerFactoryHelpersMjs from "./HandlerFactoryHelpers.mjs";
8
9
  import * as PgQueryResolver_Lambda$ReventlessAws from "../QueryDb/PgQueryResolver_Lambda.res.mjs";
@@ -72,7 +73,8 @@ function registerBinding(pushdowns, pgConnection, entry, spec) {
72
73
  capability: GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(entry.readModelName, spec.stateSchema),
73
74
  labelField: entry.labelField,
74
75
  includeIdParam: entry.includeIdParam,
75
- authorization: spec.authorization
76
+ authorization: spec.authorization,
77
+ ownerField: Owner$Reventless.fieldNames(spec.stateSchema)[0]
76
78
  });
77
79
  HandlerFactoryHelpersMjs.log.debug("registered resolver binding for " + entry.readModelName, {
78
80
  comp: "PgQueryResolver"
@@ -91,4 +93,4 @@ export {
91
93
  makePushdowns,
92
94
  registerBinding,
93
95
  }
94
- /* PgRuntime-ReventlessAws Not a pure module */
96
+ /* Owner-Reventless Not a pure module */
@@ -11,18 +11,19 @@ open ReventlessCore
11
11
 
12
12
  @val external btoa: string => string = "btoa"
13
13
 
14
- let mk = (id, status, name) => {
14
+ let mk = (id, status, name, owner) => {
15
15
  let o = Dict.make()
16
16
  o->Dict.set("id", JSON.Encode.string(id))
17
17
  o->Dict.set("status", JSON.Encode.string(status))
18
18
  o->Dict.set("name", JSON.Encode.string(name))
19
+ o->Dict.set("owner", JSON.Encode.string(owner))
19
20
  JSON.Encode.object(o)
20
21
  }
21
22
 
22
23
  let store = Dict.fromArray([
23
- ("p-1", mk("p-1", "active", "Alpha")),
24
- ("p-2", mk("p-2", "inactive", "Bravo")),
25
- ("p-3", mk("p-3", "active", "Charlie")),
24
+ ("p-1", mk("p-1", "active", "Alpha", "u-a")),
25
+ ("p-2", mk("p-2", "inactive", "Bravo", "u-b")),
26
+ ("p-3", mk("p-3", "active", "Charlie", "u-a")),
26
27
  ])
27
28
  let allItems = () => store->Dict.valuesToArray
28
29
 
@@ -35,6 +36,11 @@ let fieldEq = (item, field, value) =>
35
36
 
36
37
  // Records whether listPage was consulted, and what it returns (sentinel or None).
37
38
  let listPageReturn: ref<option<JSON.t>> = ref(None)
39
+ // The `ownerScope` the dispatcher handed the push-down on the last list call.
40
+ // The push-down's own SQL is covered by the engine's parity harness; what this
41
+ // file has to establish is that the dispatcher computes the scope from the
42
+ // caller and actually passes it on.
43
+ let lastOwnerScope: ref<option<(string, string)>> = ref(None)
38
44
 
39
45
  let ops: QueryDb_Adapter.operations = {
40
46
  load: async id => Ok(store->Dict.get(id)->Option.mapOr([], i => [i])),
@@ -51,9 +57,17 @@ let pushdowns: PgQueryResolver_Lambda.pushdowns = {
51
57
  allItems()->Array.filter(i => fieldEq(i, field, value)),
52
58
  byIds: async (~readModelName as _, ids) =>
53
59
  ids->Array.filterMap(id => store->Dict.get(id)),
54
- listPage: async (~readModelName as _, ~argsDict as _, ~capability as _, ~labelField as _) =>
55
- listPageReturn.contents,
56
- itemsPage: async (~readModelName as _, ~subIdField as _, ~id, ~argsDict as _) =>
60
+ listPage: async (
61
+ ~readModelName as _,
62
+ ~argsDict as _,
63
+ ~capability as _,
64
+ ~labelField as _,
65
+ ~ownerScope: option<(string, string)>=?,
66
+ ) => {
67
+ lastOwnerScope := ownerScope
68
+ listPageReturn.contents
69
+ },
70
+ itemsPage: async (~readModelName as _, ~subIdField as _, ~id, ~argsDict as _, ~ownerScope as _=?) =>
57
71
  // Sentinel echoing the requested id, so the dispatch routing is observable.
58
72
  JSON.Encode.object(Dict.fromArray([("itemsFor", JSON.Encode.string(id))])),
59
73
  scanAll: async (~readModelName as _) => allItems(),
@@ -67,6 +81,7 @@ let capability: GraphQL_FragmentGenerator.serverCapability = {
67
81
  let makeBinding = (
68
82
  ~authorization=Reventless.Authorization.AllowAnonymous,
69
83
  ~subIdField=None,
84
+ ~ownerField=None,
70
85
  (),
71
86
  ): PgQueryResolver_Lambda.binding => {
72
87
  ops,
@@ -80,16 +95,28 @@ let makeBinding = (
80
95
  labelField: "name",
81
96
  includeIdParam: true,
82
97
  authorization,
98
+ ownerField,
83
99
  }
84
100
 
85
- let mkPayload = (~kind, ~index=?, ~args=JSON.Encode.object(Dict.make()), ()): PgQueryResolver_Lambda.payload => {
101
+ let mkPayload = (
102
+ ~kind,
103
+ ~index=?,
104
+ ~args=JSON.Encode.object(Dict.make()),
105
+ ~identity=Reventless.Identity.anonymous,
106
+ (),
107
+ ): PgQueryResolver_Lambda.payload => {
86
108
  readModelName: "Things",
87
109
  kind,
88
110
  ?index,
89
111
  arguments: args,
90
- identity: Reventless.Identity.anonymous,
112
+ identity,
91
113
  }
92
114
 
115
+ external asIdentity: 'a => Reventless.Identity.t = "%identity"
116
+
117
+ let cognito = (~userId, ~groups): Reventless.Identity.t =>
118
+ asIdentity({"userId": userId, "username": userId, "groups": groups, "provider": "Cognito"})
119
+
93
120
  let objArgs = pairs => JSON.Encode.object(Dict.fromArray(pairs))
94
121
  let field = (j, k) => j->JSON.Decode.object->Option.flatMap(d => d->Dict.get(k))
95
122
  let str = (j, k) => j->field(k)->Option.flatMap(JSON.Decode.string)
@@ -455,3 +482,116 @@ describe("id form — the typed doors take either", () => {
455
482
  expect(r->ids)->toEqual(["p-1", "p-3"])
456
483
  })
457
484
  })
485
+
486
+ // ── Owner scoping ───────────────────────────────────────────────────────────
487
+ // The push-down's SQL is the engine's business and is covered by its parity
488
+ // harness. What has to hold HERE is that the dispatcher derives the scope from
489
+ // the caller and hands it on — a push-down that supports scoping and a
490
+ // dispatcher that never passes one produces a fully unscoped deployment while
491
+ // every SQL test stays green.
492
+ describe("owner scoping", () => {
493
+ let ownedBinding = () => makeBinding(~ownerField=Some("owner"), ())
494
+ let shopper = cognito(~userId="u-a", ~groups=["User"])
495
+
496
+ let listWith = async (~binding, ~identity) => {
497
+ lastOwnerScope := None
498
+ listPageReturn := Some(JSON.Encode.string("pushed"))
499
+ let r = await PgQueryResolver_Lambda.dispatch(
500
+ ~binding,
501
+ ~payload=mkPayload(~kind="list", ~identity, ()),
502
+ )
503
+ (r, lastOwnerScope.contents)
504
+ }
505
+
506
+ testPromise("a shopper's list is scoped to their own id", async () => {
507
+ let (_, scope) = await listWith(~binding=ownedBinding(), ~identity=shopper)
508
+ expect(scope)->toEqual(Some(("owner", "u-a")))
509
+ })
510
+
511
+ testPromise("an elevated caller's list is not scoped", async () => {
512
+ Reventless.OwnerScope.setElevatedGroups(["Admin"])
513
+ let (_, scope) = await listWith(
514
+ ~binding=ownedBinding(),
515
+ ~identity=cognito(~userId="ops-1", ~groups=["Admin"]),
516
+ )
517
+ Reventless.OwnerScope.setElevatedGroups([])
518
+ expect(scope)->toBe(None)
519
+ })
520
+
521
+ // The control that keeps this from being vacuous: a view with no `@owner`
522
+ // field must reach the push-down with no scope at all, for every caller.
523
+ testPromise("a view with no owner field is never scoped", async () => {
524
+ let (_, scope) = await listWith(~binding=makeBinding(), ~identity=shopper)
525
+ expect(scope)->toBe(None)
526
+ })
527
+
528
+ testPromise("an unidentified caller is refused before the push-down runs", async () => {
529
+ lastOwnerScope := None
530
+ listPageReturn := Some(JSON.Encode.string("pushed"))
531
+ let r = await PgQueryResolver_Lambda.dispatch(
532
+ ~binding=ownedBinding(),
533
+ ~payload=mkPayload(~kind="list", ~identity=Reventless.Identity.anonymous, ()),
534
+ )
535
+ // Not the sentinel, and the push-down was never consulted — the refusal
536
+ // happens above it rather than by handing it a value nobody holds.
537
+ expect((r->field("edges"), lastOwnerScope.contents))->toEqual((
538
+ Some(JSON.Encode.array([])),
539
+ None,
540
+ ))
541
+ })
542
+
543
+ testPromise("getById hides a row the caller does not own", async () => {
544
+ let r = await PgQueryResolver_Lambda.dispatch(
545
+ ~binding=ownedBinding(),
546
+ ~payload=mkPayload(
547
+ ~kind="getById",
548
+ ~args=objArgs([("id", JSON.Encode.string("p-2"))]),
549
+ ~identity=shopper,
550
+ (),
551
+ ),
552
+ )
553
+ expect(r)->toBe(JSON.Encode.null)
554
+ })
555
+
556
+ testPromise("getById still returns a row the caller does own", async () => {
557
+ let r = await PgQueryResolver_Lambda.dispatch(
558
+ ~binding=ownedBinding(),
559
+ ~payload=mkPayload(
560
+ ~kind="getById",
561
+ ~args=objArgs([("id", JSON.Encode.string("p-1"))]),
562
+ ~identity=shopper,
563
+ (),
564
+ ),
565
+ )
566
+ expect(r->str("id"))->toEqual(Some("p-1"))
567
+ })
568
+
569
+ testPromise("byIds drops rows the caller does not own", async () => {
570
+ let r = await PgQueryResolver_Lambda.dispatch(
571
+ ~binding=ownedBinding(),
572
+ ~payload=mkPayload(
573
+ ~kind="byIds",
574
+ ~args=objArgs([
575
+ ("ids", JSON.Encode.array(["p-1", "p-2", "p-3"]->Array.map(JSON.Encode.string))),
576
+ ]),
577
+ ~identity=shopper,
578
+ (),
579
+ ),
580
+ )
581
+ expect(r->ids)->toEqual(["p-1", "p-3"])
582
+ })
583
+
584
+ testPromise("an index query drops rows the caller does not own", async () => {
585
+ let r = await PgQueryResolver_Lambda.dispatch(
586
+ ~binding=ownedBinding(),
587
+ ~payload=mkPayload(
588
+ ~kind="index",
589
+ ~index="byStatus",
590
+ ~args=objArgs([("byStatus", JSON.Encode.string("active"))]),
591
+ ~identity=shopper,
592
+ (),
593
+ ),
594
+ )
595
+ expect(r->ids)->toEqual(["p-1", "p-3"])
596
+ })
597
+ })
@@ -6,30 +6,32 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
7
7
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
8
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
9
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
9
10
  import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
10
11
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
11
12
  import * as PgQueryResolver_Lambda$ReventlessAws from "../src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs";
12
13
 
13
- function mk(id, status, name) {
14
+ function mk(id, status, name, owner) {
14
15
  let o = {};
15
16
  o["id"] = id;
16
17
  o["status"] = status;
17
18
  o["name"] = name;
19
+ o["owner"] = owner;
18
20
  return o;
19
21
  }
20
22
 
21
23
  let store = Object.fromEntries([
22
24
  [
23
25
  "p-1",
24
- mk("p-1", "active", "Alpha")
26
+ mk("p-1", "active", "Alpha", "u-a")
25
27
  ],
26
28
  [
27
29
  "p-2",
28
- mk("p-2", "inactive", "Bravo")
30
+ mk("p-2", "inactive", "Bravo", "u-b")
29
31
  ],
30
32
  [
31
33
  "p-3",
32
- mk("p-3", "active", "Charlie")
34
+ mk("p-3", "active", "Charlie", "u-a")
33
35
  ]
34
36
  ]);
35
37
 
@@ -45,6 +47,10 @@ let listPageReturn = {
45
47
  contents: undefined
46
48
  };
47
49
 
50
+ let lastOwnerScope = {
51
+ contents: undefined
52
+ };
53
+
48
54
  async function ops_load(id) {
49
55
  return {
50
56
  TAG: "Ok",
@@ -70,11 +76,12 @@ async function pushdowns_byIds(param, ids) {
70
76
  return Stdlib_Array.filterMap(ids, id => store[id]);
71
77
  }
72
78
 
73
- async function pushdowns_listPage(param, param$1, param$2, param$3) {
79
+ async function pushdowns_listPage(param, param$1, param$2, param$3, ownerScope) {
80
+ lastOwnerScope.contents = ownerScope;
74
81
  return listPageReturn.contents;
75
82
  }
76
83
 
77
- async function pushdowns_itemsPage(param, param$1, id, param$2) {
84
+ async function pushdowns_itemsPage(param, param$1, id, param$2, param$3) {
78
85
  return Object.fromEntries([[
79
86
  "itemsFor",
80
87
  id
@@ -109,9 +116,10 @@ let capability = {
109
116
  sortFields: capability_sortFields
110
117
  };
111
118
 
112
- function makeBinding(authorizationOpt, subIdFieldOpt, param) {
119
+ function makeBinding(authorizationOpt, subIdFieldOpt, ownerFieldOpt, param) {
113
120
  let authorization = authorizationOpt !== undefined ? authorizationOpt : "AllowAnonymous";
114
121
  let subIdField = subIdFieldOpt !== undefined ? Primitive_option.valFromOption(subIdFieldOpt) : undefined;
122
+ let ownerField = ownerFieldOpt !== undefined ? Primitive_option.valFromOption(ownerFieldOpt) : undefined;
115
123
  return {
116
124
  ops: ops,
117
125
  pushdowns: pushdowns,
@@ -125,18 +133,29 @@ function makeBinding(authorizationOpt, subIdFieldOpt, param) {
125
133
  capability: capability,
126
134
  labelField: "name",
127
135
  includeIdParam: true,
128
- authorization: authorization
136
+ authorization: authorization,
137
+ ownerField: ownerField
129
138
  };
130
139
  }
131
140
 
132
- function mkPayload(kind, index, argsOpt, param) {
141
+ function mkPayload(kind, index, argsOpt, identityOpt, param) {
133
142
  let args = argsOpt !== undefined ? argsOpt : ({});
143
+ let identity = identityOpt !== undefined ? identityOpt : Identity$Reventless.anonymous;
134
144
  return {
135
145
  readModelName: "Things",
136
146
  kind: kind,
137
147
  index: index,
138
148
  arguments: args,
139
- identity: Identity$Reventless.anonymous
149
+ identity: identity
150
+ };
151
+ }
152
+
153
+ function cognito(userId, groups) {
154
+ return {
155
+ userId: userId,
156
+ username: userId,
157
+ groups: groups,
158
+ provider: "Cognito"
140
159
  };
141
160
  }
142
161
 
@@ -158,39 +177,39 @@ function ids(arr) {
158
177
 
159
178
  globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
160
179
  globalThis.test("getById returns the item (id-carrying)", async () => {
161
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
180
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
162
181
  "id",
163
182
  "p-2"
164
- ]]), undefined));
183
+ ]]), undefined, undefined));
165
184
  globalThis.expect(str(r, "id")).toEqual("p-2");
166
185
  globalThis.expect(str(r, "name")).toEqual("Bravo");
167
186
  });
168
187
  globalThis.test("getById miss → null", async () => {
169
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
188
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
170
189
  "id",
171
190
  "absent"
172
- ]]), undefined));
191
+ ]]), undefined, undefined));
173
192
  globalThis.expect(r).toBe(null);
174
193
  });
175
194
  globalThis.test("byIds returns matches, drops missing", async () => {
176
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
195
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
177
196
  "ids",
178
197
  [
179
198
  "p-1",
180
199
  "absent",
181
200
  "p-3"
182
201
  ].map(prim => prim)
183
- ]]), undefined));
202
+ ]]), undefined, undefined));
184
203
  globalThis.expect(ids(r)).toEqual([
185
204
  "p-1",
186
205
  "p-3"
187
206
  ]);
188
207
  });
189
208
  globalThis.test("index routes to indexLookup on the idField", async () => {
190
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("index", "byStatus", Object.fromEntries([[
209
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("index", "byStatus", Object.fromEntries([[
191
210
  "byStatus",
192
211
  "active"
193
- ]]), undefined));
212
+ ]]), undefined, undefined));
194
213
  globalThis.expect(ids(r)).toEqual([
195
214
  "p-1",
196
215
  "p-3"
@@ -198,13 +217,13 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
198
217
  });
199
218
  globalThis.test("list with push-down → returns the connection listPage gives", async () => {
200
219
  listPageReturn.contents = "PUSHED";
201
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined));
220
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined, undefined));
202
221
  listPageReturn.contents = undefined;
203
222
  globalThis.expect(r).toEqual("PUSHED");
204
223
  });
205
224
  globalThis.test("list without push-down → falls back to the shared spec", async () => {
206
225
  listPageReturn.contents = undefined;
207
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined));
226
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined, undefined));
208
227
  let edgeIds = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(r, "edges"), Stdlib_JSON.Decode.array), []), e => Stdlib_Option.flatMap(field(e, "node"), n => str(n, "id")));
209
228
  globalThis.expect(edgeIds).toEqual([
210
229
  "p-1",
@@ -213,32 +232,32 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
213
232
  ]);
214
233
  });
215
234
  globalThis.test("items with subId routes to itemsPage (id echoed)", async () => {
216
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, "seq", undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
235
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, "seq", undefined, undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
217
236
  "id",
218
237
  "p-2"
219
- ]]), undefined));
238
+ ]]), undefined, undefined));
220
239
  globalThis.expect(str(r, "itemsFor")).toEqual("p-2");
221
240
  });
222
241
  globalThis.test("items without subId → empty connection", async () => {
223
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
242
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
224
243
  "id",
225
244
  "p-2"
226
- ]]), undefined));
245
+ ]]), undefined, undefined));
227
246
  globalThis.expect(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(r, "edges"), Stdlib_JSON.Decode.array), []).length).toBe(0);
228
247
  });
229
248
  globalThis.test("authorization DenyAll → empty shape per kind", async () => {
230
- let b = makeBinding("DenyAll", undefined, undefined);
249
+ let b = makeBinding("DenyAll", undefined, undefined, undefined);
231
250
  let single = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("getById", undefined, Object.fromEntries([[
232
251
  "id",
233
252
  "p-1"
234
- ]]), undefined));
253
+ ]]), undefined, undefined));
235
254
  globalThis.expect(single).toBe(null);
236
- let list = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("list", undefined, undefined, undefined));
255
+ let list = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("list", undefined, undefined, undefined, undefined));
237
256
  globalThis.expect(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(list, "edges"), Stdlib_JSON.Decode.array), []).length).toBe(0);
238
257
  let byIds = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("byIds", undefined, Object.fromEntries([[
239
258
  "ids",
240
259
  []
241
- ]]), undefined));
260
+ ]]), undefined, undefined));
242
261
  globalThis.expect(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(byIds), []).length).toBe(0);
243
262
  });
244
263
  globalThis.test("queryInterceptor hook Deny → empty shape (auth allowed)", async () => {
@@ -246,10 +265,10 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
246
265
  TAG: "Deny",
247
266
  _0: "nope"
248
267
  }));
249
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
268
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
250
269
  "id",
251
270
  "p-1"
252
- ]]), undefined));
271
+ ]]), undefined, undefined));
253
272
  QueryDb_Callback$ReventlessCore.clearQueryInterceptor();
254
273
  globalThis.expect(r).toBe(null);
255
274
  });
@@ -272,7 +291,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
272
291
  arguments: payload_arguments,
273
292
  identity: Identity$Reventless.anonymous
274
293
  };
275
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
294
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, payload);
276
295
  globalThis.expect(str(r, "id")).toEqual("p-2");
277
296
  });
278
297
  globalThis.test("resolveOne multi via target index → array", async () => {
@@ -298,7 +317,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
298
317
  arguments: payload_arguments,
299
318
  identity: Identity$Reventless.anonymous
300
319
  };
301
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
320
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, payload);
302
321
  globalThis.expect(ids(r)).toEqual([
303
322
  "p-1",
304
323
  "p-3"
@@ -325,14 +344,14 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
325
344
  arguments: payload_arguments,
326
345
  identity: Identity$Reventless.anonymous
327
346
  };
328
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
347
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, payload);
329
348
  globalThis.expect(ids(r)).toEqual([
330
349
  "p-1",
331
350
  "p-3"
332
351
  ]);
333
352
  });
334
353
  globalThis.test("node decodes global id → __typename + item", async () => {
335
- PgQueryResolver_Lambda$ReventlessAws.register("Things", makeBinding(undefined, undefined, undefined));
354
+ PgQueryResolver_Lambda$ReventlessAws.register("Things", makeBinding(undefined, undefined, undefined, undefined));
336
355
  PgQueryResolver_Lambda$ReventlessAws.registerNodeType("Thing", "Things");
337
356
  let payload_arguments = Object.fromEntries([[
338
357
  "id",
@@ -382,7 +401,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
382
401
  delete: 0,
383
402
  deleteBatch: 0
384
403
  };
385
- let init = makeBinding(undefined, undefined, undefined);
404
+ let init = makeBinding(undefined, undefined, undefined, undefined);
386
405
  let authBinding_pushdowns = init.pushdowns;
387
406
  let authBinding_indexes = init.indexes;
388
407
  let authBinding_subIdField = init.subIdField;
@@ -390,6 +409,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
390
409
  let authBinding_labelField = init.labelField;
391
410
  let authBinding_includeIdParam = init.includeIdParam;
392
411
  let authBinding_authorization = init.authorization;
412
+ let authBinding_ownerField = init.ownerField;
393
413
  let authBinding = {
394
414
  ops: authOps,
395
415
  pushdowns: authBinding_pushdowns,
@@ -398,7 +418,8 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
398
418
  capability: authBinding_capability,
399
419
  labelField: authBinding_labelField,
400
420
  includeIdParam: authBinding_includeIdParam,
401
- authorization: authBinding_authorization
421
+ authorization: authBinding_authorization,
422
+ ownerField: authBinding_ownerField
402
423
  };
403
424
  let lookupAuth = name => {
404
425
  if (name === "AuthTable") {
@@ -424,18 +445,18 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
424
445
  identity: ident(username, groups)
425
446
  });
426
447
  globalThis.test("auth index: group member who owns → results", async () => {
427
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("alice", ["Owner"]));
448
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), lookupAuth, authIndexPayload("alice", ["Owner"]));
428
449
  globalThis.expect(ids(r)).toEqual([
429
450
  "p-1",
430
451
  "p-3"
431
452
  ]);
432
453
  });
433
454
  globalThis.test("auth index: group member who does NOT own → empty", async () => {
434
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", ["Owner"]));
455
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", ["Owner"]));
435
456
  globalThis.expect(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(r), []).length).toBe(0);
436
457
  });
437
458
  globalThis.test("auth index: non-member passes through → results", async () => {
438
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", []));
459
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", []));
439
460
  globalThis.expect(ids(r)).toEqual([
440
461
  "p-1",
441
462
  "p-3"
@@ -444,7 +465,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
444
465
  globalThis.test("unmapped kind throws", async () => {
445
466
  let threw;
446
467
  try {
447
- await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("frobnicate", undefined, undefined, undefined));
468
+ await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("frobnicate", undefined, undefined, undefined, undefined));
448
469
  threw = false;
449
470
  } catch (exn) {
450
471
  threw = true;
@@ -455,54 +476,135 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
455
476
 
456
477
  globalThis.describe("id form — the typed doors take either", () => {
457
478
  globalThis.test("getById resolves a Relay global id", async () => {
458
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
479
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
459
480
  "id",
460
481
  Api_Ids$ReventlessCore.encode("Thing", "p-2")
461
- ]]), undefined));
482
+ ]]), undefined, undefined));
462
483
  globalThis.expect(str(r, "name")).toEqual("Bravo");
463
484
  });
464
485
  globalThis.test("getById reports the storage key it resolved to", async () => {
465
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
486
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
466
487
  "id",
467
488
  Api_Ids$ReventlessCore.encode("Thing", "p-2")
468
- ]]), undefined));
489
+ ]]), undefined, undefined));
469
490
  globalThis.expect(str(r, "id")).toEqual("p-2");
470
491
  });
471
492
  globalThis.test("getById still resolves a plain storage key", async () => {
472
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
493
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
473
494
  "id",
474
495
  "p-2"
475
- ]]), undefined));
496
+ ]]), undefined, undefined));
476
497
  globalThis.expect(str(r, "name")).toEqual("Bravo");
477
498
  });
478
499
  globalThis.test("getById on a global id for a row that does not exist is still null", async () => {
479
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
500
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
480
501
  "id",
481
502
  Api_Ids$ReventlessCore.encode("Thing", "absent")
482
- ]]), undefined));
503
+ ]]), undefined, undefined));
483
504
  globalThis.expect(r).toBe(null);
484
505
  });
485
506
  globalThis.test("byIds accepts the two forms mixed in one call", async () => {
486
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
507
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
487
508
  "ids",
488
509
  [
489
510
  "p-1",
490
511
  Api_Ids$ReventlessCore.encode("Thing", "p-3")
491
512
  ]
492
- ]]), undefined));
513
+ ]]), undefined, undefined));
493
514
  globalThis.expect(ids(r)).toEqual([
494
515
  "p-1",
495
516
  "p-3"
496
517
  ]);
497
518
  });
498
519
  globalThis.test("byIds with every id found does not double-count", async () => {
499
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
520
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
500
521
  "ids",
501
522
  [
502
523
  "p-1",
503
524
  "p-3"
504
525
  ].map(prim => prim)
505
- ]]), undefined));
526
+ ]]), undefined, undefined));
527
+ globalThis.expect(ids(r)).toEqual([
528
+ "p-1",
529
+ "p-3"
530
+ ]);
531
+ });
532
+ });
533
+
534
+ globalThis.describe("owner scoping", () => {
535
+ let ownedBinding = () => makeBinding(undefined, undefined, "owner", undefined);
536
+ let shopper = cognito("u-a", ["User"]);
537
+ let listWith = async (binding, identity) => {
538
+ lastOwnerScope.contents = undefined;
539
+ listPageReturn.contents = "pushed";
540
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(binding, undefined, mkPayload("list", undefined, undefined, identity, undefined));
541
+ return [
542
+ r,
543
+ lastOwnerScope.contents
544
+ ];
545
+ };
546
+ globalThis.test("a shopper's list is scoped to their own id", async () => {
547
+ let match = await listWith(ownedBinding(), shopper);
548
+ globalThis.expect(match[1]).toEqual([
549
+ "owner",
550
+ "u-a"
551
+ ]);
552
+ });
553
+ globalThis.test("an elevated caller's list is not scoped", async () => {
554
+ OwnerScope$Reventless.setElevatedGroups(["Admin"]);
555
+ let match = await listWith(ownedBinding(), cognito("ops-1", ["Admin"]));
556
+ OwnerScope$Reventless.setElevatedGroups([]);
557
+ globalThis.expect(match[1]).toBe(undefined);
558
+ });
559
+ globalThis.test("a view with no owner field is never scoped", async () => {
560
+ let match = await listWith(makeBinding(undefined, undefined, undefined, undefined), shopper);
561
+ globalThis.expect(match[1]).toBe(undefined);
562
+ });
563
+ globalThis.test("an unidentified caller is refused before the push-down runs", async () => {
564
+ lastOwnerScope.contents = undefined;
565
+ listPageReturn.contents = "pushed";
566
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(ownedBinding(), undefined, mkPayload("list", undefined, undefined, Identity$Reventless.anonymous, undefined));
567
+ globalThis.expect([
568
+ field(r, "edges"),
569
+ lastOwnerScope.contents
570
+ ]).toEqual([
571
+ [],
572
+ undefined
573
+ ]);
574
+ });
575
+ globalThis.test("getById hides a row the caller does not own", async () => {
576
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(ownedBinding(), undefined, mkPayload("getById", undefined, Object.fromEntries([[
577
+ "id",
578
+ "p-2"
579
+ ]]), shopper, undefined));
580
+ globalThis.expect(r).toBe(null);
581
+ });
582
+ globalThis.test("getById still returns a row the caller does own", async () => {
583
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(ownedBinding(), undefined, mkPayload("getById", undefined, Object.fromEntries([[
584
+ "id",
585
+ "p-1"
586
+ ]]), shopper, undefined));
587
+ globalThis.expect(str(r, "id")).toEqual("p-1");
588
+ });
589
+ globalThis.test("byIds drops rows the caller does not own", async () => {
590
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(ownedBinding(), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
591
+ "ids",
592
+ [
593
+ "p-1",
594
+ "p-2",
595
+ "p-3"
596
+ ].map(prim => prim)
597
+ ]]), shopper, undefined));
598
+ globalThis.expect(ids(r)).toEqual([
599
+ "p-1",
600
+ "p-3"
601
+ ]);
602
+ });
603
+ globalThis.test("an index query drops rows the caller does not own", async () => {
604
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(ownedBinding(), undefined, mkPayload("index", "byStatus", Object.fromEntries([[
605
+ "byStatus",
606
+ "active"
607
+ ]]), shopper, undefined));
506
608
  globalThis.expect(ids(r)).toEqual([
507
609
  "p-1",
508
610
  "p-3"
@@ -516,11 +618,13 @@ export {
516
618
  allItems,
517
619
  fieldEq,
518
620
  listPageReturn,
621
+ lastOwnerScope,
519
622
  ops,
520
623
  pushdowns,
521
624
  capability,
522
625
  makeBinding,
523
626
  mkPayload,
627
+ cognito,
524
628
  objArgs,
525
629
  field,
526
630
  str,
@@ -19,18 +19,18 @@ globalThis.describe("QueryDbResolvers_AppSync.internalRowRequiredAttr", () => {
19
19
 
20
20
  globalThis.describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
21
21
  globalThis.test("with ~requireAttribute emits an attribute_exists filter that excludes internal rows", () => {
22
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name");
22
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined);
23
23
  globalThis.expect(code.includes("attribute_exists(#name)")).toBe(true);
24
24
  globalThis.expect(code.includes("names['#name'] = 'name'")).toBe(true);
25
25
  });
26
26
  globalThis.test("without ~requireAttribute emits no attribute_exists filter (default read models unchanged)", () => {
27
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined);
27
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined);
28
28
  globalThis.expect(code.includes("attribute_exists")).toBe(false);
29
29
  });
30
30
  });
31
31
 
32
32
  globalThis.describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
33
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined);
33
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined);
34
34
  globalThis.test("Fix 1: response encodes the real nextToken as the cursor", () => {
35
35
  globalThis.expect(code.includes("const next = ctx.result?.nextToken ?? null;")).toBe(true);
36
36
  globalThis.expect(code.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))")).toBe(true);