@zaaxch/tailframe 2.2.0 → 4.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.
@@ -0,0 +1,27 @@
1
+ export const OWNED_GUIDANCE_START = "<!-- tailframe:owned:start -->";
2
+ export const OWNED_GUIDANCE_END = "<!-- tailframe:owned:end -->";
3
+
4
+ export function productGuidance(config) {
5
+ const apps = config.apps.map((app) => `\`${app.kind}\` at \`${app.path}\` (${app.profiles.join(", ") || "no optional profiles"})`).join("; ");
6
+ return `${OWNED_GUIDANCE_START}
7
+ ## Tailframe-owned product contract
8
+
9
+ - Contract: \`${config.contractVersion}\`; kind: \`product\`.
10
+ - Applications: ${apps}.
11
+ - Run \`pnpm sync:architecture\` and \`pnpm validate:architecture\` from the product root after architectural changes.
12
+ - The product root is the only Git, package-manager, lockfile, Tailframe-manifest, and agent-context boundary.
13
+ - Files written by \`tailframe sync --write\` are generated sources. Change their canonical templates in Tailframe, not in this product.
14
+ ${OWNED_GUIDANCE_END}`;
15
+ }
16
+
17
+ export function appGuidance(productConfig, app) {
18
+ const profileList = app.profiles.length ? app.profiles.join(", ") : "none";
19
+ const client = app.kind !== "service";
20
+ return `${OWNED_GUIDANCE_START}
21
+ ## Tailframe-owned application contract
22
+
23
+ - Product contract: \`${productConfig.contractVersion}\`; kind: \`${app.kind}\`; path: \`${app.path}\`; profiles: ${profileList}.
24
+ - Run product-level validation and sync from the repository root; use \`tailframe validate --app ${app.kind}\` for a focused architecture check.
25
+ - Keep product capabilities under \`src/modules/<module>\`; keep application assembly, technology-neutral contracts, and provider adapters in their canonical app/core/platform roots.
26
+ ${client ? "- Notifications use the Tailframe-owned application-shell queue and host. Product copy remains in the owning capability module.\n" : "- Every use case exposes \`execute(context, input)\`. Entry points construct a request or system context and pass an explicit input object.\n- Resident processes perform no DDL. \`schema:apply\` is the only schema lifecycle entry point.\n- RPC operations use \`<owning-module>.<operation>\` with no compatibility aliases.\n"}${OWNED_GUIDANCE_END}`;
27
+ }
@@ -0,0 +1,222 @@
1
+ import {
2
+ appErrorSource,
3
+ applySchemaCliSource,
4
+ createRequestContextSource,
5
+ csrfSource,
6
+ firebaseSource,
7
+ httpErrorsSource,
8
+ mongoUsersSource,
9
+ rateLimitSource,
10
+ readEnvSource,
11
+ requestContextSource,
12
+ rpcHandlerSource,
13
+ rpcSource,
14
+ schemaLifecycleSource,
15
+ useCaseSource
16
+ } from "./service-templates.mjs";
17
+ import {
18
+ notificationHostSource,
19
+ notificationStoreSource,
20
+ uiErrorMessagesSource,
21
+ uiErrorsSource,
22
+ uiHttpSource,
23
+ uiRpcSource
24
+ } from "./ui-templates.mjs";
25
+
26
+ const requestContextTestSource = `import type { Request } from "express";
27
+ import { verifyFirebaseToken } from "@/platform/auth/firebase";
28
+ import { createRequestContext } from "@/platform/http/createRequestContext";
29
+
30
+ jest.mock("@/platform/auth/firebase", () => ({ verifyFirebaseToken: jest.fn() }));
31
+
32
+ const verifyToken = verifyFirebaseToken as jest.MockedFunction<typeof verifyFirebaseToken>;
33
+ const request = (headers: Request["headers"] = {}) => ({ headers }) as Request;
34
+
35
+ beforeEach(() => verifyToken.mockReset());
36
+
37
+ it("creates and caches an anonymous context with a bounded request id", async () => {
38
+ const req = request({ "x-request-id": "request.safe:1" });
39
+ const first = await createRequestContext(req);
40
+ const second = await createRequestContext(req);
41
+ expect(first).toEqual({ requestId: "request.safe:1" });
42
+ expect(second).toBe(first);
43
+ expect(verifyToken).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("replaces malformed and oversized request ids", async () => {
47
+ for (const value of ["contains spaces", "x".repeat(129)]) {
48
+ const context = await createRequestContext(request({ "x-request-id": value }));
49
+ expect(context.requestId).not.toBe(value);
50
+ expect(context.requestId).toMatch(/^[a-f0-9-]{36}$/);
51
+ }
52
+ });
53
+
54
+ it("strictly parses one Bearer token", async () => {
55
+ for (const authorization of ["bearer token", "Bearer", "Bearer token", "Bearer token extra", "Basic token"]) {
56
+ await expect(createRequestContext(request({ authorization }))).rejects.toMatchObject({
57
+ code: "UNAUTHENTICATED",
58
+ kind: "unauthenticated"
59
+ });
60
+ }
61
+ expect(verifyToken).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("maps verified and unverified Firebase email claims and verifies only once", async () => {
65
+ verifyToken.mockResolvedValueOnce({ uid: "verified", email: "v@example.com", email_verified: true } as never);
66
+ const verifiedRequest = request({ authorization: "Bearer verified-token" });
67
+ const verified = await createRequestContext(verifiedRequest);
68
+ expect(verified.principal).toEqual({ uid: "verified", email: "v@example.com", emailVerified: true });
69
+ await createRequestContext(verifiedRequest);
70
+ expect(verifyToken).toHaveBeenCalledTimes(1);
71
+
72
+ verifyToken.mockResolvedValueOnce({ uid: "unverified", email: "u@example.com" } as never);
73
+ const unverified = await createRequestContext(request({ authorization: "Bearer unverified-token" }));
74
+ expect(unverified.principal).toEqual({ uid: "unverified", email: "u@example.com", emailVerified: false });
75
+ });
76
+ `;
77
+
78
+ const errorTranslationTestSource = `import { AppError, type FailureKind } from "@/core/errors";
79
+ import { errorHandler } from "@/platform/http/errors";
80
+
81
+ const statuses: Array<[FailureKind, number]> = [
82
+ ["invalid", 400],
83
+ ["unauthenticated", 401],
84
+ ["forbidden", 403],
85
+ ["not_found", 404],
86
+ ["conflict", 409],
87
+ ["unprocessable", 422],
88
+ ["rate_limited", 429],
89
+ ["unavailable", 503],
90
+ ["internal", 500]
91
+ ];
92
+
93
+ it.each(statuses)("translates %s to HTTP %i", (kind, status) => {
94
+ const json = jest.fn();
95
+ const response = { status: jest.fn(() => ({ json })) } as never;
96
+ errorHandler(new AppError("CODE", "message", kind), {} as never, response, jest.fn());
97
+ expect((response as { status: jest.Mock }).status).toHaveBeenCalledWith(status);
98
+ expect(json).toHaveBeenCalledWith({ error: { code: "CODE", message: "message" } });
99
+ });
100
+ `;
101
+
102
+ const rateLimitTestSource = `import type { Request } from "express";
103
+ import { RateLimiterRes } from "rate-limiter-flexible";
104
+ import { createRateLimiter } from "@/platform/http/rateLimit";
105
+
106
+ const mockConsume = jest.fn();
107
+ jest.mock("rate-limiter-flexible", () => {
108
+ class MockRateLimiterRes {}
109
+ return {
110
+ RateLimiterRes: MockRateLimiterRes,
111
+ RateLimiterRedis: jest.fn().mockImplementation(() => ({ consume: mockConsume }))
112
+ };
113
+ });
114
+
115
+ const policy = { keyPrefix: "test", points: 2, durationSeconds: 60, key: "identity-or-ip" as const };
116
+
117
+ beforeEach(() => mockConsume.mockReset());
118
+
119
+ it("uses the authenticated identity and allows a request within budget", async () => {
120
+ mockConsume.mockResolvedValue(undefined);
121
+ const request = {
122
+ headers: {},
123
+ ip: "127.0.0.1",
124
+ requestContext: { requestId: "test", principal: { uid: "user-1", emailVerified: false } }
125
+ } as Request;
126
+ const next = jest.fn();
127
+ await (createRateLimiter({} as never, policy) as never as Function)(request, {}, next);
128
+ expect(mockConsume).toHaveBeenCalledWith("user:user-1");
129
+ expect(next).toHaveBeenCalledWith();
130
+ });
131
+
132
+ it("translates exhausted and unavailable limiter failures", async () => {
133
+ const request = { headers: {}, ip: "127.0.0.1", requestContext: { requestId: "test" } } as Request;
134
+ const exhausted = jest.fn();
135
+ mockConsume.mockRejectedValueOnce(new RateLimiterRes());
136
+ await (createRateLimiter({} as never, policy) as never as Function)(request, {}, exhausted);
137
+ expect(exhausted.mock.calls[0]?.[0]).toMatchObject({ kind: "rate_limited", code: "RATE_LIMITED" });
138
+
139
+ const unavailable = jest.fn();
140
+ mockConsume.mockRejectedValueOnce(new Error("redis down"));
141
+ await (createRateLimiter({} as never, policy) as never as Function)(request, {}, unavailable);
142
+ expect(unavailable.mock.calls[0]?.[0]).toMatchObject({ kind: "unavailable", code: "RATE_LIMIT_UNAVAILABLE" });
143
+ });
144
+ `;
145
+
146
+ const notificationTestSource = `import { mount } from "@vue/test-utils";
147
+ import { createPinia, setActivePinia } from "pinia";
148
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
149
+ import { nextTick } from "vue";
150
+ import NotificationHost from "@/app/components/NotificationHost.vue";
151
+ import { useNotificationStore } from "@/app/stores/notification.store";
152
+
153
+ describe("notification shell", () => {
154
+ beforeEach(() => {
155
+ vi.useFakeTimers();
156
+ setActivePinia(createPinia());
157
+ });
158
+ afterEach(() => vi.useRealTimers());
159
+
160
+ it("orders, dismisses, times, and clears notifications", async () => {
161
+ const store = useNotificationStore();
162
+ const host = mount(NotificationHost);
163
+ const first = store.notify({ message: "first", kind: "warning", durationMs: 1000 });
164
+ const second = store.notify({ message: "second", kind: "success", durationMs: 1000 });
165
+ expect(store.active?.id).toBe(first);
166
+ await nextTick();
167
+ expect(host.text()).toContain("first");
168
+ vi.advanceTimersByTime(1000);
169
+ await nextTick();
170
+ expect(store.active?.id).toBe(second);
171
+ store.dismiss(second);
172
+ expect(store.active).toBeUndefined();
173
+ store.notify({ message: "third" });
174
+ store.clear();
175
+ expect(store.queue).toEqual([]);
176
+ host.unmount();
177
+ });
178
+ });
179
+ `;
180
+
181
+ export function ownedSources(config) {
182
+ if (!["service", "ui"].includes(config.kind)) throw new Error("Tailframe 4 owns only service and UI sources");
183
+ const profiles = new Set(config.profiles);
184
+ if (config.kind === "service") {
185
+ const files = {
186
+ "src/core/RequestContext.ts": requestContextSource,
187
+ "src/core/UseCase.ts": useCaseSource,
188
+ "src/core/errors.ts": appErrorSource,
189
+ "src/core/SchemaLifecycle.ts": schemaLifecycleSource,
190
+ "src/platform/config/readEnv.ts": readEnvSource,
191
+ "src/platform/http/createRequestContext.ts": createRequestContextSource(profiles.has("firebase") ? "firebase" : "none"),
192
+ "src/platform/http/csrf.ts": csrfSource,
193
+ "src/platform/http/errors.ts": httpErrorsSource,
194
+ "src/platform/http/rpc.ts": rpcSource,
195
+ "src/platform/http/rpcHandler.ts": rpcHandlerSource,
196
+ "src/app/cli/applySchema.ts": applySchemaCliSource,
197
+ "src/platform/http/__tests__/errors.test.ts": errorTranslationTestSource
198
+ };
199
+ if (profiles.has("firebase")) {
200
+ files["src/platform/auth/firebase.ts"] = firebaseSource;
201
+ files["src/platform/http/__tests__/createRequestContext.test.ts"] = requestContextTestSource;
202
+ }
203
+ if (profiles.has("rate-limit")) {
204
+ files["src/platform/http/rateLimit.ts"] = rateLimitSource;
205
+ files["src/platform/http/__tests__/rateLimit.test.ts"] = rateLimitTestSource;
206
+ }
207
+ if (profiles.has("mongo")) files["deploy/mongo/10-create-users.js"] = mongoUsersSource;
208
+ return files;
209
+ }
210
+ const files = {
211
+ "src/core/errors.ts": uiErrorsSource,
212
+ "src/core/rpc.ts": uiRpcSource,
213
+ "src/platform/errors.ts": uiErrorMessagesSource,
214
+ "src/platform/http.ts": uiHttpSource
215
+ };
216
+ if (profiles.has("notifications")) {
217
+ files["src/app/stores/notification.store.ts"] = notificationStoreSource;
218
+ files["src/app/components/NotificationHost.vue"] = notificationHostSource;
219
+ files["src/app/__tests__/notificationStore.test.ts"] = notificationTestSource;
220
+ }
221
+ return files;
222
+ }
@@ -16,6 +16,7 @@ export function serviceOperationName(moduleName, verbNoun) {
16
16
  export const requestContextSource = `export interface AuthenticatedPrincipal {
17
17
  uid: string;
18
18
  email?: string;
19
+ emailVerified: boolean;
19
20
  }
20
21
 
21
22
  export interface RequestContext {
@@ -64,14 +65,40 @@ import type { RequestContext } from "@/core/RequestContext";
64
65
  import { AppError } from "@/core/errors";
65
66
  import { verifyFirebaseToken } from "@/platform/auth/firebase";
66
67
 
68
+ declare global {
69
+ namespace Express {
70
+ interface Request {
71
+ requestContext?: RequestContext;
72
+ }
73
+ }
74
+ }
75
+
76
+ function validRequestId(value: string | undefined): value is string {
77
+ return Boolean(value && value.length <= 128 && /^[a-zA-Z0-9._:-]+$/.test(value));
78
+ }
79
+
67
80
  export async function createRequestContext(req: Request): Promise<RequestContext> {
68
- const requestId = String(req.headers["x-request-id"] ?? randomUUID());
81
+ if (req.requestContext) return req.requestContext;
82
+ const headerRequestId = typeof req.headers["x-request-id"] === "string" ? req.headers["x-request-id"] : undefined;
83
+ const requestId = validRequestId(headerRequestId) ? headerRequestId : randomUUID();
69
84
  const header = req.headers.authorization;
70
- if (!header?.startsWith("Bearer ")) return { requestId };
85
+ if (!header) {
86
+ const context = { requestId };
87
+ req.requestContext = context;
88
+ return context;
89
+ }
90
+ if (!/^Bearer [^\\s]+$/.test(header)) {
91
+ throw new AppError("UNAUTHENTICATED", "Unauthorized", "unauthenticated");
92
+ }
71
93
 
72
94
  try {
73
95
  const decoded = await verifyFirebaseToken(header.slice(7));
74
- return { requestId, principal: { uid: decoded.uid, email: decoded.email } };
96
+ const context = {
97
+ requestId,
98
+ principal: { uid: decoded.uid, email: decoded.email, emailVerified: decoded.email_verified === true }
99
+ };
100
+ req.requestContext = context;
101
+ return context;
75
102
  } catch {
76
103
  throw new AppError("UNAUTHENTICATED", "Unauthorized", "unauthenticated");
77
104
  }
@@ -86,8 +113,24 @@ export function systemRequestContext(requestId = "system"): RequestContext {
86
113
  import type { Request } from "express";
87
114
  import type { RequestContext } from "@/core/RequestContext";
88
115
 
116
+ declare global {
117
+ namespace Express {
118
+ interface Request {
119
+ requestContext?: RequestContext;
120
+ }
121
+ }
122
+ }
123
+
124
+ function validRequestId(value: string | undefined): value is string {
125
+ return Boolean(value && value.length <= 128 && /^[a-zA-Z0-9._:-]+$/.test(value));
126
+ }
127
+
89
128
  export async function createRequestContext(req: Request): Promise<RequestContext> {
90
- return { requestId: String(req.headers["x-request-id"] ?? randomUUID()) };
129
+ if (req.requestContext) return req.requestContext;
130
+ const headerRequestId = typeof req.headers["x-request-id"] === "string" ? req.headers["x-request-id"] : undefined;
131
+ const context = { requestId: validRequestId(headerRequestId) ? headerRequestId : randomUUID() };
132
+ req.requestContext = context;
133
+ return context;
91
134
  }
92
135
 
93
136
  export function systemRequestContext(requestId = "system"): RequestContext {
@@ -96,13 +139,16 @@ export function systemRequestContext(requestId = "system"): RequestContext {
96
139
  `;
97
140
  }
