@open-xchange/fastify-sdk 0.1.0
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/README.md +370 -0
- package/lib/app.js +324 -0
- package/lib/config/index.js +4 -0
- package/lib/config/read.js +38 -0
- package/lib/config/registry.js +57 -0
- package/lib/config/util.js +28 -0
- package/lib/database/migrations.js +37 -0
- package/lib/database/mysql.js +81 -0
- package/lib/database/postgres.js +34 -0
- package/lib/dotenv.js +6 -0
- package/lib/health.js +36 -0
- package/lib/index.js +28 -0
- package/lib/lint/index.js +5 -0
- package/lib/logger.js +60 -0
- package/lib/metrics-server.js +33 -0
- package/lib/plugins/cors.js +16 -0
- package/lib/plugins/helmet.js +14 -0
- package/lib/plugins/jwt.js +88 -0
- package/lib/plugins/logging.js +15 -0
- package/lib/plugins/metrics.js +8 -0
- package/lib/plugins/swagger.js +31 -0
- package/lib/redis/index.js +58 -0
- package/lib/testing/index.js +32 -0
- package/lib/testing/jwt.js +17 -0
- package/package.json +85 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import yaml from 'js-yaml'
|
|
4
|
+
|
|
5
|
+
export function getConfigPath () {
|
|
6
|
+
return process.env.CONFIG_PATH || '/app/config'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function fileExists (filename = '') {
|
|
10
|
+
try {
|
|
11
|
+
await access(`${getConfigPath()}/${filename}`, constants.F_OK)
|
|
12
|
+
return true
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readConfigurationFile (filename = '', schema = null, callback = () => {}, { logger } = {}) {
|
|
19
|
+
const content = await readFile(`${getConfigPath()}/${filename}`, 'utf8')
|
|
20
|
+
try {
|
|
21
|
+
const data = await validateAndUpdateConfiguration(content, schema)
|
|
22
|
+
if (logger) logger.info(`Config "${filename}" has been read/refreshed`)
|
|
23
|
+
try {
|
|
24
|
+
callback(data)
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (logger) logger.error({ err }, `Runtime error in callback for "${filename}": ${err.message}`)
|
|
27
|
+
}
|
|
28
|
+
return data
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (logger) logger.error({ err }, `Failed to parse/validate configuration from "${filename}": ${err.message}`)
|
|
31
|
+
throw err
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function validateAndUpdateConfiguration (content = '', schema = null) {
|
|
36
|
+
const parsed = yaml.load(content)
|
|
37
|
+
return schema ? schema.validateAsync(parsed) : parsed
|
|
38
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chokidar from 'chokidar'
|
|
2
|
+
import { readConfigurationFile, getConfigPath, fileExists } from './read.js'
|
|
3
|
+
|
|
4
|
+
export function createConfigRegistry ({ logger, onShutdown } = {}) {
|
|
5
|
+
const configPath = getConfigPath()
|
|
6
|
+
const watcher = chokidar.watch(configPath, { persistent: true })
|
|
7
|
+
const current = {}
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
watcher.on('ready', async () => {
|
|
11
|
+
const watchedPaths = await watcher.getWatched()
|
|
12
|
+
if (logger) logger.info({ watchedPaths }, 'Chokidar is watching these paths')
|
|
13
|
+
})
|
|
14
|
+
watcher.on('change', (changedPath) => {
|
|
15
|
+
if (logger) logger.info({ path: changedPath }, `Configuration file at ${changedPath} changed`)
|
|
16
|
+
})
|
|
17
|
+
if (onShutdown) {
|
|
18
|
+
onShutdown(async () => {
|
|
19
|
+
if (watcher) await watcher.close()
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (logger) logger.error({ err }, `Failed to initialize watcher for configuration (${configPath}/**): ${err.message}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function registerConfigurationFile (filename = '', { optional = false, watch = true, schema = null } = {}, callback = () => {}) {
|
|
27
|
+
if (optional && !(await fileExists(filename))) return {}
|
|
28
|
+
return readConfigurationFile(filename, schema, callback, { logger })
|
|
29
|
+
.catch(err => {
|
|
30
|
+
if (logger) logger.fatal({ err }, `Failed to read configuration file "${filename}"`)
|
|
31
|
+
throw err
|
|
32
|
+
})
|
|
33
|
+
.then(data => {
|
|
34
|
+
current[filename] = data
|
|
35
|
+
if (watch) {
|
|
36
|
+
watcher.on('change', async (changedPath) => {
|
|
37
|
+
try {
|
|
38
|
+
if (changedPath.endsWith(filename)) await readConfigurationFile(filename, schema, callback, { logger })
|
|
39
|
+
} catch {
|
|
40
|
+
// readConfigurationFile() already logged an error
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
return data
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getCurrent (filename = 'config') {
|
|
49
|
+
return current[filename] ?? {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function close () {
|
|
53
|
+
if (watcher) await watcher.close()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { registerConfigurationFile, getCurrent, close }
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Joi from 'joi'
|
|
2
|
+
|
|
3
|
+
export const defaultTrue = Joi.boolean().default(true)
|
|
4
|
+
export const defaultFalse = Joi.boolean().default(false)
|
|
5
|
+
|
|
6
|
+
export const customString = Joi
|
|
7
|
+
.object({
|
|
8
|
+
en: Joi.string().allow('').required()
|
|
9
|
+
})
|
|
10
|
+
.unknown(true)
|
|
11
|
+
.required()
|
|
12
|
+
|
|
13
|
+
export const optionalCustomString = Joi
|
|
14
|
+
.object({
|
|
15
|
+
en: Joi.string().allow('').required()
|
|
16
|
+
})
|
|
17
|
+
.unknown(true)
|
|
18
|
+
.optional()
|
|
19
|
+
|
|
20
|
+
export const customURL = Joi.object()
|
|
21
|
+
.pattern(
|
|
22
|
+
Joi.string(),
|
|
23
|
+
Joi.string().allow('').uri()
|
|
24
|
+
)
|
|
25
|
+
.custom((value, helpers) => {
|
|
26
|
+
return 'en' in value ? value : helpers.error('any.custom', { message: '"en" is a required key' })
|
|
27
|
+
})
|
|
28
|
+
.required()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Umzug } from 'umzug'
|
|
2
|
+
|
|
3
|
+
export function createMigrationRunner ({ pool, migrationsGlob, tableName = 'migrations', logger, context = {} }) {
|
|
4
|
+
return new Umzug({
|
|
5
|
+
migrations: {
|
|
6
|
+
glob: migrationsGlob,
|
|
7
|
+
resolve: ({ path, name, context }) => {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
path,
|
|
11
|
+
up: async () => (await import(path)).up({ context }),
|
|
12
|
+
down: async () => (await import(path)).down({ context })
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
context: { pool, config: context },
|
|
17
|
+
storage: {
|
|
18
|
+
async executed ({ context: { pool } }) {
|
|
19
|
+
await pool.query(`CREATE TABLE IF NOT EXISTS \`${tableName}\` (name VARCHAR(255) NOT NULL, PRIMARY KEY (name))`)
|
|
20
|
+
const [results] = await pool.query(`SELECT name FROM \`${tableName}\``)
|
|
21
|
+
return [].concat(results).map(result => result.name)
|
|
22
|
+
},
|
|
23
|
+
async logMigration ({ name, context: { pool } }) {
|
|
24
|
+
await pool.query(`INSERT INTO \`${tableName}\` (name) VALUES (:name)`, { name })
|
|
25
|
+
},
|
|
26
|
+
async unlogMigration ({ name, context: { pool } }) {
|
|
27
|
+
await pool.query(`DELETE FROM \`${tableName}\` WHERE name = :name`, { name })
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
logger
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function executeMigrations (runner, config = {}) {
|
|
35
|
+
const { pool } = runner.options.context
|
|
36
|
+
await runner.up({ pool, config })
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise'
|
|
2
|
+
|
|
3
|
+
const defaults = {
|
|
4
|
+
dateStrings: ['DATE', 'DATETIME'],
|
|
5
|
+
timezone: 'Z',
|
|
6
|
+
namedPlaceholders: true
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createMySQLPool (options = {}) {
|
|
10
|
+
const pool = mysql.createPool({ ...defaults, ...options })
|
|
11
|
+
pool.on('connection', (connection) => {
|
|
12
|
+
connection.query('SET SESSION time_zone="+00:00"')
|
|
13
|
+
})
|
|
14
|
+
return pool
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createMySQLPoolFromEnv ({ prefix = 'SQL', names } = {}) {
|
|
18
|
+
if (names) {
|
|
19
|
+
const dbNames = typeof names === 'string'
|
|
20
|
+
? names.split(',').map(n => n.trim().toLowerCase()).filter(Boolean)
|
|
21
|
+
: names
|
|
22
|
+
|
|
23
|
+
const pools = {}
|
|
24
|
+
for (const name of dbNames) {
|
|
25
|
+
const envPrefix = `DB_${name.toUpperCase()}`
|
|
26
|
+
pools[name] = createMySQLPool({
|
|
27
|
+
host: process.env[`${envPrefix}_HOST`],
|
|
28
|
+
port: Number(process.env[`${envPrefix}_PORT`]) || 3306,
|
|
29
|
+
database: process.env[`${envPrefix}_NAME`],
|
|
30
|
+
user: process.env[`${envPrefix}_USER`],
|
|
31
|
+
password: process.env[`${envPrefix}_PASSWORD`],
|
|
32
|
+
connectionLimit: Number(process.env[`${envPrefix}_CONNECTIONS`] || 10),
|
|
33
|
+
multipleStatements: true
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
return pools
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return createMySQLPool({
|
|
40
|
+
host: process.env[`${prefix}_HOST`],
|
|
41
|
+
port: Number(process.env[`${prefix}_PORT`]) || 3306,
|
|
42
|
+
database: process.env[`${prefix}_DB`],
|
|
43
|
+
user: process.env[`${prefix}_USER`],
|
|
44
|
+
password: process.env[`${prefix}_PASS`],
|
|
45
|
+
connectionLimit: Number(process.env[`${prefix}_CONNECTIONS`] || 10)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function mysqlReadyCheck (pool, { retries = 12, delay = 10_000, logger } = {}) {
|
|
50
|
+
for (let i = 1; i <= retries; i++) {
|
|
51
|
+
try {
|
|
52
|
+
await pool.query('SELECT 1')
|
|
53
|
+
if (logger) logger.info('Database is ready')
|
|
54
|
+
return
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (logger) logger.warn(`Database not ready (attempt ${i}/${retries}): ${err.code} ${err.message}`)
|
|
57
|
+
if (i === retries) throw err
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let _pools
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns a singleton pool map created from the `DATABASES` env var.
|
|
67
|
+
* Calls `createMySQLPoolFromEnv({ names: process.env.DATABASES })` on first access.
|
|
68
|
+
* @returns {Record<string, import('mysql2/promise').Pool>}
|
|
69
|
+
*/
|
|
70
|
+
export function getMySQLPools () {
|
|
71
|
+
if (!_pools) {
|
|
72
|
+
const names = process.env.DATABASES
|
|
73
|
+
_pools = names ? createMySQLPoolFromEnv({ names }) : {}
|
|
74
|
+
}
|
|
75
|
+
return _pools
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function createUUID (pool) {
|
|
79
|
+
const [[{ uuid }]] = await pool.query('SELECT uuid() as uuid')
|
|
80
|
+
return uuid
|
|
81
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import pg from 'pg'
|
|
3
|
+
|
|
4
|
+
export function createPostgresPool (options = {}) {
|
|
5
|
+
return new pg.Pool(options)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createPostgresPoolFromEnv () {
|
|
9
|
+
let ca, cert, key
|
|
10
|
+
|
|
11
|
+
if (process.env.DATABASE_SSL !== 'false') {
|
|
12
|
+
try { ca = readFileSync(process.env.DATABASE_SSL_CA_PATH) } catch {}
|
|
13
|
+
try { cert = readFileSync(process.env.DATABASE_SSL_CERT_PATH) } catch {}
|
|
14
|
+
try { key = readFileSync(process.env.DATABASE_SSL_KEY_PATH) } catch {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const options = {
|
|
18
|
+
host: process.env.DATABASE_HOST,
|
|
19
|
+
port: Number(process.env.DATABASE_PORT) || 5432,
|
|
20
|
+
database: process.env.DATABASE_NAME,
|
|
21
|
+
user: process.env.DATABASE_USER,
|
|
22
|
+
password: process.env.DATABASE_PASSWORD,
|
|
23
|
+
ssl: process.env.DATABASE_SSL !== 'false'
|
|
24
|
+
? {
|
|
25
|
+
rejectUnauthorized: process.env.DATABASE_SSL === 'secure' && !!ca,
|
|
26
|
+
ca,
|
|
27
|
+
cert,
|
|
28
|
+
key
|
|
29
|
+
}
|
|
30
|
+
: false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return createPostgresPool(options)
|
|
34
|
+
}
|
package/lib/dotenv.js
ADDED
package/lib/health.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const readinessChecks = []
|
|
2
|
+
const healthChecks = []
|
|
3
|
+
|
|
4
|
+
export function registerReadinessCheck (fn) {
|
|
5
|
+
readinessChecks.push(fn)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function registerHealthCheck (fn) {
|
|
9
|
+
healthChecks.push(fn)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getReadinessChecks () {
|
|
13
|
+
return readinessChecks
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getHealthChecks () {
|
|
17
|
+
return healthChecks
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clearChecks () {
|
|
21
|
+
readinessChecks.length = 0
|
|
22
|
+
healthChecks.length = 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function mysqlHealthCheck (pool) {
|
|
26
|
+
await pool.query('SELECT 1')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function postgresHealthCheck (pool) {
|
|
30
|
+
await pool.query('SELECT 1')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function redisHealthCheck (client) {
|
|
34
|
+
const rawClient = client.getClient ? client.getClient() : client
|
|
35
|
+
await rawClient.ping()
|
|
36
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { createApp } from './app.js'
|
|
2
|
+
export { createMetricsServer } from './metrics-server.js'
|
|
3
|
+
export { createLogger, getLogger, pinoConfig } from './logger.js'
|
|
4
|
+
export { loadEnv } from './dotenv.js'
|
|
5
|
+
export { registerHealthCheck, registerReadinessCheck, clearChecks, mysqlHealthCheck, postgresHealthCheck, redisHealthCheck } from './health.js'
|
|
6
|
+
export { default as metricsPlugin } from './plugins/metrics.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Autohook that requires a valid JWT on every request in the encapsulated scope.
|
|
10
|
+
* Use as the default export of an `autohooks.js` file to protect all routes in that directory.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // src/routes/api/autohooks.js
|
|
14
|
+
* export { jwtAuthHook as default } from '@open-xchange/fastify-sdk'
|
|
15
|
+
*
|
|
16
|
+
* @param {import('fastify').FastifyInstance} app
|
|
17
|
+
*/
|
|
18
|
+
export async function jwtAuthHook (app) {
|
|
19
|
+
app.addHook('onRequest', app.verifyJWT)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Re-exports so consuming projects don't need to install these separately
|
|
23
|
+
export { default as fastify } from 'fastify'
|
|
24
|
+
export { default as fp } from 'fastify-plugin'
|
|
25
|
+
export { default as pino } from 'pino'
|
|
26
|
+
export { default as promClient } from 'prom-client'
|
|
27
|
+
export { default as createError } from 'http-errors'
|
|
28
|
+
export * as jose from 'jose'
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import pino from 'pino'
|
|
2
|
+
import { loadEnv } from './dotenv.js'
|
|
3
|
+
|
|
4
|
+
const pinoConfig = {
|
|
5
|
+
base: undefined,
|
|
6
|
+
redact: {
|
|
7
|
+
paths: ['headers.authorization', 'headers.cookie', 'headers.host', 'key', 'password', 'salt', 'hash'],
|
|
8
|
+
censor: '**REDACTED**',
|
|
9
|
+
remove: true
|
|
10
|
+
},
|
|
11
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
12
|
+
timestamp: () => `,"timestamp":${Date.now()}`,
|
|
13
|
+
formatters: {
|
|
14
|
+
level (label, number) {
|
|
15
|
+
switch (number) {
|
|
16
|
+
case 10: return { level: 8 } // trace
|
|
17
|
+
case 20: return { level: 7 } // debug
|
|
18
|
+
case 30: return { level: 6 } // info
|
|
19
|
+
case 40: return { level: 4 } // warn
|
|
20
|
+
case 50: return { level: 3 } // error
|
|
21
|
+
case 60: return { level: 0 } // fatal
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const pretty = process.env.LOG_PRETTY !== undefined
|
|
28
|
+
? process.env.LOG_PRETTY === 'true'
|
|
29
|
+
: process.stdout.isTTY
|
|
30
|
+
|
|
31
|
+
if (pretty) {
|
|
32
|
+
pinoConfig.transport = {
|
|
33
|
+
target: 'pino-pretty',
|
|
34
|
+
options: {
|
|
35
|
+
translateTime: 'SYS:mm/dd HH:MM:ss.l',
|
|
36
|
+
customLevels: 'fatal:0,error:3,warn:4,info:6,debug:7,trace:8',
|
|
37
|
+
customColors: 'fatal:red,error:red,warn:yellow,info:green,debug:blue,trace:gray'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createLogger (options = {}) {
|
|
43
|
+
return pino({ ...pinoConfig, ...options })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let _defaultLogger
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns a shared logger singleton. Creates it on first call
|
|
50
|
+
* (calling loadEnv() to ensure env vars like LOG_LEVEL are loaded).
|
|
51
|
+
*/
|
|
52
|
+
export function getLogger () {
|
|
53
|
+
if (!_defaultLogger) {
|
|
54
|
+
loadEnv()
|
|
55
|
+
_defaultLogger = createLogger()
|
|
56
|
+
}
|
|
57
|
+
return _defaultLogger
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { pinoConfig }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fastify from 'fastify'
|
|
2
|
+
import promClient from 'prom-client'
|
|
3
|
+
import { getReadinessChecks, getHealthChecks } from './health.js'
|
|
4
|
+
|
|
5
|
+
export function createMetricsServer () {
|
|
6
|
+
const app = fastify({ logger: false })
|
|
7
|
+
|
|
8
|
+
app.get('/ready', async (request, reply) => {
|
|
9
|
+
return handleChecks(reply, getReadinessChecks())
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
app.get('/live', async (request, reply) => {
|
|
13
|
+
return handleChecks(reply, getHealthChecks())
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
app.get('/metrics', async (request, reply) => {
|
|
17
|
+
const metrics = await promClient.register.metrics()
|
|
18
|
+
reply.type(promClient.register.contentType)
|
|
19
|
+
return metrics
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return app
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function handleChecks (reply, checks) {
|
|
26
|
+
try {
|
|
27
|
+
await Promise.all(checks.map(fn => fn()))
|
|
28
|
+
return { status: 'ok' }
|
|
29
|
+
} catch {
|
|
30
|
+
reply.code(503)
|
|
31
|
+
return { status: 'error' }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import cors from '@fastify/cors'
|
|
2
|
+
import fp from 'fastify-plugin'
|
|
3
|
+
|
|
4
|
+
export default fp(async function corsPlugin (fastify, opts) {
|
|
5
|
+
const originsFromEnv = (process.env.ORIGINS || '').split(',').filter(Boolean)
|
|
6
|
+
const origin = originsFromEnv.length === 1 ? originsFromEnv[0] : originsFromEnv.length > 1 ? originsFromEnv : false
|
|
7
|
+
|
|
8
|
+
const defaults = {
|
|
9
|
+
origin,
|
|
10
|
+
methods: ['GET', 'POST'],
|
|
11
|
+
maxAge: 86400
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const options = opts === true ? defaults : { ...defaults, ...opts }
|
|
15
|
+
fastify.register(cors, options)
|
|
16
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import helmet from '@fastify/helmet'
|
|
2
|
+
import fp from 'fastify-plugin'
|
|
3
|
+
|
|
4
|
+
export default fp(async function helmetPlugin (fastify, opts) {
|
|
5
|
+
const defaults = {
|
|
6
|
+
contentSecurityPolicy: false,
|
|
7
|
+
crossOriginEmbedderPolicy: false,
|
|
8
|
+
originAgentCluster: false,
|
|
9
|
+
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const options = opts === true ? defaults : { ...defaults, ...opts }
|
|
13
|
+
fastify.register(helmet, options)
|
|
14
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import jwt from '@fastify/jwt'
|
|
2
|
+
import fp from 'fastify-plugin'
|
|
3
|
+
import createError from 'http-errors'
|
|
4
|
+
import * as jose from 'jose'
|
|
5
|
+
|
|
6
|
+
const getUrl = (domain) => /^https?:\/\//.test(domain) ? domain : `https://${domain}`
|
|
7
|
+
|
|
8
|
+
function isAllowed (issuer = '', allowedIssuers = []) {
|
|
9
|
+
for (const allowed of allowedIssuers) {
|
|
10
|
+
if (issuer === allowed) return true
|
|
11
|
+
if (allowed.startsWith('https://*.') && issuer.endsWith(allowed.substring(9))) return true
|
|
12
|
+
}
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function discoverJwksUri (domain) {
|
|
17
|
+
try {
|
|
18
|
+
const discoveryUrl = new URL('.well-known/openid-configuration', domain.endsWith('/') ? domain : `${domain}/`)
|
|
19
|
+
const response = await fetch(discoveryUrl)
|
|
20
|
+
if (response.ok) {
|
|
21
|
+
const config = await response.json()
|
|
22
|
+
if (config.jwks_uri) return new URL(config.jwks_uri)
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// OIDC discovery not available, fall back to direct JWKS URI
|
|
26
|
+
}
|
|
27
|
+
return new URL('.well-known/jwks.json', domain.endsWith('/') ? domain : `${domain}/`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default fp(async function jwtPlugin (fastify, opts) {
|
|
31
|
+
const allowedIssuers = (process.env.OIDC_ISSUER || '')
|
|
32
|
+
.trim().split(/\s*,\s*/).filter(Boolean).map(getUrl)
|
|
33
|
+
|
|
34
|
+
if (!allowedIssuers.length && !opts.key) {
|
|
35
|
+
fastify.log.warn('JWT plugin registered but OIDC_ISSUER is not configured')
|
|
36
|
+
fastify.decorate('verifyJWT', async (request, reply) => {
|
|
37
|
+
throw createError(401, 'OIDC_ISSUER is not configured')
|
|
38
|
+
})
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!allowedIssuers.length && opts.key) {
|
|
43
|
+
fastify
|
|
44
|
+
.register(jwt, {
|
|
45
|
+
decode: { complete: true },
|
|
46
|
+
secret: opts.key
|
|
47
|
+
})
|
|
48
|
+
.decorate('verifyJWT', async (request, reply) => {
|
|
49
|
+
await request.jwtVerify()
|
|
50
|
+
})
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Per-domain remote JWK sets — jose handles caching and key rotation internally
|
|
55
|
+
const jwksSets = new Map()
|
|
56
|
+
|
|
57
|
+
async function getRemotePublicKey (token) {
|
|
58
|
+
const {
|
|
59
|
+
header: { kid, alg },
|
|
60
|
+
payload: { iss }
|
|
61
|
+
} = token
|
|
62
|
+
try {
|
|
63
|
+
const domain = getUrl(iss)
|
|
64
|
+
if (!isAllowed(domain, allowedIssuers)) {
|
|
65
|
+
throw new Error(`Issuer "${iss}" not in allowlist: [${allowedIssuers.join(', ')}]`)
|
|
66
|
+
}
|
|
67
|
+
if (!jwksSets.has(domain)) {
|
|
68
|
+
const jwksUri = await discoverJwksUri(domain)
|
|
69
|
+
jwksSets.set(domain, jose.createRemoteJWKSet(jwksUri))
|
|
70
|
+
}
|
|
71
|
+
const getKey = jwksSets.get(domain)
|
|
72
|
+
const key = await getKey({ kid, alg }, {})
|
|
73
|
+
return jose.exportSPKI(key)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
fastify.log.error({ err }, `Failed to get public key for token: ${err.message}`)
|
|
76
|
+
return {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fastify
|
|
81
|
+
.register(jwt, {
|
|
82
|
+
decode: { complete: true },
|
|
83
|
+
secret: (request, token) => getRemotePublicKey(token)
|
|
84
|
+
})
|
|
85
|
+
.decorate('verifyJWT', async (request, reply) => {
|
|
86
|
+
await request.jwtVerify()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fp from 'fastify-plugin'
|
|
2
|
+
|
|
3
|
+
export default fp(async function loggingPlugin (fastify) {
|
|
4
|
+
fastify.addHook('preHandler', function (req, reply, done) {
|
|
5
|
+
if (req.body) req.log.trace({ body: req.body }, 'parsed body')
|
|
6
|
+
done()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
fastify.addHook('onResponse', (req, reply, done) => {
|
|
10
|
+
const loggingOptions = { url: req.raw.url, res: reply, responseTime: reply.elapsedTime }
|
|
11
|
+
if (process.env.LOG_LEVEL === 'trace') loggingOptions.headers = req.headers
|
|
12
|
+
reply.log.debug(loggingOptions, 'request completed')
|
|
13
|
+
done()
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fp from 'fastify-plugin'
|
|
2
|
+
import fastifySwagger from '@fastify/swagger'
|
|
3
|
+
import fastifySwaggerUi from '@fastify/swagger-ui'
|
|
4
|
+
|
|
5
|
+
export default fp(async function swaggerPlugin (fastify, opts) {
|
|
6
|
+
const { openapi = {}, routePrefix = '/api-docs', prefix } = opts
|
|
7
|
+
|
|
8
|
+
const config = {
|
|
9
|
+
routePrefix,
|
|
10
|
+
prefix: prefix || process.env.APP_ROOT,
|
|
11
|
+
openapi
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
await fastify.register(fastifySwagger, config)
|
|
15
|
+
|
|
16
|
+
await fastify.register(fastifySwaggerUi, {
|
|
17
|
+
routePrefix,
|
|
18
|
+
uiConfig: {
|
|
19
|
+
docExpansion: 'full',
|
|
20
|
+
deepLinking: false
|
|
21
|
+
},
|
|
22
|
+
uiHooks: {
|
|
23
|
+
onRequest: function (request, reply, next) { next() },
|
|
24
|
+
preHandler: function (request, reply, next) { next() }
|
|
25
|
+
},
|
|
26
|
+
staticCSP: true,
|
|
27
|
+
transformStaticCSP: (header) => header,
|
|
28
|
+
transformSpecification: (swaggerObject) => swaggerObject,
|
|
29
|
+
transformSpecificationClone: true
|
|
30
|
+
})
|
|
31
|
+
})
|