@geekmidas/cloud 1.1.1 → 10.0.0-alpha.0

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 (63) hide show
  1. package/dist/{index-ByEJy40r.d.cts → index-B5CZ1xVf.d.cts} +17 -9
  2. package/dist/index-B5CZ1xVf.d.cts.map +1 -0
  3. package/dist/{index-DZ0QrJQr.d.mts → index-DhHRjduZ.d.mts} +17 -9
  4. package/dist/index-DhHRjduZ.d.mts.map +1 -0
  5. package/dist/index.cjs +1 -1
  6. package/dist/index.d.cts +1 -1
  7. package/dist/index.d.mts +1 -1
  8. package/dist/index.mjs +1 -1
  9. package/dist/utils/index.cjs +1 -1
  10. package/dist/utils/index.d.cts +1 -1
  11. package/dist/utils/index.d.mts +1 -1
  12. package/dist/utils/index.mjs +1 -1
  13. package/dist/{utils-CtMjuIMR.cjs → utils-B1a2UuEO.cjs} +5 -5
  14. package/dist/utils-B1a2UuEO.cjs.map +1 -0
  15. package/dist/{utils-BdKG20_m.mjs → utils-DMOXJ27j.mjs} +5 -5
  16. package/dist/utils-DMOXJ27j.mjs.map +1 -0
  17. package/package.json +44 -7
  18. package/src/dokploy/Application.ts +259 -0
  19. package/src/dokploy/__tests__/Application.spec.ts +69 -0
  20. package/src/dokploy/index.ts +18 -0
  21. package/src/sst/__tests__/LinkedEnvironment.spec.ts +4 -1
  22. package/src/sst/__tests__/backends.spec.ts +249 -0
  23. package/src/sst/__tests__/bootstrap.spec.ts +140 -0
  24. package/src/sst/__tests__/database.spec.ts +119 -0
  25. package/src/sst/__tests__/fromManifest.spec.ts +266 -0
  26. package/src/sst/__tests__/provides.spec.ts +107 -0
  27. package/src/sst/__tests__/ses.spec.ts +93 -0
  28. package/src/sst/__tests__/surfaces.spec.ts +132 -0
  29. package/src/sst/__type-tests__/authorizers.type-test.ts +2 -2
  30. package/src/sst/__type-tests__/manifest.type-test.ts +3 -3
  31. package/src/sst/__type-tests__/messaging.type-test.ts +3 -3
  32. package/src/sst/__type-tests__/storage.type-test.ts +2 -2
  33. package/src/sst/{Api.ts → aws/Api.ts} +3 -3
  34. package/src/sst/aws/Cache.ts +259 -0
  35. package/src/sst/aws/Credential.ts +28 -0
  36. package/src/sst/{Cron.ts → aws/Cron.ts} +2 -2
  37. package/src/sst/aws/Database.ts +190 -0
  38. package/src/sst/aws/DatabaseBootstrap.ts +309 -0
  39. package/src/sst/aws/DerivedDatabase.ts +117 -0
  40. package/src/sst/aws/Email.ts +181 -0
  41. package/src/sst/aws/FileServer.ts +82 -0
  42. package/src/sst/{Function.ts → aws/Function.ts} +3 -3
  43. package/src/sst/aws/ObjectStorage.ts +76 -0
  44. package/src/sst/aws/Queue.ts +116 -0
  45. package/src/sst/aws/RestApiSurface.ts +92 -0
  46. package/src/sst/aws/Secret.ts +76 -0
  47. package/src/sst/aws/StaticSite.ts +92 -0
  48. package/src/sst/{Storage.ts → aws/Storage.ts} +3 -3
  49. package/src/sst/aws/Topic.ts +68 -0
  50. package/src/sst/aws/bootstrap/handler.ts +110 -0
  51. package/src/sst/aws/ses.ts +132 -0
  52. package/src/sst/errors.ts +65 -0
  53. package/src/sst/fromManifest.ts +899 -0
  54. package/src/sst/index.ts +62 -7
  55. package/src/sst/naming.ts +37 -16
  56. package/src/sst/tsconfig.json +2 -2
  57. package/src/sst/upstash.d.ts +38 -0
  58. package/dist/index-ByEJy40r.d.cts.map +0 -1
  59. package/dist/index-DZ0QrJQr.d.mts.map +0 -1
  60. package/dist/utils-BdKG20_m.mjs.map +0 -1
  61. package/dist/utils-CtMjuIMR.cjs.map +0 -1
  62. package/src/sst/Queue.ts +0 -46
  63. package/src/sst/Topic.ts +0 -37
