@geekmidas/cloud 10.0.0-alpha.45 → 10.0.0-alpha.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,17 +105,25 @@ const cron = new Cron(stack, 'Nightly', { processor: fn, schedule: 'rate(1 day)'
105
105
 
106
106
  ### From a `gkm build` manifest
107
107
 
108
- `gkm build` emits a deployment manifest (types in `@geekmidas/manifest`). Each
109
- construct has a static `fromManifest` factory that maps it straight into infra:
108
+ The root `gkm build` writes the application's manifest to
109
+ `.gkm/manifest/aws.ts` (types in `@geekmidas/manifest`), and `fromManifest`
110
+ maps it straight into infra:
110
111
 
111
112
  ```ts
112
- import routes from './.gkm/routes-manifest.json';
113
+ // sst.config.ts
114
+ const { App, fromManifest, Stack } = await import('@geekmidas/cloud/sst');
115
+ const { backends, constructs } = await import('./.gkm/manifest/aws.js');
113
116
 
114
- const api = Api.fromManifest(stack, 'Api', routes, { links: [db], authorizers });
115
- const workers = Function.fromManifest(stack, functionsManifest, { links: [db] });
116
- const crons = Cron.fromManifest(stack, cronsManifest, { links: [db] });
117
+ fromManifest(new Stack(app, 'Shop'), constructs, { Database: { vpc } }, backends);
117
118
  ```
118
119
 
120
+ `constructs` is every declared construct keyed by id; the overrides are keyed
121
+ by the same ids. One call provisions the resources, each `RestApi`'s endpoints
122
+ (each a Lambda linked only to its own dependencies, placed in the database's
123
+ VPC when it reaches one, with the `iam` authorizer enforced by the gateway),
124
+ queue consumers, every function (a Lambda with an IAM-authorized URL) and every
125
+ cron (a Lambda on its schedule).
126
+
119
127
  ## `@geekmidas/cloud/utils` (runtime)
120
128
 
121
129
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cloud",
3
- "version": "10.0.0-alpha.45",
3
+ "version": "10.0.0-alpha.47",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "lodash.snakecase": "~4.1.1",
44
- "@geekmidas/envkit": "10.0.0-alpha.45",
45
- "@geekmidas/manifest": "10.0.0-alpha.45"
44
+ "@geekmidas/envkit": "10.0.0-alpha.47",
45
+ "@geekmidas/manifest": "10.0.0-alpha.47"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/lodash.get": "~4.4.9",
@@ -58,10 +58,10 @@
58
58
  "@pulumi/aws": ">=7.47.0",
59
59
  "@pulumi/random": ">=4.21.2",
60
60
  "@pulumi/pulumi": ">=3.263.0",
61
- "@geekmidas/manifest": "^10.0.0-alpha.45",
62
- "@geekmidas/storage": "^10.0.0-alpha.45",
63
- "@geekmidas/events": "^10.0.0-alpha.45",
64
- "@geekmidas/db": "^10.0.0-alpha.45"
61
+ "@geekmidas/manifest": "^10.0.0-alpha.47",
62
+ "@geekmidas/storage": "^10.0.0-alpha.47",
63
+ "@geekmidas/events": "^10.0.0-alpha.47",
64
+ "@geekmidas/db": "^10.0.0-alpha.47"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@geekmidas/manifest": {
@@ -183,49 +183,3 @@ describe('Api', () => {
183
183
  expect(recorded(api).authorizers).toEqual([]);
184
184
  });
185
185
  });
186
-
187
- describe('Api.fromManifest', () => {
188
- it('turns built routes, flat or partitioned, into the route table', () => {
189
- const routes = {
190
- public: [
191
- {
192
- method: 'GET',
193
- path: '/orders',
194
- handler: '.gkm/aws-lambda/routes/orders.handler',
195
- environment: ['DB_URL'],
196
- authorizer: 'iam',
197
- timeout: 30,
198
- memorySize: 1024,
199
- },
200
- ],
201
- internal: [
202
- {
203
- method: 'POST',
204
- path: '/jobs',
205
- handler: '.gkm/aws-lambda/routes/jobs.handler',
206
- environment: [],
207
- },
208
- ],
209
- };
210
-
211
- const api = Api.fromManifest(stack, 'Http', routes as never, {
212
- links: [db],
213
- });
214
- const { routes: recordedRoutes } = recorded(api);
215
-
216
- expect(recordedRoutes.map((r) => r.key)).toEqual([
217
- 'GET /orders',
218
- 'POST /jobs',
219
- ]);
220
- expect(recordedRoutes[0]!.handler).toMatchObject({
221
- timeout: '30 seconds',
222
- memory: '1024 MB',
223
- link: [db],
224
- });
225
- expect(recordedRoutes[0]!.args).toEqual({ auth: { iam: true } });
226
- expect(recordedRoutes[1]!.handler).toMatchObject({
227
- timeout: undefined,
228
- memory: undefined,
229
- });
230
- });
231
- });
@@ -0,0 +1,90 @@
1
+ import { ResourceType } from '@geekmidas/envkit/sst';
2
+ import { providedKeyFor, provideKey } from '@geekmidas/manifest';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { Encryption } from '../aws/Encryption';
5
+ import { provisionerFor, resolveEdges } from '../fromManifest';
6
+
7
+ // A KMS key, as far as the component reads one: what it was created with, and
8
+ // an ARN. The SST globals are stubbed the same way for every component.
9
+ vi.mock('@pulumi/aws', () => ({
10
+ kms: {
11
+ Key: class {
12
+ readonly arn: string;
13
+ constructor(
14
+ readonly name: string,
15
+ readonly args: Record<string, unknown>,
16
+ ) {
17
+ this.arn = `arn:aws:kms:eu-west-1:123456789012:key/${name}`;
18
+ }
19
+ },
20
+ },
21
+ }));
22
+
23
+ const stack = {} as never;
24
+
25
+ describe('Encryption on AWS', () => {
26
+ it('is two KMS keys: one that rotates for data, one HMAC key for the index', () => {
27
+ const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
28
+
29
+ expect(pii.key).toMatchObject({
30
+ name: 'PiiKey',
31
+ args: { enableKeyRotation: true, deletionWindowInDays: 30 },
32
+ });
33
+ expect(pii.indexKey).toMatchObject({
34
+ name: 'PiiIndexKey',
35
+ args: {
36
+ keyUsage: 'GENERATE_VERIFY_MAC',
37
+ customerMasterKeySpec: 'HMAC_256',
38
+ },
39
+ });
40
+ });
41
+
42
+ it('provides one kms:// URL, under the key the construct declared', () => {
43
+ const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
44
+ const url = new URL(pii.provides().url as string);
45
+
46
+ expect(url.protocol).toBe('kms:');
47
+ expect(url.hostname).toBe('eu-west-1');
48
+ expect(url.searchParams.get('key')).toBe(pii.key.arn);
49
+ expect(url.searchParams.get('index')).toBe(pii.indexKey.arn);
50
+ expect(
51
+ Object.keys(pii.provides()).map((role) =>
52
+ providedKeyFor('Pii', 'encryption', role),
53
+ ),
54
+ ).toEqual([provideKey('Pii', 'url')]);
55
+ expect(pii._type).toBe(ResourceType.Encryption);
56
+ });
57
+
58
+ it('grants a linked function the calls the cipher makes, on these keys only', () => {
59
+ const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
60
+
61
+ expect(pii.getSSTLink().include).toEqual([
62
+ {
63
+ permission: {
64
+ actions: ['kms:GenerateDataKey', 'kms:Decrypt'],
65
+ resources: [pii.key.arn],
66
+ },
67
+ },
68
+ {
69
+ permission: {
70
+ actions: ['kms:GenerateMac'],
71
+ resources: [pii.indexKey.arn],
72
+ },
73
+ },
74
+ ]);
75
+ });
76
+
77
+ it('is provisioned from the manifest in the stage’s region, and links its URL', () => {
78
+ const pii = provisionerFor('encryption')(
79
+ stack,
80
+ { kind: 'encryption', id: 'Pii', provides: ['PII_URL'] },
81
+ {},
82
+ {} as never,
83
+ ) as Encryption;
84
+
85
+ expect(new URL(pii.provides().url as string).hostname).toBe('eu-west-1');
86
+ expect(
87
+ resolveEdges([{ target: 'Pii', kind: 'encryption' }], { Pii: pii }),
88
+ ).toEqual({ link: [pii], envKeys: ['PII_URL'] });
89
+ });
90
+ });
@@ -1,15 +1,24 @@
1
1
  import { resolveEnvKeys } from '@geekmidas/envkit/sst';
