@better-forge/runtime 0.1.0 → 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.
@@ -1,236 +1,236 @@
1
- import fs from 'node:fs'
2
- import Fastify, { type FastifyInstance, type FastifyRequest } 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
-
15
- const DEFAULT_JWT_SECRET = 'dev-secret-change-me'
16
- const EXAMPLE_SECRETS = new Set([
17
- 'change-me-to-a-long-random-string',
18
- 'change-me-to-a-64-char-hex-string',
19
- ])
20
-
21
- /** How long a signed-in session token lives. */
22
- export const JWT_EXPIRES_IN = '12h'
23
-
24
- /** The secret sessions are signed with: from `JWT_SECRET`, which production insists on being a real one. */
25
- export function resolveJwtSecret(env: NodeJS.ProcessEnv = process.env): string {
26
- const secret = env['JWT_SECRET']
27
- if (env['NODE_ENV'] !== 'production') {
28
- if (!secret) console.warn('JWT_SECRET not set - using insecure default (development only)')
29
- return secret ?? DEFAULT_JWT_SECRET
30
- }
31
- if (!secret || secret.length < 32 || EXAMPLE_SECRETS.has(secret)) {
32
- throw new Error('JWT_SECRET must be set to a non-default value of at least 32 characters in production')
33
- }
34
- return secret
35
- }
36
-
37
- export interface BetterAppOptions {
38
- /** Request URLs are logged with the value of these query parameters replaced (capability tokens in links). */
39
- redactQueryParams?: readonly string[]
40
- /** Request paths whose next segment is a capability token, for example `/links/` (the token is not logged). */
41
- redactPathTokenPrefixes?: readonly string[]
42
- /** Checks run before anything is created (for example that the encryption key is usable); throw to refuse to start. */
43
- validate?: () => void
44
- /** Where uploaded files are kept and served from (`/uploads/`). */
45
- uploads: { dir: string; maxFileBytes: number }
46
- /** Whether a response to this request may be shown in a frame of our own origin (anything else is refused). */
47
- allowSameOriginFraming?: (request: { path: string; query: URLSearchParams }) => boolean
48
- /** The database is opened only once the application has been set up. */
49
- database: {
50
- isReady: () => boolean
51
- open: () => void
52
- migrate: () => void
53
- close: () => void
54
- }
55
- /** Registers every route of the application. */
56
- routes: (app: FastifyInstance) => Promise<void>
57
- /** Registers the built front-ends; called in production only, after the routes. */
58
- frontends?: (app: FastifyInstance) => Promise<void>
59
- /** Takes the lock that keeps a second instance away from the data directory; returns the release. */
60
- acquireLock: () => () => void
61
- /** Work that runs only while the application is set up. */
62
- backgroundServices?: {
63
- start: () => void
64
- stop: () => void
65
- /** Resolves once the work `stop` left running has finished; the shutdown waits for it until its deadline. */
66
- drain?: () => Promise<void>
67
- }
68
- /** Called when the process is asked to stop, before the server drains (close event streams, say). */
69
- stopIntake?: () => void
70
- /** How long a stop waits for the server and the background work to drain before it exits anyway. Default 5 seconds. */
71
- shutdownDeadlineMs?: number
72
- /** Name used in the line logged when the server is up. */
73
- logName?: string
74
- port?: number
75
- }
76
-
77
- /**
78
- * Builds the HTTP application the Better products share: logging that hides credentials, proxy trust, CORS and a
79
- * cross-site guard, cookies, JWT sessions, rate limiting, uploads, security headers, the database once set up, and the
80
- * application's routes. `run` then takes the lock, listens, and wires the shutdown that closes everything in order.
81
- */
82
- export async function createBetterApp(options: BetterAppOptions) {
83
- const redaction = { queryParams: options.redactQueryParams, pathTokenPrefixes: options.redactPathTokenPrefixes }
84
-
85
- const app = Fastify({
86
- logger: {
87
- level: 'info',
88
- redact: {
89
- paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
90
- censor: '[redacted]',
91
- },
92
- serializers: {
93
- req: (req) => ({
94
- method: req.method,
95
- url: redactUrl(req.url, redaction),
96
- host: req.host,
97
- remoteAddress: req.ip,
98
- ...(req.socket?.remotePort !== undefined ? { remotePort: req.socket.remotePort } : {}),
99
- }),
100
- },
101
- },
102
- trustProxy: resolveTrustProxy(),
103
- })
104
-
105
- app.setErrorHandler(errorHandler)
106
-
107
- // CORS
108
- const allowedOrigins = process.env['ALLOWED_ORIGINS']?.split(',').map((o) => o.trim())
109
- await app.register(cors, {
110
- origin: allowedOrigins
111
- ?? (process.env['NODE_ENV'] === 'production' ? false : true),
112
- credentials: true,
113
- })
114
-
115
- await app.register(cookie)
116
-
117
- // Reject unsafe cross-site browser requests before they reach auth or setup routes.
118
- app.addHook('onRequest', async (req, reply) => {
119
- if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return
120
- if (process.env['NODE_ENV'] !== 'production') return
121
- if (req.headers['sec-fetch-site'] === 'cross-site') {
122
- return reply.code(403).send({ error: 'Cross-site request rejected' })
123
- }
124
- const origin = req.headers.origin
125
- if (!origin) return
126
- const ownOrigin = `${req.protocol}://${req.headers.host}`
127
- if (origin !== ownOrigin && !allowedOrigins?.includes(origin)) {
128
- return reply.code(403).send({ error: 'Origin not allowed' })
129
- }
130
- })
131
-
132
- // JWT
133
- const jwtSecret = resolveJwtSecret()
134
- options.validate?.()
135
- await app.register(jwt, {
136
- secret: jwtSecret,
137
- sign: { expiresIn: JWT_EXPIRES_IN },
138
- })
139
-
140
- // Rate limiting (applied per-route where needed); IPv6 clients are counted per /64
141
- await app.register(rateLimit, { global: false, keyGenerator: (req) => rateLimitKey(req.ip) })
142
-
143
- // Multipart (file uploads)
144
- await app.register(multipart, {
145
- limits: { fileSize: options.uploads.maxFileBytes },
146
- })
147
-
148
- // Serve uploaded files
149
- if (!fs.existsSync(options.uploads.dir)) {
150
- fs.mkdirSync(options.uploads.dir, { recursive: true })
151
- }
152
- await app.register(staticFiles, {
153
- root: options.uploads.dir,
154
- prefix: '/uploads/',
155
- decorateReply: false,
156
- })
157
-
158
- // Security headers
159
- app.addHook('onSend', (req: FastifyRequest, reply, _payload, done) => {
160
- const [requestPath = '', query = ''] = req.url.split('?', 2)
161
- const framed = options.allowSameOriginFraming?.({ path: requestPath, query: new URLSearchParams(query) }) ?? false
162
- reply.header('X-Content-Type-Options', 'nosniff')
163
- reply.header('X-Frame-Options', framed ? 'SAMEORIGIN' : 'DENY')
164
- reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
165
- reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
166
- reply.header('Cross-Origin-Opener-Policy', 'same-origin')
167
- reply.header('Content-Security-Policy', [
168
- "default-src 'self'",
169
- "base-uri 'self'",
170
- "object-src 'none'",
171
- framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
172
- "form-action 'self'",
173
- "script-src 'self'",
174
- "style-src 'self' 'unsafe-inline'",
175
- "font-src 'self'",
176
- "img-src 'self' data: blob:",
177
- "connect-src 'self'",
178
- ].join('; '))
179
- if (process.env['NODE_ENV'] === 'production') {
180
- reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
181
- }
182
- done()
183
- })
184
-
185
- // Initialize DB only when setup has been completed
186
- if (options.database.isReady()) {
187
- options.database.open()
188
- options.database.migrate()
189
- }
190
-
191
- await options.routes(app)
192
-
193
- // Serve built frontend apps in production
194
- if (process.env['NODE_ENV'] === 'production') await options.frontends?.(app)
195
-
196
- /** Takes the lock, listens, and makes the process stop cleanly on SIGTERM and SIGINT. */
197
- async function run(): Promise<void> {
198
- const port = options.port ?? Number(process.env['PORT'] ?? 3000)
199
- const releaseLock = options.acquireLock()
200
- const runtime = createRuntimeShutdown({
201
- stopIntake: () => {
202
- options.stopIntake?.()
203
- options.backgroundServices?.stop()
204
- },
205
- closeServer: () => app.close(),
206
- ...(options.backgroundServices?.drain ? { drain: options.backgroundServices.drain } : {}),
207
- ...(options.shutdownDeadlineMs !== undefined ? { forceExitMs: options.shutdownDeadlineMs } : {}),
208
- releaseResources: () => {
209
- options.database.close()
210
- releaseLock()
211
- },
212
- exit: (code) => process.exit(code),
213
- })
214
- // onClose runs after the HTTP server has drained, so the DB is no longer in use.
215
- app.addHook('onClose', async () => {
216
- runtime.cleanup()
217
- })
218
- try {
219
- await app.listen({ port, host: '0.0.0.0' })
220
- } catch (error) {
221
- runtime.cleanup()
222
- throw error
223
- }
224
- console.log(`✓ ${options.logName ?? 'API'} running on http://localhost:${port}`)
225
-
226
- for (const signal of ['SIGTERM', 'SIGINT'] as const) {
227
- process.once(signal, runtime.shutdown)
228
- }
229
-
230
- if (options.database.isReady()) options.backgroundServices?.start()
231
- }
232
-
233
- return { app, run }
234
- }
235
-
236
- export type BetterApp = Awaited<ReturnType<typeof createBetterApp>>
1
+ import fs from 'node:fs'
2
+ import Fastify, { type FastifyInstance, type FastifyRequest } 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
+
15
+ const DEFAULT_JWT_SECRET = 'dev-secret-change-me'
16
+ const EXAMPLE_SECRETS = new Set([
17
+ 'change-me-to-a-long-random-string',
18
+ 'change-me-to-a-64-char-hex-string',
19
+ ])
20
+
21
+ /** How long a signed-in session token lives. */
22
+ export const JWT_EXPIRES_IN = '12h'
23
+
24
+ /** The secret sessions are signed with: from `JWT_SECRET`, which production insists on being a real one. */
25
+ export function resolveJwtSecret(env: NodeJS.ProcessEnv = process.env): string {
26
+ const secret = env['JWT_SECRET']
27
+ if (env['NODE_ENV'] !== 'production') {
28
+ if (!secret) console.warn('JWT_SECRET not set - using insecure default (development only)')
29
+ return secret ?? DEFAULT_JWT_SECRET
30
+ }
31
+ if (!secret || secret.length < 32 || EXAMPLE_SECRETS.has(secret)) {
32
+ throw new Error('JWT_SECRET must be set to a non-default value of at least 32 characters in production')
33
+ }
34
+ return secret
35
+ }
36
+
37
+ export interface BetterAppOptions {
38
+ /** Request URLs are logged with the value of these query parameters replaced (capability tokens in links). */
39
+ redactQueryParams?: readonly string[]
40
+ /** Request paths whose next segment is a capability token, for example `/links/` (the token is not logged). */
41
+ redactPathTokenPrefixes?: readonly string[]
42
+ /** Checks run before anything is created (for example that the encryption key is usable); throw to refuse to start. */
43
+ validate?: () => void
44
+ /** Where uploaded files are kept and served from (`/uploads/`). */
45
+ uploads: { dir: string; maxFileBytes: number }
46
+ /** Whether a response to this request may be shown in a frame of our own origin (anything else is refused). */
47
+ allowSameOriginFraming?: (request: { path: string; query: URLSearchParams }) => boolean
48
+ /** The database is opened only once the application has been set up. */
49
+ database: {
50
+ isReady: () => boolean
51
+ open: () => void
52
+ migrate: () => void
53
+ close: () => void
54
+ }
55
+ /** Registers every route of the application. */
56
+ routes: (app: FastifyInstance) => Promise<void>
57
+ /** Registers the built front-ends; called in production only, after the routes. */
58
+ frontends?: (app: FastifyInstance) => Promise<void>
59
+ /** Takes the lock that keeps a second instance away from the data directory; returns the release. */
60
+ acquireLock: () => () => void
61
+ /** Work that runs only while the application is set up. */
62
+ backgroundServices?: {
63
+ start: () => void
64
+ stop: () => void
65
+ /** Resolves once the work `stop` left running has finished; the shutdown waits for it until its deadline. */
66
+ drain?: () => Promise<void>
67
+ }
68
+ /** Called when the process is asked to stop, before the server drains (close event streams, say). */
69
+ stopIntake?: () => void
70
+ /** How long a stop waits for the server and the background work to drain before it exits anyway. Default 5 seconds. */
71
+ shutdownDeadlineMs?: number
72
+ /** Name used in the line logged when the server is up. */
73
+ logName?: string
74
+ port?: number
75
+ }
76
+
77
+ /**
78
+ * Builds the HTTP application the Better products share: logging that hides credentials, proxy trust, CORS and a
79
+ * cross-site guard, cookies, JWT sessions, rate limiting, uploads, security headers, the database once set up, and the
80
+ * application's routes. `run` then takes the lock, listens, and wires the shutdown that closes everything in order.
81
+ */
82
+ export async function createBetterApp(options: BetterAppOptions) {
83
+ const redaction = { queryParams: options.redactQueryParams, pathTokenPrefixes: options.redactPathTokenPrefixes }
84
+
85
+ const app = Fastify({
86
+ logger: {
87
+ level: 'info',
88
+ redact: {
89
+ paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
90
+ censor: '[redacted]',
91
+ },
92
+ serializers: {
93
+ req: (req) => ({
94
+ method: req.method,
95
+ url: redactUrl(req.url, redaction),
96
+ host: req.host,
97
+ remoteAddress: req.ip,
98
+ ...(req.socket?.remotePort !== undefined ? { remotePort: req.socket.remotePort } : {}),
99
+ }),
100
+ },
101
+ },
102
+ trustProxy: resolveTrustProxy(),
103
+ })
104
+
105
+ app.setErrorHandler(errorHandler)
106
+
107
+ // CORS
108
+ const allowedOrigins = process.env['ALLOWED_ORIGINS']?.split(',').map((o) => o.trim())
109
+ await app.register(cors, {
110
+ origin: allowedOrigins
111
+ ?? (process.env['NODE_ENV'] === 'production' ? false : true),
112
+ credentials: true,
113
+ })
114
+
115
+ await app.register(cookie)
116
+
117
+ // Reject unsafe cross-site browser requests before they reach auth or setup routes.
118
+ app.addHook('onRequest', async (req, reply) => {
119
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return
120
+ if (process.env['NODE_ENV'] !== 'production') return
121
+ if (req.headers['sec-fetch-site'] === 'cross-site') {
122
+ return reply.code(403).send({ error: 'Cross-site request rejected' })
123
+ }
124
+ const origin = req.headers.origin
125
+ if (!origin) return
126
+ const ownOrigin = `${req.protocol}://${req.headers.host}`
127
+ if (origin !== ownOrigin && !allowedOrigins?.includes(origin)) {
128
+ return reply.code(403).send({ error: 'Origin not allowed' })
129
+ }
130
+ })
131
+
132
+ // JWT
133
+ const jwtSecret = resolveJwtSecret()
134
+ options.validate?.()
135
+ await app.register(jwt, {
136
+ secret: jwtSecret,
137
+ sign: { expiresIn: JWT_EXPIRES_IN },
138
+ })
139
+
140
+ // Rate limiting (applied per-route where needed); IPv6 clients are counted per /64
141
+ await app.register(rateLimit, { global: false, keyGenerator: (req) => rateLimitKey(req.ip) })
142
+
143
+ // Multipart (file uploads)
144
+ await app.register(multipart, {
145
+ limits: { fileSize: options.uploads.maxFileBytes },
146
+ })
147
+
148
+ // Serve uploaded files
149
+ if (!fs.existsSync(options.uploads.dir)) {
150
+ fs.mkdirSync(options.uploads.dir, { recursive: true })
151
+ }
152
+ await app.register(staticFiles, {
153
+ root: options.uploads.dir,
154
+ prefix: '/uploads/',
155
+ decorateReply: false,
156
+ })
157
+
158
+ // Security headers
159
+ app.addHook('onSend', (req: FastifyRequest, reply, _payload, done) => {
160
+ const [requestPath = '', query = ''] = req.url.split('?', 2)
161
+ const framed = options.allowSameOriginFraming?.({ path: requestPath, query: new URLSearchParams(query) }) ?? false
162
+ reply.header('X-Content-Type-Options', 'nosniff')
163
+ reply.header('X-Frame-Options', framed ? 'SAMEORIGIN' : 'DENY')
164
+ reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
165
+ reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
166
+ reply.header('Cross-Origin-Opener-Policy', 'same-origin')
167
+ reply.header('Content-Security-Policy', [
168
+ "default-src 'self'",
169
+ "base-uri 'self'",
170
+ "object-src 'none'",
171
+ framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
172
+ "form-action 'self'",
173
+ "script-src 'self'",
174
+ "style-src 'self' 'unsafe-inline'",
175
+ "font-src 'self'",
176
+ "img-src 'self' data: blob:",
177
+ "connect-src 'self'",
178
+ ].join('; '))
179
+ if (process.env['NODE_ENV'] === 'production') {
180
+ reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
181
+ }
182
+ done()
183
+ })
184
+
185
+ // Initialize DB only when setup has been completed
186
+ if (options.database.isReady()) {
187
+ options.database.open()
188
+ options.database.migrate()
189
+ }
190
+
191
+ await options.routes(app)
192
+
193
+ // Serve built frontend apps in production
194
+ if (process.env['NODE_ENV'] === 'production') await options.frontends?.(app)
195
+
196
+ /** Takes the lock, listens, and makes the process stop cleanly on SIGTERM and SIGINT. */
197
+ async function run(): Promise<void> {
198
+ const port = options.port ?? Number(process.env['PORT'] ?? 3000)
199
+ const releaseLock = options.acquireLock()
200
+ const runtime = createRuntimeShutdown({
201
+ stopIntake: () => {
202
+ options.stopIntake?.()
203
+ options.backgroundServices?.stop()
204
+ },
205
+ closeServer: () => app.close(),
206
+ ...(options.backgroundServices?.drain ? { drain: options.backgroundServices.drain } : {}),
207
+ ...(options.shutdownDeadlineMs !== undefined ? { forceExitMs: options.shutdownDeadlineMs } : {}),
208
+ releaseResources: () => {
209
+ options.database.close()
210
+ releaseLock()
211
+ },
212
+ exit: (code) => process.exit(code),
213
+ })
214
+ // onClose runs after the HTTP server has drained, so the DB is no longer in use.
215
+ app.addHook('onClose', async () => {
216
+ runtime.cleanup()
217
+ })
218
+ try {
219
+ await app.listen({ port, host: '0.0.0.0' })
220
+ } catch (error) {
221
+ runtime.cleanup()
222
+ throw error
223
+ }
224
+ console.log(`✓ ${options.logName ?? 'API'} running on http://localhost:${port}`)
225
+
226
+ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
227
+ process.once(signal, runtime.shutdown)
228
+ }
229
+
230
+ if (options.database.isReady()) options.backgroundServices?.start()
231
+ }
232
+
233
+ return { app, run }
234
+ }
235
+
236
+ export type BetterApp = Awaited<ReturnType<typeof createBetterApp>>
package/src/dataPaths.ts CHANGED
@@ -1,48 +1,48 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
-
4
- type Env = NodeJS.ProcessEnv
5
-
6
- /** Where the application keeps its data: `DATA_DIR`, else `./data` under the working directory. */
7
- export function dataDir(env: Env = process.env): string {
8
- return path.resolve(env['DATA_DIR'] ?? path.join(process.cwd(), 'data'))
9
- }
10
-
11
- /** The SQLite file: `DATABASE_PATH`, else `db.sqlite` in the data directory. */
12
- export function databasePath(env: Env = process.env): string {
13
- return path.resolve(env['DATABASE_PATH'] ?? path.join(dataDir(env), 'db.sqlite'))
14
- }
15
-
16
- /** The file that records the first-run setup was completed: `SETUP_CONFIG_PATH`, else `setup.json` in the data directory. */
17
- export function setupConfigPath(env: Env = process.env): string {
18
- return path.resolve(env['SETUP_CONFIG_PATH'] ?? path.join(dataDir(env), 'setup.json'))
19
- }
20
-
21
- /** Where backups are written: `BACKUP_DIR`, else `backups` in the data directory. */
22
- export function backupDir(env: Env = process.env): string {
23
- return path.resolve(env['BACKUP_DIR'] ?? path.join(dataDir(env), 'backups'))
24
- }
25
-
26
- interface SetupConfig {
27
- setupComplete: boolean
28
- dbType: 'sqlite'
29
- }
30
-
31
- export function isSetupComplete(env: Env = process.env): boolean {
32
- try {
33
- const file = setupConfigPath(env)
34
- if (!fs.existsSync(file)) return false
35
- const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')) as SetupConfig
36
- return cfg.setupComplete === true
37
- } catch {
38
- // A marker that exists but cannot be read is not a fresh install: refuse setup rather than reopen it.
39
- return true
40
- }
41
- }
42
-
43
- export function writeSetupComplete(dbType: 'sqlite' = 'sqlite', env: Env = process.env): void {
44
- const file = setupConfigPath(env)
45
- const dir = path.dirname(file)
46
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
47
- fs.writeFileSync(file, JSON.stringify({ setupComplete: true, dbType }, null, 2))
48
- }
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ type Env = NodeJS.ProcessEnv
5
+
6
+ /** Where the application keeps its data: `DATA_DIR`, else `./data` under the working directory. */
7
+ export function dataDir(env: Env = process.env): string {
8
+ return path.resolve(env['DATA_DIR'] ?? path.join(process.cwd(), 'data'))
9
+ }
10
+
11
+ /** The SQLite file: `DATABASE_PATH`, else `db.sqlite` in the data directory. */
12
+ export function databasePath(env: Env = process.env): string {
13
+ return path.resolve(env['DATABASE_PATH'] ?? path.join(dataDir(env), 'db.sqlite'))
14
+ }
15
+
16
+ /** The file that records the first-run setup was completed: `SETUP_CONFIG_PATH`, else `setup.json` in the data directory. */
17
+ export function setupConfigPath(env: Env = process.env): string {
18
+ return path.resolve(env['SETUP_CONFIG_PATH'] ?? path.join(dataDir(env), 'setup.json'))
19
+ }
20
+
21
+ /** Where backups are written: `BACKUP_DIR`, else `backups` in the data directory. */
22
+ export function backupDir(env: Env = process.env): string {
23
+ return path.resolve(env['BACKUP_DIR'] ?? path.join(dataDir(env), 'backups'))
24
+ }
25
+
26
+ interface SetupConfig {
27
+ setupComplete: boolean
28
+ dbType: 'sqlite'
29
+ }
30
+
31
+ export function isSetupComplete(env: Env = process.env): boolean {
32
+ try {
33
+ const file = setupConfigPath(env)
34
+ if (!fs.existsSync(file)) return false
35
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')) as SetupConfig
36
+ return cfg.setupComplete === true
37
+ } catch {
38
+ // A marker that exists but cannot be read is not a fresh install: refuse setup rather than reopen it.
39
+ return true
40
+ }
41
+ }
42
+
43
+ export function writeSetupComplete(dbType: 'sqlite' = 'sqlite', env: Env = process.env): void {
44
+ const file = setupConfigPath(env)
45
+ const dir = path.dirname(file)
46
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
47
+ fs.writeFileSync(file, JSON.stringify({ setupComplete: true, dbType }, null, 2))
48
+ }
@@ -1,15 +1,15 @@
1
- import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
2
-
3
- /**
4
- * Unexpected failures are logged in full but never echoed to the client: driver errors carry the
5
- * SQL and its parameters, which can include encrypted secrets. Client errors (4xx) keep their
6
- * message in the `{ error }` shape the admin and status clients read.
7
- */
8
- export function errorHandler(error: FastifyError, req: FastifyRequest, reply: FastifyReply) {
9
- const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500
10
- if (statusCode >= 500) {
11
- req.log.error({ err: error }, 'Unhandled request error')
12
- return reply.code(statusCode).send({ error: 'Internal Server Error' })
13
- }
14
- return reply.code(statusCode).send({ error: error.message })
15
- }
1
+ import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
2
+
3
+ /**
4
+ * Unexpected failures are logged in full but never echoed to the client: driver errors carry the
5
+ * SQL and its parameters, which can include encrypted secrets. Client errors (4xx) keep their
6
+ * message in the `{ error }` shape the admin and status clients read.
7
+ */
8
+ export function errorHandler(error: FastifyError, req: FastifyRequest, reply: FastifyReply) {
9
+ const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500
10
+ if (statusCode >= 500) {
11
+ req.log.error({ err: error }, 'Unhandled request error')
12
+ return reply.code(statusCode).send({ error: 'Internal Server Error' })
13
+ }
14
+ return reply.code(statusCode).send({ error: error.message })
15
+ }