@@ -0,0 +1,309 @@
1
+ import * as aws from '@pulumi/aws';
2
+ import { secretsmanager } from '@pulumi/aws';
3
+ import { RandomPassword } from '@pulumi/random';
4
+ import type { BootstrapTenant } from './bootstrap/handler';
5
+ import type { Database } from './Database';
6
+
7
+ /**
8
+ * The roles a tenant needs, provisioned — and the function that creates them.
9
+ *
10
+ * Two halves that only make sense together. Pulumi can generate a password and
11
+ * store it; it cannot run `CREATE ROLE`. So the passwords are resources here and
12
+ * the DDL is a Lambda invoked once per deploy, inside the VPC, connecting as the
13
+ * cluster master — the only credential that exists before any role does.
14
+ *
15
+ * **One secret for the cluster, holding every role in it.** The obvious worry —
16
+ * that a shared secret lets anything holding it connect as any role — describes
17
+ * a read that does not happen. No function fetches a credential: each node's URL
18
+ * is carried in its own link (`getSSTLink` publishes `provides()` and nothing
19
+ * else), so a handler is *given* exactly its own role and has no IAM to read
20
+ * Secrets Manager at all. Splitting the secret would not narrow runtime
21
+ * privilege, because runtime never touches it.
22
+ *
23
+ * What the secret is actually for is out-of-band access — a person with
24
+ * break-glass, an external tool, a rotation job. That is where the case for
25
+ * splitting it lives, and it is a real one: per-tenant secrets are what let IAM
26
+ * grant somebody the reporting role's password without also handing them the
27
+ * auth schema's. Nothing here needs that yet, and one secret is one resource and
28
+ * one place to look, so it stays one until somebody has that second reader.
29
+ *
30
+ * :::caution
31
+ * Not verified against a live deploy. The shape is sound and every decision in
32
+ * it is asserted as data, but no stack has come up.
33
+ * :::
34
+ */
35
+ export class DatabaseBootstrap {
36
+ /** The generated passwords, by role name. */
37
+ private readonly passwords = new Map<string, $util.Output<string>>();
38
+
39
+ /**
40
+ * The cluster's credentials, for out-of-band use.
41
+ *
42
+ * Created by `run()` rather than by `add()`, because it holds every tenant
43
+ * and there is no every-tenant until the last one is added.
44
+ */
45
+ secret?: secretsmanager.Secret;
46
+
47
+ private readonly tenants: BootstrapTenant[] = [];
48
+
49
+ constructor(
50
+ private readonly name: string,
51
+ private readonly cluster: Database,
52
+ ) {}
53
+
54
+ /**
55
+ * Add a tenant to bootstrap, and provision its credentials.
56
+ *
57
+ * Returns the runtime password so the tenant's URL can carry it. The value is
58
+ * a Pulumi output either way — it exists in state whether or not it is also
59
+ * in Secrets Manager — so putting it in the URL adds no exposure the link did
60
+ * not already have.
61
+ */
62
+ add(tenant: {
63
+ id: string;
64
+ schema: string;
65
+ runtime: string;
66
+ owner: string;
67
+ reader?: string;
68
+ }): { runtime: $util.Output<string>; reader?: $util.Output<string> } {
69
+ const runtime = this.password(tenant.runtime);
70
+ const reader = tenant.reader ? this.password(tenant.reader) : undefined;
71
+
72
+ this.tenants.push({
73
+ id: tenant.id,
74
+ schema: tenant.schema,
75
+ runtime: tenant.runtime,
76
+ owner: tenant.owner,
77
+ ...(tenant.reader ? { reader: tenant.reader } : {}),
78
+ // Filled in by `bootstrapEvent` once the outputs resolve. The function
79
+ // reads them straight out of its input and never fetches a secret, so
80
+ // it needs no IAM to read one.
81
+ passwords: { runtime: '', owner: '' },
82
+ });
83
+
84
+ return { runtime, ...(reader ? { reader } : {}) };
85
+ }
86
+
87
+ /**
88
+ * The event this bootstrap should be invoked with.
89
+ *
90
+ * Kept as data rather than performed here, so the caller decides *when* — and
91
+ * so the decision is assertable without Pulumi. Feed it to a function
92
+ * invocation whose input changes when this changes, which is what makes a
93
+ * re-deploy re-apply only when something actually moved.
94
+ */
95
+ event(): $util.Output<string> {
96
+ // Every password, flat, so one `all` resolves them and the shape is put
97
+ // back together by a pure function that can be asserted without Pulumi.
98
+ const roles = [...this.passwords.keys()];
99
+
100
+ return $util
101
+ .all([
102
+ this.cluster.host as $util.Input<unknown>,
103
+ this.cluster.port,
104
+ this.cluster.database,
105
+ this.cluster.username,
106
+ this.cluster.password,
107
+ ...roles.map((role) => this.passwords.get(role)!),
108
+ ] as $util.Input<unknown>[])
109
+ .apply((resolved) => {
110
+ const [host, port, database, username, password, ...secrets] =
111
+ resolved as unknown as [
112
+ string,
113
+ number,
114
+ string,
115
+ string,
116
+ string,
117
+ ...string[],
118
+ ];
119
+
120
+ return bootstrapEvent(
121
+ { host, port, database, username, password },
122
+ this.tenants,
123
+ new Map(roles.map((role, index) => [role, secrets[index] as string])),
124
+ );
125
+ });
126
+ }
127
+
128
+ /**
129
+ * The read-only credential for a runtime role, once its tenant is added.
130
+ *
131
+ * Returns nothing where no reader was provisioned — a reader role is created
132
+ * only where something points at one, so asking for one that was never asked
133
+ * for is a question with a real answer rather than an error.
134
+ */
135
+ readerFor(
136
+ runtime: string,
137
+ ): { user: string; password: $util.Output<string> } | undefined {
138
+ const role = `${runtime}_reader`;
139
+ const password = this.passwords.get(role);
140
+
141
+ return password ? { user: role, password } : undefined;
142
+ }
143
+
144
+ /** Whether anything needs bootstrapping at all. */
145
+ get empty(): boolean {
146
+ return this.tenants.length === 0;
147
+ }
148
+
149
+ /**
150
+ * Create the function and invoke it once for this deploy.
151
+ *
152
+ * The invocation's input is `event()`, so it re-runs when — and only when —
153
+ * the roles, the schemas, or the cluster's address actually change. A deploy
154
+ * that touched neither costs nothing, which is what makes running this on
155
+ * every deploy acceptable rather than something to remember.
156
+ *
157
+ * @param vpc the database's VPC. The function has to reach the database, and
158
+ * a database reachable from outside its VPC is the problem this avoids.
159
+ *
160
+ * The component rather than a component's `vpc` argument: a function in a VPC
161
+ * needs subnets *and* security groups, and the two database components spell
162
+ * their argument differently — a cluster asks for both, an instance asks only
163
+ * for subnets. Naming the shared thing avoids inheriting whichever spelling
164
+ * the database happens to use, which is how this came to be typed as
165
+ * something that could not carry a security group.
166
+ */
167
+ run(vpc: sst.aws.Vpc): void {
168
+ if (this.empty) return;
169
+
170
+ this.storeCredentials();
171
+
172
+ const fn = new sst.aws.Function(`${this.name}Bootstrap`, {
173
+ // Shipped by this package rather than by the application: the DDL is
174
+ // the framework's, and an app that had to carry a bootstrap handler
175
+ // could get it wrong.
176
+ handler: HANDLER,
177
+ runtime: 'nodejs24.x',
178
+ // Long enough for a database that is still coming up to answer —
179
+ // `min: 0 ACU` means the first connection of the day pays for a
180
+ // resume, and timing out there would leave a half-bootstrapped schema.
181
+ timeout: '5 minutes',
182
+ vpc,
183
+ nodejs: { install: ['pg'] },
184
+ });
185
+
186
+ new aws.lambda.Invocation(`${this.name}BootstrapRun`, {
187
+ functionName: fn.name,
188
+ input: this.event(),
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Write every role's password to one secret, for out-of-band use.
194
+ *
195
+ * Nothing at runtime reads this — a handler is given its own URL through its
196
+ * link — so this exists for a person with break-glass, an external tool, or a
197
+ * rotation job. Keyed by role name rather than by tenant, because a role name
198
+ * is what somebody at a `psql` prompt actually has in hand.
199
+ *
200
+ * Note what this is *not*: the source of truth. The passwords are generated
201
+ * here and this records them. Making the secret authoritative — read at
202
+ * deploy, so rotating it in Secrets Manager propagates on the next deploy —
203
+ * is a better rotation story and a different design: it needs a first-deploy
204
+ * seed, and changing a value there does not change the role until the DDL
205
+ * runs again.
206
+ */
207
+ private storeCredentials(): void {
208
+ this.secret = new secretsmanager.Secret(`${this.name}Credentials`, {
209
+ // A name somebody can find. `namePrefix` was the first attempt and it
210
+ // produced `KitchenSink/roles/20260830202841251200000001` — unique,
211
+ // and useless to the person doing break-glass at 3am, which is the
212
+ // only reason this secret exists.
213
+ name: `${$app.name}/${$app.stage}/${this.name}/roles`,
214
+ description: `Database roles for ${this.name} (${$app.stage})`,
215
+ // Rotating a password *updates* this secret — a new version on the
216
+ // same resource — so none of this applies to normal operation. It
217
+ // applies to one flow: tearing a stage down and standing it back up.
218
+ // Secrets Manager holds a deleted secret for a recovery window and
219
+ // keeps its name reserved for the duration, so the recreate fails on
220
+ // a name pointing at something nobody can see. That flow is what a
221
+ // disposable stage *is*, so those waive the window; a retained app
222
+ // keeps the default, because there the recovery window is the point.
223
+ ...($app.removal === 'remove' ? { recoveryWindowInDays: 0 } : {}),
224
+ });
225
+
226
+ const roles = [...this.passwords.keys()];
227
+
228
+ new secretsmanager.SecretVersion(`${this.name}CredentialsValue`, {
229
+ secretId: this.secret.id,
230
+ secretString: $util
231
+ .all(roles.map((role) => this.passwords.get(role)!))
232
+ .apply((values) =>
233
+ JSON.stringify(
234
+ Object.fromEntries(
235
+ roles.map((role, index) => [role, values[index]]),
236
+ ),
237
+ ),
238
+ ),
239
+ });
240
+ }
241
+
242
+ private password(role: string): $util.Output<string> {
243
+ const existing = this.passwords.get(role);
244
+ if (existing) return existing;
245
+
246
+ const generated = new RandomPassword(`${this.name}${role}Password`, {
247
+ length: 32,
248
+ // Postgres accepts these in a password; a `'` would need escaping in
249
+ // DDL and a `/` breaks a URL's authority section, so neither is worth
250
+ // the risk for entropy this module has plenty of.
251
+ special: true,
252
+ overrideSpecial: '-_',
253
+ }).result;
254
+
255
+ this.passwords.set(role, generated);
256
+
257
+ return generated;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Where the bootstrap handler lives, from the application's root.
263
+ *
264
+ * A path into this package's own published files, so an app that installed
265
+ * `@geekmidas/cloud` gets the handler without copying it — and so a fix to the
266
+ * DDL reaches every app on the next upgrade rather than the next time somebody
267
+ * remembers.
268
+ *
269
+ * `src/`, not `dist/`: `src/sst/**` is published as raw TypeScript on purpose,
270
+ * because it extends ambient globals that only exist inside a consuming SST
271
+ * app. SST bundles the handler with esbuild at deploy, which is the same thing
272
+ * it does for the application's own handlers.
273
+ */
274
+ const HANDLER =
275
+ 'node_modules/@geekmidas/cloud/src/sst/aws/bootstrap/handler.handler';
276
+
277
+ /**
278
+ * The bootstrap's input, assembled from resolved values.
279
+ *
280
+ * Pure, and separate from the component for the reason every decision in this
281
+ * package is: composing it needs no Pulumi, so what the function will be handed
282
+ * can be asserted without a deploy — including that it is exactly what
283
+ * `roleStatements` accepts, which is the contract between the two halves.
284
+ */
285
+ export function bootstrapEvent(
286
+ master: {
287
+ host: string;
288
+ port: number;
289
+ database: string;
290
+ username: string;
291
+ password: string;
292
+ },
293
+ tenants: readonly BootstrapTenant[],
294
+ passwords: ReadonlyMap<string, string>,
295
+ ): string {
296
+ return JSON.stringify({
297
+ master,
298
+ tenants: tenants.map((tenant) => ({
299
+ ...tenant,
300
+ passwords: {
301
+ runtime: passwords.get(tenant.runtime) ?? '',
302
+ owner: passwords.get(tenant.owner) ?? '',
303
+ ...(tenant.reader
304
+ ? { reader: passwords.get(tenant.reader) ?? '' }
305
+ : {}),
306
+ },
307
+ })),
308
+ });
309
+ }
@@ -0,0 +1,117 @@
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { Database } from './Database';
3
+
4
+ /**
5
+ * The two database kinds that provision nothing: a reader, and a schema tenant.
6
+ *
7
+ * Neither is an AWS resource. A reader is an endpoint the cluster already has,
8
+ * and a tenant is a schema *inside* the parent's database, created by DDL that
9
+ * `gkm` runs rather than by anything Pulumi declares. What they contribute is a
10
+ * URL — a different endpoint, or the same one with a different `search_path` —
11
+ * and the identity that lets an edge point at them.
12
+ *
13
+ * That makes them the first `Provisioned`s that wrap no component. They are
14
+ * still linkable, because a function depending on one still needs its URL
15
+ * injected, and the link is what carries it.
16
+ */
17
+ abstract class DerivedDatabase implements GkmLinkable {
18
+ readonly _id: string;
19
+
20
+ constructor(
21
+ id: string,
22
+ /**
23
+ * Public because `fromManifest` walks it: a tenant may derive from another
24
+ * tenant, and what both ultimately need is the cluster underneath.
25
+ */
26
+ readonly parent: Database,
27
+ ) {
28
+ this._id = id;
29
+ }
30
+
31
+ abstract get _type(): ResourceType;
32
+ abstract provides(): Record<string, $util.Input<string>>;
33
+
34
+ /**
35
+ * A link carrying only this node's own URL.
36
+ *
37
+ * Deliberately *not* the parent's link. A handler depending on the reader
38
+ * should receive the reader's URL and no other, which is the same
39
+ * least-privilege rule edges get everywhere else — inheriting the parent's
40
+ * properties would hand it the writer's address under a second key.
41
+ */
42
+ getSSTLink() {
43
+ return { properties: { ...this.provides() } };
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A read-only endpoint on an existing cluster.
49
+ *
50
+ * Nothing is provisioned. An RDS instance has one endpoint, so this resolves to
51
+ * the same address the writer uses — which is safe rather than a silently
52
+ * writable connection behind a name that says reader, because read-only is
53
+ * enforced by the role's grants and never by which endpoint was reached.
54
+ */
55
+ export class DatabaseReader extends DerivedDatabase {
56
+ constructor(
57
+ id: string,
58
+ parent: Database,
59
+ /**
60
+ * The read-only role, where one was provisioned.
61
+ *
62
+ * Absent under `roles: false`, where there is one credential and it is
63
+ * the master's — and read-only then holds by convention rather than by
64
+ * grant, which is exactly why that mode is a downgrade.
65
+ */
66
+ private readonly role?: { user: string; password: $util.Input<string> },
67
+ ) {
68
+ super(id, parent);
69
+ }
70
+
71
+ get _type() {
72
+ return ResourceType.SSTPostgres;
73
+ }
74
+
75
+ provides(): Record<string, $util.Input<string>> {
76
+ return {
77
+ url: this.parent.urlFor({
78
+ reader: true,
79
+ ...(this.role ? { as: this.role } : {}),
80
+ }),
81
+ };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * A second schema in the parent's database, with its own URL.
87
+ *
88
+ * The schema and its role are created by DDL, so what this contributes is the
89
+ * parent's connection pinned to a different `search_path`. Through the codec,
90
+ * because `?search_path=` is not a libpq parameter: a URL carrying it is
91
+ * accepted by every parser, ignored by the server, and produces a database that
92
+ * looks empty.
93
+ */
94
+ export class DatabaseSchema extends DerivedDatabase {
95
+ constructor(
96
+ id: string,
97
+ parent: Database,
98
+ private readonly schema: string,
99
+ /** The tenant's runtime role, where one was provisioned. */
100
+ private readonly role?: { user: string; password: $util.Input<string> },
101
+ ) {
102
+ super(id, parent);
103
+ }
104
+
105
+ get _type() {
106
+ return ResourceType.SSTPostgres;
107
+ }
108
+
109
+ provides(): Record<string, $util.Input<string>> {
110
+ return {
111
+ url: this.parent.urlFor({
112
+ schema: this.schema,
113
+ ...(this.role ? { as: this.role } : {}),
114
+ }),
115
+ };
116
+ }
117
+ }
@@ -0,0 +1,181 @@
1
+ import { iam } from '@pulumi/aws';
2
+ import { type GkmLinkable, ResourceType } from '../Linkable';
3
+ import type { StackType } from '../Stack';
4
+ import { sesSmtpUrl } from './ses';
5
+
6
+ /**
7
+ * `Email` — outbound mail, and the infra half of the `email` kind.
8
+ *
9
+ * The kind whose backends differ least. Every one of them speaks SMTP, so the
10
+ * declaration's `smtp://` URL is true of all of them and the client never
11
+ * changes — Mailpit locally, Resend or SES or somebody's relay deployed. What
12
+ * differs is only who issues the credential, which is why the backend is config
13
+ * rather than a field on the construct.
14
+ *
15
+ * Two of the three are barely a component: `resend` and `smtp` compose a URL
16
+ * from a value you already hold. Only `ses` provisions anything, and it
17
+ * provisions a *chain* — an identity, a user, an access key, and a password
18
+ * derived from that key — because SES does not issue SMTP passwords, it issues
19
+ * IAM credentials and documents the arithmetic.
20
+ */
21
+ export class Email<
22
+ TStage extends string = string,
23
+ TDomain extends string = string,
24
+ > implements GkmLinkable
25
+ {
26
+ readonly _id: string;
27
+
28
+ /** Composed once, in the constructor, so `provides` cannot drift from it. */
29
+ private readonly url: $util.Input<string>;
30
+
31
+ /**
32
+ * The address mail is sent from.
33
+ *
34
+ * Required rather than defaulted: an unverified sender is rejected by every
35
+ * provider, and a guess would be rejected at the first send rather than at
36
+ * synth.
37
+ */
38
+ private readonly from: $util.Input<string>;
39
+
40
+ get _type() {
41
+ return ResourceType.Email;
42
+ }
43
+
44
+ constructor(
45
+ _stack: StackType<TStage, TDomain>,
46
+ name: string,
47
+ props: EmailProps,
48
+ ) {
49
+ this._id = name;
50
+
51
+ // A supplied URL wins over provisioning, for every backend including SES.
52
+ // Credentials that already exist are the common case — a sending identity
53
+ // set up once, by hand — and creating a second IAM user for it would be
54
+ // this deploy quietly adding another way into the account.
55
+ this.url = props.url ?? this.provision(name, props);
56
+ this.from = props.from;
57
+ }
58
+
59
+ /**
60
+ * Mint a credential, for the one backend that can.
61
+ *
62
+ * @throws {EmailNeedsUrl} for the backends that cannot. Resend and a plain
63
+ * relay *are* accounts somebody created, so a missing URL is a missing setup
64
+ * step — and saying so at synth beats composing something that cannot
65
+ * deliver and finding out at the first send.
66
+ */
67
+ private provision(name: string, props: EmailProps): $util.Input<string> {
68
+ if (props.backend !== 'ses') throw new EmailNeedsUrl(name, props.backend);
69
+
70
+ return this.provisionSes(name, props);
71
+ }
72
+
73
+ /**
74
+ * One key, the `smtp://` URL — and a second the app declared, the sending
75
+ * identity, which is the one thing about mail that genuinely differs per
76
+ * stage.
77
+ */
78
+ provides(): Record<string, $util.Input<string>> {
79
+ return { url: this.url, from: this.from };
80
+ }
81
+
82
+ getSSTLink() {
83
+ return { properties: { ...this.provides() } };
84
+ }
85
+
86
+ /**
87
+ * The SES chain: a user that may send, a key for it, and the derived password.
88
+ *
89
+ * A user of its own rather than the application's role, because SMTP is a
90
+ * long-lived static credential and the execution role is not — there is no
91
+ * way to hand a relay a rotating token. Scoped to `ses:SendRawEmail` and
92
+ * nothing else, so a leaked SMTP password sends mail and does not read the
93
+ * account's send statistics or verify new identities.
94
+ */
95
+ private provisionSes(name: string, props: EmailProps): $util.Input<string> {
96
+ const user = new iam.User(`${name}SmtpUser`, {
97
+ path: '/gkm/ses/',
98
+ });
99
+
100
+ new iam.UserPolicy(`${name}SmtpPolicy`, {
101
+ user: user.name,
102
+ policy: JSON.stringify({
103
+ Version: '2012-10-17',
104
+ Statement: [
105
+ { Effect: 'Allow', Action: 'ses:SendRawEmail', Resource: '*' },
106
+ ],
107
+ }),
108
+ });
109
+
110
+ const key = new iam.AccessKey(`${name}SmtpKey`, { user: user.name });
111
+
112
+ return $util
113
+ .all([key.id, key.secret, props.region])
114
+ .apply(([accessKeyId, secretAccessKey, region]) =>
115
+ sesSmtpUrl({ accessKeyId, secretAccessKey, region }),
116
+ );
117
+ }
118
+ }
119
+
120
+ export interface EmailProps {
121
+ /** Who delivers the mail. Only `ses` provisions anything. */
122
+ backend: 'resend' | 'ses' | 'smtp';
123
+ /**
124
+ * The `smtp://` URL, where the credentials already exist.
125
+ *
126
+ * Required for `resend` and `smtp`, which are accounts rather than
127
+ * infrastructure. Optional for `ses`, and supplying it is the difference
128
+ * between using the sending identity you have and provisioning a second one.
129
+ */
130
+ url?: $util.Input<string>;
131
+ /** The region to derive SES credentials for. Required for `ses`. */
132
+ region?: $util.Input<string>;
133
+ /**
134
+ * The identity mail is sent from — a verified address or domain.
135
+ *
136
+ * Stage-varying by nature, which is why it is here rather than on the
137
+ * construct: `noreply@myapp.test` locally, a verified domain deployed.
138
+ */
139
+ from: $util.Input<string>;
140
+ }
141
+
142
+ /**
143
+ * A backend that cannot mint its own credentials was given none.
144
+ *
145
+ * Resend and a plain relay are accounts somebody created, so there is nothing
146
+ * for a deploy to provision and a missing URL is a missing setup step.
147
+ */
148
+ export class EmailNeedsUrl extends Error {
149
+ constructor(
150
+ readonly id: string,
151
+ readonly backend: string,
152
+ ) {
153
+ super(
154
+ `'${id}' sends through ${backend}, which is an account rather than ` +
155
+ `something to provision, and no URL was supplied for it. Set it with ` +
156
+ `\`sst secret set\` and pass it through the deploy layer — ` +
157
+ `fromManifest(stack, manifest, { ${id}: { url } }).`,
158
+ );
159
+ this.name = 'EmailNeedsUrl';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Mail was declared and no sending identity was supplied.
165
+ *
166
+ * Not defaultable: every provider rejects an unverified sender, so a guess would
167
+ * deploy cleanly and fail at the first send. The address is also stage-varying
168
+ * by nature, which is why it belongs in the deploy layer rather than the
169
+ * construct.
170
+ */
171
+ export class EmailNeedsSender extends Error {
172
+ constructor(readonly id: string) {
173
+ super(
174
+ `'${id}' sends mail and no sending identity was supplied. Pass one ` +
175
+ `through the deploy layer — ` +
176
+ `fromManifest(stack, manifest, { ${id}: { from: 'noreply@example.com' } }) ` +
177
+ `— and make sure it is verified with the provider.`,
178
+ );
179
+ this.name = 'EmailNeedsSender';
180
+ }
181
+ }
@@ -0,0 +1,82 @@
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { StackType } from '../Stack';
3
+
4
+ /**
5
+ * `FileServer` — a CloudFront distribution over a bucket, and the infra half of
6
+ * the `file-server` kind.
7
+ *
8
+ * It owns the certificate and the DNS record, which is the whole reason the
9
+ * construct is its own rather than a flag on the bucket: a domain lifecycle has
10
+ * no business living inside an `objects` provisioner. It is also the same
11
+ * infrastructure a static site is — a distribution over an origin — with live
12
+ * bucket contents in place of a build output.
13
+ *
14
+ * The origin bucket must permit CloudFront to read it, which is set on the
15
+ * *bucket* rather than here: `fromManifest` gives a bucket `access:
16
+ * 'cloudfront'` when anything in the manifest serves it. That lookup is the cost
17
+ * this design named — the bucket alone no longer says whether it is served, so
18
+ * you find whoever points at it — paid once, in one place.
19
+ *
20
+ * What it does **not** do is signing. A CloudFront signed URL or signed cookie
21
+ * needs a key group and a private key, which is key material nothing declares
22
+ * yet; the construct's `signedUrl` is an S3 presign at the bucket instead. So
23
+ * open paths are served here, and a signed read goes to the bucket's own host.
24
+ * That is a real difference in hostname, and it is documented rather than hidden
25
+ * because a presign and a CloudFront signature share only the word "signed".
26
+ */
27
+ export class FileServer<
28
+ TStage extends string = string,
29
+ TDomain extends string = string,
30
+ >
31
+ extends sst.aws.Router
32
+ implements GkmLinkable
33
+ {
34
+ readonly _id!: string;
35
+
36
+ get _type() {
37
+ return ResourceType.FileServer;
38
+ }
39
+
40
+ constructor(
41
+ _stack: StackType<TStage, TDomain>,
42
+ name: string,
43
+ props: FileServerProps,
44
+ ) {
45
+ const { origin, ...args } = props;
46
+
47
+ super(name, args);
48
+ this._id = name;
49
+
50
+ // Everything at the root, so a served path *is* a bucket key and the
51
+ // client's `url(key)` and `getUploadURL(key)` speak the same language.
52
+ // Several origins would cost exactly that, which is why multi-origin is
53
+ // deferred until something asks for it.
54
+ this.routeBucket('/*', origin);
55
+ }
56
+
57
+ /**
58
+ * The one value this server resolves: where its objects answer.
59
+ *
60
+ * A custom domain when one is configured and the distribution's assigned
61
+ * hostname otherwise — `Router.url` already prefers the former. The assigned
62
+ * hostname is a fallback rather than a destination: it changes if the
63
+ * distribution is replaced, which turns every URL anyone emailed or cached
64
+ * into a dead one.
65
+ */
66
+ provides(): Record<string, $util.Input<string>> {
67
+ return { url: this.url };
68
+ }
69
+
70
+ override getSSTLink() {
71
+ const link = super.getSSTLink();
72
+ return {
73
+ ...link,
74
+ properties: { ...link.properties, ...this.provides() },
75
+ };
76
+ }
77
+ }
78
+
79
+ export interface FileServerProps extends sst.aws.RouterArgs {
80
+ /** The bucket whose objects this serves. */
81
+ origin: sst.aws.Bucket;
82
+ }
@@ -4,9 +4,9 @@ import {
4
4
  flattenManifestField,
5
5
  type ManifestField,
6
6
  } from '@geekmidas/manifest';
7
- import { type GkmLinkable, ResourceType } from './Linkable';
8
- import { LinkedEnvironment } from './LinkedEnvironment';
9
- import type { StackType } from './Stack';
7
+ import { type GkmLinkable, ResourceType } from '../Linkable';
8
+ import { LinkedEnvironment } from '../LinkedEnvironment';
9
+ import type { StackType } from '../Stack';
10
10
 
11
11
  /**
12
12
  * `Function` — wraps SST's `sst.aws.Function` with standard env defaults and