@reventlessdev/reventless-aws 3.0.0-alpha.300 → 3.0.0-alpha.302
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 +20 -0
- package/package.json +8 -8
- package/src/Platform.res +18 -4
- package/src/Platform.res.mjs +24 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +40 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +35 -13
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +88 -20
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +81 -18
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +10 -1
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +1 -1
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +39 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +22 -3
- package/src/util/Util_DynamoDbStream.res +15 -4
- package/src/util/Util_DynamoDbStream.res.mjs +15 -5
- package/src/util/Util_ShellConfig.res +22 -2
- package/src/util/Util_ShellConfig.res.mjs +11 -0
- package/tests/Auth_ActiveRoleStoreTest.res +32 -0
- package/tests/Auth_ActiveRoleStoreTest.res.mjs +29 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +46 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +31 -0
- package/tests/Util_DynamoDbStreamTest.res +43 -0
- package/tests/Util_DynamoDbStreamTest.res.mjs +41 -0
- package/tests/Util_ShellConfigTest.res +107 -0
- package/tests/Util_ShellConfigTest.res.mjs +100 -0
|
@@ -50,16 +50,25 @@ token they hold no longer mentions the role they are asking for. This is the sam
|
|
|
50
50
|
trap the local path documents on `LocalAuth.Login.reissue`, where membership is
|
|
51
51
|
re-read from the user store rather than from the token's own record of it.
|
|
52
52
|
|
|
53
|
+
🚨 **Addressed by username, not by `sub`.** `AdminListGroupsForUser` takes a
|
|
54
|
+
`Username`, and a `sub` is only accepted there when the pool makes it one — a
|
|
55
|
+
pool with `UsernameAttributes` (email or phone sign-in), where the generated
|
|
56
|
+
username *is* the subject. On a plain username pool the same call answers
|
|
57
|
+
`UserNotFoundException: User does not exist.`, which fails the whole mutation
|
|
58
|
+
before it reaches the subset check: not "you may not act as that role" but "you
|
|
59
|
+
do not exist", for a caller who is signed in and holds the role. The row is still
|
|
60
|
+
keyed on `sub` — this parameter names the user to Cognito, nothing else.
|
|
61
|
+
|
|
53
62
|
Paginated deliberately: `AdminListGroupsForUser` caps a page at 60 groups, and a
|
|
54
63
|
truncated read would silently refuse a role the caller genuinely holds.
|
|
55
64
|
*/
|
|
56
|
-
let membershipOf = async (~
|
|
65
|
+
let membershipOf = async (~username: string, ~poolId: string): array<string> => {
|
|
57
66
|
let collected = []
|
|
58
67
|
let nextToken = ref(None)
|
|
59
68
|
let more = ref(true)
|
|
60
69
|
while more.contents {
|
|
61
70
|
let page = await CognitoIdentityServiceProvider.AdminListGroupsForUserCommand.make({
|
|
62
|
-
username
|
|
71
|
+
username,
|
|
63
72
|
userPoolId: poolId,
|
|
64
73
|
nextToken: ?nextToken.contents,
|
|
65
74
|
})->CognitoIdentityServiceProvider.AdminListGroupsForUserCommand.send
|
|
@@ -89,13 +98,33 @@ let mayActAs = (~membership: array<string>, ~requested: string): bool =>
|
|
|
89
98
|
|
|
90
99
|
// ── AppSync resolver event / result shapes ──────────────────────────────────
|
|
91
100
|
|
|
92
|
-
type identity = {sub?: string}
|
|
101
|
+
type identity = {sub?: string, username?: Nullable.t<string>}
|
|
93
102
|
type activeRoleArgs = {activeRole?: Nullable.t<string>}
|
|
94
103
|
type appSyncEvent = {
|
|
95
104
|
arguments?: activeRoleArgs,
|
|
96
105
|
identity?: identity,
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
/**
|
|
109
|
+
The name to address this caller by when asking Cognito what groups they are in.
|
|
110
|
+
|
|
111
|
+
The username when the authorizer supplied one, the subject otherwise. The
|
|
112
|
+
fallback is not a guess at a username: it is the behaviour this handler had
|
|
113
|
+
before the username was forwarded, kept for the pool shapes where it is correct —
|
|
114
|
+
one whose username *is* the subject — rather than turning a working deployment
|
|
115
|
+
into a hard failure on an absent field. Pure so the choice can be tested without
|
|
116
|
+
a pool.
|
|
117
|
+
*/
|
|
118
|
+
let cognitoLookupName = (~identity: identity): option<string> =>
|
|
119
|
+
switch identity.username {
|
|
120
|
+
| Some(Value(name)) if name->String.trim != "" => Some(name)
|
|
121
|
+
| _ =>
|
|
122
|
+
switch identity.sub {
|
|
123
|
+
| Some("") | None => None
|
|
124
|
+
| Some(sub) => Some(sub)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
99
128
|
let result = (~activeRole: option<string>, ~availableRoles: array<string>): JSON.t =>
|
|
100
129
|
Dict.fromArray([
|
|
101
130
|
("activeRole", activeRole->Option.mapOr(JSON.Null, JSON.Encode.string)),
|
|
@@ -111,8 +140,14 @@ let handler = async (event: appSyncEvent): JSON.t => {
|
|
|
111
140
|
// unauthenticated path from ever writing a row keyed on the empty subject.
|
|
112
141
|
JsError.throwWithMessage("unauthenticated")
|
|
113
142
|
}
|
|
143
|
+
// Two names for one caller, and they are not interchangeable: `sub` keys the
|
|
144
|
+
// row, the lookup name addresses Cognito. See `membershipOf`.
|
|
145
|
+
let lookupName = switch event.identity->Option.flatMap(i => cognitoLookupName(~identity=i)) {
|
|
146
|
+
| Some(name) => name
|
|
147
|
+
| None => JsError.throwWithMessage("unauthenticated")
|
|
148
|
+
}
|
|
114
149
|
let table = tableName()
|
|
115
|
-
let membership = await membershipOf(~
|
|
150
|
+
let membership = await membershipOf(~username=lookupName, ~poolId=userPoolId())
|
|
116
151
|
|
|
117
152
|
// An absent argument and an explicit `null` mean the same thing — clear the
|
|
118
153
|
// preference and go back to full membership on the next refresh. That is not an
|
|
@@ -33,13 +33,13 @@ function userPoolId() {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
async function membershipOf(
|
|
36
|
+
async function membershipOf(username, poolId) {
|
|
37
37
|
let collected = [];
|
|
38
38
|
let nextToken;
|
|
39
39
|
let more = true;
|
|
40
40
|
while (more) {
|
|
41
41
|
let page = await CognitoIdentityServiceProvider$AwsSdk.AdminListGroupsForUserCommand.send(new ClientCognitoIdentityProvider.AdminListGroupsForUserCommand({
|
|
42
|
-
Username:
|
|
42
|
+
Username: username,
|
|
43
43
|
UserPoolId: poolId,
|
|
44
44
|
NextToken: nextToken
|
|
45
45
|
}));
|
|
@@ -60,6 +60,22 @@ function mayActAs(membership, requested) {
|
|
|
60
60
|
return membership.includes(requested);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function cognitoLookupName(identity) {
|
|
64
|
+
let match = identity.username;
|
|
65
|
+
if (match !== undefined) {
|
|
66
|
+
let name = Primitive_option.valFromOption(match);
|
|
67
|
+
if (name == null) {
|
|
68
|
+
name === null;
|
|
69
|
+
} else if (name.trim() !== "") {
|
|
70
|
+
return name;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let sub = identity.sub;
|
|
74
|
+
if (sub !== undefined && sub !== "") {
|
|
75
|
+
return sub;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function result(activeRole, availableRoles) {
|
|
64
80
|
return Object.fromEntries([
|
|
65
81
|
[
|
|
@@ -78,8 +94,10 @@ async function handler(event) {
|
|
|
78
94
|
if (sub === "") {
|
|
79
95
|
Stdlib_JsError.throwWithMessage("unauthenticated");
|
|
80
96
|
}
|
|
97
|
+
let name = Stdlib_Option.flatMap(event.identity, cognitoLookupName);
|
|
98
|
+
let lookupName = name !== undefined ? name : Stdlib_JsError.throwWithMessage("unauthenticated");
|
|
81
99
|
let table = tableName();
|
|
82
|
-
let membership = await membershipOf(
|
|
100
|
+
let membership = await membershipOf(lookupName, userPoolId());
|
|
83
101
|
let match = Stdlib_Option.flatMap(event.arguments, a => a.activeRole);
|
|
84
102
|
let requested;
|
|
85
103
|
if (match !== undefined) {
|
|
@@ -122,6 +140,7 @@ export {
|
|
|
122
140
|
userPoolId,
|
|
123
141
|
membershipOf,
|
|
124
142
|
mayActAs,
|
|
143
|
+
cognitoLookupName,
|
|
125
144
|
result,
|
|
126
145
|
handler,
|
|
127
146
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
let toResourceInfo = (table: PulumiAws.DynamoDb.Table.t) =>
|
|
2
2
|
table.streamArn->Pulumi.Output.apply(streamArn => ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn}))
|
|
3
3
|
|
|
4
|
+
// The table name rides IN the apply rather than being read with `Output.get` on
|
|
5
|
+
// the failure arm: `get` throws "Cannot call '.get' during update or preview" on
|
|
6
|
+
// every deploy, so the arm meant to name the offending table replaced its own
|
|
7
|
+
// message with that one and hid which resource was missing a stream.
|
|
4
8
|
let streamArnFromDynamoDbTableResource = (resource: ReventlessInfra.Adapter.resource) =>
|
|
5
|
-
resource.resourceInfo
|
|
9
|
+
(resource.resourceInfo, resource.name)
|
|
10
|
+
->Pulumi.Output.all2
|
|
11
|
+
->Pulumi.Output.apply(((resourceInfo, tableName)) =>
|
|
6
12
|
switch resourceInfo {
|
|
7
13
|
| StreamSource({sourceUrn}) => sourceUrn
|
|
8
|
-
| _ =>
|
|
9
|
-
let tableName = resource.name->Pulumi.Output.get
|
|
10
|
-
JsError.throwWithMessage("No streamArn field given for table " ++ tableName)
|
|
14
|
+
| _ => JsError.throwWithMessage("No streamArn field given for table " ++ tableName)
|
|
11
15
|
}
|
|
12
16
|
)
|
|
13
17
|
|
|
@@ -30,6 +34,13 @@ let toStreamResource = (table: ReventlessInfra.Adapter.resource): ReventlessInfr
|
|
|
30
34
|
~id=streamArn,
|
|
31
35
|
~urn=streamArn,
|
|
32
36
|
~service=table.name->Pulumi.Output.apply(_ => AWS.DynamoDbStream.service),
|
|
37
|
+
// Carries the same StreamSource the table resource carries, so asking a
|
|
38
|
+
// stream resource for its ARN answers instead of throwing. The event-topic
|
|
39
|
+
// resources a publisher exports are these, and `Upload_Claim_S3` asks
|
|
40
|
+
// exactly that of resources[0].
|
|
41
|
+
~resourceInfo=streamArn->Pulumi.Output.apply(sourceUrn => ReventlessInfra.Adapter.StreamSource({
|
|
42
|
+
sourceUrn: sourceUrn,
|
|
43
|
+
})),
|
|
33
44
|
~resourceType="aws:dynamodb:Stream"->Pulumi.Output.make,
|
|
34
45
|
)
|
|
35
46
|
}
|
|
@@ -22,12 +22,19 @@ function toResourceInfo(table) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function streamArnFromDynamoDbTableResource(resource) {
|
|
25
|
-
return
|
|
26
|
-
|
|
25
|
+
return Pulumi.all([
|
|
26
|
+
resource.resourceInfo,
|
|
27
|
+
resource.name
|
|
28
|
+
]).apply(param => {
|
|
29
|
+
let tableName = param[1];
|
|
30
|
+
let resourceInfo = param[0];
|
|
31
|
+
if (typeof resourceInfo !== "object") {
|
|
32
|
+
return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
|
|
33
|
+
} else if (resourceInfo.TAG === "StreamSource") {
|
|
27
34
|
return resourceInfo.sourceUrn;
|
|
35
|
+
} else {
|
|
36
|
+
return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
|
|
28
37
|
}
|
|
29
|
-
let tableName = resource.name.get();
|
|
30
|
-
return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
|
|
31
38
|
});
|
|
32
39
|
}
|
|
33
40
|
|
|
@@ -37,7 +44,10 @@ function toResource(tags, table) {
|
|
|
37
44
|
|
|
38
45
|
function toStreamResource(table) {
|
|
39
46
|
let streamArn = streamArnFromDynamoDbTableResource(table);
|
|
40
|
-
return Adapter$ReventlessInfra.make(table.name, streamArn, streamArn, table.name.apply(param => AWS$ReventlessAws.DynamoDbStream.service),
|
|
47
|
+
return Adapter$ReventlessInfra.make(table.name, streamArn, streamArn, table.name.apply(param => AWS$ReventlessAws.DynamoDbStream.service), streamArn.apply(sourceUrn => ({
|
|
48
|
+
TAG: "StreamSource",
|
|
49
|
+
sourceUrn: sourceUrn
|
|
50
|
+
})), undefined, undefined, Pulumi.output("aws:dynamodb:Stream"), undefined, undefined);
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
let log = Logger$ReventlessCore.fromEnv();
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
module Platform = ReventlessInfra.Platform
|
|
11
11
|
|
|
12
|
+
// Where a caller acting as a given role discovers from — a group→url map. Named
|
|
13
|
+
// here as the shell reads it, the same way `manifestUrl` is.
|
|
14
|
+
let journeyManifestsKey = "journeyManifestUrls"
|
|
15
|
+
|
|
12
16
|
// A mode's options, flattened to the wire shape. They are payloads of their arm
|
|
13
17
|
// on the deploy side (so `mapStyle` with the map off cannot be expressed) and
|
|
14
18
|
// flat siblings of `viewModes` on the wire (because that is where the released
|
|
@@ -52,12 +56,28 @@ let fields = (
|
|
|
52
56
|
// decides where the file goes — a passthrough could point the shell at a key
|
|
53
57
|
// nothing writes, and a statically-discovered shell has no admin API behind it
|
|
54
58
|
// to notice.
|
|
55
|
-
bakedManifest->Option.forEach(bake =>
|
|
59
|
+
bakedManifest->Option.forEach(bake => {
|
|
56
60
|
out->Dict.set(
|
|
57
61
|
"manifestUrl",
|
|
58
62
|
JSON.Encode.string(ReventlessCore.Platform_BakedManifest.urlForKey(bake.key)),
|
|
59
63
|
)
|
|
60
|
-
|
|
64
|
+
// Where a caller acting as a given role discovers from, beside the default
|
|
65
|
+
// rather than instead of it: `manifestUrl` stays the default journey, which
|
|
66
|
+
// is what a caller matching no declared group gets. Omitted entirely when
|
|
67
|
+
// nothing is declared, so a single-audience deployment writes the key set it
|
|
68
|
+
// always did and a shell that has never heard of journeys sees no new key.
|
|
69
|
+
switch ReventlessCore.Platform_BakedManifest.journeyUrls(~config=bake) {
|
|
70
|
+
| [] => ()
|
|
71
|
+
| urls =>
|
|
72
|
+
out->Dict.set(
|
|
73
|
+
journeyManifestsKey,
|
|
74
|
+
urls
|
|
75
|
+
->Array.map(((group, url)) => (group, JSON.Encode.string(url)))
|
|
76
|
+
->Dict.fromArray
|
|
77
|
+
->JSON.Encode.object,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
61
81
|
|
|
62
82
|
switch viewModes {
|
|
63
83
|
| Some(modes) =>
|
|
@@ -6,6 +6,8 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
6
6
|
import * as Platform$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/Platform.res.mjs";
|
|
7
7
|
import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
|
|
8
8
|
|
|
9
|
+
let journeyManifestsKey = "journeyManifestUrls";
|
|
10
|
+
|
|
9
11
|
function modeOptions(mode) {
|
|
10
12
|
if (mode.TAG === "Map") {
|
|
11
13
|
let style = mode._0.style;
|
|
@@ -33,6 +35,14 @@ function fields(computed, viewModes, bakedManifest, shellConfig) {
|
|
|
33
35
|
let out = Object.fromEntries(computed);
|
|
34
36
|
Stdlib_Option.forEach(bakedManifest, bake => {
|
|
35
37
|
out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(bake.key);
|
|
38
|
+
let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(bake);
|
|
39
|
+
if (urls.length !== 0) {
|
|
40
|
+
out[journeyManifestsKey] = Object.fromEntries(urls.map(param => [
|
|
41
|
+
param[0],
|
|
42
|
+
param[1]
|
|
43
|
+
]));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
36
46
|
});
|
|
37
47
|
if (viewModes !== undefined) {
|
|
38
48
|
out["viewModes"] = viewModes.map(Platform$ReventlessInfra.viewModeToString);
|
|
@@ -58,6 +68,7 @@ let Platform;
|
|
|
58
68
|
|
|
59
69
|
export {
|
|
60
70
|
Platform,
|
|
71
|
+
journeyManifestsKey,
|
|
61
72
|
modeOptions,
|
|
62
73
|
fields,
|
|
63
74
|
}
|
|
@@ -40,3 +40,35 @@ describe("Auth_ActiveRoleStore_Ops.mayActAs — narrowing only", () => {
|
|
|
40
40
|
expect(Ops.mayActAs(~membership=[], ~requested="Shopper"))->toBe(false)
|
|
41
41
|
)
|
|
42
42
|
})
|
|
43
|
+
|
|
44
|
+
// Which name goes to Cognito. `sub` keys the row and `AdminListGroupsForUser`
|
|
45
|
+
// takes a username, and a pool that makes neither an alias of the other answers
|
|
46
|
+
// `UserNotFoundException` when handed the wrong one — a signed-in caller holding
|
|
47
|
+
// the role they asked for, told they do not exist.
|
|
48
|
+
describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
|
|
49
|
+
testSync("the username is what addresses Cognito when the authorizer sent one", () =>
|
|
50
|
+
expect(
|
|
51
|
+
Ops.cognitoLookupName(~identity={sub: "d265d464-2091-706a-e5f9-3afafe7be29c", username: Value("merch")}),
|
|
52
|
+
)->toEqual(Some("merch"))
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
testSync("an absent username falls back to the subject, as before it was forwarded", () =>
|
|
56
|
+
expect(Ops.cognitoLookupName(~identity={sub: "sub-1"}))->toEqual(Some("sub-1"))
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// The resolver sends `id.username ?? null`, so null is the shape an authorizer
|
|
60
|
+
// that names no username actually produces — not a missing field.
|
|
61
|
+
testSync("an explicitly null username falls back too", () =>
|
|
62
|
+
expect(Ops.cognitoLookupName(~identity={sub: "sub-1", username: Null}))->toEqual(Some("sub-1"))
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
testSync("a blank username is not a name to look anyone up by", () =>
|
|
66
|
+
expect(Ops.cognitoLookupName(~identity={sub: "sub-1", username: Value(" ")}))->toEqual(
|
|
67
|
+
Some("sub-1"),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
testSync("neither name means there is nobody to ask about", () =>
|
|
72
|
+
expect(Ops.cognitoLookupName(~identity={}))->toEqual(None)
|
|
73
|
+
)
|
|
74
|
+
})
|
|
@@ -30,6 +30,35 @@ globalThis.describe("Auth_ActiveRoleStore_Ops.mayActAs — narrowing only", () =
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
globalThis.describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
|
|
34
|
+
globalThis.test("the username is what addresses Cognito when the authorizer sent one", () => {
|
|
35
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
|
|
36
|
+
sub: "d265d464-2091-706a-e5f9-3afafe7be29c",
|
|
37
|
+
username: "merch"
|
|
38
|
+
})).toEqual("merch");
|
|
39
|
+
});
|
|
40
|
+
globalThis.test("an absent username falls back to the subject, as before it was forwarded", () => {
|
|
41
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
|
|
42
|
+
sub: "sub-1"
|
|
43
|
+
})).toEqual("sub-1");
|
|
44
|
+
});
|
|
45
|
+
globalThis.test("an explicitly null username falls back too", () => {
|
|
46
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
|
|
47
|
+
sub: "sub-1",
|
|
48
|
+
username: null
|
|
49
|
+
})).toEqual("sub-1");
|
|
50
|
+
});
|
|
51
|
+
globalThis.test("a blank username is not a name to look anyone up by", () => {
|
|
52
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
|
|
53
|
+
sub: "sub-1",
|
|
54
|
+
username: " "
|
|
55
|
+
})).toEqual("sub-1");
|
|
56
|
+
});
|
|
57
|
+
globalThis.test("neither name means there is nobody to ask about", () => {
|
|
58
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({})).toEqual(undefined);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
33
62
|
let Ops;
|
|
34
63
|
|
|
35
64
|
let Contract;
|
|
@@ -241,6 +241,52 @@ describe("bakeSelection", () => {
|
|
|
241
241
|
)
|
|
242
242
|
})
|
|
243
243
|
|
|
244
|
+
// A journey travels from the deploy in the function's environment, exactly as the
|
|
245
|
+
// default include-list does, carrying the key the deploy resolved. Deriving the
|
|
246
|
+
// key a second time here would be a file written where `config.json` does not
|
|
247
|
+
// send anybody, so the handler only reads it.
|
|
248
|
+
describe("bakeJourney", () => {
|
|
249
|
+
let journey = raw => Platform_ComponentDefinitions_Lambda_Ops.bakeJourney(JSON.parseOrThrow(raw))
|
|
250
|
+
|
|
251
|
+
testSync("reads the group, the key and the include-list the deploy encoded", () => {
|
|
252
|
+
let decoded = journey(`{
|
|
253
|
+
"group": "Fulfilment",
|
|
254
|
+
"key": "component-manifest-fulfilment.json",
|
|
255
|
+
"components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
|
|
256
|
+
}`)
|
|
257
|
+
expect(decoded->Option.map(j => (j.group, j.key, j.selections->Array.map(s => s.plugin))))->toEqual(
|
|
258
|
+
Some(("Fulfilment", "component-manifest-fulfilment.json", ["Ordering"])),
|
|
259
|
+
)
|
|
260
|
+
expect(
|
|
261
|
+
decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.derived),
|
|
262
|
+
)->toEqual(Some(["lifecycles"]))
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Absent is not empty here either: a journey whose selection names no `views`
|
|
266
|
+
// takes every public view of that plugin.
|
|
267
|
+
testSync("keeps a selection's absent lists absent", () => {
|
|
268
|
+
let decoded = journey(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`)
|
|
269
|
+
expect(
|
|
270
|
+
decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.views),
|
|
271
|
+
)->toEqual(None)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Both are what the write needs. A journey missing either would be baked to a
|
|
275
|
+
// key nobody granted or reported as belonging to no audience.
|
|
276
|
+
testSync("refuses an entry naming no group or no key", () => {
|
|
277
|
+
expect(journey(`{"key": "s.json", "components": []}`)->Option.isNone)->toBe(true)
|
|
278
|
+
expect(journey(`{"group": "Shopper", "components": []}`)->Option.isNone)->toBe(true)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// A journey that curates nothing is still a journey — it writes an empty file
|
|
282
|
+
// rather than falling back to the default one.
|
|
283
|
+
testSync("a journey with no components decodes to an empty include-list", () =>
|
|
284
|
+
expect(
|
|
285
|
+
journey(`{"group": "Shopper", "key": "s.json"}`)->Option.map(j => j.selections->Array.length),
|
|
286
|
+
)->toEqual(Some(0))
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
|
|
244
290
|
// The bake reads a read model the deploy updates asynchronously, so "is this the
|
|
245
291
|
// deployment I was asked to bake" is a question it has to be able to answer. The
|
|
246
292
|
// answer is an equality check against the key each plugin stack just wrote.
|
|
@@ -154,6 +154,37 @@ globalThis.describe("bakeSelection", () => {
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
globalThis.describe("bakeJourney", () => {
|
|
158
|
+
globalThis.test("reads the group, the key and the include-list the deploy encoded", () => {
|
|
159
|
+
let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{
|
|
160
|
+
"group": "Fulfilment",
|
|
161
|
+
"key": "component-manifest-fulfilment.json",
|
|
162
|
+
"components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
|
|
163
|
+
}`));
|
|
164
|
+
globalThis.expect(Stdlib_Option.map(decoded, j => [
|
|
165
|
+
j.group,
|
|
166
|
+
j.key,
|
|
167
|
+
j.selections.map(s => s.plugin)
|
|
168
|
+
])).toEqual([
|
|
169
|
+
"Fulfilment",
|
|
170
|
+
"component-manifest-fulfilment.json",
|
|
171
|
+
["Ordering"]
|
|
172
|
+
]);
|
|
173
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.derived)).toEqual(["lifecycles"]);
|
|
174
|
+
});
|
|
175
|
+
globalThis.test("keeps a selection's absent lists absent", () => {
|
|
176
|
+
let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`));
|
|
177
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.views)).toEqual(undefined);
|
|
178
|
+
});
|
|
179
|
+
globalThis.test("refuses an entry naming no group or no key", () => {
|
|
180
|
+
globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"key": "s.json", "components": []}`)))).toBe(true);
|
|
181
|
+
globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "components": []}`)))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
globalThis.test("a journey with no components decodes to an empty include-list", () => {
|
|
184
|
+
globalThis.expect(Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json"}`)), j => j.selections.length)).toEqual(0);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
157
188
|
globalThis.describe("pendingRegistrations", () => {
|
|
158
189
|
let row = (name, key, param) => {
|
|
159
190
|
let item = Object.fromEntries([[
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// Guards that a stream resource describes its own stream.
|
|
4
|
+
//
|
|
5
|
+
// `toStreamResource` used to leave `resourceInfo` at its `NoInfo` default even
|
|
6
|
+
// though it was built FROM a stream ARN. The event-topic resources a DynamoDB
|
|
7
|
+
// stream publisher exports are these, so every reader that asks an event topic
|
|
8
|
+
// for its stream ARN — `Upload_Claim_S3` does, to grant its Lambda
|
|
9
|
+
// `dynamodb:GetRecords` — took the arm that has no ARN to give and failed the
|
|
10
|
+
// deploy. The failure only surfaced once a plugin's first StateChangeSlice
|
|
11
|
+
// declared a `@storageRef` field, which is what registers the claimer at all.
|
|
12
|
+
|
|
13
|
+
let resolve = (output: Pulumi.Output.t<'a>): promise<'a> =>
|
|
14
|
+
Promise.make((resolve, _) => {
|
|
15
|
+
let _ = output->Pulumi.Output.apply(value => resolve(value))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
let streamArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000"
|
|
19
|
+
|
|
20
|
+
let tableResource = ReventlessInfra.Adapter.make(
|
|
21
|
+
~name="CatalogDcbEventLog-abc123"->Pulumi.Output.make,
|
|
22
|
+
~id="CatalogDcbEventLog-abc123"->Pulumi.Output.make,
|
|
23
|
+
~urn="arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123"->Pulumi.Output.make,
|
|
24
|
+
~service=AWS.DynamoDbStream.service->Pulumi.Output.make,
|
|
25
|
+
~resourceInfo=ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn})->Pulumi.Output.make,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
describe("Util_DynamoDbStream.toStreamResource", () => {
|
|
29
|
+
test("carries the source stream in resourceInfo", async () => {
|
|
30
|
+
let stream = tableResource->Util_DynamoDbStream.toStreamResource
|
|
31
|
+
let resourceInfo = await stream.resourceInfo->resolve
|
|
32
|
+
expect(resourceInfo)->toEqual(ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn}))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("answers its own stream ARN", async () => {
|
|
36
|
+
let arn =
|
|
37
|
+
await tableResource
|
|
38
|
+
->Util_DynamoDbStream.toStreamResource
|
|
39
|
+
->Util_DynamoDbStream.streamArnFromDynamoDbTableResource
|
|
40
|
+
->resolve
|
|
41
|
+
expect(arn)->toBe(streamArn)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Pulumi from "@pulumi/pulumi";
|
|
4
|
+
import * as AWS$ReventlessAws from "../src/adapter/AWS.res.mjs";
|
|
5
|
+
import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
|
|
6
|
+
import * as Util_DynamoDbStream$ReventlessAws from "../src/util/Util_DynamoDbStream.res.mjs";
|
|
7
|
+
|
|
8
|
+
function resolve(output) {
|
|
9
|
+
return new Promise((resolve, param) => {
|
|
10
|
+
output.apply(value => resolve(value));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let streamArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000";
|
|
15
|
+
|
|
16
|
+
let tableResource = Adapter$ReventlessInfra.make(Pulumi.output("CatalogDcbEventLog-abc123"), Pulumi.output("CatalogDcbEventLog-abc123"), Pulumi.output("arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123"), Pulumi.output(AWS$ReventlessAws.DynamoDbStream.service), Pulumi.output({
|
|
17
|
+
TAG: "StreamSource",
|
|
18
|
+
sourceUrn: streamArn
|
|
19
|
+
}), undefined, undefined, undefined, undefined, undefined);
|
|
20
|
+
|
|
21
|
+
globalThis.describe("Util_DynamoDbStream.toStreamResource", () => {
|
|
22
|
+
globalThis.test("carries the source stream in resourceInfo", async () => {
|
|
23
|
+
let stream = Util_DynamoDbStream$ReventlessAws.toStreamResource(tableResource);
|
|
24
|
+
let resourceInfo = await resolve(stream.resourceInfo);
|
|
25
|
+
globalThis.expect(resourceInfo).toEqual({
|
|
26
|
+
TAG: "StreamSource",
|
|
27
|
+
sourceUrn: streamArn
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
globalThis.test("answers its own stream ARN", async () => {
|
|
31
|
+
let arn = await resolve(Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(Util_DynamoDbStream$ReventlessAws.toStreamResource(tableResource)));
|
|
32
|
+
globalThis.expect(arn).toBe(streamArn);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
resolve,
|
|
38
|
+
streamArn,
|
|
39
|
+
tableResource,
|
|
40
|
+
}
|
|
41
|
+
/* tableResource Not a pure module */
|
|
@@ -81,6 +81,113 @@ describe("Util_ShellConfig.fields — bakedManifest", () => {
|
|
|
81
81
|
})
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
// ── Journeys ──────────────────────────────────────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// One curated surface per audience, beside the default one. The property under
|
|
87
|
+
// test throughout is that a deployment declaring none is untouched: every
|
|
88
|
+
// deployment that predates journeys has exactly one audience, and its
|
|
89
|
+
// config.json must not grow a key for a feature it does not use.
|
|
90
|
+
describe("Util_ShellConfig.fields — journeys", () => {
|
|
91
|
+
let withJourneys = (
|
|
92
|
+
~journeys: array<ReventlessInfra.Platform.bakedJourney>,
|
|
93
|
+
): ReventlessInfra.Platform.bakedManifest => {
|
|
94
|
+
components: [{plugin: "Catalog", views: ["Products"], commands: []}],
|
|
95
|
+
journeys,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let shopper: ReventlessInfra.Platform.bakedJourney = {
|
|
99
|
+
group: "Shopper",
|
|
100
|
+
components: [{plugin: "Catalog", views: ["Products"], commands: []}],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let fulfilment: ReventlessInfra.Platform.bakedJourney = {
|
|
104
|
+
group: "Fulfilment",
|
|
105
|
+
components: [{plugin: "Ordering", views: ["Orders"], commands: ["ShipOrder"]}],
|
|
106
|
+
key: "fulfilment.json",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
testSync("a bake declaring no journeys writes no map", () => {
|
|
110
|
+
let out = Util_ShellConfig.fields(
|
|
111
|
+
~computed,
|
|
112
|
+
~bakedManifest={components: [{plugin: "Catalog", views: ["Products"], commands: []}]},
|
|
113
|
+
)
|
|
114
|
+
expect(out->Dict.get("journeyManifestUrls")->Option.isNone)->toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
testSync("an empty journeys array is the same as none", () => {
|
|
118
|
+
let out = Util_ShellConfig.fields(~computed, ~bakedManifest=withJourneys(~journeys=[]))
|
|
119
|
+
expect(out->Dict.get("journeyManifestUrls")->Option.isNone)->toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// The default journey keeps `manifestUrl`, so a caller matching no declared
|
|
123
|
+
// group lands where every caller landed before.
|
|
124
|
+
testSync("keeps manifestUrl as the default journey", () => {
|
|
125
|
+
let out = Util_ShellConfig.fields(~computed, ~bakedManifest=withJourneys(~journeys=[shopper]))
|
|
126
|
+
expect(out->get("manifestUrl"))->toEqual(JSON.Encode.string("/component-manifest.json"))
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
testSync("maps each declared group to its own file", () => {
|
|
130
|
+
let out = Util_ShellConfig.fields(
|
|
131
|
+
~computed,
|
|
132
|
+
~bakedManifest=withJourneys(~journeys=[shopper, fulfilment]),
|
|
133
|
+
)
|
|
134
|
+
expect(out->get("journeyManifestUrls"))->toEqual(
|
|
135
|
+
JSON.Encode.object(
|
|
136
|
+
Dict.fromArray([
|
|
137
|
+
// Derived from the group, lower-cased, because a key is part of a URL.
|
|
138
|
+
("Shopper", JSON.Encode.string("/component-manifest-shopper.json")),
|
|
139
|
+
// Named explicitly, and the declaration wins.
|
|
140
|
+
("Fulfilment", JSON.Encode.string("/fulfilment.json")),
|
|
141
|
+
]),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// The URL a shell fetches and the key the bake writes are one string, derived
|
|
147
|
+
// once — a second derivation is a file written where nothing looks for it. So
|
|
148
|
+
// every key the bake writes is published, and the default one is published as
|
|
149
|
+
// `manifestUrl` rather than in the map.
|
|
150
|
+
testSync("publishes the URL of every key the bake writes, exactly once", () => {
|
|
151
|
+
let bake = withJourneys(~journeys=[shopper, fulfilment])
|
|
152
|
+
let out = Util_ShellConfig.fields(~computed, ~bakedManifest=bake)
|
|
153
|
+
let published = Array.concat(
|
|
154
|
+
out->get("manifestUrl")->JSON.Decode.string->Option.mapOr([], url => [url]),
|
|
155
|
+
out
|
|
156
|
+
->get("journeyManifestUrls")
|
|
157
|
+
->JSON.Decode.object
|
|
158
|
+
->Option.getOr(Dict.make())
|
|
159
|
+
->Dict.valuesToArray
|
|
160
|
+
->Array.filterMap(JSON.Decode.string),
|
|
161
|
+
)
|
|
162
|
+
expect(published)->toEqual(
|
|
163
|
+
ReventlessCore.Platform_BakedManifest.files(~config=bake)->Array.map(((key, _)) =>
|
|
164
|
+
"/" ++ key
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// A passthrough cannot redirect a key the deploy computes — the same rule
|
|
170
|
+
// `manifestUrl` already carries, extended to the map beside it.
|
|
171
|
+
testSync("a shellConfig journey map fails the deploy rather than redirecting it", () => {
|
|
172
|
+
let failure = try {
|
|
173
|
+
let _ = Util_ShellConfig.fields(
|
|
174
|
+
~computed,
|
|
175
|
+
~bakedManifest=withJourneys(~journeys=[shopper]),
|
|
176
|
+
~shellConfig=Dict.fromArray([
|
|
177
|
+
("journeyManifestUrls", JSON.Encode.object(Dict.make())),
|
|
178
|
+
]),
|
|
179
|
+
)
|
|
180
|
+
None
|
|
181
|
+
} catch {
|
|
182
|
+
| Failure(message) => Some(message)
|
|
183
|
+
}
|
|
184
|
+
switch failure {
|
|
185
|
+
| Some(message) => expect(message->String.includes("journeyManifestUrls"))->toBe(true)
|
|
186
|
+
| None => fail("a shellConfig key redirecting the computed journey map must fail the deploy")
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
84
191
|
describe("Util_ShellConfig.fields — shellConfig passthrough", () => {
|
|
85
192
|
testSync("shell-owned keys land verbatim, under the computed ones", () => {
|
|
86
193
|
let out = Util_ShellConfig.fields(
|