@zaaxch/tailframe 2.1.0 → 3.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,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { authAppStoreSource, GenerateError } from "./generate.mjs";
4
+ import { configSource } from "./config.mjs";
5
+ import { ownedSources } from "./owned-sources.mjs";
6
+ import { runSync } from "./sync.mjs";
4
7
  import {
5
8
  appErrorSource,
6
9
  applicationRoutesSource,
@@ -16,6 +19,7 @@ import {
16
19
  healthSchemasSource,
17
20
  httpErrorsSource,
18
21
  mongoReadinessProbeSource,
22
+ postgresReadinessProbeSource,
19
23
  readinessProbeSource,
20
24
  redisReadinessProbeSource,
21
25
  redisSource,
@@ -42,13 +46,14 @@ function parseArgs(argv) {
42
46
  const result = { db: "mongo", auth: "none", ui: false, redis: false, worker: false };
43
47
  for (let i = 0; i < argv.length; i += 1) {
44
48
  const arg = argv[i];
45
- if (["--name", "--path", "--auth"].includes(arg)) result[arg.slice(2)] = argv[++i];
49
+ if (["--name", "--path", "--auth", "--db"].includes(arg)) result[arg.slice(2)] = argv[++i];
46
50
  else if (["--ui", "--redis", "--worker"].includes(arg)) result[arg.slice(2)] = true;
47
51
  else fail(`Unknown argument: ${arg}`);
48
52
  }
49
53
  if (!result.name || !/^[a-z][a-z0-9-]*$/.test(result.name)) fail("--name must use lowercase letters, digits, and hyphens and start with a letter");
50
54
  if (!result.path) fail("--path is required");
51
55
  if (!["none", "firebase"].includes(result.auth)) fail("--auth must be none or firebase");
56
+ if (!["mongo", "postgres"].includes(result.db)) fail("--db must be mongo or postgres");
52
57
  if (result.worker) result.redis = true;
53
58
  return result;
54
59
  }
@@ -63,11 +68,22 @@ if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwr
63
68
  const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
64
69
  const svc = `${options.name}-svc`;
65
70
  const ui = `${options.name}-ui`;
71
+ const databaseName = options.name.replaceAll("-", "_");
72
+ const postgres = options.db === "postgres";
73
+ const mongoApplicationUser = `${options.name}-app`;
74
+ const mongoBackupUser = `${options.name}-backup`;
66
75
  const files = {};
67
76
  const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
68
77
  // Generated repositories depend on the published toolkit rather than copying a validator, and they
69
78
  // pin the exact contract version they were generated against.
70
79
  const contractVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
80
+ const serviceProfiles = [
81
+ ...(options.auth === "firebase" ? ["firebase"] : []),
82
+ options.db,
83
+ ...(options.redis ? ["redis", "rate-limit"] : []),
84
+ ...(options.worker ? ["worker"] : []),
85
+ ...(options.ui ? ["ui-host"] : [])
86
+ ];
71
87
  const prettierConfig = {
72
88
  useTabs: true,
73
89
  tabWidth: 4,
@@ -166,13 +182,13 @@ Use this workflow for any new domain capability or operation. Add only the layer
166
182
  2. Read the root and every applicable child \`AGENTS.md\`.
167
183
  3. Inspect the nearest working module and tests.
168
184
  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.
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.
185
+ 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\`, \`notification.store.ts\`, and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. 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.
170
186
  6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
171
187
  7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
172
188
  8. Define repository contracts in the owning module and implement them in persistence adapters.
173
189
  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.
174
190
  10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
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.
191
+ 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/notification/theme app stores and \`ThemeToggle\` from app; every other app path is private.
176
192
  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.
177
193
  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.
178
194
 
@@ -186,12 +202,13 @@ const svcDeps = {
186
202
  joi: "^17.13.3",
187
203
  "reflect-metadata": "^0.2.2",
188
204
  tsyringe: "^4.9.1",
189
- mongodb: "^6.17.0",
205
+ ...(postgres ? { pg: "^8.16.3" } : { mongodb: "^6.17.0" }),
190
206
  ...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
191
- ...(options.redis ? { redis: "^5.10.0" } : {})
207
+ ...(options.redis ? { "rate-limiter-flexible": "^11.2.0", redis: "^5.10.0" } : {})
192
208
  };
193
209
  const svcDevDeps = {
194
210
  "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/jest": "^30.0.0", "@types/node": "^22.13.14", "@types/supertest": "^7.2.1",
211
+ ...(postgres ? { "@types/pg": "^8.15.5" } : {}),
195
212
  "@zaaxch/tailframe": contractVersion,
196
213
  jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
197
214
  "tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
@@ -204,13 +221,15 @@ add(`${svc}/package.json`, JSON.stringify({
204
221
  ...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
205
222
  build: "tsc && tsc-alias", start: "node dist/server.js",
206
223
  ...(options.worker ? { worker: "node dist/worker.js" } : {}),
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/",
224
+ 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 .", "sync:architecture": "tailframe sync --check .", format: "prettier --write src/", "format:check": "prettier --check src/",
225
+ "schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
208
226
  "test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
209
227
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
210
228
  "docker:prod": "docker compose -f docker-compose.yml up -d",
211
229
  "docker:push": "bash scripts/build_and_push.sh"
212
230
  }, dependencies: svcDeps, devDependencies: svcDevDeps
213
231
  }, null, "\t"));
232
+ add(`${svc}/tailframe.json`, configSource({ kind: "service", profiles: serviceProfiles, contractVersion }));
214
233
  add(`${svc}/tsconfig.json`, JSON.stringify({
215
234
  compilerOptions: { target: "ES2022", module: "commonjs", rootDir: "src", outDir: "dist", strict: true, esModuleInterop: true, experimentalDecorators: true, emitDecoratorMetadata: true, baseUrl: ".", paths: { "@/*": ["src/*"] }, skipLibCheck: true },
216
235
  include: ["src/**/*.ts"]
@@ -222,22 +241,54 @@ module.exports = { projects: [
222
241
  ] };`);
223
242
  add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
224
243
  add(`${svc}/.gitattributes`, gitAttributes);
225
- add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
244
+ add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
226
245
  add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
227
246
  if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
228
- const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + options.name.replaceAll("-", "_");
247
+ const dbEnv = postgres
248
+ ? `POSTGRES_URI=postgresql://${options.name}-app:development@localhost:5432/${databaseName}`
249
+ : "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
229
250
  add(`${svc}/.env.example`, `NODE_ENV=development\nPORT=3000\nCORS_ORIGIN=https://localhost:5173\n${dbEnv}\n${options.auth === "firebase" ? "FIREBASE_PROJECT_ID=\n" : ""}${options.redis ? "REDIS_URL=redis://localhost:6379\n" : ""}`);
251
+ add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
252
+ IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
253
+ APP_ENV_FILE=/opt/${options.name}/shared/.env.production
254
+ ${postgres ? `POSTGRES_DATA_DIR=/opt/${options.name}/shared/postgres/data
255
+ POSTGRES_ROOT_PASSWORD=<uri-safe-random-value>
256
+ POSTGRES_APP_PASSWORD=<different-uri-safe-random-value>
257
+ ` : `MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
258
+ MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
259
+ MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
260
+ MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
261
+ MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
262
+ MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
263
+ `}
264
+ ${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
265
+ ` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
266
+ REDIS_PASSWORD=<different-uri-safe-random-value>
267
+ ` : ""}`);
230
268
  add(`${svc}/src/platform/config/env.ts`, `import "@dotenvx/dotenvx/config";
231
269
  export const env = {
232
270
  nodeEnv: process.env.NODE_ENV ?? "development",
233
271
  port: Number(process.env.PORT ?? 3000),
234
272
  corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
235
- ${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",` : ""}
236
- mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
237
- mongodbDbName: process.env.MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}",
273
+ ${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",
274
+ firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH,` : ""}
275
+ ${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",
276
+ mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",`}
238
277
  ${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
239
278
  };`);
240
- const databaseSource = `import { MongoClient } from "mongodb";
279
+ const databaseSource = postgres ? `import { Pool } from "pg";
280
+ import { env } from "@/platform/config/env";
281
+
282
+ export const postgres = new Pool({ connectionString: env.postgresUri });
283
+
284
+ export async function connectDatabase() {
285
+ await postgres.query("SELECT 1");
286
+ return postgres;
287
+ }
288
+
289
+ export async function closeDatabase() {
290
+ await postgres.end();
291
+ }` : `import { MongoClient } from "mongodb";
241
292
  import { env } from "@/platform/config/env";
242
293
 
243
294
  export const mongo = new MongoClient(env.mongodbUri);
@@ -251,6 +302,15 @@ export async function closeDatabase() {
251
302
  await mongo.close();
252
303
  }`;
253
304
  add(`${svc}/src/platform/database/index.ts`, databaseSource);
305
+ add(`${svc}/src/app/schema.ts`, `import type { SchemaLifecycle } from "@/core/SchemaLifecycle";
306
+ import { closeDatabase, connectDatabase } from "@/platform/database";
307
+
308
+ export const schemaLifecycle: SchemaLifecycle = {
309
+ async apply() {
310
+ await connectDatabase();
311
+ },
312
+ close: closeDatabase
313
+ };`);
254
314
  if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
255
315
  add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
256
316
  add(`${svc}/src/core/UseCase.ts`, useCaseSource);
@@ -267,9 +327,10 @@ add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
267
327
  add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
268
328
  add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
269
329
  add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
270
- add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
330
+ if (postgres) add(`${svc}/src/platform/integrations/postgres/PostgresReadinessProbe.ts`, postgresReadinessProbeSource);
331
+ else add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
271
332
  if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
272
- add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
333
+ add(`${svc}/src/app/container.ts`, containerSource({ database: options.db, redis: options.redis }));
273
334
  add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
274
335
  add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
275
336
  add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
@@ -277,13 +338,34 @@ if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource())
277
338
  if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
278
339
  add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
279
340
  add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
280
- add(`${svc}/src/__tests__/testDb.ts`, `import { MongoClient } from "mongodb";
341
+ add(`${svc}/src/__tests__/testDb.ts`, postgres ? `import { Pool } from "pg";
342
+ const pool = new Pool({ connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test" });
343
+ export async function openTestDatabase() { await pool.query("SELECT 1"); return pool; }
344
+ export async function resetTestDatabase() { await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public"); return pool; }
345
+ export async function closeTestDatabase() { await pool.end(); }
346
+ ` : `import { MongoClient } from "mongodb";
281
347
  const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
282
348
  export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
283
349
  export async function resetTestDatabase() { const database = await openTestDatabase(); await database.dropDatabase(); return database; }
284
350
  export async function closeTestDatabase() { await client.close(); }
285
351
  `);
286
- add(`${svc}/docker-compose.test.yml`, `name: ${options.name}-test
352
+ add(`${svc}/docker-compose.test.yml`, postgres ? `name: ${options.name}-test
353
+ services:
354
+ postgres:
355
+ image: postgres:17
356
+ ports:
357
+ - "5433:5432"
358
+ environment:
359
+ POSTGRES_DB: ${databaseName}_test
360
+ POSTGRES_PASSWORD: postgres
361
+ tmpfs:
362
+ - /var/lib/postgresql/data
363
+ healthcheck:
364
+ test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}_test"]
365
+ interval: 2s
366
+ timeout: 2s
367
+ retries: 30
368
+ ` : `name: ${options.name}-test
287
369
  services:
288
370
  mongo:
289
371
  image: mongo:8
@@ -444,10 +526,14 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
444
526
  - 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.
445
527
  - 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
528
  - Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
529
+ - Scheduled work prevents unintended overlap, stops intake before shutdown, awaits the active run, destroys its scheduler or consumer, and only then closes Redis and database clients.
447
530
  ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
448
531
 
449
532
  ## 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.` : ""}
533
+ 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."} The runtime image contains production dependencies only and runs as the Node user.${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.` : ""}
534
+
535
+ ## Production deployment
536
+ Only service \`main\` pushes or manual workflow dispatch deploy; UI pushes never trigger deployment. GitHub Actions may replace files under \`/opt/${options.name}/current\` and MUST NOT replace persistent configuration, credentials, keyfiles, or data under \`/opt/${options.name}/shared\`. Keep Node runtime behavior in the shared service \`.env.production\`, Compose interpolation and infrastructure credentials in \`.env.infrastructure\`, and fixed topology wiring in Compose \`environment:\`. The optional UI build-time \`.env.production\` is a separate ephemeral file. Do not run deployment until the host and GitHub production environment have been bootstrapped according to \`docs/production-deployment.md\`.
451
537
 
452
538
  ## Tests and validation
453
539
  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.
@@ -456,7 +542,12 @@ Run \`npm run validate:architecture\` and \`npm run format:check\` after changin
456
542
  if (options.ui) {
457
543
  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" } : {}) };
458
544
  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" };
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"));
545
+ 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 .", "sync:architecture": "tailframe sync --check .", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
546
+ add(`${ui}/tailframe.json`, configSource({
547
+ kind: "ui",
548
+ profiles: [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"],
549
+ contractVersion
550
+ }));
460
551
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
461
552
  add(`${ui}/.gitattributes`, gitAttributes);
462
553
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
@@ -495,6 +586,7 @@ export default createRouter({
495
586
  });`);
496
587
  add(`${ui}/src/platform/http.ts`, uiHttpSource);
497
588
  if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
589
+ add(`${ui}/src/platform/config.ts`, `export const apiBaseUrl = \`\${window.location.origin}/api/v1\`;`);
498
590
  add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
499
591
  add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
500
592
  import { ref } from "vue";
@@ -536,23 +628,31 @@ async function checkReadiness() {
536
628
  <p v-if="error" role="alert">{{ error }}</p>
537
629
  </main>
538
630
  </template>`);
539
- add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
631
+ add(`${ui}/src/app/App.vue`, `<template>
632
+ <RouterView />
633
+ <NotificationHost />
634
+ </template>
635
+
636
+ <script setup lang="ts">
637
+ import { RouterView } from "vue-router";
638
+ import NotificationHost from "@/app/components/NotificationHost.vue";
639
+ </script>`);
540
640
  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`);
541
641
  add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
542
642
  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; }`);
543
- 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"] } }).exists()).toBe(true); }); });`);
643
+ 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); }); });`);
544
644
  add(`${ui}/AGENTS.md`, `# ${title} UI guidance