2
2
  import { NoUrlForStage, providedKeyFor, provideKey } from '@geekmidas/manifest';
3
3
  import { describe, expect, it } from 'vitest';
4
+ import { App } from '../App';
5
+ import { Cron } from '../aws/Cron';
6
+ import { Database } from '../aws/Database';
7
+ import { DatabaseSchema } from '../aws/DerivedDatabase';
8
+ import { Function } from '../aws/Function';
4
9
  import { ObjectStorage } from '../aws/ObjectStorage';
10
+ import { RestApiSurface } from '../aws/RestApiSurface';
5
11
  import { type ProvidesMismatch, UnknownDeclarationKind } from '../errors';
6
12
  import {
7
13
  assertProvides,
14
+ mountRoutes,
8
15
  PROVISIONED_ELSEWHERE,
9
16
  type ProvisionedManifest,
17
+ provisionCompute,
10
18
  provisionerFor,
11
19
  resolveEdges,
12
20
  subscribeConsumers,
21
+ vpcFor,
13
22
  } from '../fromManifest';
14
23
 
15
24
  /**
@@ -363,7 +372,7 @@ describe('a queue’s one consumer', () => {
363
372
  provides: ['EMAILS_PUBLISHER_CONNECTION_STRING'],
364
373
  worker: {
365
374
  id: 'EmailsWorker',
366
- handler: '.gkm/aws-lambda/queues/emails.handler',
375
+ handler: 'apps/api/.gkm/aws/queues/emails.handler',
367
376
  dependencies: [{ target: 'Uploads', kind: 'objects' }],
368
377
  },
369
378
  },
@@ -372,7 +381,7 @@ describe('a queue’s one consumer', () => {
372
381
  );
373
382
 
374
383
  expect(consumed).toEqual([
375
- { handler: '.gkm/aws-lambda/queues/emails.handler', link: [uploads] },
384
+ { handler: 'apps/api/.gkm/aws/queues/emails.handler', link: [uploads] },
376
385
  ]);
377
386
  });
378
387
 
@@ -391,6 +400,162 @@ describe('a queue’s one consumer', () => {
391
400
  });
392
401
  });
393
402
 
403
+ describe('a surface’s endpoints', () => {
404
+ const routesOf = (surface: RestApiSurface) =>
405
+ (
406
+ surface as unknown as {
407
+ routes: { key: string; handler: any; args: unknown }[];
408
+ }
409
+ ).routes;
410
+
411
+ it('are mounted from the handlers the build wrote, each linked to what it depends on', () => {
412
+ // An API Gateway with no routes 404s everything — which is what a deploy
413
+ // produced while the routes sat in the manifest with nothing reading them.
414
+ const api = new RestApiSurface({} as never, 'Api');
415
+ const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
416
+
417
+ mountRoutes(
418
+ {
419
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
420
+ Api: {
421
+ kind: 'rest-api',
422
+ id: 'Api',
423
+ provides: ['API_URL'],
424
+ endpoints: [
425
+ {
426
+ id: 'ApiGET/users/{id}',
427
+ method: 'GET',
428
+ path: '/users/{id}',
429
+ handler: 'apps/api/.gkm/aws/routes/getUser.handler',
430
+ dependencies: [{ target: 'Uploads', kind: 'objects' }],
431
+ authorizer: 'none',
432
+ },
433
+ {
434
+ id: 'ApiGET/health',
435
+ method: 'GET',
436
+ path: '/health',
437
+ handler: 'apps/api/.gkm/aws/routes/health.handler',
438
+ dependencies: [],
439
+ },
440
+ ],
441
+ },
442
+ } as never,
443
+ { Uploads: uploads, Api: api } as unknown as ProvisionedManifest,
444
+ );
445
+
446
+ const [getUser, health] = routesOf(api);
447
+ expect(getUser?.key).toBe('GET /users/{id}');
448
+ expect(getUser?.handler.handler).toBe(
449
+ 'apps/api/.gkm/aws/routes/getUser.handler',
450
+ );
451
+ expect(getUser?.handler.link).toEqual([uploads]);
452
+ expect(getUser?.args).toEqual({});
453
+ // Least privilege: an endpoint is linked to its own edges, not the app's.
454
+ expect(health?.key).toBe('GET /health');
455
+ expect(health?.handler.link).toEqual([]);
456
+ });
457
+
458
+ it('leaves IAM to the gateway, and every other authorizer to the handler', () => {
459
+ const api = new RestApiSurface({} as never, 'Api');
460
+
461
+ api.mount({
462
+ method: 'POST',
463
+ path: '/x',
464
+ handler: 'x.handler',
465
+ authorizer: 'iam',
466
+ });
467
+ api.mount({
468
+ method: 'POST',
469
+ path: '/y',
470
+ handler: 'y.handler',
471
+ authorizer: 'session',
472
+ });
473
+
474
+ const [x, y] = routesOf(api);
475
+ expect(x?.args).toEqual({ auth: { iam: true } });
476
+ expect(y?.args).toEqual({});
477
+ });
478
+
479
+ it('runs a handler that reaches a database inside the database’s network', () => {
480
+ const vpc = { id: 'stub-vpc' } as never;
481
+ const cluster = new Database({} as never, 'Orders', { vpc });
482
+ const tenant = new DatabaseSchema('Billing', cluster, 'billing');
483
+
484
+ expect(vpcFor([cluster as never])).toBe(vpc);
485
+ // A schema tenant lives in its parent's cluster, so in its network too.
486
+ expect(vpcFor([{ _id: 'Uploads' } as never, tenant as never])).toBe(vpc);
487
+ expect(vpcFor([{ _id: 'Uploads' } as never])).toBeUndefined();
488
+ });
489
+ });
490
+
491
+ describe('functions and crons', () => {
492
+ const stack = new App({
493
+ name: 'shop',
494
+ stage: 'prod',
495
+ domain: 'example.com',
496
+ hostedZoneId: 'Z',
497
+ region: 'eu-west-1',
498
+ }).stack('jobs');
499
+ const argsOf = (component: unknown) =>
500
+ (component as { args: Record<string, any> }).args;
501
+
502
+ it('are deployed from the declarations, each linked to its own edges', () => {
503
+ // The `manifest.crons` / `manifest.functions` tables they were built from
504
+ // are gone: the declarations carry the handler, schedule and edges.
505
+ const vpc = { id: 'stub-vpc' } as never;
506
+ const orders = new Database({} as never, 'Orders', { vpc });
507
+ const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
508
+ const provisioned = {
509
+ Orders: orders,
510
+ Uploads: uploads,
511
+ } as unknown as ProvisionedManifest;
512
+
513
+ const [digest] = provisionCompute(
514
+ stack,
515
+ {
516
+ Orders: { kind: 'database', id: 'Orders' },
517
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
518
+ Invoice: {
519
+ kind: 'function',
520
+ id: 'Invoice',
521
+ handler: 'apps/api/.gkm/aws/functions/invoice.handler',
522
+ dependencies: [{ target: 'Uploads', kind: 'objects' }],
523
+ provides: ['INVOICE_URL'],
524
+ },
525
+ Digest: {
526
+ kind: 'cron',
527
+ id: 'Digest',
528
+ handler: 'apps/api/.gkm/aws/crons/digest.handler',
529
+ schedule: 'rate(1 day)',
530
+ dependencies: [{ target: 'Orders', kind: 'database' }],
531
+ },
532
+ } as never,
533
+ provisioned,
534
+ );
535
+
536
+ const invoice = provisioned.Invoice as unknown as Function;
537
+ expect(invoice).toBeInstanceOf(Function);
538
+ expect(argsOf(invoice)).toMatchObject({
539
+ name: stack.logicalPrefixedName('Invoice'),
540
+ handler: 'apps/api/.gkm/aws/functions/invoice.handler',
541
+ link: [uploads],
542
+ url: { authorization: 'iam' },
543
+ });
544
+ expect(argsOf(invoice).vpc).toBeUndefined();
545
+
546
+ // A cron is a schedule, not something to link to; its Lambda reaches
547
+ // the database, so it runs in the database's network.
548
+ expect(provisioned.Digest).toBeUndefined();
549
+ expect(digest).toBeInstanceOf(Cron);
550
+ expect(argsOf(digest).schedule).toBe('rate(1 day)');
551
+ expect(argsOf(digest!.processor)).toMatchObject({
552
+ handler: 'apps/api/.gkm/aws/crons/digest.handler',
553
+ link: [orders],
554
+ vpc,
555
+ });
556
+ });
557
+ });
558
+
394
559
  describe('kinds provisioned elsewhere', () => {
395
560
  it('skips a worker, which is a process rather than a resource', () => {
396
561
  // A worker in the manifest used to throw UnknownDeclarationKind at synth,
@@ -79,46 +79,6 @@ describe('Function', () => {
79
79
  invalidVars: ['SMTP_URL'],
80
80
  });
81
81
  });
82
-
83
- it('builds one function per manifest entry, named for the stack', () => {
84
- const functions = Function.fromManifest(
85
- stack,
86
- {
87
- billing: [
88
- {
89
- name: 'Invoice',
90
- handler: '.gkm/aws-lambda/functions/invoice.handler',
91
- environment: ['DB_URL'],
92
- timeout: 60,
93
- memorySize: 512,
94
- },
95
- ],
96
- ops: [
97
- {
98
- name: 'Ping',
99
- handler: '.gkm/aws-lambda/functions/ping.handler',
100
- environment: [],
101
- },
102
- ],
103
- } as never,
104
- { links: [db] },
105
- );
106
-
107
- expect(functions.map((f) => argsOf(f))).toEqual([
108
- expect.objectContaining({
109
- name: stack.logicalPrefixedName('Invoice'),
110
- timeout: '60 seconds',
111
- memory: '512 MB',
112
- link: [db],
113
- }),
114
- expect.objectContaining({
115
- name: stack.logicalPrefixedName('Ping'),
116
- timeout: undefined,
117
- memory: undefined,
118
- link: [],
119
- }),
120
- ]);
121
- });
122
82
  });
123
83
 
124
84
  describe('Cron', () => {
@@ -136,37 +96,6 @@ describe('Cron', () => {
136
96
  function: processor.arn,
137
97
  });
138
98
  });
139
-
140
- it('builds a validated function and a schedule per manifest cron', () => {
141
- const crons = Cron.fromManifest(
142
- stack,
143
- [
144
- {
145
- name: 'Digest',
146
- handler: '.gkm/aws-lambda/crons/digest.handler',
147
- schedule: 'cron(0 8 * * ? *)',
148
- environment: ['DB_URL'],
149
- timeout: 120,
150
- memorySize: 1024,
151
- },
152
- {
153
- name: 'Sweep',
154
- handler: '.gkm/aws-lambda/crons/sweep.handler',
155
- schedule: 'rate(1 hour)',
156
- environment: [],
157
- },
158
- ] as never,
159
- { links: [db], enabled: true },
160
- );
161
-
162
- const [digest, sweep] = crons.map((c) => argsOf(c));
163
- expect(digest).toMatchObject({
164
- schedule: 'cron(0 8 * * ? *)',
165
- enabled: true,
166
- function: expect.stringContaining('DigestFunction'),
167
- });
168
- expect(sweep).toMatchObject({ schedule: 'rate(1 hour)' });
169
- });
170
99
  });
171
100
 
172
101
  describe('App and Stack', () => {
@@ -28,6 +28,11 @@ const manifest = {
28
28
  provides: ['API_URL', 'API_TRUSTED_ORIGINS', 'API_COOKIE_DOMAIN'],
29
29
  },
30
30
  Orders: { kind: 'database', id: 'Orders', provides: ['ORDERS_URL'] },
31
+ Mailer: {
32
+ kind: 'email',
33
+ id: 'Mailer',
34
+ provides: ['MAILER_URL', 'MAILER_FROM'],
35
+ },
31
36
  Console: {
32
37
  kind: 'site',
33
38
  id: 'Console',
@@ -37,6 +42,7 @@ const manifest = {
37
42
  { target: 'Api', kind: 'rest-api' },
38
43
  { target: 'Orders', kind: 'database' },
39
44
  { target: 'UploadsServer', kind: 'file-server' },
45
+ { target: 'Mailer', kind: 'email' },
40
46
  ],
41
47
  provides: ['CONSOLE_URL'],
42
48
  },
@@ -101,6 +107,10 @@ describe('siteEnvironment', () => {
101
107
  Api: provided({ url: 'https://api.example.com' }),
102
108
  Orders: provided({ url: 'postgres://user:pw@db/orders' }),
103
109
  UploadsServer: provided({ url: 'https://files.example.com' }),
110
+ Mailer: provided({
111
+ url: 'smtp://user:pw@email-smtp.example.com:587',
112
+ from: 'hello@shop.test',
113
+ }),
104
114
  }),
105
115
  );
106
116
 
@@ -122,6 +132,13 @@ describe('siteEnvironment', () => {
122
132
  expect(built()).not.toHaveProperty('VITE_ORDERS_URL');
123
133
  });
124
134
 
135
+ it('has no inbox to share for deployed mail, and never shares the mail URL', () => {
136
+ // The inbox is Mailpit's, and exists only locally: deployed, an app that
137
+ // reads it falls back to the phone's own mail app.
138
+ expect(built()).not.toHaveProperty('VITE_MAILER_INBOX_URL');
139
+ expect(built()).not.toHaveProperty('VITE_MAILER_URL');
140
+ });
141
+
125
142
  it('fails loudly on an edge nothing provisioned', () => {
126
143
  // A smaller environment builds and then fails at runtime against
127
144
  // `http:///`, with nothing to point at.
@@ -1,10 +1,5 @@
1
1
  import path from 'node:path';
2
2
  import { EnvValidationError } from '@geekmidas/envkit/sst';
3
- import {
4
- flattenManifestField,
5
- type ManifestField,
6
- type RouteInfo,
7
- } from '@geekmidas/manifest';
8
3
  import { type GkmLinkable, ResourceType } from '../Linkable';
9
4
  import { LinkedEnvironment } from '../LinkedEnvironment';
10
5
  import type { StackType } from '../Stack';
@@ -157,44 +152,6 @@ export class Api<
157
152
  }
158
153
  }
159
154
 
160
- /**
161
- * Build an `Api` from a `gkm build` manifest's `routes` field (flat or
162
- * partitioned): each `RouteInfo` becomes a route (env vars, authorizer,
163
- * timeout/memory mapped). Supply `authorizers` (JWT/Lambda settings),
164
- * `links`, and any native args via `props`.
165
- *
166
- * ```ts
167
- * import { manifest } from './.gkm/manifest/aws';
168
- * Api.fromManifest(stack, 'Api', manifest.routes, { links: [db] });
169
- * ```
170
- */
171
- static fromManifest<
172
- TAuthorizers extends Record<string, unknown> = {},
173
- TStage extends string = string,
174
- TDomain extends string = string,
175
- >(
176
- stack: StackType<TStage, TDomain>,
177
- id: string,
178
- routes: ManifestField<RouteInfo>,
179
- props: Omit<ApiProps<TAuthorizers>, 'routes'> = {},
180
- ): Api<TAuthorizers, TStage, TDomain> {
181
- const routeTable = flattenManifestField(routes).map(
182
- (route): Route<AuthorizerName<TAuthorizers>> => ({
183
- method: route.method as Route['method'],
184
- path: route.path,
185
- handler: route.handler,
186
- environment: route.environment,
187
- authorizer: route.authorizer as AuthorizerName<TAuthorizers>,
188
- timeout: route.timeout ? `${route.timeout} seconds` : undefined,
189
- memory: route.memorySize ? `${route.memorySize} MB` : undefined,
190
- }),
191
- );
192
- return new Api(stack, id, {
193
- ...props,
194
- routes: routeTable,
195
- } as ApiProps<TAuthorizers>);
196
- }
197
-
198
155
  /** Resolves a route's `authorizer` name to the SST `auth` option. */
