@geekmidas/cloud 9.0.2 → 10.0.0-alpha.1

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,899 @@
1
+ /**
2
+ * Manifest → SST.
3
+ *
4
+ * Split deliberately into decisions and instantiation. The decisions —
5
+ * which component provisions a kind, whether what it supplies matches what the
6
+ * app declared — are pure functions, so they can be asserted as data. Only
7
+ * {@link fromManifest} touches Pulumi, and it is thin enough that little is
8
+ * hidden behind a runtime nothing can unit-test.
9
+ *
10
+ * Built for AWS. Extending to another provider means adding entries to
11
+ * {@link PROVISIONERS}, not restructuring: the manifest names a *kind*, never a
12
+ * cloud.
13
+ */
14
+
15
+ import { ownerRole, readerRole } from '@geekmidas/db/pg/roles';
16
+ import { resolveEnvKeys } from '@geekmidas/envkit/sst';
17
+ import type {
18
+ ConstructManifest,
19
+ Declaration,
20
+ DeclarationKind,
21
+ Dependency,
22
+ SiteDeclaration,
23
+ } from '@geekmidas/manifest';
24
+ import {
25
+ cacheTable,
26
+ cookieDomain,
27
+ DEFAULT_POSTGRES_VERSION,
28
+ dependentsOf,
29
+ PUBLIC,
30
+ PUBLIC_PREFIX,
31
+ providedKeyFor,
32
+ provisionOrder,
33
+ } from '@geekmidas/manifest';
34
+ import {
35
+ Cache,
36
+ CacheIsAmbiguous,
37
+ CacheNeedsDatabase,
38
+ type CacheProps,
39
+ withCacheTable,
40
+ } from './aws/Cache';
41
+ import { Credential } from './aws/Credential';
42
+ import { Database, DatabaseNeedsVpc } from './aws/Database';
43
+ import { DatabaseBootstrap } from './aws/DatabaseBootstrap';
44
+ import { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
45
+ import { Email, EmailNeedsSender } from './aws/Email';
46
+ import { FileServer } from './aws/FileServer';
47
+ import { ObjectStorage } from './aws/ObjectStorage';
48
+ import { Queue } from './aws/Queue';
49
+ import { RestApiSurface } from './aws/RestApiSurface';
50
+ import { Secret } from './aws/Secret';
51
+ import { StaticSite } from './aws/StaticSite';
52
+ import { Topic } from './aws/Topic';
53
+ import {
54
+ ProvidesMismatch,
55
+ UnknownDeclarationKind,
56
+ UnresolvedDependency,
57
+ } from './errors';
58
+ import type { GkmLinkable } from './Linkable';
59
+ import type { StackType } from './Stack';
60
+
61
+ /**
62
+ * A provisioned construct — the component, which *is* the linkable.
63
+ *
64
+ * `provides()` returns its values keyed by role. They reach the running code by
65
+ * being spread into the link's properties, which SST injects and envkit's
66
+ * resolvers flatten into env; keeping them behind a method is what lets the
67
+ * contract be asserted at synth without a deploy.
68
+ */
69
+ export interface Provisioned extends GkmLinkable {
70
+ provides(): Record<string, $util.Input<string>>;
71
+ }
72
+
73
+ /** Everything the manifest declared, keyed by id — the shape edges resolve against. */
74
+ export type ProvisionedManifest = Record<string, Provisioned>;
75
+
76
+ /**
77
+ * What a provisioner can see beyond its own declaration.
78
+ *
79
+ * Two kinds need it, and both are consequences of decisions the design took
80
+ * deliberately. A bucket has to know whether anything serves it, because the
81
+ * file server is a construct of its own rather than a flag — the cost that
82
+ * decision names, paid here by looking rather than by reading a flag. And a
83
+ * derived node needs the component its parent became, since it provisions
84
+ * nothing and resolves an address off that one.
85
+ */
86
+ export interface ProvisionContext {
87
+ manifest: ConstructManifest;
88
+ /** Everything already provisioned. Parents are present; siblings may not be. */
89
+ provisioned: ProvisionedManifest;
90
+ /**
91
+ * The role bootstrap for each cluster, keyed by the cluster's id.
92
+ *
93
+ * Populated as tenants are provisioned and run once at the end: Pulumi can
94
+ * generate a password and store it but cannot run `CREATE ROLE`, so the DDL
95
+ * is a function invoked after everything it operates on exists.
96
+ */
97
+ bootstraps: Map<string, DatabaseBootstrap>;
98
+ /**
99
+ * Where a declared cache lives, and who delivers mail.
100
+ *
101
+ * Deployment choices rather than declarations, for the same reason
102
+ * `services.events` is: the same application code caches into any of them and
103
+ * sends through any of them. They reach the provisioner here because it is
104
+ * the only place that knows both the choice and the manifest.
105
+ */
106
+ cache?: 'upstash' | 'elasticache' | 'db';
107
+ email?: 'resend' | 'ses' | 'smtp';
108
+ /**
109
+ * One deferred caller list per surface, resolved once everything exists.
110
+ *
111
+ * A surface's origins come from its *inbound* edges, and one of those is
112
+ * usually a site whose own build needs this surface's address — so the values
113
+ * are circular even though the resources are not. Deferring the value is
114
+ * what breaks it; deferring the resource would deadlock.
115
+ */
116
+ callers?: Map<string, Deferred>;
117
+ }
118
+
119
+ /** A promise and the handle to settle it. */
120
+ interface Deferred {
121
+ promise: Promise<{ trustedOrigins: string; cookieDomain?: string }>;
122
+ resolve: (
123
+ value:
124
+ | { trustedOrigins: string; cookieDomain?: string }
125
+ | Promise<{ trustedOrigins: string; cookieDomain?: string }>,
126
+ ) => void;
127
+ }
128
+
129
+ type Provisioner = (
130
+ stack: StackType,
131
+ declaration: Declaration,
132
+ props: Record<string, unknown>,
133
+ context: ProvisionContext,
134
+ ) => Provisioned;
135
+
136
+ /**
137
+ * Provider-specific props, per construct id.
138
+ *
139
+ * Neutral options — `versioned`, and later `cdn` — travel in the declaration,
140
+ * because the app legitimately has an opinion about them. Anything with S3 in
141
+ * its name does not belong in application code, so lifecycle rules, CORS, and
142
+ * canned ACLs are supplied here, in the deploy layer, keyed by the id they
143
+ * apply to and typed against the component that receives them.
144
+ *
145
+ * A third escape hatch needs no API at all: `fromManifest` returns the
146
+ * components, so `provisioned.Uploads.nodes.bucket` is reachable for anything
147
+ * neither route covers.
148
+ */
149
+ export type ComponentOverrides = Record<string, Record<string, unknown>>;
150
+
151
+ /**
152
+ * Which component provisions which kind.
153
+ *
154
+ * The extension point: a second provider adds entries here, and nothing above
155
+ * this line changes.
156
+ */
157
+ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
158
+ objects: (stack, d, props, context) =>
159
+ new ObjectStorage(stack, d.id, {
160
+ // Neutral options the app declared, mapped to this provider's words.
161
+ ...(d.kind === 'objects' && d.versioned ? { versioning: true } : {}),
162
+ // A served bucket has to let CloudFront read it, and only the manifest
163
+ // knows whether anything serves it — the cost of making the file
164
+ // server its own construct, paid here rather than by a flag on the
165
+ // bucket that every consumer would then branch on.
166
+ ...(isServed(d.id, context.manifest) ? { access: 'cloudfront' } : {}),
167
+ // Overrides win: they are the more specific statement, and the escape
168
+ // hatch is worthless if it cannot override the general case.
169
+ ...props,
170
+ }),
171
+
172
+ 'file-server': (stack, d, props, context) => {
173
+ if (d.kind !== 'file-server') throw new UnknownDeclarationKind(d.kind, []);
174
+
175
+ const origin = context.provisioned[d.of];
176
+ if (!origin) {
177
+ throw new UnresolvedDependency(d.of, Object.keys(context.provisioned));
178
+ }
179
+
180
+ return new FileServer(stack, d.id, {
181
+ origin: origin as unknown as sst.aws.Bucket,
182
+ ...props,
183
+ });
184
+ },
185
+
186
+ site: (stack, d, props, context) => {
187
+ if (d.kind !== 'site') throw new UnknownDeclarationKind(d.kind, []);
188
+
189
+ return new StaticSite(stack, d.id, {
190
+ path: d.app.path,
191
+ variant: d.variant,
192
+ environment: siteEnvironment(d, context),
193
+ ...props,
194
+ });
195
+ },
196
+
197
+ queue: (stack, d, props) =>
198
+ new Queue(stack, d.id, {
199
+ // FIFO is a neutral option — the app legitimately has an opinion about
200
+ // ordering — and `fifo` is also what SST calls it.
201
+ ...(d.kind === 'queue' && d.fifo ? { fifo: true } : {}),
202
+ ...props,
203
+ }),
204
+
205
+ // A topic declares nothing beyond its id: which events it carries is the
206
+ // producer's and subscribers' business, and SNS has no per-event
207
+ // configuration to map it onto.
208
+ topic: (stack, d, props) => new Topic(stack, d.id, props),
209
+
210
+ database: (stack, d, props, context) => {
211
+ if (d.kind !== 'database') throw new UnknownDeclarationKind(d.kind, []);
212
+ // Required rather than defaulted: creating a VPC means creating a NAT
213
+ // gateway, which is a monthly cost in an account whose networking may
214
+ // already be someone else's decision.
215
+ if (!('vpc' in props)) throw new DatabaseNeedsVpc(d.id);
216
+
217
+ const cluster = new Database(stack, d.id, {
218
+ ...(d.schema ? { schema: d.schema } : {}),
219
+ // The declared major, so the cluster runs what the declaration says
220
+ // rather than whatever Aurora defaults to that month. Local ran 18
221
+ // while this ran 17.7, and neither was written down anywhere.
222
+ version: String(d.version ?? DEFAULT_POSTGRES_VERSION),
223
+ ...(props as unknown as sst.aws.PostgresArgs),
224
+ });
225
+
226
+ // A database needs its own roles, not just its tenants'. Registering only
227
+ // tenants left the *database* connecting as the cluster master and its
228
+ // reader falling back to the same — so the split the local target
229
+ // enforces was absent deployed, which is the worst shape a gap can take:
230
+ // a developer sees it working and production silently does not have it.
231
+ if (d.schema) {
232
+ const bootstrap = bootstrapFor(cluster, d.id, context);
233
+ const runtime = roleNameFor(d, context);
234
+
235
+ bootstrap?.add({
236
+ id: d.id,
237
+ schema: d.schema,
238
+ runtime,
239
+ owner: ownerRole(runtime),
240
+ ...(hasReader(d.id, context) ? { reader: readerRole(runtime) } : {}),
241
+ });
242
+ }
243
+
244
+ return cluster;
245
+ },
246
+
247
+ 'database-reader': (_stack, d, _props, context) =>
248
+ derived(d, context, (id, parent) => {
249
+ // A reader reads through the *parent's* read-only role: read-only is
250
+ // enforced by the grants, which is what makes falling back to the
251
+ // writer's endpoint safe where a cluster has no replica.
252
+ const source = parentOf(d, context);
253
+ const roles = context.bootstraps.get(rootId(source, context));
254
+ const runtime = roleNameFor(source, context);
255
+ const reader = roles?.readerFor(runtime);
256
+
257
+ return new DatabaseReader(id, parent, reader);
258
+ }),
259
+
260
+ 'database-schema': (_stack, d, _props, context) => {
261
+ if (d.kind !== 'database-schema')
262
+ throw new UnknownDeclarationKind(d.kind, []);
263
+
264
+ return derived(d, context, (id, parent) => {
265
+ const bootstrap = bootstrapFor(parent, rootId(d, context), context);
266
+ const runtime = roleNameFor(d, context);
267
+
268
+ // Registering the tenant is what creates its passwords and its secret;
269
+ // the DDL that uses them runs at the end, from one function.
270
+ const credentials = bootstrap?.add({
271
+ id,
272
+ schema: d.schema,
273
+ runtime,
274
+ owner: ownerRole(runtime),
275
+ ...(hasReader(d.id, context) ? { reader: readerRole(runtime) } : {}),
276
+ });
277
+
278
+ return new DatabaseSchema(
279
+ id,
280
+ parent,
281
+ d.schema,
282
+ credentials
283
+ ? { user: runtime, password: credentials.runtime }
284
+ : undefined,
285
+ );
286
+ });
287
+ },
288
+
289
+ cache: (stack, d, props, context) => {
290
+ const backend = context.cache ?? 'upstash';
291
+
292
+ // A cache that named a database is in *that* one, whatever the backend
293
+ // config says — the declaration is the stronger statement. Without one,
294
+ // the `db` backend resolves to the declared database, which is an answer
295
+ // only while there is one of them: picking the first of two would put a
296
+ // cache in a database nobody chose, and that surfaces as missing entries
297
+ // long after the deploy reported success.
298
+ const databases =
299
+ d.kind === 'cache' && !d.of && backend === 'db'
300
+ ? Object.entries(context.manifest)
301
+ .filter(([, declaration]) => declaration.kind === 'database')
302
+ .map(([id]) => id)
303
+ : [];
304
+
305
+ if (databases.length > 1) throw new CacheIsAmbiguous(d.id, databases);
306
+
307
+ const parentId = d.kind === 'cache' && d.of ? d.of : databases[0];
308
+
309
+ if (parentId) {
310
+ const parent = context.provisioned[parentId];
311
+ if (!parent) throw new CacheNeedsDatabase(d.id);
312
+
313
+ // Same address, same role, one more table — which is what makes this
314
+ // backend cost nothing to run. The table travels *in* the URL, because
315
+ // two caches in one database resolve the same connection string and a
316
+ // client built from the URL alone could not tell them apart.
317
+ const table =
318
+ (d.kind === 'cache' && d.table) || cacheTable(d.id as string);
319
+
320
+ return new Cache(stack, d.id, {
321
+ url: $util
322
+ .output(parent.provides().url!)
323
+ .apply((url) => withCacheTable(url, table)),
324
+ });
325
+ }
326
+
327
+ const supplied = props as {
328
+ url?: $util.Input<string>;
329
+ vpc?: CacheProps['vpc'];
330
+ region?: string;
331
+ };
332
+
333
+ // Both remaining backends create something, and both take a URL instead
334
+ // for a cache that already exists.
335
+ return new Cache(stack, d.id, {
336
+ backend,
337
+ ...(supplied.url ? { url: supplied.url } : {}),
338
+ ...(supplied.vpc ? { vpc: supplied.vpc } : {}),
339
+ ...(supplied.region ? { region: supplied.region } : {}),
340
+ });
341
+ },
342
+
343
+ email: (stack, d, props, context) => {
344
+ const backend = context.email ?? 'ses';
345
+ const supplied = props as {
346
+ url?: $util.Input<string>;
347
+ region?: $util.Input<string>;
348
+ from?: $util.Input<string>;
349
+ };
350
+
351
+ // Not defaultable: every provider rejects an unverified sender, so a
352
+ // guess deploys cleanly and fails at the first send.
353
+ if (!supplied.from) throw new EmailNeedsSender(d.id);
354
+
355
+ return new Email(stack, d.id, {
356
+ backend,
357
+ from: supplied.from,
358
+ ...(supplied.url ? { url: supplied.url } : {}),
359
+ // SES derives its own credential and needs to know which region's
360
+ // endpoint to derive it for; the others were handed a URL already.
361
+ region: supplied.region ?? $app.providers?.aws?.region ?? 'us-east-1',
362
+ });
363
+ },
364
+
365
+ /**
366
+ * The surface, without its routes.
367
+ *
368
+ * An API Gateway with no routes 404s everything, and that is the honest
369
+ * state: the *surface* is what this kind declares, and mounting handlers on
370
+ * it is the endpoint merge that has not landed — routes still reach the
371
+ * deploy target through the separate `RouteInfo[]` pipeline.
372
+ *
373
+ * Provisioning it anyway is not ceremony. Its address is what a site inlines
374
+ * as `VITE_API_URL`, what an auth server puts on its trusted-origin list, and
375
+ * what the cookie domain derives from — so everything downstream of the API
376
+ * is blocked on the API *existing*, not on it answering.
377
+ */
378
+ 'rest-api': (stack, d, props, context) =>
379
+ new RestApiSurface(stack, d.id, {
380
+ ...(props as sst.aws.ApiGatewayV2Args),
381
+ callers: context.callers?.get(d.id)?.promise,
382
+ }),
383
+
384
+ secret: (stack, d, props) => new Secret(stack, d.id, props),
385
+
386
+ // The same storage as a secret, under a different role — see the component.
387
+ credential: (stack, d, props) => new Credential(stack, d.id, props),
388
+ };
389
+
390
+ /**
391
+ * A site's build-time environment: the actual values, under the names its
392
+ * bundler inlines.
393
+ *
394
+ * The names come from the shared derivation, so a site built by `gkm dev` and
395
+ * the same site built here inline the same keys. The *values* can only come
396
+ * from the provisioned components — a static site has no server half to read a
397
+ * link at runtime, so an address it needs has to be an input to its build.
398
+ *
399
+ * @throws {UnresolvedDependency} when an edge names something not provisioned.
400
+ * Silently emitting a smaller environment would produce a frontend that builds
401
+ * and then fails against `http:///`, with nothing to point at.
402
+ */
403
+ export function siteEnvironment(
404
+ declaration: SiteDeclaration,
405
+ context: ProvisionContext,
406
+ ): Record<string, $util.Input<string>> {
407
+ const prefix = PUBLIC_PREFIX[declaration.variant];
408
+ const environment: Record<string, $util.Input<string>> = {};
409
+
410
+ for (const edge of declaration.dependencies) {
411
+ const target = context.manifest[edge.target];
412
+ if (!target) continue;
413
+
414
+ const roles = PUBLIC[target.kind] ?? [];
415
+ if (roles.length === 0) continue;
416
+
417
+ const component = context.provisioned[edge.target];
418
+ if (!component) {
419
+ throw new UnresolvedDependency(
420
+ edge.target,
421
+ Object.keys(context.provisioned),
422
+ );
423
+ }
424
+
425
+ const provided = component.provides();
426
+ for (const role of roles) {
427
+ const value = provided[role as string];
428
+ if (value === undefined) continue;
429
+
430
+ environment[
431
+ `${prefix}${providedKeyFor(edge.target, target.kind, role as string)}`
432
+ ] = value;
433
+ }
434
+ }
435
+
436
+ return environment;
437
+ }
438
+
439
+ /**
440
+ * Resolve a derived database node against the cluster its parent became.
441
+ *
442
+ * A reader and a schema tenant provision nothing, so the whole of their
443
+ * provisioning is finding the parent — which `provisionOrder` guarantees is
444
+ * already there, and `assertDerivations` guarantees exists at all.
445
+ *
446
+ * @throws {UnresolvedDependency} if neither guarantee held, which would mean the
447
+ * manifest reached here without its own validation having run.
448
+ */
449
+ function derived(
450
+ declaration: Declaration,
451
+ context: ProvisionContext,
452
+ build: (id: string, parent: Database) => Provisioned,
453
+ ): Provisioned {
454
+ if (!('of' in declaration)) {
455
+ throw new UnknownDeclarationKind(declaration.kind, []);
456
+ }
457
+
458
+ const parent = context.provisioned[declaration.of];
459
+ if (!parent) {
460
+ throw new UnresolvedDependency(
461
+ declaration.of,
462
+ Object.keys(context.provisioned),
463
+ );
464
+ }
465
+
466
+ // A tenant may derive from another tenant, and what both ultimately need is
467
+ // the cluster underneath. `DerivedDatabase` holds its parent, so walking up
468
+ // is following the same chain `provisionOrder` walked to get here.
469
+ return build(declaration.id, rootCluster(parent));
470
+ }
471
+
472
+ /** The declaration a derived node hangs off. */
473
+ function parentOf(
474
+ declaration: Declaration,
475
+ context: ProvisionContext,
476
+ ): Declaration {
477
+ if (!('of' in declaration)) return declaration;
478
+
479
+ return context.manifest[declaration.of] ?? declaration;
480
+ }
481
+
482
+ /**
483
+ * The id of the cluster at the bottom of a chain of derived nodes.
484
+ *
485
+ * A tenant may derive from another tenant, and what both ultimately live in is
486
+ * one database — so this follows `of` to the end rather than reading the
487
+ * immediate parent.
488
+ */
489
+ function rootId(declaration: Declaration, context: ProvisionContext): string {
490
+ let current = declaration;
491
+
492
+ while ('of' in current) {
493
+ const parent = context.manifest[current.of];
494
+ if (!parent) break;
495
+ current = parent;
496
+ }
497
+
498
+ return current.id;
499
+ }
500
+
501
+ /**
502
+ * The runtime role a node connects as: its own id, lowercased.
503
+ *
504
+ * The same rule the local target uses, so a role a developer sees in `\du` is
505
+ * the role that exists in production. Roles are cluster-scoped, so two stages
506
+ * sharing a cluster would collide — which is why a deployed stage gets its own.
507
+ */
508
+ function roleNameFor(
509
+ declaration: Declaration,
510
+ _context: ProvisionContext,
511
+ ): string {
512
+ return declaration.id.toLowerCase();
513
+ }
514
+
515
+ /** Whether anything in the manifest reads through this node. */
516
+ function hasReader(id: string, context: ProvisionContext): boolean {
517
+ return Object.values(context.manifest).some(
518
+ (declaration) =>
519
+ declaration.kind === 'database-reader' && declaration.of === id,
520
+ );
521
+ }
522
+
523
+ /**
524
+ * The bootstrap for a cluster, created on first use.
525
+ *
526
+ * One per cluster rather than one per tenant: the DDL for every tenant runs on
527
+ * the same connection, as the same master, so a function each would be the same
528
+ * work done N times with N cold starts.
529
+ */
530
+ function bootstrapFor(
531
+ cluster: Database,
532
+ clusterId: string,
533
+ context: ProvisionContext,
534
+ ): DatabaseBootstrap | undefined {
535
+ // `roles: false` is the documented downgrade: no roles, and both URLs fall
536
+ // back to the master. Nothing to bootstrap.
537
+ const declaration = context.manifest[clusterId];
538
+ if (declaration && 'roles' in declaration && declaration.roles === false) {
539
+ return undefined;
540
+ }
541
+
542
+ const existing = context.bootstraps.get(clusterId);
543
+ if (existing) return existing;
544
+
545
+ const created = new DatabaseBootstrap(clusterId, cluster);
546
+ context.bootstraps.set(clusterId, created);
547
+
548
+ return created;
549
+ }
550
+
551
+ /** The `Database` at the bottom of a chain of derived nodes. */
552
+ function rootCluster(component: Provisioned): Database {
553
+ const parent = (component as { parent?: Provisioned }).parent;
554
+
555
+ return parent ? rootCluster(parent) : (component as unknown as Database);
556
+ }
557
+
558
+ /**
559
+ * Every route and exactly what it depends on, as lines to print.
560
+ *
561
+ * The design's central claim is that a function is linked to the constructs it
562
+ * declared and nothing else — least privilege falling out of the graph rather
563
+ * than out of discipline. That is easy to assert in a test and invisible during
564
+ * a deploy, which is the moment somebody would want to check it. So it is
565
+ * printed: one line per route, naming what it can reach.
566
+ *
567
+ * A surface with no routes says so rather than printing nothing, because "no
568
+ * routes yet" and "this printed nothing" look identical otherwise — and for an
569
+ * application's own API that is currently the true state, pending the endpoint
570
+ * merge.
571
+ *
572
+ * Pure, so what gets printed can be asserted without a deploy.
573
+ */
574
+ export function describeRoutes(manifest: ConstructManifest): string[] {
575
+ const lines: string[] = [];
576
+
577
+ for (const [id, declaration] of Object.entries(manifest)) {
578
+ if (declaration.kind !== 'rest-api') continue;
579
+
580
+ lines.push(`${id}:`);
581
+
582
+ if (declaration.endpoints.length === 0) {
583
+ // Empty is now genuinely empty: the build folds discovered routes into
584
+ // the surface that serves them, so a surface with none has none.
585
+ lines.push(' (no routes)');
586
+ }
587
+
588
+ for (const endpoint of declaration.endpoints) {
589
+ const reaches = endpoint.dependencies
590
+ .map((edge) => `${edge.target} (${edge.kind})`)
591
+ .join(', ');
592
+
593
+ lines.push(
594
+ ` ${endpoint.method} ${endpoint.path} → ${reaches || 'nothing'}`,
595
+ );
596
+ }
597
+
598
+ // A caller relationship, printed apart from the routes because it grants
599
+ // nothing — see `RestApiDeclaration.calls`.
600
+ if (declaration.calls?.length) {
601
+ lines.push(
602
+ ` calls ${declaration.calls.map((edge) => edge.target).join(', ')} (origin only; grants nothing)`,
603
+ );
604
+ }
605
+ }
606
+
607
+ return lines;
608
+ }
609
+
610
+ /** A promise with its settle function, for a value that arrives later. */
611
+ function defer(): Deferred {
612
+ let resolve!: Deferred['resolve'];
613
+ const promise = new Promise<{
614
+ trustedOrigins: string;
615
+ cookieDomain?: string;
616
+ }>((settle) => {
617
+ resolve = settle;
618
+ });
619
+
620
+ return { promise, resolve };
621
+ }
622
+
623
+ /**
624
+ * Who may call one surface, and the domain they can share a cookie on.
625
+ *
626
+ * The same derivation the local target runs, from the same two functions —
627
+ * `dependentsOf` reads the graph backwards and `cookieDomain` finds the shared
628
+ * parent. What differs is only where an address comes from: a published port
629
+ * there, a provisioned component here.
630
+ *
631
+ * A surface is left off its own origin list: it does not need permission to call
632
+ * itself, and adding it would make every surface trust every other one sharing a
633
+ * host.
634
+ */
635
+ async function callersOf(
636
+ id: string,
637
+ manifest: ConstructManifest,
638
+ provisioned: ProvisionedManifest,
639
+ ): Promise<{ trustedOrigins: string; cookieDomain?: string }> {
640
+ // Every address here is a Pulumi output, not a string — a CloudFront domain
641
+ // and an API Gateway endpoint are both known only after their resource
642
+ // exists. An earlier version filtered for `typeof url === 'string'` and so
643
+ // filtered out *everything*, which deployed cleanly with an empty
644
+ // trusted-origin list: the failure mode this whole derivation exists to
645
+ // avoid, arrived at by a type guard that looked defensive.
646
+ const urls = await Promise.all(
647
+ dependentsOf(manifest, id).map((caller) =>
648
+ resolved(provisioned[caller]?.provides().url),
649
+ ),
650
+ );
651
+
652
+ const origins = [
653
+ ...new Set(
654
+ urls
655
+ .map((url) => (url ? originOf(url) : undefined))
656
+ .filter((origin): origin is string => Boolean(origin)),
657
+ ),
658
+ ].sort();
659
+
660
+ const own = await resolved(provisioned[id]?.provides().url);
661
+ const domain = cookieDomain([...(own ? [own] : []), ...origins]);
662
+
663
+ return {
664
+ trustedOrigins: origins.join(','),
665
+ ...(domain ? { cookieDomain: domain } : {}),
666
+ };
667
+ }
668
+
669
+ /**
670
+ * A Pulumi input as the string it eventually is.
671
+ *
672
+ * `$util.output(…).apply()` is how a value is read, and the promise it returns
673
+ * is what makes the caller list resolvable at all — the whole reason a surface's
674
+ * origins are deferred rather than computed at construction.
675
+ */
676
+ function resolved(
677
+ value: $util.Input<string> | undefined,
678
+ ): Promise<string | undefined> {
679
+ if (value === undefined) return Promise.resolve(undefined);
680
+ if (typeof value === 'string') return Promise.resolve(value);
681
+
682
+ return new Promise((settle) => {
683
+ $util.output(value).apply((url) => {
684
+ settle(url);
685
+ return url;
686
+ });
687
+ });
688
+ }
689
+
690
+ /** An address reduced to the origin a browser compares against. */
691
+ function originOf(address: string): string | undefined {
692
+ try {
693
+ return new URL(address).origin;
694
+ } catch {
695
+ return undefined;
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Whether anything in the manifest serves this bucket.
701
+ *
702
+ * The question the design's chosen shape makes you ask. Under a `cdn: true`
703
+ * flag you read one declaration; here you find whoever points at it — which is
704
+ * a real regression in auditability, answered by making the lookup one function
705
+ * that every consumer shares rather than something each caller re-derives.
706
+ */
707
+ export function isServed(id: string, manifest: ConstructManifest): boolean {
708
+ return Object.values(manifest).some(
709
+ (declaration) =>
710
+ declaration.kind === 'file-server' && declaration.of === id,
711
+ );
712
+ }
713
+
714
+ /** The provisioner for a kind. Pure — the lookup is testable without Pulumi. */
715
+ export function provisionerFor(kind: DeclarationKind): Provisioner {
716
+ const provisioner = PROVISIONERS[kind];
717
+ if (!provisioner) {
718
+ throw new UnknownDeclarationKind(kind, Object.keys(PROVISIONERS));
719
+ }
720
+ return provisioner;
721
+ }
722
+
723
+ /**
724
+ * Assert that a link yields exactly the env keys the app declared.
725
+ *
726
+ * `supplied` comes from `resolveEnvKeys`, which derives the keys a resource type
727
+ * produces — the same derivation that runs for real. The app↔infra contract is
728
+ * the one guarantee spanning two packages, two build phases, and two authors,
729
+ * and the one a JavaScript consumer gets no compiler help with, so it is checked
730
+ * at synth rather than trusted.
731
+ */
732
+ export function assertProvides(
733
+ id: string,
734
+ declared: readonly string[] = [],
735
+ supplied: readonly string[] = [],
736
+ ): void {
737
+ const missing = declared.filter((key) => !supplied.includes(key));
738
+ const extra = supplied.filter((key) => !declared.includes(key));
739
+
740
+ if (missing.length || extra.length) {
741
+ throw new ProvidesMismatch(id, missing, extra);
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Provision everything the manifest declares.
747
+ *
748
+ * In `provisionOrder`, which puts every parent before its children — a schema
749
+ * tenant, a read replica, and the surface over a bucket all resolve an address
750
+ * off something else, and a pass in map order would find it half the time.
751
+ */
752
+ export function fromManifest(
753
+ stack: StackType,
754
+ manifest: ConstructManifest,
755
+ overrides: ComponentOverrides = {},
756
+ /**
757
+ * The backend choices that are config rather than declaration.
758
+ *
759
+ * Defaulted the same way the local target defaults them, so a stage deployed
760
+ * without saying gets the same backend a developer ran against.
761
+ */
762
+ backends: {
763
+ cache?: 'upstash' | 'elasticache' | 'db';
764
+ email?: 'resend' | 'ses' | 'smtp';
765
+ } = {},
766
+ ): ProvisionedManifest {
767
+ const provisioned: ProvisionedManifest = {};
768
+
769
+ // In provisioning order, so a derived node finds the component its parent
770
+ // became. `assertDerivations` has already ruled out a missing parent, so the
771
+ // order is total rather than best-effort.
772
+ //
773
+ // Sites come last, and separately, because `provisionOrder` orders `of` and
774
+ // not `dependencies` — a site needs its edges' *values* at construction
775
+ // time, since a static site has no server half to read a link at runtime.
776
+ // It is a pure consumer of addresses, so building it after everything else
777
+ // is enough; the day a kind needs a site's URL at construction, this needs a
778
+ // real topological sort rather than a second pass.
779
+ const order = provisionOrder(manifest);
780
+ const sites = order.filter((id) => manifest[id]?.kind === 'site');
781
+ const rest = order.filter((id) => manifest[id]?.kind !== 'site');
782
+
783
+ // One per surface, created up front so a surface can be handed its own before
784
+ // the things that call it exist.
785
+ const callers = new Map<string, Deferred>();
786
+ for (const [id, declaration] of Object.entries(manifest)) {
787
+ if (declaration.kind === 'rest-api') callers.set(id, defer());
788
+ }
789
+
790
+ const context: ProvisionContext = {
791
+ manifest,
792
+ provisioned,
793
+ callers,
794
+ bootstraps: new Map<string, DatabaseBootstrap>(),
795
+ ...(backends.cache ? { cache: backends.cache } : {}),
796
+ ...(backends.email ? { email: backends.email } : {}),
797
+ };
798
+
799
+ for (const id of [...rest, ...sites]) {
800
+ const declaration = manifest[id];
801
+ if (!declaration) continue;
802
+
803
+ const component = provisionerFor(declaration.kind)(
804
+ stack,
805
+ declaration,
806
+ overrides[id] ?? {},
807
+ context,
808
+ );
809
+
810
+ assertProvides(
811
+ id,
812
+ declaration.provides,
813
+ // A role becomes the env key the app declared: `url` → `UPLOADS_URL`.
814
+ // Through the shared derivation, not a local copy of it — a secret's
815
+ // name *is* its key, and a check that derived it differently from the
816
+ // thing being checked would pass on drift instead of catching it.
817
+ Object.keys(component.provides()).map((role) =>
818
+ providedKeyFor(id, declaration.kind, role),
819
+ ),
820
+ );
821
+
822
+ provisioned[id] = component;
823
+ }
824
+
825
+ for (const line of describeRoutes(manifest)) console.log(line);
826
+
827
+ // Now everything exists, so every surface's callers can be resolved — the
828
+ // site's address included, which is what the deferral was for.
829
+ for (const [id, deferred] of callers) {
830
+ // Resolved with the promise itself: the addresses it needs are Pulumi
831
+ // outputs, so the answer arrives when they do.
832
+ deferred.resolve(callersOf(id, manifest, provisioned));
833
+ }
834
+
835
+ // Last, and only now: the roles exist as passwords and secrets from the
836
+ // moment each tenant was provisioned, but nothing has run `CREATE ROLE`.
837
+ // Pulumi cannot — so one function per cluster does, inside the VPC, invoked
838
+ // with an input that changes only when the roles or the cluster do.
839
+ for (const [clusterId, bootstrap] of context.bootstraps) {
840
+ const cluster = provisioned[clusterId] as unknown as Database | undefined;
841
+ if (cluster) bootstrap.run(cluster.vpc);
842
+ }
843
+
844
+ return provisioned;
845
+ }
846
+
847
+ /** What one function receives from its edges. */
848
+ export interface ResolvedEdges {
849
+ /** The components it may reach. SST turns these into IAM *and* injects their
850
+ * properties, so this is the whole delivery mechanism. */
851
+ link: Provisioned[];
852
+ /**
853
+ * The env keys those links yield.
854
+ *
855
+ * Not values: the values are injected at runtime by the link, and a map of
856
+ * key→placeholder would be a lie that ships. Keys are what the adapter needs,
857
+ * so a function's `requires` can be checked against what its own edges cover.
858
+ */
859
+ envKeys: string[];
860
+ }
861
+
862
+ /**
863
+ * Resolve one function's dependencies into what it is given.
864
+ *
865
+ * A function is linked to exactly the constructs it declared, never the app's
866
+ * full set — so least privilege falls out of the edges rather than out of
867
+ * discipline. Adding an unrelated construct to the manifest cannot widen what
868
+ * an existing function can reach, which is the property worth testing as an
869
+ * exclusion.
870
+ *
871
+ * Pure: given a provisioned map, this is data in and data out, so the whole
872
+ * filtering rule is assertable without a deploy.
873
+ */
874
+ export function resolveEdges(
875
+ dependencies: readonly Dependency[] = [],
876
+ provisioned: ProvisionedManifest,
877
+ ): ResolvedEdges {
878
+ const link: Provisioned[] = [];
879
+ const envKeys = new Set<string>();
880
+
881
+ for (const dependency of dependencies) {
882
+ const component = provisioned[dependency.target];
883
+ if (!component) {
884
+ throw new UnresolvedDependency(
885
+ dependency.target,
886
+ Object.keys(provisioned),
887
+ );
888
+ }
889
+
890
+ link.push(component);
891
+ for (const key of resolveEnvKeys({
892
+ [dependency.target]: { type: component._type as string },
893
+ })) {
894
+ envKeys.add(key);
895
+ }
896
+ }
897
+
898
+ return { link, envKeys: [...envKeys].sort() };
899
+ }