@reventlessdev/reventless-aws 3.0.0-alpha.345 → 3.0.0-alpha.347

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +19 -12
  3. package/rescript.json +2 -1
  4. package/run-bake-manifest.mjs +3 -0
  5. package/run-deploy-app.mjs +3 -0
  6. package/run-provision-accounts.mjs +3 -0
  7. package/run-provision-admin.mjs +3 -0
  8. package/scripts/BakeManifest.res +395 -0
  9. package/scripts/BakeManifest.res.mjs +487 -0
  10. package/scripts/DeployApp.res +708 -0
  11. package/scripts/DeployApp.res.mjs +1015 -0
  12. package/scripts/DeployManifest.res +84 -0
  13. package/scripts/DeployManifest.res.mjs +112 -0
  14. package/scripts/ProvisionAccounts.res +339 -0
  15. package/scripts/ProvisionAccounts.res.mjs +351 -0
  16. package/scripts/ProvisionAdmin.res +275 -0
  17. package/scripts/ProvisionAdmin.res.mjs +289 -0
  18. package/scripts/ProvisionCognito.res +149 -0
  19. package/scripts/ProvisionCognito.res.mjs +98 -0
  20. package/scripts/ProvisionIdentity.res +76 -11
  21. package/scripts/ProvisionIdentity.res.mjs +72 -7
  22. package/scripts/ProvisionProvider.res +104 -0
  23. package/scripts/ProvisionProvider.res.mjs +123 -0
  24. package/scripts/PulumiCli.res +43 -0
  25. package/scripts/PulumiCli.res.mjs +106 -0
  26. package/src/Platform.res +51 -4
  27. package/src/Platform.res.mjs +32 -14
  28. package/src/Platform_Stack.res +119 -9
  29. package/src/Platform_Stack.res.mjs +42 -4
  30. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +1 -5
  31. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +1 -1
  32. package/src/adapter/Api/Platform_UIFragments_Lambda.res +1 -5
  33. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +1 -2
  34. package/src/adapter/Auth/Auth_ActiveRoleStore.res +1 -5
  35. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +1 -2
  36. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +1 -5
  37. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +1 -2
  38. package/src/adapter/Auth/Auth_LoginIdentifier.res +66 -0
  39. package/src/adapter/Auth/Auth_LoginIdentifier.res.mjs +70 -0
  40. package/src/adapter/Auth/Auth_SignUpMode.res +78 -0
  41. package/src/adapter/Auth/Auth_SignUpMode.res.mjs +56 -0
  42. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +52 -31
  43. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +75 -44
  44. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +1 -5
  45. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
  46. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +1 -5
  47. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +1 -2
  48. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +5 -4
  49. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +1 -1
  50. package/src/adapter/Postgres/PgMigration_Builder.res +1 -1
  51. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +1 -2
  52. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +5 -0
  53. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +5 -1
  54. package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res +2 -0
  55. package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res.mjs +2 -2
  56. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +15 -6
  57. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +51 -30
  58. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +31 -14
  59. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -5
  60. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
  61. package/src/adapter/StateTopic/StateTopic_AppSync.res +1 -5
  62. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +1 -1
  63. package/src/adapter/Task/TaskBucket_S3.res +1 -3
  64. package/src/adapter/Task/TaskBucket_S3.res.mjs +1 -1
  65. package/src/adapter/Upload/Upload_Claim_S3.res +1 -5
  66. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +1 -1
  67. package/src/adapter/Upload/Upload_Presign_S3.res +1 -5
  68. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -2
  69. package/src/capability/Capability_Geocoding_AwsLocation.res +5 -1
  70. package/src/capability/Capability_Geocoding_AwsLocation.res.mjs +7 -1
  71. package/src/components/Api/AppSync_Adapter.res +56 -72
  72. package/src/components/Api/AppSync_Adapter.res.mjs +26 -60
  73. package/src/components/Api/AppSync_SdlDecorate.res +82 -1
  74. package/src/components/Api/AppSync_SdlDecorate.res.mjs +45 -0
  75. package/src/plugin/cloner/ClonerRunner_Fargate.res +1 -5
  76. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +1 -1
  77. package/src/plugin/stack/Plugin_Stack.res +4 -0
  78. package/src/plugin/stack/Plugin_Stack.res.mjs +2 -0
  79. package/src/util/Util_DeadLetterQueue.res +1 -5
  80. package/src/util/Util_DeadLetterQueue.res.mjs +1 -2
  81. package/src/util/Util_StoreLayout.res +15 -2
  82. package/src/util/Util_StoreLayout.res.mjs +11 -3
  83. package/tests/AppSync_AdapterTest.res +83 -0
  84. package/tests/AppSync_AdapterTest.res.mjs +69 -0
  85. package/tests/Auth_LoginIdentifierTest.res +51 -0
  86. package/tests/Auth_LoginIdentifierTest.res.mjs +51 -0
  87. package/tests/Auth_SignUpModeTest.res +56 -0
  88. package/tests/Auth_SignUpModeTest.res.mjs +53 -0
  89. package/tests/BakeManifestTest.res +204 -0
  90. package/tests/BakeManifestTest.res.mjs +259 -0
  91. package/tests/DcbColdStartPartitionTest.res +32 -0
  92. package/tests/DcbColdStartPartitionTest.res.mjs +33 -0
  93. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +88 -0
  94. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +112 -0
  95. package/tests/DcbUnresolvedPartitionSlice.res +28 -0
  96. package/tests/DcbUnresolvedPartitionSlice.res.mjs +38 -0
  97. package/tests/DeployAppTest.res +90 -0
  98. package/tests/DeployAppTest.res.mjs +133 -0
  99. package/tests/LambdaLayerLookupTest.res +104 -0
  100. package/tests/LambdaLayerLookupTest.res.mjs +117 -0
  101. package/tests/ProvisionAccountsTest.res +168 -0
  102. package/tests/ProvisionAccountsTest.res.mjs +184 -0
  103. package/tests/ProvisionAdminTest.res +55 -0
  104. package/tests/ProvisionAdminTest.res.mjs +66 -0
  105. package/tests/ProvisionIdentityTest.res +89 -3
  106. package/tests/ProvisionIdentityTest.res.mjs +66 -1
  107. package/tests/Util_OwnerScopeEnvTest.res +25 -0
  108. package/tests/Util_OwnerScopeEnvTest.res.mjs +13 -0
  109. package/tests/Util_StoreLayoutTest.res +6 -0
  110. package/tests/Util_StoreLayoutTest.res.mjs +7 -4
  111. package/tests/dcbColdStartPartition.mjs +66 -0
  112. package/tests/setup/layerArn.cjs +5 -0