199
156
  private static buildRouteAuth(
200
157
  route: Route<string>,
@@ -1,11 +1,5 @@
1
- import {
2
- type CronInfo,
3
- flattenManifestField,
4
- type ManifestField,
5
- } from '@geekmidas/manifest';
6
- import type { GkmLinkable } from '../Linkable';
7
1
  import type { StackType } from '../Stack';
8
- import { Function } from './Function';
2
+ import type { Function } from './Function';
9
3
 
10
4
  export type CronExpressionValue = number | '*' | '?' | `${number}/${number}`;
11
5
  export type CronExpressionDay =
@@ -52,6 +46,9 @@ export class Cron<
52
46
  TStage extends string = string,
53
47
  TDomain extends string = string,
54
48
  > extends sst.aws.CronV2 {
49
+ /** The function it invokes. */
50
+ readonly processor: CronProps['processor'];
51
+
55
52
  constructor(
56
53
  _stack: StackType<TStage, TDomain>,
57
54
  name: string,
@@ -63,44 +60,7 @@ export class Cron<
63
60
  schedule,
64
61
  function: processor.arn,
65
62
  });
66
- }
67
-
68
- /**
69
- * Build one `Cron` per entry in a `gkm build` manifest's `crons` field (flat
70
- * or partitioned). Each cron's handler becomes a validated `Function` (the
71
- * cron's target), so pass `links` for that function's env validation;
72
- * remaining `props` are CronV2 args.
73
- *
74
- * ```ts
75
- * Cron.fromManifest(stack, manifest.crons, { links: [db] });
76
- * ```
77
- */
78
- static fromManifest<
79
- TStage extends string = string,
80
- TDomain extends string = string,
81
- >(
82
- stack: StackType<TStage, TDomain>,
83
- crons: ManifestField<CronInfo>,
84
- props: Omit<CronProps, 'processor' | 'schedule'> & {
85
- links?: GkmLinkable[];
86
- } = {},
87
- ): Cron<TStage, TDomain>[] {
88
- const { links, ...cronArgs } = props;
89
- return flattenManifestField(crons).map((cron) => {
90
- const processor = new Function(stack, `${cron.name}Function`, {
91
- name: stack.logicalPrefixedName(cron.name),
92
- handler: cron.handler,
93
- envVars: cron.environment,
94
- links,
95
- timeout: cron.timeout ? `${cron.timeout} seconds` : undefined,
96
- memory: cron.memorySize ? `${cron.memorySize} MB` : undefined,
97
- });
98
- return new Cron(stack, cron.name, {
99
- ...cronArgs,
100
- processor,
101
- schedule: cron.schedule as CronSchedule,
102
- });
103
- });
63
+ this.processor = processor;
104
64
  }
