@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.
@@ -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
@@ -0,0 +1,6 @@
1
+ export function loadEnv () {
2
+ // .env first (higher precedence), then .env.defaults fills in gaps.
3
+ // process.loadEnvFile() does not overwrite existing vars.
4
+ try { process.loadEnvFile('.env') } catch {}
5
+ try { process.loadEnvFile('.env.defaults') } catch {}
6
+ }
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'
@@ -0,0 +1,5 @@
1
+ import config from '@open-xchange/lint'
2
+
3
+ export default [
4
+ ...config
5
+ ]
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,8 @@
1
+ import fp from 'fastify-plugin'
2
+ import fastifyMetrics from 'fastify-metrics'
3
+
4
+ export default fp(async function metricsPlugin (fastify) {
5
+ await fastify.register(fastifyMetrics, {
6
+ endpoint: null
7
+ })
8
+ })
@@ -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
+ })