@reventlessdev/reventless-aws 3.0.0-alpha.283 → 3.0.0-alpha.285
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 +17 -0
- package/package.json +8 -8
- package/src/Platform.res +21 -12
- package/src/Platform.res.mjs +2 -2
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +58 -23
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +26 -6
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +35 -0
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +12 -1
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +3 -0
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +4 -2
- package/tests/PgQueryResolver_LambdaTest.res +149 -9
- package/tests/PgQueryResolver_LambdaTest.res.mjs +156 -52
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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.285 (2026-08-12)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @reventlessdev/reventless-aws
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.284 (2026-08-12)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **platform:** bake a curated component manifest as a static asset ([4f30265](https://github.com/ReventlessDev/reventless-core/commit/4f30265a51fc2c59e69afd8074cf1b2534c06378))
|
|
19
|
+
* **queries:** narrow owner-bearing reads to the caller ([ba9cc3d](https://github.com/ReventlessDev/reventless-core/commit/ba9cc3d58d7914a9e4827bda90a704a74b1b82dd))
|
|
20
|
+
* **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
|
|
21
|
+
|
|
22
|
+
|
|
6
23
|
# 3.0.0-alpha.283 (2026-08-11)
|
|
7
24
|
|
|
8
25
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.285",
|
|
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",
|
|
18
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.4",
|
|
19
|
+
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.71",
|
|
20
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",
|
|
25
21
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
26
|
-
"@reventlessdev/reventless-
|
|
22
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.226",
|
|
23
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.90",
|
|
24
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.136",
|
|
25
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
26
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.110"
|
|
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
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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(
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/*
|
|
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 (
|
|
55
|
-
|
|
56
|
-
|
|
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 = (
|
|
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
|
|
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:
|
|
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);
|