105
65
  }
106
66
 
@@ -0,0 +1,91 @@
1
+ import { kmsUrl } from '@geekmidas/manifest';
2
+ import { kms } from '@pulumi/aws';
3
+ import { type GkmLinkable, ResourceType } from '../Linkable';
4
+ import type { StackType } from '../Stack';
5
+
6
+ /**
7
+ * `Encryption` — two KMS keys, and the infra half of the `encryption` kind.
8
+ *
9
+ * One key encrypts, by envelope: each value is sealed with a data key KMS
10
+ * mints and wraps, so the key itself never leaves KMS. It rotates yearly, and
11
+ * KMS keeps every version it rotated through, so nothing written is ever
12
+ * stranded. The other is an HMAC key for the blind index, which does not
13
+ * rotate — a rotated index key would make every stored index miss.
14
+ *
15
+ * Both are reached through one `kms://` URL, and a function linked to this is
16
+ * granted exactly the calls the cipher makes on exactly these two keys. Not
17
+ * `kms:*`, and not any other key in the account.
18
+ *
19
+ * It holds the keys rather than being one, and reports its own type, so a link
20
+ * resolves to `<ID>_URL` — the key the construct declared.
21
+ */
22
+ export class Encryption<
23
+ TStage extends string = string,
24
+ TDomain extends string = string,
25
+ > implements GkmLinkable
26
+ {
27
+ readonly _id: string;
28
+
29
+ readonly key: kms.Key;
30
+ readonly indexKey: kms.Key;
31
+ private readonly region: string;
32
+
33
+ get _type() {
34
+ return ResourceType.Encryption;
35
+ }
36
+
37
+ constructor(
38
+ _stack: StackType<TStage, TDomain>,
39
+ name: string,
40
+ props: EncryptionProps,
41
+ ) {
42
+ this._id = name;
43
+ this.region = props.region;
44
+
45
+ this.key = new kms.Key(`${name}Key`, {
46
+ description: `${name}: encrypts what the application stores`,
47
+ enableKeyRotation: true,
48
+ deletionWindowInDays: props.deletionWindowInDays ?? 30,
49
+ });
50
+ this.indexKey = new kms.Key(`${name}IndexKey`, {
51
+ description: `${name}: the blind index's HMAC key`,
52
+ keyUsage: 'GENERATE_VERIFY_MAC',
53
+ customerMasterKeySpec: 'HMAC_256',
54
+ deletionWindowInDays: props.deletionWindowInDays ?? 30,
55
+ });
56
+ }
57
+
58
+ provides(): Record<string, $util.Input<string>> {
59
+ return {
60
+ url: $util
61
+ .all([this.key.arn, this.indexKey.arn])
62
+ .apply(([key, index]) => kmsUrl({ region: this.region, key, index })),
63
+ };
64
+ }
65
+
66
+ getSSTLink() {
67
+ return {
68
+ properties: { ...this.provides() },
69
+ include: [
70
+ sst.aws.permission({
71
+ actions: ['kms:GenerateDataKey', 'kms:Decrypt'],
72
+ resources: [this.key.arn],
73
+ }),
74
+ sst.aws.permission({
75
+ actions: ['kms:GenerateMac'],
76
+ resources: [this.indexKey.arn],
77
+ }),
78
+ ],
79
+ };
80
+ }
81
+ }
82
+
83
+ export interface EncryptionProps {
84
+ /** Where the keys live — the stage's region. */
85
+ region: string;
86
+ /**
87
+ * How long a deleted key can still be recovered, 7–30 days. A key's
88
+ * deletion is the deletion of everything it encrypted, so the longest.
89
+ */
90
+ deletionWindowInDays?: number;
91
+ }
@@ -1,9 +1,4 @@
1
1
  import type { EnvValidator, ValidationResult } from '@geekmidas/envkit/sst';