545
645
 
546
646
  ## Scope
547
647
  These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
548
648
 
549
649
  ## Architecture vocabulary
550
- - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/theme catalog.
650
+ - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/notification/theme catalog.
551
651
  - Use \`src/core\` only for small technology-neutral client contracts.
552
652
  - Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
553
653
  - Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
554
- - These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it. \`src/app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`src/app/public\` is closed to generated \`ThemeToggle.vue\`. Do not edit their implementations; only the generated theme storage key varies. Internal shell components remain private under \`src/app/components\`.
555
- - Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module.
654
+ - These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it. \`src/app/stores\` is closed to generated \`auth.store.ts\`, \`notification.store.ts\`, and \`theme.store.ts\`; \`src/app/public\` is closed to generated \`ThemeToggle.vue\`. Do not edit their implementations; only the generated theme storage key varies. Internal shell components remain private under \`src/app/components\`.
655
+ - Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module.
556
656
 
557
657
  ## Change placement
558
658
  | Change | Canonical owner |
@@ -560,14 +660,14 @@ These conventions apply to \`${ui}/src\`. Inspect the nearest working module bef
560
660
  | Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
561
661
  | Capability intentionally consumed by another module | Named file directly under provider \`src/modules/<module>/public\` |
562
662
  | Axios, Firebase, browser, or vendor mechanism | \`src/platform\` |
563
- | Firebase identity/readiness or theme preference | Generated \`src/app/stores/auth.store.ts\` or \`theme.store.ts\` |
663
+ | Firebase identity/readiness, notification queue, or theme preference | Generated \`src/app/stores/auth.store.ts\`, \`notification.store.ts\`, or \`theme.store.ts\` |
564
664
  | Canonical theme control consumed by modules | Generated \`src/app/public/ThemeToggle.vue\` |
565
665
  | Any other state or reusable presentation | Owning \`src/modules/<module>\` |
566
666
  | Shell, router, global styles, or cross-capability composition | \`src/app\` |
567
667
  | Technology-neutral client contract | Flat \`src/core\` |
568
668
  | Root executable | Bootstrap of \`src/app\` only |
569
669
 
570
- UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
670
+ UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`notification.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
571
671
 
