otavia 0.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.
Files changed (63) hide show
  1. package/bun.lock +589 -0
  2. package/package.json +35 -0
  3. package/src/cli.ts +153 -0
  4. package/src/commands/__tests__/aws-auth.test.ts +32 -0
  5. package/src/commands/__tests__/cell.test.ts +44 -0
  6. package/src/commands/__tests__/dev.test.ts +49 -0
  7. package/src/commands/__tests__/init.test.ts +47 -0
  8. package/src/commands/__tests__/setup.test.ts +263 -0
  9. package/src/commands/aws-auth.ts +32 -0
  10. package/src/commands/aws.ts +59 -0
  11. package/src/commands/cell.ts +33 -0
  12. package/src/commands/clean.ts +32 -0
  13. package/src/commands/deploy.ts +508 -0
  14. package/src/commands/dev/__tests__/fixtures/gateway-cell/cell.yaml +8 -0
  15. package/src/commands/dev/__tests__/gateway-backend-routes.test.ts +13 -0
  16. package/src/commands/dev/__tests__/gateway-forward-url.test.ts +20 -0
  17. package/src/commands/dev/__tests__/gateway-sso-base-url.test.ts +93 -0
  18. package/src/commands/dev/__tests__/tunnel.test.ts +93 -0
  19. package/src/commands/dev/__tests__/vite-dev-proxy-rules.test.ts +220 -0
  20. package/src/commands/dev/__tests__/well-known.test.ts +88 -0
  21. package/src/commands/dev/forward-url.ts +7 -0
  22. package/src/commands/dev/gateway.ts +421 -0
  23. package/src/commands/dev/main-frontend-runtime/main-entry.ts +35 -0
  24. package/src/commands/dev/main-frontend-runtime/vite-config.ts +210 -0
  25. package/src/commands/dev/mount-selection.ts +9 -0
  26. package/src/commands/dev/tunnel.ts +176 -0
  27. package/src/commands/dev/vite-dev.ts +382 -0
  28. package/src/commands/dev/well-known.ts +76 -0
  29. package/src/commands/dev.ts +107 -0
  30. package/src/commands/init.ts +69 -0
  31. package/src/commands/lint.ts +49 -0
  32. package/src/commands/setup.ts +887 -0
  33. package/src/commands/test.ts +331 -0
  34. package/src/commands/typecheck.ts +36 -0
  35. package/src/config/__tests__/load-cell-yaml.test.ts +248 -0
  36. package/src/config/__tests__/load-otavia-yaml.test.ts +492 -0
  37. package/src/config/__tests__/ports.test.ts +48 -0
  38. package/src/config/__tests__/resolve-cell-dir.test.ts +60 -0
  39. package/src/config/__tests__/resolve-params.test.ts +137 -0
  40. package/src/config/__tests__/resource-names.test.ts +62 -0
  41. package/src/config/cell-yaml-schema.ts +115 -0
  42. package/src/config/load-cell-yaml.ts +87 -0
  43. package/src/config/load-otavia-yaml.ts +256 -0
  44. package/src/config/otavia-yaml-schema.ts +49 -0
  45. package/src/config/ports.ts +57 -0
  46. package/src/config/resolve-cell-dir.ts +55 -0
  47. package/src/config/resolve-params.ts +160 -0
  48. package/src/config/resource-names.ts +60 -0
  49. package/src/deploy/__tests__/template.test.ts +137 -0
  50. package/src/deploy/api-gateway.ts +96 -0
  51. package/src/deploy/cloudflare-dns.ts +261 -0
  52. package/src/deploy/cloudfront.ts +228 -0
  53. package/src/deploy/dynamodb.ts +68 -0
  54. package/src/deploy/lambda.ts +121 -0
  55. package/src/deploy/s3.ts +57 -0
  56. package/src/deploy/template.ts +264 -0
  57. package/src/deploy/types.ts +16 -0
  58. package/src/local/docker.ts +175 -0
  59. package/src/local/dynamodb-local.ts +124 -0
  60. package/src/local/minio-local.ts +44 -0
  61. package/src/utils/env.test.ts +74 -0
  62. package/src/utils/env.ts +79 -0
  63. package/tsconfig.json +14 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Cloudflare DNS + ACM certificate automation for deploy.