2
- import {
3
- type FunctionInfo,
4
- flattenManifestField,
5
- type ManifestField,
6
- } from '@geekmidas/manifest';
7
2
  import { type GkmLinkable, ResourceType } from '../Linkable';
8
3
  import { LinkedEnvironment } from '../LinkedEnvironment';
9
4
  import type { StackType } from '../Stack';
@@ -66,9 +61,10 @@ export class Function<
66
61
  super(id, {
67
62
  ...fnArgs,
68
63
  environment: mergedEnvironment,
69
- // Linking is managed via the `links`/`envVars` flow, so this overrides
70
- // any native `link` passed through `fnArgs`.
71
- link: linked.resolveLink(envVars),
64
+ // Resolved from `links` when `envVars` names what it needs; otherwise
65
+ // the `link` it was given — the edges a manifest declared.
66
+ link:
67
+ envVars.length > 0 ? linked.resolveLink(envVars) : (fnArgs.link ?? []),
72
68
  runtime: runtime ?? 'nodejs24.x',
73
69
  logging: logging ?? { format: 'json' },
74
70
  });
@@ -78,40 +74,15 @@ export class Function<
78
74
  this.envVars = envVars;
79
75
  }
80
76
 
77
+ /** Its address, for whatever declared an edge to it. */
78
+ provides(): Record<string, $util.Input<string>> {
79
+ return { url: this.url };
80
+ }
81
+
81
82
  /** Re-runs validation and returns the result (does not throw). */