572
672
  ## Module shape
573
673
  Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`public/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Public entries are named direct files, never nested directories or barrels. Do not invent alternative layer names or generate speculative CRUD. Create module and public shell files only with \`tailframe generate\`.
@@ -582,7 +682,7 @@ Use named routes.${options.auth === "firebase" ? " Preserve Firebase authenticat
582
682
 
583
683
  ## State management
584
684
  Keep local state local. Use Pinia only for state shared across routes or unrelated components.
585
- Keep all product state in the owning module. The auth/theme stores and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
685
+ Keep all product state in the owning module. The auth/notification/theme stores, NotificationHost, and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
586
686
 
587
687
  ## UI behavior
588
688
  Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
@@ -610,6 +710,11 @@ RUN npm ci
610
710
  COPY ${svc}/ ./
611
711
  RUN npm run build
612
712
 
713
+ FROM node:22.13-bookworm AS service-production-dependencies
714
+ WORKDIR /app/service
715
+ COPY ${svc}/package*.json ./
716
+ RUN npm ci --omit=dev && npm cache clean --force
717
+
613
718
  FROM node:22.13-bookworm AS ui-build
614
719
  WORKDIR /app/ui
615
720
  COPY ${ui}/package*.json ./
@@ -622,8 +727,9 @@ WORKDIR /app
622
727
  ENV NODE_ENV=production
623
728
  COPY --from=service-build /app/service/dist ./dist
624
729
  COPY --from=service-build /app/service/package*.json ./
625
- COPY --from=service-build /app/service/node_modules ./node_modules
730
+ COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
626
731
  COPY --from=ui-build /app/ui/dist ./dist/public
732
+ USER node
627
733
  EXPOSE 3000
628
734
  CMD ["node", "dist/server.js"]
629
735
  ` : `FROM node:22.13-bookworm AS build
@@ -633,12 +739,18 @@ RUN npm ci
633
739
  COPY . .
634
740
  RUN npm run build
635
741
 
742
+ FROM node:22.13-bookworm AS production-dependencies
743
+ WORKDIR /app
744
+ COPY package*.json ./
745
+ RUN npm ci --omit=dev && npm cache clean --force
746
+
636
747
  FROM node:22.13-bookworm-slim
637
748
  WORKDIR /app
638
749
  ENV NODE_ENV=production
639
750
  COPY --from=build /app/dist ./dist
640
751
  COPY --from=build /app/package*.json ./
641
- COPY --from=build /app/node_modules ./node_modules
752
+ COPY --from=production-dependencies /app/node_modules ./node_modules
753
+ USER node
642
754
  EXPOSE 3000
643
755
  CMD ["node", "dist/server.js"]
644
756
  `);
