@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.
- package/bin/tailframe.mjs +43 -13
- package/package.json +1 -1
- package/src/architecture.mjs +13 -8
- package/src/config.mjs +93 -0
- package/src/conventions.mjs +17 -3
- package/src/exceptions.mjs +16 -6
- package/src/generate.mjs +16 -6
- package/src/new.mjs +324 -201
- package/src/owned-guidance.mjs +27 -0
- package/src/owned-sources.mjs +222 -0
- package/src/service-templates.mjs +222 -19
- package/src/sync.mjs +64 -0
- package/src/ui-templates.mjs +135 -1
- package/src/validate.mjs +90 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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 {
|
|
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 ? "
|
|
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
|
+
}
|