@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.
@@ -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 (~sub: string, ~poolId: string): array<string> => {
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: sub,
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(~sub, ~poolId=userPoolId())
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(sub, poolId) {
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: sub,
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(sub, userPoolId());
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->Pulumi.Output.apply(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 resource.resourceInfo.apply(resourceInfo => {
26
- if (typeof resourceInfo === "object" && resourceInfo.TAG === "StreamSource") {
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), undefined, undefined, undefined, Pulumi.output("aws:dynamodb:Stream"), undefined, undefined);
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(