@zaaxch/tailframe 4.0.5 → 4.0.7

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": "@zaaxch/tailframe",
3
- "version": "4.0.5",
3
+ "version": "4.0.7",
4
4
  "description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/new.mjs CHANGED
@@ -269,6 +269,10 @@ add(`${svc}/.env.example`, `NODE_ENV=development\nPORT=3000\nCORS_ORIGIN=https:/
269
269
  add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
270
270
  IMAGE_TAG=<product-sha>
271
271
  APP_ENV_FILE=/opt/${options.name}/shared/.env.production
272
+ CADDY_ORIGIN_CERTIFICATE=/opt/${options.name}/shared/caddy/origin.pem
273
+ CADDY_ORIGIN_KEY=/opt/${options.name}/shared/caddy/origin-key.pem
274
+ CADDY_DATA_DIR=/opt/${options.name}/shared/caddy/data
275
+ CADDY_CONFIG_DIR=/opt/${options.name}/shared/caddy/config
272
276
  ${postgres ? `POSTGRES_DATA_DIR=/opt/${options.name}/shared/postgres/data
273
277
  POSTGRES_ROOT_PASSWORD=<uri-safe-random-value>
274
278
  POSTGRES_APP_PASSWORD=<different-uri-safe-random-value>
@@ -292,7 +296,7 @@ export const env = {
292
296
  firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH,` : ""}
293
297
  ${postgres ? `postgresUri: process.env.POSTGRES_URI ?? "postgresql://${options.name}-app:development@localhost:5432/${databaseName}",` : `mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
294
298
  mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",`}
295
- ${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
299
+ ${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379"` : ""}
296
300
  };`);
297
301
  const databaseSource = postgres ? `import { Pool } from "pg";
298
302
  import { env } from "@/platform/config/env";
@@ -358,15 +362,36 @@ if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimp
358
362
  add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
359
363
  add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
360
364
  add(`${svc}/src/__tests__/testDb.ts`, postgres ? `import { Pool } from "pg";
361
- const pool = new Pool({ connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test" });
362
- export async function openTestDatabase() { await pool.query("SELECT 1"); return pool; }
363
- export async function resetTestDatabase() { await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public"); return pool; }
364
- export async function closeTestDatabase() { await pool.end(); }
365
+ const pool = new Pool({
366
+ connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test"
367
+ });
368
+ export async function openTestDatabase() {
369
+ await pool.query("SELECT 1");
370
+ return pool;
371
+ }
372
+ export async function resetTestDatabase() {
373
+ await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public");
374
+ return pool;
375
+ }
376
+ export async function closeTestDatabase() {
377
+ await pool.end();
378
+ }
365
379
  ` : `import { MongoClient } from "mongodb";
366
- const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
367
- export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
368
- export async function resetTestDatabase() { const database = await openTestDatabase(); await database.dropDatabase(); return database; }
369
- export async function closeTestDatabase() { await client.close(); }
380
+ const client = new MongoClient(
381
+ process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true"
382
+ );
383
+ export async function openTestDatabase() {
384
+ await client.connect();
385
+ return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test");
386
+ }
387
+ export async function resetTestDatabase() {
388
+ const database = await openTestDatabase();
389
+ await database.dropDatabase();
390
+ return database;
391
+ }
392
+ export async function closeTestDatabase() {
393
+ await client.close();
394
+ }
370
395
  `);
371
396
  add(`${svc}/docker-compose.test.yml`, postgres ? `name: ${options.name}-test
372
397
  services:
@@ -468,13 +493,20 @@ ${csrf ? `
468
493
  ` : ""}${options.ui ? `
469
494
  it("serves the production SPA without falling through for API paths", async () => {
470
495
  const app = createApplication({ production: true, publicPath: path.join(__dirname, "fixtures/public") });
471
- await request(app).get("/dashboard").expect(200).expect(({ text }) => expect(text).toContain("boundary-test-spa"));
496
+ await request(app)
497
+ .get("/dashboard")
498
+ .expect(200)
499
+ .expect(({ text }) => expect(text).toContain("boundary-test-spa"));
472
500
  await request(app).get("/api/v1/does-not-exist").expect(404);
473
- });
474
- ` : ""}
501
+ });` : ""}
475
502
  });
476
503
  `);
477
- if (options.ui) add(`${svc}/src/app/__tests__/fixtures/public/index.html`, `<!doctype html><html><body><div id="app">boundary-test-spa</div></body></html>`);
504
+ if (options.ui) add(`${svc}/src/app/__tests__/fixtures/public/index.html`, `<!doctype html>
505
+ <html>
506
+ <body>
507
+ <div id="app">boundary-test-spa</div>
508
+ </body>
509
+ </html>`);
478
510
  add(`${svc}/AGENTS.md`, `# ${title} service guidance
479
511
 
480
512
  ## Scope
@@ -659,8 +691,22 @@ import NotificationHost from "@/app/components/NotificationHost.vue";
659
691
  </script>`);
660
692
  add(`${ui}/src/app/startApplication.ts`, `import { createApp } from "vue";\nimport { createPinia } from "pinia";\nimport PrimeVue from "primevue/config";\nimport App from "@/app/App.vue";\n${options.auth === "firebase" ? 'import { configureHttp } from "@/app/configureHttp";\n' : ""}import router from "@/app/router";\nimport "@/app/styles.css";\nexport function startApplication() {\n\tconst app = createApp(App);\n\tconst pinia = createPinia();\n\tapp.use(pinia).use(PrimeVue).use(router);\n\t${options.auth === "firebase" ? "configureHttp(pinia);\n\t" : ""}app.mount("#app");\n}\n`);
661
693
  add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
662
- add(`${ui}/src/app/styles.css`, `@import "tailwindcss";\n:root { font-family: Inter, ui-sans-serif, system-ui; color: #17221c; background: #f5f3ec; }\nbody { margin: 0; }`);
663
- add(`${ui}/src/app/__tests__/App.spec.ts`, `import { mount } from "@vue/test-utils";\nimport App from "@/app/App.vue";\ndescribe("App", () => { it("renders a router view", () => { expect(mount(App, { global: { stubs: ["RouterView", "NotificationHost"] } }).exists()).toBe(true); }); });`);
694
+ add(`${ui}/src/app/styles.css`, `@import "tailwindcss";
695
+ :root {
696
+ font-family: Inter, ui-sans-serif, system-ui;
697
+ color: #17221c;
698
+ background: #f5f3ec;
699
+ }
700
+ body {
701
+ margin: 0;
702
+ }`);
703
+ add(`${ui}/src/app/__tests__/App.spec.ts`, `import { mount } from "@vue/test-utils";
704
+ import App from "@/app/App.vue";
705
+ describe("App", () => {
706
+ it("renders a router view", () => {
707
+ expect(mount(App, { global: { stubs: ["RouterView", "NotificationHost"] } }).exists()).toBe(true);
708
+ });
709
+ });`);
664
710
  add(`${ui}/AGENTS.md`, `# ${title} UI guidance
665
711
 
666
712
  ## Scope
@@ -1148,6 +1194,9 @@ configured. No push or merge deploys production automatically.
1148
1194
  Configure the ECR repository with immutable image tags. The release-only role may publish; the distinct deployment
1149
1195
  role is pull-only. Record the full commit SHA and ECR digest; deployment and product migrations use that release image.
1150
1196
 
1197
+ Production ingress is a required Caddy reverse proxy in every generated service deployment. The application container remains
1198
+ internal; Caddy is the only edge surface and must own 80/443 exposure.
1199
+
1151
1200
  The host separates replaceable artifacts from persistent state:
1152
1201
 
1153
1202
  \`\`\`text
@@ -1159,6 +1208,12 @@ The host separates replaceable artifacts from persistent state:
1159
1208
  └── shared/
1160
1209
  ├── .env.production
1161
1210
  ├── .env.infrastructure
1211
+ ├── caddy/
1212
+ │ ├── certs/
1213
+ │ │ ├── origin.pem
1214
+ │ │ └── origin-key.pem
1215
+ │ ├── config/
1216
+ │ └── data/
1162
1217
  ${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
1163
1218
  ` : ""}${postgres ? ` ├── postgres/data/
1164
1219
  ` : ` ├── mongo/config/keyfile
@@ -1173,6 +1228,8 @@ Actions may replace \`current/\` and must never replace \`shared/\`. The server
1173
1228
  - \`.env.production\` contains Node application behavior and provider credentials.
1174
1229
  - \`.env.infrastructure\` contains Compose paths, image identity, and infrastructure passwords. Start from
1175
1230
  \`.env.infrastructure.example\` and replace every placeholder.
1231
+ - \`.env.infrastructure\` is also the host-owned control plane for Caddy certificate paths and runtime directories; keep TLS
1232
+ key material under shared storage and rotate it independently from application images.
1176
1233
  - Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
1177
1234
  credential paths.
1178
1235
  ${options.ui && options.auth === "firebase" ? `- The UI application's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
@@ -1183,9 +1240,11 @@ Do not inject \`.env.infrastructure\` into Node. Make both host environment file
1183
1240
 
1184
1241
  1. Install Docker Engine and Docker Compose.
1185
1242
  ${postgres
1186
- ? `2. Create \`/opt/${options.name}/current/deploy/postgres\` and \`/opt/${options.name}/shared/postgres/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.`
1243
+ ? `2. Create \`/opt/${options.name}/current/deploy/postgres\`, \`/opt/${options.name}/shared/postgres/data\`,
1244
+ \`/opt/${options.name}/shared/caddy/certs\`, \`/opt/${options.name}/shared/caddy/data\`, \`/opt/${options.name}/shared/caddy/config\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.`
1187
1245
  : `2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
1188
- \`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.`}
1246
+ \`/opt/${options.name}/shared/mongo/data\`, \`/opt/${options.name}/shared/caddy/certs\`,
1247
+ \`/opt/${options.name}/shared/caddy/data\`, \`/opt/${options.name}/shared/caddy/config\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.`}
1189
1248
  3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
1190
1249
  ${postgres ? "" : `4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
1191
1250
  mode \`0400\`.
@@ -1289,6 +1348,34 @@ const developmentEnvironment = [
1289
1348
  ];
1290
1349
  const developmentEnvironmentBlock = `\n environment:\n${developmentEnvironment.join("\n")}`;
1291
1350
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
1351
+ const caddyService = `
1352
+ caddy:
1353
+ image: caddy:2-alpine
1354
+ ports:
1355
+ - "80:80"
1356
+ - "443:443"
1357
+ volumes:
1358
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
1359
+ - \${CADDY_ORIGIN_CERTIFICATE:?Set CADDY_ORIGIN_CERTIFICATE}:/etc/caddy/certs/origin.pem:ro
1360
+ - \${CADDY_ORIGIN_KEY:?Set CADDY_ORIGIN_KEY}:/etc/caddy/certs/origin-key.pem:ro
1361
+ - \${CADDY_DATA_DIR:?Set CADDY_DATA_DIR}:/data
1362
+ - \${CADDY_CONFIG_DIR:?Set CADDY_CONFIG_DIR}:/config
1363
+ depends_on:
1364
+ ${options.name}:
1365
+ condition: service_healthy
1366
+ networks:
1367
+ - backend
1368
+ restart: unless-stopped`;
1369
+ add(`${svc}/Caddyfile`, `{
1370
+ auto_https off
1371
+ }
1372
+ :80 {
1373
+ redir https://{host}{uri} 308
1374
+ }
1375
+ :443 {
1376
+ tls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin-key.pem
1377
+ reverse_proxy ${options.name}:3000
1378
+ }`);
1292
1379
  const workerService = options.worker ? `\n worker:
1293
1380
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
1294
1381
  env_file:
@@ -1431,8 +1518,7 @@ services:
1431
1518
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
1432
1519
  env_file:
1433
1520
  - \${APP_ENV_FILE:-.env.production}
1434
- ports:
1435
- - "3000:3000"${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
1521
+ ${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
1436
1522
  healthcheck:
1437
1523
  test:
1438
1524
  - CMD-SHELL
@@ -1448,7 +1534,7 @@ services:
1448
1534
  stop_grace_period: 2m
1449
1535
  networks:
1450
1536
  - backend
1451
- restart: unless-stopped${workerService}${schemaInitService}${databaseService}${redisService}
1537
+ restart: unless-stopped${workerService}${schemaInitService}${databaseService}${redisService}${caddyService}
1452
1538
  networks:
1453
1539
  backend:
1454
1540
  driver: bridge
@@ -80,7 +80,10 @@ const errorTranslationTestSource = `import { AppError, type FailureKind } from "
80
80
  import { errorHandler } from "@/platform/http/errors";
81
81
  import { logger } from "@/platform/logging/logger";
82
82
 
83
- jest.mock("@/platform/logging/logger", () => ({ logger: { log: jest.fn() }, serializeError: (error: unknown) => error }));
83
+ jest.mock("@/platform/logging/logger", () => ({
84
+ logger: { log: jest.fn() },
85
+ serializeError: (error: unknown) => error
86
+ }));
84
87
 
85
88
  const log = logger.log as jest.MockedFunction<typeof logger.log>;
86
89
 
@@ -104,13 +107,15 @@ it.each(statuses)("translates %s to HTTP %i", (kind, status) => {
104
107
  errorHandler(new AppError({ code: "CODE", message: "message", kind }), request, response, jest.fn());
105
108
  expect((response as { status: jest.Mock }).status).toHaveBeenCalledWith(status);
106
109
  expect(json).toHaveBeenCalledWith({ error: { code: "CODE", message: "message" } });
107
- expect(log).toHaveBeenCalledWith(expect.objectContaining({
108
- level: status >= 500 ? "error" : "warn",
109
- event: "http.request.failed",
110
- requestId: "request-1",
111
- code: "CODE",
112
- kind
113
- }));
110
+ expect(log).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ level: status >= 500 ? "error" : "warn",
113
+ event: "http.request.failed",
114
+ requestId: "request-1",
115
+ code: "CODE",
116
+ kind
117
+ })
118
+ );
114
119
  });
115
120
 
116
121
  it("logs an unknown failure without exposing it to the client", () => {
@@ -120,11 +125,13 @@ it("logs an unknown failure without exposing it to the client", () => {
120
125
  const error = new Error("database disconnected");
121
126
  errorHandler(error, { method: "POST", path: "/health.get" } as never, response, jest.fn());
122
127
  expect(json).toHaveBeenCalledWith({ error: { code: "INTERNAL", message: "Internal server error" } });
123
- expect(log).toHaveBeenCalledWith(expect.objectContaining({
124
- level: "error",
125
- code: "INTERNAL",
126
- error: expect.objectContaining({ message: "database disconnected" })
127
- }));
128
+ expect(log).toHaveBeenCalledWith(
129
+ expect.objectContaining({
130
+ level: "error",
131
+ code: "INTERNAL",
132
+ error: expect.objectContaining({ message: "database disconnected" })
133
+ })
134
+ );
128
135
  });
129
136
 
130
137
  it("logs a preserved application-error cause", () => {
@@ -138,9 +145,13 @@ it("logs a preserved application-error cause", () => {
138
145
  response,
139
146
  jest.fn()
140
147
  );
141
- expect(log).toHaveBeenCalledWith(expect.objectContaining({
142
- error: expect.objectContaining({ cause: expect.objectContaining({ message: "Firebase token verification failed" }) })
143
- }));
148
+ expect(log).toHaveBeenCalledWith(
149
+ expect.objectContaining({
150
+ error: expect.objectContaining({
151
+ cause: expect.objectContaining({ message: "Firebase token verification failed" })
152
+ })
153
+ })
154
+ );
144
155
  });
145
156
  `;
146
157
 
@@ -417,7 +417,15 @@ export function createRateLimiter(client: RedisClientType, policy: RateLimitPoli
417
417
  if (error instanceof RateLimiterRes)
418
418
  next(new AppError({ code: "RATE_LIMITED", message: "Too many requests", kind: "rate_limited" }));
419
419
  else if (error instanceof AppError) next(error);
420
- else next(new AppError({ code: "RATE_LIMIT_UNAVAILABLE", message: "Request protection unavailable", kind: "unavailable", cause: error }));
420
+ else
421
+ next(
422
+ new AppError({
423
+ code: "RATE_LIMIT_UNAVAILABLE",
424
+ message: "Request protection unavailable",
425
+ kind: "unavailable",
426
+ cause: error
427
+ })
428
+ );
421
429
  }
422
430
  };
423
431
  }
@@ -499,7 +507,12 @@ export class GetReadiness implements UseCase<Record<string, never>, ReadinessRes
499
507
  await Promise.all(this.probes.map((probe) => probe.check()));
500
508
  return { status: "ready" };
501
509
  } catch (error) {
502
- throw new AppError({ code: "NOT_READY", message: "Service is not ready", kind: "unavailable", cause: error });
510
+ throw new AppError({
511
+ code: "NOT_READY",
512
+ message: "Service is not ready",
513
+ kind: "unavailable",
514
+ cause: error
515
+ });
503
516
  }
504
517
  }
505
518
  }