3
+ * Creates ACM cert, adds DNS validation record via Cloudflare API,
4
+ * waits for validation, then creates CNAME to CloudFront.
5
+ */
6
+
7
+ type CloudflareDnsRecord = {
8
+ id: string;
9
+ name: string;
10
+ type: string;
11
+ content: string;
12
+ };
13
+
14
+ type AcmValidationRecord = {
15
+ Name: string;
16
+ Type: string;
17
+ Value: string;
18
+ };
19
+
20
+ type AwsRunner = (
21
+ args: string[],
22
+ env: Record<string, string | undefined>,
23
+ opts?: { pipeStderr?: boolean }
24
+ ) => Promise<{ exitCode: number; stdout: string }>;
25
+
26
+ export type CloudflareDnsOptions = {
27
+ domainHost: string;
28
+ zoneId: string;
29
+ cloudflareToken: string;
30
+ awsEnv: Record<string, string | undefined>;
31
+ awsCli: AwsRunner;
32
+ region?: string;
33
+ };
34
+
35
+ async function cfApi(
36
+ path: string,
37
+ token: string,
38
+ options?: { method?: string; body?: unknown }
39
+ ): Promise<unknown> {
40
+ const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
41
+ method: options?.method ?? "GET",
42
+ headers: {
43
+ Authorization: `Bearer ${token}`,
44
+ "Content-Type": "application/json",
45
+ },
46
+ body: options?.body ? JSON.stringify(options.body) : undefined,
47
+ });
48
+ const data = (await res.json()) as { success: boolean; result: unknown; errors?: unknown[] };
49
+ if (!data.success) {
50
+ throw new Error(`Cloudflare API error: ${JSON.stringify(data.errors)}`);
51
+ }
52
+ return data.result;
53
+ }
54
+
55
+ /** Request ACM certificate and return ARN. */
56
+ async function requestAcmCertificate(
57
+ domainHost: string,
58
+ awsCli: AwsRunner,
59
+ awsEnv: Record<string, string | undefined>,
60
+ region: string,
61
+ ): Promise<string> {
62
+ const { exitCode, stdout } = await awsCli(
63
+ [
64
+ "acm", "request-certificate",
65
+ "--domain-name", domainHost,
66
+ "--validation-method", "DNS",
67
+ "--region", region,
68
+ "--output", "json",
69
+ ],
70
+ awsEnv,
71
+ { pipeStderr: true },
72
+ );
73
+ if (exitCode !== 0) throw new Error("Failed to request ACM certificate");
74
+ const data = JSON.parse(stdout) as { CertificateArn: string };
75
+ return data.CertificateArn;
76
+ }
77
+
78
+ /** Get DNS validation record from ACM certificate. Retries until available. */
79
+ async function getAcmValidationRecord(
80
+ certArn: string,
81
+ awsCli: AwsRunner,
82
+ awsEnv: Record<string, string | undefined>,
83
+ region: string,
84
+ ): Promise<AcmValidationRecord> {
85
+ for (let i = 0; i < 30; i++) {
86
+ const { exitCode, stdout } = await awsCli(
87
+ [
88
+ "acm", "describe-certificate",
89
+ "--certificate-arn", certArn,
90
+ "--region", region,
91
+ "--query", "Certificate.DomainValidationOptions[0].ResourceRecord",
92
+ "--output", "json",
93
+ ],
94
+ awsEnv,
95
+ { pipeStderr: true },
96
+ );
97
+ if (exitCode === 0) {
98
+ const record = JSON.parse(stdout) as AcmValidationRecord | null;
99
+ if (record?.Name && record?.Value) return record;
100
+ }
101
+ await new Promise((r) => setTimeout(r, 2000));
102
+ }
103
+ throw new Error("Timed out waiting for ACM validation record");
104
+ }
105
+
106
+ /** Create or update a DNS record in Cloudflare. */
107
+ async function upsertCloudflareRecord(
108
+ zoneId: string,
109
+ token: string,
110
+ name: string,
111
+ type: string,
112
+ content: string,
113
+ ): Promise<void> {
114
+ const cleanName = name.replace(/\.$/, "");
115
+ const cleanContent = content.replace(/\.$/, "");
116
+
117
+ // Search for existing record — use search param for partial match since
118
+ // Cloudflare's exact name filter can miss records with zone suffix appended
119
+ const allRecords = (await cfApi(
120
+ `/zones/${zoneId}/dns_records?type=${type}&per_page=100`,
121
+ token,
122
+ )) as CloudflareDnsRecord[];
123
+ const existing = allRecords.filter(
124
+ (r) => r.name === cleanName || r.name.startsWith(cleanName.split(".")[0]),
125
+ );
126
+
127
+ if (existing.length > 0) {
128
+ // Update existing
129
+ await cfApi(`/zones/${zoneId}/dns_records/${existing[0].id}`, token, {
130
+ method: "PUT",
131
+ body: { type, name: cleanName, content: cleanContent, ttl: 1, proxied: false },
132
+ });
133
+ } else {
134
+ // Create new
135
+ await cfApi(`/zones/${zoneId}/dns_records`, token, {
136
+ method: "POST",
137
+ body: { type, name: cleanName, content: cleanContent, ttl: 1, proxied: false },
138
+ });
139
+ }
140
+ }
141
+
142
+ /** Wait for ACM certificate to be issued. */
143
+ async function waitForAcmValidation(
144
+ certArn: string,
145
+ awsCli: AwsRunner,
146
+ awsEnv: Record<string, string | undefined>,
147
+ region: string,
148
+ ): Promise<void> {
149
+ console.log(" Waiting for ACM certificate validation...");
150
+ for (let i = 0; i < 60; i++) {
151
+ const { exitCode, stdout } = await awsCli(
152
+ [
153
+ "acm", "describe-certificate",
154
+ "--certificate-arn", certArn,
155
+ "--region", region,
156
+ "--query", "Certificate.Status",
157
+ "--output", "text",
158
+ ],
159
+ awsEnv,
160
+ { pipeStderr: true },
161
+ );
162
+ if (exitCode === 0) {
163
+ const status = stdout.trim();
164
+ if (status === "ISSUED") {
165
+ console.log(" Certificate issued.");
166
+ return;
167
+ }
168
+ if (status === "FAILED") {
169
+ throw new Error("ACM certificate validation failed");
170
+ }
171
+ }
172
+ if (i % 5 === 0 && i > 0) console.log(` Still waiting... (${i * 5}s)`);
173
+ await new Promise((r) => setTimeout(r, 5000));
174
+ }
175
+ throw new Error("Timed out waiting for ACM certificate validation (5 minutes)");
176
+ }
177
+
178
+ /** Check if an ISSUED ACM certificate already exists for the domain. */
179
+ async function findExistingCertificate(
180
+ domainHost: string,
181
+ awsCli: AwsRunner,
182
+ awsEnv: Record<string, string | undefined>,
183
+ region: string,
184
+ ): Promise<string | null> {
185
+ const { exitCode, stdout } = await awsCli(
186
+ [
187
+ "acm", "list-certificates",
188
+ "--region", region,
189
+ "--query", `CertificateSummaryList[?DomainName=='${domainHost}' && Status=='ISSUED'].CertificateArn | [0]`,
190
+ "--output", "text",
191
+ ],
192
+ awsEnv,
193
+ { pipeStderr: true },
194
+ );
195
+ if (exitCode !== 0) return null;
196
+ const arn = stdout.trim();
197
+ return arn && arn !== "None" ? arn : null;
198
+ }
199
+
200
+ /**
201
+ * Ensure ACM certificate exists for domain with Cloudflare DNS validation.
202
+ * Returns certificate ARN.
203
+ */
204
+ export async function ensureAcmCertificateWithCloudflare(
205
+ opts: CloudflareDnsOptions,
206
+ ): Promise<string> {
207
+ const region = opts.region ?? "us-east-1";
208
+
209
+ // Check for existing valid certificate
210
+ const existing = await findExistingCertificate(opts.domainHost, opts.awsCli, opts.awsEnv, region);
211
+ if (existing) {
212
+ console.log(` Using existing ACM certificate: ${existing}`);
213
+ return existing;
214
+ }
215
+
216
+ // Request new certificate
217
+ console.log(` Requesting ACM certificate for ${opts.domainHost}...`);
218
+ const certArn = await requestAcmCertificate(opts.domainHost, opts.awsCli, opts.awsEnv, region);
219
+ console.log(` Certificate ARN: ${certArn}`);
220
+
221
+ // Get validation record
222
+ const validationRecord = await getAcmValidationRecord(certArn, opts.awsCli, opts.awsEnv, region);
223
+ console.log(` Adding DNS validation record to Cloudflare...`);
224
+
225
+ // Create validation record in Cloudflare
226
+ await upsertCloudflareRecord(
227
+ opts.zoneId,
228
+ opts.cloudflareToken,
229
+ validationRecord.Name,
230
+ validationRecord.Type,
231
+ validationRecord.Value,
232
+ );
233
+ console.log(` DNS record created: ${validationRecord.Name}`);
234
+
235
+ // Wait for validation
236
+ await waitForAcmValidation(certArn, opts.awsCli, opts.awsEnv, region);
237
+
238
+ return certArn;
239
+ }
240
+
241
+ /**
242
+ * Create CNAME record pointing domain to CloudFront distribution.
243
+ */
244
+ export async function createCloudFrontDnsRecord(
245
+ opts: {
246
+ domainHost: string;
247
+ cloudFrontDomain: string;
248
+ zoneId: string;
249
+ cloudflareToken: string;
250
+ },
251
+ ): Promise<void> {
252
+ console.log(` Creating DNS record: ${opts.domainHost} → ${opts.cloudFrontDomain}`);
253
+ await upsertCloudflareRecord(
254
+ opts.zoneId,
255
+ opts.cloudflareToken,
256
+ opts.domainHost,
257
+ "CNAME",
258
+ opts.cloudFrontDomain,
259
+ );
260
+ console.log(` DNS record created.`);
261
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Generate CloudFront distribution: single domain, path behaviors per cell.
3
+ * Reference: cell-cli cloudfront.ts generateCloudFrontPlatform.
4
+ */
5
+
6
+ const CACHING_DISABLED_POLICY = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
7
+ const CACHING_OPTIMIZED_POLICY = "658327ea-f89d-4fab-a63d-7e88639e58f6";
8
+
9
+ export interface GenerateCloudFrontOptions {
10
+ domainHost: string;
11
+ defaultOriginId: string;
12
+ /** Root path ("/") should redirect to /<mount>/ when set. */
13
+ defaultCellMount?: string;
14
+ /** S3 frontend bucket ref - used for default origin */
15
+ frontendBucketRef?: string;
16
+ /** Path behaviors: pathPattern (e.g. /sso/*) -> originId. API origins use originId as Api Ref. */
17
+ pathBehaviors: Array<{ pathPattern: string; originId: string; isApi?: boolean }>;
18
+ hostedZoneId?: string;
19
+ certificateArn?: string;
20
+ stackName: string;
21
+ }
22
+
23
+ import type { CfnFragment } from "./types.js";
24
+
25
+ export function generateCloudFrontDistribution(options: GenerateCloudFrontOptions): CfnFragment {
26
+ const {
27
+ domainHost,
28
+ defaultOriginId,
29
+ defaultCellMount,
30
+ frontendBucketRef = "FrontendBucket",
31
+ pathBehaviors,
32
+ hostedZoneId,
33
+ certificateArn,
34
+ stackName,
35
+ } = options;
36
+
37
+ const resources: Record<string, unknown> = {};
38
+ const conditions: Record<string, unknown> = {};
39
+ const rootRedirectPath = defaultCellMount ? `/${defaultCellMount}/` : "/";
40
+ const rootIndexPath = defaultCellMount ? `/${defaultCellMount}/index.html` : "/index.html";
41
+
42
+ const autoCert = !certificateArn && !!domainHost && !!hostedZoneId;
43
+ let certificateRef: unknown;
44
+ if (autoCert) {
45
+ resources.AcmCertificate = {
46
+ Type: "AWS::CertificateManager::Certificate",
47
+ Properties: {
48
+ DomainName: domainHost,
49
+ ValidationMethod: "DNS",
50
+ DomainValidationOptions: [{ DomainName: domainHost, HostedZoneId: hostedZoneId }],
51
+ },
52
+ };
53
+ certificateRef = { Ref: "AcmCertificate" };
54
+ } else if (certificateArn) {
55
+ certificateRef = certificateArn;
56
+ }
57
+
58
+ const useCustomDomain = !!domainHost && (autoCert || !!certificateArn);
59
+ conditions.UseCustomDomain = {
60
+ "Fn::Not": [{ "Fn::Equals": [useCustomDomain ? domainHost : "", ""] }],
61
+ };
62
+
63
+ resources.FrontendOAC = {
64
+ Type: "AWS::CloudFront::OriginAccessControl",
65
+ Properties: {
66
+ OriginAccessControlConfig: {
67
+ Name: `${stackName}-frontend-oac`,
68
+ OriginAccessControlOriginType: "s3",
69
+ SigningBehavior: "always",
70
+ SigningProtocol: "sigv4",
71
+ },
72
+ },
73
+ };
74
+
75
+ resources.SpaRewriteFunction = {
76
+ Type: "AWS::CloudFront::Function",
77
+ Properties: {
78
+ Name: `${stackName}-spa-rewrite`,
79
+ AutoPublish: true,
80
+ FunctionCode: [
81
+ "function handler(event) {",
82
+ " var uri = event.request.uri;",
83
+ ` var rootRedirectPath = ${JSON.stringify(rootRedirectPath)};`,
84
+ ` var rootIndexPath = ${JSON.stringify(rootIndexPath)};`,
85
+ " if (uri === '/') {",
86
+ " return {",
87
+ " statusCode: 302,",
88
+ " statusDescription: 'Found',",
89
+ " headers: {",
90
+ " location: { value: rootRedirectPath }",
91
+ " }",
92
+ " };",
93
+ " } else if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
94
+ " var parts = uri.split('/').filter(Boolean);",
95
+ " if (parts.length > 0) {",
96
+ " event.request.uri = '/' + parts[0] + '/index.html';",
97
+ " } else {",
98
+ " event.request.uri = rootIndexPath;",
99
+ " }",
100
+ " }",
101
+ " return event.request;",
102
+ "}",
103
+ ].join("\n"),
104
+ FunctionConfig: {
105
+ Comment: "SPA fallback",
106
+ Runtime: "cloudfront-js-2.0",
107
+ },
108
+ },
109
+ };
110
+
111
+ const origins: unknown[] = [
112
+ {
113
+ Id: defaultOriginId,
114
+ DomainName: { "Fn::GetAtt": [frontendBucketRef, "RegionalDomainName"] },
115
+ OriginAccessControlId: { "Fn::GetAtt": ["FrontendOAC", "Id"] },
116
+ S3OriginConfig: { OriginAccessIdentity: "" },
117
+ },
118
+ ];
119
+
120
+ const seenApiOrigins = new Set<string>();
121
+ for (const b of pathBehaviors) {
122
+ if (b.isApi && !seenApiOrigins.has(b.originId)) {
123
+ seenApiOrigins.add(b.originId);
124
+ origins.push({
125
+ Id: b.originId,
126
+ DomainName: {
127
+ "Fn::Sub": `\${${b.originId}}.execute-api.\${AWS::Region}.amazonaws.com`,
128
+ },
129
+ CustomOriginConfig: {
130
+ HTTPSPort: 443,
131
+ OriginProtocolPolicy: "https-only",
132
+ },
133
+ });
134
+ }
135
+ }
136
+
137
+ const cacheBehaviors = pathBehaviors.map((b) => ({
138
+ PathPattern: b.pathPattern,
139
+ TargetOriginId: b.originId,
140
+ ViewerProtocolPolicy: "https-only" as const,
141
+ AllowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
142
+ Compress: true,
143
+ CachePolicyId: CACHING_DISABLED_POLICY,
144
+ OriginRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac",
145
+ }));
146
+
147
+ resources.FrontendCloudFront = {
148
+ Type: "AWS::CloudFront::Distribution",
149
+ Properties: {
150
+ DistributionConfig: {
151
+ Enabled: true,
152
+ DefaultRootObject: "index.html",
153
+ Origins: origins,
154
+ DefaultCacheBehavior: {
155
+ TargetOriginId: defaultOriginId,
156
+ ViewerProtocolPolicy: "redirect-to-https",
157
+ AllowedMethods: ["GET", "HEAD", "OPTIONS"],
158
+ Compress: true,
159
+ CachePolicyId: CACHING_OPTIMIZED_POLICY,
160
+ FunctionAssociations: [
161
+ {
162
+ EventType: "viewer-request",
163
+ FunctionARN: { "Fn::GetAtt": ["SpaRewriteFunction", "FunctionARN"] },
164
+ },
165
+ ],
166
+ },
167
+ CacheBehaviors: cacheBehaviors,
168
+ Aliases: {
169
+ "Fn::If": ["UseCustomDomain", [domainHost], { Ref: "AWS::NoValue" }],
170
+ },
171
+ ViewerCertificate: {
172
+ "Fn::If": [
173
+ "UseCustomDomain",
174
+ {
175
+ AcmCertificateArn: certificateRef,
176
+ SslSupportMethod: "sni-only",
177
+ MinimumProtocolVersion: "TLSv1.2_2021",
178
+ },
179
+ { CloudFrontDefaultCertificate: true },
180
+ ],
181
+ },
182
+ },
183
+ },
184
+ };
185
+
186
+ resources.FrontendBucketPolicy = {
187
+ Type: "AWS::S3::BucketPolicy",
188
+ DependsOn: "FrontendCloudFront",
189
+ Properties: {
190
+ Bucket: { Ref: frontendBucketRef },
191
+ PolicyDocument: {
192
+ Version: "2012-10-17",
193
+ Statement: [
194
+ {
195
+ Sid: "AllowCloudFrontOAC",
196
+ Effect: "Allow",
197
+ Principal: { Service: "cloudfront.amazonaws.com" },
198
+ Action: "s3:GetObject",
199
+ Resource: { "Fn::Sub": `\${${frontendBucketRef}.Arn}/*` },
200
+ Condition: {
201
+ StringEquals: {
202
+ "AWS:SourceArn": {
203
+ "Fn::Sub":
204
+ "arn:aws:cloudfront::${AWS::AccountId}:distribution/${FrontendCloudFront}",
205
+ },
206
+ },
207
+ },
208
+ },
209
+ ],
210
+ },
211
+ },
212
+ };
213
+
214
+ return {
215
+ Resources: resources,
216
+ Outputs: {
217
+ FrontendUrl: {
218
+ Description: "CloudFront URL",
219
+ Value: { "Fn::Sub": "https://${FrontendCloudFront.DomainName}" },
220
+ },
221
+ FrontendDistributionId: {
222
+ Description: "CloudFront distribution ID",
223
+ Value: { Ref: "FrontendCloudFront" },
224
+ },
225
+ },
226
+ Conditions: conditions,
227
+ };
228
+ }
@@ -0,0 +1,68 @@
1
+ import type { TableConfig } from "../config/cell-yaml-schema.js";
2
+ import type { CfnFragment } from "./types.js";
3
+ import { toPascalCase } from "./types.js";
4
+
5
+ /**
6
+ * Generate a single DynamoDB table fragment.
7
+ * Uses KeySchema, AttributeDefinitions, BillingMode PAY_PER_REQUEST, GSI if present.
8
+ */
9
+ export function generateDynamoDBTable(
10
+ tableName: string,
11
+ tableKey: string,
12
+ config: TableConfig
13
+ ): CfnFragment {
14
+ const logicalId = `${toPascalCase(tableKey)}Table`;
15
+ const keys = Object.entries(config.keys);
16
+
17
+ const attrMap = new Map<string, string>();
18
+ for (const [name, type] of keys) {
19
+ attrMap.set(name, type);
20
+ }
21
+ if (config.gsi) {
22
+ for (const gsi of Object.values(config.gsi)) {
23
+ for (const [name, type] of Object.entries(gsi.keys)) {
24
+ attrMap.set(name, type);
25
+ }
26
+ }
27
+ }
28
+
29
+ const properties: Record<string, unknown> = {
30
+ TableName: tableName,
31
+ BillingMode: "PAY_PER_REQUEST",
32
+ AttributeDefinitions: [...attrMap.entries()].map(([name, type]) => ({
33
+ AttributeName: name,
34
+ AttributeType: type,
35
+ })),
36
+ KeySchema: keys.map(([name], i) => ({
37
+ AttributeName: name,
38
+ KeyType: i === 0 ? "HASH" : "RANGE",
39
+ })),
40
+ };
41
+
42
+ if (config.gsi) {
43
+ properties.GlobalSecondaryIndexes = Object.entries(config.gsi).map(
44
+ ([indexName, gsi]) => ({
45
+ IndexName: indexName,
46
+ KeySchema: Object.entries(gsi.keys).map(([name], i) => ({
47
+ AttributeName: name,
48
+ KeyType: i === 0 ? "HASH" : "RANGE",
49
+ })),
50
+ Projection: { ProjectionType: gsi.projection },
51
+ })
52
+ );
53
+ }
54
+
55
+ return {
56
+ Resources: {
57
+ [logicalId]: {
58
+ Type: "AWS::DynamoDB::Table",
59
+ Properties: properties,
60
+ },
61
+ },
62
+ Outputs: {
63
+ [`${logicalId}Arn`]: {
64
+ Value: { "Fn::GetAtt": [logicalId, "Arn"] },
65
+ },
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,121 @@
1
+ import type { CfnFragment } from "./types.js";
2
+ import { toPascalCase } from "./types.js";
3
+
4
+ export interface LambdaFragmentProps {
5
+ handlerPath: string;
6
+ runtime: string;
7
+ timeout: number;
8
+ memory: number;
9
+ envVars: Record<string, string>;
10
+ /** Logical IDs of DynamoDB table resources (e.g. SsoThreadsTable) for IAM */
11
+ tableLogicalIds?: string[];
12
+ /** Logical IDs of S3 bucket resources for IAM */
13
+ bucketLogicalIds?: string[];
14
+ /** For Secrets Manager refs: key -> secret name (cell/secretName) */
15
+ secretRefs?: Record<string, string>;
16
+ }
17
+
18
+ /**
19
+ * Generate Lambda function + IAM role for one backend entry.
20
+ * Env vars from resolved params; IAM policy for DynamoDB and S3 if provided.
21
+ */
22
+ export function generateLambdaFragment(
23
+ entryKey: string,
24
+ logicalIdPrefix: string,
25
+ props: LambdaFragmentProps
26
+ ): CfnFragment {
27
+ const pascalEntry = toPascalCase(entryKey);
28
+ const functionLogicalId = `${logicalIdPrefix}${pascalEntry}Function`;
29
+ const roleLogicalId = `${logicalIdPrefix}${pascalEntry}LambdaRole`;
30
+
31
+ const envVariables: Record<string, string> = { ...props.envVars };
32
+ if (props.secretRefs) {
33
+ for (const [key, secretName] of Object.entries(props.secretRefs)) {
34
+ envVariables[key] = `{{resolve:secretsmanager:${secretName}}}`;
35
+ }
36
+ }
37
+
38
+ const policyStatements: unknown[] = [
39
+ {
40
+ Effect: "Allow",
41
+ Action: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
42
+ Resource: "*",
43
+ },
44
+ ];
45
+
46
+ if (props.tableLogicalIds && props.tableLogicalIds.length > 0) {
47
+ const tableResources: unknown[] = [];
48
+ for (const id of props.tableLogicalIds) {
49
+ tableResources.push({ "Fn::GetAtt": [id, "Arn"] });
50
+ tableResources.push({ "Fn::Sub": `\${${id}.Arn}/index/*` });
51
+ }
52
+ policyStatements.push({
53
+ Effect: "Allow",
54
+ Action: [
55
+ "dynamodb:GetItem",
56
+ "dynamodb:PutItem",
57
+ "dynamodb:UpdateItem",
58
+ "dynamodb:DeleteItem",
59
+ "dynamodb:Query",
60
+ "dynamodb:BatchGetItem",
61
+ "dynamodb:BatchWriteItem",
62
+ "dynamodb:Scan",
63
+ ],
64
+ Resource: tableResources,
65
+ });
66
+ }
67
+
68
+ if (props.bucketLogicalIds && props.bucketLogicalIds.length > 0) {
69
+ const bucketResources: unknown[] = [];
70
+ for (const id of props.bucketLogicalIds) {
71
+ bucketResources.push({ "Fn::GetAtt": [id, "Arn"] });
72
+ bucketResources.push({ "Fn::Sub": `\${${id}.Arn}/*` });
73
+ }
74
+ policyStatements.push({
75
+ Effect: "Allow",
76
+ Action: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
77
+ Resource: bucketResources,
78
+ });
79
+ }
80
+
81
+ const resources: Record<string, unknown> = {
82
+ [roleLogicalId]: {
83
+ Type: "AWS::IAM::Role",
84
+ Properties: {
85
+ AssumeRolePolicyDocument: {
86
+ Version: "2012-10-17",
87
+ Statement: [
88
+ {
89
+ Effect: "Allow",
90
+ Principal: { Service: "lambda.amazonaws.com" },
91
+ Action: "sts:AssumeRole",
92
+ },
93
+ ],
94
+ },
95
+ Policies: [
96
+ {
97
+ PolicyName: "LambdaPolicy",
98
+ PolicyDocument: {
99
+ Version: "2012-10-17",
100
+ Statement: policyStatements,
101
+ },
102
+ },
103
+ ],
104
+ },
105
+ },
106
+ [functionLogicalId]: {
107
+ Type: "AWS::Lambda::Function",
108
+ Properties: {
109
+ Runtime: props.runtime,
110
+ Handler: "index.handler",
111
+ Code: { S3Bucket: "PLACEHOLDER", S3Key: props.handlerPath },
112
+ Timeout: props.timeout,
113
+ MemorySize: props.memory,
114
+ Role: { "Fn::GetAtt": [roleLogicalId, "Arn"] },
115
+ Environment: { Variables: envVariables },
116
+ },
117
+ },
118
+ };
119
+
120
+ return { Resources: resources };
121
+ }