@zaaxch/tailframe 1.0.0 → 2.0.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);
@@ -132,7 +164,7 @@ Use this workflow for any new domain capability or operation. Add only the layer
132
164
  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
165
  2. Read the root and every applicable child \`AGENTS.md\`.
134
166
  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.
167
+ 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
168
  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
169
  6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
138
170
  7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
@@ -140,7 +172,7 @@ Use this workflow for any new domain capability or operation. Add only the layer
140
172
  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
173
  10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
142
174
  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.
175
+ 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
176
  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
177
 
146
178
  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 +180,6 @@ Do not create empty architectural layers, speculative operations, or mandatory c
148
180
 
149
181
  const svcDeps = {
150
182
  "@dotenvx/dotenvx": "^1.39.1",
151
- "body-parser": "^2.2.0",
152
183
  cors: "^2.8.5",
153
184
  express: "^4.21.2",
154
185
  joi: "^17.13.3",
@@ -206,106 +237,44 @@ export const env = {
206
237
  };`);
207
238
  const databaseSource = `import { MongoClient } from "mongodb";
208
239
  import { env } from "@/platform/config/env";
240
+
209
241
  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 };
242
+
243
+ export async function connectDatabase() {
244
+ await mongo.connect();
245
+ return mongo.db(env.mongodbDbName);
290
246
  }
291
- `);
247
+
248
+ export async function closeDatabase() {
249
+ await mongo.close();
250
+ }`;
251
+ add(`${svc}/src/platform/database/index.ts`, databaseSource);
252
+ if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
253
+ add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
254
+ add(`${svc}/src/core/UseCase.ts`, useCaseSource);
255
+ add(`${svc}/src/core/errors.ts`, appErrorSource);
256
+ add(`${svc}/src/platform/http/createRequestContext.ts`, createRequestContextSource(options.auth));
257
+ if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, firebaseSource);
258
+ add(`${svc}/src/platform/http/rpc.ts`, rpcSource);
259
+ add(`${svc}/src/platform/http/rpcHandler.ts`, rpcHandlerSource);
260
+ add(`${svc}/src/platform/http/errors.ts`, httpErrorsSource);
261
+ const csrf = options.auth === "firebase" && options.ui;
262
+ if (csrf) add(`${svc}/src/platform/http/csrf.ts`, csrfSource);
263
+ add(`${svc}/src/modules/health/use-cases/GetHealth.ts`, getHealthSource);
264
+ add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
265
+ add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
266
+ add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
267
+ add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
268
+ add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
269
+ if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
270
+ add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
271
+ add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
272
+ add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
292
273
  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
- `);
274
+ if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource());
307
275
  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`);
276
+ add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
277
+ add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
309
278
  add(`${svc}/src/__tests__/testDb.ts`, `import { MongoClient } from "mongodb";
310
279
  const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
311
280
  export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
@@ -344,24 +313,57 @@ services:
344
313
  }
345
314
  '
346
315
  `);
316
+ const boundaryRequest = (expression) => csrf ? `browser(${expression})` : expression;
347
317
  add(`${svc}/src/app/__tests__/boundary.integration.test.ts`, `import request from "supertest";
348
- ${options.ui ? 'import path from "node:path";\n' : ""}
318
+ ${options.ui ? 'import path from "node:path";\n' : ""}${options.redis ? 'import type { RedisClientType } from "redis";\n' : ""}
319
+ import { registerDependencies } from "@/app/container";
349
320
  import { createApplication } from "@/app/server";
350
321
  import { closeTestDatabase, resetTestDatabase } from "@/__tests__/testDb";
322
+ ${options.redis ? `
323
+ const stubRedis = {
324
+ ping: async () => "PONG"
325
+ } as unknown as RedisClientType;
326
+ ` : ""}${csrf ? `
327
+ const browser = (agent: request.Test) => agent.set("X-Requested-With", "XMLHttpRequest");
328
+ ` : ""}
351
329
  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
- });
330
+ beforeAll(async () => {
331
+ registerDependencies({ db: await resetTestDatabase()${options.redis ? ", redis: stubRedis" : ""} });
358
332
  });
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
- });
333
+ afterAll(async () => {
334
+ await closeTestDatabase();
363
335
  });