98
141
 
99
- export const firebaseSource = `import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
142
+ export const firebaseSource = `import { applicationDefault, cert, getApps, initializeApp } from "firebase-admin/app";
100
143
  import { getAuth, type DecodedIdToken } from "firebase-admin/auth";
101
144
  import { env } from "@/platform/config/env";
102
145
 
103
146
  export function verifyFirebaseToken(token: string): Promise<DecodedIdToken> {
104
147
  if (!getApps().length) {
105
- initializeApp({ credential: applicationDefault(), projectId: env.firebaseProjectId || undefined });
148
+ initializeApp({
149
+ credential: env.firebaseServiceAccountPath ? cert(env.firebaseServiceAccountPath) : applicationDefault(),
150
+ projectId: env.firebaseProjectId || undefined
151
+ });
106
152
  }
107
153
  return getAuth().verifyIdToken(token);
108
154
  }
@@ -127,6 +173,7 @@ interface RpcOperation<Input, Output> {
127
173
 
128
174
  export interface RpcHandlerOptions<Input, WireInput> {
129
175
  mapInput?: (input: WireInput) => Input;
176
+ fromRequest?: (req: Request) => Input;
130
177
  status?: number;
131
178
  }
132
179
 
@@ -139,7 +186,11 @@ export function rpcHandler<Input, Output, WireInput = Input>(
139
186
  return async (req: Request, res: Response, next: NextFunction) => {
140
187
  try {
141
188
  const validated = await schema.validateAsync(req.body ?? {}, { abortEarly: false });
142
- const input = options.mapInput ? options.mapInput(validated) : (validated as unknown as Input);
189
+ const input = options.fromRequest
190
+ ? options.fromRequest(req)
191
+ : options.mapInput
192
+ ? options.mapInput(validated)
193
+ : (validated as unknown as Input);
143
194
  if (options.status !== undefined) res.status(options.status);
144
195
  rpcResult(res, await operation.execute(await createRequestContext(req), input));
145
196
  } catch (error) {
@@ -196,13 +247,152 @@ import { AppError } from "@/core/errors";
196
247
 
197
248
  const MUTATING = ["POST", "PUT", "PATCH", "DELETE"];
198
249
 
199
- export const requireCsrfHeader: RequestHandler = (req, _res, next) => {
200
- if (MUTATING.includes(req.method) && req.headers["x-requested-with"] !== "XMLHttpRequest") {
201
- next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
202
- return;
250
+ export function csrfHeaderGuard(exemptPaths: readonly string[] = []): RequestHandler {
251
+ const exempt = new Set(exemptPaths);
252
+ return (req, _res, next) => {
253
+ if (
254
+ MUTATING.includes(req.method) &&
255
+ !exempt.has(req.path) &&
256
+ req.headers["x-requested-with"] !== "XMLHttpRequest"
257
+ ) {
258
+ next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
259
+ return;
260
+ }
261
+ next();
262
+ };
263
+ }
264
+ `;
265
+
266
+ export const schemaLifecycleSource = `export interface SchemaLifecycle {
267
+ apply(): Promise<void>;
268
+ close(): Promise<void>;
269
+ }
270
+ `;
271
+
272
+ export const applySchemaCliSource = `import "reflect-metadata";
273
+ import { schemaLifecycle } from "@/app/schema";
274
+
275
+ async function main() {
276
+ try {
277
+ await schemaLifecycle.apply();
278
+ } finally {
279
+ await schemaLifecycle.close();
203
280
  }
204
- next();
281
+ }
282
+
283
+ void main().catch((error) => {
284
+ console.error(error instanceof Error ? error.message : error);
285
+ process.exitCode = 1;
286
+ });
287
+ `;
288
+
289
+ export const readEnvSource = `export function requiredEnv(name: string): string {
290
+ const value = process.env[name];
291
+ if (!value) throw new Error(\`Missing required environment variable \${name}\`);
292
+ return value;
293
+ }
294
+
295
+ export function integerEnv(name: string, fallback: number): number {
296
+ const value = process.env[name];
297
+ if (value === undefined || value === "") return fallback;
298
+ const parsed = Number(value);
299
+ if (!Number.isInteger(parsed)) throw new Error(\`Environment variable \${name} must be an integer\`);
300
+ return parsed;
301
+ }
302
+
303
+ export function listEnv(name: string, fallback: readonly string[] = []): string[] {
304
+ const value = process.env[name];
305
+ return value
306
+ ? value
307
+ .split(",")
308
+ .map((item) => item.trim())
309
+ .filter(Boolean)
310
+ : [...fallback];
311
+ }
312
+ `;
313
+
314
+ export const rateLimitSource = `import type { NextFunction, Request, RequestHandler, Response } from "express";
315
+ import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
316
+ import type { RedisClientType } from "redis";
317
+ import { AppError } from "@/core/errors";
318
+ import { createRequestContext } from "@/platform/http/createRequestContext";
319
+
320
+ export interface RateLimitPolicy {
321
+ keyPrefix: string;
322
+ points: number;
323
+ durationSeconds: number;
324
+ key: "identity-or-ip" | "ip";
325
+ }
326
+
327
+ export function createRateLimiter(client: RedisClientType, policy: RateLimitPolicy): RequestHandler {
328
+ const limiter = new RateLimiterRedis({
329
+ useRedisPackage: true,
330
+ storeClient: client,
331
+ keyPrefix: policy.keyPrefix,
332
+ points: policy.points,
333
+ duration: policy.durationSeconds
334
+ });
335
+ return async (req: Request, _res: Response, next: NextFunction) => {
336
+ try {
337
+ const context = await createRequestContext(req);
338
+ const key =
339
+ policy.key === "identity-or-ip" && context.principal?.uid
340
+ ? \`user:\${context.principal.uid}\`
341
+ : \`ip:\${req.ip ?? "unknown"}\`;
342
+ await limiter.consume(key);
343
+ next();
344
+ } catch (error) {
345
+ if (error instanceof RateLimiterRes)
346
+ next(new AppError("RATE_LIMITED", "Too many requests", "rate_limited"));
347
+ else if (error instanceof AppError) next(error);
348
+ else next(new AppError("RATE_LIMIT_UNAVAILABLE", "Request protection unavailable", "unavailable"));
349
+ }
350
+ };
351
+ }
352
+ `;
353
+
354
+ export const mongoUsersSource = `const required = (name) => {
355
+ const value = process.env[name];
356
+ if (!value) throw new Error(\`\${name} is required\`);
357
+ return value;
205
358
  };
359
+
360
+ const databaseName = required("MONGODB_DB_NAME");
361
+ const applicationUsername = required("MONGODB_APP_USERNAME");
362
+ const applicationPassword = required("MONGODB_APP_PASSWORD");
363
+ const backupUsername = required("MONGODB_BACKUP_USERNAME");
364
+ const backupPassword = required("MONGODB_BACKUP_PASSWORD");
365
+ const applicationCollections = required("MONGODB_APP_COLLECTIONS")
366
+ .split(",")
367
+ .map((name) => name.trim())
368
+ .filter(Boolean);
369
+
370
+ if (applicationCollections.length === 0) throw new Error("MONGODB_APP_COLLECTIONS must name at least one collection");
371
+
372
+ const applicationDatabase = db.getSiblingDB(databaseName);
373
+ applicationDatabase.createRole({
374
+ role: "applicationDml",
375
+ privileges: [
376
+ ...applicationCollections.map((collection) => ({
377
+ resource: { db: databaseName, collection },
378
+ actions: ["find", "insert", "remove", "update", "changeStream", "listIndexes"]
379
+ })),
380
+ { resource: { db: databaseName, collection: "" }, actions: ["listCollections"] }
381
+ ],
382
+ roles: []
383
+ });
384
+
385
+ applicationDatabase.createUser({
386
+ user: applicationUsername,
387
+ pwd: applicationPassword,
388
+ roles: [{ role: "applicationDml", db: databaseName }]
389
+ });
390
+
391
+ db.getSiblingDB("admin").createUser({
392
+ user: backupUsername,
393
+ pwd: backupPassword,
394
+ roles: [{ role: "backup", db: "admin" }]
395
+ });
206
396
  `;
207
397
 
208
398
  export const getHealthSource = `import type { RequestContext } from "@/core/RequestContext";
@@ -280,6 +470,18 @@ export class MongoReadinessProbe implements ReadinessProbe {
280
470
  }
281
471
  `;
282
472
 
473
+ export const postgresReadinessProbeSource = `import type { Pool } from "pg";
474
+ import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
475
+
476
+ export class PostgresReadinessProbe implements ReadinessProbe {
477
+ constructor(private readonly pool: Pool) {}
478
+
479
+ async check(): Promise<void> {
480
+ await this.pool.query("SELECT 1");
481
+ }
482
+ }
483
+ `;
484
+
283
485
  export const redisReadinessProbeSource = `import type { RedisClientType } from "redis";
284
486
  import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
285
487
 
@@ -308,25 +510,26 @@ export async function closeRedis() {
308
510
  }
309
511
  `;
310
512
 
311
- export function containerSource({ redis }) {
513
+ export function containerSource({ database = "mongo", redis }) {
514
+ const postgres = database === "postgres";
312
515
  return `import { container, type InjectionToken } from "tsyringe";
313
- import type { Db } from "mongodb";
516
+ import type { ${postgres ? "Pool" : "Db"} } from "${postgres ? "pg" : "mongodb"}";
314
517
  ${redis ? 'import type { RedisClientType } from "redis";\n' : ""}import { GetHealth } from "@/modules/health/use-cases/GetHealth";
315
518
  import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
316
519
  import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
317
- import { MongoReadinessProbe } from "@/platform/integrations/mongodb/MongoReadinessProbe";
520
+ import { ${postgres ? "PostgresReadinessProbe" : "MongoReadinessProbe"} } from "@/platform/integrations/${postgres ? "postgres/PostgresReadinessProbe" : "mongodb/MongoReadinessProbe"}";
318
521
  ${redis ? 'import { RedisReadinessProbe } from "@/platform/integrations/redis/RedisReadinessProbe";\n' : ""}
319
522
  const register = <T>(token: InjectionToken<T>, value: T) => container.registerInstance(token, value);
320
523
 
321
524
  export interface ApplicationDependencies {
322
- db: Db;
525
+ db: ${postgres ? "Pool" : "Db"};
323
526
  ${redis ? "\tredis: RedisClientType;\n" : ""}}
324
527
 
325
528
  /** App-owned composition root. Domain and use-case classes remain dependency-injection-framework free. */
326
529
  export function registerDependencies(dependencies: ApplicationDependencies) {
327
530
  container.reset();
328
531
 
329
- const readinessProbes: ReadinessProbe[] = [new MongoReadinessProbe(dependencies.db)];
532
+ const readinessProbes: ReadinessProbe[] = [new ${postgres ? "PostgresReadinessProbe" : "MongoReadinessProbe"}(dependencies.db)];
330
533
  ${redis ? "\treadinessProbes.push(new RedisReadinessProbe(dependencies.redis));\n" : ""}
331
534
  register(GetHealth, new GetHealth());
332
535
  register(GetReadiness, new GetReadiness(readinessProbes));
@@ -363,7 +566,7 @@ import { registerDependencies } from "@/app/container";
363
566
  import { applicationRoutes } from "@/app/routes";
364
567
  import { env } from "@/platform/config/env";
365
568
  import { closeDatabase, connectDatabase } from "@/platform/database";
366
- ${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { requireCsrfHeader } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
569
+ ${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { csrfHeaderGuard } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
367
570
 
368
571
  export interface ApplicationOptions {
369
572
  ${ui ? "\tpublicPath?: string;\n\tproduction?: boolean;\n" : ""}}
@@ -373,7 +576,7 @@ export function createApplication(options: ApplicationOptions = {}) {
373
576
  ${ui ? '\tconst publicPath = options.publicPath ?? path.join(__dirname, "..", "public");\n\tconst production = options.production ?? env.nodeEnv === "production";\n' : ""} app.set("trust proxy", 1);
374
577
  ${ui ? "\tif (production) {\n\t\tapp.use(express.static(publicPath, { index: false, maxAge: \"1y\", immutable: true }));\n\t}\n" : ""} app.use(cors({ origin: env.corsOrigin }));
375
578
  app.use(express.json());
376
- app.use("/api/v1", ${csrf ? "requireCsrfHeader, " : ""}applicationRoutes());
579
+ app.use("/api/v1", ${csrf ? "csrfHeaderGuard(), " : ""}applicationRoutes());
377
580
  ${ui ? '\tif (production) {\n\t\tapp.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) => res.sendFile(path.join(publicPath, "index.html")));\n\t}\n' : ""} app.use(errorHandler);
378
581
  return app;
379
582
  }
package/src/sync.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { appRoot, loadConfig } from "./config.mjs";
4
+ import { appGuidance, OWNED_GUIDANCE_END, OWNED_GUIDANCE_START, productGuidance } from "./owned-guidance.mjs";
5
+ import { ownedSources } from "./owned-sources.mjs";
6
+
7
+ function syncGuidance(productRoot, root, guidance, mode, changed, errors, label) {
8
+ const guidanceFile = path.join(root, "AGENTS.md");
9
+ const existingGuidance = fs.existsSync(guidanceFile) ? fs.readFileSync(guidanceFile, "utf8") : "";
10
+ const start = existingGuidance.indexOf(OWNED_GUIDANCE_START);
11
+ const end = existingGuidance.indexOf(OWNED_GUIDANCE_END);
12
+ const validSentinels = start >= 0 && end > start;
13
+ const currentSection = validSentinels
14
+ ? existingGuidance.slice(start, end + OWNED_GUIDANCE_END.length)
15
+ : undefined;
16
+ if (currentSection === guidance) return;
17
+ if (mode === "check") {
18
+ errors.push(`${label} AGENTS.md Tailframe-owned section differs from the canonical guidance`);
19
+ return;
20
+ }
21
+ let appendix = validSentinels
22
+ ? `${existingGuidance.slice(0, start)}${existingGuidance.slice(end + OWNED_GUIDANCE_END.length)}`.trim()
23
+ : existingGuidance.trim();
24
+ appendix = appendix.replace(/^## Product-specific appendix\s*/u, "");
25
+ const next = appendix
26
+ ? `${guidance}\n\n## Product-specific appendix\n\n${appendix}\n`
27
+ : `${guidance}\n`;
28
+ fs.mkdirSync(root, { recursive: true });
29
+ fs.writeFileSync(guidanceFile, next);
30
+ changed.push(path.relative(productRoot, guidanceFile));
31
+ }
32
+
33
+ export function runSync(rootArgument, mode, runningVersion, selectedKind) {
34
+ const loaded = loadConfig(rootArgument);
35
+ if (loaded.errors.length) return { changed: [], errors: loaded.errors };
36
+ if (loaded.config.contractVersion !== runningVersion) {
37
+ return { changed: [], errors: [`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`] };
38
+ }
39
+ const changed = [];
40
+ const errors = [];
41
+ syncGuidance(loaded.root, loaded.root, productGuidance(loaded.config), mode, changed, errors, "root");
42
+
43
+ for (const app of loaded.config.apps.filter((candidate) => !selectedKind || candidate.kind === selectedKind)) {
44
+ const root = appRoot(loaded.root, app);
45
+ if (!fs.existsSync(root)) {
46
+ errors.push(`Missing configured application root: ${app.path}`);
47
+ continue;
48
+ }
49
+ syncGuidance(loaded.root, root, appGuidance(loaded.config, app), mode, changed, errors, app.path);
50
+ for (const [relative, expected] of Object.entries(ownedSources(app))) {
51
+ const absolute = path.join(root, relative);
52
+ const productRelative = path.join(app.path, relative);
53
+ const actual = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : undefined;
54
+ if (actual === expected) continue;
55
+ if (mode === "check") errors.push(`${productRelative} differs from the Tailframe ${runningVersion} canonical source`);
56
+ else {
57
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
58
+ fs.writeFileSync(absolute, expected);
59
+ changed.push(productRelative);
60
+ }
61
+ }
62
+ }
63
+ return { changed, errors };
64
+ }