@@ -678,75 +790,443 @@ docker push "\${IMAGE}"
678
790
  echo "Pushed \${IMAGE}"
679
791
  `);
680
792
 
793
+ const workflowUiCheckout = options.ui ? `
794
+ - name: Checkout UI dependency
795
+ uses: actions/checkout@v6
796
+ with:
797
+ repository: \${{ github.repository_owner }}/${ui}
798
+ ref: main
799
+ ssh-key: \${{ secrets.UI_REPO_SSH_KEY }}
800
+ path: ${ui}
801
+ ` : "";
802
+ const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
803
+ - name: Write UI production environment
804
+ env:
805
+ UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
806
+ run: |
807
+ printf '%s\\n' "$UI_ENV_PRODUCTION" > ${ui}/.env.production
808
+ ` : "";
809
+ const workflowTagResolution = options.ui ? `
810
+ ui_sha="$(git -C ${ui} rev-parse --short HEAD)"
811
+ echo "tag=\${svc_sha}_\${ui_sha}" >> "$GITHUB_OUTPUT"` : `
812
+ echo "tag=\${svc_sha}" >> "$GITHUB_OUTPUT"`;
813
+ add(`${svc}/.github/workflows/deploy.yml`, `name: Build and deploy
814
+
815
+ on:
816
+ push:
817
+ branches:
818
+ - main
819
+ workflow_dispatch:
820
+
821
+ permissions:
822
+ contents: read
823
+ id-token: write
824
+
825
+ concurrency:
826
+ group: ${options.name}-production
827
+ cancel-in-progress: false
828
+
829
+ jobs:
830
+ deploy:
831
+ name: Deploy production
832
+ if: \${{ vars.DEPLOY_HOST != '' && vars.DEPLOY_USER != '' }}
833
+ runs-on: ubuntu-latest
834
+ environment: production
835
+ env:
836
+ AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
837
+ AWS_REGION: \${{ vars.AWS_REGION }}
838
+ ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
839
+ DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
840
+ DEPLOY_USER: \${{ vars.DEPLOY_USER }}
841
+
842
+ steps:
843
+ - name: Checkout service
844
+ uses: actions/checkout@v6
845
+ with:
846
+ path: ${svc}
847
+ ${workflowUiCheckout}${workflowUiEnvironment}
848
+ - name: Resolve immutable image
849
+ id: image
850
+ run: |
851
+ svc_sha="$(git -C ${svc} rev-parse --short HEAD)"${workflowTagResolution}
852
+ echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
853
+ echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
854
+
855
+ - name: Configure AWS credentials
856
+ uses: aws-actions/configure-aws-credentials@v6
857
+ with:
858
+ role-to-assume: \${{ vars.AWS_ROLE_ARN }}
859
+ aws-region: \${{ vars.AWS_REGION }}
860
+
861
+ - name: Build and push image
862
+ working-directory: ${svc}
863
+ env:
864
+ IMAGE_TAG: \${{ steps.image.outputs.tag }}
865
+ run: ./scripts/build_and_push.sh
866
+
867
+ - name: Configure deployment SSH
868
+ env:
869
+ DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
870
+ DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
871
+ run: |
872
+ install -m 700 -d ~/.ssh
873
+ printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
874
+ chmod 600 ~/.ssh/${options.name}-deploy
875
+ printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
876
+
877
+ - name: Authenticate server with ECR
878
+ run: |
879
+ aws ecr get-login-password --region "$AWS_REGION" |
880
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
881
+ "docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
882
+
883
+ - name: Install deployment files
884
+ run: |
885
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
886
+ "install -m 755 -d /opt/${options.name}/current/deploy/mongo"
887
+ scp -i ~/.ssh/${options.name}-deploy \\
888
+ ${svc}/docker-compose.yml \\
889
+ ${svc}/scripts/deploy_remote.sh \\
890
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
891
+ scp -i ~/.ssh/${options.name}-deploy \\
892
+ ${svc}/deploy/mongo/10-create-users.js \\
893
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
894
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
895
+ "chmod 755 /opt/${options.name}/current/deploy_remote.sh"
896
+
897
+ - name: Deploy image
898
+ run: |
899
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
900
+ "/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
901
+ `);
902
+
903
+ add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
904
+ set -euo pipefail
905
+
906
+ IMAGE_TAG="\${1:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
907
+ ECR_IMAGE="\${2:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
908
+ DEPLOY_DIR="\${DEPLOY_DIR:-/opt/${options.name}/current}"
909
+ SHARED_DIR="\${SHARED_DIR:-/opt/${options.name}/shared}"
910
+ COMPOSE_FILE="\${DEPLOY_DIR}/docker-compose.yml"
911
+ INFRA_ENV="\${SHARED_DIR}/.env.infrastructure"
912
+
913
+ if [[ ! -f "\${INFRA_ENV}" ]]; then
914
+ echo "Missing provisioned infrastructure environment: \${INFRA_ENV}" >&2
915
+ exit 1
916
+ fi
917
+
918
+ NEXT_ENV="$(mktemp "\${SHARED_DIR}/.env.infrastructure.XXXXXX")"
919
+ cleanup() {
920
+ rm -f "\${NEXT_ENV}"
921
+ }
922
+ trap cleanup EXIT
923
+
924
+ grep -Ev '^(ECR_IMAGE|IMAGE_TAG)=' "\${INFRA_ENV}" > "\${NEXT_ENV}" || true
925
+ printf 'ECR_IMAGE=%s\\nIMAGE_TAG=%s\\n' "\${ECR_IMAGE}" "\${IMAGE_TAG}" >> "\${NEXT_ENV}"
926
+ chmod 600 "\${NEXT_ENV}"
927
+ mv "\${NEXT_ENV}" "\${INFRA_ENV}"
928
+ trap - EXIT
929
+
930
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" config --quiet
931
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
932
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
933
+ `);
934
+
935
+ if (postgres) add(`${svc}/deploy/postgres/10-create-application-user.sh`, `#!/usr/bin/env bash
936
+ set -euo pipefail
937
+
938
+ psql --set ON_ERROR_STOP=1 --username postgres --dbname "${databaseName}" --set app_password="\${POSTGRES_APP_PASSWORD}" <<'SQL'
939
+ SELECT 'CREATE ROLE "${options.name}-app" LOGIN PASSWORD ' || quote_literal(:'app_password')
940
+ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.name}-app')\gexec
941
+ GRANT CONNECT ON DATABASE "${databaseName}" TO "${options.name}-app";
942
+ GRANT USAGE ON SCHEMA public TO "${options.name}-app";
943
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${options.name}-app";
944
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO "${options.name}-app";
945
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "${options.name}-app";
946
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO "${options.name}-app";
947
+ SQL
948
+ `);
949
+
950
+ add(`${svc}/docs/production-deployment.md`, `# Production deployment
951
+
952
+ ## Delivery model
953
+
954
+ GitHub Actions checks out ${svc}${options.ui ? ` and ${ui}` : ""}${options.ui ? " as sibling build inputs" : ""}, builds one
955
+ non-root production image, tags it ${options.ui ? "`<service-sha>_<ui-sha>`" : "`<service-sha>`"}, pushes it to ECR, and installs only deployment artifacts on the host.
956
+ Only service \`main\` pushes and manual dispatch trigger this workflow; UI pushes never deploy.
957
+
958
+ The host separates replaceable artifacts from persistent state:
959
+
960
+ \`\`\`text
961
+ /opt/${options.name}/
962
+ ├── current/
963
+ │ ├── docker-compose.yml
964
+ │ ├── deploy_remote.sh
965
+ │ └── deploy/mongo/10-create-users.js
966
+ └── shared/
967
+ ├── .env.production
968
+ ├── .env.infrastructure
969
+ ${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
970
+ ` : ""} ├── mongo/config/keyfile
971
+ ├── mongo/data/
972
+ ${options.redis ? ` └── redis/data/
973
+ ` : ""}\`\`\`
974
+
975
+ Actions may replace \`current/\` and must never replace \`shared/\`. The server never needs a source checkout.
976
+
977
+ ## Configuration ownership
978
+
979
+ - \`.env.production\` contains Node application behavior and provider credentials.
980
+ - \`.env.infrastructure\` contains Compose paths, image identity, and infrastructure passwords. Start from
981
+ \`.env.infrastructure.example\` and replace every placeholder.
982
+ - Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
983
+ credential paths.
984
+ ${options.ui && options.auth === "firebase" ? `- The UI repository's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
985
+ ` : ""}
986
+ Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
987
+
988
+ ## One-time host bootstrap
989
+
990
+ 1. Install Docker Engine and Docker Compose.
991
+ 2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
992
+ \`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.
993
+ 3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
994
+ 4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
995
+ mode \`0400\`.
996
+ ${options.auth === "firebase" ? `5. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
997
+ 6. ` : "5. "}Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
998
+ ${options.auth === "firebase" ? "7" : "6"}. Authorize the dedicated deployment SSH key and record the host key for GitHub Actions.
999
+
1000
+ Use a fresh Mongo data directory. Migrating an existing unauthenticated database requires a separately reviewed plan.
1001
+
1002
+ ## GitHub production environment
1003
+
1004
+ Configure these variables:
1005
+
1006
+ - \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` for the repository-restricted OIDC role.
1007
+ - \`DEPLOY_HOST\` and \`DEPLOY_USER\`; the deployment job remains skipped until both exist.
1008
+ - Optional \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
1009
+
1010
+ Configure these secrets:
1011
+
1012
+ ${options.ui ? `- \`UI_REPO_SSH_KEY\`: read-only deploy key for \`${ui}\`.
1013
+ ` : ""}${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
1014
+ ` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
1015
+ - \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
1016
+
1017
+ The ECR repository and AWS OIDC provider/role are provisioned separately. The workflow never stores long-lived AWS
1018
+ keys or creates cloud infrastructure.
1019
+
1020
+ ## Deploy and rollback
1021
+
1022
+ The remote script requires the provisioned infrastructure file, atomically replaces only \`ECR_IMAGE\` and
1023
+ \`IMAGE_TAG\`, validates Compose, pulls, and starts with health waiting. Roll back by selecting a previous immutable
1024
+ tag and running the same script. Never use \`docker compose down -v\` in production; image rollback does not restore
1025
+ database state.
1026
+
1027
+ MongoDB and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
1028
+ database/cache authentication, file permissions, and off-host backup/restore before treating the deployment as
1029
+ production-ready. Product-specific TLS, capacity limits, migrations, and scheduled-work grace periods must be added
1030
+ from measured application requirements rather than copied from another product.
1031
+ `);
1032
+
681
1033
  const serviceVolumes = [
682
- ...(options.auth === "firebase" ? [" - ./firebase-service-account.production.json:/app/firebase-service-account.production.json:ro"] : [])
1034
+ ...(options.auth === "firebase" ? [" - ${SECRETS_DIR:?Set SECRETS_DIR}/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro"] : [])
683
1035
  ];