364
- ${options.ui ? ` it("serves the production SPA without falling through for API paths", async () => {
336
+
337
+ it("returns successful RPC envelopes", async () => {
338
+ await ${boundaryRequest('request(createApplication()).post("/api/v1/health.get")')}
339
+ .send({})
340
+ .expect(200)
341
+ .expect(({ body }) => expect(body).toEqual({ result: { status: "ok" } }));
342
+ await ${boundaryRequest('request(createApplication()).post("/api/v1/health.readiness")')}
343
+ .send({})
344
+ .expect(200)
345
+ .expect(({ body }) => expect(body).toEqual({ result: { status: "ready" } }));
346
+ });
347
+
348
+ it("returns a validation error envelope with a machine-readable code", async () => {
349
+ await ${boundaryRequest('request(createApplication()).post("/api/v1/health.get")')}
350
+ .send({ unexpected: true })
351
+ .expect(400)
352
+ .expect(({ body }) => {
353
+ expect(body.error.code).toBe("VALIDATION_FAILED");
354
+ expect(body.error.message).toEqual(expect.any(String));
355
+ });
356
+ });
357
+ ${csrf ? `
358
+ it("rejects a mutating browser request without the CSRF header", async () => {
359
+ await request(createApplication())
360
+ .post("/api/v1/health.get")
361
+ .send({})
362
+ .expect(403)
363
+ .expect(({ body }) => expect(body.error.code).toBe("CSRF_HEADER_MISSING"));
364
+ });
365
+ ` : ""}${options.ui ? `
366
+ it("serves the production SPA without falling through for API paths", async () => {
365
367
  const app = createApplication({ production: true, publicPath: path.join(__dirname, "fixtures/public") });
366
368
  await request(app).get("/dashboard").expect(200).expect(({ text }) => expect(text).toContain("boundary-test-spa"));
367
369
  await request(app).get("/api/v1/does-not-exist").expect(404);
@@ -402,12 +404,15 @@ Product vocabulary stays in the module that owns its meaning; never move it into
402
404
 
403
405
  ## Public API contract
404
406
  - Use singular RPC-shaped \`POST /api/v1/<module>.<operation>\` operations and the \`{ result: ... }\` success envelope.
407
+ - Give each module one \`<module>.routes.ts\` file with exactly one \`<module>Routes\` factory. Pass multiple operations through one named use-case object.
408
+ - 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
409
  - Implement only requested operations; do not create speculative CRUD.
406
410
 
407
411
  ## Module ownership
408
412
  - Create architecture files only with \`tailframe generate\`; hand-created architecture files are a conformance violation.
409
413
  - Colocate capability code under \`src/modules/<module>\`.
410
414
  - Start with \`use-cases/\`, required \`http/\` translation, and tests. Add \`domain/\` or \`persistence/\` only when real behavior earns them.
415
+ - 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
416
  - Use cases expose \`execute(context, input)\` and remain callable from HTTP, jobs, workers, scheduled tasks, or CLIs.
412
417
  - Do not require controllers that only validate and delegate.
413
418
 
@@ -435,7 +440,8 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
435
440
  - HTTP routes, jobs, workers, scheduled tasks, and CLIs call use cases directly; they do not call each other.
436
441
  - Keep external-system clients under \`src/platform/integrations/<provider>\` and adapt them through module-owned ports.
437
442
  - 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.
443
+ - 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.
444
+ - Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
439
445
  ${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
446
 
441
447
  ## Tests and validation
@@ -467,17 +473,63 @@ add(`${ui}/index.html`, `<!doctype html>
467
473
  `);
468
474
  add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n`);
469
475
  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 }; }`);
476
+ add(`${ui}/src/core/errors.ts`, uiErrorsSource);
477
+ add(`${ui}/src/core/rpc.ts`, uiRpcSource);
471
478
  if (options.auth === "firebase") {
472
479
  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
480
  add(`${ui}/src/app/stores/auth.store.ts`, authAppStoreSource());
474
481
  }
475
482
  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>`);
483
+ add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";
484
+ import { RouteNames } from "@/core/RouteNames";
485
+
486
+ export default createRouter({
487
+ history: createWebHistory(),
488
+ routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/modules/health/views/HealthView.vue") }]
489
+ });`);
490
+ add(`${ui}/src/platform/http.ts`, uiHttpSource);
491
+ if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
492
+ add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
493
+ add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
494
+ import { ref } from "vue";
495
+ import { isServiceError } from "@/core/errors";
496
+ import { getReadiness } from "@/modules/health/api/health.api";
497
+
498
+ const loading = ref(false);
499
+ const status = ref("");
500
+ const error = ref("");
501
+
502
+ async function checkReadiness() {
503
+ loading.value = true;
504
+ status.value = "";
505
+ error.value = "";
506
+ try {
507
+ status.value = (await getReadiness()).status;
508
+ } catch (value) {
509
+ error.value = isServiceError(value) ? value.message : "Readiness check failed";
510
+ } finally {
511
+ loading.value = false;
512
+ }
513
+ }
514
+ </script>
515
+
516
+ <template>
517
+ <main class="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 px-6 py-16">
518
+ <p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Architecture reference</p>
519
+ <h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1>
520
+ <p>The product domain is intentionally undefined.</p>
521
+ <button
522
+ type="button"
523
+ class="self-start rounded-full bg-emerald-800 px-4 py-3 text-white disabled:opacity-60"
524
+ :disabled="loading"
525
+ @click="checkReadiness"
526
+ >
527
+ {{ loading ? "Checking…" : "Check readiness" }}
528
+ </button>
529
+ <p v-if="status" role="status">Service status: {{ status }}</p>
530
+ <p v-if="error" role="alert">{{ error }}</p>
531
+ </main>
532
+ </template>`);
481
533
  add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
482
534
  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
535
  add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);