@zaaxch/tailframe 1.0.0 → 2.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/src/new.mjs CHANGED
@@ -1,6 +1,38 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { authAppStoreSource, GenerateError } from "./generate.mjs";
4
+ import {
5
+ appErrorSource,
6
+ applicationRoutesSource,
7
+ containerSource,
8
+ createRequestContextSource,
9
+ csrfSource,
10
+ firebaseSource,
11
+ getHealthSource,
12
+ getHealthTestSource,
13
+ getReadinessSource,
14
+ getReadinessTestSource,
15
+ healthRoutesSource,
16
+ healthSchemasSource,
17
+ httpErrorsSource,
18
+ mongoReadinessProbeSource,
19
+ readinessProbeSource,
20
+ redisReadinessProbeSource,
21
+ redisSource,
22
+ requestContextSource,
23
+ rpcHandlerSource,
24
+ rpcSource,
25
+ serverSource,
26
+ useCaseSource,
27
+ workerSource
28
+ } from "./service-templates.mjs";
29
+ import {
30
+ configureHttpSource,
31
+ healthApiSource,
32
+ uiErrorsSource,
33
+ uiHttpSource,
34
+ uiRpcSource
35
+ } from "./ui-templates.mjs";
4
36
 
5
37
  function fail(message) {
6
38
  throw new GenerateError(message);
@@ -45,9 +77,7 @@ const prettierConfig = {
45
77
  endOfLine: "lf",
46
78
  singleQuote: false
47
79
  };
48
-
49
- add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
50
- add(".gitattributes", `* text=auto eol=lf
80
+ const gitAttributes = `* text=auto eol=lf
51
81
 
52
82
  *.png binary
53
83
  *.jpg binary
@@ -55,7 +85,10 @@ add(".gitattributes", `* text=auto eol=lf
55
85
  *.gif binary
56
86
  *.ico binary
57
87
  *.pdf binary
58
- `);
88
+ `;
89
+
90
+ add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
91
+ add(".gitattributes", gitAttributes);
59
92
 
60
93
  const repos = [[svc, "Express/TypeScript API and background runtime."]];
61
94
  if (options.ui) repos.push([ui, "Vue/Vite customer interface."]);
@@ -132,7 +165,7 @@ Use this workflow for any new domain capability or operation. Add only the layer
132
165
  1. Identify the module, requested operations, entry points, and repositories in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
133
166
  2. Read the root and every applicable child \`AGENTS.md\`.
134
167
  3. Inspect the nearest working module and tests.
135
- 4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; complete the printed wiring checklist after each generation.
168
+ 4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Service module generation also registers the new use case and mounts or extends its single route factory through deterministic composition-file edits. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; inspect the generated diff and complete only the remaining behavior-specific checklist.
136
169
  5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
137
170
  6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
138
171
  7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
@@ -140,7 +173,7 @@ Use this workflow for any new domain capability or operation. Add only the layer
140
173
  9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate the module's identifier file with \`tailframe generate identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters, using persistence-only document types rather than \`any\` to bypass the boundary.
141
174
  10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
142
175
  11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory. A UI module may import another only through a named file directly under the provider's public directory. Both graphs must remain acyclic. UI modules may import only the generated auth/theme app stores and \`ThemeToggle\` from app; every other app path is private.
143
- 12. Add required container, route, client, navigation, worker, or process registration and focused tests. Route module views directly from \`app/router.ts\`. App views and shell components consume product modules only through named module \`public/\` entries; they never import module views or other internals.
176
+ 12. Verify generated container and route wiring, then add only dependency construction, client, navigation, worker, or process registration earned by real behavior. Route module views directly from \`app/router.ts\`. App views and shell components consume product modules only through named module \`public/\` entries; they never import module views or other internals.
144
177
  13. Run npm run validate:architecture in every affected repository, then run focused type checks and tests. Report repositories, operations, entry points, checks, placement decisions, and gaps.
145
178
 
146
179
  Do not create empty architectural layers, speculative operations, or mandatory controllers. Do not claim behavior from static files, weaken trusted context or persistence ownership, or change unrelated background behavior.
@@ -148,7 +181,6 @@ Do not create empty architectural layers, speculative operations, or mandatory c
148
181
 
149
182
  const svcDeps = {
150
183
  "@dotenvx/dotenvx": "^1.39.1",
151
- "body-parser": "^2.2.0",
152
184
  cors: "^2.8.5",
153
185
  express: "^4.21.2",
154
186
  joi: "^17.13.3",
@@ -172,7 +204,7 @@ add(`${svc}/package.json`, JSON.stringify({
172
204
  ...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
173
205
  build: "tsc && tsc-alias", start: "node dist/server.js",
174
206
  ...(options.worker ? { worker: "node dist/worker.js" } : {}),
175
- test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/",
207
+ test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/", "format:check": "prettier --check src/",
176
208
  "test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
177
209
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
178
210
  "docker:prod": "docker compose -f docker-compose.yml up -d",
@@ -189,6 +221,7 @@ module.exports = { projects: [
189
221
  { ...base, displayName: "integration", testMatch: ["**/*.integration.test.ts"] }
190
222
  ] };`);
191
223
  add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
224
+ add(`${svc}/.gitattributes`, gitAttributes);
192
225
  add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
193
226
  add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
194
227
  if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
@@ -206,106 +239,44 @@ export const env = {
206
239
  };`);
207
240
  const databaseSource = `import { MongoClient } from "mongodb";
208
241
  import { env } from "@/platform/config/env";
242
+
209
243
  export const mongo = new MongoClient(env.mongodbUri);
210
- export async function connectDatabase() { await mongo.connect(); return mongo.db(env.mongodbDbName); }
211
- export async function closeDatabase() { await mongo.close(); }`;
212
- add(`${svc}/src/platform/database/index.ts`, databaseSource);
213
- if (options.redis) add(`${svc}/src/platform/redis/index.ts`, `import { createClient, type RedisClientType } from "redis";
214
- import { env } from "@/platform/config/env";
215
- export const redis: RedisClientType = createClient({ url: env.redisUrl });
216
- redis.on("error", (error) => console.error("Redis client error", error));
217
- export async function connectRedis() { if (!redis.isOpen) await redis.connect(); return redis; }
218
- export async function closeRedis() { if (redis.isOpen) await redis.quit(); }
219
- `);
220
- add(`${svc}/src/core/RequestContext.ts`, `export interface AuthenticatedPrincipal {\n\tuid: string;\n\temail?: string;\n}\nexport interface RequestContext {\n\trequestId: string;\n\tprincipal?: AuthenticatedPrincipal;\n\troles: string[];\n}\n`);
221
- add(`${svc}/src/core/UseCase.ts`, `import type { RequestContext } from "@/core/RequestContext";\nexport interface UseCase<Input, Output> {\n\texecute(context: RequestContext, input: Input): Promise<Output>;\n}\n`);
222
- add(`${svc}/src/platform/http/createRequestContext.ts`, options.auth === "firebase" ? `import { randomUUID } from "node:crypto";
223
- import type { Request } from "express";
224
- import type { RequestContext } from "@/core/RequestContext";
225
- import { verifyFirebaseToken } from "@/platform/auth/firebase";
226
- export async function createRequestContext(req: Request): Promise<RequestContext> {
227
- const requestId = String(req.headers["x-request-id"] ?? randomUUID());
228
- const decoded = req.headers.authorization?.startsWith("Bearer ") ? await verifyFirebaseToken(req.headers.authorization.slice(7)) : undefined;
229
- return { requestId, principal: decoded ? { uid: decoded.uid, email: decoded.email } : undefined, roles: [] };
230
- }
231
- export function systemRequestContext(requestId = "system"): RequestContext { return { requestId, roles: [] }; }
232
- ` : `import { randomUUID } from "node:crypto";
233
- import type { Request } from "express";
234
- import type { RequestContext } from "@/core/RequestContext";
235
- export function createRequestContext(req: Request): RequestContext {
236
- return { requestId: String(req.headers["x-request-id"] ?? randomUUID()), roles: [] };
237
- }
238
- export function systemRequestContext(requestId = "system"): RequestContext { return { requestId, roles: [] }; }
239
- `);
240
- if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, `import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
241
- import { getAuth, type DecodedIdToken } from "firebase-admin/auth";
242
- import { env } from "@/platform/config/env";
243
- export function verifyFirebaseToken(token: string): Promise<DecodedIdToken> {
244
- if (!getApps().length) initializeApp({ credential: applicationDefault(), projectId: env.firebaseProjectId || undefined });
245
- return getAuth().verifyIdToken(token);
246
- }`);
247
- add(`${svc}/src/platform/http/rpc.ts`, `import type { Response } from "express";\nexport function rpcResult(res: Response, result: unknown) { return res.json({ result }); }\n`);
248
- add(`${svc}/src/platform/http/errors.ts`, `import type { ErrorRequestHandler } from "express";
249
- export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
250
- if (error?.isJoi) { res.status(400).json({ error: { message: error.message } }); return; }
251
- const status = Number(error?.status ?? 500);
252
- res.status(status).json({ error: { message: status === 500 ? "Internal server error" : error.message } });
253
- };
254
- `);
255
- add(`${svc}/src/modules/health/use-cases/GetHealth.ts`, `import type { UseCase } from "@/core/UseCase";\nimport type { RequestContext } from "@/core/RequestContext";\nexport type HealthResult = { status: "ok" };\nexport class GetHealth implements UseCase<Record<string, never>, HealthResult> {\n\tasync execute(_context: RequestContext, _input: Record<string, never>): Promise<HealthResult> { return { status: "ok" }; }\n}\n`);
256
- add(`${svc}/src/modules/health/http/health.schemas.ts`, `import Joi from "joi";\nexport const GetHealthSchema = Joi.object({}).unknown(false);\n`);
257
- add(`${svc}/src/modules/health/http/health.routes.ts`, `import { Router } from "express";\nimport type { GetHealth } from "@/modules/health/use-cases/GetHealth";\nimport { GetHealthSchema } from "@/modules/health/http/health.schemas";\nimport { createRequestContext } from "@/platform/http/createRequestContext";\nimport { rpcResult } from "@/platform/http/rpc";\nexport function healthRoutes(getHealth: GetHealth) {\n\tconst router = Router();\n\trouter.post("/health.get", async (req, res, next) => { try { const input = await GetHealthSchema.validateAsync(req.body ?? {}); rpcResult(res, await getHealth.execute(await createRequestContext(req), input)); } catch (error) { next(error); } });\n\treturn router;\n}\n`);
258
- add(`${svc}/src/app/container.ts`, `import { container } from "tsyringe";\nimport { GetHealth } from "@/modules/health/use-cases/GetHealth";\ncontainer.registerSingleton(GetHealth, GetHealth);\nexport { container };\n`);
259
- add(`${svc}/src/app/routes.ts`, `import { Router } from "express";\nimport { container } from "@/app/container";\nimport { GetHealth } from "@/modules/health/use-cases/GetHealth";\nimport { healthRoutes } from "@/modules/health/http/health.routes";\nexport function applicationRoutes() { const router = Router(); router.use(healthRoutes(container.resolve(GetHealth))); return router; }\n`);
260
- add(`${svc}/src/app/server.ts`, `import path from "node:path";
261
- import express from "express";
262
- import cors from "cors";
263
- import { applicationRoutes } from "@/app/routes";
264
- import { env } from "@/platform/config/env";
265
- import { connectDatabase, closeDatabase } from "@/platform/database";
266
- import { errorHandler } from "@/platform/http/errors";
267
- export interface ApplicationOptions { publicPath?: string; production?: boolean; }
268
- export function createApplication(options: ApplicationOptions = {}) {
269
- const app = express();
270
- ${options.ui ? `const publicPath = options.publicPath ?? path.join(__dirname, "..", "public");
271
- const production = options.production ?? env.nodeEnv === "production";
272
- if (production) app.use(express.static(publicPath, { index: false, maxAge: "1y", immutable: true }));` : ""}
273
- app.use(cors({ origin: env.corsOrigin }));
274
- app.use(express.json());
275
- app.use("/api/v1", applicationRoutes());
276
- ${options.ui ? `if (production) {
277
- app.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) => res.sendFile(path.join(publicPath, "index.html")));
278
- }` : ""}
279
- app.use(errorHandler);
280
- return app;
281
- }
282
- export async function startServer() {
283
- await connectDatabase();
284
- const app = createApplication();
285
- const server = app.listen(env.port);
286
- const shutdown = async () => { server.close(); await closeDatabase(); };
287
- process.once("SIGINT", shutdown);
288
- process.once("SIGTERM", shutdown);
289
- return { app, server, shutdown };
244
+
245
+ export async function connectDatabase() {
246
+ await mongo.connect();
247
+ return mongo.db(env.mongodbDbName);
290
248
  }
291
- `);
249
+
250
+ export async function closeDatabase() {
251
+ await mongo.close();
252
+ }`;
253
+ add(`${svc}/src/platform/database/index.ts`, databaseSource);
254
+ if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
255
+ add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
256
+ add(`${svc}/src/core/UseCase.ts`, useCaseSource);
257
+ add(`${svc}/src/core/errors.ts`, appErrorSource);
258
+ add(`${svc}/src/platform/http/createRequestContext.ts`, createRequestContextSource(options.auth));
259
+ if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, firebaseSource);
260
+ add(`${svc}/src/platform/http/rpc.ts`, rpcSource);
261
+ add(`${svc}/src/platform/http/rpcHandler.ts`, rpcHandlerSource);
262
+ add(`${svc}/src/platform/http/errors.ts`, httpErrorsSource);
263
+ const csrf = options.auth === "firebase" && options.ui;
264
+ if (csrf) add(`${svc}/src/platform/http/csrf.ts`, csrfSource);
265
+ add(`${svc}/src/modules/health/use-cases/GetHealth.ts`, getHealthSource);
266
+ add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
267
+ add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
268
+ add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
269
+ add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
270
+ add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
271
+ if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
272
+ add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
273
+ add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
274
+ add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
292
275
  add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
