@zaaxch/tailframe 4.0.3 → 4.0.5
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/package.json +1 -1
- package/src/new.mjs +19 -6
- package/src/owned-sources.mjs +47 -1
- package/src/service-templates.mjs +135 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.5",
|
|
4
4
|
"description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/new.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
healthRoutesSource,
|
|
19
19
|
healthSchemasSource,
|
|
20
20
|
httpErrorsSource,
|
|
21
|
+
observabilitySource,
|
|
21
22
|
mongoReadinessProbeSource,
|
|
22
23
|
postgresReadinessProbeSource,
|
|
23
24
|
readinessProbeSource,
|
|
@@ -221,6 +222,7 @@ const svcDeps = {
|
|
|
221
222
|
joi: "^17.13.3",
|
|
222
223
|
"reflect-metadata": "^0.2.2",
|
|
223
224
|
tsyringe: "^4.9.1",
|
|
225
|
+
winston: "^3.19.0",
|
|
224
226
|
...(postgres ? { pg: "^8.16.3" } : { mongodb: "^6.17.0" }),
|
|
225
227
|
...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
|
|
226
228
|
...(options.redis ? { "rate-limiter-flexible": "^11.2.0", redis: "^5.10.0" } : {})
|
|
@@ -331,6 +333,7 @@ if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
|
|
|
331
333
|
add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
|
|
332
334
|
add(`${svc}/src/core/UseCase.ts`, useCaseSource);
|
|
333
335
|
add(`${svc}/src/core/errors.ts`, appErrorSource);
|
|
336
|
+
add(`${svc}/src/app/observability.ts`, observabilitySource);
|
|
334
337
|
add(`${svc}/src/platform/http/createRequestContext.ts`, createRequestContextSource(options.auth));
|
|
335
338
|
if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, firebaseSource);
|
|
336
339
|
add(`${svc}/src/platform/http/rpc.ts`, rpcSource);
|
|
@@ -480,7 +483,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
|
|
|
480
483
|
## Architecture vocabulary
|
|
481
484
|
- Use \`src/app\` for process assembly, server creation, route mounting, and dependency registration.
|
|
482
485
|
- Use \`src/core\` only for small technology-neutral contracts such as \`RequestContext\` and \`UseCase\`.
|
|
483
|
-
- Use \`src/platform\` for technical mechanisms such as Express, authentication, configuration, databases, Redis, and shared HTTP behavior.
|
|
486
|
+
- Use \`src/platform\` for technical mechanisms such as Express, authentication, configuration, databases, Redis, structured logging, and shared HTTP behavior.
|
|
484
487
|
- Use \`src/modules/<module>\` for product capabilities.
|
|
485
488
|
- These are the only service architecture roots. Keep \`src/core\` flat. Put external-system adapters under \`src/platform/integrations/<provider>\`; directories immediately under \`src/app\` are limited to \`cli\`, \`jobs\`, \`mcp\`, \`scheduled-tasks\`, \`workers\`, and \`__tests__\`.
|
|
486
489
|
- Inside a module, use \`use-cases/\`, \`http/\`, and tests first. Add \`domain/\` or \`persistence/\` only when behavior requires them.
|
|
@@ -496,6 +499,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
|
|
|
496
499
|
| Express route or request schema | Owning module's \`http\` |
|
|
497
500
|
| Vendor or external-system adapter | \`src/platform/integrations/<provider>\` |
|
|
498
501
|
| Database, Redis, authentication, or HTTP mechanism | \`src/platform\` |
|
|
502
|
+
| Product-specific Winston transport | \`src/app/observability.ts\` |
|
|
499
503
|
| Process lifecycle, registration, schedule, or consumer | \`src/app/<entry-point-kind>\` |
|
|
500
504
|
| Cross-entry-point technology-neutral contract | Flat \`src/core\` |
|
|
501
505
|
| Root executable | Bootstrap of matching \`src/app\` assembly only |
|
|
@@ -544,6 +548,7 @@ ${postgres
|
|
|
544
548
|
- 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.
|
|
545
549
|
- 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.
|
|
546
550
|
- Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
|
|
551
|
+
- The canonical Winston JSON console transport logs every HTTP failure with safe request metadata. Add product-specific transports only in \`src/app/observability.ts\`; do not remove, silence, or reconfigure the canonical transport. Preserve diagnostic causes on translated errors without exposing them in client envelopes.
|
|
547
552
|
- 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.
|
|
548
553
|
${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
|
|
549
554
|
|
|
@@ -551,7 +556,7 @@ ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a
|
|
|
551
556
|
The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<product-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<product-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.` : ""}
|
|
552
557
|
|
|
553
558
|
## Production deployment
|
|
554
|
-
Pull requests into product \`main\` validate release candidates. The resulting \`main\` push validates again before GitHub Actions may publish an immutable image tagged with the full product commit SHA. Image publication and production deployment are separate workflows with distinct AWS roles: the release role publishes, while the pull-only deployment role manually deploys from \`main\` using an already-published full SHA from \`main\` history and that commit's deployment files. Product data migrations must authenticate to the registry, pull and digest-verify that exact release image, and explicitly select the production datastore; never inherit rehearsal image or target defaults. 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 environments have been bootstrapped according to \`docs/production-deployment.md\`.
|
|
559
|
+
Pull requests into product \`main\` validate release candidates. The resulting \`main\` push validates again before GitHub Actions may publish an immutable image tagged with the full product commit SHA. Image publication and production deployment are separate workflows with distinct AWS roles: the release role publishes, while the pull-only deployment role manually deploys from \`main\` using an already-published full SHA from \`main\` history and that commit's deployment files. Build both role-trust subjects from GitHub's API-reported \`sub_claim_prefix\`; never infer the effective prefix from \`use_immutable_subject\`. Product data migrations must authenticate to the registry, pull and digest-verify that exact release image, and explicitly select the production datastore; never inherit rehearsal image or target defaults. 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 environments have been bootstrapped according to \`docs/production-deployment.md\`.
|
|
555
560
|
|
|
556
561
|
## Tests and validation
|
|
557
562
|
Run \`pnpm validate:architecture\` and \`pnpm format:check\` after changing files or imports. The generated service also includes unit tests and ${postgres ? "PostgreSQL-backed" : "Mongo-backed"} integration tests. Run focused service database tests through pnpm for the full service check. Test use cases, policies, ${postgres ? "PostgreSQL" : "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 ${postgres ? "PostgreSQL" : "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 the release image script, must run outside the sandbox from the first attempt.
|
|
@@ -1209,10 +1214,18 @@ Configure \`DEPLOY_HOST\` and \`DEPLOY_USER\` as production-environment variable
|
|
|
1209
1214
|
- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
|
|
1210
1215
|
- \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
|
|
1211
1216
|
|
|
1212
|
-
Restrict both environments' deployment branches to \`main\`.
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1217
|
+
Restrict both environments' deployment branches to \`main\`. Before creating or updating either AWS role trust, query
|
|
1218
|
+
GitHub's repository OIDC endpoint and use its effective prefix:
|
|
1219
|
+
|
|
1220
|
+
\`\`\`bash
|
|
1221
|
+
gh api /repos/OWNER/REPOSITORY/actions/oidc/customization/sub --jq .sub_claim_prefix
|
|
1222
|
+
\`\`\`
|
|
1223
|
+
|
|
1224
|
+
Treat the returned \`sub_claim_prefix\` as authoritative; do not infer it from \`use_immutable_subject\`, repository
|
|
1225
|
+
age, names, or separately queried IDs. The release role trust uses exactly
|
|
1226
|
+
\`<sub_claim_prefix>:environment:release\`; the pull-only deployment role uses exactly
|
|
1227
|
+
\`<sub_claim_prefix>:environment:production\`. Keep the audience equal to \`sts.amazonaws.com\` and do not use a
|
|
1228
|
+
repository-wide wildcard subject. Required reviewer approval MAY add a second manual gate.
|
|
1216
1229
|
|
|
1217
1230
|
## Deploy and rollback
|
|
1218
1231
|
|
package/src/owned-sources.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
csrfSource,
|
|
6
6
|
firebaseSource,
|
|
7
7
|
httpErrorsSource,
|
|
8
|
+
loggerSource,
|
|
8
9
|
mongoUsersSource,
|
|
9
10
|
rateLimitSource,
|
|
10
11
|
readEnvSource,
|
|
@@ -77,6 +78,11 @@ it("maps verified and unverified Firebase email claims and verifies only once",
|
|
|
77
78
|
|
|
78
79
|
const errorTranslationTestSource = `import { AppError, type FailureKind } from "@/core/errors";
|
|
79
80
|
import { errorHandler } from "@/platform/http/errors";
|
|
81
|
+
import { logger } from "@/platform/logging/logger";
|
|
82
|
+
|
|
83
|
+
jest.mock("@/platform/logging/logger", () => ({ logger: { log: jest.fn() }, serializeError: (error: unknown) => error }));
|
|
84
|
+
|
|
85
|
+
const log = logger.log as jest.MockedFunction<typeof logger.log>;
|
|
80
86
|
|
|
81
87
|
const statuses: Array<[FailureKind, number]> = [
|
|
82
88
|
["invalid", 400],
|
|
@@ -91,11 +97,50 @@ const statuses: Array<[FailureKind, number]> = [
|
|
|
91
97
|
];
|
|
92
98
|
|
|
93
99
|
it.each(statuses)("translates %s to HTTP %i", (kind, status) => {
|
|
100
|
+
log.mockClear();
|
|
94
101
|
const json = jest.fn();
|
|
95
102
|
const response = { status: jest.fn(() => ({ json })) } as never;
|
|
96
|
-
|
|
103
|
+
const request = { method: "POST", path: "/health.get", requestContext: { requestId: "request-1" } } as never;
|
|
104
|
+
errorHandler(new AppError({ code: "CODE", message: "message", kind }), request, response, jest.fn());
|
|
97
105
|
expect((response as { status: jest.Mock }).status).toHaveBeenCalledWith(status);
|
|
98
106
|
expect(json).toHaveBeenCalledWith({ error: { code: "CODE", message: "message" } });
|
|
107
|
+
expect(log).toHaveBeenCalledWith(expect.objectContaining({
|
|
108
|
+
level: status >= 500 ? "error" : "warn",
|
|
109
|
+
event: "http.request.failed",
|
|
110
|
+
requestId: "request-1",
|
|
111
|
+
code: "CODE",
|
|
112
|
+
kind
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("logs an unknown failure without exposing it to the client", () => {
|
|
117
|
+
log.mockClear();
|
|
118
|
+
const json = jest.fn();
|
|
119
|
+
const response = { status: jest.fn(() => ({ json })) } as never;
|
|
120
|
+
const error = new Error("database disconnected");
|
|
121
|
+
errorHandler(error, { method: "POST", path: "/health.get" } as never, response, jest.fn());
|
|
122
|
+
expect(json).toHaveBeenCalledWith({ error: { code: "INTERNAL", message: "Internal server error" } });
|
|
123
|
+
expect(log).toHaveBeenCalledWith(expect.objectContaining({
|
|
124
|
+
level: "error",
|
|
125
|
+
code: "INTERNAL",
|
|
126
|
+
error: expect.objectContaining({ message: "database disconnected" })
|
|
127
|
+
}));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("logs a preserved application-error cause", () => {
|
|
131
|
+
log.mockClear();
|
|
132
|
+
const json = jest.fn();
|
|
133
|
+
const response = { status: jest.fn(() => ({ json })) } as never;
|
|
134
|
+
const cause = new Error("Firebase token verification failed");
|
|
135
|
+
errorHandler(
|
|
136
|
+
AppError.unauthenticated(cause),
|
|
137
|
+
{ method: "POST", path: "/health.get" } as never,
|
|
138
|
+
response,
|
|
139
|
+
jest.fn()
|
|
140
|
+
);
|
|
141
|
+
expect(log).toHaveBeenCalledWith(expect.objectContaining({
|
|
142
|
+
error: expect.objectContaining({ cause: expect.objectContaining({ message: "Firebase token verification failed" }) })
|
|
143
|
+
}));
|
|
99
144
|
});
|
|
100
145
|
`;
|
|
101
146
|
|
|
@@ -188,6 +233,7 @@ export function ownedSources(config) {
|
|
|
188
233
|
"src/core/errors.ts": appErrorSource,
|
|
189
234
|
"src/core/SchemaLifecycle.ts": schemaLifecycleSource,
|
|
190
235
|
"src/platform/config/readEnv.ts": readEnvSource,
|
|
236
|
+
"src/platform/logging/logger.ts": loggerSource,
|
|
191
237
|
"src/platform/http/createRequestContext.ts": createRequestContextSource(profiles.has("firebase") ? "firebase" : "none"),
|
|
192
238
|
"src/platform/http/csrf.ts": csrfSource,
|
|
193
239
|
"src/platform/http/errors.ts": httpErrorsSource,
|
|
@@ -43,20 +43,61 @@ export const appErrorSource = `export type FailureKind =
|
|
|
43
43
|
| "unavailable"
|
|
44
44
|
| "internal";
|
|
45
45
|
|
|
46
|
+
export interface AppErrorOptions {
|
|
47
|
+
code: string;
|
|
48
|
+
message: string;
|
|
49
|
+
kind: FailureKind;
|
|
50
|
+
details?: unknown;
|
|
51
|
+
cause?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
export class AppError extends Error {
|
|
47
55
|
readonly name = "AppError";
|
|
56
|
+
readonly code: string;
|
|
57
|
+
readonly kind: FailureKind;
|
|
58
|
+
readonly details: unknown;
|
|
59
|
+
|
|
60
|
+
constructor({ code, message, kind, details, cause }: AppErrorOptions) {
|
|
61
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
62
|
+
this.code = code;
|
|
63
|
+
this.kind = kind;
|
|
64
|
+
this.details = details;
|
|
65
|
+
}
|
|
48
66
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
message: string,
|
|
52
|
-
readonly kind: FailureKind,
|
|
53
|
-
readonly details?: unknown
|
|
54
|
-
) {
|
|
55
|
-
super(message);
|
|
67
|
+
static unauthenticated(cause?: unknown): AppError {
|
|
68
|
+
return new AppError({ code: "UNAUTHENTICATED", message: "Unauthorized", kind: "unauthenticated", cause });
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
`;
|
|
59
72
|
|
|
73
|
+
export const loggerSource = `import { createLogger, format, transports } from "winston";
|
|
74
|
+
|
|
75
|
+
export function serializeError(error: unknown, depth = 0): unknown {
|
|
76
|
+
if (!(error instanceof Error)) return error;
|
|
77
|
+
return {
|
|
78
|
+
name: error.name,
|
|
79
|
+
message: error.message,
|
|
80
|
+
stack: error.stack,
|
|
81
|
+
...(error.cause === undefined || depth >= 3 ? {} : { cause: serializeError(error.cause, depth + 1) })
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const serializeErrorMetadata = format((info) => {
|
|
86
|
+
if (info.error !== undefined) info.error = serializeError(info.error);
|
|
87
|
+
return info;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The service-wide logger. Tailframe always retains the JSON console transport so
|
|
92
|
+
* canonical failure logging cannot be disabled by application assembly.
|
|
93
|
+
*/
|
|
94
|
+
export const logger = createLogger({
|
|
95
|
+
level: "info",
|
|
96
|
+
format: format.combine(format.timestamp(), format.errors({ stack: true }), serializeErrorMetadata(), format.json()),
|
|
97
|
+
transports: [new transports.Console()]
|
|
98
|
+
});
|
|
99
|
+
`;
|
|
100
|
+
|
|
60
101
|
export function createRequestContextSource(auth) {
|
|
61
102
|
if (auth === "firebase") {
|
|
62
103
|
return `import { randomUUID } from "node:crypto";
|
|
@@ -88,7 +129,7 @@ export async function createRequestContext(req: Request): Promise<RequestContext
|
|
|
88
129
|
return context;
|
|
89
130
|
}
|
|
90
131
|
if (!/^Bearer [^\\s]+$/.test(header)) {
|
|
91
|
-
throw
|
|
132
|
+
throw AppError.unauthenticated();
|
|
92
133
|
}
|
|
93
134
|
|
|
94
135
|
try {
|
|
@@ -99,8 +140,8 @@ export async function createRequestContext(req: Request): Promise<RequestContext
|
|
|
99
140
|
};
|
|
100
141
|
req.requestContext = context;
|
|
101
142
|
return context;
|
|
102
|
-
} catch {
|
|
103
|
-
throw
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw AppError.unauthenticated(error);
|
|
104
145
|
}
|
|
105
146
|
}
|
|
106
147
|
|
|
@@ -200,9 +241,10 @@ export function rpcHandler<Input, Output, WireInput = Input>(
|
|
|
200
241
|
}
|
|
201
242
|
`;
|
|
202
243
|
|
|
203
|
-
export const httpErrorsSource = `import type { ErrorRequestHandler } from "express";
|
|
244
|
+
export const httpErrorsSource = `import type { ErrorRequestHandler, Request } from "express";
|
|
204
245
|
import Joi from "joi";
|
|
205
246
|
import { AppError } from "@/core/errors";
|
|
247
|
+
import { logger, serializeError } from "@/platform/logging/logger";
|
|
206
248
|
|
|
207
249
|
const statusByKind: Readonly<Record<AppError["kind"], number>> = {
|
|
208
250
|
invalid: 400,
|
|
@@ -216,9 +258,26 @@ const statusByKind: Readonly<Record<AppError["kind"], number>> = {
|
|
|
216
258
|
internal: 500
|
|
217
259
|
};
|
|
218
260
|
|
|
219
|
-
|
|
261
|
+
function logFailure(req: Request, error: unknown, status: number, code: string, kind?: AppError["kind"]) {
|
|
262
|
+
logger.log({
|
|
263
|
+
level: status >= 500 ? "error" : "warn",
|
|
264
|
+
message: "HTTP request failed",
|
|
265
|
+
event: "http.request.failed",
|
|
266
|
+
requestId: req.requestContext?.requestId,
|
|
267
|
+
method: req.method,
|
|
268
|
+
path: req.path,
|
|
269
|
+
status,
|
|
270
|
+
code,
|
|
271
|
+
...(kind === undefined ? {} : { kind }),
|
|
272
|
+
error: serializeError(error)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
|
220
277
|
if (error instanceof AppError) {
|
|
221
|
-
|
|
278
|
+
const status = statusByKind[error.kind];
|
|
279
|
+
logFailure(req, error, status, error.code, error.kind);
|
|
280
|
+
res.status(status).json({
|
|
222
281
|
error: {
|
|
223
282
|
code: error.code,
|
|
224
283
|
message: error.message,
|
|
@@ -228,6 +287,7 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|
|
228
287
|
return;
|
|
229
288
|
}
|
|
230
289
|
if (Joi.isError(error)) {
|
|
290
|
+
logFailure(req, error, 400, "VALIDATION_FAILED", "invalid");
|
|
231
291
|
res.status(400).json({
|
|
232
292
|
error: {
|
|
233
293
|
code: "VALIDATION_FAILED",
|
|
@@ -237,11 +297,22 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|
|
237
297
|
});
|
|
238
298
|
return;
|
|
239
299
|
}
|
|
240
|
-
|
|
300
|
+
logFailure(req, error, 500, "INTERNAL", "internal");
|
|
241
301
|
res.status(500).json({ error: { code: "INTERNAL", message: "Internal server error" } });
|
|
242
302
|
};
|
|
243
303
|
`;
|
|
244
304
|
|
|
305
|
+
export const observabilitySource = `import type { Logger } from "winston";
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Add product-specific Winston transports here. Do not remove or reconfigure the
|
|
309
|
+
* Tailframe console transport: it is the required baseline for canonical errors.
|
|
310
|
+
*/
|
|
311
|
+
export function configureObservability(_logger: Pick<Logger, "add">): void {
|
|
312
|
+
// Add product-specific transports and leave the canonical console transport intact.
|
|
313
|
+
}
|
|
314
|
+
`;
|
|
315
|
+
|
|
245
316
|
export const csrfSource = `import type { RequestHandler } from "express";
|
|
246
317
|
import { AppError } from "@/core/errors";
|
|
247
318
|
|
|
@@ -255,7 +326,7 @@ export function csrfHeaderGuard(exemptPaths: readonly string[] = []): RequestHan
|
|
|
255
326
|
!exempt.has(req.path) &&
|
|
256
327
|
req.headers["x-requested-with"] !== "XMLHttpRequest"
|
|
257
328
|
) {
|
|
258
|
-
next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
|
|
329
|
+
next(new AppError({ code: "CSRF_HEADER_MISSING", message: "CSRF header missing", kind: "forbidden" }));
|
|
259
330
|
return;
|
|
260
331
|
}
|
|
261
332
|
next();
|
|
@@ -271,6 +342,7 @@ export const schemaLifecycleSource = `export interface SchemaLifecycle {
|
|
|
271
342
|
|
|
272
343
|
export const applySchemaCliSource = `import "reflect-metadata";
|
|
273
344
|
import { schemaLifecycle } from "@/app/schema";
|
|
345
|
+
import { logger } from "@/platform/logging/logger";
|
|
274
346
|
|
|
275
347
|
async function main() {
|
|
276
348
|
try {
|
|
@@ -281,7 +353,7 @@ async function main() {
|
|
|
281
353
|
}
|
|
282
354
|
|
|
283
355
|
void main().catch((error) => {
|
|
284
|
-
|
|
356
|
+
logger.error("Schema application failed", { event: "schema.apply.failed", error });
|
|
285
357
|
process.exitCode = 1;
|
|
286
358
|
});
|
|
287
359
|
`;
|
|
@@ -343,9 +415,9 @@ export function createRateLimiter(client: RedisClientType, policy: RateLimitPoli
|
|
|
343
415
|
next();
|
|
344
416
|
} catch (error) {
|
|
345
417
|
if (error instanceof RateLimiterRes)
|
|
346
|
-
next(new AppError("RATE_LIMITED", "Too many requests", "rate_limited"));
|
|
418
|
+
next(new AppError({ code: "RATE_LIMITED", message: "Too many requests", kind: "rate_limited" }));
|
|
347
419
|
else if (error instanceof AppError) next(error);
|
|
348
|
-
else next(new AppError("RATE_LIMIT_UNAVAILABLE", "Request protection unavailable", "unavailable"));
|
|
420
|
+
else next(new AppError({ code: "RATE_LIMIT_UNAVAILABLE", message: "Request protection unavailable", kind: "unavailable", cause: error }));
|
|
349
421
|
}
|
|
350
422
|
};
|
|
351
423
|
}
|
|
@@ -426,8 +498,8 @@ export class GetReadiness implements UseCase<Record<string, never>, ReadinessRes
|
|
|
426
498
|
try {
|
|
427
499
|
await Promise.all(this.probes.map((probe) => probe.check()));
|
|
428
500
|
return { status: "ready" };
|
|
429
|
-
} catch {
|
|
430
|
-
throw new AppError("NOT_READY", "Service is not ready", "unavailable");
|
|
501
|
+
} catch (error) {
|
|
502
|
+
throw new AppError({ code: "NOT_READY", message: "Service is not ready", kind: "unavailable", cause: error });
|
|
431
503
|
}
|
|
432
504
|
}
|
|
433
505
|
}
|
|
@@ -496,9 +568,10 @@ export class RedisReadinessProbe implements ReadinessProbe {
|
|
|
496
568
|
|
|
497
569
|
export const redisSource = `import { createClient, type RedisClientType } from "redis";
|
|
498
570
|
import { env } from "@/platform/config/env";
|
|
571
|
+
import { logger } from "@/platform/logging/logger";
|
|
499
572
|
|
|
500
573
|
export const redis: RedisClientType = createClient({ url: env.redisUrl });
|
|
501
|
-
redis.on("error", (error) =>
|
|
574
|
+
redis.on("error", (error) => logger.error("Redis client error", { event: "redis.client.error", error }));
|
|
502
575
|
|
|
503
576
|
export async function connectRedis() {
|
|
504
577
|
if (!redis.isOpen) await redis.connect();
|
|
@@ -563,10 +636,12 @@ export function serverSource({ ui, redis, csrf }) {
|
|
|
563
636
|
return `${ui ? 'import path from "node:path";\n' : ""}import express from "express";
|
|
564
637
|
import cors from "cors";
|
|
565
638
|
import { registerDependencies } from "@/app/container";
|
|
639
|
+
import { configureObservability } from "@/app/observability";
|
|
566
640
|
import { applicationRoutes } from "@/app/routes";
|
|
567
641
|
import { env } from "@/platform/config/env";
|
|
568
642
|
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
569
643
|
${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { csrfHeaderGuard } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
|
|
644
|
+
import { logger } from "@/platform/logging/logger";
|
|
570
645
|
|
|
571
646
|
export interface ApplicationOptions {
|
|
572
647
|
${ui ? "\tpublicPath?: string;\n\tproduction?: boolean;\n" : ""}}
|
|
@@ -582,43 +657,57 @@ ${ui ? '\tif (production) {\n\t\tapp.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) =
|
|
|
582
657
|
}
|
|
583
658
|
|
|
584
659
|
export async function startServer() {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
660
|
+
try {
|
|
661
|
+
configureObservability(logger);
|
|
662
|
+
const db = await connectDatabase();
|
|
663
|
+
${redis ? "\t\tconst redis = await connectRedis();\n" : ""} registerDependencies({ db${redis ? ", redis" : ""} });
|
|
664
|
+
const app = createApplication();
|
|
665
|
+
const server = app.listen(env.port);
|
|
666
|
+
const shutdown = async () => {
|
|
667
|
+
await new Promise<void>((resolve, reject) => {
|
|
668
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
669
|
+
});
|
|
670
|
+
${redis ? "\t\t\tawait closeRedis();\n" : ""} await closeDatabase();
|
|
671
|
+
};
|
|
672
|
+
process.once("SIGINT", () => void shutdown());
|
|
673
|
+
process.once("SIGTERM", () => void shutdown());
|
|
674
|
+
return { app, server, shutdown };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
logger.error("Server startup failed", { event: "server.start.failed", error });
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
598
679
|
}
|
|
599
680
|
`;
|
|
600
681
|
}
|
|
601
682
|
|
|
602
683
|
export function workerSource() {
|
|
603
684
|
return `import { container, registerDependencies } from "@/app/container";
|
|
685
|
+
import { configureObservability } from "@/app/observability";
|
|
604
686
|
import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
605
687
|
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
606
688
|
import { systemRequestContext } from "@/platform/http/createRequestContext";
|
|
689
|
+
import { logger } from "@/platform/logging/logger";
|
|
607
690
|
import { closeRedis, connectRedis } from "@/platform/redis";
|
|
608
691
|
|
|
609
692
|
export async function startWorker() {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
693
|
+
try {
|
|
694
|
+
configureObservability(logger);
|
|
695
|
+
const db = await connectDatabase();
|
|
696
|
+
const redis = await connectRedis();
|
|
697
|
+
registerDependencies({ db, redis });
|
|
698
|
+
await container.resolve(GetReadiness).execute(systemRequestContext(), {});
|
|
699
|
+
|
|
700
|
+
const shutdown = async () => {
|
|
701
|
+
await closeRedis();
|
|
702
|
+
await closeDatabase();
|
|
703
|
+
};
|
|
704
|
+
process.once("SIGINT", () => void shutdown());
|
|
705
|
+
process.once("SIGTERM", () => void shutdown());
|
|
706
|
+
return { shutdown };
|
|
707
|
+
} catch (error) {
|
|
708
|
+
logger.error("Worker startup failed", { event: "worker.start.failed", error });
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
622
711
|
}
|
|
623
712
|
`;
|
|
624
713
|
}
|