@geekmidas/cloud 10.0.0-alpha.1 → 10.0.0-alpha.100

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 (48) hide show
  1. package/README.md +14 -6
  2. package/dist/index.cjs +3 -3
  3. package/dist/index.d.cts +1 -1
  4. package/dist/index.d.mts +1 -1
  5. package/dist/index.mjs +2 -3
  6. package/dist/utils/index.cjs +106 -5
  7. package/dist/utils/index.cjs.map +1 -0
  8. package/dist/utils/index.d.cts +62 -2
  9. package/dist/utils/index.d.cts.map +1 -0
  10. package/dist/utils/index.d.mts +62 -2
  11. package/dist/utils/index.d.mts.map +1 -0
  12. package/dist/utils/index.mjs +78 -2
  13. package/dist/utils/index.mjs.map +1 -0
  14. package/package.json +16 -15
  15. package/src/dokploy/Application.ts +52 -5
  16. package/src/dokploy/__tests__/Application.provider.spec.ts +206 -0
  17. package/src/sst/__tests__/api.spec.ts +185 -0
  18. package/src/sst/__tests__/encryption.spec.ts +90 -0
  19. package/src/sst/__tests__/fromManifest.integration.spec.ts +427 -0
  20. package/src/sst/__tests__/fromManifest.spec.ts +306 -5
  21. package/src/sst/__tests__/function.spec.ts +121 -0
  22. package/src/sst/__tests__/links.spec.ts +209 -0
  23. package/src/sst/__tests__/surfaces.spec.ts +17 -0
  24. package/src/sst/__type-tests__/overrides.type-test.ts +152 -0
  25. package/src/sst/aws/Api.ts +16 -44
  26. package/src/sst/aws/Cache.ts +4 -4
  27. package/src/sst/aws/Credential.ts +42 -9
  28. package/src/sst/aws/Cron.ts +5 -45
  29. package/src/sst/aws/Database.ts +15 -1
  30. package/src/sst/aws/DatabaseBootstrap.ts +3 -0
  31. package/src/sst/aws/Encryption.ts +91 -0
  32. package/src/sst/aws/ExternalApi.ts +58 -0
  33. package/src/sst/aws/Function.ts +9 -38
  34. package/src/sst/aws/Queue.ts +34 -1
  35. package/src/sst/aws/RestApiSurface.ts +36 -14
  36. package/src/sst/aws/bootstrap/handler.ts +10 -1
  37. package/src/sst/fromManifest.ts +261 -44
  38. package/src/sst/index.ts +6 -1
  39. package/src/sst/overrides.ts +133 -0
  40. package/dist/index-B5CZ1xVf.d.cts +0 -64
  41. package/dist/index-B5CZ1xVf.d.cts.map +0 -1
  42. package/dist/index-DhHRjduZ.d.mts +0 -64
  43. package/dist/index-DhHRjduZ.d.mts.map +0 -1
  44. package/dist/utils-B1a2UuEO.cjs +0 -129
  45. package/dist/utils-B1a2UuEO.cjs.map +0 -1
  46. package/dist/utils-DMOXJ27j.mjs +0 -83
  47. package/dist/utils-DMOXJ27j.mjs.map +0 -1
  48. package/src/sst/__type-tests__/manifest.type-test.ts +0 -77