684
1036
  const devVolumes = [
685
1037
  " - .:/app",
686
1038
  " - /app/node_modules",
687
1039
  ...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
688
1040
  ];
689
- const dependencies = [
690
- ["mongo-init", "service_completed_successfully"],
1041
+ const productionDependencies = [
1042
+ ["schema-init", "service_completed_successfully"],
1043
+ ...(options.redis ? [["redis", "service_healthy"]] : [])
1044
+ ];
1045
+ const developmentDependencies = [
1046
+ ["schema-init", "service_completed_successfully"],
691
1047
  ...(options.redis ? [["redis", "service_started"]] : [])
692
1048
  ];
693
- const dependsBlock = `\n depends_on:\n${dependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
694
- const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
1049
+ const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
1050
+ const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
1051
+ const productionEnvironment = [
1052
+ " NODE_ENV: production",
1053
+ ...(postgres
1054
+ ? [` POSTGRES_URI: "postgresql://${options.name}-app:\${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}@postgres:5432/${databaseName}"`]
1055
+ : [
1056
+ ` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
1057
+ ` MONGODB_DB_NAME: "${databaseName}"`
1058
+ ]),
1059
+ ...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
1060
+ ...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
1061
+ ];
1062
+ const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
1063
+ const databaseEnvironmentBlock = postgres
1064
+ ? `\n environment:\n POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
1065
+ : `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
695
1066
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
696
1067
  const workerService = options.worker ? `\n worker:
697
1068
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
698
1069
  env_file:
699
- - .env.production
700
- command: ["node", "dist/worker.js"]${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
1070
+ - \${APP_ENV_FILE:-.env.production}
1071
+ command: ["node", "dist/worker.js"]${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
1072
+ healthcheck:
1073
+ test: ["CMD", "node", "-e", "process.kill(1, 0)"]
1074
+ interval: 60s
1075
+ timeout: 5s
1076
+ retries: 3
1077
+ start_period: 30s
1078
+ stop_grace_period: 2m
1079
+ networks:
1080
+ - backend
701
1081
  restart: unless-stopped` : "";
