@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/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'
@@ -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
+ }
@@ -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
+ }