@@ -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.
@@ -0,0 +1,152 @@
1
+ // Type-level tests for `fromManifest`'s overrides, inferred from the manifest.
2
+ // Checked by `ts:check:sst`; each `@ts-expect-error` self-validates: if the
3
+ // inference regresses, the now-unused directive becomes a TS error the gate
4
+ // catches.
5
+
6
+ import type { ConstructManifest } from '@geekmidas/manifest';
7
+ import { App } from '../App';
8
+ import { fromManifest } from '../fromManifest';
9
+
10
+ const stack = new App({
11
+ name: 'a',
12
+ stage: 'dev',
13
+ domain: 'example.com',
14
+ hostedZoneId: 'Z',
15
+ region: 'us-east-1',
16
+ }).stack('shop');
17
+
18
+ declare const vpc: sst.aws.Vpc;
19
+
20
+ // The shape `gkm build` emits: `as const satisfies ConstructManifest`.
21
+ const constructs = {
22
+ Orders: {
23
+ kind: 'database',
24
+ id: 'Orders',
25
+ engine: 'postgres',
26
+ provides: ['ORDERS_URL'],
27
+ },
28
+ Billing: {
29
+ kind: 'database-schema',
30
+ id: 'Billing',
31
+ of: 'Orders',
32
+ schema: 'billing',
33
+ provides: ['BILLING_URL'],
34
+ },
35
+ Sessions: { kind: 'cache', id: 'Sessions', provides: ['SESSIONS_URL'] },
36
+ Rates: {
37
+ kind: 'cache',
38
+ id: 'Rates',
39
+ of: 'Orders',
40
+ provides: ['RATES_URL'],
41
+ },
42
+ Mail: { kind: 'email', id: 'Mail', provides: ['MAIL_URL'] },
43
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
44
+ Api: {
45
+ kind: 'rest-api',
46
+ id: 'Api',
47
+ path: 'apps/api',
48
+ endpoints: [],
49
+ provides: ['API_URL'],
50
+ },
51
+ } as const satisfies ConstructManifest;
52
+
53
+ // Valid: the two the synth cannot guess, plus an optional bucket prop.
54
+ export const ok = fromManifest(stack, constructs, {
55
+ Orders: { vpc },
56
+ Mail: { from: 'no-reply@example.com' },
57
+ Uploads: { cors: false },
58
+ });
59
+
60
+ // A database without its VPC.
61
+ // @ts-expect-error — `Orders` is a database: `vpc` is required.
62
+ fromManifest(stack, constructs, { Mail: { from: 'a@example.com' } });
63
+
64
+ // Mail without a sender.
65
+ fromManifest(stack, constructs, {
66
+ Orders: { vpc },
67
+ // @ts-expect-error — every provider rejects an unverified sender.
68
+ Mail: {},
69
+ });
70
+
71
+ // A misspelt id.
72
+ fromManifest(stack, constructs, {
73
+ Orders: { vpc },
74
+ Mail: { from: 'a@example.com' },
75
+ // @ts-expect-error — not a construct in this manifest.
76
+ Ordrs: { vpc },
77
+ });
78
+
79
+ // A prop the kind's component never reads.
80
+ fromManifest(stack, constructs, {
81
+ Orders: { vpc },
82
+ Mail: { from: 'a@example.com' },
83
+ // @ts-expect-error — a surface takes API Gateway args, not bucket ones.
84
+ Api: { versioning: true },
85
+ });
86
+
87
+ // What the declaration already decides is not overridable.
88
+ fromManifest(stack, constructs, {
89
+ // @ts-expect-error — the schema is the declaration's, not the deploy's.
90
+ Orders: { vpc, schema: 'other' },
91
+ Mail: { from: 'a@example.com' },
92
+ });
93
+
94
+ // A kind with nothing to override takes no key.
95
+ fromManifest(stack, constructs, {
96
+ Orders: { vpc },
97
+ Mail: { from: 'a@example.com' },
98
+ // @ts-expect-error — a schema derives from its database.
99
+ Billing: {},
100
+ });
101
+
102
+ // Nor does a cache in a database: same address, one more table.
103
+ fromManifest(stack, constructs, {
104
+ Orders: { vpc },
105
+ Mail: { from: 'a@example.com' },
106
+ // @ts-expect-error — `Rates` lives in `Orders`.
107
+ Rates: { region: 'eu-west-1' },
108
+ });
109
+
110
+ // The backend decides too: ElastiCache needs a VPC to live in…
111
+ fromManifest(
112
+ stack,
113
+ constructs,
114
+ // @ts-expect-error — `Sessions` on ElastiCache needs `vpc`.
115
+ { Orders: { vpc }, Mail: { from: 'a@example.com' } },
116
+ { cache: 'elasticache' },
117
+ );
118
+ export const elasticache = fromManifest(
119
+ stack,
120
+ constructs,
121
+ {
122
+ Orders: { vpc },
123
+ Sessions: { vpc: { subnets: [], securityGroups: [] } },
124
+ Mail: { from: 'a@example.com' },
125
+ },
126
+ { cache: 'elasticache' },
127
+ );
128
+
129
+ // …and mail that is not SES is an account somebody created, with its URL.
130
+ fromManifest(
131
+ stack,
132
+ constructs,
133
+ {
134
+ Orders: { vpc },
135
+ // @ts-expect-error — Resend's URL is not something a deploy can mint.
136
+ Mail: { from: 'a@example.com' },
137
+ },
138
+ { email: 'resend' },
139
+ );
140
+ export const resend = fromManifest(
141
+ stack,
142
+ constructs,
143
+ {
144
+ Orders: { vpc },
145
+ Mail: { from: 'a@example.com', url: 'smtp://resend' },
146
+ },
147
+ { email: 'resend' },
148
+ );
149
+
150
+ // A manifest whose ids are not known statically keeps the untyped record.
151
+ declare const untyped: ConstructManifest;
152
+ export const loose = fromManifest(stack, untyped, { Anything: { at: 'all' } });
@@ -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';
@@ -153,48 +148,10 @@ export class Api<
153
148
  // Fail the whole synth if any route is misconfigured, with one actionable
154
149
  // message per offending route.
