@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.
- package/LICENSE +21 -21
- package/README.md +9 -9
- package/package.json +65 -65
- package/src/appLock.ts +63 -63
- package/src/createBetterApp.ts +236 -236
- package/src/dataPaths.ts +48 -48
- package/src/errorHandler.ts +15 -15
- package/src/health.ts +109 -109
- package/src/index.ts +14 -14
- package/src/processLiveness.ts +31 -31
- package/src/proxy.ts +20 -20
- package/src/publicUrl.ts +26 -26
- package/src/rateLimitKey.ts +20 -20
- package/src/redactUrl.ts +49 -49
- package/src/shutdown.ts +62 -62
- package/src/sse.ts +154 -154
- package/src/updateCheck.ts +107 -107
- package/src/version.ts +7 -7
package/src/health.ts
CHANGED
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
import type { FastifyInstance } from 'fastify'
|
|
2
|
-
import { sql } from 'drizzle-orm'
|
|
3
|
-
import type { SqliteDatabase } from '@better-forge/db'
|
|
4
|
-
import type { LatestRelease } from './updateCheck.js'
|
|
5
|
-
|
|
6
|
-
/** `GET /health` (the process is up) and `GET /ready` (set up and the database answers), for load balancers and probes. */
|
|
7
|
-
export function createHealthRoutes(options: { db: SqliteDatabase; isSetupComplete: () => boolean }) {
|
|
8
|
-
return async function healthRoutes(app: FastifyInstance) {
|
|
9
|
-
app.get('/health', async () => ({ status: 'ok' }))
|
|
10
|
-
|
|
11
|
-
app.get('/ready', async (_req, reply) => {
|
|
12
|
-
if (!options.isSetupComplete()) {
|
|
13
|
-
return reply.code(503).send({ status: 'not_ready' })
|
|
14
|
-
}
|
|
15
|
-
try {
|
|
16
|
-
await options.db.run(sql`SELECT 1`)
|
|
17
|
-
return { status: 'ready' }
|
|
18
|
-
} catch {
|
|
19
|
-
return reply.code(503).send({ status: 'not_ready' })
|
|
20
|
-
}
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type ComponentStatus = 'ok' | 'attention' | 'error' | 'disabled'
|
|
26
|
-
|
|
27
|
-
/** One part of the system the report describes. Its `status` is what the overall status is decided by. */
|
|
28
|
-
export type HealthSection = { status: ComponentStatus } & Record<string, unknown>
|
|
29
|
-
|
|
30
|
-
export interface HealthContributor {
|
|
31
|
-
/** The key of the section in the report. */
|
|
32
|
-
key: string
|
|
33
|
-
/**
|
|
34
|
-
* When true, a section in the `error` status makes the whole report `degraded`; otherwise it is only shown.
|
|
35
|
-
*/
|
|
36
|
-
critical: boolean
|
|
37
|
-
/** Builds the section. Called after the database was probed; `now` is the time the report is made for. */
|
|
38
|
-
collect(context: { now: number; databaseOk: boolean }): Promise<HealthSection> | HealthSection
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface SystemHealthReport {
|
|
42
|
-
status: 'healthy' | 'degraded'
|
|
43
|
-
generatedAt: number
|
|
44
|
-
application: {
|
|
45
|
-
status: 'ok'
|
|
46
|
-
version: string
|
|
47
|
-
uptimeSeconds: number
|
|
48
|
-
/** A newer published release, or null when up to date, unknown or the update check is off. */
|
|
49
|
-
update: LatestRelease | null
|
|
50
|
-
}
|
|
51
|
-
database: {
|
|
52
|
-
status: 'ok' | 'error'
|
|
53
|
-
responseMs: number | null
|
|
54
|
-
}
|
|
55
|
-
/** The sections of the contributors, by key. */
|
|
56
|
-
[section: string]: unknown
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The protected report of how the instance is doing: the process, the database, and one section per contributor the
|
|
61
|
-
* application registers (a scheduler, a queue, backups, ...). Exposes state, not raw errors.
|
|
62
|
-
*/
|
|
63
|
-
export function createSystemHealth(options: {
|
|
64
|
-
db: SqliteDatabase
|
|
65
|
-
version: () => string
|
|
66
|
-
update: () => LatestRelease | null
|
|
67
|
-
contributors: readonly HealthContributor[]
|
|
68
|
-
}) {
|
|
69
|
-
async function getSystemHealthReport(now = Date.now()): Promise<SystemHealthReport> {
|
|
70
|
-
const databaseStartedAt = Date.now()
|
|
71
|
-
let database: SystemHealthReport['database'] = { status: 'error', responseMs: null }
|
|
72
|
-
try {
|
|
73
|
-
await options.db.run(sql`SELECT 1`)
|
|
74
|
-
database = { status: 'ok', responseMs: Date.now() - databaseStartedAt }
|
|
75
|
-
} catch {
|
|
76
|
-
// The protected report deliberately exposes state, not raw database errors.
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const sections: Record<string, HealthSection> = {}
|
|
80
|
-
for (const contributor of options.contributors) {
|
|
81
|
-
sections[contributor.key] = await contributor.collect({ now, databaseOk: database.status === 'ok' })
|
|
82
|
-
}
|
|
83
|
-
const failing = options.contributors.some((contributor) => contributor.critical && sections[contributor.key]!.status === 'error')
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
status: database.status === 'ok' && !failing ? 'healthy' : 'degraded',
|
|
87
|
-
generatedAt: now,
|
|
88
|
-
application: { status: 'ok', version: options.version(), uptimeSeconds: Math.floor(process.uptime()), update: options.update() },
|
|
89
|
-
database,
|
|
90
|
-
...sections,
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Routes: the report at `/` and, at `/update`, only the running version and a newer release, if any. */
|
|
95
|
-
async function systemHealthRoutes(app: FastifyInstance) {
|
|
96
|
-
app.get('/', async (_request, reply) => {
|
|
97
|
-
reply.header('Cache-Control', 'no-store')
|
|
98
|
-
return getSystemHealthReport()
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
/** Cheap enough for a dashboard to ask on every visit. */
|
|
102
|
-
app.get('/update', async (_request, reply) => {
|
|
103
|
-
reply.header('Cache-Control', 'no-store')
|
|
104
|
-
return { version: options.version(), update: options.update() }
|
|
105
|
-
})
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return { getSystemHealthReport, systemHealthRoutes }
|
|
109
|
-
}
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
|
+
import { sql } from 'drizzle-orm'
|
|
3
|
+
import type { SqliteDatabase } from '@better-forge/db'
|
|
4
|
+
import type { LatestRelease } from './updateCheck.js'
|
|
5
|
+
|
|
6
|
+
/** `GET /health` (the process is up) and `GET /ready` (set up and the database answers), for load balancers and probes. */
|
|
7
|
+
export function createHealthRoutes(options: { db: SqliteDatabase; isSetupComplete: () => boolean }) {
|
|
8
|
+
return async function healthRoutes(app: FastifyInstance) {
|
|
9
|
+
app.get('/health', async () => ({ status: 'ok' }))
|
|
10
|
+
|
|
11
|
+
app.get('/ready', async (_req, reply) => {
|
|
12
|
+
if (!options.isSetupComplete()) {
|
|
13
|
+
return reply.code(503).send({ status: 'not_ready' })
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
await options.db.run(sql`SELECT 1`)
|
|
17
|
+
return { status: 'ready' }
|
|
18
|
+
} catch {
|
|
19
|
+
return reply.code(503).send({ status: 'not_ready' })
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ComponentStatus = 'ok' | 'attention' | 'error' | 'disabled'
|
|
26
|
+
|
|
27
|
+
/** One part of the system the report describes. Its `status` is what the overall status is decided by. */
|
|
28
|
+
export type HealthSection = { status: ComponentStatus } & Record<string, unknown>
|
|
29
|
+
|
|
30
|
+
export interface HealthContributor {
|
|
31
|
+
/** The key of the section in the report. */
|
|
32
|
+
key: string
|
|
33
|
+
/**
|
|
34
|
+
* When true, a section in the `error` status makes the whole report `degraded`; otherwise it is only shown.
|
|
35
|
+
*/
|
|
36
|
+
critical: boolean
|
|
37
|
+
/** Builds the section. Called after the database was probed; `now` is the time the report is made for. */
|
|
38
|
+
collect(context: { now: number; databaseOk: boolean }): Promise<HealthSection> | HealthSection
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SystemHealthReport {
|
|
42
|
+
status: 'healthy' | 'degraded'
|
|
43
|
+
generatedAt: number
|
|
44
|
+
application: {
|
|
45
|
+
status: 'ok'
|
|
46
|
+
version: string
|
|
47
|
+
uptimeSeconds: number
|
|
48
|
+
/** A newer published release, or null when up to date, unknown or the update check is off. */
|
|
49
|
+
update: LatestRelease | null
|
|
50
|
+
}
|
|
51
|
+
database: {
|
|
52
|
+
status: 'ok' | 'error'
|
|
53
|
+
responseMs: number | null
|
|
54
|
+
}
|
|
55
|
+
/** The sections of the contributors, by key. */
|
|
56
|
+
[section: string]: unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The protected report of how the instance is doing: the process, the database, and one section per contributor the
|
|
61
|
+
* application registers (a scheduler, a queue, backups, ...). Exposes state, not raw errors.
|
|
62
|
+
*/
|
|
63
|
+
export function createSystemHealth(options: {
|
|
64
|
+
db: SqliteDatabase
|
|
65
|
+
version: () => string
|
|
66
|
+
update: () => LatestRelease | null
|
|
67
|
+
contributors: readonly HealthContributor[]
|
|
68
|
+
}) {
|
|
69
|
+
async function getSystemHealthReport(now = Date.now()): Promise<SystemHealthReport> {
|
|
70
|
+
const databaseStartedAt = Date.now()
|
|
71
|
+
let database: SystemHealthReport['database'] = { status: 'error', responseMs: null }
|
|
72
|
+
try {
|
|
73
|
+
await options.db.run(sql`SELECT 1`)
|
|
74
|
+
database = { status: 'ok', responseMs: Date.now() - databaseStartedAt }
|
|
75
|
+
} catch {
|
|
76
|
+
// The protected report deliberately exposes state, not raw database errors.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sections: Record<string, HealthSection> = {}
|
|
80
|
+
for (const contributor of options.contributors) {
|
|
81
|
+
sections[contributor.key] = await contributor.collect({ now, databaseOk: database.status === 'ok' })
|
|
82
|
+
}
|
|
83
|
+
const failing = options.contributors.some((contributor) => contributor.critical && sections[contributor.key]!.status === 'error')
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
status: database.status === 'ok' && !failing ? 'healthy' : 'degraded',
|
|
87
|
+
generatedAt: now,
|
|
88
|
+
application: { status: 'ok', version: options.version(), uptimeSeconds: Math.floor(process.uptime()), update: options.update() },
|
|
89
|
+
database,
|
|
90
|
+
...sections,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Routes: the report at `/` and, at `/update`, only the running version and a newer release, if any. */
|
|
95
|
+
async function systemHealthRoutes(app: FastifyInstance) {
|
|
96
|
+
app.get('/', async (_request, reply) => {
|
|
97
|
+
reply.header('Cache-Control', 'no-store')
|
|
98
|
+
return getSystemHealthReport()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** Cheap enough for a dashboard to ask on every visit. */
|
|
102
|
+
app.get('/update', async (_request, reply) => {
|
|
103
|
+
reply.header('Cache-Control', 'no-store')
|
|
104
|
+
return { version: options.version(), update: options.update() }
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { getSystemHealthReport, systemHealthRoutes }
|
|
109
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
export { backupDir, dataDir, databasePath, isSetupComplete, setupConfigPath, writeSetupComplete } from './dataPaths.js'
|
|
2
|
-
export { resolveTrustProxy, type TrustProxy } from './proxy.js'
|
|
3
|
-
export { rateLimitKey } from './rateLimitKey.js'
|
|
4
|
-
export { createPublicUrlResolver } from './publicUrl.js'
|
|
5
|
-
export { errorHandler } from './errorHandler.js'
|
|
6
|
-
export { createAppLock } from './appLock.js'
|
|
7
|
-
export { isOwnerAlive, isProcessAlive, currentProcessOwner, PROCESS_STARTED_AT, type ProcessOwner } from './processLiveness.js'
|
|
8
|
-
export { createRuntimeShutdown, type RuntimeShutdown, type ShutdownSteps } from './shutdown.js'
|
|
9
|
-
export { createSse, type ClientInfo, type EventStreamOptions, type SseFilter, type SseLimits } from './sse.js'
|
|
10
|
-
export { createUpdateCheck, isNewerVersion, updateCheckEnabled, type LatestRelease } from './updateCheck.js'
|
|
11
|
-
export { createHealthRoutes, createSystemHealth, type ComponentStatus, type HealthContributor, type HealthSection, type SystemHealthReport } from './health.js'
|
|
12
|
-
export { resolveAppVersion } from './version.js'
|
|
13
|
-
export { createBetterApp, JWT_EXPIRES_IN, resolveJwtSecret, type BetterApp, type BetterAppOptions } from './createBetterApp.js'
|
|
14
|
-
export { redactUrl, type UrlRedaction } from './redactUrl.js'
|
|
1
|
+
export { backupDir, dataDir, databasePath, isSetupComplete, setupConfigPath, writeSetupComplete } from './dataPaths.js'
|
|
2
|
+
export { resolveTrustProxy, type TrustProxy } from './proxy.js'
|
|
3
|
+
export { rateLimitKey } from './rateLimitKey.js'
|
|
4
|
+
export { createPublicUrlResolver } from './publicUrl.js'
|
|
5
|
+
export { errorHandler } from './errorHandler.js'
|
|
6
|
+
export { createAppLock } from './appLock.js'
|
|
7
|
+
export { isOwnerAlive, isProcessAlive, currentProcessOwner, PROCESS_STARTED_AT, type ProcessOwner } from './processLiveness.js'
|
|
8
|
+
export { createRuntimeShutdown, type RuntimeShutdown, type ShutdownSteps } from './shutdown.js'
|
|
9
|
+
export { createSse, type ClientInfo, type EventStreamOptions, type SseFilter, type SseLimits } from './sse.js'
|
|
10
|
+
export { createUpdateCheck, isNewerVersion, updateCheckEnabled, type LatestRelease } from './updateCheck.js'
|
|
11
|
+
export { createHealthRoutes, createSystemHealth, type ComponentStatus, type HealthContributor, type HealthSection, type SystemHealthReport } from './health.js'
|
|
12
|
+
export { resolveAppVersion } from './version.js'
|
|
13
|
+
export { createBetterApp, JWT_EXPIRES_IN, resolveJwtSecret, type BetterApp, type BetterAppOptions } from './createBetterApp.js'
|
|
14
|
+
export { redactUrl, type UrlRedaction } from './redactUrl.js'
|
package/src/processLiveness.ts
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
/** Wall-clock start of this process; identical in the main thread and in worker threads. */
|
|
2
|
-
export const PROCESS_STARTED_AT = Math.round(Date.now() - process.uptime() * 1000)
|
|
3
|
-
|
|
4
|
-
/** Signal 0 checks existence only; EPERM still means the process exists under another user. */
|
|
5
|
-
export function isProcessAlive(pid: number): boolean {
|
|
6
|
-
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
7
|
-
try {
|
|
8
|
-
process.kill(pid, 0)
|
|
9
|
-
return true
|
|
10
|
-
} catch (error) {
|
|
11
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface ProcessOwner { pid: number; processStartedAt: number }
|
|
16
|
-
|
|
17
|
-
export function currentProcessOwner(): ProcessOwner {
|
|
18
|
-
return { pid: process.pid, processStartedAt: PROCESS_STARTED_AT }
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Whether the recorded owner may still be running. The start time distinguishes a crashed
|
|
23
|
-
* earlier incarnation that reused our pid (containers restart as the same pid) from us.
|
|
24
|
-
*/
|
|
25
|
-
export function isOwnerAlive(owner: Partial<ProcessOwner>): boolean {
|
|
26
|
-
if (typeof owner.pid !== 'number') return false
|
|
27
|
-
if (owner.pid === process.pid) {
|
|
28
|
-
return typeof owner.processStartedAt === 'number' && Math.abs(owner.processStartedAt - PROCESS_STARTED_AT) < 1_000
|
|
29
|
-
}
|
|
30
|
-
return isProcessAlive(owner.pid)
|
|
31
|
-
}
|
|
1
|
+
/** Wall-clock start of this process; identical in the main thread and in worker threads. */
|
|
2
|
+
export const PROCESS_STARTED_AT = Math.round(Date.now() - process.uptime() * 1000)
|
|
3
|
+
|
|
4
|
+
/** Signal 0 checks existence only; EPERM still means the process exists under another user. */
|
|
5
|
+
export function isProcessAlive(pid: number): boolean {
|
|
6
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0)
|
|
9
|
+
return true
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProcessOwner { pid: number; processStartedAt: number }
|
|
16
|
+
|
|
17
|
+
export function currentProcessOwner(): ProcessOwner {
|
|
18
|
+
return { pid: process.pid, processStartedAt: PROCESS_STARTED_AT }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the recorded owner may still be running. The start time distinguishes a crashed
|
|
23
|
+
* earlier incarnation that reused our pid (containers restart as the same pid) from us.
|
|
24
|
+
*/
|
|
25
|
+
export function isOwnerAlive(owner: Partial<ProcessOwner>): boolean {
|
|
26
|
+
if (typeof owner.pid !== 'number') return false
|
|
27
|
+
if (owner.pid === process.pid) {
|
|
28
|
+
return typeof owner.processStartedAt === 'number' && Math.abs(owner.processStartedAt - PROCESS_STARTED_AT) < 1_000
|
|
29
|
+
}
|
|
30
|
+
return isProcessAlive(owner.pid)
|
|
31
|
+
}
|
package/src/proxy.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import type { FastifyServerOptions } from 'fastify'
|
|
2
|
-
|
|
3
|
-
export type TrustProxy = NonNullable<FastifyServerOptions['trustProxy']>
|
|
4
|
-
|
|
5
|
-
export function resolveTrustProxy(raw = process.env['TRUST_PROXY']): TrustProxy {
|
|
6
|
-
const value = raw?.trim()
|
|
7
|
-
if (!value || value.toLowerCase() === 'false') return false
|
|
8
|
-
if (value.toLowerCase() === 'true') return true
|
|
9
|
-
if (/^\d+$/.test(value)) {
|
|
10
|
-
// Hop-count-only trust can't validate the immediate peer, so @fastify/proxy-addr
|
|
11
|
-
// no longer supports it (it always fails closed). Treat a numeric value as "trust a
|
|
12
|
-
// proxy on this host or on a private network": loopback covers a proxy on the same
|
|
13
|
-
// host, uniquelocal (10/8, 172.16/12, 192.168/16, fc00::/7) covers one reaching the
|
|
14
|
-
// app over a Docker bridge network. Use an explicit address/CIDR list to narrow it.
|
|
15
|
-
return ['loopback', 'uniquelocal']
|
|
16
|
-
}
|
|
17
|
-
const addresses = value.split(',').map((address) => address.trim()).filter(Boolean)
|
|
18
|
-
if (addresses.length === 0) return false
|
|
19
|
-
return addresses.length === 1 ? addresses[0]! : addresses
|
|
20
|
-
}
|
|
1
|
+
import type { FastifyServerOptions } from 'fastify'
|
|
2
|
+
|
|
3
|
+
export type TrustProxy = NonNullable<FastifyServerOptions['trustProxy']>
|
|
4
|
+
|
|
5
|
+
export function resolveTrustProxy(raw = process.env['TRUST_PROXY']): TrustProxy {
|
|
6
|
+
const value = raw?.trim()
|
|
7
|
+
if (!value || value.toLowerCase() === 'false') return false
|
|
8
|
+
if (value.toLowerCase() === 'true') return true
|
|
9
|
+
if (/^\d+$/.test(value)) {
|
|
10
|
+
// Hop-count-only trust can't validate the immediate peer, so @fastify/proxy-addr
|
|
11
|
+
// no longer supports it (it always fails closed). Treat a numeric value as "trust a
|
|
12
|
+
// proxy on this host or on a private network": loopback covers a proxy on the same
|
|
13
|
+
// host, uniquelocal (10/8, 172.16/12, 192.168/16, fc00::/7) covers one reaching the
|
|
14
|
+
// app over a Docker bridge network. Use an explicit address/CIDR list to narrow it.
|
|
15
|
+
return ['loopback', 'uniquelocal']
|
|
16
|
+
}
|
|
17
|
+
const addresses = value.split(',').map((address) => address.trim()).filter(Boolean)
|
|
18
|
+
if (addresses.length === 0) return false
|
|
19
|
+
return addresses.length === 1 ? addresses[0]! : addresses
|
|
20
|
+
}
|
package/src/publicUrl.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The absolute base URL the application is reached at, from the `PUBLIC_URL` environment variable.
|
|
3
|
-
*
|
|
4
|
-
* It is deployment configuration, not an admin setting: links in emails and redirects are built from it, so changing
|
|
5
|
-
* it must require access to the server — an account in the admin console alone must not be able to point those links
|
|
6
|
-
* at another domain.
|
|
7
|
-
*
|
|
8
|
-
* The returned function reports an invalid value once per distinct value, then keeps answering ''.
|
|
9
|
-
*/
|
|
10
|
-
export function createPublicUrlResolver() {
|
|
11
|
-
let warnedAbout: string | null = null
|
|
12
|
-
|
|
13
|
-
return function resolvePublicUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
14
|
-
const raw = (env['PUBLIC_URL'] ?? '').trim().replace(/\/+$/, '')
|
|
15
|
-
if (!raw) return ''
|
|
16
|
-
try {
|
|
17
|
-
const url = new URL(raw)
|
|
18
|
-
if ((url.protocol === 'https:' || url.protocol === 'http:') && !url.search && !url.hash) return raw
|
|
19
|
-
} catch { /* reported below */ }
|
|
20
|
-
if (warnedAbout !== raw) {
|
|
21
|
-
warnedAbout = raw
|
|
22
|
-
console.warn(`PUBLIC_URL "${raw}" is not an absolute http(s) URL without query or fragment — ignoring it`)
|
|
23
|
-
}
|
|
24
|
-
return ''
|
|
25
|
-
}
|
|
26
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The absolute base URL the application is reached at, from the `PUBLIC_URL` environment variable.
|
|
3
|
+
*
|
|
4
|
+
* It is deployment configuration, not an admin setting: links in emails and redirects are built from it, so changing
|
|
5
|
+
* it must require access to the server — an account in the admin console alone must not be able to point those links
|
|
6
|
+
* at another domain.
|
|
7
|
+
*
|
|
8
|
+
* The returned function reports an invalid value once per distinct value, then keeps answering ''.
|
|
9
|
+
*/
|
|
10
|
+
export function createPublicUrlResolver() {
|
|
11
|
+
let warnedAbout: string | null = null
|
|
12
|
+
|
|
13
|
+
return function resolvePublicUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
14
|
+
const raw = (env['PUBLIC_URL'] ?? '').trim().replace(/\/+$/, '')
|
|
15
|
+
if (!raw) return ''
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(raw)
|
|
18
|
+
if ((url.protocol === 'https:' || url.protocol === 'http:') && !url.search && !url.hash) return raw
|
|
19
|
+
} catch { /* reported below */ }
|
|
20
|
+
if (warnedAbout !== raw) {
|
|
21
|
+
warnedAbout = raw
|
|
22
|
+
console.warn(`PUBLIC_URL "${raw}" is not an absolute http(s) URL without query or fragment — ignoring it`)
|
|
23
|
+
}
|
|
24
|
+
return ''
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/rateLimitKey.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { isIPv6 } from 'node:net'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The key a client is rate limited under. An IPv4 address is its own key; an IPv6 address is
|
|
5
|
-
* reduced to its /64, because one subscriber is routinely handed a whole /64 and could otherwise
|
|
6
|
-
* rotate through billions of addresses to dodge a per-address limit.
|
|
7
|
-
*/
|
|
8
|
-
export function rateLimitKey(ip: string): string {
|
|
9
|
-
const address = ip.split('%')[0] ?? ip
|
|
10
|
-
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)
|
|
11
|
-
if (mapped) return mapped[1]!
|
|
12
|
-
if (!isIPv6(address)) return address
|
|
13
|
-
|
|
14
|
-
const [head = '', tail] = address.split('::')
|
|
15
|
-
const front = head ? head.split(':') : []
|
|
16
|
-
const back = tail ? tail.split(':') : []
|
|
17
|
-
const groups = tail === undefined ? front : [...front, ...Array<string>(8 - front.length - back.length).fill('0'), ...back]
|
|
18
|
-
const prefix = groups.slice(0, 4).map((group) => parseInt(group, 16).toString(16))
|
|
19
|
-
return `${prefix.join(':')}::/64`
|
|
20
|
-
}
|
|
1
|
+
import { isIPv6 } from 'node:net'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The key a client is rate limited under. An IPv4 address is its own key; an IPv6 address is
|
|
5
|
+
* reduced to its /64, because one subscriber is routinely handed a whole /64 and could otherwise
|
|
6
|
+
* rotate through billions of addresses to dodge a per-address limit.
|
|
7
|
+
*/
|
|
8
|
+
export function rateLimitKey(ip: string): string {
|
|
9
|
+
const address = ip.split('%')[0] ?? ip
|
|
10
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)
|
|
11
|
+
if (mapped) return mapped[1]!
|
|
12
|
+
if (!isIPv6(address)) return address
|
|
13
|
+
|
|
14
|
+
const [head = '', tail] = address.split('::')
|
|
15
|
+
const front = head ? head.split(':') : []
|
|
16
|
+
const back = tail ? tail.split(':') : []
|
|
17
|
+
const groups = tail === undefined ? front : [...front, ...Array<string>(8 - front.length - back.length).fill('0'), ...back]
|
|
18
|
+
const prefix = groups.slice(0, 4).map((group) => parseInt(group, 16).toString(16))
|
|
19
|
+
return `${prefix.join(':')}::/64`
|
|
20
|
+
}
|
package/src/redactUrl.ts
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
export interface UrlRedaction {
|
|
2
|
-
/** Query parameters whose value is replaced. Names are compared after decoding, ignoring case. */
|
|
3
|
-
queryParams?: readonly string[] | undefined
|
|
4
|
-
/** Paths whose next segment is a capability token (`/t/` hides `abc` in `/t/abc?x=1`), matched after percent-decoding as the router does. */
|
|
5
|
-
pathTokenPrefixes?: readonly string[] | undefined
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const REDACTED = '[redacted]'
|
|
9
|
-
|
|
10
|
-
function decodeName(name: string): string {
|
|
11
|
-
try { return decodeURIComponent(name.replace(/\+/g, ' ')) } catch { return name }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The router decodes the path before it matches a route (`/%68ook/` reaches `/hook/`), but keeps an encoded slash.
|
|
16
|
-
* Each segment is decoded on its own, so one malformed sequence leaves only that segment as it was sent.
|
|
17
|
-
*/
|
|
18
|
-
function decodePath(path: string): string {
|
|
19
|
-
return path.split('/').map((segment) => {
|
|
20
|
-
try { return decodeURI(segment) } catch { return segment }
|
|
21
|
-
}).join('/')
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** The URL as it may be logged: capability tokens in the path and the values of secret query parameters are hidden. */
|
|
25
|
-
export function redactUrl(url: string, rules: UrlRedaction): string {
|
|
26
|
-
const queryAt = url.indexOf('?')
|
|
27
|
-
let path = queryAt === -1 ? url : url.slice(0, queryAt)
|
|
28
|
-
let query = queryAt === -1 ? '' : url.slice(queryAt + 1)
|
|
29
|
-
|
|
30
|
-
const decoded = decodePath(path)
|
|
31
|
-
for (const prefix of rules.pathTokenPrefixes ?? []) {
|
|
32
|
-
// Everything from the token on is dropped: the rest of a path is not decoded text a log should repeat.
|
|
33
|
-
if (decoded.startsWith(prefix) && decoded.length > prefix.length) {
|
|
34
|
-
path = `${prefix}${REDACTED}`
|
|
35
|
-
break
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const names = (rules.queryParams ?? []).map((name) => name.toLowerCase())
|
|
40
|
-
if (names.length > 0 && query) {
|
|
41
|
-
query = query.split('&').map((pair) => {
|
|
42
|
-
const equals = pair.indexOf('=')
|
|
43
|
-
if (equals === -1) return pair
|
|
44
|
-
return names.includes(decodeName(pair.slice(0, equals)).toLowerCase()) ? `${pair.slice(0, equals + 1)}${REDACTED}` : pair
|
|
45
|
-
}).join('&')
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return queryAt === -1 ? path : `${path}?${query}`
|
|
49
|
-
}
|
|
1
|
+
export interface UrlRedaction {
|
|
2
|
+
/** Query parameters whose value is replaced. Names are compared after decoding, ignoring case. */
|
|
3
|
+
queryParams?: readonly string[] | undefined
|
|
4
|
+
/** Paths whose next segment is a capability token (`/t/` hides `abc` in `/t/abc?x=1`), matched after percent-decoding as the router does. */
|
|
5
|
+
pathTokenPrefixes?: readonly string[] | undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const REDACTED = '[redacted]'
|
|
9
|
+
|
|
10
|
+
function decodeName(name: string): string {
|
|
11
|
+
try { return decodeURIComponent(name.replace(/\+/g, ' ')) } catch { return name }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The router decodes the path before it matches a route (`/%68ook/` reaches `/hook/`), but keeps an encoded slash.
|
|
16
|
+
* Each segment is decoded on its own, so one malformed sequence leaves only that segment as it was sent.
|
|
17
|
+
*/
|
|
18
|
+
function decodePath(path: string): string {
|
|
19
|
+
return path.split('/').map((segment) => {
|
|
20
|
+
try { return decodeURI(segment) } catch { return segment }
|
|
21
|
+
}).join('/')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The URL as it may be logged: capability tokens in the path and the values of secret query parameters are hidden. */
|
|
25
|
+
export function redactUrl(url: string, rules: UrlRedaction): string {
|
|
26
|
+
const queryAt = url.indexOf('?')
|
|
27
|
+
let path = queryAt === -1 ? url : url.slice(0, queryAt)
|
|
28
|
+
let query = queryAt === -1 ? '' : url.slice(queryAt + 1)
|
|
29
|
+
|
|
30
|
+
const decoded = decodePath(path)
|
|
31
|
+
for (const prefix of rules.pathTokenPrefixes ?? []) {
|
|
32
|
+
// Everything from the token on is dropped: the rest of a path is not decoded text a log should repeat.
|
|
33
|
+
if (decoded.startsWith(prefix) && decoded.length > prefix.length) {
|
|
34
|
+
path = `${prefix}${REDACTED}`
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const names = (rules.queryParams ?? []).map((name) => name.toLowerCase())
|
|
40
|
+
if (names.length > 0 && query) {
|
|
41
|
+
query = query.split('&').map((pair) => {
|
|
42
|
+
const equals = pair.indexOf('=')
|
|
43
|
+
if (equals === -1) return pair
|
|
44
|
+
return names.includes(decodeName(pair.slice(0, equals)).toLowerCase()) ? `${pair.slice(0, equals + 1)}${REDACTED}` : pair
|
|
45
|
+
}).join('&')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return queryAt === -1 ? path : `${path}?${query}`
|
|
49
|
+
}
|