293
- if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, `import { connectDatabase, closeDatabase } from "@/platform/database";
294
- import { connectRedis, closeRedis } from "@/platform/redis";
295
- import { systemRequestContext } from "@/platform/http/createRequestContext";
296
- import { GetHealth } from "@/modules/health/use-cases/GetHealth";
297
- // Workers invoke use cases directly. Add real scheduled work only with an explicit domain workflow.
298
- export async function startWorker() {
299
- await connectDatabase();
300
- await connectRedis();
301
- console.log("${title} worker runtime ready", await new GetHealth().execute(systemRequestContext(), {}));
302
- const shutdown = async () => { await closeDatabase(); await closeRedis(); process.exit(0); };
303
- process.once("SIGINT", shutdown);
304
- process.once("SIGTERM", shutdown);
305
- }
306
- `);
276
+ if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource());
307
277
  if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
308
- add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, `import { GetHealth } from "@/modules/health/use-cases/GetHealth";\ndescribe("GetHealth", () => { it("returns readiness through the use-case contract", async () => { await expect(new GetHealth().execute({ requestId: "test", roles: [] }, {})).resolves.toEqual({ status: "ok" }); }); });\n`);
278
+ add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
279
+ add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
309
280
  add(`${svc}/src/__tests__/testDb.ts`, `import { MongoClient } from "mongodb";
310
281
  const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
311
282
  export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
@@ -344,24 +315,57 @@ services:
344
315
  }
345
316
  '
346
317
  `);
