@reventlessdev/rescript-pulumi-aws 2.4.0-alpha.76 → 2.4.0-alpha.78

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,22 @@
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.78 (2026-08-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** scope by-key reads to the owner, not only lists ([8232fd4](https://github.com/ReventlessDev/reventless-core/commit/8232fd4c09c1098c7265e4a17882ee44884f3bec))
11
+
12
+
13
+ # 2.4.0-alpha.77 (2026-08-16)
14
+
15
+ ### Features
16
+
17
+ * **core:** exclude retired rows from reads a caller may not widen ([662f31a](https://github.com/ReventlessDev/reventless-core/commit/662f31abb717bda5154199d349da6dcf8e2d3e78))
18
+ * **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
19
+ * **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
20
+
21
+
6
22
  # 2.4.0-alpha.76 (2026-08-15)
7
23
 
8
24
  **Note:** Version bump only for package @reventlessdev/rescript-pulumi-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/rescript-pulumi-aws",
3
- "version": "2.4.0-alpha.76",
3
+ "version": "2.4.0-alpha.78",
4
4
  "description": "ReScript bindings for @pulumi/aws",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,6 +29,69 @@ 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
+ A by-key read's response, refusing a row the caller does not own.
60
+
61
+ **Null, not an error** — which is the opposite of what a first reading suggests,
62
+ since "you may not read this" and "there is nothing here" are different answers
63
+ and only one of them is true. Two things settle it. The in-process platform
64
+ already answers `null` here, and a rule enforced differently per transport is
65
+ the failure mode owner scoping exists to avoid. And an error would confirm the
66
+ row exists to a caller who may not read it, which is a worse leak than the
67
+ ambiguity it removes.
68
+ */
69
+ let ownerScopedResultResponse = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
70
+ switch ownerField {
71
+ | None => resultResponseCode
72
+ | Some(field) =>
73
+ `
74
+ export function response(ctx) {
75
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
76
+ // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}
77
+ return _owns(ctx.result) ? ctx.result : null;
78
+ }`
79
+ }
80
+
81
+ /** The `queryByIdSort` counterpart — same rule, over the first row of a Query. */
82
+ let ownerScopedFirstResultResponse = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
83
+ switch ownerField {
84
+ | None => firstResultResponseCode
85
+ | Some(field) =>
86
+ `
87
+ export function response(ctx) {
88
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
89
+ // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}
90
+ const _row = ctx.result.items[0] ?? null;
91
+ return _owns(_row) ? _row : null;
92
+ }`
93
+ }
94
+
32
95
  // ---------------------------------------------------------------------------
33
96
  // Pipeline resolver pass-through (no before/after processing)
34
97
  // ---------------------------------------------------------------------------
@@ -89,7 +152,12 @@ export function response(ctx) {
89
152
  // DynamoDB read — by primary key
90
153
  // ---------------------------------------------------------------------------
91
154
 
92
- let getItemById =
155
+ // `~ownerField` / `~elevatedGroups` carry the same meaning as on
156
+ // `listAllItemsConnection`, and are optional for the same reason: a state that
157
+ // declares no owner emits exactly the source it emitted before scoping existed.
158
+ // A list that scopes beside a by-id read that does not is not a partial
159
+ // delivery — it is a hole, reachable by anyone who can name a row.
160
+ let getItemById = (~ownerField: option<string>=?, ~elevatedGroups: array<string>=[]) =>
93
161
  `${importUtil}
94
162
  export function request(ctx) {
95
163
  return {
@@ -97,7 +165,7 @@ export function request(ctx) {
97
165
  key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
98
166
  };
99
167
  }
100
- ${resultResponseCode}
168
+ ${ownerScopedResultResponse(~ownerField, ~elevatedGroups)}
101
169
  `->Pulumi.Input.make
102
170
 
103
171
  let queryById =
@@ -119,7 +187,39 @@ ${resultResponseCode}
119
187
  Relay pagination: `first`/`after` (forward) or `last`/`before` (backward).
120
188
  Cursor is base64 of the sort key value.
121
189
  Returns a Relay `{ edges, pageInfo }` shape reusing the entity's `Connection` type. */
122
- let queryItemsWithSortConditions = (sortField: string) =>
190
+ // A list in everything but its name, so it scopes the way `listAllItemsConnection`
191
+ // does — a FilterExpression on the request, not a guard on the response. The
192
+ // response is where the page is cut, and narrowing after that cut would report
193
+ // `hasNextPage` from a count the caller was never allowed to see.
194
+ let queryItemsWithSortConditions = (
195
+ sortField: string,
196
+ ~ownerField: option<string>=?,
197
+ ~elevatedGroups: array<string>=[],
198
+ ) => {
199
+ let ownerFilter = switch ownerField {
200
+ | None => ""
201
+ | Some(field) =>
202
+ let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
203
+ `
204
+ // ── owner scoping (generated) ──
205
+ // Not read from ctx.args, for the reason the list resolver gives: a predicate
206
+ // deciding what the caller may see must arrive on a channel they cannot name.
207
+ const _id = ctx.identity;
208
+ const _sub = _id == null ? null : _id.sub;
209
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
210
+ const _elevated = [${elevatedLiteral}];
211
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
212
+ const _ownerFilter = _exempt ? undefined : {
213
+ expression: '#owner = :owner',
214
+ expressionNames: { '#owner': '${field}' },
215
+ expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
216
+ };`
217
+ }
218
+ let ownerFilterField = switch ownerField {
219
+ | None => ""
220
+ | Some(_) => `
221
+ filter: _ownerFilter,`
222
+ }
123
223
  `${importUtil}
124
224
  const encodeCursor = (skValue) => util.base64Encode(skValue);
125
225
  const decodeCursor = (cursor) => util.base64Decode(cursor);
@@ -162,10 +262,10 @@ export function request(ctx) {
162
262
  const expression = skCondition ? \`#id = :id AND \${skCondition}\` : '#id = :id';
163
263
  const orderDesc = filter.order === 'DESC';
164
264
  const scanForward = isBackward ? orderDesc : !orderDesc;
165
- const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);
265
+ const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);${ownerFilter}
166
266
  return {
167
267
  operation: 'Query',
168
- query: { expression, expressionNames, expressionValues },
268
+ query: { expression, expressionNames, expressionValues },${ownerFilterField}
169
269
  scanIndexForward: scanForward,
170
270
  limit: pageSize + 1,
171
271
  };
@@ -194,8 +294,13 @@ export function response(ctx) {
194
294
  };
195
295
  }
196
296
  `->Pulumi.Input.make
297
+ }
197
298
 
198
- let queryByIdSort = (sortField: string) =>
299
+ let queryByIdSort = (
300
+ sortField: string,
301
+ ~ownerField: option<string>=?,
302
+ ~elevatedGroups: array<string>=[],
303
+ ) =>
199
304
  `${importUtil}
200
305
  export function request(ctx) {
201
306
  return {
@@ -210,7 +315,7 @@ export function request(ctx) {
210
315
  }
211
316
  };
212
317
  }
213
- ${firstResultResponseCode}
318
+ ${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups)}
214
319
  `->Pulumi.Input.make
215
320
 
216
321
  // ---------------------------------------------------------------------------
@@ -447,6 +552,13 @@ let listAllItemsConnection = (
447
552
  // than a per-request one.
448
553
  ~ownerField: option<string>=?,
449
554
  ~elevatedGroups: array<string>=[],
555
+ // The state's `@retired` field, when it declares one. Baked in for the same
556
+ // reason `ownerField` is: there is no Lambda in this path to read it at
557
+ // request time.
558
+ ~retiredField: option<string>=?,
559
+ // The states that retire the row, for the state form of the annotation.
560
+ // Absent is the boolean form, where the value is always `true`.
561
+ ~retiredValues: option<array<string>>=?,
450
562
  ) => {
451
563
  let requireAttributeClause = switch requireAttribute {
452
564
  | Some(attr) => `
@@ -480,6 +592,70 @@ let listAllItemsConnection = (
480
592
  parts.push('#owner = :owner');
481
593
  }`
482
594
  }
595
+ // ── retirement narrowing (generated) ──
596
+ // Reuses `_exempt` when the owner clause already computed it, and computes its
597
+ // own when it did not — the two clauses are independently optional and either
598
+ // may be the only one present.
599
+ //
600
+ // `includeRetired` IS read from ctx.args, unlike the owner predicate, and the
601
+ // difference is deliberate: this argument does not say which rows the caller
602
+ // wants, it asks to lift a restriction, and it is honoured only inside the
603
+ // `_exempt` branch. A non-exempt caller passing it changes nothing.
604
+ //
605
+ // `attribute_not_exists OR = false` rather than `<> true`: a row written before
606
+ // the annotation existed carries no such attribute, and DynamoDB's `<>` does
607
+ // not match a missing one — the whole view would come back empty on the day
608
+ // the annotation lands.
609
+ //
610
+ // The state form compares `<>` against the retiring state instead, under the
611
+ // same `attribute_not_exists` guard and for the same reason. An equality
612
+ // predicate over an enum-valued attribute indexes exactly as a boolean one
613
+ // does, so the warning about an unindexed retirement field carries over
614
+ // unchanged.
615
+ let retiredClause = switch retiredField {
616
+ | None => ""
617
+ | Some(field) =>
618
+ let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
619
+ let exemptPrelude = switch ownerField {
620
+ | Some(_) => ""
621
+ | None => `
622
+ const _id = ctx.identity;
623
+ const _sub = _id == null ? null : _id.sub;
624
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
625
+ const _elevated = [${elevatedLiteral}];
626
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`
627
+ }
628
+ `${exemptPrelude}
629
+ // ── retirement narrowing (generated) ──
630
+ const _wantsRetired = _exempt && ctx.args.includeRetired === true;
631
+ if (!_wantsRetired) {
632
+ names['#retired'] = '${field}';
633
+ ${switch retiredValues {
634
+ | None => ` values[':retiredFalse'] = util.dynamodb.toDynamoDB(false);
635
+ parts.push('(attribute_not_exists(#retired) OR #retired = :retiredFalse)');`
636
+ // One `<>` per state, ANDed, rather than `NOT IN`: DynamoDB's `IN` needs a
637
+ // parenthesised operand list built from the same placeholders anyway, and
638
+ // the conjunction keeps `attribute_not_exists` covering the absent case
639
+ // once for the whole clause — a row that states no lifecycle is not
640
+ // retired, the same reading every other adapter takes.
641
+ | Some(states) =>
642
+ let placeholders = states->Array.mapWithIndex((state, i) => (`:retiredValue${Int.toString(i)}`, state))
643
+ let assignments =
644
+ placeholders
645
+ ->Array.map(((ph, state)) => ` values['${ph}'] = util.dynamodb.toDynamoDB('${state}');`)
646
+ ->Array.join("\n")
647
+ let comparisons =
648
+ placeholders->Array.map(((ph, _)) => `#retired <> ${ph}`)->Array.join(" AND ")
649
+ // A set naming nothing withdraws nothing, so it pushes no predicate down.
650
+ switch placeholders {
651
+ | [] => ""
652
+ | _ =>
653
+ `${assignments}
654
+ parts.push('(attribute_not_exists(#retired) OR (${comparisons}))');`
655
+ }
656
+ }}
657
+ }`
658
+ }
483
659
  let filterClauses =
484
660
  filterFields
485
661
  ->Array.map(f => `
@@ -569,7 +745,7 @@ export function request(ctx) {
569
745
  return key;
570
746
  });
571
747
  parts.push('#id IN (' + placeholders.join(', ') + ')');
572
- }${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}
748
+ }${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}${retiredClause}
573
749
  // The cursor is base64(JSON({ token, index })); decode the after arg back to the raw
574
750
  // DynamoDB continuation token the response side emitted (Fix 1 round-trip).
575
751
  let after = null;
@@ -21,6 +21,44 @@ export function response(ctx) {
21
21
 
22
22
  let importUtil = `import { util } from '@aws-appsync/utils';`;
23
23
 
24
+ function ownerGuardPreamble(ownerField, elevatedGroups) {
25
+ let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
26
+ return `
27
+ const _id = ctx.identity;
28
+ const _sub = _id == null ? null : _id.sub;
29
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
30
+ const _elevated = [` + elevatedLiteral + `];
31
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
32
+ const _owns = (row) => row == null || _exempt || row['` + ownerField + `'] === _sub;`;
33
+ }
34
+
35
+ function ownerScopedResultResponse(ownerField, elevatedGroups) {
36
+ if (ownerField !== undefined) {
37
+ return `
38
+ export function response(ctx) {
39
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
40
+ // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) + `
41
+ return _owns(ctx.result) ? ctx.result : null;
42
+ }`;
43
+ } else {
44
+ return resultResponseCode;
45
+ }
46
+ }
47
+
48
+ function ownerScopedFirstResultResponse(ownerField, elevatedGroups) {
49
+ if (ownerField !== undefined) {
50
+ return `
51
+ export function response(ctx) {
52
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
53
+ // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) + `
54
+ const _row = ctx.result.items[0] ?? null;
55
+ return _owns(_row) ? _row : null;
56
+ }`;
57
+ } else {
58
+ return firstResultResponseCode;
59
+ }
60
+ }
61
+
24
62
  let pipelinePassThrough = importUtil + `
25
63
  export function request(ctx) { return {}; }
26
64
  ` + resultResponseCode + `
@@ -64,15 +102,18 @@ export function response(ctx) {
64
102
  `;
65
103
  }
66
104
 
67
- let getItemById = importUtil + `
105
+ function getItemById(ownerField, elevatedGroupsOpt) {
106
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
107
+ return importUtil + `
68
108
  export function request(ctx) {
69
109
  return {
70
110
  operation: 'GetItem',
71
111
  key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
72
112
  };
73
113
  }
74
- ` + resultResponseCode + `
114
+ ` + ownerScopedResultResponse(ownerField, elevatedGroups) + `
75
115
  `;
116
+ }
76
117
 
77
118
  let queryById = importUtil + `
78
119
  export function request(ctx) {
@@ -87,7 +128,30 @@ export function request(ctx) {
87
128
  ` + resultResponseCode + `
88
129
  `;
89
130
 
90
- function queryItemsWithSortConditions(sortField) {
131
+ function queryItemsWithSortConditions(sortField, ownerField, elevatedGroupsOpt) {
132
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
133
+ let ownerFilter;
134
+ if (ownerField !== undefined) {
135
+ let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
136
+ ownerFilter = `
137
+ // ── owner scoping (generated) ──
138
+ // Not read from ctx.args, for the reason the list resolver gives: a predicate
139
+ // deciding what the caller may see must arrive on a channel they cannot name.
140
+ const _id = ctx.identity;
141
+ const _sub = _id == null ? null : _id.sub;
142
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
143
+ const _elevated = [` + elevatedLiteral + `];
144
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
145
+ const _ownerFilter = _exempt ? undefined : {
146
+ expression: '#owner = :owner',
147
+ expressionNames: { '#owner': '` + ownerField + `' },
148
+ expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
149
+ };`;
150
+ } else {
151
+ ownerFilter = "";
152
+ }
153
+ let ownerFilterField = ownerField !== undefined ? `
154
+ filter: _ownerFilter,` : "";
91
155
  return importUtil + `
92
156
  const encodeCursor = (skValue) => util.base64Encode(skValue);
93
157
  const decodeCursor = (cursor) => util.base64Decode(cursor);
@@ -130,10 +194,10 @@ export function request(ctx) {
130
194
  const expression = skCondition ? \`#id = :id AND \${skCondition}\` : '#id = :id';
131
195
  const orderDesc = filter.order === 'DESC';
132
196
  const scanForward = isBackward ? orderDesc : !orderDesc;
133
- const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);
197
+ const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);` + ownerFilter + `
134
198
  return {
135
199
  operation: 'Query',
136
- query: { expression, expressionNames, expressionValues },
200
+ query: { expression, expressionNames, expressionValues },` + ownerFilterField + `
137
201
  scanIndexForward: scanForward,
138
202
  limit: pageSize + 1,
139
203
  };
@@ -164,7 +228,8 @@ export function response(ctx) {
164
228
  `;
165
229
  }
166
230
 
167
- function queryByIdSort(sortField) {
231
+ function queryByIdSort(sortField, ownerField, elevatedGroupsOpt) {
232
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
168
233
  return importUtil + `
169
234
  export function request(ctx) {
170
235
  return {
@@ -179,7 +244,7 @@ export function request(ctx) {
179
244
  }
180
245
  };
181
246
  }
182
- ` + firstResultResponseCode + `
247
+ ` + ownerScopedFirstResultResponse(ownerField, elevatedGroups) + `
183
248
  `;
184
249
  }
185
250
 
@@ -360,7 +425,7 @@ export function request(ctx) {
360
425
  ` + resultResponseCode + `
361
426
  `;
362
427
 
363
- function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sortFieldsOpt, requireAttribute, ownerField, elevatedGroupsOpt) {
428
+ function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sortFieldsOpt, requireAttribute, ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
364
429
  let filterFields = filterFieldsOpt !== undefined ? filterFieldsOpt : [];
365
430
  let rangeFields = rangeFieldsOpt !== undefined ? rangeFieldsOpt : [];
366
431
  let sortFields = sortFieldsOpt !== undefined ? sortFieldsOpt : [];
@@ -391,6 +456,39 @@ function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sor
391
456
  } else {
392
457
  ownerClause = "";
393
458
  }
459
+ let retiredClause;
460
+ if (retiredField !== undefined) {
461
+ let elevatedLiteral$1 = elevatedGroups.map(g => `'` + g + `'`).join(", ");
462
+ let exemptPrelude = ownerField !== undefined ? "" : `
463
+ const _id = ctx.identity;
464
+ const _sub = _id == null ? null : _id.sub;
465
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
466
+ const _elevated = [` + elevatedLiteral$1 + `];
467
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`;
468
+ let tmp;
469
+ if (retiredValues !== undefined) {
470
+ let placeholders = retiredValues.map((state, i) => [
471
+ `:retiredValue` + i.toString(),
472
+ state
473
+ ]);
474
+ let assignments = placeholders.map(param => ` values['` + param[0] + `'] = util.dynamodb.toDynamoDB('` + param[1] + `');`).join("\n");
475
+ let comparisons = placeholders.map(param => `#retired <> ` + param[0]).join(" AND ");
476
+ tmp = placeholders.length !== 0 ? assignments + `
477
+ parts.push('(attribute_not_exists(#retired) OR (` + comparisons + `))');` : "";
478
+ } else {
479
+ tmp = ` values[':retiredFalse'] = util.dynamodb.toDynamoDB(false);
480
+ parts.push('(attribute_not_exists(#retired) OR #retired = :retiredFalse)');`;
481
+ }
482
+ retiredClause = exemptPrelude + `
483
+ // ── retirement narrowing (generated) ──
484
+ const _wantsRetired = _exempt && ctx.args.includeRetired === true;
485
+ if (!_wantsRetired) {
486
+ names['#retired'] = '` + retiredField + `';
487
+ ` + tmp + `
488
+ }`;
489
+ } else {
490
+ retiredClause = "";
491
+ }
394
492
  let filterClauses = filterFields.map(f => `
395
493
  if (filter.` + f + `Eq !== undefined && filter.` + f + `Eq !== null && filter.` + f + `Eq !== '') {
396
494
  names['#` + f + `'] = '` + f + `';
@@ -460,7 +558,7 @@ export function request(ctx) {
460
558
  return key;
461
559
  });
462
560
  parts.push('#id IN (' + placeholders.join(', ') + ')');
463
- }` + filterClauses + rangeClauses + requireAttributeClause + ownerClause + `
561
+ }` + filterClauses + rangeClauses + requireAttributeClause + ownerClause + retiredClause + `
464
562
  // The cursor is base64(JSON({ token, index })); decode the after arg back to the raw
465
563
  // DynamoDB continuation token the response side emitted (Fix 1 round-trip).
466
564
  let after = null;
@@ -920,6 +1018,9 @@ export {
920
1018
  firstResultResponseCode,
921
1019
  resultListResponseCode,
922
1020
  importUtil,
1021
+ ownerGuardPreamble,
1022
+ ownerScopedResultResponse,
1023
+ ownerScopedFirstResultResponse,
923
1024
  pipelinePassThrough,
924
1025
  nodeDecodeGlobalId,
925
1026
  nodeGetItemForType,
@@ -16,7 +16,7 @@ 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')],
@@ -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' } })
@@ -743,3 +743,80 @@ describe('listAllItemsConnection — owner scoping', () => {
743
743
  expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
744
744
  })
745
745
  })
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // By-key reads — owner scoping
749
+ // ---------------------------------------------------------------------------
750
+ // The list resolver above carried the predicate while these did not, so a caller
751
+ // narrowed to their own rows could still read any row they could name. These
752
+ // cases pin the guard in the place it has to live: the response, since a GetItem
753
+ // has no FilterExpression, and null rather than an error, which is what the
754
+ // in-process platform answers and what does not confirm the row exists.
755
+ describe('getItemById / queryByIdSort — owner scoping', () => {
756
+ const asUser = (sub, groups = []) => ({
757
+ username: sub,
758
+ sub,
759
+ sourceIp: [],
760
+ claims: { 'cognito:groups': groups },
761
+ })
762
+ const row = { id: 'ord-1', customerId: 'cust-a', total: 10 }
763
+
764
+ const getScoped = () => evalResolver(F.getItemById('customerId', ['Admin']))
765
+ const sortScoped = () => evalResolver(F.queryByIdSort('status', 'customerId', ['Admin']))
766
+
767
+ test('an owner reads their own row', () => {
768
+ const { response } = getScoped()
769
+ expect(response(makeCtx({ result: row, identity: asUser('cust-a') }))).toEqual(row)
770
+ })
771
+
772
+ test('a foreign row reads as null, not as an error', () => {
773
+ const { response } = getScoped()
774
+ expect(response(makeCtx({ result: row, identity: asUser('cust-b') }))).toBeNull()
775
+ })
776
+
777
+ test('an elevated caller reads any row', () => {
778
+ const { response } = getScoped()
779
+ expect(response(makeCtx({ result: row, identity: asUser('ops-1', ['Admin']) }))).toEqual(row)
780
+ })
781
+
782
+ test('an IAM-shaped identity with no sub is exempt, not compared against undefined', () => {
783
+ const { response } = getScoped()
784
+ const iam = { username: 'svc', userArn: 'arn:aws:iam::1:role/r', sourceIp: [] }
785
+ expect(response(makeCtx({ result: row, identity: iam }))).toEqual(row)
786
+ })
787
+
788
+ test('a wholly absent identity is exempt rather than a crash', () => {
789
+ const { response } = getScoped()
790
+ expect(response(makeCtx({ result: row, identity: null }))).toEqual(row)
791
+ })
792
+
793
+ test('a missing row stays null and does not fall into the ownership branch', () => {
794
+ const { response } = getScoped()
795
+ expect(response(makeCtx({ result: null, identity: asUser('cust-b') }))).toBeNull()
796
+ })
797
+
798
+ test('a view with no owner field is never scoped', () => {
799
+ const { response } = evalResolver(F.getItemById(undefined, []))
800
+ expect(response(makeCtx({ result: row, identity: asUser('cust-b') }))).toEqual(row)
801
+ })
802
+
803
+ test('queryByIdSort narrows its first row the same way', () => {
804
+ const { response } = sortScoped()
805
+ const ctxFor = sub => makeCtx({ result: { items: [row] }, identity: asUser(sub) })
806
+ expect(response(ctxFor('cust-a'))).toEqual(row)
807
+ expect(response(ctxFor('cust-b'))).toBeNull()
808
+ })
809
+
810
+ test('queryItemsWithSortConditions scopes in the REQUEST, so the page is cut after narrowing', () => {
811
+ const { request } = evalResolver(
812
+ F.queryItemsWithSortConditions('createdAt', 'customerId', ['Admin']),
813
+ )
814
+ const scoped = request(makeCtx({ args: { id: 'ord-1' }, identity: asUser('cust-a') }))
815
+ expect(scoped.filter.expression).toBe('#owner = :owner')
816
+ expect(scoped.filter.expressionValues[':owner']).toEqual({ S: 'cust-a' })
817
+ const elevated = request(
818
+ makeCtx({ args: { id: 'ord-1' }, identity: asUser('ops-1', ['Admin']) }),
819
+ )
820
+ expect(elevated.filter).toBeUndefined()
821
+ })
822
+ })