@better-forge/runtime 0.0.0-stage → 0.1.1

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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/dist/appLock.d.ts +13 -0
  4. package/dist/appLock.d.ts.map +1 -0
  5. package/dist/appLock.js +67 -0
  6. package/dist/appLock.js.map +1 -0
  7. package/dist/createBetterApp.d.ts +63 -0
  8. package/dist/createBetterApp.d.ts.map +1 -0
  9. package/dist/createBetterApp.js +181 -0
  10. package/dist/createBetterApp.js.map +1 -0
  11. package/dist/dataPaths.d.ts +13 -0
  12. package/dist/dataPaths.d.ts.map +1 -0
  13. package/dist/dataPaths.js +39 -0
  14. package/dist/dataPaths.js.map +1 -0
  15. package/dist/errorHandler.d.ts +8 -0
  16. package/dist/errorHandler.d.ts.map +1 -0
  17. package/dist/errorHandler.js +14 -0
  18. package/dist/errorHandler.js.map +1 -0
  19. package/dist/health.d.ts +57 -0
  20. package/dist/health.d.ts.map +1 -0
  21. package/dist/health.js +62 -0
  22. package/dist/health.js.map +1 -0
  23. package/dist/index.d.ts +15 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +15 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/processLiveness.d.ts +15 -0
  28. package/dist/processLiveness.d.ts.map +1 -0
  29. package/dist/processLiveness.js +30 -0
  30. package/dist/processLiveness.js.map +1 -0
  31. package/dist/proxy.d.ts +4 -0
  32. package/dist/proxy.d.ts.map +1 -0
  33. package/dist/proxy.js +20 -0
  34. package/dist/proxy.js.map +1 -0
  35. package/dist/publicUrl.d.ts +11 -0
  36. package/dist/publicUrl.d.ts.map +1 -0
  37. package/dist/publicUrl.js +29 -0
  38. package/dist/publicUrl.js.map +1 -0
  39. package/dist/rateLimitKey.d.ts +7 -0
  40. package/dist/rateLimitKey.d.ts.map +1 -0
  41. package/dist/rateLimitKey.js +21 -0
  42. package/dist/rateLimitKey.js.map +1 -0
  43. package/dist/redactUrl.d.ts +9 -0
  44. package/dist/redactUrl.d.ts.map +1 -0
  45. package/dist/redactUrl.js +48 -0
  46. package/dist/redactUrl.js.map +1 -0
  47. package/dist/shutdown.d.ts +20 -0
  48. package/dist/shutdown.d.ts.map +1 -0
  49. package/dist/shutdown.js +46 -0
  50. package/dist/shutdown.js.map +1 -0
  51. package/dist/sse.d.ts +58 -0
  52. package/dist/sse.d.ts.map +1 -0
  53. package/dist/sse.js +119 -0
  54. package/dist/sse.js.map +1 -0
  55. package/dist/updateCheck.d.ts +26 -0
  56. package/dist/updateCheck.d.ts.map +1 -0
  57. package/dist/updateCheck.js +95 -0
  58. package/dist/updateCheck.js.map +1 -0
  59. package/dist/version.d.ts +5 -0
  60. package/dist/version.d.ts.map +1 -0
  61. package/dist/version.js +5 -0
  62. package/dist/version.js.map +1 -0
  63. package/package.json +63 -4
  64. package/src/appLock.ts +63 -0
  65. package/src/createBetterApp.ts +236 -0
  66. package/src/dataPaths.ts +48 -0
  67. package/src/errorHandler.ts +15 -0
  68. package/src/health.ts +109 -0
  69. package/src/index.ts +14 -0
  70. package/src/processLiveness.ts +31 -0
  71. package/src/proxy.ts +20 -0
  72. package/src/publicUrl.ts +26 -0
  73. package/src/rateLimitKey.ts +20 -0
  74. package/src/redactUrl.ts +49 -0
  75. package/src/shutdown.ts +62 -0
  76. package/src/sse.ts +154 -0
  77. package/src/updateCheck.ts +107 -0
  78. package/src/version.ts +7 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BetterStatusPage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @better-forge/runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Application bootstrap: Fastify defaults, health, shutdown, update check, app lock and SSE.