155
150
  if (failures.length) {
156
- throw new Error(failures.map((f) => f.message).join('\n\n'));
151
+ throw new RoutesMissingEnvironment(id, failures);
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>,
@@ -307,3 +264,18 @@ export interface ApiProps<TAuthorizers extends Record<string, unknown> = {}>
307
264
  * may override it. */
308
265
  runtime?: sst.aws.FunctionArgs['runtime'];
309
266
  }
267
+
268
+ /**
269
+ * Routes that read variables nothing links or whitelists. Collected across
270
+ * every route so one synth reports all of them, each with its own suggestions,
271
+ * rather than stopping at the first.
272
+ */
273
+ export class RoutesMissingEnvironment extends Error {
274
+ constructor(
275
+ readonly api: string,
276
+ readonly failures: readonly EnvValidationError[],
277
+ ) {
278
+ super(failures.map((f) => f.message).join('\n\n'));
279
+ this.name = 'RoutesMissingEnvironment';
280
+ }
281
+ }
@@ -206,7 +206,7 @@ export function withCacheTable(url: string, table: string): string {
206
206
  }
207
207
 
208
208
  /**
209
- * `services.cache: 'db'` in an app that declares more than one database.
209
+ * A `'db'` cache in an app that declares more than one database.
210
210
  *
211
211
  * Picking one would put the cache somewhere nobody chose, and the symptom —
212
212
  * entries that are never found — appears long after the deploy reported
@@ -222,7 +222,7 @@ export class CacheIsAmbiguous extends Error {
222
222
  `'${id}' is a cache backed by the database, and this app declares ` +
223
223
  `${databases.length}: ${databases.join(', ')}. Declare the cache from ` +
224
224
  `its database — e.g. ${databases[0]}.cache('${id}') — rather than ` +
225
- `with services.cache.`,
225
+ `as a bare new Cache().`,
226
226
  );
227
227
  this.name = 'CacheIsAmbiguous';
228
228
  }
@@ -232,8 +232,8 @@ export class CacheNeedsDatabase extends Error {
232
232
  constructor(readonly id: string) {
233
233
  super(
234
234
  `'${id}' is a cache backed by the database, and this app declares no ` +
235
- `database for it to live in. Declare one, or set services.cache to ` +
236
- `'upstash' or 'elasticache'.`,
235
+ `database for it to live in. Declare one, or declare the cache from ` +
236
+ `the database it belongs to.`,
237
237
  );
238
238
  this.name = 'CacheNeedsDatabase';
239
239
  }
@@ -1,4 +1,5 @@
1
- import { Secret, type SecretProps } from './Secret';
1
+ import { type GkmLinkable, ResourceType } from '../Linkable';
2
+ import type { StackType } from '../Stack';
2
3
 
3
4
  /**
4
5
  * `Credential` — a third-party credential SST holds, and the infra half of the
@@ -8,21 +9,53 @@ import { Secret, type SecretProps } from './Secret';
8
9
  * the lifecycle rather than the mechanism: a secret is generated and rotated by
9
10
  * the platform, while a credential is issued by someone else and has a shape
10
11
  * the construct validates on the way in. Both are values you set out of band
11
- * with `sst secret set`.
12
+ * with `sst secret set`, under the construct's id.
12
13
  *
13
- * The role is `credential` rather than `value`, and that is not cosmetic. The
14
+ * The role is `credentials` rather than `value`, and that is not cosmetic. The
14
15
  * role *is* the contract — `providedKeyFor` turns it into the key the app
15
16
  * declared — so a credential providing `value` would supply `STRIPE_VALUE`
16
- * against a declared `STRIPE_CREDENTIAL`, and `assertProvides` would reject the
17
- * stack at synth. Which is the check working; renaming here is the fix.
17
+ * against a declared `STRIPE_CREDENTIALS`, and `assertProvides` would reject
18
+ * the stack at synth.
19
+ *
20
+ * It holds an `sst.Secret` rather than being one, and reports its own type,
21
+ * for the same reason one level down: the type decides the keys a link
22
+ * resolves to. As an SST secret it resolved to `STRIPE`, so a function that
23
+ * declared `STRIPE_CREDENTIALS` was linked to nothing.
18
24
  */
19
25
  export class Credential<
20
26
  TStage extends string = string,
21
27
  TDomain extends string = string,
22
- > extends Secret<TStage, TDomain> {
23
- override provides(): Record<string, $util.Input<string>> {
24
- return { credential: this.value };
28
+ > implements GkmLinkable
29
+ {
30
+ readonly _id: string;
31
+
32
+ private readonly secret: sst.Secret;
33
+
34
+ get _type() {
35
+ return ResourceType.Credential;
36
+ }
37
+
38
+ constructor(
39
+ _stack: StackType<TStage, TDomain>,
40
+ name: string,
41
+ props: CredentialProps = {},
42
+ ) {
43
+ this._id = name;
44
+ // The id itself, as it always was: a value already set with
45
+ // `sst secret set Stripe …` is still the one read.
46
+ this.secret = new sst.Secret(name, props.placeholder);
47
+ }
48
+
49
+ provides(): Record<string, $util.Input<string>> {
50
+ return { credentials: this.secret.value };
51
+ }
52
+
53
+ getSSTLink() {
54
+ return { properties: { ...this.provides() } };
25
55
  }
26
56
  }
27
57
 
28
- export interface CredentialProps extends SecretProps {}
58
+ export interface CredentialProps {
59
+ /** A value until one is set, for a preview stage. */
60
+ placeholder?: string;
61
+ }
@@ -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
 
@@ -84,7 +84,21 @@ export class Database<
84
84
  * produces a database that looks empty.
85
85
  */
86
86
  provides(): Record<string, $util.Input<string>> {
87
- return { url: this.urlFor({}) };
87
+ return { url: this.urlFor(this.role ? { as: this.role } : {}) };
88
+ }
89
+
90
+ /**
91
+ * The runtime role this database's own handlers connect as.
92
+ *
93
+ * Set by the adapter once the bootstrap has registered the role — the
94
+ * cluster exists before any role does, so it cannot be a constructor
95
+ * argument. Until it is set (and under `roles: false`, where it never is),
96
+ * the URL is the master's.
97
+ */
98
+ private role: { user: string; password: $util.Input<string> } | undefined;
99
+
100
+ connectAs(role: { user: string; password: $util.Input<string> }): void {
101
+ this.role = role;
88
102
  }
89
103
 
90
104
  /**
@@ -65,6 +65,8 @@ export class DatabaseBootstrap {
65
65
  runtime: string;
66
66
  owner: string;
67
67
  reader?: string;
68
+ /** The database's own roles, whose owner may create in it. */
69
+ ownsDatabase?: boolean;
68
70
  }): { runtime: $util.Output<string>; reader?: $util.Output<string> } {
69
71
  const runtime = this.password(tenant.runtime);
70
72
  const reader = tenant.reader ? this.password(tenant.reader) : undefined;
@@ -75,6 +77,7 @@ export class DatabaseBootstrap {
75
77
  runtime: tenant.runtime,
76
78
  owner: tenant.owner,
77
79
  ...(tenant.reader ? { reader: tenant.reader } : {}),
80
+ ...(tenant.ownsDatabase ? { ownsDatabase: true } : {}),
78
81
  // Filled in by `bootstrapEvent` once the outputs resolve. The function
79
82
  // reads them straight out of its input and never fetches a secret, so
80
83
  // it needs no IAM to read one.
@@ -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
+ }
@@ -0,0 +1,58 @@
1
+ import { externalApiUrl } from '@geekmidas/manifest';
2
+ import { type GkmLinkable, ResourceType } from '../Linkable';
3
+ import type { StackType } from '../Stack';
4
+
5
+ /**
6
+ * `ExternalApi` — the infra half of the `external-api` kind.
7
+ *
8
+ * Nothing is provisioned: the API is somebody else's. What this holds is the
9
+ * two facts a function needs to call it on this stage — the URL the
10
+ * declaration names for it, and the credentials the provider issued, kept as
11
+ * an SST secret and set out of band:
12
+ *
13
+ * ```
14
+ * sst secret set PolarCredentials '{"clientId":"…","clientSecret":"…"}' --stage prod
15
+ * ```
16
+ */
17
+ export class ExternalApi<
18
+ TStage extends string = string,
19
+ TDomain extends string = string,
20
+ > implements GkmLinkable
21
+ {
22
+ readonly _id: string;
23
+
24
+ private readonly url: string;
25
+
26
+ private readonly credentials: sst.Secret;
27
+
28
+ get _type() {
29
+ return ResourceType.ExternalApi;
30
+ }
31
+
32
+ constructor(
33
+ stack: StackType<TStage, TDomain>,
34
+ name: string,
35
+ props: ExternalApiProps,
36
+ ) {
37
+ this._id = name;
38
+ // Read at synth, so a stage the declaration has no URL for fails the
39
+ // deploy — with the stage named — rather than the first request.
40
+ this.url = externalApiUrl({ id: name, url: props.url }, stack.stage);
41
+ this.credentials = new sst.Secret(`${name}Credentials`, props.placeholder);
42
+ }
43
+
44
+ provides(): Record<string, $util.Input<string>> {
45
+ return { url: this.url, credentials: this.credentials.value };
46
+ }
47
+
48
+ getSSTLink() {
49
+ return { properties: { ...this.provides() } };
50
+ }
51
+ }
52
+
53
+ export interface ExternalApiProps {
54
+ /** The declaration's `url` — one for every stage, or one per stage name. */
55
+ url: string | Readonly<Record<string, string>>;
56
+ /** A value for the credentials secret until one is set, for a preview stage. */
57
+ placeholder?: string;
58
+ }