82
83
  validate(): ValidationResult {
83
84
  return this.validator.validate(this.envVars);
84
85
  }
85
-
86
- /**
87
- * Build one `Function` per entry in a `gkm build` manifest's `functions`
88
- * field (flat or partitioned). Shared `props` (e.g. `links`) apply to every
89
- * function.
90
- *
91
- * ```ts
92
- * Function.fromManifest(stack, manifest.functions, { links: [db] });
93
- * ```
94
- */
95
- static fromManifest<
96
- TStage extends string = string,
97
- TDomain extends string = string,
98
- >(
99
- stack: StackType<TStage, TDomain>,
100
- functions: ManifestField<FunctionInfo>,
101
- props: Omit<FunctionProps, 'handler'> = {},
102
- ): Function<TStage, TDomain>[] {
103
- return flattenManifestField(functions).map(
104
- (fn) =>
105
- new Function(stack, fn.name, {
106
- ...props,
107
- name: stack.logicalPrefixedName(fn.name),
108
- handler: fn.handler,
109
- envVars: fn.environment,
110
- timeout: fn.timeout ? `${fn.timeout} seconds` : undefined,
111
- memory: fn.memorySize ? `${fn.memorySize} MB` : undefined,
112
- }),
113
- );
114
- }
115
86
  }
116
87
 
117
88
  export interface FunctionProps extends sst.aws.FunctionArgs {
@@ -87,6 +87,7 @@ export class Queue<
87
87
  handler: consumer.handler,
88
88
  link: [this, ...(consumer.link ?? [])],
89
89
  runtime: 'nodejs24.x',
90
+ ...(consumer.vpc ? { vpc: consumer.vpc } : {}),
90
91
  ...(consumer.timeout ? { timeout: consumer.timeout } : {}),
91
92
  },
92
93
  consumer.batchSize ? { batch: { size: consumer.batchSize } } : {},
@@ -104,10 +105,12 @@ export class Queue<
104
105
 
105
106
  /** The queue's one consumer, as {@link Queue.consume} subscribes it. */
106
107
  export interface QueueConsumer {
107
- /** The built handler — `.gkm/aws-lambda/queues/emails.handler`. */
108
+ /** The built handler — `apps/api/.gkm/aws/queues/emails.handler`. */
108
109
  handler: string;
109
110
  /** What the consumer depends on; the queue itself is always linked. */
110
111
  link?: unknown[];
112
+ /** The database's network, when the consumer reaches one. */
113
+ vpc?: sst.aws.Vpc;
111
114
  timeout?: `${number} seconds` | `${number} minutes`;
112
115
  batchSize?: number;
113
116
  }
@@ -4,20 +4,10 @@ import type { StackType } from '../Stack';
4
4
  /**
5
5
  * `RestApiSurface` — an HTTP API, and the infra half of the `rest-api` kind.
6
6
  *
7
- * Deliberately *not* `Api`, which takes a route table and validates each
8
- * route's environment at synth. That component is the endpoint pipeline's, and
9
- * it needs routes; this one is the manifest's, and the manifest's `rest-api`
10
- * node currently carries `endpoints: []` for an application's own API.
11
- *
12
- * So what this provisions is the surface and nothing on it. An API Gateway with
13
- * no routes 404s everything, which is the honest state of things — and it is
14
- * still worth provisioning, because the *address* is what everything downstream
15
- * needs: a site inlines it as `VITE_API_URL`, an auth server puts it on its
16
- * trusted-origin list, and the cookie domain derives from it. Those are blocked
17
- * on the API existing, not on it answering.
18
- *
19
- * The two collapse into one component when the endpoint merge lands and the
20
- * surface node carries its routes.
7
+ * Provisioned before its routes, because its *address* is what everything
8
+ * downstream needs: a site inlines it as `VITE_API_URL`, an auth server puts it
9
+ * on its trusted-origin list, and the cookie domain derives from it. The routes
10
+ * are mounted once everything they link to exists — see {@link mount}.
21
11
  */
22
12
 
23
13
  export interface RestApiSurfaceProps extends sst.aws.ApiGatewayV2Args {
@@ -82,6 +72,25 @@ export class RestApiSurface<
82
72
  };
83
73
  }
84
74
 
75
+ /**
76
+ * Mount one endpoint: a Lambda running the handler the build wrote for it,
77
+ * linked to exactly what that endpoint depends on.
78
+ */
79
+ mount(endpoint: SurfaceRoute) {
80
+ return this.route(
81
+ `${endpoint.method} ${endpoint.path}`,
82
+ {
83
+ handler: endpoint.handler,
84
+ link: endpoint.link ?? [],
85
+ runtime: 'nodejs24.x',
86
+ ...(endpoint.vpc ? { vpc: endpoint.vpc } : {}),
87
+ },
88
+ // `iam` is the one authorizer the gateway enforces itself; every other
89
+ // is the endpoint's own, checked in the handler.
90
+ endpoint.authorizer === 'iam' ? { auth: { iam: true } } : {},
91
+ );
92
+ }
93
+
85
94
  override getSSTLink() {
86
95
  const link = super.getSSTLink();
87
96
  return {
@@ -90,3 +99,16 @@ export class RestApiSurface<
90
99
  };
91
100
  }
92
101
  }
102
+
103
+ /** One endpoint, as {@link RestApiSurface.mount} mounts it. */
104
+ export interface SurfaceRoute {
105
+ method: string;
106
+ path: string;
107
+ /** The built handler — `apps/api/.gkm/aws/routes/getUser.handler`. */
108
+ handler: string;
109
+ /** What the endpoint depends on, and nothing else. */
110
+ link?: unknown[];
111
+ /** The database's network, when the endpoint reaches one. */
112
+ vpc?: sst.aws.Vpc;
113
+ authorizer?: string;
114
+ }
@@ -41,12 +41,15 @@ import {
41
41
  withCacheTable,
42
42
  } from './aws/Cache';
43
43
  import { Credential } from './aws/Credential';
44
+ import { Cron, type CronSchedule } from './aws/Cron';
44
45
  import { Database, DatabaseNeedsVpc, type DatabaseProps } from './aws/Database';
45
46
  import { DatabaseBootstrap } from './aws/DatabaseBootstrap';