4
+
5
+ Request logs hide secrets: `redactQueryParams` replaces the value of those query parameters (names are compared after decoding), `redactPathTokenPrefixes` the path segment after a prefix such as `/links/`. `redactUrl` does the same for any URL.
6
+
7
+ `createSse(limits)` serves live event streams. `SseLimits` caps the open anonymous streams in total and per address (`rateLimitKey` groups an IPv6 address by its /64), and the unsent bytes a client may hold before it is disconnected; signed-in streams are not counted. `rateLimitKey(ip)` is the key to use for any per-address limit.
8
+
9
+ Status: skeleton. Depends on: `@better-forge/db`.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The marker file that tells a restore or a second start that the application is running. `name` is how the
3
+ * application is called in the refusal, `fileName` the marker inside `dir()` (evaluated on each use).
4
+ */
5
+ export declare function createAppLock(options: {
6
+ name: string;
7
+ fileName: string;
8
+ dir: () => string;
9
+ }): {
10
+ assertAppStopped: () => void;
11
+ acquireAppLock: () => () => void;
12
+ };
13
+ //# sourceMappingURL=appLock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"appLock.d.ts","sourceRoot":"","sources":["../src/appLock.ts"],"names":[],"mappings":"AAYA;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,MAAM,CAAA;CAAE;4BAsB7D,IAAI;0BAKN,MAAM,IAAI;EAmBtC"}
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { currentProcessOwner, isOwnerAlive } from './processLiveness.js';
4
+ const FRESH_MS = 30_000;
5
+ /**
6
+ * A stalled heartbeat (event loop blocked by a long synchronous task) still counts as running
7
+ * while the recorded pid is alive — but only for this long, so a reused pid cannot block forever.
8
+ */
9
+ const LIVE_OWNER_MS = 10 * 60_000;
10
+ /**
11
+ * The marker file that tells a restore or a second start that the application is running. `name` is how the
12
+ * application is called in the refusal, `fileName` the marker inside `dir()` (evaluated on each use).
13
+ */
14
+ export function createAppLock(options) {
15
+ const RUNNING_MESSAGE = `${options.name} is still running. Stop the application before restoring.`;
16
+ const lockPath = () => path.join(options.dir(), options.fileName);
17
+ function readOwner(file) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ /** Removes a marker whose owner is gone; throws while the app still looks alive. */
26
+ function clearStaleMarker(options) {
27
+ const file = lockPath();
28
+ if (!fs.existsSync(file))
29
+ return;
30
+ const age = Date.now() - fs.statSync(file).mtimeMs;
31
+ const ownerAlive = isOwnerAlive(readOwner(file));
32
+ // A killed process (Ctrl+C on Windows, tsx watch restart) leaves a fresh marker; startup trusts a dead pid over the heartbeat.
33
+ if (age < FRESH_MS && (options.checkOwner || ownerAlive))
34
+ throw new Error(RUNNING_MESSAGE);
35
+ if (options.checkOwner && age < LIVE_OWNER_MS && ownerAlive)
36
+ throw new Error(RUNNING_MESSAGE);
37
+ fs.rmSync(file, { force: true });
38
+ }
39
+ /** Used by restore: also refuses while the recorded app process is alive but its heartbeat has stalled. */
40
+ function assertAppStopped() {
41
+ clearStaleMarker({ checkOwner: true });
42
+ }
43
+ /** Startup uses the heartbeat only, so a restart after a crash is never blocked by an unrelated process that reused the pid. */
44
+ function acquireAppLock() {
45
+ clearStaleMarker({ checkOwner: false });
46
+ fs.mkdirSync(options.dir(), { recursive: true });
47
+ const file = lockPath();
48
+ fs.writeFileSync(file, JSON.stringify({ ...currentProcessOwner(), startedAt: Date.now() }), { flag: 'wx', mode: 0o600 });
49
+ const heartbeat = setInterval(() => {
50
+ try {
51
+ fs.utimesSync(file, new Date(), new Date());
52
+ }
53
+ catch { /* shutdown race */ }
54
+ }, 5_000);
55
+ heartbeat.unref();
56
+ let released = false;
57
+ return () => {
58
+ if (released)
59
+ return;
60
+ released = true;
61
+ clearInterval(heartbeat);
62
+ fs.rmSync(file, { force: true });
63
+ };
64
+ }
65
+ return { assertAppStopped, acquireAppLock };
66
+ }
67
+ //# sourceMappingURL=appLock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"appLock.js","sourceRoot":"","sources":["../src/appLock.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ import Fastify, { type FastifyInstance } from 'fastify';
2
+ /** How long a signed-in session token lives. */
3
+ export declare const JWT_EXPIRES_IN = "12h";
4
+ /** The secret sessions are signed with: from `JWT_SECRET`, which production insists on being a real one. */
5
+ export declare function resolveJwtSecret(env?: NodeJS.ProcessEnv): string;
6
+ export interface BetterAppOptions {
7
+ /** Request URLs are logged with the value of these query parameters replaced (capability tokens in links). */
8
+ redactQueryParams?: readonly string[];
9
+ /** Request paths whose next segment is a capability token, for example `/links/` (the token is not logged). */
10
+ redactPathTokenPrefixes?: readonly string[];
11
+ /** Checks run before anything is created (for example that the encryption key is usable); throw to refuse to start. */
12
+ validate?: () => void;
13
+ /** Where uploaded files are kept and served from (`/uploads/`). */
14
+ uploads: {
15
+ dir: string;
16
+ maxFileBytes: number;
17
+ };
18
+ /** Whether a response to this request may be shown in a frame of our own origin (anything else is refused). */
19
+ allowSameOriginFraming?: (request: {
20
+ path: string;
21
+ query: URLSearchParams;
22
+ }) => boolean;
23
+ /** The database is opened only once the application has been set up. */
24
+ database: {
25
+ isReady: () => boolean;
26
+ open: () => void;
27
+ migrate: () => void;
28
+ close: () => void;
29
+ };
30
+ /** Registers every route of the application. */
31
+ routes: (app: FastifyInstance) => Promise<void>;
32
+ /** Registers the built front-ends; called in production only, after the routes. */
33
+ frontends?: (app: FastifyInstance) => Promise<void>;
34
+ /** Takes the lock that keeps a second instance away from the data directory; returns the release. */
35
+ acquireLock: () => () => void;
36
+ /** Work that runs only while the application is set up. */
37
+ backgroundServices?: {
38
+ start: () => void;
39
+ stop: () => void;
40
+ /** Resolves once the work `stop` left running has finished; the shutdown waits for it until its deadline. */
41
+ drain?: () => Promise<void>;
42
+ };
43
+ /** Called when the process is asked to stop, before the server drains (close event streams, say). */
44
+ stopIntake?: () => void;
45
+ /** How long a stop waits for the server and the background work to drain before it exits anyway. Default 5 seconds. */
46
+ shutdownDeadlineMs?: number;
47
+ /** Name used in the line logged when the server is up. */
48
+ logName?: string;
49
+ port?: number;
50
+ }
51
+ /**
52
+ * Builds the HTTP application the Better products share: logging that hides credentials, proxy trust, CORS and a
53
+ * cross-site guard, cookies, JWT sessions, rate limiting, uploads, security headers, the database once set up, and the
54
+ * application's routes. `run` then takes the lock, listens, and wires the shutdown that closes everything in order.
55
+ */
56
+ export declare function createBetterApp(options: BetterAppOptions): Promise<{
57
+ app: Fastify.FastifyInstance<import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>, import("node:http").IncomingMessage, import("node:http").ServerResponse<import("node:http").IncomingMessage>, Fastify.FastifyBaseLogger, Fastify.FastifyTypeProviderDefault> & PromiseLike<Fastify.FastifyInstance<import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>, import("node:http").IncomingMessage, import("node:http").ServerResponse<import("node:http").IncomingMessage>, Fastify.FastifyBaseLogger, Fastify.FastifyTypeProviderDefault>> & {
58
+ __linterBrands: "SafePromiseLike";
59
+ };
60
+ run: () => Promise<void>;
61
+ }>;
62
+ export type BetterApp = Awaited<ReturnType<typeof createBetterApp>>;
63
+ //# sourceMappingURL=createBetterApp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createBetterApp.d.ts","sourceRoot":"","sources":["../src/createBetterApp.ts"],"names":[],"mappings":"AACA,OAAO,OAAO,EAAE,EAAE,KAAK,eAAe,EAAuB,MAAM,SAAS,CAAA;AAmB5E,gDAAgD;AAChD,eAAO,MAAM,cAAc,QAAQ,CAAA;AAEnC,4GAA4G;AAC5G,wBAAgB,gBAAgB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAU7E;AAED,MAAM,WAAW,gBAAgB;IAC/B,8GAA8G;IAC9G,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,+GAA+G;IAC/G,uBAAuB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3C,uHAAuH;IACvH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,mEAAmE;IACnE,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE,CAAA;IAC9C,+GAA+G;IAC/G,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,eAAe,CAAA;KAAE,KAAK,OAAO,CAAA;IACvF,wEAAwE;IACxE,QAAQ,EAAE;QACR,OAAO,EAAE,MAAM,OAAO,CAAA;QACtB,IAAI,EAAE,MAAM,IAAI,CAAA;QAChB,OAAO,EAAE,MAAM,IAAI,CAAA;QACnB,KAAK,EAAE,MAAM,IAAI,CAAA;KAClB,CAAA;IACD,gDAAgD;IAChD,MAAM,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/C,mFAAmF;IACnF,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACnD,qGAAqG;IACrG,WAAW,EAAE,MAAM,MAAM,IAAI,CAAA;IAC7B,2DAA2D;IAC3D,kBAAkB,CAAC,EAAE;QACnB,KAAK,EAAE,MAAM,IAAI,CAAA;QACjB,IAAI,EAAE,MAAM,IAAI,CAAA;QAChB,6GAA6G;QAC7G,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAC5B,CAAA;IACD,qGAAqG;IACrG,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,uHAAuH;IACvH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,0DAA0D;IAC1D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB;;;;eAmHvC,OAAO,CAAC,IAAI,CAAC;GAqCpC;AAED,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAA"}
@@ -0,0 +1,181 @@
1
+ import fs from 'node:fs';
2
+ import Fastify from 'fastify';
3
+ import cors from '@fastify/cors';
4
+ import cookie from '@fastify/cookie';
5
+ import jwt from '@fastify/jwt';
6
+ import multipart from '@fastify/multipart';
7
+ import rateLimit from '@fastify/rate-limit';
8
+ import { rateLimitKey } from './rateLimitKey.js';
9
+ import staticFiles from '@fastify/static';
10
+ import { errorHandler } from './errorHandler.js';
11
+ import { redactUrl } from './redactUrl.js';
12
+ import { resolveTrustProxy } from './proxy.js';
13
+ import { createRuntimeShutdown } from './shutdown.js';
14
+ const DEFAULT_JWT_SECRET = 'dev-secret-change-me';
15
+ const EXAMPLE_SECRETS = new Set([
16
+ 'change-me-to-a-long-random-string',
17
+ 'change-me-to-a-64-char-hex-string',
18
+ ]);
19
+ /** How long a signed-in session token lives. */
20
+ export const JWT_EXPIRES_IN = '12h';
21
+ /** The secret sessions are signed with: from `JWT_SECRET`, which production insists on being a real one. */
22
+ export function resolveJwtSecret(env = process.env) {
23
+ const secret = env['JWT_SECRET'];
24
+ if (env['NODE_ENV'] !== 'production') {
25
+ if (!secret)
26
+ console.warn('JWT_SECRET not set - using insecure default (development only)');
27
+ return secret ?? DEFAULT_JWT_SECRET;
28
+ }
29
+ if (!secret || secret.length < 32 || EXAMPLE_SECRETS.has(secret)) {
30
+ throw new Error('JWT_SECRET must be set to a non-default value of at least 32 characters in production');
31
+ }
32
+ return secret;
33
+ }
34
+ /**
35
+ * Builds the HTTP application the Better products share: logging that hides credentials, proxy trust, CORS and a
36
+ * cross-site guard, cookies, JWT sessions, rate limiting, uploads, security headers, the database once set up, and the
37
+ * application's routes. `run` then takes the lock, listens, and wires the shutdown that closes everything in order.
38
+ */
39
+ export async function createBetterApp(options) {
40
+ const redaction = { queryParams: options.redactQueryParams, pathTokenPrefixes: options.redactPathTokenPrefixes };
41
+ const app = Fastify({
42
+ logger: {
43
+ level: 'info',
44
+ redact: {
45
+ paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
46
+ censor: '[redacted]',
47
+ },
48
+ serializers: {
49
+ req: (req) => ({
50
+ method: req.method,
51
+ url: redactUrl(req.url, redaction),
52
+ host: req.host,
53
+ remoteAddress: req.ip,
54
+ ...(req.socket?.remotePort !== undefined ? { remotePort: req.socket.remotePort } : {}),
55
+ }),
56
+ },
57
+ },
58
+ trustProxy: resolveTrustProxy(),
59
+ });
60
+ app.setErrorHandler(errorHandler);
61
+ // CORS
62
+ const allowedOrigins = process.env['ALLOWED_ORIGINS']?.split(',').map((o) => o.trim());
63
+ await app.register(cors, {
64
+ origin: allowedOrigins
65
+ ?? (process.env['NODE_ENV'] === 'production' ? false : true),
66
+ credentials: true,
67
+ });
68
+ await app.register(cookie);
69
+ // Reject unsafe cross-site browser requests before they reach auth or setup routes.
70
+ app.addHook('onRequest', async (req, reply) => {
71
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method))
72
+ return;
73
+ if (process.env['NODE_ENV'] !== 'production')
74
+ return;
75
+ if (req.headers['sec-fetch-site'] === 'cross-site') {
76
+ return reply.code(403).send({ error: 'Cross-site request rejected' });
77
+ }
78
+ const origin = req.headers.origin;
79
+ if (!origin)
80
+ return;
81
+ const ownOrigin = `${req.protocol}://${req.headers.host}`;
82
+ if (origin !== ownOrigin && !allowedOrigins?.includes(origin)) {
83
+ return reply.code(403).send({ error: 'Origin not allowed' });
84
+ }
85
+ });
86
+ // JWT
87
+ const jwtSecret = resolveJwtSecret();
88
+ options.validate?.();
89
+ await app.register(jwt, {
90
+ secret: jwtSecret,
91
+ sign: { expiresIn: JWT_EXPIRES_IN },
92
+ });
93
+ // Rate limiting (applied per-route where needed); IPv6 clients are counted per /64
94
+ await app.register(rateLimit, { global: false, keyGenerator: (req) => rateLimitKey(req.ip) });
95
+ // Multipart (file uploads)
96
+ await app.register(multipart, {
97
+ limits: { fileSize: options.uploads.maxFileBytes },
98
+ });
99
+ // Serve uploaded files
100
+ if (!fs.existsSync(options.uploads.dir)) {
101
+ fs.mkdirSync(options.uploads.dir, { recursive: true });
102
+ }
103
+ await app.register(staticFiles, {
104
+ root: options.uploads.dir,
105
+ prefix: '/uploads/',
106
+ decorateReply: false,
107
+ });
108
+ // Security headers
109
+ app.addHook('onSend', (req, reply, _payload, done) => {
110
+ const [requestPath = '', query = ''] = req.url.split('?', 2);
111
+ const framed = options.allowSameOriginFraming?.({ path: requestPath, query: new URLSearchParams(query) }) ?? false;
112
+ reply.header('X-Content-Type-Options', 'nosniff');
113
+ reply.header('X-Frame-Options', framed ? 'SAMEORIGIN' : 'DENY');
114
+ reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
115
+ reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
116
+ reply.header('Cross-Origin-Opener-Policy', 'same-origin');
117
+ reply.header('Content-Security-Policy', [
118
+ "default-src 'self'",
119
+ "base-uri 'self'",
120
+ "object-src 'none'",
121
+ framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
122
+ "form-action 'self'",
123
+ "script-src 'self'",
124
+ "style-src 'self' 'unsafe-inline'",
125
+ "font-src 'self'",
126
+ "img-src 'self' data: blob:",
127
+ "connect-src 'self'",
128
+ ].join('; '));
129
+ if (process.env['NODE_ENV'] === 'production') {
130
+ reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
131
+ }
132
+ done();
133
+ });
134
+ // Initialize DB only when setup has been completed
135
+ if (options.database.isReady()) {
136
+ options.database.open();
137
+ options.database.migrate();
138
+ }
139
+ await options.routes(app);
140
+ // Serve built frontend apps in production
141
+ if (process.env['NODE_ENV'] === 'production')
142
+ await options.frontends?.(app);
143
+ /** Takes the lock, listens, and makes the process stop cleanly on SIGTERM and SIGINT. */
144
+ async function run() {
145
+ const port = options.port ?? Number(process.env['PORT'] ?? 3000);
146
+ const releaseLock = options.acquireLock();
147
+ const runtime = createRuntimeShutdown({
148
+ stopIntake: () => {
149
+ options.stopIntake?.();
150
+ options.backgroundServices?.stop();
151
+ },
152
+ closeServer: () => app.close(),
153
+ ...(options.backgroundServices?.drain ? { drain: options.backgroundServices.drain } : {}),
154
+ ...(options.shutdownDeadlineMs !== undefined ? { forceExitMs: options.shutdownDeadlineMs } : {}),
155
+ releaseResources: () => {
156
+ options.database.close();
157
+ releaseLock();
158
+ },
159
+ exit: (code) => process.exit(code),
160
+ });
161
+ // onClose runs after the HTTP server has drained, so the DB is no longer in use.
162
+ app.addHook('onClose', async () => {
163
+ runtime.cleanup();
164
+ });
165
+ try {
166
+ await app.listen({ port, host: '0.0.0.0' });
167
+ }
168
+ catch (error) {
169
+ runtime.cleanup();
170
+ throw error;
171
+ }
172
+ console.log(`✓ ${options.logName ?? 'API'} running on http://localhost:${port}`);
173
+ for (const signal of ['SIGTERM', 'SIGINT']) {
174
+ process.once(signal, runtime.shutdown);
175
+ }
176
+ if (options.database.isReady())
177
+ options.backgroundServices?.start();
178
+ }
179
+ return { app, run };
180
+ }
181
+ //# sourceMappingURL=createBetterApp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createBetterApp.js","sourceRoot":"","sources":["../src/createBetterApp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,OAAsD,MAAM,SAAS,CAAA;AAC5E,OAAO,IAAI,MAAM,eAAe,CAAA;AAChC,OAAO,MAAM,MAAM,iBAAiB,CAAA;AACpC,OAAO,GAAG,MAAM,cAAc,CAAA;AAC9B,OAAO,SAAS,MAAM,oBAAoB,CAAA;AAC1C,OAAO,SAAS,MAAM,qBAAqB,CAAA;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,WAAW,MAAM,iBAAiB,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAErD,MAAM,kBAAkB,GAAG,sBAAsB,CAAA;AACjD,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC;IAC9B,mCAAmC;IACnC,mCAAmC;CACpC,CAAC,CAAA;AAEF,gDAAgD;AAChD,MAAM,CAAC,MAAM,cAAc,GAAG,KAAK,CAAA;AAEnC,4GAA4G;AAC5G,MAAM,UAAU,gBAAgB,CAAC,MAAyB,OAAO,CAAC,GAAG;IACnE,MAAM,MAAM,GAAG,GAAG,CAAC,YAAY,CAAC,CAAA;IAChC,IAAI,GAAG,CAAC,UAAU,CAAC,KAAK,YAAY,EAAE,CAAC;QACrC,IAAI,CAAC,MAAM;YAAE,OAAO,CAAC,IAAI,CAAC,gEAAgE,CAAC,CAAA;QAC3F,OAAO,MAAM,IAAI,kBAAkB,CAAA;IACrC,CAAC;IACD,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,EAAE,IAAI,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CAAC,uFAAuF,CAAC,CAAA;IAC1G,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC;AA0CD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,OAAyB;IAC7D,MAAM,SAAS,GAAG,EAAE,WAAW,EAAE,OAAO,CAAC,iBAAiB,EAAE,iBAAiB,EAAE,OAAO,CAAC,uBAAuB,EAAE,CAAA;IAEhH,MAAM,GAAG,GAAG,OAAO,CAAC;QAClB,MAAM,EAAE;YACN,KAAK,EAAE,MAAM;YACb,MAAM,EAAE;gBACN,KAAK,EAAE,CAAC,2BAA2B,EAAE,oBAAoB,EAAE,2BAA2B,CAAC;gBACvF,MAAM,EAAE,YAAY;aACrB;YACD,WAAW,EAAE;gBACX,GAAG,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;oBACb,MAAM,EAAE,GAAG,CAAC,MAAM;oBAClB,GAAG,EAAE,SAAS,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,CAAC;oBAClC,IAAI,EAAE,GAAG,CAAC,IAAI;oBACd,aAAa,EAAE,GAAG,CAAC,EAAE;oBACrB,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBACvF,CAAC;aACH;SACF;QACD,UAAU,EAAE,iBAAiB,EAAE;KAChC,CAAC,CAAA;IAEF,GAAG,CAAC,eAAe,CAAC,YAAY,CAAC,CAAA;IAEjC,OAAO;IACP,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAA;IACtF,MAAM,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE;QACvB,MAAM,EAAE,cAAc;eACjB,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;QAC9D,WAAW,EAAE,IAAI;KAClB,CAAC,CAAA;IAEF,MAAM,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;IAE1B,oFAAoF;IACpF,GAAG,CAAC,OAAO,CAAC,WAAW,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE;QAC5C,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,OAAM;QAC3D,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,YAAY;YAAE,OAAM;QACpD,IAAI,GAAG,CAAC,OAAO,CAAC,gBAAgB,CAAC,KAAK,YAAY,EAAE,CAAC;YACnD,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,6BAA6B,EAAE,CAAC,CAAA;QACvE,CAAC;QACD,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,CAAA;QACjC,IAAI,CAAC,MAAM;YAAE,OAAM;QACnB,MAAM,SAAS,GAAG,GAAG,GAAG,CAAC,QAAQ,MAAM,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAA;QACzD,IAAI,MAAM,KAAK,SAAS,IAAI,CAAC,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;YAC9D,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC,CAAA;QAC9D,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,MAAM;IACN,MAAM,SAAS,GAAG,gBAAgB,EAAE,CAAA;IACpC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAA;IACpB,MAAM,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE;QACtB,MAAM,EAAE,SAAS;QACjB,IAAI,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE;KACpC,CAAC,CAAA;IAEF,mFAAmF;IACnF,MAAM,GAAG,CAAC,QAAQ,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,CAAA;IAE7F,2BAA2B;IAC3B,MAAM,GAAG,CAAC,QAAQ,CAAC,SAAS,EAAE;QAC5B,MAAM,EAAE,EAAE,QAAQ,EAAE,OAAO,CAAC,OAAO,CAAC,YAAY,EAAE;KACnD,CAAC,CAAA;IAEF,uBAAuB;IACvB,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QACxC,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IACxD,CAAC;IACD,MAAM,GAAG,CAAC,QAAQ,CAAC,WAAW,EAAE;QAC9B,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG;QACzB,MAAM,EAAE,WAAW;QACnB,aAAa,EAAE,KAAK;KACrB,CAAC,CAAA;IAEF,mBAAmB;IACnB,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,GAAmB,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE;QACnE,MAAM,CAAC,WAAW,GAAG,EAAE,EAAE,KAAK,GAAG,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;QAC5D,MAAM,MAAM,GAAG,OAAO,CAAC,sBAAsB,EAAE,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,IAAI,eAAe,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,KAAK,CAAA;QAClH,KAAK,CAAC,MAAM,CAAC,wBAAwB,EAAE,SAAS,CAAC,CAAA;QACjD,KAAK,CAAC,MAAM,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;QAC/D,KAAK,CAAC,MAAM,CAAC,iBAAiB,EAAE,iCAAiC,CAAC,CAAA;QAClE,KAAK,CAAC,MAAM,CAAC,oBAAoB,EAAE,sDAAsD,CAAC,CAAA;QAC1F,KAAK,CAAC,MAAM,CAAC,4BAA4B,EAAE,aAAa,CAAC,CAAA;QACzD,KAAK,CAAC,MAAM,CAAC,yBAAyB,EAAE;YACtC,oBAAoB;YACpB,iBAAiB;YACjB,mBAAmB;YACnB,MAAM,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC,CAAC,wBAAwB;YAC5D,oBAAoB;YACpB,mBAAmB;YACnB,kCAAkC;YAClC,iBAAiB;YACjB,4BAA4B;YAC5B,oBAAoB;SACrB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;QACb,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,YAAY,EAAE,CAAC;YAC7C,KAAK,CAAC,MAAM,CAAC,2BAA2B,EAAE,qCAAqC,CAAC,CAAA;QAClF,CAAC;QACD,IAAI,EAAE,CAAA;IACR,CAAC,CAAC,CAAA;IAEF,mDAAmD;IACnD,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC;QAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAA;QACvB,OAAO,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAA;IAC5B,CAAC;IAED,MAAM,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IAEzB,0CAA0C;IAC1C,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,YAAY;QAAE,MAAM,OAAO,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,CAAA;IAE5E,yFAAyF;IACzF,KAAK,UAAU,GAAG;QAChB,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,CAAA;QAChE,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,EAAE,CAAA;QACzC,MAAM,OAAO,GAAG,qBAAqB,CAAC;YACpC,UAAU,EAAE,GAAG,EAAE;gBACf,OAAO,CAAC,UAAU,EAAE,EAAE,CAAA;gBACtB,OAAO,CAAC,kBAAkB,EAAE,IAAI,EAAE,CAAA;YACpC,CAAC;YACD,WAAW,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,KAAK,EAAE;YAC9B,GAAG,CAAC,OAAO,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,kBAAkB,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACzF,GAAG,CAAC,OAAO,CAAC,kBAAkB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAChG,gBAAgB,EAAE,GAAG,EAAE;gBACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAA;gBACxB,WAAW,EAAE,CAAA;YACf,CAAC;YACD,IAAI,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC;SACnC,CAAC,CAAA;QACF,iFAAiF;QACjF,GAAG,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,IAAI,EAAE;YAChC,OAAO,CAAC,OAAO,EAAE,CAAA;QACnB,CAAC,CAAC,CAAA;QACF,IAAI,CAAC;YACH,MAAM,GAAG,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;QAC7C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,OAAO,EAAE,CAAA;YACjB,MAAM,KAAK,CAAA;QACb,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,OAAO,IAAI,KAAK,gCAAgC,IAAI,EAAE,CAAC,CAAA;QAEhF,KAAK,MAAM,MAAM,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAU,EAAE,CAAC;YACpD,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAA;QACxC,CAAC;QAED,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,EAAE;YAAE,OAAO,CAAC,kBAAkB,EAAE,KAAK,EAAE,CAAA;IACrE,CAAC;IAED,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,CAAA;AACrB,CAAC"}
@@ -0,0 +1,13 @@
1
+ type Env = NodeJS.ProcessEnv;
2
+ /** Where the application keeps its data: `DATA_DIR`, else `./data` under the working directory. */
3
+ export declare function dataDir(env?: Env): string;
4
+ /** The SQLite file: `DATABASE_PATH`, else `db.sqlite` in the data directory. */
5
+ export declare function databasePath(env?: Env): string;
6
+ /** The file that records the first-run setup was completed: `SETUP_CONFIG_PATH`, else `setup.json` in the data directory. */
7
+ export declare function setupConfigPath(env?: Env): string;
8
+ /** Where backups are written: `BACKUP_DIR`, else `backups` in the data directory. */
9
+ export declare function backupDir(env?: Env): string;
10
+ export declare function isSetupComplete(env?: Env): boolean;
11
+ export declare function writeSetupComplete(dbType?: 'sqlite', env?: Env): void;
12
+ export {};
13
+ //# sourceMappingURL=dataPaths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataPaths.d.ts","sourceRoot":"","sources":["../src/dataPaths.ts"],"names":[],"mappings":"AAGA,KAAK,GAAG,GAAG,MAAM,CAAC,UAAU,CAAA;AAE5B,mGAAmG;AACnG,wBAAgB,OAAO,CAAC,GAAG,GAAE,GAAiB,GAAG,MAAM,CAEtD;AAED,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,GAAG,GAAE,GAAiB,GAAG,MAAM,CAE3D;AAED,6HAA6H;AAC7H,wBAAgB,eAAe,CAAC,GAAG,GAAE,GAAiB,GAAG,MAAM,CAE9D;AAED,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,GAAG,GAAE,GAAiB,GAAG,MAAM,CAExD;AAOD,wBAAgB,eAAe,CAAC,GAAG,GAAE,GAAiB,GAAG,OAAO,CAU/D;AAED,wBAAgB,kBAAkB,CAAC,MAAM,GAAE,QAAmB,EAAE,GAAG,GAAE,GAAiB,GAAG,IAAI,CAK5F"}
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Where the application keeps its data: `DATA_DIR`, else `./data` under the working directory. */
4
+ export function dataDir(env = process.env) {
5
+ return path.resolve(env['DATA_DIR'] ?? path.join(process.cwd(), 'data'));
6
+ }
7
+ /** The SQLite file: `DATABASE_PATH`, else `db.sqlite` in the data directory. */
8
+ export function databasePath(env = process.env) {
9
+ return path.resolve(env['DATABASE_PATH'] ?? path.join(dataDir(env), 'db.sqlite'));
10
+ }
11
+ /** The file that records the first-run setup was completed: `SETUP_CONFIG_PATH`, else `setup.json` in the data directory. */
12
+ export function setupConfigPath(env = process.env) {
13
+ return path.resolve(env['SETUP_CONFIG_PATH'] ?? path.join(dataDir(env), 'setup.json'));
14
+ }
15
+ /** Where backups are written: `BACKUP_DIR`, else `backups` in the data directory. */
16
+ export function backupDir(env = process.env) {
17
+ return path.resolve(env['BACKUP_DIR'] ?? path.join(dataDir(env), 'backups'));
18
+ }
19
+ export function isSetupComplete(env = process.env) {
20
+ try {
21
+ const file = setupConfigPath(env);
22
+ if (!fs.existsSync(file))
23
+ return false;
24
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
25
+ return cfg.setupComplete === true;
26
+ }
27
+ catch {
28
+ // A marker that exists but cannot be read is not a fresh install: refuse setup rather than reopen it.
29
+ return true;
30
+ }
31
+ }
32
+ export function writeSetupComplete(dbType = 'sqlite', env = process.env) {
33
+ const file = setupConfigPath(env);
34
+ const dir = path.dirname(file);
35
+ if (!fs.existsSync(dir))
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ fs.writeFileSync(file, JSON.stringify({ setupComplete: true, dbType }, null, 2));
38
+ }
39
+ //# sourceMappingURL=dataPaths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataPaths.js","sourceRoot":"","sources":["../src/dataPaths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAA;AACxB,OAAO,IAAI,MAAM,WAAW,CAAA;AAI5B,mGAAmG;AACnG,MAAM,UAAU,OAAO,CAAC,MAAW,OAAO,CAAC,GAAG;IAC5C,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,MAAM,CAAC,CAAC,CAAA;AAC1E,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,YAAY,CAAC,MAAW,OAAO,CAAC,GAAG;IACjD,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,WAAW,CAAC,CAAC,CAAA;AACnF,CAAC;AAED,6HAA6H;AAC7H,MAAM,UAAU,eAAe,CAAC,MAAW,OAAO,CAAC,GAAG;IACpD,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,YAAY,CAAC,CAAC,CAAA;AACxF,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,SAAS,CAAC,MAAW,OAAO,CAAC,GAAG;IAC9C,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,SAAS,CAAC,CAAC,CAAA;AAC9E,CAAC;AAOD,MAAM,UAAU,eAAe,CAAC,MAAW,OAAO,CAAC,GAAG;IACpD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,eAAe,CAAC,GAAG,CAAC,CAAA;QACjC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAA;QACtC,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAgB,CAAA;QACrE,OAAO,GAAG,CAAC,aAAa,KAAK,IAAI,CAAA;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,sGAAsG;QACtG,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,SAAmB,QAAQ,EAAE,MAAW,OAAO,CAAC,GAAG;IACpF,MAAM,IAAI,GAAG,eAAe,CAAC,GAAG,CAAC,CAAA;IACjC,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/D,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAA;AAClF,CAAC"}
@@ -0,0 +1,8 @@
1
+ import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
2
+ /**
3
+ * Unexpected failures are logged in full but never echoed to the client: driver errors carry the
4
+ * SQL and its parameters, which can include encrypted secrets. Client errors (4xx) keep their
5
+ * message in the `{ error }` shape the admin and status clients read.
6
+ */
7
+ export declare function errorHandler(error: FastifyError, req: FastifyRequest, reply: FastifyReply): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("node:http").IncomingMessage, import("node:http").ServerResponse<import("node:http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
8
+ //# sourceMappingURL=errorHandler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errorHandler.d.ts","sourceRoot":"","sources":["../src/errorHandler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEzE;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,4SAOzF"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Unexpected failures are logged in full but never echoed to the client: driver errors carry the
3
+ * SQL and its parameters, which can include encrypted secrets. Client errors (4xx) keep their
4
+ * message in the `{ error }` shape the admin and status clients read.
5
+ */
6
+ export function errorHandler(error, req, reply) {
7
+ const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
8
+ if (statusCode >= 500) {
9
+ req.log.error({ err: error }, 'Unhandled request error');
10
+ return reply.code(statusCode).send({ error: 'Internal Server Error' });
11
+ }
12
+ return reply.code(statusCode).send({ error: error.message });
13
+ }
14
+ //# sourceMappingURL=errorHandler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errorHandler.js","sourceRoot":"","sources":["../src/errorHandler.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,KAAmB,EAAE,GAAmB,EAAE,KAAmB;IACxF,MAAM,UAAU,GAAG,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,UAAU,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,CAAA;IACvF,IAAI,UAAU,IAAI,GAAG,EAAE,CAAC;QACtB,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,yBAAyB,CAAC,CAAA;QACxD,OAAO,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC,CAAA;IACxE,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAA;AAC9D,CAAC"}
@@ -0,0 +1,57 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { SqliteDatabase } from '@better-forge/db';
3
+ import type { LatestRelease } from './updateCheck.js';
4
+ /** `GET /health` (the process is up) and `GET /ready` (set up and the database answers), for load balancers and probes. */
5
+ export declare function createHealthRoutes(options: {
6
+ db: SqliteDatabase;
7
+ isSetupComplete: () => boolean;
8
+ }): (app: FastifyInstance) => Promise<void>;
9
+ export type ComponentStatus = 'ok' | 'attention' | 'error' | 'disabled';
10
+ /** One part of the system the report describes. Its `status` is what the overall status is decided by. */
11
+ export type HealthSection = {
12
+ status: ComponentStatus;
13
+ } & Record<string, unknown>;
14
+ export interface HealthContributor {
15
+ /** The key of the section in the report. */
16
+ key: string;
17
+ /**
18
+ * When true, a section in the `error` status makes the whole report `degraded`; otherwise it is only shown.
19
+ */
20
+ critical: boolean;
21
+ /** Builds the section. Called after the database was probed; `now` is the time the report is made for. */
22
+ collect(context: {
23
+ now: number;
24
+ databaseOk: boolean;
25
+ }): Promise<HealthSection> | HealthSection;
26
+ }
27
+ export interface SystemHealthReport {
28
+ status: 'healthy' | 'degraded';
29
+ generatedAt: number;
30
+ application: {
31
+ status: 'ok';
32
+ version: string;
33
+ uptimeSeconds: number;
34
+ /** A newer published release, or null when up to date, unknown or the update check is off. */
35
+ update: LatestRelease | null;
36
+ };
37
+ database: {
38
+ status: 'ok' | 'error';
39
+ responseMs: number | null;
40
+ };
41
+ /** The sections of the contributors, by key. */
42
+ [section: string]: unknown;
43
+ }
44
+ /**
45
+ * The protected report of how the instance is doing: the process, the database, and one section per contributor the
46
+ * application registers (a scheduler, a queue, backups, ...). Exposes state, not raw errors.
47
+ */
48
+ export declare function createSystemHealth(options: {
49
+ db: SqliteDatabase;
50
+ version: () => string;
51
+ update: () => LatestRelease | null;
52
+ contributors: readonly HealthContributor[];
53
+ }): {
54
+ getSystemHealthReport: (now?: number) => Promise<SystemHealthReport>;
55
+ systemHealthRoutes: (app: FastifyInstance) => Promise<void>;
56
+ };
57
+ //# sourceMappingURL=health.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../src/health.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAE9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAErD,2HAA2H;AAC3H,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAAE,EAAE,EAAE,cAAc,CAAC;IAAC,eAAe,EAAE,MAAM,OAAO,CAAA;CAAE,IAC7D,KAAK,eAAe,mBAexD;AAED,MAAM,MAAM,eAAe,GAAG,IAAI,GAAG,WAAW,GAAG,OAAO,GAAG,UAAU,CAAA;AAEvE,0GAA0G;AAC1G,MAAM,MAAM,aAAa,GAAG;IAAE,MAAM,EAAE,eAAe,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAEjF,MAAM,WAAW,iBAAiB;IAChC,4CAA4C;IAC5C,GAAG,EAAE,MAAM,CAAA;IACX;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAA;IACjB,0GAA0G;IAC1G,OAAO,CAAC,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,aAAa,CAAC,GAAG,aAAa,CAAA;CAC/F;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,SAAS,GAAG,UAAU,CAAA;IAC9B,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE;QACX,MAAM,EAAE,IAAI,CAAA;QACZ,OAAO,EAAE,MAAM,CAAA;QACf,aAAa,EAAE,MAAM,CAAA;QACrB,8FAA8F;QAC9F,MAAM,EAAE,aAAa,GAAG,IAAI,CAAA;KAC7B,CAAA;IACD,QAAQ,EAAE;QACR,MAAM,EAAE,IAAI,GAAG,OAAO,CAAA;QACtB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAC1B,CAAA;IACD,gDAAgD;IAChD,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAA;CAC3B;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC1C,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,MAAM,MAAM,CAAA;IACrB,MAAM,EAAE,MAAM,aAAa,GAAG,IAAI,CAAA;IAClC,YAAY,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAC3C;6CACyD,OAAO,CAAC,kBAAkB,CAAC;8BA0B5C,eAAe;EAcvD"}
package/dist/health.js ADDED
@@ -0,0 +1,62 @@
1
+ import { sql } from 'drizzle-orm';
2
+ /** `GET /health` (the process is up) and `GET /ready` (set up and the database answers), for load balancers and probes. */
3
+ export function createHealthRoutes(options) {
4
+ return async function healthRoutes(app) {
5
+ app.get('/health', async () => ({ status: 'ok' }));
6
+ app.get('/ready', async (_req, reply) => {
7
+ if (!options.isSetupComplete()) {
8
+ return reply.code(503).send({ status: 'not_ready' });
9
+ }
10
+ try {
11
+ await options.db.run(sql `SELECT 1`);
12
+ return { status: 'ready' };
13
+ }
14
+ catch {
15
+ return reply.code(503).send({ status: 'not_ready' });
16
+ }
17
+ });
18
+ };
19
+ }
20
+ /**
21
+ * The protected report of how the instance is doing: the process, the database, and one section per contributor the
22
+ * application registers (a scheduler, a queue, backups, ...). Exposes state, not raw errors.
23
+ */
24
+ export function createSystemHealth(options) {
25
+ async function getSystemHealthReport(now = Date.now()) {
26
+ const databaseStartedAt = Date.now();
27
+ let database = { status: 'error', responseMs: null };
28
+ try {
29
+ await options.db.run(sql `SELECT 1`);
30
+ database = { status: 'ok', responseMs: Date.now() - databaseStartedAt };
31
+ }
32
+ catch {
33
+ // The protected report deliberately exposes state, not raw database errors.
34
+ }
35
+ const sections = {};
36
+ for (const contributor of options.contributors) {
37
+ sections[contributor.key] = await contributor.collect({ now, databaseOk: database.status === 'ok' });
38
+ }
39
+ const failing = options.contributors.some((contributor) => contributor.critical && sections[contributor.key].status === 'error');
40
+ return {
41
+ status: database.status === 'ok' && !failing ? 'healthy' : 'degraded',
42
+ generatedAt: now,
43
+ application: { status: 'ok', version: options.version(), uptimeSeconds: Math.floor(process.uptime()), update: options.update() },
44
+ database,
45
+ ...sections,
46
+ };
47
+ }
48
+ /** Routes: the report at `/` and, at `/update`, only the running version and a newer release, if any. */
49
+ async function systemHealthRoutes(app) {
50
+ app.get('/', async (_request, reply) => {
51
+ reply.header('Cache-Control', 'no-store');
52
+ return getSystemHealthReport();
53
+ });
54
+ /** Cheap enough for a dashboard to ask on every visit. */
55
+ app.get('/update', async (_request, reply) => {
56
+ reply.header('Cache-Control', 'no-store');
57
+ return { version: options.version(), update: options.update() };
58
+ });
59
+ }
60
+ return { getSystemHealthReport, systemHealthRoutes };
61
+ }
62
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.js","sourceRoot":"","sources":["../src/health.ts"],"names":[],"mappings":"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"}