@reventlessdev/rescript-pulumi-aws 2.4.0-alpha.77 → 2.4.0-alpha.79
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,25 @@
|
|
|
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
|
+
# 2.4.0-alpha.79 (2026-08-18)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **api:** make the by-index door answer, and let an elevated caller widen it ([0fe0c6f](https://github.com/ReventlessDev/reventless-core/commit/0fe0c6f8dec6228ecaba39577e28d780b4f79c83))
|
|
11
|
+
* **aws:** apply [@owner](https://github.com/owner) on the DynamoDB by-ids and by-index doors ([a6b5afc](https://github.com/ReventlessDev/reventless-core/commit/a6b5afc2923bfa4d3e0b2990367f67e8ffdd8877))
|
|
12
|
+
* **aws:** narrow retirement on every DynamoDB door, not only the list ([d6a799b](https://github.com/ReventlessDev/reventless-core/commit/d6a799b287b28e9c1f75e193adbf3f6328a6bf2d))
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **core:** let a reference name a retired row, and let an elevated caller open one ([9e2623a](https://github.com/ReventlessDev/reventless-core/commit/9e2623a4b22487561607fcc0ca19d51726069ee4))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 2.4.0-alpha.78 (2026-08-16)
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **aws:** scope by-key reads to the owner, not only lists ([8232fd4](https://github.com/ReventlessDev/reventless-core/commit/8232fd4c09c1098c7265e4a17882ee44884f3bec))
|
|
23
|
+
|
|
24
|
+
|
|
6
25
|
# 2.4.0-alpha.77 (2026-08-16)
|
|
7
26
|
|
|
8
27
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/rescript-pulumi-aws",
|
|
3
|
-
"version": "2.4.0-alpha.
|
|
3
|
+
"version": "2.4.0-alpha.79",
|
|
4
4
|
"description": "ReScript bindings for @pulumi/aws",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"@pulumi/aws": "~7.19.0",
|
|
23
23
|
"@pulumi/aws-native": "^1.62.0",
|
|
24
24
|
"sury": "11.0.0-alpha.4",
|
|
25
|
-
"@reventlessdev/rescript-
|
|
26
|
-
"@reventlessdev/rescript-
|
|
25
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.12",
|
|
26
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"esbuild": "^0.25.12",
|
|
@@ -29,6 +29,250 @@ export function response(ctx) {
|
|
|
29
29
|
|
|
30
30
|
let importUtil = `import { util } from '@aws-appsync/utils';`
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
The caller-is-exempt test, emitted into a resolver's response.
|
|
34
|
+
|
|
35
|
+
Mirrors `Reventless.OwnerScope.resolve` — the same branch order for the same
|
|
36
|
+
reason the list predicate gives: an IAM-signed service caller has no `sub`
|
|
37
|
+
because it is inside the trust boundary, not because it is anonymous, so the
|
|
38
|
+
provider question is answered before the identity one.
|
|
39
|
+
|
|
40
|
+
In the RESPONSE rather than the request, because a `GetItem` has no
|
|
41
|
+
FilterExpression to carry a predicate: the row is fetched by key and the
|
|
42
|
+
decision is made on what came back. A `Query` could filter server-side, and
|
|
43
|
+
deliberately does not — a single-row read that filtered in one place and
|
|
44
|
+
guarded in another would have two implementations of one rule, and the cheaper
|
|
45
|
+
one is the one nobody would remember to change.
|
|
46
|
+
*/
|
|
47
|
+
let ownerGuardPreamble = (~ownerField: string, ~elevatedGroups: array<string>) => {
|
|
48
|
+
let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
|
|
49
|
+
`
|
|
50
|
+
const _id = ctx.identity;
|
|
51
|
+
const _sub = _id == null ? null : _id.sub;
|
|
52
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
53
|
+
const _elevated = [${elevatedLiteral}];
|
|
54
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
|
|
55
|
+
const _owns = (row) => row == null || _exempt || row['${ownerField}'] === _sub;`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
The exemption test on its own, for a door that narrows retirement but has no
|
|
60
|
+
`@owner` field to have declared it already.
|
|
61
|
+
|
|
62
|
+
Identical to the three lines `ownerGuardPreamble` opens with, and emitted only
|
|
63
|
+
when that preamble is absent — two `const _exempt` in one function body is a
|
|
64
|
+
syntax error, and two *different* definitions of exempt would be worse than one.
|
|
65
|
+
*/
|
|
66
|
+
let exemptPreamble = (~elevatedGroups: array<string>) => {
|
|
67
|
+
let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
|
|
68
|
+
`
|
|
69
|
+
const _id = ctx.identity;
|
|
70
|
+
const _sub = _id == null ? null : _id.sub;
|
|
71
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
72
|
+
const _elevated = [${elevatedLiteral}];
|
|
73
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
A by-key read's retirement guard: `_live(row)`, true when the caller may see it.
|
|
78
|
+
|
|
79
|
+
The post-read half of the predicate `listAllItemsConnection` pushes into a
|
|
80
|
+
FilterExpression. `GetItem` and `BatchGetItem` have no filter to push into — the
|
|
81
|
+
row is fetched by key — so the decision is made on what came back, which is where
|
|
82
|
+
the owner guard beside it already makes its own.
|
|
83
|
+
|
|
84
|
+
`row[field] == null` keeps a row written before the annotation existed, matching
|
|
85
|
+
the `attribute_not_exists` half of the list's clause and every other adapter's
|
|
86
|
+
reading of an absent value.
|
|
87
|
+
|
|
88
|
+
Asking is required, not merely permitted: `_exempt` alone leaves a retired row
|
|
89
|
+
withheld until `includeRetired` is passed, so an operator's ordinary read is as
|
|
90
|
+
narrow as anyone's. An archive that is always underfoot is not an archive.
|
|
91
|
+
|
|
92
|
+
`~ownerScoped` says whether an `ownerGuardPreamble` has already been emitted into
|
|
93
|
+
the same body; when it has, this reuses its `_exempt` instead of redeclaring one.
|
|
94
|
+
*/
|
|
95
|
+
let retiredGuardPreamble = (
|
|
96
|
+
~retiredField: option<string>,
|
|
97
|
+
~retiredValues: option<array<string>>,
|
|
98
|
+
~elevatedGroups: array<string>,
|
|
99
|
+
~ownerScoped: bool,
|
|
100
|
+
) =>
|
|
101
|
+
switch retiredField {
|
|
102
|
+
| None => ""
|
|
103
|
+
| Some(field) =>
|
|
104
|
+
let prelude = ownerScoped ? "" : exemptPreamble(~elevatedGroups)
|
|
105
|
+
let isRetired = switch retiredValues {
|
|
106
|
+
| None => `row['${field}'] === true`
|
|
107
|
+
| Some(states) =>
|
|
108
|
+
let literal = states->Array.map(s => `'${s}'`)->Array.join(", ")
|
|
109
|
+
`[${literal}].indexOf(row['${field}']) >= 0`
|
|
110
|
+
}
|
|
111
|
+
`${prelude}
|
|
112
|
+
// ── retirement narrowing (generated) ──
|
|
113
|
+
const _wantsRetired = _exempt && ctx.args.includeRetired === true;
|
|
114
|
+
const _live = (row) =>
|
|
115
|
+
row == null || _wantsRetired || row['${field}'] == null || !(${isRetired});`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
The `@owner` predicate for an index door, as a FilterExpression clause.
|
|
120
|
+
|
|
121
|
+
Same rule and same branch order as `listAllItemsConnection`'s own owner clause —
|
|
122
|
+
provider before identity, because an IAM-signed service caller has no `sub` for a
|
|
123
|
+
reason that has nothing to do with being anonymous — and pushed into the read for
|
|
124
|
+
the same reason: an index door takes a `limit`, and a page cut before the
|
|
125
|
+
predicate would come back short with nothing said about why.
|
|
126
|
+
|
|
127
|
+
**Not applied to a group-restricted index.** Where `indexConfig.authorization` is
|
|
128
|
+
set, the door already runs `authorizeIndexedAccess`: the caller must be in the
|
|
129
|
+
named group AND be the holder the auth table records for that index value. Those
|
|
130
|
+
rows are, by construction, other people's — an order assigned to a fulfilment
|
|
131
|
+
operator is owned by the customer who placed it — so ANDing `@owner` on top would
|
|
132
|
+
return nothing and revoke exactly the access the auth table was written to grant.
|
|
133
|
+
An explicit per-index rule is the deployment's answer for that door; this is the
|
|
134
|
+
default for doors that have none. `QueryDbResolvers_AppSync` decides which is
|
|
135
|
+
which and passes `ownerField` only for the latter.
|
|
136
|
+
*/
|
|
137
|
+
let ownerFilterClause = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
|
|
138
|
+
switch ownerField {
|
|
139
|
+
| None => ""
|
|
140
|
+
| Some(field) =>
|
|
141
|
+
let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
|
|
142
|
+
`
|
|
143
|
+
// ── owner scoping (generated) ──
|
|
144
|
+
// Not read from ctx.args: a predicate deciding what the caller may see must
|
|
145
|
+
// arrive on a channel the caller cannot name.
|
|
146
|
+
const _oid = ctx.identity;
|
|
147
|
+
const _osub = _oid == null ? null : _oid.sub;
|
|
148
|
+
const _ogroups = (_oid != null && _oid.claims != null && _oid.claims['cognito:groups']) || [];
|
|
149
|
+
const _oelevated = [${elevatedLiteral}];
|
|
150
|
+
if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0))) {
|
|
151
|
+
if (expression) expression += ' AND ';
|
|
152
|
+
names['#owner'] = '${field}';
|
|
153
|
+
values[':owner'] = _osub;
|
|
154
|
+
expression += '#owner = :owner';
|
|
155
|
+
}
|
|
156
|
+
`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
The same retirement predicate as `retiredGuardPreamble`, for a door that reads
|
|
161
|
+
with `Query` and therefore has a FilterExpression to put it in.
|
|
162
|
+
|
|
163
|
+
Pushed into the read rather than applied to what came back, on
|
|
164
|
+
`listAllItemsConnection`'s reasoning: an index door takes a `limit`, and
|
|
165
|
+
narrowing after the read would hand back a page of fewer rows than the caller
|
|
166
|
+
asked for while reporting nothing about why.
|
|
167
|
+
|
|
168
|
+
Emits JS that appends to the `expression` / `names` / `values` the index
|
|
169
|
+
templates already build, so it composes with a caller's own filter arguments
|
|
170
|
+
instead of replacing them.
|
|
171
|
+
*/
|
|
172
|
+
let retiredFilterClause = (
|
|
173
|
+
~retiredField: option<string>,
|
|
174
|
+
~retiredValues: option<array<string>>,
|
|
175
|
+
~elevatedGroups: array<string>,
|
|
176
|
+
) =>
|
|
177
|
+
switch retiredField {
|
|
178
|
+
| None => ""
|
|
179
|
+
| Some(field) =>
|
|
180
|
+
let assignments = switch retiredValues {
|
|
181
|
+
| None => ` values[':retiredFalse'] = false;
|
|
182
|
+
expression += '(attribute_not_exists(#retired) OR #retired = :retiredFalse)';`
|
|
183
|
+
| Some(states) =>
|
|
184
|
+
let assigns =
|
|
185
|
+
states
|
|
186
|
+
->Array.mapWithIndex((state, i) =>
|
|
187
|
+
` values[':retiredValue${Int.toString(i)}'] = '${state}';`
|
|
188
|
+
)
|
|
189
|
+
->Array.join("\n")
|
|
190
|
+
let comparisons =
|
|
191
|
+
states
|
|
192
|
+
->Array.mapWithIndex((_, i) => `#retired <> :retiredValue${Int.toString(i)}`)
|
|
193
|
+
->Array.join(" AND ")
|
|
194
|
+
`${assigns}
|
|
195
|
+
expression += '(attribute_not_exists(#retired) OR (${comparisons}))';`
|
|
196
|
+
}
|
|
197
|
+
`${exemptPreamble(~elevatedGroups)}
|
|
198
|
+
// ── retirement narrowing (generated) ──
|
|
199
|
+
if (!(_exempt && ctx.args.includeRetired === true)) {
|
|
200
|
+
if (expression) expression += ' AND ';
|
|
201
|
+
names['#retired'] = '${field}';
|
|
202
|
+
${assignments}
|
|
203
|
+
}
|
|
204
|
+
`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
A by-key read's response, refusing a row the caller does not own.
|
|
209
|
+
|
|
210
|
+
**Null, not an error** — which is the opposite of what a first reading suggests,
|
|
211
|
+
since "you may not read this" and "there is nothing here" are different answers
|
|
212
|
+
and only one of them is true. Two things settle it. The in-process platform
|
|
213
|
+
already answers `null` here, and a rule enforced differently per transport is
|
|
214
|
+
the failure mode owner scoping exists to avoid. And an error would confirm the
|
|
215
|
+
row exists to a caller who may not read it, which is a worse leak than the
|
|
216
|
+
ambiguity it removes.
|
|
217
|
+
*/
|
|
218
|
+
let ownerScopedResultResponse = (
|
|
219
|
+
~ownerField: option<string>,
|
|
220
|
+
~elevatedGroups: array<string>,
|
|
221
|
+
~retiredField: option<string>=?,
|
|
222
|
+
~retiredValues: option<array<string>>=?,
|
|
223
|
+
) =>
|
|
224
|
+
switch (ownerField, retiredField) {
|
|
225
|
+
| (None, None) => resultResponseCode
|
|
226
|
+
| _ =>
|
|
227
|
+
let ownerPart = switch ownerField {
|
|
228
|
+
| None => "\n const _owns = () => true;"
|
|
229
|
+
| Some(field) =>
|
|
230
|
+
`
|
|
231
|
+
// ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
|
|
232
|
+
}
|
|
233
|
+
let retiredPart = retiredGuardPreamble(
|
|
234
|
+
~retiredField,
|
|
235
|
+
~retiredValues,
|
|
236
|
+
~elevatedGroups,
|
|
237
|
+
~ownerScoped=ownerField->Option.isSome,
|
|
238
|
+
)
|
|
239
|
+
`
|
|
240
|
+
export function response(ctx) {
|
|
241
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
|
|
242
|
+
return _owns(ctx.result)${retiredField->Option.isSome ? " && _live(ctx.result)" : ""} ? ctx.result : null;
|
|
243
|
+
}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The `queryByIdSort` counterpart — same rule, over the first row of a Query. */
|
|
247
|
+
let ownerScopedFirstResultResponse = (
|
|
248
|
+
~ownerField: option<string>,
|
|
249
|
+
~elevatedGroups: array<string>,
|
|
250
|
+
~retiredField: option<string>=?,
|
|
251
|
+
~retiredValues: option<array<string>>=?,
|
|
252
|
+
) =>
|
|
253
|
+
switch (ownerField, retiredField) {
|
|
254
|
+
| (None, None) => firstResultResponseCode
|
|
255
|
+
| _ =>
|
|
256
|
+
let ownerPart = switch ownerField {
|
|
257
|
+
| None => "\n const _owns = () => true;"
|
|
258
|
+
| Some(field) =>
|
|
259
|
+
`
|
|
260
|
+
// ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
|
|
261
|
+
}
|
|
262
|
+
let retiredPart = retiredGuardPreamble(
|
|
263
|
+
~retiredField,
|
|
264
|
+
~retiredValues,
|
|
265
|
+
~elevatedGroups,
|
|
266
|
+
~ownerScoped=ownerField->Option.isSome,
|
|
267
|
+
)
|
|
268
|
+
`
|
|
269
|
+
export function response(ctx) {
|
|
270
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
|
|
271
|
+
const _row = ctx.result.items[0] ?? null;
|
|
272
|
+
return _owns(_row)${retiredField->Option.isSome ? " && _live(_row)" : ""} ? _row : null;
|
|
273
|
+
}`
|
|
274
|
+
}
|
|
275
|
+
|
|
32
276
|
// ---------------------------------------------------------------------------
|
|
33
277
|
// Pipeline resolver pass-through (no before/after processing)
|
|
34
278
|
// ---------------------------------------------------------------------------
|
|
@@ -89,7 +333,17 @@ export function response(ctx) {
|
|
|
89
333
|
// DynamoDB read — by primary key
|
|
90
334
|
// ---------------------------------------------------------------------------
|
|
91
335
|
|
|
92
|
-
|
|
336
|
+
// `~ownerField` / `~elevatedGroups` carry the same meaning as on
|
|
337
|
+
// `listAllItemsConnection`, and are optional for the same reason: a state that
|
|
338
|
+
// declares no owner emits exactly the source it emitted before scoping existed.
|
|
339
|
+
// A list that scopes beside a by-id read that does not is not a partial
|
|
340
|
+
// delivery — it is a hole, reachable by anyone who can name a row.
|
|
341
|
+
let getItemById = (
|
|
342
|
+
~ownerField: option<string>=?,
|
|
343
|
+
~elevatedGroups: array<string>=[],
|
|
344
|
+
~retiredField: option<string>=?,
|
|
345
|
+
~retiredValues: option<array<string>>=?,
|
|
346
|
+
) =>
|
|
93
347
|
`${importUtil}
|
|
94
348
|
export function request(ctx) {
|
|
95
349
|
return {
|
|
@@ -97,7 +351,7 @@ export function request(ctx) {
|
|
|
97
351
|
key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
|
|
98
352
|
};
|
|
99
353
|
}
|
|
100
|
-
${
|
|
354
|
+
${ownerScopedResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
|
|
101
355
|
`->Pulumi.Input.make
|
|
102
356
|
|
|
103
357
|
let queryById =
|
|
@@ -119,7 +373,39 @@ ${resultResponseCode}
|
|
|
119
373
|
Relay pagination: `first`/`after` (forward) or `last`/`before` (backward).
|
|
120
374
|
Cursor is base64 of the sort key value.
|
|
121
375
|
Returns a Relay `{ edges, pageInfo }` shape reusing the entity's `Connection` type. */
|
|
122
|
-
|
|
376
|
+
// A list in everything but its name, so it scopes the way `listAllItemsConnection`
|
|
377
|
+
// does — a FilterExpression on the request, not a guard on the response. The
|
|
378
|
+
// response is where the page is cut, and narrowing after that cut would report
|
|
379
|
+
// `hasNextPage` from a count the caller was never allowed to see.
|
|
380
|
+
let queryItemsWithSortConditions = (
|
|
381
|
+
sortField: string,
|
|
382
|
+
~ownerField: option<string>=?,
|
|
383
|
+
~elevatedGroups: array<string>=[],
|
|
384
|
+
) => {
|
|
385
|
+
let ownerFilter = switch ownerField {
|
|
386
|
+
| None => ""
|
|
387
|
+
| Some(field) =>
|
|
388
|
+
let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
|
|
389
|
+
`
|
|
390
|
+
// ── owner scoping (generated) ──
|
|
391
|
+
// Not read from ctx.args, for the reason the list resolver gives: a predicate
|
|
392
|
+
// deciding what the caller may see must arrive on a channel they cannot name.
|
|
393
|
+
const _id = ctx.identity;
|
|
394
|
+
const _sub = _id == null ? null : _id.sub;
|
|
395
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
396
|
+
const _elevated = [${elevatedLiteral}];
|
|
397
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
|
|
398
|
+
const _ownerFilter = _exempt ? undefined : {
|
|
399
|
+
expression: '#owner = :owner',
|
|
400
|
+
expressionNames: { '#owner': '${field}' },
|
|
401
|
+
expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
|
|
402
|
+
};`
|
|
403
|
+
}
|
|
404
|
+
let ownerFilterField = switch ownerField {
|
|
405
|
+
| None => ""
|
|
406
|
+
| Some(_) => `
|
|
407
|
+
filter: _ownerFilter,`
|
|
408
|
+
}
|
|
123
409
|
`${importUtil}
|
|
124
410
|
const encodeCursor = (skValue) => util.base64Encode(skValue);
|
|
125
411
|
const decodeCursor = (cursor) => util.base64Decode(cursor);
|
|
@@ -162,10 +448,10 @@ export function request(ctx) {
|
|
|
162
448
|
const expression = skCondition ? \`#id = :id AND \${skCondition}\` : '#id = :id';
|
|
163
449
|
const orderDesc = filter.order === 'DESC';
|
|
164
450
|
const scanForward = isBackward ? orderDesc : !orderDesc;
|
|
165
|
-
const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50)
|
|
451
|
+
const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);${ownerFilter}
|
|
166
452
|
return {
|
|
167
453
|
operation: 'Query',
|
|
168
|
-
query: { expression, expressionNames, expressionValues }
|
|
454
|
+
query: { expression, expressionNames, expressionValues },${ownerFilterField}
|
|
169
455
|
scanIndexForward: scanForward,
|
|
170
456
|
limit: pageSize + 1,
|
|
171
457
|
};
|
|
@@ -194,8 +480,15 @@ export function response(ctx) {
|
|
|
194
480
|
};
|
|
195
481
|
}
|
|
196
482
|
`->Pulumi.Input.make
|
|
483
|
+
}
|
|
197
484
|
|
|
198
|
-
let queryByIdSort = (
|
|
485
|
+
let queryByIdSort = (
|
|
486
|
+
sortField: string,
|
|
487
|
+
~ownerField: option<string>=?,
|
|
488
|
+
~elevatedGroups: array<string>=[],
|
|
489
|
+
~retiredField: option<string>=?,
|
|
490
|
+
~retiredValues: option<array<string>>=?,
|
|
491
|
+
) =>
|
|
199
492
|
`${importUtil}
|
|
200
493
|
export function request(ctx) {
|
|
201
494
|
return {
|
|
@@ -210,7 +503,7 @@ export function request(ctx) {
|
|
|
210
503
|
}
|
|
211
504
|
};
|
|
212
505
|
}
|
|
213
|
-
${
|
|
506
|
+
${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
|
|
214
507
|
`->Pulumi.Input.make
|
|
215
508
|
|
|
216
509
|
// ---------------------------------------------------------------------------
|
|
@@ -279,14 +572,84 @@ export function request(ctx) {
|
|
|
279
572
|
${resultResponseCode}
|
|
280
573
|
`->Pulumi.Input.make
|
|
281
574
|
|
|
575
|
+
/**
|
|
576
|
+
The by-index door's response.
|
|
577
|
+
|
|
578
|
+
Cursors are the DynamoDB continuation token carrying the row's position in the
|
|
579
|
+
page, exactly as `listAllItemsConnection` builds them — the two doors page over
|
|
580
|
+
the same kind of result, so they page the same way. The boundary cursor covers
|
|
581
|
+
the case that door documents: a filtered page can come back empty while a token
|
|
582
|
+
is still set, and a client has to be able to resume past it.
|
|
583
|
+
|
|
584
|
+
This replaces returning `ctx.result` raw. The field has always been declared as
|
|
585
|
+
returning a `Connection!`, and handing back DynamoDB's `{items, nextToken}`
|
|
586
|
+
satisfied no part of that contract.
|
|
587
|
+
*/
|
|
588
|
+
let indexConnectionResponseCode = `
|
|
589
|
+
export function response(ctx) {
|
|
590
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);
|
|
591
|
+
const items = ctx.result?.items ?? [];
|
|
592
|
+
const next = ctx.result?.nextToken ?? null;
|
|
593
|
+
const edges = items.map((item, i) => ({
|
|
594
|
+
node: item,
|
|
595
|
+
cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
|
|
596
|
+
}));
|
|
597
|
+
const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
|
|
598
|
+
return {
|
|
599
|
+
edges,
|
|
600
|
+
pageInfo: {
|
|
601
|
+
hasNextPage: !!next,
|
|
602
|
+
hasPreviousPage: !!ctx.args.after,
|
|
603
|
+
startCursor: edges.length > 0 ? edges[0].cursor : boundary,
|
|
604
|
+
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}`
|
|
608
|
+
|
|
609
|
+
/** Refuses backward paging, for the reason `listAllItemsConnection` gives: the
|
|
610
|
+
cursor is DynamoDB's own continuation token, which only walks forward, so
|
|
611
|
+
`last`/`before` cannot be honoured and handing back the forward page would answer
|
|
612
|
+
a different question without saying so.
|
|
613
|
+
|
|
614
|
+
The arguments stay declared — one that came and went with the index's shape would
|
|
615
|
+
make every client feature-detect — and the local backend refuses them with the
|
|
616
|
+
same message, so the door reads the same either side of a deploy. */
|
|
617
|
+
let indexBackwardPagingGuard = `
|
|
618
|
+
if (args.before != null || args.last != null) {
|
|
619
|
+
util.error('Backward pagination (last/before) is not supported on by-index connections; use first/after.', 'UnsupportedPagination');
|
|
620
|
+
}`
|
|
621
|
+
|
|
622
|
+
/** Decodes the Relay `after` cursor back to the DynamoDB continuation token the
|
|
623
|
+
response side encoded. Mirrors `listAllItemsConnection`'s request half. */
|
|
624
|
+
let indexCursorPreamble = `
|
|
625
|
+
let after = null;
|
|
626
|
+
if (args.after != null && args.after !== '') {
|
|
627
|
+
const parsed = JSON.parse(util.base64Decode(args.after));
|
|
628
|
+
after = parsed.token ?? null;
|
|
629
|
+
}`
|
|
630
|
+
|
|
631
|
+
// The arguments the by-index door declares, none of which is a column to match
|
|
632
|
+
// on. `includeRetired` is a request to lift a restriction and the rest are
|
|
633
|
+
// paging; left unlisted, the filter loop below turns each into a
|
|
634
|
+
// `contains(#arg, :arg)` against an attribute no row carries, and the door
|
|
635
|
+
// answers nothing.
|
|
636
|
+
let indexReservedArgs = `key === 'first' || key === 'after' || key === 'last' || key === 'before' || key === 'includeRetired' || key === 'limit' || key === 'nextToken' || key === 'forward'`
|
|
637
|
+
|
|
282
638
|
// AppSync JS runtime restrictions (APPSYNC_JS 1.0.0):
|
|
283
639
|
// - No `for` loops (for/for-of/for-in all fail validation)
|
|
284
640
|
// - No String() / .toString() — use '' + value instead
|
|
285
641
|
// - Object.keys().forEach() works for iteration
|
|
286
|
-
let queryByIndexFiltered = (
|
|
642
|
+
let queryByIndexFiltered = (
|
|
643
|
+
~index: string,
|
|
644
|
+
~idField: string,
|
|
645
|
+
~ownerField: option<string>=?,
|
|
646
|
+
~retiredField: option<string>=?,
|
|
647
|
+
~retiredValues: option<array<string>>=?,
|
|
648
|
+
~elevatedGroups: array<string>=[],
|
|
649
|
+
) =>
|
|
287
650
|
`${importUtil}
|
|
288
651
|
export function request(ctx) {
|
|
289
|
-
const args = ctx.args
|
|
652
|
+
const args = ctx.args;${indexBackwardPagingGuard}
|
|
290
653
|
const query = {
|
|
291
654
|
expression: '#${idField} = :${idField}',
|
|
292
655
|
expressionNames: { '#${idField}': '${idField}' },
|
|
@@ -297,7 +660,7 @@ export function request(ctx) {
|
|
|
297
660
|
const values = {};
|
|
298
661
|
Object.keys(args).forEach(key => {
|
|
299
662
|
const value = args[key];
|
|
300
|
-
if (key === '${idField}' ||
|
|
663
|
+
if (key === '${idField}' || ${indexReservedArgs}) return;
|
|
301
664
|
if (value == null || value === '') return;
|
|
302
665
|
if (expression) expression += ' AND';
|
|
303
666
|
if (key === 'hideDeleted') {
|
|
@@ -312,12 +675,13 @@ export function request(ctx) {
|
|
|
312
675
|
values[':' + key] = '' + value;
|
|
313
676
|
}
|
|
314
677
|
});
|
|
678
|
+
${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
|
|
315
679
|
const result = {
|
|
316
680
|
operation: 'Query',
|
|
317
681
|
query,
|
|
318
682
|
index: '${index}',
|
|
319
|
-
limit: (args.
|
|
320
|
-
nextToken:
|
|
683
|
+
limit: (args.first ?? 50),
|
|
684
|
+
nextToken: after,
|
|
321
685
|
scanIndexForward: (args.forward ?? true)
|
|
322
686
|
};
|
|
323
687
|
if (expression) {
|
|
@@ -325,13 +689,21 @@ export function request(ctx) {
|
|
|
325
689
|
}
|
|
326
690
|
return result;
|
|
327
691
|
}
|
|
328
|
-
${
|
|
692
|
+
${indexConnectionResponseCode}
|
|
329
693
|
`->Pulumi.Input.make
|
|
330
694
|
|
|
331
|
-
let queryByIndexSortFiltered = (
|
|
695
|
+
let queryByIndexSortFiltered = (
|
|
696
|
+
~index: string,
|
|
697
|
+
~idField: string,
|
|
698
|
+
~ownerField: option<string>=?,
|
|
699
|
+
~sortField: string,
|
|
700
|
+
~retiredField: option<string>=?,
|
|
701
|
+
~retiredValues: option<array<string>>=?,
|
|
702
|
+
~elevatedGroups: array<string>=[],
|
|
703
|
+
) =>
|
|
332
704
|
`${importUtil}
|
|
333
705
|
export function request(ctx) {
|
|
334
|
-
const args = ctx.args
|
|
706
|
+
const args = ctx.args;${indexBackwardPagingGuard}
|
|
335
707
|
const query = args.${sortField}
|
|
336
708
|
? {
|
|
337
709
|
expression: '#${idField} = :${idField} AND #${sortField} = :${sortField}',
|
|
@@ -351,7 +723,7 @@ export function request(ctx) {
|
|
|
351
723
|
const values = {};
|
|
352
724
|
Object.keys(args).forEach(key => {
|
|
353
725
|
const value = args[key];
|
|
354
|
-
if (key === '${idField}' || key === '${sortField}' ||
|
|
726
|
+
if (key === '${idField}' || key === '${sortField}' || ${indexReservedArgs}) return;
|
|
355
727
|
if (value == null || value === '') return;
|
|
356
728
|
if (expression) expression += ' AND';
|
|
357
729
|
if (key === 'hideDeleted') {
|
|
@@ -366,12 +738,13 @@ export function request(ctx) {
|
|
|
366
738
|
values[':' + key] = '' + value;
|
|
367
739
|
}
|
|
368
740
|
});
|
|
741
|
+
${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
|
|
369
742
|
const result = {
|
|
370
743
|
operation: 'Query',
|
|
371
744
|
query,
|
|
372
745
|
index: '${index}',
|
|
373
|
-
limit: (args.
|
|
374
|
-
nextToken:
|
|
746
|
+
limit: (args.first ?? 50),
|
|
747
|
+
nextToken: after,
|
|
375
748
|
scanIndexForward: (args.forward ?? true)
|
|
376
749
|
};
|
|
377
750
|
if (expression) {
|
|
@@ -379,7 +752,7 @@ export function request(ctx) {
|
|
|
379
752
|
}
|
|
380
753
|
return result;
|
|
381
754
|
}
|
|
382
|
-
${
|
|
755
|
+
${indexConnectionResponseCode}
|
|
383
756
|
`->Pulumi.Input.make
|
|
384
757
|
|
|
385
758
|
// ---------------------------------------------------------------------------
|
|
@@ -893,7 +1266,12 @@ export function response(ctx) {
|
|
|
893
1266
|
time, since BatchGetItem's `tables` map keys on the literal table name.
|
|
894
1267
|
Single-key tables only — composite-key BatchGetItem needs both pk + sk per
|
|
895
1268
|
key entry, which this template doesn't construct. */
|
|
896
|
-
let batchGetItemsByIds = (
|
|
1269
|
+
let batchGetItemsByIds = (
|
|
1270
|
+
~ownerField: option<string>=?,
|
|
1271
|
+
~retiredField: option<string>=?,
|
|
1272
|
+
~retiredValues: option<array<string>>=?,
|
|
1273
|
+
~elevatedGroups: array<string>=[],
|
|
1274
|
+
) => (tableName: string) =>
|
|
897
1275
|
`${importUtil}
|
|
898
1276
|
import { runtime } from '@aws-appsync/utils';
|
|
899
1277
|
export function request(ctx) {
|
|
@@ -917,10 +1295,98 @@ export function response(ctx) {
|
|
|
917
1295
|
// missing id makes the entire field fail with "Cannot return null for
|
|
918
1296
|
// non-nullable type" and the caller sees data=null. Filter the nulls so
|
|
919
1297
|
// the field returns just the items that were found.
|
|
920
|
-
|
|
1298
|
+
// The owner and retirement guards the list pushes into a FilterExpression,
|
|
1299
|
+
// applied after the read because BatchGetItem has none to push into. A row the
|
|
1300
|
+
// caller does not own is dropped rather than refused, for the reason the
|
|
1301
|
+
// single-key door answers null: distinguishing "not yours" from "not there"
|
|
1302
|
+
// would make this door an oracle for which ids exist.${switch ownerField {
|
|
1303
|
+
| None => ""
|
|
1304
|
+
| Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
|
|
1305
|
+
}}${retiredGuardPreamble(
|
|
1306
|
+
~retiredField,
|
|
1307
|
+
~retiredValues,
|
|
1308
|
+
~elevatedGroups,
|
|
1309
|
+
~ownerScoped=ownerField->Option.isSome,
|
|
1310
|
+
)}
|
|
1311
|
+
return (ctx.result?.data?.['${tableName}'] ?? []).filter(item =>
|
|
1312
|
+
item !== null${ownerField->Option.isSome ? " && _owns(item)" : ""}${retiredField->Option.isSome
|
|
1313
|
+
? " && _live(item)"
|
|
1314
|
+
: ""});
|
|
921
1315
|
}
|
|
922
1316
|
`
|
|
923
1317
|
|
|
1318
|
+
/** The reference door — `{list}Refs(ids)`: what a caller holding a pointer to a
|
|
1319
|
+
row may learn about it, and nothing else.
|
|
1320
|
+
|
|
1321
|
+
The same BatchGetItem as `batchGetItemsByIds`, projected in the response to
|
|
1322
|
+
`{id, label, retired, retiredState}`. The projection is the type's — a caller
|
|
1323
|
+
cannot ask for a price here because the SDL type has none — so the response
|
|
1324
|
+
only has to *build* the three fields, never decide which to withhold.
|
|
1325
|
+
|
|
1326
|
+
`namedWhenRetired` is what a retired row turns on: false drops it, exactly as
|
|
1327
|
+
every other door does; true lets it through with the state that withdrew it.
|
|
1328
|
+
The owner rule is applied either way and is not what the annotation lifts. */
|
|
1329
|
+
let refsByIds = (
|
|
1330
|
+
~labelField: string,
|
|
1331
|
+
~retiredField: option<string>,
|
|
1332
|
+
~retiredValues: option<array<string>>,
|
|
1333
|
+
~namedWhenRetired: bool,
|
|
1334
|
+
~ownerField: option<string>=?,
|
|
1335
|
+
~elevatedGroups: array<string>=[],
|
|
1336
|
+
) => (tableName: string) => {
|
|
1337
|
+
let ownerGuard = switch ownerField {
|
|
1338
|
+
| None => "\n const _owns = () => true;"
|
|
1339
|
+
| Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
|
|
1340
|
+
}
|
|
1341
|
+
// Retirement, in the vocabulary the row itself uses: a member test for the
|
|
1342
|
+
// state form, truthiness for the boolean one. Absent keeps the row live, which
|
|
1343
|
+
// is what a row written before the annotation is.
|
|
1344
|
+
let retiredExpr = switch (retiredField, retiredValues) {
|
|
1345
|
+
| (None, _) => "false"
|
|
1346
|
+
| (Some(f), Some(values)) =>
|
|
1347
|
+
let literal = values->Array.map(v => `'${v}'`)->Array.join(", ")
|
|
1348
|
+
`[${literal}].indexOf(row['${f}']) >= 0`
|
|
1349
|
+
| (Some(f), None) => `row['${f}'] === true`
|
|
1350
|
+
}
|
|
1351
|
+
// Only the state form has a state to name, and only a retired row reports one:
|
|
1352
|
+
// this door names rows, it does not publish a lifecycle column to callers the
|
|
1353
|
+
// list withholds.
|
|
1354
|
+
let stateExpr = switch (retiredField, retiredValues) {
|
|
1355
|
+
| (Some(f), Some(_)) => `_retired(row) ? (row['${f}'] ?? null) : null`
|
|
1356
|
+
| _ => "null"
|
|
1357
|
+
}
|
|
1358
|
+
`${importUtil}
|
|
1359
|
+
import { runtime } from '@aws-appsync/utils';
|
|
1360
|
+
export function request(ctx) {
|
|
1361
|
+
const ids = ctx.args.ids ?? [];
|
|
1362
|
+
if (ids.length === 0) return runtime.earlyReturn([]);
|
|
1363
|
+
return {
|
|
1364
|
+
operation: 'BatchGetItem',
|
|
1365
|
+
tables: {
|
|
1366
|
+
'${tableName}': {
|
|
1367
|
+
keys: ids.map(id => ({ id: util.dynamodb.toDynamoDB(id) })),
|
|
1368
|
+
consistentRead: true,
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
export function response(ctx) {
|
|
1374
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerGuard}
|
|
1375
|
+
const _retired = (row) => ${retiredExpr};
|
|
1376
|
+
const _namesRetired = ${namedWhenRetired ? "true" : "false"};
|
|
1377
|
+
return (ctx.result?.data?.['${tableName}'] ?? [])
|
|
1378
|
+
.filter(row => row !== null && _owns(row))
|
|
1379
|
+
.filter(row => _namesRetired || !_retired(row))
|
|
1380
|
+
.map(row => ({
|
|
1381
|
+
id: row.id,
|
|
1382
|
+
label: row['${labelField}'] ?? row.id,
|
|
1383
|
+
retired: _retired(row),
|
|
1384
|
+
retiredState: ${stateExpr},
|
|
1385
|
+
}));
|
|
1386
|
+
}
|
|
1387
|
+
`
|
|
1388
|
+
}
|
|
1389
|
+
|
|
924
1390
|
// ---------------------------------------------------------------------------
|
|
925
1391
|
// DynamoDB write
|
|
926
1392
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
3
4
|
|
|
4
5
|
let resultResponseCode = `
|
|
5
6
|
export function response(ctx) {
|
|
@@ -21,6 +22,125 @@ export function response(ctx) {
|
|
|
21
22
|
|
|
22
23
|
let importUtil = `import { util } from '@aws-appsync/utils';`;
|
|
23
24
|
|
|
25
|
+
function ownerGuardPreamble(ownerField, elevatedGroups) {
|
|
26
|
+
let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
|
|
27
|
+
return `
|
|
28
|
+
const _id = ctx.identity;
|
|
29
|
+
const _sub = _id == null ? null : _id.sub;
|
|
30
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
31
|
+
const _elevated = [` + elevatedLiteral + `];
|
|
32
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
|
|
33
|
+
const _owns = (row) => row == null || _exempt || row['` + ownerField + `'] === _sub;`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function exemptPreamble(elevatedGroups) {
|
|
37
|
+
let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
|
|
38
|
+
return `
|
|
39
|
+
const _id = ctx.identity;
|
|
40
|
+
const _sub = _id == null ? null : _id.sub;
|
|
41
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
42
|
+
const _elevated = [` + elevatedLiteral + `];
|
|
43
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, ownerScoped) {
|
|
47
|
+
if (retiredField === undefined) {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
let prelude = ownerScoped ? "" : exemptPreamble(elevatedGroups);
|
|
51
|
+
let isRetired;
|
|
52
|
+
if (retiredValues !== undefined) {
|
|
53
|
+
let literal = retiredValues.map(s => `'` + s + `'`).join(", ");
|
|
54
|
+
isRetired = `[` + literal + `].indexOf(row['` + retiredField + `']) >= 0`;
|
|
55
|
+
} else {
|
|
56
|
+
isRetired = `row['` + retiredField + `'] === true`;
|
|
57
|
+
}
|
|
58
|
+
return prelude + `
|
|
59
|
+
// ── retirement narrowing (generated) ──
|
|
60
|
+
const _wantsRetired = _exempt && ctx.args.includeRetired === true;
|
|
61
|
+
const _live = (row) =>
|
|
62
|
+
row == null || _wantsRetired || row['` + retiredField + `'] == null || !(` + isRetired + `);`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ownerFilterClause(ownerField, elevatedGroups) {
|
|
66
|
+
if (ownerField === undefined) {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
|
|
70
|
+
return `
|
|
71
|
+
// ── owner scoping (generated) ──
|
|
72
|
+
// Not read from ctx.args: a predicate deciding what the caller may see must
|
|
73
|
+
// arrive on a channel the caller cannot name.
|
|
74
|
+
const _oid = ctx.identity;
|
|
75
|
+
const _osub = _oid == null ? null : _oid.sub;
|
|
76
|
+
const _ogroups = (_oid != null && _oid.claims != null && _oid.claims['cognito:groups']) || [];
|
|
77
|
+
const _oelevated = [` + elevatedLiteral + `];
|
|
78
|
+
if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0))) {
|
|
79
|
+
if (expression) expression += ' AND ';
|
|
80
|
+
names['#owner'] = '` + ownerField + `';
|
|
81
|
+
values[':owner'] = _osub;
|
|
82
|
+
expression += '#owner = :owner';
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function retiredFilterClause(retiredField, retiredValues, elevatedGroups) {
|
|
88
|
+
if (retiredField === undefined) {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
let assignments;
|
|
92
|
+
if (retiredValues !== undefined) {
|
|
93
|
+
let assigns = retiredValues.map((state, i) => ` values[':retiredValue` + i.toString() + `'] = '` + state + `';`).join("\n");
|
|
94
|
+
let comparisons = retiredValues.map((param, i) => `#retired <> :retiredValue` + i.toString()).join(" AND ");
|
|
95
|
+
assignments = assigns + `
|
|
96
|
+
expression += '(attribute_not_exists(#retired) OR (` + comparisons + `))';`;
|
|
97
|
+
} else {
|
|
98
|
+
assignments = ` values[':retiredFalse'] = false;
|
|
99
|
+
expression += '(attribute_not_exists(#retired) OR #retired = :retiredFalse)';`;
|
|
100
|
+
}
|
|
101
|
+
return exemptPreamble(elevatedGroups) + `
|
|
102
|
+
// ── retirement narrowing (generated) ──
|
|
103
|
+
if (!(_exempt && ctx.args.includeRetired === true)) {
|
|
104
|
+
if (expression) expression += ' AND ';
|
|
105
|
+
names['#retired'] = '` + retiredField + `';
|
|
106
|
+
` + assignments + `
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ownerScopedResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) {
|
|
112
|
+
if (ownerField === undefined && retiredField === undefined) {
|
|
113
|
+
return resultResponseCode;
|
|
114
|
+
}
|
|
115
|
+
let ownerPart = ownerField !== undefined ? `
|
|
116
|
+
// ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = () => true;";
|
|
117
|
+
let retiredPart = retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField));
|
|
118
|
+
return `
|
|
119
|
+
export function response(ctx) {
|
|
120
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerPart + retiredPart + `
|
|
121
|
+
return _owns(ctx.result)` + (
|
|
122
|
+
Stdlib_Option.isSome(retiredField) ? " && _live(ctx.result)" : ""
|
|
123
|
+
) + ` ? ctx.result : null;
|
|
124
|
+
}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ownerScopedFirstResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) {
|
|
128
|
+
if (ownerField === undefined && retiredField === undefined) {
|
|
129
|
+
return firstResultResponseCode;
|
|
130
|
+
}
|
|
131
|
+
let ownerPart = ownerField !== undefined ? `
|
|
132
|
+
// ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = () => true;";
|
|
133
|
+
let retiredPart = retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField));
|
|
134
|
+
return `
|
|
135
|
+
export function response(ctx) {
|
|
136
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerPart + retiredPart + `
|
|
137
|
+
const _row = ctx.result.items[0] ?? null;
|
|
138
|
+
return _owns(_row)` + (
|
|
139
|
+
Stdlib_Option.isSome(retiredField) ? " && _live(_row)" : ""
|
|
140
|
+
) + ` ? _row : null;
|
|
141
|
+
}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
24
144
|
let pipelinePassThrough = importUtil + `
|
|
25
145
|
export function request(ctx) { return {}; }
|
|
26
146
|
` + resultResponseCode + `
|
|
@@ -64,15 +184,18 @@ export function response(ctx) {
|
|
|
64
184
|
`;
|
|
65
185
|
}
|
|
66
186
|
|
|
67
|
-
|
|
187
|
+
function getItemById(ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
|
|
188
|
+
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
189
|
+
return importUtil + `
|
|
68
190
|
export function request(ctx) {
|
|
69
191
|
return {
|
|
70
192
|
operation: 'GetItem',
|
|
71
193
|
key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
|
|
72
194
|
};
|
|
73
195
|
}
|
|
74
|
-
` +
|
|
196
|
+
` + ownerScopedResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) + `
|
|
75
197
|
`;
|
|
198
|
+
}
|
|
76
199
|
|
|
77
200
|
let queryById = importUtil + `
|
|
78
201
|
export function request(ctx) {
|
|
@@ -87,7 +210,30 @@ export function request(ctx) {
|
|
|
87
210
|
` + resultResponseCode + `
|
|
88
211
|
`;
|
|
89
212
|
|
|
90
|
-
function queryItemsWithSortConditions(sortField) {
|
|
213
|
+
function queryItemsWithSortConditions(sortField, ownerField, elevatedGroupsOpt) {
|
|
214
|
+
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
215
|
+
let ownerFilter;
|
|
216
|
+
if (ownerField !== undefined) {
|
|
217
|
+
let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
|
|
218
|
+
ownerFilter = `
|
|
219
|
+
// ── owner scoping (generated) ──
|
|
220
|
+
// Not read from ctx.args, for the reason the list resolver gives: a predicate
|
|
221
|
+
// deciding what the caller may see must arrive on a channel they cannot name.
|
|
222
|
+
const _id = ctx.identity;
|
|
223
|
+
const _sub = _id == null ? null : _id.sub;
|
|
224
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
225
|
+
const _elevated = [` + elevatedLiteral + `];
|
|
226
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
|
|
227
|
+
const _ownerFilter = _exempt ? undefined : {
|
|
228
|
+
expression: '#owner = :owner',
|
|
229
|
+
expressionNames: { '#owner': '` + ownerField + `' },
|
|
230
|
+
expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
|
|
231
|
+
};`;
|
|
232
|
+
} else {
|
|
233
|
+
ownerFilter = "";
|
|
234
|
+
}
|
|
235
|
+
let ownerFilterField = ownerField !== undefined ? `
|
|
236
|
+
filter: _ownerFilter,` : "";
|
|
91
237
|
return importUtil + `
|
|
92
238
|
const encodeCursor = (skValue) => util.base64Encode(skValue);
|
|
93
239
|
const decodeCursor = (cursor) => util.base64Decode(cursor);
|
|
@@ -130,10 +276,10 @@ export function request(ctx) {
|
|
|
130
276
|
const expression = skCondition ? \`#id = :id AND \${skCondition}\` : '#id = :id';
|
|
131
277
|
const orderDesc = filter.order === 'DESC';
|
|
132
278
|
const scanForward = isBackward ? orderDesc : !orderDesc;
|
|
133
|
-
const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50)
|
|
279
|
+
const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);` + ownerFilter + `
|
|
134
280
|
return {
|
|
135
281
|
operation: 'Query',
|
|
136
|
-
query: { expression, expressionNames, expressionValues }
|
|
282
|
+
query: { expression, expressionNames, expressionValues },` + ownerFilterField + `
|
|
137
283
|
scanIndexForward: scanForward,
|
|
138
284
|
limit: pageSize + 1,
|
|
139
285
|
};
|
|
@@ -164,7 +310,8 @@ export function response(ctx) {
|
|
|
164
310
|
`;
|
|
165
311
|
}
|
|
166
312
|
|
|
167
|
-
function queryByIdSort(sortField) {
|
|
313
|
+
function queryByIdSort(sortField, ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
|
|
314
|
+
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
168
315
|
return importUtil + `
|
|
169
316
|
export function request(ctx) {
|
|
170
317
|
return {
|
|
@@ -179,7 +326,7 @@ export function request(ctx) {
|
|
|
179
326
|
}
|
|
180
327
|
};
|
|
181
328
|
}
|
|
182
|
-
` +
|
|
329
|
+
` + ownerScopedFirstResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) + `
|
|
183
330
|
`;
|
|
184
331
|
}
|
|
185
332
|
|
|
@@ -248,10 +395,46 @@ export function request(ctx) {
|
|
|
248
395
|
`;
|
|
249
396
|
}
|
|
250
397
|
|
|
251
|
-
|
|
398
|
+
let indexConnectionResponseCode = `
|
|
399
|
+
export function response(ctx) {
|
|
400
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);
|
|
401
|
+
const items = ctx.result?.items ?? [];
|
|
402
|
+
const next = ctx.result?.nextToken ?? null;
|
|
403
|
+
const edges = items.map((item, i) => ({
|
|
404
|
+
node: item,
|
|
405
|
+
cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
|
|
406
|
+
}));
|
|
407
|
+
const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
|
|
408
|
+
return {
|
|
409
|
+
edges,
|
|
410
|
+
pageInfo: {
|
|
411
|
+
hasNextPage: !!next,
|
|
412
|
+
hasPreviousPage: !!ctx.args.after,
|
|
413
|
+
startCursor: edges.length > 0 ? edges[0].cursor : boundary,
|
|
414
|
+
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}`;
|
|
418
|
+
|
|
419
|
+
let indexBackwardPagingGuard = `
|
|
420
|
+
if (args.before != null || args.last != null) {
|
|
421
|
+
util.error('Backward pagination (last/before) is not supported on by-index connections; use first/after.', 'UnsupportedPagination');
|
|
422
|
+
}`;
|
|
423
|
+
|
|
424
|
+
let indexCursorPreamble = `
|
|
425
|
+
let after = null;
|
|
426
|
+
if (args.after != null && args.after !== '') {
|
|
427
|
+
const parsed = JSON.parse(util.base64Decode(args.after));
|
|
428
|
+
after = parsed.token ?? null;
|
|
429
|
+
}`;
|
|
430
|
+
|
|
431
|
+
let indexReservedArgs = `key === 'first' || key === 'after' || key === 'last' || key === 'before' || key === 'includeRetired' || key === 'limit' || key === 'nextToken' || key === 'forward'`;
|
|
432
|
+
|
|
433
|
+
function queryByIndexFiltered(index, idField, ownerField, retiredField, retiredValues, elevatedGroupsOpt) {
|
|
434
|
+
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
252
435
|
return importUtil + `
|
|
253
436
|
export function request(ctx) {
|
|
254
|
-
const args = ctx.args
|
|
437
|
+
const args = ctx.args;` + indexBackwardPagingGuard + `
|
|
255
438
|
const query = {
|
|
256
439
|
expression: '#` + idField + ` = :` + idField + `',
|
|
257
440
|
expressionNames: { '#` + idField + `': '` + idField + `' },
|
|
@@ -262,7 +445,7 @@ export function request(ctx) {
|
|
|
262
445
|
const values = {};
|
|
263
446
|
Object.keys(args).forEach(key => {
|
|
264
447
|
const value = args[key];
|
|
265
|
-
if (key === '` + idField + `' ||
|
|
448
|
+
if (key === '` + idField + `' || ` + indexReservedArgs + `) return;
|
|
266
449
|
if (value == null || value === '') return;
|
|
267
450
|
if (expression) expression += ' AND';
|
|
268
451
|
if (key === 'hideDeleted') {
|
|
@@ -277,12 +460,13 @@ export function request(ctx) {
|
|
|
277
460
|
values[':' + key] = '' + value;
|
|
278
461
|
}
|
|
279
462
|
});
|
|
463
|
+
` + ownerFilterClause(ownerField, elevatedGroups) + retiredFilterClause(retiredField, retiredValues, elevatedGroups) + indexCursorPreamble + `
|
|
280
464
|
const result = {
|
|
281
465
|
operation: 'Query',
|
|
282
466
|
query,
|
|
283
467
|
index: '` + index + `',
|
|
284
|
-
limit: (args.
|
|
285
|
-
nextToken:
|
|
468
|
+
limit: (args.first ?? 50),
|
|
469
|
+
nextToken: after,
|
|
286
470
|
scanIndexForward: (args.forward ?? true)
|
|
287
471
|
};
|
|
288
472
|
if (expression) {
|
|
@@ -290,14 +474,15 @@ export function request(ctx) {
|
|
|
290
474
|
}
|
|
291
475
|
return result;
|
|
292
476
|
}
|
|
293
|
-
` +
|
|
477
|
+
` + indexConnectionResponseCode + `
|
|
294
478
|
`;
|
|
295
479
|
}
|
|
296
480
|
|
|
297
|
-
function queryByIndexSortFiltered(index, idField, sortField) {
|
|
481
|
+
function queryByIndexSortFiltered(index, idField, ownerField, sortField, retiredField, retiredValues, elevatedGroupsOpt) {
|
|
482
|
+
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
298
483
|
return importUtil + `
|
|
299
484
|
export function request(ctx) {
|
|
300
|
-
const args = ctx.args
|
|
485
|
+
const args = ctx.args;` + indexBackwardPagingGuard + `
|
|
301
486
|
const query = args.` + sortField + `
|
|
302
487
|
? {
|
|
303
488
|
expression: '#` + idField + ` = :` + idField + ` AND #` + sortField + ` = :` + sortField + `',
|
|
@@ -317,7 +502,7 @@ export function request(ctx) {
|
|
|
317
502
|
const values = {};
|
|
318
503
|
Object.keys(args).forEach(key => {
|
|
319
504
|
const value = args[key];
|
|
320
|
-
if (key === '` + idField + `' || key === '` + sortField + `' ||
|
|
505
|
+
if (key === '` + idField + `' || key === '` + sortField + `' || ` + indexReservedArgs + `) return;
|
|
321
506
|
if (value == null || value === '') return;
|
|
322
507
|
if (expression) expression += ' AND';
|
|
323
508
|
if (key === 'hideDeleted') {
|
|
@@ -332,12 +517,13 @@ export function request(ctx) {
|
|
|
332
517
|
values[':' + key] = '' + value;
|
|
333
518
|
}
|
|
334
519
|
});
|
|
520
|
+
` + ownerFilterClause(ownerField, elevatedGroups) + retiredFilterClause(retiredField, retiredValues, elevatedGroups) + indexCursorPreamble + `
|
|
335
521
|
const result = {
|
|
336
522
|
operation: 'Query',
|
|
337
523
|
query,
|
|
338
524
|
index: '` + index + `',
|
|
339
|
-
limit: (args.
|
|
340
|
-
nextToken:
|
|
525
|
+
limit: (args.first ?? 50),
|
|
526
|
+
nextToken: after,
|
|
341
527
|
scanIndexForward: (args.forward ?? true)
|
|
342
528
|
};
|
|
343
529
|
if (expression) {
|
|
@@ -345,7 +531,7 @@ export function request(ctx) {
|
|
|
345
531
|
}
|
|
346
532
|
return result;
|
|
347
533
|
}
|
|
348
|
-
` +
|
|
534
|
+
` + indexConnectionResponseCode + `
|
|
349
535
|
`;
|
|
350
536
|
}
|
|
351
537
|
|
|
@@ -715,8 +901,10 @@ export function response(ctx) {
|
|
|
715
901
|
`;
|
|
716
902
|
}
|
|
717
903
|
|
|
718
|
-
function batchGetItemsByIds(
|
|
719
|
-
return
|
|
904
|
+
function batchGetItemsByIds(ownerField, retiredField, retiredValues, $staropt$star) {
|
|
905
|
+
return tableName => {
|
|
906
|
+
let elevatedGroups = $staropt$star !== undefined ? $staropt$star : [];
|
|
907
|
+
return importUtil + `
|
|
720
908
|
import { runtime } from '@aws-appsync/utils';
|
|
721
909
|
export function request(ctx) {
|
|
722
910
|
const ids = ctx.args.ids ?? [];
|
|
@@ -739,9 +927,73 @@ export function response(ctx) {
|
|
|
739
927
|
// missing id makes the entire field fail with "Cannot return null for
|
|
740
928
|
// non-nullable type" and the caller sees data=null. Filter the nulls so
|
|
741
929
|
// the field returns just the items that were found.
|
|
742
|
-
|
|
930
|
+
// The owner and retirement guards the list pushes into a FilterExpression,
|
|
931
|
+
// applied after the read because BatchGetItem has none to push into. A row the
|
|
932
|
+
// caller does not own is dropped rather than refused, for the reason the
|
|
933
|
+
// single-key door answers null: distinguishing "not yours" from "not there"
|
|
934
|
+
// would make this door an oracle for which ids exist.` + (
|
|
935
|
+
ownerField !== undefined ? ownerGuardPreamble(ownerField, elevatedGroups) : ""
|
|
936
|
+
) + retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField)) + `
|
|
937
|
+
return (ctx.result?.data?.['` + tableName + `'] ?? []).filter(item =>
|
|
938
|
+
item !== null` + (
|
|
939
|
+
Stdlib_Option.isSome(ownerField) ? " && _owns(item)" : ""
|
|
940
|
+
) + (
|
|
941
|
+
Stdlib_Option.isSome(retiredField) ? " && _live(item)" : ""
|
|
942
|
+
) + `);
|
|
943
|
+
}
|
|
944
|
+
`;
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function refsByIds(labelField, retiredField, retiredValues, namedWhenRetired, ownerField, $staropt$star) {
|
|
949
|
+
return tableName => {
|
|
950
|
+
let elevatedGroups = $staropt$star !== undefined ? $staropt$star : [];
|
|
951
|
+
let ownerGuard = ownerField !== undefined ? ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = () => true;";
|
|
952
|
+
let retiredExpr;
|
|
953
|
+
if (retiredField !== undefined) {
|
|
954
|
+
if (retiredValues !== undefined) {
|
|
955
|
+
let literal = retiredValues.map(v => `'` + v + `'`).join(", ");
|
|
956
|
+
retiredExpr = `[` + literal + `].indexOf(row['` + retiredField + `']) >= 0`;
|
|
957
|
+
} else {
|
|
958
|
+
retiredExpr = `row['` + retiredField + `'] === true`;
|
|
959
|
+
}
|
|
960
|
+
} else {
|
|
961
|
+
retiredExpr = "false";
|
|
962
|
+
}
|
|
963
|
+
let stateExpr = retiredField !== undefined && retiredValues !== undefined ? `_retired(row) ? (row['` + retiredField + `'] ?? null) : null` : "null";
|
|
964
|
+
return importUtil + `
|
|
965
|
+
import { runtime } from '@aws-appsync/utils';
|
|
966
|
+
export function request(ctx) {
|
|
967
|
+
const ids = ctx.args.ids ?? [];
|
|
968
|
+
if (ids.length === 0) return runtime.earlyReturn([]);
|
|
969
|
+
return {
|
|
970
|
+
operation: 'BatchGetItem',
|
|
971
|
+
tables: {
|
|
972
|
+
'` + tableName + `': {
|
|
973
|
+
keys: ids.map(id => ({ id: util.dynamodb.toDynamoDB(id) })),
|
|
974
|
+
consistentRead: true,
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
export function response(ctx) {
|
|
980
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerGuard + `
|
|
981
|
+
const _retired = (row) => ` + retiredExpr + `;
|
|
982
|
+
const _namesRetired = ` + (
|
|
983
|
+
namedWhenRetired ? "true" : "false"
|
|
984
|
+
) + `;
|
|
985
|
+
return (ctx.result?.data?.['` + tableName + `'] ?? [])
|
|
986
|
+
.filter(row => row !== null && _owns(row))
|
|
987
|
+
.filter(row => _namesRetired || !_retired(row))
|
|
988
|
+
.map(row => ({
|
|
989
|
+
id: row.id,
|
|
990
|
+
label: row['` + labelField + `'] ?? row.id,
|
|
991
|
+
retired: _retired(row),
|
|
992
|
+
retiredState: ` + stateExpr + `,
|
|
993
|
+
}));
|
|
743
994
|
}
|
|
744
995
|
`;
|
|
996
|
+
};
|
|
745
997
|
}
|
|
746
998
|
|
|
747
999
|
let putItem = importUtil + `
|
|
@@ -953,6 +1205,13 @@ export {
|
|
|
953
1205
|
firstResultResponseCode,
|
|
954
1206
|
resultListResponseCode,
|
|
955
1207
|
importUtil,
|
|
1208
|
+
ownerGuardPreamble,
|
|
1209
|
+
exemptPreamble,
|
|
1210
|
+
retiredGuardPreamble,
|
|
1211
|
+
ownerFilterClause,
|
|
1212
|
+
retiredFilterClause,
|
|
1213
|
+
ownerScopedResultResponse,
|
|
1214
|
+
ownerScopedFirstResultResponse,
|
|
956
1215
|
pipelinePassThrough,
|
|
957
1216
|
nodeDecodeGlobalId,
|
|
958
1217
|
nodeGetItemForType,
|
|
@@ -963,6 +1222,10 @@ export {
|
|
|
963
1222
|
queryByIndex,
|
|
964
1223
|
queryByIndexDeletable,
|
|
965
1224
|
queryByIndexSort,
|
|
1225
|
+
indexConnectionResponseCode,
|
|
1226
|
+
indexBackwardPagingGuard,
|
|
1227
|
+
indexCursorPreamble,
|
|
1228
|
+
indexReservedArgs,
|
|
966
1229
|
queryByIndexFiltered,
|
|
967
1230
|
queryByIndexSortFiltered,
|
|
968
1231
|
listAllItems,
|
|
@@ -975,6 +1238,7 @@ export {
|
|
|
975
1238
|
resolveIdByIndexSortArgument,
|
|
976
1239
|
resolveIds,
|
|
977
1240
|
batchGetItemsByIds,
|
|
1241
|
+
refsByIds,
|
|
978
1242
|
putItem,
|
|
979
1243
|
addItemToList,
|
|
980
1244
|
deleteItem,
|
|
@@ -16,14 +16,14 @@ describe('Resolver code structure', () => {
|
|
|
16
16
|
// Labeled ReScript args compile to positional JS args in declaration order.
|
|
17
17
|
const codeValues = [
|
|
18
18
|
['pipelinePassThrough', F.pipelinePassThrough],
|
|
19
|
-
['getItemById', F.getItemById],
|
|
19
|
+
['getItemById', F.getItemById(undefined, undefined)],
|
|
20
20
|
['queryById', F.queryById],
|
|
21
21
|
['queryByIdSort(sortField)', F.queryByIdSort('status')],
|
|
22
22
|
['queryByIndex(index)', F.queryByIndex('userId')],
|
|
23
23
|
['queryByIndexDeletable(index)', F.queryByIndexDeletable('userId')],
|
|
24
24
|
['queryByIndexSort(index,idField,sortField)', F.queryByIndexSort('userId', 'userId', 'createdAt')],
|
|
25
25
|
['queryByIndexFiltered(index,idField)', F.queryByIndexFiltered('userId', 'userId')],
|
|
26
|
-
['queryByIndexSortFiltered(index,idField,sortField)', F.queryByIndexSortFiltered('userId', 'userId', 'status')],
|
|
26
|
+
['queryByIndexSortFiltered(index,idField,sortField)', F.queryByIndexSortFiltered('userId', 'userId', undefined, 'status')],
|
|
27
27
|
['listAllItems', F.listAllItems],
|
|
28
28
|
['resolveId(sourceIdField)', F.resolveId('productId')],
|
|
29
29
|
['resolveIdSort(sourceIdField,sourceSortField,targetSortField)', F.resolveIdSort('productId', 'status', 'status')],
|
|
@@ -54,7 +54,7 @@ describe('Resolver code structure', () => {
|
|
|
54
54
|
// getItemById
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
describe('getItemById', () => {
|
|
57
|
-
const { request, response } = evalResolver(F.getItemById)
|
|
57
|
+
const { request, response } = evalResolver(F.getItemById(undefined, undefined))
|
|
58
58
|
|
|
59
59
|
test('request returns GetItem with id key', () => {
|
|
60
60
|
const ctx = makeCtx({ args: { id: 'abc123' } })
|
|
@@ -437,7 +437,12 @@ describe('listAllItemsConnection', () => {
|
|
|
437
437
|
// queryByIndexSortFiltered — complex template, most important to test
|
|
438
438
|
// ---------------------------------------------------------------------------
|
|
439
439
|
describe('queryByIndexSortFiltered', () => {
|
|
440
|
-
|
|
440
|
+
// index, idField, ownerField, sortField — `ownerField` is optional but sits
|
|
441
|
+
// ahead of `sortField`, so it has to be passed explicitly. Skipping it slid
|
|
442
|
+
// the sort key into the owner slot and this file scoped the read by `status`
|
|
443
|
+
// while asserting it did not, which is the failure mode a positional call
|
|
444
|
+
// against a labelled-argument function always has.
|
|
445
|
+
const code = F.queryByIndexSortFiltered('ownerId', 'ownerId', undefined, 'status')
|
|
441
446
|
const { request, response } = evalResolver(code)
|
|
442
447
|
|
|
443
448
|
test('query by idField only when sortField is absent', () => {
|
|
@@ -483,9 +488,19 @@ describe('queryByIndexSortFiltered', () => {
|
|
|
483
488
|
expect(request(ctx).scanIndexForward).toBe(true)
|
|
484
489
|
})
|
|
485
490
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
491
|
+
// The field this template is attached to has always been declared as returning
|
|
492
|
+
// a Connection; handing back DynamoDB's `{items, nextToken}` satisfied no part
|
|
493
|
+
// of that, so the door errored for every caller that selected `edges`.
|
|
494
|
+
test('response returns a Relay connection', () => {
|
|
495
|
+
const ctx = makeCtx({ result: { items: [{ id: 'x' }], nextToken: null } })
|
|
496
|
+
const out = response(ctx)
|
|
497
|
+
expect(out.edges.map(e => e.node)).toEqual([{ id: 'x' }])
|
|
498
|
+
expect(out.pageInfo.hasNextPage).toBe(false)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('reports a further page when DynamoDB hands back a token', () => {
|
|
502
|
+
const ctx = makeCtx({ result: { items: [{ id: 'x' }], nextToken: 'TOK' } })
|
|
503
|
+
expect(response(ctx).pageInfo.hasNextPage).toBe(true)
|
|
489
504
|
})
|
|
490
505
|
})
|
|
491
506
|
|
|
@@ -743,3 +758,80 @@ describe('listAllItemsConnection — owner scoping', () => {
|
|
|
743
758
|
expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
|
|
744
759
|
})
|
|
745
760
|
})
|
|
761
|
+
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// By-key reads — owner scoping
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
// The list resolver above carried the predicate while these did not, so a caller
|
|
766
|
+
// narrowed to their own rows could still read any row they could name. These
|
|
767
|
+
// cases pin the guard in the place it has to live: the response, since a GetItem
|
|
768
|
+
// has no FilterExpression, and null rather than an error, which is what the
|
|
769
|
+
// in-process platform answers and what does not confirm the row exists.
|
|
770
|
+
describe('getItemById / queryByIdSort — owner scoping', () => {
|
|
771
|
+
const asUser = (sub, groups = []) => ({
|
|
772
|
+
username: sub,
|
|
773
|
+
sub,
|
|
774
|
+
sourceIp: [],
|
|
775
|
+
claims: { 'cognito:groups': groups },
|
|
776
|
+
})
|
|
777
|
+
const row = { id: 'ord-1', customerId: 'cust-a', total: 10 }
|
|
778
|
+
|
|
779
|
+
const getScoped = () => evalResolver(F.getItemById('customerId', ['Admin']))
|
|
780
|
+
const sortScoped = () => evalResolver(F.queryByIdSort('status', 'customerId', ['Admin']))
|
|
781
|
+
|
|
782
|
+
test('an owner reads their own row', () => {
|
|
783
|
+
const { response } = getScoped()
|
|
784
|
+
expect(response(makeCtx({ result: row, identity: asUser('cust-a') }))).toEqual(row)
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
test('a foreign row reads as null, not as an error', () => {
|
|
788
|
+
const { response } = getScoped()
|
|
789
|
+
expect(response(makeCtx({ result: row, identity: asUser('cust-b') }))).toBeNull()
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
test('an elevated caller reads any row', () => {
|
|
793
|
+
const { response } = getScoped()
|
|
794
|
+
expect(response(makeCtx({ result: row, identity: asUser('ops-1', ['Admin']) }))).toEqual(row)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
test('an IAM-shaped identity with no sub is exempt, not compared against undefined', () => {
|
|
798
|
+
const { response } = getScoped()
|
|
799
|
+
const iam = { username: 'svc', userArn: 'arn:aws:iam::1:role/r', sourceIp: [] }
|
|
800
|
+
expect(response(makeCtx({ result: row, identity: iam }))).toEqual(row)
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
test('a wholly absent identity is exempt rather than a crash', () => {
|
|
804
|
+
const { response } = getScoped()
|
|
805
|
+
expect(response(makeCtx({ result: row, identity: null }))).toEqual(row)
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
test('a missing row stays null and does not fall into the ownership branch', () => {
|
|
809
|
+
const { response } = getScoped()
|
|
810
|
+
expect(response(makeCtx({ result: null, identity: asUser('cust-b') }))).toBeNull()
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
test('a view with no owner field is never scoped', () => {
|
|
814
|
+
const { response } = evalResolver(F.getItemById(undefined, []))
|
|
815
|
+
expect(response(makeCtx({ result: row, identity: asUser('cust-b') }))).toEqual(row)
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
test('queryByIdSort narrows its first row the same way', () => {
|
|
819
|
+
const { response } = sortScoped()
|
|
820
|
+
const ctxFor = sub => makeCtx({ result: { items: [row] }, identity: asUser(sub) })
|
|
821
|
+
expect(response(ctxFor('cust-a'))).toEqual(row)
|
|
822
|
+
expect(response(ctxFor('cust-b'))).toBeNull()
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
test('queryItemsWithSortConditions scopes in the REQUEST, so the page is cut after narrowing', () => {
|
|
826
|
+
const { request } = evalResolver(
|
|
827
|
+
F.queryItemsWithSortConditions('createdAt', 'customerId', ['Admin']),
|
|
828
|
+
)
|
|
829
|
+
const scoped = request(makeCtx({ args: { id: 'ord-1' }, identity: asUser('cust-a') }))
|
|
830
|
+
expect(scoped.filter.expression).toBe('#owner = :owner')
|
|
831
|
+
expect(scoped.filter.expressionValues[':owner']).toEqual({ S: 'cust-a' })
|
|
832
|
+
const elevated = request(
|
|
833
|
+
makeCtx({ args: { id: 'ord-1' }, identity: asUser('ops-1', ['Admin']) }),
|
|
834
|
+
)
|
|
835
|
+
expect(elevated.filter).toBeUndefined()
|
|
836
|
+
})
|
|
837
|
+
})
|