318
+ const boundaryRequest = (expression) => csrf ? `browser(${expression})` : expression;
347
319
  add(`${svc}/src/app/__tests__/boundary.integration.test.ts`, `import request from "supertest";
348
- ${options.ui ? 'import path from "node:path";\n' : ""}
320
+ ${options.ui ? 'import path from "node:path";\n' : ""}${options.redis ? 'import type { RedisClientType } from "redis";\n' : ""}
321
+ import { registerDependencies } from "@/app/container";
349
322
  import { createApplication } from "@/app/server";
350
323
  import { closeTestDatabase, resetTestDatabase } from "@/__tests__/testDb";
324
+ ${options.redis ? `
325
+ const stubRedis = {
326
+ ping: async () => "PONG"
327
+ } as unknown as RedisClientType;
328
+ ` : ""}${csrf ? `
329
+ const browser = (agent: request.Test) => agent.set("X-Requested-With", "XMLHttpRequest");
330
+ ` : ""}
351
331
  describe("HTTP boundary", () => {
352
- beforeAll(async () => { await resetTestDatabase(); });
353
- afterAll(async () => { await closeTestDatabase(); });
354
- it("returns a successful RPC envelope", async () => {
355
- await request(createApplication()).post("/api/v1/health.get").send({}).expect(200).expect(({ body }) => {
356
- expect(body).toEqual({ result: { status: "ok" } });
357
- });
332
+ beforeAll(async () => {
333
+ registerDependencies({ db: await resetTestDatabase()${options.redis ? ", redis: stubRedis" : ""} });
334
+ });
335
+ afterAll(async () => {
336
+ await closeTestDatabase();
337
+ });
338
+
339
+ it("returns successful RPC envelopes", async () => {
340
+ await ${boundaryRequest('request(createApplication()).post("/api/v1/health.get")')}
341
+ .send({})
342
+ .expect(200)
343
+ .expect(({ body }) => expect(body).toEqual({ result: { status: "ok" } }));
344
+ await ${boundaryRequest('request(createApplication()).post("/api/v1/health.readiness")')}
345
+ .send({})
346
+ .expect(200)
347
+ .expect(({ body }) => expect(body).toEqual({ result: { status: "ready" } }));
348
+ });
349
+
350
+ it("returns a validation error envelope with a machine-readable code", async () => {
351
+ await ${boundaryRequest('request(createApplication()).post("/api/v1/health.get")')}
352
+ .send({ unexpected: true })
353
+ .expect(400)
354
+ .expect(({ body }) => {
355
+ expect(body.error.code).toBe("VALIDATION_FAILED");
356
+ expect(body.error.message).toEqual(expect.any(String));
357
+ });
358
358
  });
359
- it("returns a validation error envelope", async () => {
360
- await request(createApplication()).post("/api/v1/health.get").send({ unexpected: true }).expect(400).expect(({ body }) => {
361
- expect(body.error.message).toEqual(expect.any(String));
362
- });
359
+ ${csrf ? `
360
+ it("rejects a mutating browser request without the CSRF header", async () => {
361
+ await request(createApplication())
362
+ .post("/api/v1/health.get")
363
+ .send({})
364
+ .expect(403)
365
+ .expect(({ body }) => expect(body.error.code).toBe("CSRF_HEADER_MISSING"));
363
366
  });
364
- ${options.ui ? ` it("serves the production SPA without falling through for API paths", async () => {
367
+ ` : ""}${options.ui ? `
368
+ it("serves the production SPA without falling through for API paths", async () => {
365
369
  const app = createApplication({ production: true, publicPath: path.join(__dirname, "fixtures/public") });
366
370
  await request(app).get("/dashboard").expect(200).expect(({ text }) => expect(text).toContain("boundary-test-spa"));
367
371
  await request(app).get("/api/v1/does-not-exist").expect(404);
@@ -402,12 +406,15 @@ Product vocabulary stays in the module that owns its meaning; never move it into
402
406
 
403
407
  ## Public API contract
404
408
  - Use singular RPC-shaped \`POST /api/v1/<module>.<operation>\` operations and the \`{ result: ... }\` success envelope.
409
+ - Give each module one \`<module>.routes.ts\` file with exactly one \`<module>Routes\` factory. Pass multiple operations through one named use-case object.
410
+ - Name request schemas after operations in PascalCase and end them with \`Schema\`. Translate RPC POST requests only through \`src/platform/http/rpcHandler.ts\`; keep redirects, callbacks, and other non-RPC HTTP behavior explicit.
405
411
  - Implement only requested operations; do not create speculative CRUD.
406
412
 
407
413
  ## Module ownership
408
414
  - Create architecture files only with \`tailframe generate\`; hand-created architecture files are a conformance violation.
409
415
  - Colocate capability code under \`src/modules/<module>\`.
410
416
  - Start with \`use-cases/\`, required \`http/\` translation, and tests. Add \`domain/\` or \`persistence/\` only when real behavior earns them.
417
+ - Keep one named use-case class, port, adapter, or domain concept per file. Do not bundle operations or framework injection metadata into product files.
411
418
  - Use cases expose \`execute(context, input)\` and remain callable from HTTP, jobs, workers, scheduled tasks, or CLIs.
412
419
  - Do not require controllers that only validate and delegate.
413
420
 
@@ -435,18 +442,23 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
435
442
  - HTTP routes, jobs, workers, scheduled tasks, and CLIs call use cases directly; they do not call each other.
436
443
  - Keep external-system clients under \`src/platform/integrations/<provider>\` and adapt them through module-owned ports.
437
444
  - Keep optional entry-point assembly under \`src/app/<entry-point-kind>\`; root files such as \`src/server.ts\` and \`src/worker.ts\` may only bootstrap that assembly.
438
- - Register dependencies, routes, workers, and jobs explicitly.
445
+ - Initialize infrastructure first, then call \`registerDependencies(...)\` from every process entry point. The composition root resets the container, explicitly constructs dependencies, and registers class-token instances. Keep tsyringe, injection tokens, decorators, and container resolution out of modules and platform adapters.
446
+ - Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
439
447
  ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
440
448
 
449
+ ## Production image
450
+ The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."}${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
451
+
441
452
  ## Tests and validation
442
- Run \`npm run validate:architecture\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
453
+ Run \`npm run validate:architecture\` and \`npm run format:check\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
443
454
  `);
444
455
 
445
456
  if (options.ui) {
446
457
  const uiDeps = { "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.3.0", axios: "^1.9.0", pinia: "^3.0.1", primevue: "^4.2.5", vue: "^3.5.13", "vue-router": "^4.5.0", ...(options.auth === "firebase" ? { firebase: "^11.0.0" } : {}) };
447
458
  const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@zaaxch/tailframe": contractVersion, "@types/node": "^22.13.14", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.7.0", jsdom: "^29.1.1", "npm-run-all2": "^7.0.2", prettier: "3.5.3", typescript: "~5.8.0", vite: "^6.2.4", vitest: "^4.1.7", "vue-tsc": "^2.2.8" };
448
- add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
459
+ add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest run", "test:watch": "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
449
460
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
461
+ add(`${ui}/.gitattributes`, gitAttributes);
450
462
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
451
463
  add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
452
464
  add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.*"], compilerOptions: { composite: true, types: ["node"] } }, null, "\t"));
@@ -467,17 +479,63 @@ add(`${ui}/index.html`, `<!doctype html>
467
479
  `);
468
480
  add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n`);
469
481
  if (options.auth === "firebase") add(`${ui}/.env.example`, `VITE_FIREBASE_API_KEY=\nVITE_FIREBASE_AUTH_DOMAIN=\nVITE_FIREBASE_PROJECT_ID=\nVITE_FIREBASE_APP_ID=\n`);
470
- add(`${ui}/src/core/rpc.ts`, `export interface RpcResponse<T> { result: T; }\nexport interface RpcError { error: { message: string; code?: string }; }`);
482
+ add(`${ui}/src/core/errors.ts`, uiErrorsSource);
483
+ add(`${ui}/src/core/rpc.ts`, uiRpcSource);
471
484
  if (options.auth === "firebase") {
472
485
  add(`${ui}/src/platform/firebase.ts`, `import { initializeApp } from "firebase/app";\nimport { getAuth, GoogleAuthProvider } from "firebase/auth";\n\nconst firebaseApp = initializeApp({\n\tapiKey: import.meta.env.VITE_FIREBASE_API_KEY,\n\tauthDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,\n\tprojectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,\n\tappId: import.meta.env.VITE_FIREBASE_APP_ID\n});\n\nexport const auth = getAuth(firebaseApp);\nexport const googleProvider = new GoogleAuthProvider();`);
473
486
  add(`${ui}/src/app/stores/auth.store.ts`, authAppStoreSource());
474
487
  }
475
488
  add(`${ui}/src/core/RouteNames.ts`, `/** Route vocabulary shared by the router and by modules. Modules never import the router itself. */\nexport enum RouteNames {\n\tHOME = "Home"\n}\n`);
476
- add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";\nimport { RouteNames } from "@/core/RouteNames";\nexport default createRouter({ history: createWebHistory(), routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/app/views/HomeView.vue") }] });`);
477
- add(`${ui}/src/platform/http.ts`, `import axios from "axios";\nexport const http = axios.create({ baseURL: \`\${window.location.origin}/api/v1\`, headers: { "Content-Type": "application/json" } });`);
478
- if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, `import type { Pinia } from "pinia";\nimport router from "@/app/router";\nimport { RouteNames } from "@/core/RouteNames";\nimport { useAuthStore } from "@/app/stores/auth.store";\nimport { http } from "@/platform/http";\nexport function configureHttp(pinia: Pinia) {\n\tconst auth = useAuthStore(pinia);\n\thttp.interceptors.request.use(async config => { const token = await auth.getIdToken(); if (token) config.headers.Authorization = \`Bearer \${token}\`; return config; });\n\thttp.interceptors.response.use(response => response, async error => { if (error?.response?.status === 401) { await auth.logout(); await router.push({ name: RouteNames.HOME }); } return Promise.reject(error); });\n}\n`);
479
- add(`${ui}/src/modules/health/api/health.api.ts`, `import { http } from "@/platform/http";\nimport type { RpcResponse } from "@/core/rpc";\nexport async function getHealth() { return (await http.post<RpcResponse<{ status: string }>>("health.get")).data.result; }`);
480
- add(`${ui}/src/app/views/HomeView.vue`, `<template><main class="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 px-6 py-16"><p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Project foundation</p><h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1><p>The product domain is intentionally undefined.</p></main></template>`);
489
+ add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";
490
+ import { RouteNames } from "@/core/RouteNames";
491
+
492
+ export default createRouter({
493
+ history: createWebHistory(),
494
+ routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/modules/health/views/HealthView.vue") }]
495
+ });`);
496
+ add(`${ui}/src/platform/http.ts`, uiHttpSource);
497
+ if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
498
+ add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
499
+ add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
500
+ import { ref } from "vue";
501
+ import { isServiceError } from "@/core/errors";
502
+ import { getReadiness } from "@/modules/health/api/health.api";
503
+
504
+ const loading = ref(false);
505
+ const status = ref("");
506
+ const error = ref("");
507
+
508
+ async function checkReadiness() {
509
+ loading.value = true;
510
+ status.value = "";
511
+ error.value = "";
512
+ try {
513
+ status.value = (await getReadiness()).status;
514
+ } catch (value) {
515
+ error.value = isServiceError(value) ? value.message : "Readiness check failed";
516
+ } finally {
517
+ loading.value = false;
518
+ }
519
+ }
520
+ </script>
521
+
522
+ <template>
523
+ <main class="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 px-6 py-16">
524
+ <p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Architecture reference</p>
525
+ <h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1>
526
+ <p>The product domain is intentionally undefined.</p>
527
+ <button
528
+ type="button"
529
+ class="self-start rounded-full bg-emerald-800 px-4 py-3 text-white disabled:opacity-60"
530
+ :disabled="loading"
531
+ @click="checkReadiness"
532
+ >
533
+ {{ loading ? "Checking…" : "Check readiness" }}
534
+ </button>
535
+ <p v-if="status" role="status">Service status: {{ status }}</p>
536
+ <p v-if="error" role="alert">{{ error }}</p>
537
+ </main>
538
+ </template>`);
481
539
  add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
