@geekmidas/cloud 1.1.1 → 10.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{index-ByEJy40r.d.cts → index-B5CZ1xVf.d.cts} +17 -9
- package/dist/index-B5CZ1xVf.d.cts.map +1 -0
- package/dist/{index-DZ0QrJQr.d.mts → index-DhHRjduZ.d.mts} +17 -9
- package/dist/index-DhHRjduZ.d.mts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.d.cts +1 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-CtMjuIMR.cjs → utils-B1a2UuEO.cjs} +5 -5
- package/dist/utils-B1a2UuEO.cjs.map +1 -0
- package/dist/{utils-BdKG20_m.mjs → utils-DMOXJ27j.mjs} +5 -5
- package/dist/utils-DMOXJ27j.mjs.map +1 -0
- package/package.json +44 -7
- package/src/dokploy/Application.ts +259 -0
- package/src/dokploy/__tests__/Application.spec.ts +69 -0
- package/src/dokploy/index.ts +18 -0
- package/src/sst/__tests__/LinkedEnvironment.spec.ts +4 -1
- package/src/sst/__tests__/backends.spec.ts +249 -0
- package/src/sst/__tests__/bootstrap.spec.ts +140 -0
- package/src/sst/__tests__/database.spec.ts +119 -0
- package/src/sst/__tests__/fromManifest.spec.ts +266 -0
- package/src/sst/__tests__/provides.spec.ts +107 -0
- package/src/sst/__tests__/ses.spec.ts +93 -0
- package/src/sst/__tests__/surfaces.spec.ts +132 -0
- package/src/sst/__type-tests__/authorizers.type-test.ts +2 -2
- package/src/sst/__type-tests__/manifest.type-test.ts +3 -3
- package/src/sst/__type-tests__/messaging.type-test.ts +3 -3
- package/src/sst/__type-tests__/storage.type-test.ts +2 -2
- package/src/sst/{Api.ts → aws/Api.ts} +3 -3
- package/src/sst/aws/Cache.ts +259 -0
- package/src/sst/aws/Credential.ts +28 -0
- package/src/sst/{Cron.ts → aws/Cron.ts} +2 -2
- package/src/sst/aws/Database.ts +190 -0
- package/src/sst/aws/DatabaseBootstrap.ts +309 -0
- package/src/sst/aws/DerivedDatabase.ts +117 -0
- package/src/sst/aws/Email.ts +181 -0
- package/src/sst/aws/FileServer.ts +82 -0
- package/src/sst/{Function.ts → aws/Function.ts} +3 -3
- package/src/sst/aws/ObjectStorage.ts +76 -0
- package/src/sst/aws/Queue.ts +116 -0
- package/src/sst/aws/RestApiSurface.ts +92 -0
- package/src/sst/aws/Secret.ts +76 -0
- package/src/sst/aws/StaticSite.ts +92 -0
- package/src/sst/{Storage.ts → aws/Storage.ts} +3 -3
- package/src/sst/aws/Topic.ts +68 -0
- package/src/sst/aws/bootstrap/handler.ts +110 -0
- package/src/sst/aws/ses.ts +132 -0
- package/src/sst/errors.ts +65 -0
- package/src/sst/fromManifest.ts +899 -0
- package/src/sst/index.ts +62 -7
- package/src/sst/naming.ts +37 -16
- package/src/sst/tsconfig.json +2 -2
- package/src/sst/upstash.d.ts +38 -0
- package/dist/index-ByEJy40r.d.cts.map +0 -1
- package/dist/index-DZ0QrJQr.d.mts.map +0 -1
- package/dist/utils-BdKG20_m.mjs.map +0 -1
- package/dist/utils-CtMjuIMR.cjs.map +0 -1
- package/src/sst/Queue.ts +0 -46
- package/src/sst/Topic.ts +0 -37
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { resolveEnvKeys } from '@geekmidas/envkit/sst';
|
|
2
|
+
import { providedKeyFor, provideKey } from '@geekmidas/manifest';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { ObjectStorage } from '../aws/ObjectStorage';
|
|
5
|
+
import { type ProvidesMismatch, UnknownDeclarationKind } from '../errors';
|
|
6
|
+
import {
|
|
7
|
+
assertProvides,
|
|
8
|
+
type ProvisionedManifest,
|
|
9
|
+
provisionerFor,
|
|
10
|
+
resolveEdges,
|
|
11
|
+
} from '../fromManifest';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The decisions, tested as data. Instantiating a component needs Pulumi, so the
|
|
15
|
+
* adapter keeps its decisions in pure functions and its Pulumi in three lines —
|
|
16
|
+
* otherwise nothing here would be reachable without a deploy.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
describe('provisionerFor', () => {
|
|
20
|
+
it('resolves a kind this adapter can provision', () => {
|
|
21
|
+
expect(provisionerFor('objects')).toBeTypeOf('function');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it.each([
|
|
25
|
+
'objects',
|
|
26
|
+
'queue',
|
|
27
|
+
'topic',
|
|
28
|
+
'secret',
|
|
29
|
+
] as const)('provisions %s', (kind) => {
|
|
30
|
+
expect(provisionerFor(kind)).toBeTypeOf('function');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('reports what is supported when a kind has no provisioner', () => {
|
|
34
|
+
expect.assertions(3);
|
|
35
|
+
try {
|
|
36
|
+
// A kind no adapter will ever have, so this test does not go stale
|
|
37
|
+
// every time one more kind becomes provisionable.
|
|
38
|
+
provisionerFor('carrier-pigeon' as never);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const e = error as UnknownDeclarationKind;
|
|
41
|
+
expect(e).toBeInstanceOf(UnknownDeclarationKind);
|
|
42
|
+
expect(e.kind).toBe('carrier-pigeon');
|
|
43
|
+
expect(e.supported).toContain('objects');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('assertProvides', () => {
|
|
49
|
+
it('passes when declared and supplied agree', () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
assertProvides('Uploads', ['UPLOADS_URL'], ['UPLOADS_URL']),
|
|
52
|
+
).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('catches infra supplying nothing for a declared key', () => {
|
|
56
|
+
expect.assertions(2);
|
|
57
|
+
try {
|
|
58
|
+
assertProvides('Uploads', ['UPLOADS_URL'], []);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const e = error as ProvidesMismatch;
|
|
61
|
+
expect(e.missing).toEqual(['UPLOADS_URL']);
|
|
62
|
+
expect(e.extra).toEqual([]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('catches infra supplying a key the app never declared', () => {
|
|
67
|
+
expect.assertions(2);
|
|
68
|
+
try {
|
|
69
|
+
assertProvides(
|
|
70
|
+
'Uploads',
|
|
71
|
+
['UPLOADS_URL'],
|
|
72
|
+
['UPLOADS_URL', 'UPLOADS_CDN_URL'],
|
|
73
|
+
);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const e = error as ProvidesMismatch;
|
|
76
|
+
expect(e.missing).toEqual([]);
|
|
77
|
+
// adding cdn to the component without declaring it is caught here
|
|
78
|
+
expect(e.extra).toEqual(['UPLOADS_CDN_URL']);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('treats both sides as empty by default, so a resource may provide nothing', () => {
|
|
83
|
+
expect(() => assertProvides('Nothing')).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('names the construct, since a manifest has many', () => {
|
|
87
|
+
expect.assertions(1);
|
|
88
|
+
try {
|
|
89
|
+
assertProvides('Avatars', ['AVATARS_URL'], []);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
expect((error as ProvidesMismatch).id).toBe('Avatars');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('ObjectStorage', () => {
|
|
97
|
+
const bucket = new ObjectStorage({} as never, 'Uploads');
|
|
98
|
+
|
|
99
|
+
it('composes the url from the resource, not the stack', () => {
|
|
100
|
+
expect(bucket.provides().url).toBe('s3://Uploads?region=stub-region');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('carries those values on the link, which is what reaches runtime', () => {
|
|
104
|
+
const link = bucket.getSSTLink();
|
|
105
|
+
expect(link.properties).toMatchObject({
|
|
106
|
+
name: 'Uploads',
|
|
107
|
+
url: 's3://Uploads?region=stub-region',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('leaves the permissions super grants alone', () => {
|
|
112
|
+
expect(bucket.getSSTLink()).toHaveProperty('include');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('supplies exactly the key the construct declares', () => {
|
|
116
|
+
// The construct emits provides: ['UPLOADS_URL']; a role becomes that key.
|
|
117
|
+
expect(() =>
|
|
118
|
+
assertProvides(
|
|
119
|
+
'Uploads',
|
|
120
|
+
['UPLOADS_URL'],
|
|
121
|
+
Object.keys(bucket.provides()).map((role) =>
|
|
122
|
+
provideKey('Uploads', role),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
).not.toThrow();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('is what envkit flattens into env', () => {
|
|
129
|
+
expect(resolveEnvKeys({ Uploads: { type: bucket._type } })).toContain(
|
|
130
|
+
'UPLOADS_URL',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('component settings', () => {
|
|
136
|
+
const provision = (
|
|
137
|
+
declaration: never,
|
|
138
|
+
props = {},
|
|
139
|
+
manifest: ConstructManifest = {},
|
|
140
|
+
) =>
|
|
141
|
+
provisionerFor('objects')({} as never, declaration, props, {
|
|
142
|
+
manifest,
|
|
143
|
+
provisioned: {},
|
|
144
|
+
}) as unknown as {
|
|
145
|
+
name: string;
|
|
146
|
+
props?: Record<string, unknown>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
it('maps a declared neutral option to this provider’s word for it', () => {
|
|
150
|
+
// `versioned` is the app's vocabulary; `versioning` is S3's.
|
|
151
|
+
const bucket = provision({
|
|
152
|
+
kind: 'objects',
|
|
153
|
+
id: 'Uploads',
|
|
154
|
+
versioned: true,
|
|
155
|
+
} as never);
|
|
156
|
+
expect(bucket.name).toBe('Uploads');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('accepts provider-specific props the declaration cannot express', () => {
|
|
160
|
+
expect(() =>
|
|
161
|
+
provision({ kind: 'objects', id: 'Uploads' } as never, {
|
|
162
|
+
cors: [{ allowOrigins: ['*'] }],
|
|
163
|
+
}),
|
|
164
|
+
).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('resolveEdges', () => {
|
|
169
|
+
const stub = (id: string) => ({ _id: id, _type: 'sst.aws.Bucket' }) as never;
|
|
170
|
+
|
|
171
|
+
const provisioned = {
|
|
172
|
+
Uploads: stub('Uploads'),
|
|
173
|
+
Avatars: stub('Avatars'),
|
|
174
|
+
} as unknown as ProvisionedManifest;
|
|
175
|
+
|
|
176
|
+
it('links what the function declared, and yields its keys', () => {
|
|
177
|
+
const { link, envKeys } = resolveEdges(
|
|
178
|
+
[{ target: 'Uploads', kind: 'objects' }],
|
|
179
|
+
provisioned,
|
|
180
|
+
);
|
|
181
|
+
expect(link.map((l) => l._id)).toEqual(['Uploads']);
|
|
182
|
+
expect(envKeys).toContain('UPLOADS_URL');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('links nothing more — the property least privilege depends on', () => {
|
|
186
|
+
const { link, envKeys } = resolveEdges(
|
|
187
|
+
[{ target: 'Uploads', kind: 'objects' }],
|
|
188
|
+
provisioned,
|
|
189
|
+
);
|
|
190
|
+
expect(link.map((l) => l._id)).not.toContain('Avatars');
|
|
191
|
+
expect(envKeys.some((k) => k.startsWith('AVATARS'))).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('is unchanged when an unrelated construct joins the manifest', () => {
|
|
195
|
+
const before = resolveEdges(
|
|
196
|
+
[{ target: 'Uploads', kind: 'objects' }],
|
|
197
|
+
provisioned,
|
|
198
|
+
);
|
|
199
|
+
const after = resolveEdges([{ target: 'Uploads', kind: 'objects' }], {
|
|
200
|
+
...provisioned,
|
|
201
|
+
Reports: stub('Reports'),
|
|
202
|
+
} as unknown as ProvisionedManifest);
|
|
203
|
+
|
|
204
|
+
expect(after.envKeys).toEqual(before.envKeys);
|
|
205
|
+
expect(after.link.map((l) => l._id)).toEqual(before.link.map((l) => l._id));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('gives a function with no edges nothing at all', () => {
|
|
209
|
+
expect(resolveEdges([], provisioned)).toEqual({ link: [], envKeys: [] });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('names what is available when an edge does not resolve', () => {
|
|
213
|
+
expect.assertions(2);
|
|
214
|
+
try {
|
|
215
|
+
resolveEdges([{ target: 'Missing', kind: 'objects' }], provisioned);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const e = error as UnresolvedDependency;
|
|
218
|
+
expect(e.target).toBe('Missing');
|
|
219
|
+
expect(e.available).toContain('Uploads');
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('the key a provided role becomes', () => {
|
|
225
|
+
it('qualifies an ordinary role by its id', () => {
|
|
226
|
+
expect(providedKeyFor('Uploads', 'objects', 'url')).toBe('UPLOADS_URL');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('leaves a secret’s name as its key', () => {
|
|
230
|
+
// `Auth` signs with `AUTH_SECRET`, which is also what better-auth's own
|
|
231
|
+
// tooling looks for — and qualifying it would produce
|
|
232
|
+
// `AUTH_SECRET_VALUE`, which nothing reads.
|
|
233
|
+
expect(providedKeyFor('AuthSecret', 'secret', 'value')).toBe('AUTH_SECRET');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('is the same derivation the contract check uses', () => {
|
|
237
|
+
// The point of sharing it: a check deriving the key differently from the
|
|
238
|
+
// thing it checks passes on drift instead of catching it.
|
|
239
|
+
expect(() =>
|
|
240
|
+
assertProvides(
|
|
241
|
+
'AuthSecret',
|
|
242
|
+
['AUTH_SECRET'],
|
|
243
|
+
['value'].map((role) => providedKeyFor('AuthSecret', 'secret', role)),
|
|
244
|
+
),
|
|
245
|
+
).not.toThrow();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('Credential', () => {
|
|
250
|
+
it('provides under the role the declaration named', async () => {
|
|
251
|
+
// The role *is* the contract: providing `value` against a declared
|
|
252
|
+
// `STRIPE_CREDENTIAL` supplies `STRIPE_VALUE`, and assertProvides
|
|
253
|
+
// rejects the stack at synth. Which is the check working.
|
|
254
|
+
const { Credential } = await import('../aws/Credential');
|
|
255
|
+
|
|
256
|
+
expect(
|
|
257
|
+
Object.keys(new Credential({} as never, 'Stripe').provides()),
|
|
258
|
+
).toEqual(['credential']);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('agrees with what the construct declares', () => {
|
|
262
|
+
expect(providedKeyFor('Stripe', 'credential', 'credential')).toBe(
|
|
263
|
+
'STRIPE_CREDENTIAL',
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { snsUrl } from '@geekmidas/events/sns';
|
|
2
|
+
import { sqsUrl } from '@geekmidas/events/sqs';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { fifoName, Queue } from '../aws/Queue';
|
|
5
|
+
import { Secret } from '../aws/Secret';
|
|
6
|
+
import { Topic } from '../aws/Topic';
|
|
7
|
+
import { regionOfArn } from '../naming';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What each component resolves onto its consumers.
|
|
11
|
+
*
|
|
12
|
+
* Asserted by parsing the string back with the client's own codec, not by
|
|
13
|
+
* matching text: the contract is that the publisher can read what the deploy
|
|
14
|
+
* target wrote, and a string comparison would pass on a value nothing can
|
|
15
|
+
* consume.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const stack = {} as never;
|
|
19
|
+
|
|
20
|
+
describe('Queue', () => {
|
|
21
|
+
it('provides a connection string its publisher can parse', () => {
|
|
22
|
+
const provided = new Queue(stack, 'Emails').provides();
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
sqsUrl.parse(provided.publisherConnectionString as string),
|
|
26
|
+
).toMatchObject({
|
|
27
|
+
queueUrl: expect.stringContaining('/Emails'),
|
|
28
|
+
region: 'stub-region',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('provides one key, the producer’s', () => {
|
|
33
|
+
// A worker is reached *through* the queue, so there is no second address
|
|
34
|
+
// and nothing can depend on the handler.
|
|
35
|
+
expect(Object.keys(new Queue(stack, 'Emails').provides())).toEqual([
|
|
36
|
+
'publisherConnectionString',
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Topic', () => {
|
|
42
|
+
it('provides a connection string its publisher can parse', () => {
|
|
43
|
+
const provided = new Topic(stack, 'Users').provides();
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
snsUrl.parse(provided.publisherConnectionString as string),
|
|
47
|
+
).toMatchObject({
|
|
48
|
+
topicArn: expect.stringContaining(':Users'),
|
|
49
|
+
region: 'stub-region',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('names no queue, so a subscriber cannot be reached by holding it', () => {
|
|
54
|
+
const provided = new Topic(stack, 'Users').provides();
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
snsUrl.parse(provided.publisherConnectionString as string).queueName,
|
|
58
|
+
).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('Secret', () => {
|
|
63
|
+
it('provides a value, because it has no address to hand out instead', () => {
|
|
64
|
+
expect(new Secret(stack, 'AuthSecret').provides()).toEqual({
|
|
65
|
+
value: 'stub-secret-AuthSecret',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('regionOfArn', () => {
|
|
71
|
+
it('reads the region out of an ARN', () => {
|
|
72
|
+
// Carried explicitly because AWS_REGION inside a Lambda is the
|
|
73
|
+
// *function's* region: a queue elsewhere works until it doesn't.
|
|
74
|
+
expect(regionOfArn('arn:aws:sqs:eu-west-1:123:emails')).toBe('eu-west-1');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is undefined where an ARN carries no region', () => {
|
|
78
|
+
expect(regionOfArn('arn:aws:iam::123:role/x')).toBeUndefined();
|
|
79
|
+
expect(regionOfArn('not-an-arn')).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('fifoName', () => {
|
|
84
|
+
it('appends .fifo to a FIFO queue’s name', () => {
|
|
85
|
+
// AWS rejects the mistake at the API call rather than at plan time, so
|
|
86
|
+
// the component fixes it where the fact lives.
|
|
87
|
+
expect(fifoName('emails', true)).toBe('emails.fifo');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not append it twice', () => {
|
|
91
|
+
// `.fifo` counts toward the 80-character limit.
|
|
92
|
+
expect(fifoName('emails.fifo', true)).toBe('emails.fifo');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('treats the object form as FIFO', () => {
|
|
96
|
+
expect(fifoName('emails', { contentBasedDeduplication: true })).toBe(
|
|
97
|
+
'emails.fifo',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('strips it from a standard queue’s name', () => {
|
|
102
|
+
// The rule runs both ways: a standard queue whose name ends in `.fifo`
|
|
103
|
+
// is rejected just as loudly.
|
|
104
|
+
expect(fifoName('emails.fifo', false)).toBe('emails');
|
|
105
|
+
expect(fifoName('emails.fifo', undefined)).toBe('emails');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
NoSmtpEndpoint,
|
|
4
|
+
SMTP_REGIONS,
|
|
5
|
+
sesSmtpUrl,
|
|
6
|
+
smtpEndpoint,
|
|
7
|
+
smtpPassword,
|
|
8
|
+
} from '../aws/ses';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AWS documents the recipe and publishes no worked example, so there is no
|
|
12
|
+
* golden value to assert against. These check the properties the recipe implies
|
|
13
|
+
* instead — which is weaker, and worth being explicit about rather than
|
|
14
|
+
* inventing an expected string.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const KEY = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY';
|
|
18
|
+
|
|
19
|
+
describe('smtpPassword', () => {
|
|
20
|
+
it('carries the version byte SES looks for', () => {
|
|
21
|
+
// Without it the password is the right bytes and still rejected.
|
|
22
|
+
const decoded = Buffer.from(smtpPassword(KEY, 'us-east-1'), 'base64');
|
|
23
|
+
|
|
24
|
+
expect(decoded[0]).toBe(0x04);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('is a version byte and one SHA-256 digest, and nothing else', () => {
|
|
28
|
+
expect(Buffer.from(smtpPassword(KEY, 'us-east-1'), 'base64')).toHaveLength(
|
|
29
|
+
33,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('is deterministic, because it is a signature and not a secret', () => {
|
|
34
|
+
// A password that changed per call would rotate itself out from under
|
|
35
|
+
// every deploy.
|
|
36
|
+
expect(smtpPassword(KEY, 'us-east-1')).toBe(smtpPassword(KEY, 'us-east-1'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('differs per region, which is why credentials are not portable', () => {
|
|
40
|
+
expect(smtpPassword(KEY, 'us-east-1')).not.toBe(
|
|
41
|
+
smtpPassword(KEY, 'eu-west-1'),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('differs per key', () => {
|
|
46
|
+
expect(smtpPassword(KEY, 'us-east-1')).not.toBe(
|
|
47
|
+
smtpPassword(`${KEY}x`, 'us-east-1'),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('refuses a region with no SMTP endpoint', () => {
|
|
52
|
+
// The set is smaller than the set of regions SES runs in, so this is a
|
|
53
|
+
// real mistake to make — and it would surface at the first send.
|
|
54
|
+
expect(() => smtpPassword(KEY, 'af-south-1')).toThrow(NoSmtpEndpoint);
|
|
55
|
+
expect(SMTP_REGIONS).not.toContain('af-south-1');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('sesSmtpUrl', () => {
|
|
60
|
+
it('is an smtp:// URL like every other backend’s', () => {
|
|
61
|
+
// The whole reason the declaration promises `smtp://` and names no
|
|
62
|
+
// provider: what changes is a host and a credential, not the client.
|
|
63
|
+
const url = sesSmtpUrl({
|
|
64
|
+
accessKeyId: 'AKIAEXAMPLE',
|
|
65
|
+
secretAccessKey: KEY,
|
|
66
|
+
region: 'eu-west-1',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(url.startsWith('smtp://AKIAEXAMPLE:')).toBe(true);
|
|
70
|
+
expect(url.endsWith('@email-smtp.eu-west-1.amazonaws.com:587')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('encodes the password, which is base64 and may contain + and /', () => {
|
|
74
|
+
// Both mean something else in a URL's authority section.
|
|
75
|
+
const url = sesSmtpUrl({
|
|
76
|
+
accessKeyId: 'AKIAEXAMPLE',
|
|
77
|
+
secretAccessKey: KEY,
|
|
78
|
+
region: 'eu-west-1',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Round-tripped through the URL parser, which is the consumer that has
|
|
82
|
+
// to get the password back out intact.
|
|
83
|
+
expect(decodeURIComponent(new URL(url).password)).toBe(
|
|
84
|
+
smtpPassword(KEY, 'eu-west-1'),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('derives the endpoint rather than taking one', () => {
|
|
89
|
+
expect(smtpEndpoint('ap-southeast-2')).toBe(
|
|
90
|
+
'email-smtp.ap-southeast-2.amazonaws.com',
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ConstructManifest } from '@geekmidas/manifest';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ObjectStorage } from '../aws/ObjectStorage';
|
|
4
|
+
import { UnresolvedDependency } from '../errors';
|
|
5
|
+
import {
|
|
6
|
+
isServed,
|
|
7
|
+
type ProvisionContext,
|
|
8
|
+
provisionerFor,
|
|
9
|
+
siteEnvironment,
|
|
10
|
+
} from '../fromManifest';
|
|
11
|
+
|
|
12
|
+
const stack = {} as never;
|
|
13
|
+
|
|
14
|
+
const manifest = {
|
|
15
|
+
Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
|
|
16
|
+
UploadsServer: {
|
|
17
|
+
kind: 'file-server',
|
|
18
|
+
id: 'UploadsServer',
|
|
19
|
+
of: 'Uploads',
|
|
20
|
+
open: ['brand/**'],
|
|
21
|
+
provides: ['UPLOADS_SERVER_URL'],
|
|
22
|
+
},
|
|
23
|
+
Assets: { kind: 'objects', id: 'Assets', provides: ['ASSETS_URL'] },
|
|
24
|
+
Api: {
|
|
25
|
+
kind: 'rest-api',
|
|
26
|
+
id: 'Api',
|
|
27
|
+
endpoints: [],
|
|
28
|
+
provides: ['API_URL', 'API_TRUSTED_ORIGINS', 'API_COOKIE_DOMAIN'],
|
|
29
|
+
},
|
|
30
|
+
Orders: { kind: 'database', id: 'Orders', provides: ['ORDERS_URL'] },
|
|
31
|
+
Console: {
|
|
32
|
+
kind: 'site',
|
|
33
|
+
id: 'Console',
|
|
34
|
+
variant: 'static',
|
|
35
|
+
path: 'apps/console',
|
|
36
|
+
dependencies: [
|
|
37
|
+
{ target: 'Api', kind: 'rest-api' },
|
|
38
|
+
{ target: 'Orders', kind: 'database' },
|
|
39
|
+
{ target: 'UploadsServer', kind: 'file-server' },
|
|
40
|
+
],
|
|
41
|
+
provides: ['CONSOLE_URL'],
|
|
42
|
+
},
|
|
43
|
+
} as const satisfies ConstructManifest;
|
|
44
|
+
|
|
45
|
+
/** A provisioned stand-in that provides whatever it was handed. */
|
|
46
|
+
const provided = (values: Record<string, string>) =>
|
|
47
|
+
({ provides: () => values }) as never;
|
|
48
|
+
|
|
49
|
+
const context = (
|
|
50
|
+
provisioned: Record<string, unknown> = {},
|
|
51
|
+
): ProvisionContext => ({
|
|
52
|
+
manifest,
|
|
53
|
+
provisioned: provisioned as never,
|
|
54
|
+
bootstraps: new Map(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('isServed', () => {
|
|
58
|
+
it('finds the surface pointing at a bucket', () => {
|
|
59
|
+
// The cost this design named: the bucket alone no longer says whether it
|
|
60
|
+
// is served, so you find whoever points at it.
|
|
61
|
+
expect(isServed('Uploads', manifest)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('leaves an unserved bucket alone', () => {
|
|
65
|
+
expect(isServed('Assets', manifest)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('file-server provisioning', () => {
|
|
70
|
+
it('refuses an origin that was not provisioned', () => {
|
|
71
|
+
expect(() =>
|
|
72
|
+
provisionerFor('file-server')(
|
|
73
|
+
stack,
|
|
74
|
+
manifest.UploadsServer,
|
|
75
|
+
{},
|
|
76
|
+
context(),
|
|
77
|
+
),
|
|
78
|
+
).toThrow(UnresolvedDependency);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('routes everything at the root, so a served path is a bucket key', () => {
|
|
82
|
+
const origin = new ObjectStorage(stack, 'Uploads');
|
|
83
|
+
const server = provisionerFor('file-server')(
|
|
84
|
+
stack,
|
|
85
|
+
manifest.UploadsServer,
|
|
86
|
+
{},
|
|
87
|
+
context({ Uploads: origin }),
|
|
88
|
+
) as unknown as { routed: { pattern: string }[] };
|
|
89
|
+
|
|
90
|
+
expect(server.routed).toEqual([
|
|
91
|
+
{ pattern: '/*', bucket: expect.anything() },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('siteEnvironment', () => {
|
|
97
|
+
const built = () =>
|
|
98
|
+
siteEnvironment(
|
|
99
|
+
manifest.Console,
|
|
100
|
+
context({
|
|
101
|
+
Api: provided({ url: 'https://api.example.com' }),
|
|
102
|
+
Orders: provided({ url: 'postgres://user:pw@db/orders' }),
|
|
103
|
+
UploadsServer: provided({ url: 'https://files.example.com' }),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
it('inlines an address under the name the bundler expects', () => {
|
|
108
|
+
expect(built().VITE_API_URL).toBe('https://api.example.com');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('inlines a served bucket’s address, but never the bucket’s own', () => {
|
|
112
|
+
// A served URL is what a browser fetches; the bucket's presigns, and a
|
|
113
|
+
// presigner in a bundle is a credential in a bundle.
|
|
114
|
+
expect(built().VITE_UPLOADS_SERVER_URL).toBe('https://files.example.com');
|
|
115
|
+
expect(built()).not.toHaveProperty('VITE_UPLOADS_URL');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('keeps a connection string out of the bundle entirely', () => {
|
|
119
|
+
// A site may legitimately depend on a database — its server half, where
|
|
120
|
+
// it has one, reads env like anything else. `PUBLIC` decides only what
|
|
121
|
+
// may be *prefixed*, and a password may not.
|
|
122
|
+
expect(built()).not.toHaveProperty('VITE_ORDERS_URL');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('fails loudly on an edge nothing provisioned', () => {
|
|
126
|
+
// A smaller environment builds and then fails at runtime against
|
|
127
|
+
// `http:///`, with nothing to point at.
|
|
128
|
+
expect(() =>
|
|
129
|
+
siteEnvironment(manifest.Console, context({ Api: provided({}) })),
|
|
130
|
+
).toThrow(UnresolvedDependency);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// ignores it). Each `@ts-expect-error` self-validates: if the enforcement
|
|
4
4
|
// regresses, the now-unused directive becomes a TS error the gate catches.
|
|
5
5
|
|
|
6
|
-
import { Api } from '../Api';
|
|
7
6
|
import { App } from '../App';
|
|
8
|
-
import {
|
|
7
|
+
import { Api } from '../aws/Api';
|
|
8
|
+
import { Function } from '../aws/Function';
|
|
9
9
|
|
|
10
10
|
const stack = new App({
|
|
11
11
|
name: 'a',
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// the flat and the partitioned `ManifestField` forms. Checked by `ts:check:sst`.
|
|
4
4
|
|
|
5
5
|
import type { Manifest, ManifestField, RouteInfo } from '@geekmidas/manifest';
|
|
6
|
-
import { Api } from '../Api';
|
|
7
6
|
import { App } from '../App';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { Api } from '../aws/Api';
|
|
8
|
+
import { Cron } from '../aws/Cron';
|
|
9
|
+
import { Function } from '../aws/Function';
|
|
10
10
|
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
11
11
|
|
|
12
12
|
const stack = new App({
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// strings validate. Checked by `ts:check:sst`; vitest ignores it.
|
|
3
3
|
|
|
4
4
|
import { App } from '../App';
|
|
5
|
-
import { Function } from '../Function';
|
|
6
|
-
import { Queue } from '../Queue';
|
|
7
|
-
import { Topic } from '../Topic';
|
|
5
|
+
import { Function } from '../aws/Function';
|
|
6
|
+
import { Queue } from '../aws/Queue';
|
|
7
|
+
import { Topic } from '../aws/Topic';
|
|
8
8
|
|
|
9
9
|
const stack = new App({
|
|
10
10
|
name: 'shop',
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Checked by `ts:check:sst`; vitest ignores it.
|
|
3
3
|
|
|
4
4
|
import { App } from '../App';
|
|
5
|
-
import { Function } from '../Function';
|
|
6
|
-
import { Storage } from '../Storage';
|
|
5
|
+
import { Function } from '../aws/Function';
|
|
6
|
+
import { Storage } from '../aws/Storage';
|
|
7
7
|
|
|
8
8
|
const stack = new App({
|
|
9
9
|
name: 'a',
|
|
@@ -5,10 +5,10 @@ import {
|
|
|
5
5
|
type ManifestField,
|
|
6
6
|
type RouteInfo,
|
|
7
7
|
} from '@geekmidas/manifest';
|
|
8
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
9
|
+
import { LinkedEnvironment } from '../LinkedEnvironment';
|
|
10
|
+
import type { StackType } from '../Stack';
|
|
8
11
|
import type { Function } from './Function';
|
|
9
|
-
import { type GkmLinkable, ResourceType } from './Linkable';
|
|
10
|
-
import { LinkedEnvironment } from './LinkedEnvironment';
|
|
11
|
-
import type { StackType } from './Stack';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* `Api` — wraps SST's `sst.aws.ApiGatewayV2` (HTTP API) with a typed route
|