@@ -0,0 +1,84 @@
1
+ /***
2
+ An app's `deploy-manifest.yaml`: the folder holding the platform stack, and the
3
+ folders holding the plugin stacks, in the order they deploy.
4
+
5
+ The reusable deploy workflow reads it with `yq`; the commands in this folder read
6
+ it here. Paths in the file are relative to the file's own folder.
7
+
8
+ `stack-defaults` on an entry are the settings `deploy-app` gives a stack it
9
+ creates for that folder — what a new stack of this app cannot deploy without.
10
+ */
11
+
12
+ @module("yaml") external parseYaml: string => JSON.t = "parse"
13
+
14
+ @schema
15
+ type platform = {
16
+ path: string,
17
+ name?: string,
18
+ @as("stack-defaults") stackDefaults?: dict<string>,
19
+ }
20
+
21
+ @schema
22
+ type plugin = {
23
+ name: string,
24
+ path: string,
25
+ @as("depends-on") dependsOn?: array<string>,
26
+ @as("stack-defaults") stackDefaults?: dict<string>,
27
+ }
28
+
29
+ @schema
30
+ type t = {
31
+ region?: string,
32
+ platform: platform,
33
+ plugins?: array<plugin>,
34
+ }
35
+
36
+ /** One stack folder, with its path made absolute. */
37
+ type project = {name: string, dir: string, stackDefaults: dict<string>}
38
+
39
+ type resolved = {
40
+ file: string,
41
+ region: option<string>,
42
+ platform: project,
43
+ plugins: array<project>,
44
+ }
45
+
46
+ let defaultFile = "deploy-manifest.yaml"
47
+
48
+ let parseString = (text: string): result<t, string> =>
49
+ try Ok(S.parseOrThrow(parseYaml(text), ~to=schema)) catch {
50
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr("not a deploy manifest"))
51
+ | _ => Error("not a deploy manifest")
52
+ }
53
+
54
+ let resolve = (manifest: t, ~file: string): resolved => {
55
+ let base = NodePath.dirname(NodePath.resolve([file]))
56
+ {
57
+ file,
58
+ region: manifest.region,
59
+ platform: {
60
+ name: manifest.platform.name->Option.getOr("platform"),
61
+ dir: NodePath.resolve([base, manifest.platform.path]),
62
+ stackDefaults: manifest.platform.stackDefaults->Option.getOr(Dict.make()),
63
+ },
64
+ plugins: manifest.plugins
65
+ ->Option.getOr([])
66
+ ->Array.map(p => {
67
+ name: p.name,
68
+ dir: NodePath.resolve([base, p.path]),
69
+ stackDefaults: p.stackDefaults->Option.getOr(Dict.make()),
70
+ }),
71
+ }
72
+ }
73
+
74
+ let load = (file: string): result<resolved, string> =>
75
+ switch try Ok(NodeFs.readFileSync(file)) catch {
76
+ | _ => Error(`cannot read ${file}`)
77
+ } {
78
+ | Error(_) as e => e
79
+ | Ok(text) =>
80
+ switch parseString(text) {
81
+ | Error(message) => Error(`${file}: ${message}`)
82
+ | Ok(manifest) => Ok(manifest->resolve(~file))
83
+ }
84
+ }
@@ -0,0 +1,112 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Sury from "sury";
5
+ import * as Yaml from "yaml";
6
+ import * as Nodefs from "node:fs";
7
+ import * as Nodepath from "node:path";
8
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
9
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
+
12
+ let platformSchema = Sury.$schema(s => ({
13
+ path: s.m(Sury.string),
14
+ name: s.m(Sury.$option(Sury.string)),
15
+ "stack-defaults": s.m(Sury.$option(Sury.dict(Sury.string)))
16
+ }));
17
+
18
+ let pluginSchema = Sury.$schema(s => ({
19
+ name: s.m(Sury.string),
20
+ path: s.m(Sury.string),
21
+ "depends-on": s.m(Sury.$option(Sury.array(Sury.string))),
22
+ "stack-defaults": s.m(Sury.$option(Sury.dict(Sury.string)))
23
+ }));
24
+
25
+ let schema = Sury.$schema(s => ({
26
+ region: s.m(Sury.$option(Sury.string)),
27
+ platform: s.m(platformSchema),
28
+ plugins: s.m(Sury.$option(Sury.array(pluginSchema)))
29
+ }));
30
+
31
+ function parseString(text) {
32
+ try {
33
+ return {
34
+ TAG: "Ok",
35
+ _0: S.parseOrThrow(Yaml.parse(text), schema)
36
+ };
37
+ } catch (raw_err) {
38
+ let err = Primitive_exceptions.internalToException(raw_err);
39
+ if (err.RE_EXN_ID === "JsExn") {
40
+ return {
41
+ TAG: "Error",
42
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "not a deploy manifest")
43
+ };
44
+ } else {
45
+ return {
46
+ TAG: "Error",
47
+ _0: "not a deploy manifest"
48
+ };
49
+ }
50
+ }
51
+ }
52
+
53
+ function resolve(manifest, file) {
54
+ let base = Nodepath.dirname(Nodepath.resolve(file));
55
+ return {
56
+ file: file,
57
+ region: manifest.region,
58
+ platform: {
59
+ name: Stdlib_Option.getOr(manifest.platform.name, "platform"),
60
+ dir: Nodepath.resolve(base, manifest.platform.path),
61
+ stackDefaults: Stdlib_Option.getOr(manifest.platform["stack-defaults"], {})
62
+ },
63
+ plugins: Stdlib_Option.getOr(manifest.plugins, []).map(p => ({
64
+ name: p.name,
65
+ dir: Nodepath.resolve(base, p.path),
66
+ stackDefaults: Stdlib_Option.getOr(p["stack-defaults"], {})
67
+ }))
68
+ };
69
+ }
70
+
71
+ function load(file) {
72
+ let e;
73
+ try {
74
+ e = {
75
+ TAG: "Ok",
76
+ _0: Nodefs.readFileSync(file, "utf8")
77
+ };
78
+ } catch (exn) {
79
+ e = {
80
+ TAG: "Error",
81
+ _0: `cannot read ` + file
82
+ };
83
+ }
84
+ if (e.TAG !== "Ok") {
85
+ return e;
86
+ }
87
+ let message = parseString(e._0);
88
+ if (message.TAG === "Ok") {
89
+ return {
90
+ TAG: "Ok",
91
+ _0: resolve(message._0, file)
92
+ };
93
+ } else {
94
+ return {
95
+ TAG: "Error",
96
+ _0: file + `: ` + message._0
97
+ };
98
+ }
99
+ }
100
+
101
+ let defaultFile = "deploy-manifest.yaml";
102
+
103
+ export {
104
+ platformSchema,
105
+ pluginSchema,
106
+ schema,
107
+ defaultFile,
108
+ parseString,
109
+ resolve,
110
+ load,
111
+ }
112
+ /* platformSchema Not a pure module */
@@ -0,0 +1,339 @@
1
+ /***
2
+ Turn a declared cast of accounts into working sign-ins.
3
+
4
+ `provision-admin` makes the *first* administrator, which is the account that
5
+ cannot be made from a signed-in session because there is no signed-in session yet.
6
+ This one makes a declared list of accounts instead.
7
+
8
+ 🚨 **Neither needs the other to have run.** Both authenticate as the AWS caller,
9
+ not as a Cognito principal, so there is no bootstrap ordering between them: a
10
+ manifest declaring an entry in the administrator group stands a deployment up on
11
+ its own. The difference is where the credential ends up — `provision-admin` prints
12
+ one and keeps no copy, while this writes into the manifest, which is what
13
+ `pnpm run seed` reads. For a demo whose cast already names an administrator, this
14
+ one alone is enough.
15
+
16
+ ```
17
+ pnpm exec provision-accounts
18
+ ```
19
+
20
+ It reads `.reventless/users.yaml`, and knows nothing about any particular
21
+ application's cast: the manifest belongs to whoever is deploying. A demo's four
22
+ roles and a company's twelve staff are the same run. Which pool it writes to is
23
+ [ProvisionProvider]'s question, and usually needs no argument either.
24
+
25
+ 🚨 **Two halves, and only the second is AWS's.** [Reventless.AccountsManifest.prepare]
26
+ validates the manifest and mints a password into every empty field, on any
27
+ platform and with no credentials — locally that is the whole of provisioning,
28
+ because there the manifest *is* the user store. This script then applies the
29
+ prepared manifest to a Cognito pool. That first half is its own bin,
30
+ `prepare-accounts`, so a platform that never deploys to AWS can reach it without
31
+ depending on this package.
32
+
33
+ Re-running is safe and is the normal case — an operator adding one account to
34
+ four. A password already in the file is never replaced; see
35
+ [Reventless.AccountsManifest.applyFills] for why that rule is the whole of the
36
+ write-back's safety.
37
+ */
38
+
39
+ open AwsSdk
40
+
41
+ module Cognito = CognitoIdentityServiceProvider
42
+ module Manifest = Reventless.AccountsManifest
43
+
44
+ // ── Arguments ────────────────────────────────────────────────────────────────
45
+
46
+ type args = {
47
+ providerId: option<string>,
48
+ stack: option<string>,
49
+ file: option<string>,
50
+ help: bool,
51
+ }
52
+
53
+ /** Parsed rather than positional, and unknown flags are an error — the reason
54
+ [ProvisionAdmin.parseArgs] gives, which lands harder here: a typo'd
55
+ `--provider-id` would fall through to the environment and create the whole cast
56
+ in a *different pool* than the operator named. */
57
+ let parseArgs = (argv: array<string>): result<args, string> => {
58
+ let acc = ref(Ok({providerId: None, stack: None, file: None, help: false}))
59
+ let i = ref(0)
60
+ let count = argv->Array.length
61
+ while i.contents < count {
62
+ let flag = argv->Array.getUnsafe(i.contents)
63
+ let value = argv->Array.get(i.contents + 1)
64
+ switch (acc.contents, flag, value) {
65
+ | (Error(_), _, _) => i := count
66
+ | (Ok(a), "--provider-id", Some(v)) =>
67
+ acc := Ok({...a, providerId: Some(v)})
68
+ i := i.contents + 2
69
+ | (Ok(a), "--stack", Some(v)) =>
70
+ acc := Ok({...a, stack: Some(v)})
71
+ i := i.contents + 2
72
+ | (Ok(a), "--file", Some(v)) =>
73
+ acc := Ok({...a, file: Some(v)})
74
+ i := i.contents + 2
75
+ | (Ok(a), "--help", _) | (Ok(a), "-h", _) =>
76
+ acc := Ok({...a, help: true})
77
+ i := i.contents + 1
78
+ | (Ok(_), "--provider-id", None) | (Ok(_), "--file", None) | (Ok(_), "--stack", None) =>
79
+ acc := Error(`${flag} needs a value`)
80
+ | (Ok(_), unknown, _) => acc := Error(`unknown argument "${unknown}"`)
81
+ }
82
+ }
83
+ acc.contents
84
+ }
85
+
86
+ let usage = `
87
+ Turn a declared cast of accounts into working sign-ins.
88
+
89
+ --provider-id <id> The identity provider to create the accounts in. Usually
90
+ omitted: it falls back to ${ProvisionProvider.envKey},
91
+ then to the identityProviderId exported by the selected
92
+ Pulumi stack — which every deployment exports, whether it
93
+ created the pool or was handed one.
94
+ --stack <name> Read that output from this stack instead of the selected
95
+ one. The run always names the stack it used.
96
+ --file <path> The manifest. Defaults to .reventless/users.yaml relative
97
+ to the working directory, which is where both platforms
98
+ keep it.
99
+
100
+ For every entry: ensures the groups it names, ensures the account, sets a
101
+ permanent password, and applies the memberships. Generated passwords and the ids
102
+ the pool mints are written back into the manifest, so the seed client can read
103
+ them without anyone retyping a UUID.
104
+
105
+ A password already in the manifest is never replaced. Region and credentials come
106
+ from the environment, as for any AWS SDK call.
107
+ `
108
+
109
+ // ── Preflight ────────────────────────────────────────────────────────────────
110
+
111
+ let looksLikeEmail = (username: string): bool =>
112
+ username->String.includes("@") && username->String.includes(".")
113
+
114
+ /**
115
+ Refuse a pool this cast could never sign in to.
116
+
117
+ The mirror of [ProvisionAdmin.checkPoolAcceptsEmail], and it has to run the other
118
+ way round: that script is handed an address and asks whether the pool takes one,
119
+ while a manifest usually names plain usernames — `shopper`, `merch` — and the
120
+ question is whether the pool will accept *those*. A pool created with
121
+ `UsernameAttributes: [email]` will not, and the accounts it makes instead are
122
+ correctly created and unable to authenticate.
123
+
124
+ Checked for every entry before anything is created or written, so a manifest that
125
+ cannot work costs nothing rather than half a cast.
126
+ */
127
+ let checkPoolAcceptsUsernames = async (~providerId: string, ~usernames: array<string>): result<
128
+ unit,
129
+ string,
130
+ > =>
131
+ switch await ProvisionCognito.usernameAttributes(~providerId) {
132
+ | Error(_) as e => e
133
+ | Ok(attributes) =>
134
+ let signsInOnEmail = attributes->Array.includes("email")
135
+ let unusable = signsInOnEmail ? usernames->Array.filter(u => !looksLikeEmail(u)) : []
136
+ if unusable->Array.length == 0 {
137
+ Console.log(
138
+ `pool signs in on ${attributes->Array.length == 0
139
+ ? "username"
140
+ : attributes->Array.join(", ")}`,
141
+ )
142
+ Ok()
143
+ } else {
144
+ Error(
145
+ `pool "${providerId}" signs in on ${attributes->Array.join(
146
+ ", ",
147
+ )}, so ${unusable->Array.join(
148
+ ", ",
149
+ )} could never authenticate. The sign-in attribute is fixed at pool creation and no update can change it — either name these accounts by address in the manifest, or provision them in a pool that signs in on a username.`,
150
+ )
151
+ }
152
+ }
153
+
154
+ // ── Applying ─────────────────────────────────────────────────────────────────
155
+
156
+ let describe = (outcome: ProvisionCognito.outcome) =>
157
+ switch outcome {
158
+ | Created => "created"
159
+ | AlreadyPresent => "already present"
160
+ }
161
+
162
+ /** Every group the manifest names, made once rather than once per member.
163
+
164
+ The description says where the group came from because a pool outlives the
165
+ deployment that seeded it, and an operator meeting an undocumented group in the
166
+ console has no other way to find out. */
167
+ let ensureGroups = async (~providerId: string, ~entries: array<Manifest.entry>): unit => {
168
+ let named = []
169
+ entries->Array.forEach(entry =>
170
+ entry.groups->Array.forEach(group =>
171
+ if !(named->Array.includes(group)) {
172
+ named->Array.push(group)
173
+ }
174
+ )
175
+ )
176
+ for index in 0 to named->Array.length - 1 {
177
+ let group = named->Array.getUnsafe(index)
178
+ let outcome = await ProvisionCognito.ensureGroup(
179
+ ~providerId,
180
+ ~group,
181
+ ~description="Declared in a Reventless accounts manifest",
182
+ )
183
+ Console.log(`group ${group} (${outcome->describe})`)
184
+ }
185
+ }
186
+
187
+ /**
188
+ One account: the entry, applied.
189
+
190
+ The password is set unconditionally rather than only for an account this run
191
+ created — the same reason `provision-admin` does it, arriving at a manifest: the
192
+ file is the record of what the credential *is*, so an account whose password
193
+ drifted from the file is exactly the case a re-run is expected to fix.
194
+
195
+ Returns the id the pool minted, which is the value the manifest cannot know until
196
+ the account exists and which nobody should be pasting by hand.
197
+ */
198
+ let applyEntry = async (~providerId: string, ~entry: Manifest.entry): option<string> => {
199
+ let attributes: array<Cognito.AdminCreateUserCommand.attributeType> = looksLikeEmail(
200
+ entry.username,
201
+ )
202
+ ? [{name: "email", value: entry.username}, {name: "email_verified", value: "true"}]
203
+ : []
204
+ let outcome = await ProvisionCognito.ensureUser(
205
+ ~providerId,
206
+ ~username=entry.username,
207
+ ~attributes,
208
+ )
209
+ Console.log(`user ${entry.username} (${outcome->describe})`)
210
+ await ProvisionCognito.setPassword(
211
+ ~providerId,
212
+ ~username=entry.username,
213
+ ~password=entry.password,
214
+ )
215
+ for index in 0 to entry.groups->Array.length - 1 {
216
+ let group = entry.groups->Array.getUnsafe(index)
217
+ await ProvisionCognito.addToGroup(~providerId, ~username=entry.username, ~group)
218
+ Console.log(`member ${entry.username} in ${group}`)
219
+ }
220
+ await ProvisionCognito.subOf(~providerId, ~username=entry.username)
221
+ }
222
+
223
+ // ── Main ─────────────────────────────────────────────────────────────────────
224
+
225
+ /**
226
+ 🚨 **The passwords are in the manifest, not in this output, and that is the whole
227
+ point of the write-back.**
228
+
229
+ `provision-admin` prints its one credential because there is nowhere else for it
230
+ to live. A cast of four has somewhere: the file the seed client already reads to
231
+ sign in. Printing them here would put every account's password in scrollback and
232
+ in any build log, and buy nothing — whoever needs them can read the file they
233
+ already have.
234
+ */
235
+ let manifestNote = (~file: string) =>
236
+ `
237
+ 🚨 Generated passwords were written into ${file}. They are not printed here
238
+ because that file is where they are read from — by you and by \`pnpm run seed\`.
239
+ It is gitignored on every platform; keep it that way. These are bootstrap
240
+ credentials for a deployment, not secrets to reuse anywhere real.`
241
+
242
+ /**
243
+ The manifest at `file`, applied to the pool `providerId`.
244
+
245
+ 🚨 **The pool is checked before anything is written.** Preparing mints passwords
246
+ into the file; doing that first left a refused run with a manifest offering
247
+ accounts that exist nowhere, which the seed then tried to sign in as. `checkPool`
248
+ is a parameter so a test can refuse without a pool.
249
+ */
250
+ let provision = async (
251
+ ~file: string,
252
+ ~providerId: string,
253
+ ~checkPool=checkPoolAcceptsUsernames,
254
+ ): result<unit, string> =>
255
+ switch Manifest.parseFile(file) {
256
+ | Error(message) => Error(`${file}: ${message}`)
257
+ | Ok([]) => Error(`${file} declares no accounts`)
258
+ | Ok(declared) =>
259
+ switch await checkPool(~providerId, ~usernames=declared->Array.map(e => e.username)) {
260
+ | Error(_) as e => e
261
+ | Ok() =>
262
+ switch Manifest.prepare(~path=file) {
263
+ | Error(message) => Error(`${file}: ${message}`)
264
+ | Ok(prepared) =>
265
+ let generated = prepared->Array.filter(p => p.passwordGenerated)->Array.length
266
+ Console.log(
267
+ `manifest ${file} (${prepared
268
+ ->Array.length
269
+ ->Int.toString} accounts, ${generated->Int.toString} password(s) generated)`,
270
+ )
271
+ let entries = prepared->Array.map(p => p.entry)
272
+ await ensureGroups(~providerId, ~entries)
273
+ let fills = []
274
+ for index in 0 to entries->Array.length - 1 {
275
+ let entry = entries->Array.getUnsafe(index)
276
+ let userId = await applyEntry(~providerId, ~entry)
277
+ fills->Array.push({Manifest.index, password: None, userId})
278
+ }
279
+ switch Manifest.fillFile(~path=file, ~fills) {
280
+ | Error(message) => Error(`${file}: ${message}`)
281
+ | Ok(report) =>
282
+ let corrected = report->Array.filter(r => r.userIdWritten)->Array.length
283
+ Console.log(
284
+ `manifest ${file} (${corrected->Int.toString} id(s) written back)${manifestNote(
285
+ ~file,
286
+ )}`,
287
+ )
288
+ Ok()
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ let run = async (): result<unit, string> =>
295
+ // argv[0] is node, argv[1] this script.
296
+ switch parseArgs(NodeProcess.argv->Array.slice(~start=2, ~end=NodeProcess.argv->Array.length)) {
297
+ | Error(_) as e => e
298
+ | Ok(args) if args.help =>
299
+ Console.log(usage)
300
+ Ok()
301
+ | Ok(args) =>
302
+ switch Manifest.locate(~given=?args.file, ()) {
303
+ | Error(_) as e => e
304
+ | Ok(located) =>
305
+ let file = located->Manifest.pathOf
306
+ switch located {
307
+ | SeededFrom(_, template) => Console.log(`manifest ${file} (new, copied from ${template})`)
308
+ | Declared(_) => ()
309
+ }
310
+ switch ProvisionProvider.resolve(~given=args.providerId, ~stack=args.stack) {
311
+ | Error(message) =>
312
+ Error(`${message}. To fill in the manifest without creating anything, run prepare-accounts`)
313
+ | Ok((providerId, source)) =>
314
+ Console.log(`provider ${providerId} (from ${source->ProvisionProvider.describe})`)
315
+ await provision(~file, ~providerId)
316
+ }
317
+ }
318
+ }
319
+
320
+ /**
321
+ 🚨 **Catches thrown exceptions, not only `Error` results** — the reason
322
+ [ProvisionAdmin.main] gives. Without it an SDK failure escapes an async function
323
+ nothing awaits and Node reports `UnhandledPromiseRejection ... "#<Object>"`,
324
+ naming neither the call that failed nor why.
325
+ */
326
+ let main = async () =>
327
+ switch await run() {
328
+ | Ok() => ()
329
+ | Error(message) =>
330
+ Console.error(`provision-accounts: ${message}`)
331
+ NodeProcess.exit(1)
332
+ | exception exn =>
333
+ Console.error(`provision-accounts: ${Util_AwsError.describe(exn)}`)
334
+ NodeProcess.exit(1)
335
+ }
336
+
337
+ // 🚨 **No top-level call.** `../run-provision-accounts.mjs` invokes [main]; this
338
+ // module only defines it. A module that ran itself on import could not be
339
+ // imported by a test.