46
47
  import { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
47
48
  import { Email, EmailNeedsSender } from './aws/Email';
49
+ import { Encryption, type EncryptionProps } from './aws/Encryption';
48
50
  import { ExternalApi, type ExternalApiProps } from './aws/ExternalApi';
49
51
  import { FileServer } from './aws/FileServer';
52
+ import { Function } from './aws/Function';
50
53
  import { ObjectStorage } from './aws/ObjectStorage';
51
54
  import { Queue } from './aws/Queue';
52
55
  import { RestApiSurface } from './aws/RestApiSurface';
@@ -378,17 +381,12 @@ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
378
381
  },
379
382
 
380
383
  /**
381
- * The surface, without its routes.
384
+ * The surface, without its routes yet.
382
385
  *
383
- * An API Gateway with no routes 404s everything, and that is the honest
384
- * state: the *surface* is what this kind declares, and mounting handlers on
385
- * it is the endpoint merge that has not landed — routes still reach the
386
- * deploy target through the separate `RouteInfo[]` pipeline.
387
- *
388
- * Provisioning it anyway is not ceremony. Its address is what a site inlines
389
- * as `VITE_API_URL`, what an auth server puts on its trusted-origin list, and
390
- * what the cookie domain derives from — so everything downstream of the API
391
- * is blocked on the API *existing*, not on it answering.
386
+ * Its address is what a site inlines as `VITE_API_URL`, what an auth server
387
+ * puts on its trusted-origin list, and what the cookie domain derives from —
388
+ * so it is provisioned in order, and its endpoints are mounted by
389
+ * {@link mountRoutes} once everything they link to exists.
392
390
  */
393
391
  'rest-api': (stack, d, props, context) =>
