@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.321
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 +30 -0
- package/package.json +12 -8
- package/rescript.json +4 -0
- package/run-provision-identity.mjs +3 -0
- package/scripts/ProvisionIdentity.res +303 -0
- package/scripts/ProvisionIdentity.res.mjs +376 -0
- package/src/Platform.res +54 -30
- package/src/Platform.res.mjs +34 -32
- package/src/Platform_Stack.res +102 -19
- package/src/Platform_Stack.res.mjs +43 -7
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -18
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +14 -4
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +29 -14
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +11 -1
- package/src/util/Util_AwsError.res +56 -0
- package/src/util/Util_AwsError.res.mjs +59 -0
- package/src/util/Util_ShellConfig.res +28 -0
- package/src/util/Util_ShellConfig.res.mjs +22 -0
- package/tests/AppSync_RetirementNarrowingTest.res +10 -12
- package/tests/AppSync_RetirementNarrowingTest.res.mjs +4 -2
- package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
- package/tests/Auth_ActiveRoleStoreTest.res +65 -0
- package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
- package/tests/AwsErrorFixtures.mjs +16 -0
- package/tests/ProvisionIdentityTest.res +109 -0
- package/tests/ProvisionIdentityTest.res.mjs +132 -0
- package/tests/QueryDbOwnerIndexTableTest.res +53 -0
- package/tests/QueryDbOwnerIndexTableTest.res.mjs +50 -0
- package/tests/QueryDbResolvers_AppSyncTest.res +19 -26
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +15 -17
- package/tests/Util_AwsErrorTest.res +127 -0
- package/tests/Util_AwsErrorTest.res.mjs +76 -0
- package/tests/Util_ShellConfigTest.res +51 -0
- package/tests/Util_ShellConfigTest.res.mjs +42 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The active-role store's identity: what it is called, and how its rows are
|
|
2
|
+
// keyed. See [docs/plans/active-role-store-scoped-to-the-pool.md].
|
|
3
|
+
//
|
|
4
|
+
// 🚨 **One definition, four consumers, and that is the whole reason this file
|
|
5
|
+
// exists.** The deploy creates or looks the table up ([Auth_ActiveRoleStore]),
|
|
6
|
+
// the write door writes rows ([Auth_ActiveRoleStore_Ops]), the pre-token trigger
|
|
7
|
+
// reads them ([Auth_ActiveRoleTrigger_Ops]), and the provisioning script creates
|
|
8
|
+
// the table on a provider no stack owns (`scripts/provision-identity.mjs`). Any
|
|
9
|
+
// two of those disagreeing produces the same failure: a row written where nothing
|
|
10
|
+
// looks for it, so a role switch reports success and does nothing.
|
|
11
|
+
//
|
|
12
|
+
// Deliberately free of Pulumi and of the AWS SDK, so the script and both Lambda
|
|
13
|
+
// bundles can import it without dragging a deploy-time dependency into a runtime
|
|
14
|
+
// graph — the hazard that once broke command-handler cold starts.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
The store belonging to an identity provider this framework does not own.
|
|
18
|
+
|
|
19
|
+
Derived rather than configured. The objection to deriving was that two stacks
|
|
20
|
+
would both try to *create* the table and the second would fail or adopt a
|
|
21
|
+
resource it does not own — true while stacks create it, and no stack creates this
|
|
22
|
+
one. What derivation buys is worth more than the config key it removes: two
|
|
23
|
+
platforms on one provider **cannot** name different stores, so the defect stops
|
|
24
|
+
being something to detect and becomes something that cannot be expressed.
|
|
25
|
+
|
|
26
|
+
The provider id carries its own region (`eu-west-1_CQTwafSeX`), which is the
|
|
27
|
+
region the store must live in too — a pre-token-generation trigger has to sit in
|
|
28
|
+
its pool's region, so every stack that can attach one derives the same name in
|
|
29
|
+
the same place.
|
|
30
|
+
*/
|
|
31
|
+
let derivedStoreName = (~identityProviderId: string): string =>
|
|
32
|
+
`ReventlessActiveRoleStore-${identityProviderId}`
|
|
33
|
+
|
|
34
|
+
/** The caller's Cognito `sub`. Stable across a rename, unlike the username. */
|
|
35
|
+
let partitionKey = "id"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
The app client the token was minted for.
|
|
39
|
+
|
|
40
|
+
A sort key rather than nothing, because one identity provider can serve several
|
|
41
|
+
platforms: each platform stack declares its own app client, so keying on the pair
|
|
42
|
+
gives every platform its own active role over one shared set of rows. Keyed on
|
|
43
|
+
the subject alone, narrowing to a role in one platform would narrow the caller's
|
|
44
|
+
session in every other platform on that provider — defensible as "one identity,
|
|
45
|
+
one session", but not what an operator wants, since a role with surfaces in one
|
|
46
|
+
platform and none in another leaves the second showing nothing.
|
|
47
|
+
*/
|
|
48
|
+
let sortKey = "clientId"
|
|
49
|
+
|
|
50
|
+
/** The stored choice itself. */
|
|
51
|
+
let roleAttribute = "activeRole"
|
|
52
|
+
|
|
53
|
+
/** The key schema as DynamoDB describes one: `(attribute, keyType)` pairs.
|
|
54
|
+
|
|
55
|
+
Plain tuples rather than the SDK's `keySchemaElement` so this module keeps its
|
|
56
|
+
"no side effect" footer — importing the AWS SDK here would put it in both Lambda
|
|
57
|
+
bundles, which is the dependency leak that once broke command-handler cold
|
|
58
|
+
starts. Callers map their own shapes into this. */
|
|
59
|
+
let expectedKeySchema: array<(string, string)> = [(partitionKey, "HASH"), (sortKey, "RANGE")]
|
|
60
|
+
|
|
61
|
+
let describeKeySchema = (elements: array<(string, string)>): string =>
|
|
62
|
+
elements
|
|
63
|
+
->Array.map(((attribute, keyType)) => `${attribute}:${keyType}`)
|
|
64
|
+
->Array.toSorted(String.compare)
|
|
65
|
+
->Array.join(",")
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
Why a table that already exists cannot serve as the store, if it cannot.
|
|
69
|
+
|
|
70
|
+
🚨 **A table under the right name with the wrong key is worse than no table.** The
|
|
71
|
+
deploy finds it, the handlers write into it, and every read misses — a role switch
|
|
72
|
+
that reports success and does nothing, which is the defect this whole store was
|
|
73
|
+
repaired for. So adoption checks the schema and refuses, rather than reporting
|
|
74
|
+
success.
|
|
75
|
+
|
|
76
|
+
The pre-`clientId` store is exactly this case: keyed on the subject alone. An
|
|
77
|
+
operator upgrading meets a sentence instead of a silent misbehaviour.
|
|
78
|
+
|
|
79
|
+
Order-insensitive, because `DescribeTable` does not promise one.
|
|
80
|
+
*/
|
|
81
|
+
let keySchemaRefusal = (
|
|
82
|
+
~tableName: string,
|
|
83
|
+
~actual: array<(string, string)>,
|
|
84
|
+
): option<string> => {
|
|
85
|
+
let actualKey = describeKeySchema(actual)
|
|
86
|
+
let wantedKey = describeKeySchema(expectedKeySchema)
|
|
87
|
+
actualKey == wantedKey
|
|
88
|
+
? None
|
|
89
|
+
: Some(
|
|
90
|
+
`table "${tableName}" already exists with key schema [${actualKey}], but the active-role store needs [${wantedKey}]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
4
|
+
|
|
5
|
+
function derivedStoreName(identityProviderId) {
|
|
6
|
+
return `ReventlessActiveRoleStore-` + identityProviderId;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let partitionKey = "id";
|
|
10
|
+
|
|
11
|
+
let sortKey = "clientId";
|
|
12
|
+
|
|
13
|
+
let expectedKeySchema = [
|
|
14
|
+
[
|
|
15
|
+
partitionKey,
|
|
16
|
+
"HASH"
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
sortKey,
|
|
20
|
+
"RANGE"
|
|
21
|
+
]
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function describeKeySchema(elements) {
|
|
25
|
+
return elements.map(param => param[0] + `:` + param[1]).toSorted(Primitive_string.compare).join(",");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function keySchemaRefusal(tableName, actual) {
|
|
29
|
+
let actualKey = describeKeySchema(actual);
|
|
30
|
+
let wantedKey = describeKeySchema(expectedKeySchema);
|
|
31
|
+
if (actualKey === wantedKey) {
|
|
32
|
+
return;
|
|
33
|
+
} else {
|
|
34
|
+
return `table "` + tableName + `" already exists with key schema [` + actualKey + `], but the active-role store needs [` + wantedKey + `]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let roleAttribute = "activeRole";
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
derivedStoreName,
|
|
42
|
+
partitionKey,
|
|
43
|
+
sortKey,
|
|
44
|
+
roleAttribute,
|
|
45
|
+
expectedKeySchema,
|
|
46
|
+
describeKeySchema,
|
|
47
|
+
keySchemaRefusal,
|
|
48
|
+
}
|
|
49
|
+
/* No side effect */
|
|
@@ -60,6 +60,11 @@ type triggerRequest = {
|
|
|
60
60
|
groupConfiguration?: groupConfiguration,
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** The app client this token is being minted for — the second half of the store's
|
|
64
|
+
row key, and what makes the active role per-platform on a provider serving
|
|
65
|
+
several. Cognito supplies it on every pre-token-generation event. */
|
|
66
|
+
type callerContext = {clientId?: string}
|
|
67
|
+
|
|
63
68
|
type groupOverrideDetails = {
|
|
64
69
|
groupsToOverride: array<string>,
|
|
65
70
|
iamRolesToOverride: array<string>,
|
|
@@ -77,6 +82,7 @@ type event = {
|
|
|
77
82
|
request?: triggerRequest,
|
|
78
83
|
response?: triggerResponse,
|
|
79
84
|
userName?: string,
|
|
85
|
+
callerContext?: callerContext,
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
// ── The decision ────────────────────────────────────────────────────────────
|
|
@@ -171,15 +177,18 @@ sign-in outright, and failing a login because a *preference* could not be read
|
|
|
171
177
|
trades a working session for a cosmetic one. The caller lands on full membership —
|
|
172
178
|
their existing privileges, not more — which is the safe direction to fail.
|
|
173
179
|
*/
|
|
174
|
-
let storedRoleFor = async (~sub: string, ~table: string): option<string> =>
|
|
180
|
+
let storedRoleFor = async (~sub: string, ~clientId: string, ~table: string): option<string> =>
|
|
175
181
|
try {
|
|
176
182
|
let out = await DynamoDb_DocumentClient.GetCommand.make({
|
|
177
183
|
tableName: table,
|
|
178
|
-
key: Dict.fromArray([
|
|
184
|
+
key: Dict.fromArray([
|
|
185
|
+
(Auth_ActiveRoleStore_Schema.partitionKey, JSON.Encode.string(sub)),
|
|
186
|
+
(Auth_ActiveRoleStore_Schema.sortKey, JSON.Encode.string(clientId)),
|
|
187
|
+
]),
|
|
179
188
|
})->DynamoDb_DocumentClient.GetCommand.send
|
|
180
189
|
out.item
|
|
181
190
|
->Option.flatMap(JSON.Decode.object)
|
|
182
|
-
->Option.flatMap(o => o->Dict.get(
|
|
191
|
+
->Option.flatMap(o => o->Dict.get(Auth_ActiveRoleStore_Schema.roleAttribute))
|
|
183
192
|
->Option.flatMap(JSON.Decode.string)
|
|
184
193
|
} catch {
|
|
185
194
|
| _ => None
|
|
@@ -197,12 +206,17 @@ let handler = async (event: event): event => {
|
|
|
197
206
|
let sub =
|
|
198
207
|
event.request->Option.flatMap(r => r.userAttributes)->Option.flatMap(u => u.sub)->Option.getOr("")
|
|
199
208
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
209
|
+
let clientId = event.callerContext->Option.flatMap(c => c.clientId)->Option.getOr("")
|
|
210
|
+
|
|
211
|
+
// Neither half of the key means no row to look up. Returning the event
|
|
212
|
+
// untouched keeps the sign-in working on exactly the membership the pool
|
|
213
|
+
// granted — the safe direction, and the same one a read failure takes. An
|
|
214
|
+
// absent client id is not worth failing a login over: the caller lands on their
|
|
215
|
+
// full membership, which is their existing privileges and not more.
|
|
216
|
+
if sub == "" || clientId == "" {
|
|
203
217
|
event
|
|
204
218
|
} else {
|
|
205
|
-
let storedRole = await storedRoleFor(~sub, ~table=tableName())
|
|
219
|
+
let storedRole = await storedRoleFor(~sub, ~clientId, ~table=tableName())
|
|
206
220
|
respond(~event, ~decision=decide(~membership, ~storedRole))
|
|
207
221
|
}
|
|
208
222
|
}
|
|
@@ -6,6 +6,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
|
6
6
|
import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
|
|
7
7
|
import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
|
|
8
8
|
import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
|
|
9
|
+
import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
|
|
9
10
|
|
|
10
11
|
function getEnv(k) {
|
|
11
12
|
let v = process.env[k];
|
|
@@ -90,16 +91,22 @@ function respond(event, decision) {
|
|
|
90
91
|
]));
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
async function storedRoleFor(sub, table) {
|
|
94
|
+
async function storedRoleFor(sub, clientId, table) {
|
|
94
95
|
try {
|
|
95
96
|
let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
|
|
96
97
|
TableName: table,
|
|
97
|
-
Key: Object.fromEntries([
|
|
98
|
-
|
|
98
|
+
Key: Object.fromEntries([
|
|
99
|
+
[
|
|
100
|
+
Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
|
|
99
101
|
sub
|
|
100
|
-
]
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey,
|
|
105
|
+
clientId
|
|
106
|
+
]
|
|
107
|
+
])
|
|
101
108
|
}));
|
|
102
|
-
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o[
|
|
109
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o[Auth_ActiveRoleStore_Schema$ReventlessAws.roleAttribute]), Stdlib_JSON.Decode.string);
|
|
103
110
|
} catch (exn) {
|
|
104
111
|
return;
|
|
105
112
|
}
|
|
@@ -108,10 +115,11 @@ async function storedRoleFor(sub, table) {
|
|
|
108
115
|
async function handler(event) {
|
|
109
116
|
let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
|
|
110
117
|
let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
|
|
111
|
-
|
|
118
|
+
let clientId = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.callerContext, c => c.clientId), "");
|
|
119
|
+
if (sub === "" || clientId === "") {
|
|
112
120
|
return event;
|
|
113
121
|
}
|
|
114
|
-
let storedRole = await storedRoleFor(sub, tableName());
|
|
122
|
+
let storedRole = await storedRoleFor(sub, clientId, tableName());
|
|
115
123
|
return respond(event, decide(membership, storedRole));
|
|
116
124
|
}
|
|
117
125
|
|
|
@@ -283,13 +283,6 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
283
283
|
// (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
|
|
284
284
|
// See docs/plans/done/platform-plugins-admin-connection-null-rows.md.
|
|
285
285
|
let requireAttribute = internalRowRequiredAttr(name)
|
|
286
|
-
// A DynamoDB FilterExpression is applied AFTER the page is read, so a scoped
|
|
287
|
-
// list over a table with no index on the owner field returns short pages —
|
|
288
|
-
// correct, but pathological once a caller owns a small fraction of the rows.
|
|
289
|
-
// Warned rather than refused: the resolver does serve the query, and a
|
|
290
|
-
// deployment may legitimately accept the cost on a small table. Mirrors the
|
|
291
|
-
// `@scanSort` alignment warning above, which exists for the same class of
|
|
292
|
-
// "works, but scans" mistake.
|
|
293
286
|
ReventlessCore.OwnerScopeDiagnostics.warnIfNoElevatedGroups(
|
|
294
287
|
~comp="QueryDbResolvers_AppSync",
|
|
295
288
|
~view=name,
|
|
@@ -298,17 +291,33 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
298
291
|
let isIndexed = f =>
|
|
299
292
|
indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
|
|
300
293
|
subIdField->Option.getOr("") == f
|
|
301
|
-
|
|
294
|
+
// The index `@owner` derives, and the sort key that orders one caller's rows
|
|
295
|
+
// inside it. Its absence means the author declined it — the list then falls
|
|
296
|
+
// back to the Scan-and-filter this used to prescribe an `@index` for.
|
|
297
|
+
let ownerIndexConfig = switch ownerField {
|
|
302
298
|
| Some(f) =>
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
299
|
+
indexes->Array.find(ic =>
|
|
300
|
+
Reventless.ReadModel.isDerivedIndex(ic) && ic.idField->Option.getOr(ic.index) == f
|
|
301
|
+
)
|
|
302
|
+
| None => None
|
|
303
|
+
}
|
|
304
|
+
let ownerIndex = ownerIndexConfig->Option.map(ic => ic.index)
|
|
305
|
+
let ownerIndexSortField = ownerIndexConfig->Option.flatMap(ic => ic.subIdField)
|
|
306
|
+
// Only reachable through `@owner({index: false})` now that the index is
|
|
307
|
+
// derived by default, so this states the cost of that choice rather than
|
|
308
|
+
// prescribing an `@index` — which would provision a second index on the same
|
|
309
|
+
// key and still not be the one the list reads.
|
|
310
|
+
switch (ownerField, ownerIndex) {
|
|
311
|
+
| (Some(f), None) if !isIndexed(f) =>
|
|
312
|
+
log.warn(
|
|
313
|
+
~comp="QueryDbResolvers_AppSync",
|
|
314
|
+
`${name}: @owner field "${f}" keys no index on this table, so owner-scoped ` ++
|
|
315
|
+
"reads Scan the table and filter after the page is read — cost grows with the " ++
|
|
316
|
+
"table while the answer shrinks with the caller's share of it. Drop " ++
|
|
317
|
+
"`@owner({index: false})` to let the framework derive the index, or accept " ++
|
|
318
|
+
"the cost on a view that stays small.",
|
|
319
|
+
)
|
|
320
|
+
| _ => ()
|
|
312
321
|
}
|
|
313
322
|
// The same class of "works, but scans" mistake as the owner warning above,
|
|
314
323
|
// and the retirement case degrades the same way: the FilterExpression is
|
|
@@ -346,12 +355,19 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
346
355
|
~elevatedGroups,
|
|
347
356
|
~retiredField?,
|
|
348
357
|
~retiredValues?,
|
|
358
|
+
~ownerIndex?,
|
|
359
|
+
~ownerIndexSortField?,
|
|
349
360
|
)
|
|
350
361
|
} else {
|
|
351
362
|
Resolver.Functions.listAllItems
|
|
352
363
|
},
|
|
353
364
|
)
|
|
354
|
-
|
|
365
|
+
// Derived indexes are absent from the SDL (`GraphQL_FragmentGenerator` skips
|
|
366
|
+
// them), so a resolver here would attach to a field that does not exist and
|
|
367
|
+
// fail the deploy. The list resolver above is the only thing that reads one.
|
|
368
|
+
let resolversByIndex = indexes
|
|
369
|
+
->Array.filter(ic => !Reventless.ReadModel.isDerivedIndex(ic))
|
|
370
|
+
->Array.map(({index} as indexConfig) => {
|
|
355
371
|
// Name and key field both come from `GraphQL_FragmentGenerator`, which is
|
|
356
372
|
// where the SDL field this attaches to is derived. Deriving them here as
|
|
357
373
|
// well is how the two came to disagree: the emitted field declared `id`
|
|
@@ -4,6 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
6
6
|
import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
|
|
7
|
+
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
7
8
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
8
9
|
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
9
10
|
import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
|
|
@@ -123,14 +124,23 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
|
|
|
123
124
|
return Stdlib_Option.getOr(subIdField, "") === f;
|
|
124
125
|
}
|
|
125
126
|
};
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
let ownerIndexConfig = ownerField !== undefined ? indexes.find(ic => {
|
|
128
|
+
if (ReadModel$Reventless.isDerivedIndex(ic)) {
|
|
129
|
+
return Stdlib_Option.getOr(ic.idField, ic.index) === ownerField;
|
|
130
|
+
} else {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}) : undefined;
|
|
134
|
+
let ownerIndex = Stdlib_Option.map(ownerIndexConfig, ic => ic.index);
|
|
135
|
+
let ownerIndexSortField = Stdlib_Option.flatMap(ownerIndexConfig, ic => ic.subIdField);
|
|
136
|
+
if (ownerField !== undefined && !(ownerIndex !== undefined || isIndexed(ownerField))) {
|
|
137
|
+
log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" keys no index on this table, so owner-scoped ` + "reads Scan the table and filter after the page is read — cost grows with the table while the answer shrinks with the caller's share of it. Drop `@owner({index: false})` to let the framework derive the index, or accept the cost on a view that stays small.");
|
|
128
138
|
}
|
|
129
139
|
if (retiredField !== undefined && !isIndexed(retiredField)) {
|
|
130
140
|
log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @retired field "` + retiredField + `" is not the key of any index on this table. ` + "Reads that exclude retired rows will Scan and filter, so pages shrink as the archive's share of the rows grows. Add an @index on that field before this read model grows.");
|
|
131
141
|
}
|
|
132
|
-
let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, elevatedGroups, retiredField, retiredValues) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
|
|
133
|
-
let resolversByIndex = indexes.map(indexConfig => {
|
|
142
|
+
let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, elevatedGroups, retiredField, retiredValues, ownerIndex, ownerIndexSortField) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
|
|
143
|
+
let resolversByIndex = indexes.filter(ic => !ReadModel$Reventless.isDerivedIndex(ic)).map(indexConfig => {
|
|
134
144
|
let index = indexConfig.index;
|
|
135
145
|
let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(fieldNameForSingle, index);
|
|
136
146
|
let resolverName = Stdlib_String.capitalize(fieldName);
|
|
@@ -29,20 +29,35 @@ let globalSecondaryIndexes = (indexes: array<Reventless.ReadModel.indexConfig>)
|
|
|
29
29
|
})
|
|
30
30
|
->Pulumi.Input.make
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
[
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
32
|
+
// Pulumi rejects a table that defines the same attribute twice, and an index may
|
|
33
|
+
// legitimately key on one the table already declares — the derived `@owner` index
|
|
34
|
+
// sorts on `id`, and any index may sort on the table's own sort key. First
|
|
35
|
+
// declaration wins; they agree on the type because both name the same column.
|
|
36
|
+
let attributes = (sortField, indexes: array<Reventless.ReadModel.indexConfig>) => {
|
|
37
|
+
let all =
|
|
38
|
+
[
|
|
39
|
+
[{name: "id", type_: "S"}],
|
|
40
|
+
sortField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
|
|
41
|
+
indexes
|
|
42
|
+
->Array.map((indexConfig: Reventless.ReadModel.indexConfig) => {
|
|
43
|
+
let {index, type_} = indexConfig
|
|
44
|
+
[
|
|
45
|
+
[{name: indexConfig.idField->Option.getOr(index), type_}],
|
|
46
|
+
indexConfig.subIdField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
|
|
47
|
+
]->Array.flat
|
|
48
|
+
})
|
|
49
|
+
->Array.flat,
|
|
50
|
+
]->Array.flat
|
|
51
|
+
let seen = Set.make()
|
|
52
|
+
all->Array.filter(({name}) =>
|
|
53
|
+
if seen->Set.has(name) {
|
|
54
|
+
false
|
|
55
|
+
} else {
|
|
56
|
+
seen->Set.add(name)
|
|
57
|
+
true
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
}
|
|
46
61
|
|
|
47
62
|
let dataSource = (name, table, api, apiRole, opts) => {
|
|
48
63
|
let _dataSourceRolePolicy = {
|
|
@@ -38,7 +38,7 @@ function globalSecondaryIndexes(indexes) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function attributes(sortField, indexes) {
|
|
41
|
-
|
|
41
|
+
let all = [
|
|
42
42
|
[{
|
|
43
43
|
name: "id",
|
|
44
44
|
type: "S"
|
|
@@ -58,6 +58,16 @@ function attributes(sortField, indexes) {
|
|
|
58
58
|
}])
|
|
59
59
|
].flat()).flat()
|
|
60
60
|
].flat();
|
|
61
|
+
let seen = new Set();
|
|
62
|
+
return all.filter(param => {
|
|
63
|
+
let name = param.name;
|
|
64
|
+
if (seen.has(name)) {
|
|
65
|
+
return false;
|
|
66
|
+
} else {
|
|
67
|
+
seen.add(name);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
function dataSource(name, table, api, apiRole, opts) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Classifying an AWS SDK failure, for callers that must tell one apart from the
|
|
2
|
+
// rest rather than treat every throw alike.
|
|
3
|
+
//
|
|
4
|
+
// 🚨 **The error's `name` carries the code; its `message` does not have to.** A
|
|
5
|
+
// v3 SDK `DescribeTable` on a missing table throws `name:
|
|
6
|
+
// "ResourceNotFoundException"` with the message "Requested resource not found:
|
|
7
|
+
// Table: X not found" — which does not contain the code anywhere. A guard written
|
|
8
|
+
// against the message alone therefore never matches, and the ordinary case it was
|
|
9
|
+
// meant to absorb escapes as an unhandled rejection instead.
|
|
10
|
+
//
|
|
11
|
+
// That is not hypothetical: it is why this module exists rather than the check
|
|
12
|
+
// living inline. A predicate inside a script that runs on import cannot be tested,
|
|
13
|
+
// so the version that could never match shipped.
|
|
14
|
+
//
|
|
15
|
+
// `Auth_ActiveRolePoolAttachment` keeps its own inline copy on purpose — Pulumi
|
|
16
|
+
// serialises a dynamic provider's whole closure into stack state, and a helper
|
|
17
|
+
// reached through a module import is a dependency that serialisation cannot carry.
|
|
18
|
+
|
|
19
|
+
@get @return(nullable) external name: JsExn.t => option<string> = "name"
|
|
20
|
+
|
|
21
|
+
/** Whether a failure carries this AWS error code, by `name` first and message as
|
|
22
|
+
a fallback for wrapped or re-thrown shapes. */
|
|
23
|
+
let hasCode = (exn: exn, ~code: string): bool =>
|
|
24
|
+
switch exn->JsExn.fromException {
|
|
25
|
+
| Some(jsErr) =>
|
|
26
|
+
switch (jsErr->name, JsExn.message(jsErr)) {
|
|
27
|
+
| (Some(actual), _) if actual == code => true
|
|
28
|
+
| (_, Some(message)) => message->String.includes(code)
|
|
29
|
+
| _ => false
|
|
30
|
+
}
|
|
31
|
+
| None => false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The resource named by the call does not exist — the shape both DynamoDB and
|
|
35
|
+
Cognito use, and normally an absence to handle rather than a failure to report. */
|
|
36
|
+
let isNotFound = (exn: exn): bool => exn->hasCode(~code="ResourceNotFoundException")
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
An escaped exception as one line an operator can act on.
|
|
40
|
+
|
|
41
|
+
For the top of a CLI: without it Node reports `UnhandledPromiseRejection ...
|
|
42
|
+
"#<Object>"`, which names neither the call that failed nor why. Every branch
|
|
43
|
+
returns something, because a describer that itself throws replaces one unreadable
|
|
44
|
+
failure with another.
|
|
45
|
+
*/
|
|
46
|
+
let describe = (exn: exn): string =>
|
|
47
|
+
switch exn->JsExn.fromException {
|
|
48
|
+
| Some(jsErr) =>
|
|
49
|
+
switch (jsErr->name, JsExn.message(jsErr)) {
|
|
50
|
+
| (Some(n), Some(m)) => `${n}: ${m}`
|
|
51
|
+
| (Some(n), None) => n
|
|
52
|
+
| (None, Some(m)) => m
|
|
53
|
+
| (None, None) => "an AWS call failed with no message"
|
|
54
|
+
}
|
|
55
|
+
| None => "an unexpected error escaped"
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
4
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
5
|
+
|
|
6
|
+
function hasCode(exn, code) {
|
|
7
|
+
let jsErr = Stdlib_JsExn.fromException(exn);
|
|
8
|
+
if (jsErr === undefined) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
let jsErr$1 = Primitive_option.valFromOption(jsErr);
|
|
12
|
+
let match = jsErr$1.name;
|
|
13
|
+
let match$1 = Stdlib_JsExn.message(jsErr$1);
|
|
14
|
+
if (match == null) {
|
|
15
|
+
if (match$1 !== undefined) {
|
|
16
|
+
return match$1.includes(code);
|
|
17
|
+
} else {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
} else if (match === code) {
|
|
21
|
+
return true;
|
|
22
|
+
} else if (match$1 !== undefined) {
|
|
23
|
+
return match$1.includes(code);
|
|
24
|
+
} else {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isNotFound(exn) {
|
|
30
|
+
return hasCode(exn, "ResourceNotFoundException");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function describe(exn) {
|
|
34
|
+
let jsErr = Stdlib_JsExn.fromException(exn);
|
|
35
|
+
if (jsErr === undefined) {
|
|
36
|
+
return "an unexpected error escaped";
|
|
37
|
+
}
|
|
38
|
+
let jsErr$1 = Primitive_option.valFromOption(jsErr);
|
|
39
|
+
let match = jsErr$1.name;
|
|
40
|
+
let match$1 = Stdlib_JsExn.message(jsErr$1);
|
|
41
|
+
if (match == null) {
|
|
42
|
+
if (match$1 !== undefined) {
|
|
43
|
+
return match$1;
|
|
44
|
+
} else {
|
|
45
|
+
return "an AWS call failed with no message";
|
|
46
|
+
}
|
|
47
|
+
} else if (match$1 !== undefined) {
|
|
48
|
+
return match + `: ` + match$1;
|
|
49
|
+
} else {
|
|
50
|
+
return match;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
hasCode,
|
|
56
|
+
isNotFound,
|
|
57
|
+
describe,
|
|
58
|
+
}
|
|
59
|
+
/* Stdlib_JsExn Not a pure module */
|
|
@@ -31,6 +31,34 @@ let modeOptions = (mode: Platform.viewMode): array<(string, JSON.t)> =>
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
The identity fields a shell reads, under **both** spellings.
|
|
36
|
+
|
|
37
|
+
🚨 **Both, and not as a sequenced rename.** `identityProvider*` is what these
|
|
38
|
+
become — the concept is not AWS-specific, so a second cloud's adapter should meet
|
|
39
|
+
the same names. `cognito*` is what every shipped shell reads today.
|
|
40
|
+
|
|
41
|
+
A shell reads `config.json` at runtime and CloudFront serves the previous bundle
|
|
42
|
+
until someone invalidates it, so switching the keys in one deploy leaves a window
|
|
43
|
+
where the served bundle and the served config disagree. `cognitoClientId` is read
|
|
44
|
+
into an `option`: a bundle that cannot find its key does not error, it gets
|
|
45
|
+
`None`, and login, silent refresh and token refresh all quietly fall through —
|
|
46
|
+
the window is a total auth outage that reports nothing.
|
|
47
|
+
|
|
48
|
+
Writing both removes the ordering dependency rather than managing it: any bundle,
|
|
49
|
+
old or new or stale in a CDN, finds one it understands. The `cognito*` pair goes
|
|
50
|
+
once the shell that prefers the other is the pinned one.
|
|
51
|
+
|
|
52
|
+
Here rather than inline at the call site so the pairing is one fact in one place,
|
|
53
|
+
and so the property can be asserted without a deploy.
|
|
54
|
+
*/
|
|
55
|
+
let identityFields = (~providerId: string, ~clientId: string): array<(string, JSON.t)> => [
|
|
56
|
+
("identityProviderId", JSON.Encode.string(providerId)),
|
|
57
|
+
("identityProviderClientId", JSON.Encode.string(clientId)),
|
|
58
|
+
("cognitoUserPoolId", JSON.Encode.string(providerId)),
|
|
59
|
+
("cognitoClientId", JSON.Encode.string(clientId)),
|
|
60
|
+
]
|
|
61
|
+
|
|
34
62
|
/**
|
|
35
63
|
The config.json field set.
|
|
36
64
|
|
|
@@ -31,6 +31,27 @@ function modeOptions(mode) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function identityFields(providerId, clientId) {
|
|
35
|
+
return [
|
|
36
|
+
[
|
|
37
|
+
"identityProviderId",
|
|
38
|
+
providerId
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"identityProviderClientId",
|
|
42
|
+
clientId
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
"cognitoUserPoolId",
|
|
46
|
+
providerId
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
"cognitoClientId",
|
|
50
|
+
clientId
|
|
51
|
+
]
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
function fields(computed, viewModes, bakedManifest, shellConfig) {
|
|
35
56
|
let out = Object.fromEntries(computed);
|
|
36
57
|
Stdlib_Option.forEach(bakedManifest, bake => {
|
|
@@ -70,6 +91,7 @@ export {
|
|
|
70
91
|
Platform,
|
|
71
92
|
journeyManifestsKey,
|
|
72
93
|
modeOptions,
|
|
94
|
+
identityFields,
|
|
73
95
|
fields,
|
|
74
96
|
}
|
|
75
97
|
/* No side effect */
|