702
- const databaseService = `\n mongodb:
1082
+ const databaseService = postgres ? `\n postgres:
1083
+ image: postgres:17
1084
+ environment:
1085
+ POSTGRES_DB: "${databaseName}"
1086
+ POSTGRES_USER: postgres
1087
+ POSTGRES_PASSWORD: \${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}
1088
+ POSTGRES_APP_PASSWORD: \${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}
1089
+ volumes:
1090
+ - \${POSTGRES_DATA_DIR:?Set POSTGRES_DATA_DIR}:/var/lib/postgresql/data
1091
+ - ./deploy/postgres/10-create-application-user.sh:/docker-entrypoint-initdb.d/10-create-application-user.sh:ro
1092
+ healthcheck:
1093
+ test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}"]
1094
+ interval: 5s
1095
+ timeout: 5s
1096
+ retries: 30
1097
+ start_period: 20s
1098
+ networks:
1099
+ - backend
1100
+ restart: unless-stopped` : `\n mongodb:
703
1101
  image: mongo:8
704
- command: mongod --replSet rs0 --bind_ip_all
1102
+ command:
1103
+ - mongod
1104
+ - --replSet
1105
+ - rs0
1106
+ - --bind_ip_all
1107
+ - --keyFile
1108
+ - /etc/mongo-keyfile/keyfile
1109
+ environment:
1110
+ MONGO_INITDB_DATABASE: "${databaseName}"
1111
+ MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1112
+ MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
1113
+ MONGODB_DB_NAME: "${databaseName}"
1114
+ MONGODB_APP_USERNAME: "${mongoApplicationUser}"
1115
+ MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
1116
+ MONGODB_BACKUP_USERNAME: "${mongoBackupUser}"
1117
+ MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
1118
+ MONGODB_APP_COLLECTIONS: "_tailframe_reserved"
705
1119
  volumes:
