@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 +1 -1
- package/src/new.mjs +106 -20
- package/src/owned-sources.mjs +27 -16
- package/src/service-templates.mjs +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "4.0.
|
|
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({
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
export async function
|
|
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(
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
export async function
|
|
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)
|
|
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
|
|
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"
|
|
663
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
package/src/owned-sources.mjs
CHANGED
|
@@ -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", () => ({
|
|
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(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
142
|
-
|
|
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
|
|
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({
|
|
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
|
}
|