@geekmidas/cloud 1.0.1 → 1.1.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/README.md +125 -0
- package/package.json +18 -5
- package/src/sst/Api.ts +309 -0
- package/src/sst/App.ts +80 -0
- package/src/sst/Cron.ts +113 -0
- package/src/sst/Function.ts +128 -0
- package/src/sst/Linkable.ts +19 -0
- package/src/sst/LinkedEnvironment.ts +61 -0
- package/src/sst/Queue.ts +46 -0
- package/src/sst/Stack.ts +55 -0
- package/src/sst/Storage.ts +55 -0
- package/src/sst/Topic.ts +37 -0
- package/src/sst/__tests__/LinkedEnvironment.spec.ts +79 -0
- package/src/sst/__type-tests__/authorizers.type-test.ts +72 -0
- package/src/sst/__type-tests__/manifest.type-test.ts +77 -0
- package/src/sst/__type-tests__/messaging.type-test.ts +29 -0
- package/src/sst/__type-tests__/storage.type-test.ts +24 -0
- package/src/sst/index.ts +23 -0
- package/src/sst/naming.ts +23 -0
- package/src/sst/tsconfig.json +5 -0
- package/CHANGELOG.md +0 -13
- package/src/__tests__/utils.spec.ts +0 -256
- package/src/index.ts +0 -1
- package/src/utils/index.ts +0 -158
- package/tsconfig.json +0 -9
- package/tsdown.config.ts +0 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { EnvValidator, ValidationResult } from '@geekmidas/envkit/sst';
|
|
2
|
+
import {
|
|
3
|
+
type FunctionInfo,
|
|
4
|
+
flattenManifestField,
|
|
5
|
+
type ManifestField,
|
|
6
|
+
} from '@geekmidas/manifest';
|
|
7
|
+
import { type GkmLinkable, ResourceType } from './Linkable';
|
|
8
|
+
import { LinkedEnvironment } from './LinkedEnvironment';
|
|
9
|
+
import type { StackType } from './Stack';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `Function` — wraps SST's `sst.aws.Function` with standard env defaults and
|
|
13
|
+
* before-deploy env-var validation.
|
|
14
|
+
*
|
|
15
|
+
* `FunctionProps` extends the native `sst.aws.FunctionArgs`, so every native
|
|
16
|
+
* option (`handler`, `name`, `nodejs`, `vpc`, `url`, `permissions`, …) passes
|
|
17
|
+
* through; the construct only merges env defaults, defaults the runtime to
|
|
18
|
+
* `nodejs24.x` and JSON logging (both overridable), and resolves/validates
|
|
19
|
+
* `link` from the `links` pool against the required `envVars`.
|
|
20
|
+
*
|
|
21
|
+
* Source-only (extends ambient `sst.aws.*`); see docs §2.
|
|
22
|
+
*/
|
|
23
|
+
export class Function<
|
|
24
|
+
TStage extends string = string,
|
|
25
|
+
TDomain extends string = string,
|
|
26
|
+
>
|
|
27
|
+
extends sst.aws.Function
|
|
28
|
+
implements GkmLinkable
|
|
29
|
+
{
|
|
30
|
+
readonly _id!: string;
|
|
31
|
+
private readonly validator: EnvValidator;
|
|
32
|
+
private readonly envVars: readonly string[];
|
|
33
|
+
|
|
34
|
+
get _type() {
|
|
35
|
+
return ResourceType.Function;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
stack: StackType<TStage, TDomain>,
|
|
40
|
+
id: string,
|
|
41
|
+
props: FunctionProps,
|
|
42
|
+
) {
|
|
43
|
+
const {
|
|
44
|
+
links = [],
|
|
45
|
+
envVars = [],
|
|
46
|
+
autoValidate = true,
|
|
47
|
+
environment,
|
|
48
|
+
runtime,
|
|
49
|
+
logging,
|
|
50
|
+
...fnArgs
|
|
51
|
+
} = props;
|
|
52
|
+
|
|
53
|
+
const mergedEnvironment = {
|
|
54
|
+
...LinkedEnvironment.createBaseEnvironment(stack, id),
|
|
55
|
+
...environment,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const linked = new LinkedEnvironment(links, {
|
|
59
|
+
whitelist: Object.keys(mergedEnvironment),
|
|
60
|
+
context: id,
|
|
61
|
+
});
|
|
62
|
+
if (autoValidate) {
|
|
63
|
+
linked.validator.assert(envVars);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
super(id, {
|
|
67
|
+
...fnArgs,
|
|
68
|
+
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),
|
|
72
|
+
runtime: runtime ?? 'nodejs24.x',
|
|
73
|
+
logging: logging ?? { format: 'json' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this._id = id;
|
|
77
|
+
this.validator = linked.validator;
|
|
78
|
+
this.envVars = envVars;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Re-runs validation and returns the result (does not throw). */
|
|
82
|
+
validate(): ValidationResult {
|
|
83
|
+
return this.validator.validate(this.envVars);
|
|
84
|
+
}
|
|
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
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface FunctionProps extends sst.aws.FunctionArgs {
|
|
118
|
+
/** Required env vars for this function; validated against `links`. */
|
|
119
|
+
envVars?: readonly string[];
|
|
120
|
+
/** Pool of linkable resources `envVars` are resolved and validated against. */
|
|
121
|
+
links?: GkmLinkable[];
|
|
122
|
+
/**
|
|
123
|
+
* Validate `envVars` against the links in the constructor (fails synth before
|
|
124
|
+
* deploy on a missing variable).
|
|
125
|
+
* @default true
|
|
126
|
+
*/
|
|
127
|
+
autoValidate?: boolean;
|
|
128
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ResourceType } from '@geekmidas/envkit/sst';
|
|
2
|
+
|
|
3
|
+
export { ResourceType };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A linkable resource: an SST component carrying a stable `_id` (its name, used
|
|
7
|
+
* as the environment-variable prefix) and a `_type` drawn from the shared
|
|
8
|
+
* `ResourceType` vocabulary in `@geekmidas/envkit/sst`. The same `_type` values
|
|
9
|
+
* drive the runtime resolvers, so a linked resource resolves to predictable
|
|
10
|
+
* environment variables — and can be validated before deploy (see the validation
|
|
11
|
+
* model in `packages/cloud/docs/sst-constructs.md`).
|
|
12
|
+
*
|
|
13
|
+
* `_type` is the infra-time analogue of the runtime resource's `type`; the Api
|
|
14
|
+
* construct bridges the two when validating (`{ [_id]: { type: _type } }`).
|
|
15
|
+
*/
|
|
16
|
+
export interface GkmLinkable {
|
|
17
|
+
readonly _id: string;
|
|
18
|
+
readonly _type: ResourceType;
|
|
19
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { EnvValidator, type LinkRecord } from '@geekmidas/envkit/sst';
|
|
2
|
+
import type { GkmLinkable } from './Linkable';
|
|
3
|
+
import type { StackType } from './Stack';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared environment defaults + env-var validation + least-privilege linking for
|
|
7
|
+
* the Lambda-backed constructs — `Function` (once) and `Api` (per route).
|
|
8
|
+
* Bridges infra-time links (`_id`/`_type`) to the runtime resolver shape, builds
|
|
9
|
+
* one `EnvValidator`, and resolves the minimal set of links a required-vars set
|
|
10
|
+
* needs.
|
|
11
|
+
*
|
|
12
|
+
* Used by **composition**, not inheritance: the constructs already extend their
|
|
13
|
+
* SST base component (`sst.aws.Function` / `sst.aws.ApiGatewayV2`), so they hold
|
|
14
|
+
* a `LinkedEnvironment` rather than subclassing a common base.
|
|
15
|
+
*/
|
|
16
|
+
export class LinkedEnvironment {
|
|
17
|
+
readonly validator: EnvValidator;
|
|
18
|
+
private readonly linkByName: Map<string, GkmLinkable>;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
links: GkmLinkable[],
|
|
22
|
+
options: { whitelist: readonly string[]; context?: string },
|
|
23
|
+
) {
|
|
24
|
+
this.linkByName = new Map(links.map((link) => [link._id, link]));
|
|
25
|
+
const linkRecord: LinkRecord = Object.fromEntries(
|
|
26
|
+
links.map((link) => [link._id, { type: link._type }]),
|
|
27
|
+
);
|
|
28
|
+
this.validator = new EnvValidator(linkRecord, {
|
|
29
|
+
platform: 'aws',
|
|
30
|
+
whitelist: options.whitelist,
|
|
31
|
+
context: options.context,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The standard environment defaults every Lambda-backed construct injects from
|
|
37
|
+
* its stack. `serviceName` adds `SERVICE_NAME` (functions set it to their id;
|
|
38
|
+
* the API leaves it off). Callers spread their own `environment` over the
|
|
39
|
+
* result, so the user's values always win.
|
|
40
|
+
*/
|
|
41
|
+
static createBaseEnvironment<TStage extends string, TDomain extends string>(
|
|
42
|
+
stack: StackType<TStage, TDomain>,
|
|
43
|
+
serviceName?: string,
|
|
44
|
+
): Record<string, string> {
|
|
45
|
+
return {
|
|
46
|
+
NODE_ENV: 'production',
|
|
47
|
+
...(serviceName ? { SERVICE_NAME: serviceName } : {}),
|
|
48
|
+
STAGE: stack.stage,
|
|
49
|
+
REGION: stack.region,
|
|
50
|
+
APP_NAME: stack.app.name,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The link objects that provide at least one of `envVars` (least privilege). */
|
|
55
|
+
resolveLink(envVars: readonly string[]): GkmLinkable[] {
|
|
56
|
+
return this.validator
|
|
57
|
+
.getProvidersForEnvVars(envVars)
|
|
58
|
+
.map((name) => this.linkByName.get(name))
|
|
59
|
+
.filter((link): link is GkmLinkable => link !== undefined);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/sst/Queue.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type GkmLinkable, ResourceType } from './Linkable';
|
|
2
|
+
import type { StackType } from './Stack';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `Queue` — a linkable SQS queue (wraps `sst.aws.Queue`), the point-to-point
|
|
6
|
+
* work queue. Link it to a producer and the runtime resolves `<NAME>_URL`,
|
|
7
|
+
* `<NAME>_ARN`, and a `<NAME>_PUBLISHER_CONNECTION_STRING` (`sqs://?queueUrl=…`)
|
|
8
|
+
* that `@geekmidas/events`'s `Publisher.fromConnectionString` consumes. Its
|
|
9
|
+
* single consumer is wired by `QueueSubscriber`.
|
|
10
|
+
*
|
|
11
|
+
* SST's native `Queue` link exposes only `url`, so `getSSTLink` is overridden to
|
|
12
|
+
* also expose `arn` (what the resolver needs). `QueueProps` extends
|
|
13
|
+
* `sst.aws.QueueArgs`. Source-only (extends ambient `sst.aws.*`); see docs §2.
|
|
14
|
+
*/
|
|
15
|
+
export class Queue<
|
|
16
|
+
TStage extends string = string,
|
|
17
|
+
TDomain extends string = string,
|
|
18
|
+
>
|
|
19
|
+
extends sst.aws.Queue
|
|
20
|
+
implements GkmLinkable
|
|
21
|
+
{
|
|
22
|
+
readonly _id!: string;
|
|
23
|
+
|
|
24
|
+
get _type() {
|
|
25
|
+
return ResourceType.Queue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
_stack: StackType<TStage, TDomain>,
|
|
30
|
+
name: string,
|
|
31
|
+
props: QueueProps = {},
|
|
32
|
+
) {
|
|
33
|
+
super(name, props);
|
|
34
|
+
this._id = name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override getSSTLink() {
|
|
38
|
+
const link = super.getSSTLink();
|
|
39
|
+
return {
|
|
40
|
+
...link,
|
|
41
|
+
properties: { ...link.properties, arn: this.arn },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface QueueProps extends sst.aws.QueueArgs {}
|
package/src/sst/Stack.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { App, StageValues } from './App';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stack context: an `App` scoped to a logical stack name. Created via
|
|
5
|
+
* `app.stack(name)` (see docs §5). Delegates `stage`/`region`/`domain` to the
|
|
6
|
+
* app and prefixes resource names with the stack name.
|
|
7
|
+
*/
|
|
8
|
+
export class Stack<
|
|
9
|
+
TStage extends string = string,
|
|
10
|
+
TDomain extends string = string,
|
|
11
|
+
> {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly app: App<TStage, TDomain>,
|
|
14
|
+
/** This stack's logical name (e.g. `api`), used in resource prefixes. */
|
|
15
|
+
readonly name: string,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
get stage(): TStage {
|
|
19
|
+
return this.app.stage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get region(): string {
|
|
23
|
+
return this.app.region;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get domain(): TDomain {
|
|
27
|
+
return this.app.domain;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Kebab-cased `{stage}-{appName}-{stackName}-{resource}` physical name.
|
|
32
|
+
* Delegates to the app's scheme (so the two can't drift) and includes the app
|
|
33
|
+
* name so resources stay unique across apps sharing an account/stage.
|
|
34
|
+
*/
|
|
35
|
+
logicalPrefixedName(resource: string): string {
|
|
36
|
+
return this.app.logicalPrefixedName(`${this.name}-${resource}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
select<T>(values: StageValues<TStage, T>): T {
|
|
40
|
+
return this.app.select(values);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getSubdomain<TSub extends string>(subdomain: TSub) {
|
|
44
|
+
return this.app.getSubdomain(subdomain);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getURL<TSub extends string>(subdomain?: TSub) {
|
|
48
|
+
return this.app.getURL(subdomain);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type StackType<
|
|
53
|
+
TStage extends string = string,
|
|
54
|
+
TDomain extends string = string,
|
|
55
|
+
> = Stack<TStage, TDomain>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type GkmLinkable, ResourceType } from './Linkable';
|
|
2
|
+
import type { StackType } from './Stack';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `Storage` — a linkable S3 bucket (wraps `sst.aws.Bucket`). Link it to a
|
|
6
|
+
* `Function`/`Api` route and the runtime resolves a `<NAME>_NAME` environment
|
|
7
|
+
* variable (via the `Bucket` resolver in `@geekmidas/envkit/sst`) holding the
|
|
8
|
+
* bucket's name — exactly what `@geekmidas/storage`'s `AmazonStorageClient`
|
|
9
|
+
* consumes:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* // app: a service backed by @geekmidas/storage
|
|
13
|
+
* const storage = {
|
|
14
|
+
* serviceName: 'storage' as const,
|
|
15
|
+
* async register(env) {
|
|
16
|
+
* const { bucket } = env.create((get) => ({
|
|
17
|
+
* bucket: get('UPLOADS_NAME').string(),
|
|
18
|
+
* })).parse();
|
|
19
|
+
* return AmazonStorageClient.create({ bucket });
|
|
20
|
+
* },
|
|
21
|
+
* };
|
|
22
|
+
*
|
|
23
|
+
* // infra: provision + link
|
|
24
|
+
* const uploads = new Storage(stack, 'uploads');
|
|
25
|
+
* new Function(stack, 'Upload', { handler, links: [uploads], envVars: ['UPLOADS_NAME'] });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* The construct id (`uploads`) is the link's `_id`, which becomes the env-var
|
|
29
|
+
* prefix; `StorageProps` extends `sst.aws.BucketArgs` so native options pass
|
|
30
|
+
* through. Source-only (extends ambient `sst.aws.*`); see docs §2.
|
|
31
|
+
*/
|
|
32
|
+
export class Storage<
|
|
33
|
+
TStage extends string = string,
|
|
34
|
+
TDomain extends string = string,
|
|
35
|
+
>
|
|
36
|
+
extends sst.aws.Bucket
|
|
37
|
+
implements GkmLinkable
|
|
38
|
+
{
|
|
39
|
+
readonly _id!: string;
|
|
40
|
+
|
|
41
|
+
get _type() {
|
|
42
|
+
return ResourceType.Bucket;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
_stack: StackType<TStage, TDomain>,
|
|
47
|
+
name: string,
|
|
48
|
+
props: StorageProps = {},
|
|
49
|
+
) {
|
|
50
|
+
super(name, props);
|
|
51
|
+
this._id = name;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface StorageProps extends sst.aws.BucketArgs {}
|
package/src/sst/Topic.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type GkmLinkable, ResourceType } from './Linkable';
|
|
2
|
+
import type { StackType } from './Stack';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `Topic` — a linkable SNS topic (wraps `sst.aws.SnsTopic`), the pub/sub fan-out
|
|
6
|
+
* bus. Link it to a publisher and the runtime resolves `<NAME>_ARN` and a
|
|
7
|
+
* `<NAME>_PUBLISHER_CONNECTION_STRING` (`sns://?topicArn=…`) that
|
|
8
|
+
* `@geekmidas/events`'s `Publisher.fromConnectionString` consumes. Subscribers
|
|
9
|
+
* attach via `TopicSubscriber`/`Subscriber`.
|
|
10
|
+
*
|
|
11
|
+
* `StorageProps`-style: `TopicProps` extends `sst.aws.SnsTopicArgs`.
|
|
12
|
+
* Source-only (extends ambient `sst.aws.*`); see docs §2.
|
|
13
|
+
*/
|
|
14
|
+
export class Topic<
|
|
15
|
+
TStage extends string = string,
|
|
16
|
+
TDomain extends string = string,
|
|
17
|
+
>
|
|
18
|
+
extends sst.aws.SnsTopic
|
|
19
|
+
implements GkmLinkable
|
|
20
|
+
{
|
|
21
|
+
readonly _id!: string;
|
|
22
|
+
|
|
23
|
+
get _type() {
|
|
24
|
+
return ResourceType.SnsTopic;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
_stack: StackType<TStage, TDomain>,
|
|
29
|
+
name: string,
|
|
30
|
+
props: TopicProps = {},
|
|
31
|
+
) {
|
|
32
|
+
super(name, props);
|
|
33
|
+
this._id = name;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TopicProps extends sst.aws.SnsTopicArgs {}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { App } from '../App';
|
|
3
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
4
|
+
import { LinkedEnvironment } from '../LinkedEnvironment';
|
|
5
|
+
|
|
6
|
+
// App/Stack are runtime-pure (they reference SST only via erased type
|
|
7
|
+
// annotations), so they can be constructed directly in tests.
|
|
8
|
+
const stack = new App({
|
|
9
|
+
name: 'my-app',
|
|
10
|
+
stage: 'prod',
|
|
11
|
+
domain: 'example.com',
|
|
12
|
+
hostedZoneId: 'Z123',
|
|
13
|
+
region: 'us-east-1',
|
|
14
|
+
}).stack('api');
|
|
15
|
+
|
|
16
|
+
const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
|
|
17
|
+
const uploads: GkmLinkable = { _id: 'uploads', _type: ResourceType.Bucket };
|
|
18
|
+
|
|
19
|
+
describe('LinkedEnvironment', () => {
|
|
20
|
+
describe('createBaseEnvironment', () => {
|
|
21
|
+
it('builds the stack env defaults (no SERVICE_NAME without a name)', () => {
|
|
22
|
+
expect(LinkedEnvironment.createBaseEnvironment(stack)).toEqual({
|
|
23
|
+
NODE_ENV: 'production',
|
|
24
|
+
STAGE: 'prod',
|
|
25
|
+
REGION: 'us-east-1',
|
|
26
|
+
APP_NAME: 'my-app',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('adds SERVICE_NAME when a service name is given', () => {
|
|
31
|
+
const env = LinkedEnvironment.createBaseEnvironment(stack, 'processor');
|
|
32
|
+
expect(env.SERVICE_NAME).toBe('processor');
|
|
33
|
+
expect(env.APP_NAME).toBe('my-app');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('validation', () => {
|
|
38
|
+
const linked = new LinkedEnvironment([db, uploads], {
|
|
39
|
+
whitelist: ['APP_NAME'],
|
|
40
|
+
context: 'orders-fn',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('exposes link-derived vars, the platform whitelist, and extras', () => {
|
|
44
|
+
expect(linked.validator.has('DB_HOST')).toBe(true);
|
|
45
|
+
expect(linked.validator.has('UPLOADS_NAME')).toBe(true);
|
|
46
|
+
expect(linked.validator.has('AWS_REGION')).toBe(true); // platform: aws
|
|
47
|
+
expect(linked.validator.has('APP_NAME')).toBe(true); // extra whitelist
|
|
48
|
+
expect(linked.validator.has('NOPE')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('validates required vars against the links', () => {
|
|
52
|
+
expect(linked.validator.validate(['DB_HOST', 'APP_NAME']).valid).toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
expect(linked.validator.validate(['MISSING_ONE']).valid).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('resolveLink (least privilege)', () => {
|
|
60
|
+
const linked = new LinkedEnvironment([db, uploads], {
|
|
61
|
+
whitelist: [],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('attaches only the links that provide a requested var', () => {
|
|
65
|
+
expect(linked.resolveLink(['DB_HOST'])).toEqual([db]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('attaches multiple links when several are needed', () => {
|
|
69
|
+
expect(linked.resolveLink(['DB_URL', 'UPLOADS_NAME'])).toEqual([
|
|
70
|
+
db,
|
|
71
|
+
uploads,
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('attaches nothing for a non-link (e.g. platform) var', () => {
|
|
76
|
+
expect(linked.resolveLink(['AWS_REGION'])).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Type-level tests for the Api authorizer generics. Checked by `ts:check:sst`
|
|
2
|
+
// (it's under src/sst and not a *.spec.ts, so the gate type-checks it; vitest
|
|
3
|
+
// ignores it). Each `@ts-expect-error` self-validates: if the enforcement
|
|
4
|
+
// regresses, the now-unused directive becomes a TS error the gate catches.
|
|
5
|
+
|
|
6
|
+
import { Api } from '../Api';
|
|
7
|
+
import { App } from '../App';
|
|
8
|
+
import { Function } from '../Function';
|
|
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('api');
|
|
17
|
+
|
|
18
|
+
// Valid: built-ins (`iam`/`none`) plus the declared `jwt` and custom names.
|
|
19
|
+
export const ok = new Api(stack, 'Ok', {
|
|
20
|
+
authorizers: {
|
|
21
|
+
jwt: { issuer: 'https://issuer', audiences: ['aud'] },
|
|
22
|
+
employee: { handler: 'src/employee-auth.handler' },
|
|
23
|
+
},
|
|
24
|
+
routes: [
|
|
25
|
+
{ method: 'GET', path: '/a', handler: 'a.handler', authorizer: 'iam' },
|
|
26
|
+
{ method: 'GET', path: '/b', handler: 'b.handler', authorizer: 'none' },
|
|
27
|
+
{ method: 'GET', path: '/c', handler: 'c.handler', authorizer: 'jwt' },
|
|
28
|
+
{ method: 'GET', path: '/d', handler: 'd.handler', authorizer: 'employee' },
|
|
29
|
+
{ method: 'GET', path: '/e', handler: 'e.handler' },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// An undeclared authorizer name is rejected.
|
|
34
|
+
export const badName = new Api(stack, 'BadName', {
|
|
35
|
+
authorizers: { jwt: { issuer: 'i', audiences: ['a'] } },
|
|
36
|
+
routes: [
|
|
37
|
+
{
|
|
38
|
+
method: 'GET',
|
|
39
|
+
path: '/x',
|
|
40
|
+
handler: 'x.handler',
|
|
41
|
+
// @ts-expect-error 'nope' is not a declared authorizer
|
|
42
|
+
authorizer: 'nope',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A `jwt` authorizer must supply JWT settings (`audiences` required here).
|
|
48
|
+
export const badJwt = new Api(stack, 'BadJwt', {
|
|
49
|
+
authorizers: {
|
|
50
|
+
// @ts-expect-error jwt requires `audiences`
|
|
51
|
+
jwt: { issuer: 'i' },
|
|
52
|
+
},
|
|
53
|
+
routes: [],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// A custom (Lambda) authorizer must supply a `handler`.
|
|
57
|
+
export const badLambda = new Api(stack, 'BadLambda', {
|
|
58
|
+
authorizers: {
|
|
59
|
+
// @ts-expect-error custom authorizer requires `handler`
|
|
60
|
+
employee: { payload: '2.0' },
|
|
61
|
+
},
|
|
62
|
+
routes: [],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A Lambda authorizer `handler` accepts one of our `Function` constructs.
|
|
66
|
+
const authFn = new Function(stack, 'AuthFn', { handler: 'src/auth.handler' });
|
|
67
|
+
export const okFnHandler = new Api(stack, 'OkFn', {
|
|
68
|
+
authorizers: { employee: { handler: authFn } },
|
|
69
|
+
routes: [
|
|
70
|
+
{ method: 'GET', path: '/x', handler: 'x.handler', authorizer: 'employee' },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { Api } from '../Api';
|
|
7
|
+
import { App } from '../App';
|
|
8
|
+
import { Cron } from '../Cron';
|
|
9
|
+
import { Function } from '../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
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Type-level check that Queue/Topic are linkables whose publisher connection
|
|
2
|
+
// strings validate. Checked by `ts:check:sst`; vitest ignores it.
|
|
3
|
+
|
|
4
|
+
import { App } from '../App';
|
|
5
|
+
import { Function } from '../Function';
|
|
6
|
+
import { Queue } from '../Queue';
|
|
7
|
+
import { Topic } from '../Topic';
|
|
8
|
+
|
|
9
|
+
const stack = new App({
|
|
10
|
+
name: 'shop',
|
|
11
|
+
stage: 'dev',
|
|
12
|
+
domain: 'example.com',
|
|
13
|
+
hostedZoneId: 'Z',
|
|
14
|
+
region: 'us-east-1',
|
|
15
|
+
}).stack('events');
|
|
16
|
+
|
|
17
|
+
const orders = new Queue(stack, 'orders');
|
|
18
|
+
const events = new Topic(stack, 'events');
|
|
19
|
+
|
|
20
|
+
// A producer linked to both resolves each resource's publisher connection
|
|
21
|
+
// string (namespaced by name) — env validation passes for both.
|
|
22
|
+
export const producer = new Function(stack, 'Producer', {
|
|
23
|
+
handler: 'producer.handler',
|
|
24
|
+
links: [orders, events],
|
|
25
|
+
envVars: [
|
|
26
|
+
'ORDERS_PUBLISHER_CONNECTION_STRING',
|
|
27
|
+
'EVENTS_PUBLISHER_CONNECTION_STRING',
|
|
28
|
+
],
|
|
29
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Type-level check that Storage is a linkable usable in a Function's links.
|
|
2
|
+
// Checked by `ts:check:sst`; vitest ignores it.
|
|
3
|
+
|
|
4
|
+
import { App } from '../App';
|
|
5
|
+
import { Function } from '../Function';
|
|
6
|
+
import { Storage } from '../Storage';
|
|
7
|
+
|
|
8
|
+
const stack = new App({
|
|
9
|
+
name: 'a',
|
|
10
|
+
stage: 'dev',
|
|
11
|
+
domain: 'example.com',
|
|
12
|
+
hostedZoneId: 'Z',
|
|
13
|
+
region: 'us-east-1',
|
|
14
|
+
}).stack('files');
|
|
15
|
+
|
|
16
|
+
const uploads = new Storage(stack, 'uploads');
|
|
17
|
+
|
|
18
|
+
// A Storage is a GkmLinkable, so it can be linked; `UPLOADS_NAME` is the env var
|
|
19
|
+
// its `Bucket` resolver yields and what `@geekmidas/storage` consumes.
|
|
20
|
+
export const upload = new Function(stack, 'Upload', {
|
|
21
|
+
handler: 'upload.handler',
|
|
22
|
+
links: [uploads],
|
|
23
|
+
envVars: ['UPLOADS_NAME'],
|
|
24
|
+
});
|