482
540
  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`);
483
541
  add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
@@ -535,13 +593,13 @@ Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Pr
535
593
  - Keep \`src/app/styles.css\` limited to Tailwind imports, theme tokens, and true global base behavior.
536
594
 
537
595
  ## Production packaging
538
- The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined production image.
596
+ The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined \`linux/amd64\` production image under the default immutable \`<service-sha>_<ui-sha>\` tag, and production Compose requires that exact tag.${options.auth === "firebase" ? ` Provide \`${ui}/.env.production\` before a production build; the service build mounts it as a required BuildKit secret only while Vite compiles the browser bundle and does not copy it into the final image.` : ""}
539
597
 
540
598
  ## Product language
541
599
  The product domain is undefined. Do not invent entities, workflows, roles, claims, navigation, or customer-facing promises.
542
600
 
543
601
  ## Validation
544
- Run \`npm run validate:architecture\`, then add focused Vitest coverage and run type-check, build, and tests as applicable.
602
+ Run \`npm run validate:architecture\` and \`npm run format:check\`, then add focused Vitest coverage and run type-check, build, and \`npm test\` as applicable. Use \`npm run test:watch\` only for interactive development.
545
603
  `);
546
604
  }
547
605
 
@@ -557,7 +615,7 @@ WORKDIR /app/ui
557
615
  COPY ${ui}/package*.json ./