706
- - mongodb-data:/data/db
1120
+ - \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
1121
+ - \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
1122
+ - \${MONGO_INIT_SCRIPT:?Set MONGO_INIT_SCRIPT}:/docker-entrypoint-initdb.d/10-create-users.js:ro
707
1123
  healthcheck:
708
- test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
709
- interval: 2s
710
- timeout: 2s
1124
+ test:
1125
+ - CMD-SHELL
1126
+ - >-
1127
+ mongosh --quiet --host localhost
1128
+ --username "$\${MONGO_INITDB_ROOT_USERNAME}"
1129
+ --password "$\${MONGO_INITDB_ROOT_PASSWORD}"
1130
+ --authenticationDatabase admin
1131
+ --eval "quit(db.adminCommand({ping:1}).ok ? 0 : 1)"
1132
+ interval: 5s
1133
+ timeout: 5s
711
1134
  retries: 30
1135
+ start_period: 20s
1136
+ networks:
1137
+ - backend
712
1138
  restart: unless-stopped
713
1139
  mongo-init:
714
1140
  image: mongo:8
1141
+ environment:
1142
+ MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1143
+ MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
715
1144
  depends_on:
716
1145
  mongodb:
717
1146
  condition: service_healthy
718
1147
  restart: "no"
719
- entrypoint:
720
- - bash
721
- - -c
722
- - |
723
- mongosh --host mongodb --quiet --eval '
724
- try {
725
- rs.status().ok
726
- } catch (error) {
727
- rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })
1148
+ entrypoint: ["bash", "-ec"]
1149
+ command:
1150
+ - >-
1151
+ mongosh --quiet --host mongodb:27017
1152
+ --username "$\${MONGO_INITDB_ROOT_USERNAME}"
1153
+ --password "$\${MONGO_INITDB_ROOT_PASSWORD}"
1154
+ --authenticationDatabase admin
1155
+ --eval "try { const status = rs.status(); quit(status.ok === 1 ? 0 : 1); }
1156
+ catch (error) {
1157
+ if (error.code === 94 || error.codeName === 'NotYetInitialized') {
1158
+ const result = rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]});
1159
+ quit(result.ok === 1 ? 0 : 1);
728
1160
  }