394
392
  new RestApiSurface(stack, d.id, {
@@ -401,6 +399,17 @@ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
401
399
  // The same storage as a secret, under a different role — see the component.
402
400
  credential: (stack, d, props) => new Credential(stack, d.id, props),
403
401
 
402
+ // In the stage's region, as mail is: a key in another region is a key
403
+ // every call crosses the world to reach.
404
+ encryption: (stack, d, props) =>
405
+ new Encryption(stack, d.id, {
406
+ ...(props as Partial<EncryptionProps>),
407
+ region:
408
+ (props as Partial<EncryptionProps>).region ??
409
+ $app.providers?.aws?.region ??
410
+ 'us-east-1',
411
+ }),
412
+
404
413
  // Nothing to provision: the URL this stage calls, and the credentials kept
405
414
  // as a secret.
406
415
  'external-api': (stack, d, props) => {
@@ -593,9 +602,7 @@ function rootCluster(component: Provisioned): Database {
593
602
  * printed: one line per route, naming what it can reach.
594
603
  *
595
604
  * A surface with no routes says so rather than printing nothing, because "no
596
- * routes yet" and "this printed nothing" look identical otherwise — and for an
597
- * application's own API that is currently the true state, pending the endpoint
598
- * merge.
605
+ * routes" and "this printed nothing" look identical otherwise.
599
606
  *
600
607
  * Pure, so what gets printed can be asserted without a deploy.
601
608
  */
@@ -751,21 +758,132 @@ export function isServed(id: string, manifest: ConstructManifest): boolean {
751
758
  }
752
759
 
753
760
  /**
754
- * Kinds this stack provisions nothing for of their own.
761
+ * Kinds the in-order pass skips.
755
762
  *
756
763
  * - `mobile-app` ships through EAS and the stores. It is in the manifest for
757
764
  * what it makes the surfaces trust, which `callersOf` reads off the graph.
758
765
  * - `worker` is a process, not a resource: what it runs is provisioned as
759
766
  * what it is — a queue's consumer by {@link subscribeConsumers}.
760
- * - `cron` and `function` are Lambdas built from `manifest.crons` and
761
- * `manifest.functions` (`Cron.fromManifest`, `Function.fromManifest`).
767
+ * - `cron` and `function` are Lambdas linked to what they depend on, so they
768
+ * are provisioned once all of it exists, by {@link provisionCompute}.
762
769
  */
763
770
  export const PROVISIONED_ELSEWHERE: ReadonlySet<DeclarationKind> =
764
771
  new Set<DeclarationKind>(['mobile-app', 'worker', 'cron', 'function']);
765
772
 
773
+ /**
774
+ * Every function and cron: a Lambda running the handler the build wrote,
775
+ * linked to exactly what it depends on, in the database's network when it
776
+ * reaches one. A cron is that Lambda on its schedule; a function is reached
777
+ * at its URL, which only callers granted IAM access can invoke.
778
+ *
779
+ * After everything else is provisioned, because each links to things
780
+ * `provisionOrder` does not order before it.
781
+ */
782
+ export function provisionCompute(
783
+ stack: StackType,
784
+ manifest: ConstructManifest,
785
+ provisioned: ProvisionedManifest,
786
+ ): Cron[] {
787
+ const crons: Cron[] = [];
788
+
789
+ for (const [id, declaration] of Object.entries(manifest)) {
790
+ if (declaration?.kind !== 'function' && declaration?.kind !== 'cron') {
791
+ continue;
792
+ }
793
+
794
+ const { link } = resolveEdges(declaration.dependencies, provisioned);
795
+ const vpc = vpcFor(link);
796
+ const lambda = new Function(
797
+ stack,
798
+ declaration.kind === 'cron' ? `${id}Function` : id,
799
+ {
800
+ name: stack.logicalPrefixedName(id),
801
+ handler: declaration.handler,
802
+ link,
803
+ ...(vpc ? { vpc } : {}),
804
+ ...(declaration.kind === 'function'
805
+ ? { url: { authorization: 'iam' } }
806
+ : {}),
807
+ },
808
+ );
809
+
810
+ if (declaration.kind === 'cron') {
811
+ crons.push(
812
+ new Cron(stack, id, {
813
+ processor: lambda,
814
+ schedule: declaration.schedule as CronSchedule,
815
+ }),
816
+ );
817
+ continue;
818
+ }
819
+
820
+ assertProvides(
821
+ id,
822
+ declaration.provides,
823
+ Object.keys(lambda.provides()).map((role) =>
824
+ providedKeyFor(id, declaration.kind, role),
825
+ ),
826
+ );
827
+ provisioned[id] = lambda;
828
+ }
829
+
830
+ return crons;
831
+ }
832
+
766
833
  /** A provisioned queue: what {@link subscribeConsumers} subscribes through. */
767
834
  interface Consumable {
768
- consume(consumer: { handler: string; link?: unknown[] }): unknown;
835
+ consume(consumer: {
836
+ handler: string;
837
+ link?: unknown[];
838
+ vpc?: sst.aws.Vpc;
839
+ }): unknown;
840
+ }
841
+
842
+ /**
843
+ * The network a function has to run in to reach what it links to.
844
+ *
845
+ * A database is private to its VPC, so a handler linked to one — or to a
846
+ * schema tenant or reader of one — runs inside it, or its connection times out.
847
+ */
848
+ export function vpcFor(link: readonly Provisioned[]): sst.aws.Vpc | undefined {
849
+ for (const component of link) {
850
+ const cluster = rootCluster(component);
851
+ if (cluster instanceof Database) return cluster.vpc;
852
+ }
853
+
854
+ return undefined;
855
+ }
856
+
857
+ /**
858
+ * Mount every surface's endpoints — the handlers the build wrote, each linked
859
+ * to exactly what that endpoint depends on.
860
+ *
861
+ * After every construct is provisioned, for the reason consumers are: an
862
+ * endpoint links to things `provisionOrder` does not order before its surface.
863
+ */
864
+ export function mountRoutes(
865
+ manifest: ConstructManifest,
866
+ provisioned: ProvisionedManifest,
867
+ ): void {
868
+ for (const [id, declaration] of Object.entries(manifest)) {
869
+ if (declaration?.kind !== 'rest-api') continue;
870
+
871
+ const surface = provisioned[id];
872
+ if (!(surface instanceof RestApiSurface)) continue;
873
+
874
+ for (const endpoint of declaration.endpoints) {
875
+ const { link } = resolveEdges(endpoint.dependencies, provisioned);
876
+ const vpc = vpcFor(link);
877
+ surface.mount({
878
+ method: endpoint.method,
879
+ path: endpoint.path,
880
+ handler: endpoint.handler,
881
+ link,
882
+ ...(vpc ? { vpc } : {}),
883
+ ...(endpoint.authorizer ? { authorizer: endpoint.authorizer } : {}),
884
+ });
885
+ }
886
+ }
769
887
  }
770
888
 
771
889
  /**
@@ -786,7 +904,12 @@ export function subscribeConsumers(
786
904
  if (!queue) continue;
787
905
 
788
906
  const { link } = resolveEdges(declaration.worker.dependencies, provisioned);
789
- queue.consume({ handler: declaration.worker.handler, link });
907
+ const vpc = vpcFor(link);
908
+ queue.consume({
909
+ handler: declaration.worker.handler,
910
+ link,
911
+ ...(vpc ? { vpc } : {}),
912
+ });
790
913
  }
791
914
  }
792
915
 
@@ -902,8 +1025,11 @@ export function fromManifest(
902
1025
  provisioned[id] = component;
903
1026
  }
904
1027
 
905
- // Each queue's one consumer, once everything it links to exists.
1028
+ // The functions and crons, each queue's one consumer and each surface's
1029
+ // endpoints, once everything they link to exists.
1030
+ provisionCompute(stack, manifest, provisioned);
906
1031
  subscribeConsumers(manifest, provisioned);
1032
+ mountRoutes(manifest, provisioned);
907
1033
 
908
1034
  for (const line of describeRoutes(manifest)) console.log(line);
909
1035
 
package/src/sst/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  } from './aws/DatabaseBootstrap';
35
35
  export { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
36
36
  export { Email, EmailNeedsUrl, type EmailProps } from './aws/Email';
37
+ export { Encryption, type EncryptionProps } from './aws/Encryption';
37
38
  export { FileServer, type FileServerProps } from './aws/FileServer';
38
39
  export { Function, type FunctionProps } from './aws/Function';
39
40
  export {
@@ -1,77 +0,0 @@
1
- // Type-level checks that the `fromManifest` integrators accept the unified
2
- // `gkm build` manifest shape (`export const manifest = { … } as const`) — both
3
- // the flat and the partitioned `ManifestField` forms. Checked by `ts:check:sst`.
4
-
5
- import type { Manifest, ManifestField, RouteInfo } from '@geekmidas/manifest';
6
- import { App } from '../App';
7
- import { Api } from '../aws/Api';
8
- import { Cron } from '../aws/Cron';
9
- import { Function } from '../aws/Function';
10
- import { type GkmLinkable, ResourceType } from '../Linkable';
11
-
12
- const stack = new App({
13
- name: 'a',
14
- stage: 'dev',
15
- domain: 'example.com',
16
- hostedZoneId: 'Z',
17
- region: 'us-east-1',
18
- }).stack('api');
19
-
20
- const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
21
-
22
- // Mirrors a generated `manifest/aws.ts` — one object, `as const`.
23
- const manifest = {
24
- routes: [
25
- {
26
- path: '/users/{id}',
27
- method: 'GET',
28
- handler: 'users.handler',
29
- authorizer: 'none',
30
- environment: ['DB_HOST'],
31
- timeout: 30,
32
- memorySize: 1024,
33
- },
34
- ],
35
- functions: [
36
- { name: 'worker', handler: 'worker.handler', environment: ['DB_URL'] },
37
- ],
38
- crons: [
39
- { name: 'nightly', handler: 'nightly.handler', schedule: 'rate(1 day)' },
40
- ],
41
- } as const satisfies Manifest;
42
-
43
- // Each integrator takes the manifest *field*.
44
- export const api = Api.fromManifest(stack, 'Api', manifest.routes, {
45
- links: [db],
46
- authorizers: { jwt: { issuer: 'https://i', audiences: ['a'] } },
47
- });
48
- export const workers = Function.fromManifest(stack, manifest.functions, {
49
- links: [db],
50
- });
51
- export const crons = Cron.fromManifest(stack, manifest.crons, { links: [db] });
52
-
53
- // The partitioned `ManifestField` form is also accepted.
54
- const partitionedRoutes = {
55
- admin: [
56
- {
57
- path: '/admin',
58
- method: 'GET',
59
- handler: 'admin.handler',
60
- authorizer: 'iam',
61
- },
62
- ],
63
- default: [
64
- {
65
- path: '/health',
66
- method: 'GET',
67
- handler: 'health.handler',
68
- authorizer: 'none',
69
- },
70
- ],
71
- } satisfies ManifestField<RouteInfo>;
72
- export const partitionedApi = Api.fromManifest(
73
- stack,
74
- 'PartApi',
75
- partitionedRoutes,
76
- { links: [db] },
77
- );