558
616
  RUN npm ci
559
617
  COPY ${ui}/ ./
560
- RUN npm run build
618
+ ${options.auth === "firebase" ? "RUN --mount=type=secret,id=ui_env,target=/app/ui/.env.production,required=true npm run build" : "RUN npm run build"}
561
619
 
562
620
  FROM node:22.13-bookworm-slim
563
621
  WORKDIR /app
@@ -598,18 +656,23 @@ set -euo pipefail
598
656
  : "\${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID}"
599
657
  : "\${AWS_REGION:?Set AWS_REGION}"
600
658
 
659
+ SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
660
+ BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
661
+ ${options.ui ? `UI_DIR="\${BUILD_CONTEXT}/${ui}"
662
+ SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"
663
+ UI_SHA="$(git -C "\${UI_DIR}" rev-parse --short HEAD)"` : `SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"`}
664
+
601
665
  ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
602
- IMAGE_TAG="\${IMAGE_TAG:-latest}"
666
+ IMAGE_TAG="\${IMAGE_TAG:-${options.ui ? `\${SVC_SHA}_\${UI_SHA}` : `\${SVC_SHA}`}}"
603
667
  REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
604
668
  IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
605
- SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
606
- BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
607
669
  DOCKERFILE="\${SVC_DIR}/Dockerfile"
670
+ ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${UI_DIR}/.env.production}"` : ""}
608
671
 
609
672
  aws ecr get-login-password --region "\${AWS_REGION}" |
610
673
  docker login --username AWS --password-stdin "\${REGISTRY}"
611
674
 
612
- docker build --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
675
+ docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
613
676
  docker push "\${IMAGE}"
614
677
 
615
678
  echo "Pushed \${IMAGE}"
@@ -631,7 +694,7 @@ const dependsBlock = `\n depends_on:\n${dependencies.map(([dependency, condit
631
694
  const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
632
695
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
633
696
  const workerService = options.worker ? `\n worker:
634
- image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:-latest}
697
+ image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
635
698
  env_file:
636
699
  - .env.production
637
700
  command: ["node", "dist/worker.js"]${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
@@ -676,7 +739,7 @@ const namedVolumes = [
676
739
  add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
677
740
  services:
678
741
  ${options.name}:
679
- image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:-latest}
742
+ image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
680
743
  env_file:
681
744
  - .env.production
682
745
  ports: