@geekmidas/cloud 10.0.0-alpha.46 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cloud",
3
- "version": "10.0.0-alpha.46",
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.46",
45
- "@geekmidas/manifest": "10.0.0-alpha.46"
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.46",
62
- "@geekmidas/storage": "^10.0.0-alpha.46",
63
- "@geekmidas/events": "^10.0.0-alpha.46",
64
- "@geekmidas/db": "^10.0.0-alpha.46"
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": {
@@ -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
+ });
@@ -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,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
+ }
@@ -46,6 +46,7 @@ import { Database, DatabaseNeedsVpc, type DatabaseProps } from './aws/Database';
46
46
  import { DatabaseBootstrap } from './aws/DatabaseBootstrap';
47
47
  import { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
48
48
  import { Email, EmailNeedsSender } from './aws/Email';
49
+ import { Encryption, type EncryptionProps } from './aws/Encryption';
49
50
  import { ExternalApi, type ExternalApiProps } from './aws/ExternalApi';
50
51
  import { FileServer } from './aws/FileServer';
51
52
  import { Function } from './aws/Function';
@@ -398,6 +399,17 @@ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
398
399
  // The same storage as a secret, under a different role — see the component.
399
400
  credential: (stack, d, props) => new Credential(stack, d.id, props),
400
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
+
401
413
  // Nothing to provision: the URL this stage calls, and the credentials kept
402
414
  // as a secret.
403
415
  'external-api': (stack, d, props) => {
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 {