729
- '`;
1161
+ throw error;
1162
+ }"
1163
+ networks:
1164
+ - backend`;
1165
+ const schemaInitDependencies = postgres ? [["postgres", "service_healthy"]] : [["mongo-init", "service_completed_successfully"]];
1166
+ const schemaInitDependsBlock = `\n depends_on:\n${schemaInitDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
1167
+ const schemaInitEnvironment = postgres
1168
+ ? `\n environment:\n POSTGRES_URI: "postgresql://postgres:\${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}@postgres:5432/${databaseName}"`
1169
+ : `\n environment:\n MONGODB_URI: "mongodb://${options.name}-root:\${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=admin"\n MONGODB_DB_NAME: "${databaseName}"`;
1170
+ const schemaInitService = `\n schema-init:
1171
+ image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
1172
+ command: ["node", "dist/app/cli/applySchema.js"]${schemaInitEnvironment}${schemaInitDependsBlock}
1173
+ networks:
1174
+ - backend
1175
+ restart: "no"`;
730
1176
  const redisService = options.redis ? `\n redis:
731
1177
  image: redis:7-alpine
1178
+ command:
1179
+ - redis-server
1180
+ - --appendonly
1181
+ - "yes"
1182
+ - --appendfsync
1183
+ - everysec
1184
+ - --maxmemory-policy
1185
+ - noeviction
1186
+ - --requirepass
1187
+ - \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
1188
+ environment:
1189
+ REDIS_PASSWORD: \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
732
1190
  volumes:
733
- - redis-data:/data
1191
+ - \${REDIS_DATA_DIR:?Set REDIS_DATA_DIR}:/data
1192
+ healthcheck:
1193
+ test:
1194
+ - CMD-SHELL
1195
+ - REDISCLI_AUTH="$\${REDIS_PASSWORD}" redis-cli ping | grep -q PONG
1196
+ interval: 10s
1197
+ timeout: 3s
1198
+ retries: 10
1199
+ start_period: 10s
1200
+ networks:
1201
+ - backend
734
1202
  restart: unless-stopped` : "";
735
- const namedVolumes = [
736
- " mongodb-data:",
737
- ...(options.redis ? [" redis-data:"] : [])
738
- ];
739
1203
  add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
740
1204
  services:
741
1205
  ${options.name}:
742
1206
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
743
1207
  env_file:
744
- - .env.production
1208
+ - \${APP_ENV_FILE:-.env.production}
745
1209
  ports:
746
- - "3000:3000"${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
747
- restart: unless-stopped${workerService}${databaseService}${redisService}
748
- volumes:
749
- ${namedVolumes.join("\n")}
1210
+ - "3000:3000"${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
1211
+ healthcheck:
1212
+ test:
1213
+ - CMD-SHELL
1214
+ - >-
1215
+ node -e
1216
+ "fetch('http://127.0.0.1:3000/api/v1/health.readiness',
1217
+ {method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:'{}'})
1218
+ .then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
1219
+ interval: 30s
1220
+ timeout: 10s
1221
+ retries: 3
1222
+ start_period: 30s
1223
+ stop_grace_period: 2m
1224
+ networks:
1225
+ - backend
1226
+ restart: unless-stopped${workerService}${schemaInitService}${databaseService}${redisService}
1227
+ networks:
1228
+ backend:
1229
+ driver: bridge
750
1230
  `);
751
1231
 
752
1232
  const devWorker = options.worker ? `\n worker:
@@ -757,8 +1237,22 @@ const devWorker = options.worker ? `\n worker:
757
1237
  - .env.development
758
1238
  command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
759
1239
  volumes:
760
- ${devVolumes.join("\n")}${dependsBlock}` : "";
761
- const devDatabase = `\n mongodb:
1240
+ ${devVolumes.join("\n")}${devDependsBlock}` : "";
1241
+ const devDatabase = postgres ? `\n postgres:
1242
+ image: postgres:17
1243
+ ports:
1244
+ - "5432:5432"
1245
+ environment:
1246
+ POSTGRES_DB: ${databaseName}
1247
+ POSTGRES_USER: ${options.name}-app
1248
+ POSTGRES_PASSWORD: development
1249
+ volumes:
1250
+ - postgres-dev-data:/var/lib/postgresql/data
1251
+ healthcheck:
1252
+ test: ["CMD-SHELL", "pg_isready -U ${options.name}-app -d ${databaseName}"]
1253
+ interval: 2s
1254
+ timeout: 2s
1255
+ retries: 30` : `\n mongodb:
762
1256
  image: mongo:8
763
1257
  command: mongod --replSet rs0 --bind_ip_all
764
1258
  ports:
@@ -794,11 +1288,23 @@ const devRedis = options.redis ? `\n redis:
794
1288
  volumes:
795
1289
  - redis-dev-data:/data` : "";
796
1290
  const devNamedVolumes = [
797
- " mongodb-dev-data:",
1291
+ postgres ? " postgres-dev-data:" : " mongodb-dev-data:",
798
1292
  ...(options.redis ? [" redis-dev-data:"] : [])
799
1293
  ];
800
1294
  add(`${svc}/docker-compose.dev.yml`, `name: ${options.name}-dev
801
1295
  services:
1296
+ schema-init:
1297
+ build:
1298
+ context: .
1299
+ dockerfile: Dockerfile.dev
1300
+ env_file:
1301
+ - .env.development
1302
+ command: ["npm", "run", "schema:apply:dev"]${databaseEnvironmentBlock}
1303
+ volumes:
1304
+ ${devVolumes.join("\n")}
1305
+ depends_on:
1306
+ ${postgres ? "postgres" : "mongo-init"}:
1307
+ condition: ${postgres ? "service_healthy" : "service_completed_successfully"}
802
1308
  ${options.name}:
803
1309
  build:
804
1310
  context: .
@@ -808,11 +1314,21 @@ services:
808
1314
  ports:
809
1315
  - "3000:3000"${databaseEnvironmentBlock}
810
1316
  volumes:
811
- ${devVolumes.join("\n")}${dependsBlock}${devWorker}${devDatabase}${devRedis}
1317
+ ${devVolumes.join("\n")}${devDependsBlock}${devWorker}${devDatabase}${devRedis}
812
1318
  volumes:
813
1319
  ${devNamedVolumes.join("\n")}
814
1320
  `);
815
1321
 
1322
+ for (const [relative, content] of Object.entries(ownedSources({ kind: "service", profiles: serviceProfiles }))) {
1323
+ add(`${svc}/${relative}`, content);
1324
+ }
1325
+ if (options.ui) {
1326
+ const uiProfiles = [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"];
1327
+ for (const [relative, content] of Object.entries(ownedSources({ kind: "ui", profiles: uiProfiles }))) {
1328
+ add(`${ui}/${relative}`, content);
1329
+ }
1330
+ }
1331
+
816
1332
  for (const [relative, content] of Object.entries(files)) {
817
1333
  const destination = path.join(root, relative);
818
1334
  fs.mkdirSync(path.dirname(destination), { recursive: true });
@@ -820,5 +1336,10 @@ for (const [relative, content] of Object.entries(files)) {
820
1336
  if (relative.endsWith(".sh")) fs.chmodSync(destination, 0o755);
821
1337
  }
822
1338
 
823
- return { root, name: options.name, database: "mongo", authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, repositories: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
1339
+ for (const repository of [svc, ...(options.ui ? [ui] : [])]) {
1340
+ const result = runSync(path.join(root, repository), "write", contractVersion);
1341
+ if (result.errors.length) fail(result.errors.join("\n"));
1342
+ }
1343
+
1344
+ return { root, name: options.name, database: options.db, authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, repositories: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
824
1345
  }