diff --git a/.github/workflows/ga-npm-publish.yml b/.github/workflows/ga-npm-publish.yml index 1a3e49f..efe90a8 100644 --- a/.github/workflows/ga-npm-publish.yml +++ b/.github/workflows/ga-npm-publish.yml @@ -14,7 +14,7 @@ jobs: - name: 🟢 Update node uses: actions/setup-node@v6 - with: { node-version: 24, registry-url: "https://registry.npmjs.org" } + with: { node-version: 24, registry-url: 'https://registry.npmjs.org' } - name: 🦖 Set up Deno uses: denoland/setup-deno@v2 diff --git a/README.md b/README.md index 8489ab0..c1615f8 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ classes (`ResponseError`). Adds default JSON headers and bodyless handling ### [`api/env`](./api/env.ts) `ENV()` getter with fallback and required enforcement; typed `APP_ENV` guard -(dev/test/prod) with validation; `CI_COMMIT_SHA`, `DEVTOOL_TOKEN`, `DEVTOOL_URL` -accessors. +(dev/test/prod) with validation; `CI_COMMIT_SHA`, `DEVTOOL_REPORT_TOKEN`, `DEVTOOL_ACCESS_TOKEN`, +`DEVTOOL_URL` accessors. ### [`api/validator`](./api/validator.ts) diff --git a/api/dev.ts b/api/dev.ts new file mode 100644 index 0000000..0769ce3 --- /dev/null +++ b/api/dev.ts @@ -0,0 +1,83 @@ +import { APP_ENV, DEVTOOL_ACCESS_TOKEN } from '@01edu/api/env' +import { respond } from '@01edu/api/response' +import type { RequestContext } from '@01edu/types/context' +import { route } from '@01edu/api/router' +import { ARR, OBJ, optional, STR } from '@01edu/api/validator' +import type { Sql } from '@01edu/db' + +/** + * Authorizes access to developer routes. + * Checks for `DEVTOOL_ACCESS_TOKEN` in the Authorization header. + * In non-prod environments, access is allowed if no token is configured. + * + * @param ctx - The request context. + * @throws {respond.UnauthorizedError} If access is denied. + */ +export const authorizeDevAccess = ({ req }: RequestContext) => { + if (APP_ENV !== 'prod') return // always open for dev env + const auth = req.headers.get('Authorization') || '' + const bearer = auth.toLowerCase().startsWith('bearer ') + ? auth.slice(7).trim() + : '' + if (bearer && bearer === DEVTOOL_ACCESS_TOKEN) return + throw new respond.UnauthorizedError({ message: 'Unauthorized access' }) +} + +/** + * Creates a route handler for executing arbitrary SQL queries. + * Useful for debugging and development tools. + * + * @param sql - The SQL tag function to use for execution. + * @returns A route handler configuration. + */ +export const createSqlDevRoute = (sql?: Sql) => { + return route({ + authorize: authorizeDevAccess, + fn: (_, { query, params }) => { + try { + if (!sql) { + return respond.NotImplemented({ + message: 'Database not configured', + }) + } + return sql`${query}`.all(params) + } catch (error) { + throw new respond.BadRequestError({ + message: error instanceof Error ? error.message : 'Unexpected Error', + }) + } + }, + input: OBJ({ + query: STR('The SQL query to execute'), + params: optional(OBJ({}, 'The parameters to bind to the query')), + }), + output: ARR( + optional(OBJ({}, 'A single result row')), + 'List of results', + ), + description: 'Execute an SQL query', + }) +} + +/** + * Creates a route handler for receiving the DevTools logging token. + * This allows the local app to send logs to the connected DevTools instance. + */ +export const createDevToolsHandshakeRoute = () => { + return route({ + fn: (_, { token }) => { + // @ts-ignore: Call hidden method + const setToken = globalThis.__DEVTOOLS_SET_TOKEN__ + if (typeof setToken === 'function') { + setToken(token) + return { success: true } + } + return respond.NotImplemented({ message: 'Logger not configured for dynamic updates' }) + }, + input: OBJ({ + token: STR('The logging token from DevTools'), + }), + output: OBJ({}, 'Success response'), + description: 'Receive DevTools logging token', + }) +} diff --git a/api/doc.ts b/api/doc.ts new file mode 100644 index 0000000..b417580 --- /dev/null +++ b/api/doc.ts @@ -0,0 +1,140 @@ +import type { Def, DefBase } from '@01edu/types/validator' +import type { GenericRoutes } from '@01edu/types/router' +import { route } from '@01edu/api/router' +import { ARR, BOOL, LIST, OBJ, optional, STR } from '@01edu/api/validator' + +/** + * Recursive type representing the structure of input/output documentation. + * It mirrors the structure of the validator definitions but simplified for documentation purposes. + */ +export type Documentation = + & ( + | { type: Exclude } + | { type: 'object'; properties: Record } + | { type: 'array'; items: Documentation } + | { type: 'list'; options: (string | number)[] } + | { type: 'union'; options: Documentation[] } + ) + & { description?: string; optional?: boolean } + +/** + * Represents the documentation for a single API endpoint. + */ +export type EndpointDoc = { + method: string + path: string + requiresAuth: boolean + authFunction: string + description?: string + input?: Documentation + output?: Documentation +} + +/** + * Extracts documentation from a validator definition. + * Recursively processes objects and arrays to build a `Documentation` structure. + * + * @param def - The validator definition to extract documentation from. + * @returns The extracted documentation or undefined if no definition is provided. + */ +function extractDocs(def?: Def): Documentation | undefined { + if (!def) return undefined + const base = { + type: def.type, + description: def.description, + optional: def.optional, + } + + switch (def.type) { + case 'object': { + const properties: Record = {} + for (const [key, value] of Object.entries(def.properties)) { + const doc = extractDocs(value) + if (doc) { + properties[key] = doc + } + } + return { ...base, properties, type: 'object' } + } + case 'array': { + const items = extractDocs(def.of) as Documentation + return { ...base, items, type: 'array' } + } + case 'list': + return { ...base, options: def.of as (string | number)[], type: 'list' } + case 'union': + return { + ...base, + options: def.of.map((d: Def) => extractDocs(d) as Documentation), + type: 'union', + } + case 'boolean': + return { ...base, type: 'boolean' } + case 'number': + return { ...base, type: 'number' } + case 'string': + return { ...base, type: 'string' } + } +} + +/** + * Generates API documentation for a set of routes. + * Iterates through the route definitions and extracts metadata, input, and output documentation. + * + * @param defs - The route definitions to generate documentation for. + * @returns An array of `EndpointDoc` objects describing the API. + */ +export const generateApiDocs = (defs: GenericRoutes) => { + return Object.entries(defs).map( + ([key, handler]) => { + const slashIndex = key.indexOf('/') + const method = key.slice(0, slashIndex).toUpperCase() + const path = key.slice(slashIndex) + const requiresAuth = handler.authorize ? true : false + + return { + method, + path, + requiresAuth, + authFunction: handler.authorize?.name || '', + description: 'description' in handler ? handler.description : undefined, + input: 'input' in handler ? extractDocs(handler.input) : undefined, + output: 'output' in handler ? extractDocs(handler.output) : undefined, + } + }, + ) +} + +const encoder = new TextEncoder() +const apiDocOutputDef: Def = ARR( + OBJ({ + method: LIST(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], 'HTTP method'), + path: STR('API endpoint path'), + requiresAuth: BOOL('whether authentication is required'), + authFunction: STR('name of the authorization function'), + description: STR('Endpoint description'), + input: optional(OBJ({}, 'Input documentation structure')), + output: optional(OBJ({}, 'Output documentation structure')), + }, 'API documentation object structure'), + 'API documentation array', +) + +/** + * Creates a route handler that serves the generated API documentation. + * The documentation is served as a JSON array of `EndpointDoc` objects. + * + * @param defs - The route definitions to generate documentation for. + * @returns A route handler that serves the API documentation. + */ +export const createDocRoute = (defs: GenericRoutes) => { + const docStr = JSON.stringify(generateApiDocs(defs)) + const docBuffer = encoder.encode(docStr) + return route({ + fn: () => + new Response(docBuffer, { + headers: { 'content-type': 'application/json' }, + }), + output: apiDocOutputDef, + description: 'Get the API documentation', + }) +} diff --git a/api/env.ts b/api/env.ts index 2178075..9f78843 100644 --- a/api/env.ts +++ b/api/env.ts @@ -66,14 +66,14 @@ export const CI_COMMIT_SHA: string = ENV('CI_COMMIT_SHA', '') * * @example * ```ts - * import { DEVTOOL_TOKEN } from '@01edu/api/env'; + * import { DEVTOOL_REPORT_TOKEN } from '@01edu/api/env'; * * const headers = { - * 'Authorization': `Bearer ${DEVTOOL_TOKEN}`, + * 'Authorization': `Bearer ${DEVTOOL_REPORT_TOKEN}`, * }; * ``` */ -export const DEVTOOL_TOKEN: string = ENV('DEVTOOL_TOKEN', '') +export const DEVTOOL_REPORT_TOKEN: string = ENV('DEVTOOL_REPORT_TOKEN', '') /** * The URL for a developer tool service. * @@ -86,6 +86,20 @@ export const DEVTOOL_TOKEN: string = ENV('DEVTOOL_TOKEN', '') */ export const DEVTOOL_URL: string = ENV('DEVTOOL_URL', '') +/** + * Internal token for dev access in production. + * + * @example + * ```ts + * import { DEVTOOL_ACCESS_TOKEN } from '@01edu/api/env'; + * + * if (req.headers.get('Authorization') === `Bearer ${DEVTOOL_ACCESS_TOKEN}`) { + * // Allow access + * } + * ``` + */ +export const DEVTOOL_ACCESS_TOKEN: string = ENV('DEVTOOL_ACCESS_TOKEN', '') + const forAppEnv = (env: AppEnvironments) => (key: string, fallback?: string): string => { const value = Deno.env.get(key) diff --git a/api/log.ts b/api/log.ts index 8ab28b4..b662de4 100644 --- a/api/log.ts +++ b/api/log.ts @@ -22,10 +22,31 @@ import { } from '@std/fmt/colors' import { now, startTime } from '@01edu/time' import { getContext } from './context.ts' -import { APP_ENV, CI_COMMIT_SHA, DEVTOOL_TOKEN, DEVTOOL_URL } from './env.ts' +import { + APP_ENV, + CI_COMMIT_SHA, + DEVTOOL_REPORT_TOKEN, + DEVTOOL_URL, +} from './env.ts' // Types type LogLevel = 'info' | 'error' | 'warn' | 'debug' +type LoggerOptions = + { + /** The URL of the devtool service to send logs to (prod only). Defaults to `DEVTOOL_URL` env var. */ + logUrl?: string + /** The authentication token for the devtool service (prod only). Defaults to `DEVTOOL_REPORT_TOKEN` env var. */ + logToken?: string + /** The version of the application, typically a git commit SHA. Defaults to `CI_COMMIT_SHA` env var or `git rev-parse HEAD`. */ + version?: string + /** The interval in milliseconds to batch and send logs (prod only). */ + batchInterval?: number + /** The maximum number of logs to batch before sending (prod only). */ + maxBatchSize?: number + /** A set of event names to filter out and not log. */ + filters?: Set +} + type LogFunction = ( level: LogLevel, event: string, @@ -94,6 +115,10 @@ const redactLongString = (_: string, value: unknown) => { return value.length > 100 ? 'long_string' : value } +// Storage key for dev token, linked to the project path +const projectKey = Deno.cwd().replace(/[^a-zA-Z0-9]/g, '_') +const storageKey = `DEVTOOL_TOKEN_${projectKey}` + const bind = (log: LogFunction) => Object.assign(log, { error: log.bind(null, 'error'), @@ -101,6 +126,7 @@ const bind = (log: LogFunction) => warn: log.bind(null, 'warn'), info: log.bind(null, 'info'), }) as Log + /** * Initializes and returns a logger instance. @@ -129,27 +155,37 @@ export const logger = async ({ batchInterval = 5000, maxBatchSize = 50, logUrl = DEVTOOL_URL, - logToken = DEVTOOL_TOKEN, + logToken = DEVTOOL_REPORT_TOKEN, version = CI_COMMIT_SHA, -}: { - /** The URL of the devtool service to send logs to (prod only). Defaults to `DEVTOOL_URL` env var. */ - logUrl?: string - /** The authentication token for the devtool service (prod only). Defaults to `DEVTOOL_TOKEN` env var. */ - logToken?: string - /** The version of the application, typically a git commit SHA. Defaults to `CI_COMMIT_SHA` env var or `git rev-parse HEAD`. */ - version?: string - /** The interval in milliseconds to batch and send logs (prod only). */ - batchInterval?: number - /** The maximum number of logs to batch before sending (prod only). */ - maxBatchSize?: number - /** A set of event names to filter out and not log. */ - filters?: Set -}): Promise => { +}: LoggerOptions): Promise => { let logBatch: unknown[] = [] + + // Try to load token from storage in dev if not provided + if (APP_ENV === 'dev' && !logToken) { + try { + logToken = localStorage.getItem(storageKey) || '' + } catch { /* Ignore storage errors */ } + } + + // Allow dynamic token update for dev mode handshake + const setToken = (newToken: string) => { + logToken = newToken + if (APP_ENV !== 'dev') return + try { + localStorage.setItem(storageKey, newToken) + } catch { /* Ignore storage errors */ } + } + // @ts-ignore: Attach hidden method for dev tools + globalThis.__DEVTOOLS_SET_TOKEN__ = setToken + if (APP_ENV === 'prod' && (!logToken || !logUrl)) { throw Error('DEVTOOLS configuration is required in production') } + if (APP_ENV === 'dev' && logUrl) { + console.log(`\n🔌 Connect to DevTools: ${logUrl}/claim?url=http://localhost:${Deno.env.get('PORT') || 8000}\n`) + } + if (!version) { try { const p = new Deno.Command('git', { @@ -172,6 +208,7 @@ export const logger = async ({ // DEVTOOLS Batch Logic async function flushLogs() { if (logBatch.length === 0) return + if (!logToken) return // Don't send if no token yet const batchToSend = logBatch logBatch = [] @@ -187,9 +224,15 @@ export const logger = async ({ }) if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${await response.text()}`) + // If 401/403, maybe token is bad? But we just retry or ignore in background. + // throw new Error(`HTTP ${response.status}: ${await response.text()}`) + // Silent fail or minimal log in dev to avoid spam? + if (APP_ENV === 'prod') console.error(`Batch send failed: ${response.status}`) } } catch (err) { + if (APP_ENV !== 'prod') return + // In dev, if it fails (e.g. devtool down), we might not want to re-queue indefinitely or spam console. + // But adhering to 'prod' logic: console.error('DEVTOOLS batch send failed:', err) logBatch = [...batchToSend, ...logBatch] // Requeue failed logs } @@ -200,11 +243,11 @@ export const logger = async ({ import.meta.dirname?.slice(0, -'/lib'.length).replaceAll('\\', '/') || '' const f = filters || new Set() - if (APP_ENV === 'prod') { - // Initialize batch interval + + // Initialize batch interval if we are in prod OR if we have a logUrl in dev + if (APP_ENV === 'prod' || (APP_ENV === 'dev' && logUrl)) { const interval = setInterval(flushLogs, batchInterval) - // Cleanup on exit const cleanup = async () => { clearInterval(interval) await flushLogs() @@ -213,8 +256,9 @@ export const logger = async ({ Deno.addSignalListener('SIGINT', cleanup) Deno.addSignalListener('SIGTERM', cleanup) - return bind((level, event, props) => { - if (f.has(event)) return + } + + const logToBatch = (level: LogLevel, event: string, props?: Record) => { const { trace, span } = getContext() const logData = { severity_number: levels[level].level, @@ -226,11 +270,16 @@ export const logger = async ({ service_version: version, service_instance_id: startTime.toString(), } + logBatch.push(logData) + if (logBatch.length >= maxBatchSize) flushLogs() + } + + if (APP_ENV === 'prod') { + return bind((level, event, props) => { + if (f.has(event)) return // Local logging console.log(event, props) - - logBatch.push(logData) - logBatch.length >= maxBatchSize && flushLogs() + logToBatch(level, event, props) }) } @@ -260,6 +309,11 @@ export const logger = async ({ const ev = `${makePrettyTimestamp(level, event)} ${callChain}`.trim() props ? console[level](ev, props) : console[level](ev) + + // Also send to remote if configured + if (logUrl) { + logToBatch(level, event, props) + } }) } diff --git a/api/router.ts b/api/router.ts index a77c2da..0798332 100644 --- a/api/router.ts +++ b/api/router.ts @@ -22,6 +22,19 @@ import type { } from '@01edu/types/router' import type { Log } from './log.ts' import { respond, ResponseError } from './response.ts' +import type { Sql } from '@01edu/db' +import { createDevToolsHandshakeRoute, createSqlDevRoute } from './dev.ts' +import { createDocRoute } from './doc.ts' +import { APP_ENV } from '@01edu/api/env' + +/** + * Options for configuring the router. + */ +export type RouterOptions = { + log: Log + sql?: Sql + sensitiveKeys?: string[] +} /** * A declaration function for creating a route handler. @@ -96,21 +109,36 @@ const sensitiveData = ( * }), * }; * - * const router = makeRouter(log, routes); + * const router = makeRouter(routes, { log }); * ``` */ export const makeRouter = ( - log: Log, defs: T, - sensitiveKeys = [ - 'password', - 'confPassword', - 'currentPassword', - 'newPassword', - ], + { + log, + sql, + sensitiveKeys = [ + 'password', + 'confPassword', + 'currentPassword', + 'newPassword', + ], + }: RouterOptions, ): (ctx: RequestContext) => Awaitable => { const routeMaps: Record = Object.create(null) + if (!defs['POST/api/execute-sql']) { + defs['POST/api/execute-sql'] = createSqlDevRoute(sql) + } + + if (!defs['POST/api/connect-devtools'] && APP_ENV !== 'prod') { + defs['POST/api/connect-devtools'] = createDevToolsHandshakeRoute() + } + + if (!defs['GET/api/doc']) { + defs['GET/api/doc'] = createDocRoute(defs) + } + for (const key in defs) { const slashIndex = key.indexOf('/') const method = key.slice(0, slashIndex) as HttpMethod diff --git a/api/server.ts b/api/server.ts index 8110e19..5db38ad 100644 --- a/api/server.ts +++ b/api/server.ts @@ -40,8 +40,28 @@ import { type RequestContext, runContext } from './context.ts' import { respond, ResponseError } from './response.ts' import { now } from '@01edu/time' import type { Awaitable } from '@01edu/types' +import { APP_ENV, DEVTOOL_URL } from './env.ts' type Handler = (ctx: RequestContext) => Awaitable + +const allowedOrigin = DEVTOOL_URL || '' +const addCorsHeaders = (res: Response, origin: string | null) => { + if (APP_ENV !== 'dev') return res + if (allowedOrigin && origin && new URL(origin).origin === new URL(allowedOrigin).origin) { + res.headers.set('Access-Control-Allow-Origin', allowedOrigin) + res.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS') + res.headers.set('Access-Control-Allow-Headers', 'Authorization, Content-Type') + res.headers.set('Access-Control-Allow-Credentials', 'true') + } else if (origin && (origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1'))) { + // Dev convenience for local devtools development + res.headers.set('Access-Control-Allow-Origin', origin) + res.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS') + res.headers.set('Access-Control-Allow-Headers', 'Authorization, Content-Type') + res.headers.set('Access-Control-Allow-Credentials', 'true') + } + return res +} + /** * Creates a server request handler that wraps a route handler with logging, error handling, and context creation. * @@ -53,6 +73,8 @@ type Handler = (ctx: RequestContext) => Awaitable export const server = ( { routeHandler, log }: { routeHandler: Handler; log: Log }, ): (req: Request, url?: URL) => Promise => { + + const handleRequest = async (ctx: RequestContext) => { const logProps: Record = {} logProps.path = `${ctx.req.method}:${ctx.url.pathname}` @@ -62,7 +84,7 @@ export const server = ( logProps.status = res.status logProps.duration = now() - ctx.span! log.info('out', logProps) - return res + return addCorsHeaders(res, ctx.req.headers.get('Origin')) } catch (err) { let response: Response if (err instanceof ResponseError) { @@ -76,13 +98,18 @@ export const server = ( logProps.duration = now() - ctx.span! log.error('out', logProps) - return response + return addCorsHeaders(response, ctx.req.headers.get('Origin')) } } return async (req: Request, url = new URL(req.url)) => { const method = req.method - if (method === 'OPTIONS') return respond.NoContent() + const origin = req.headers.get('Origin') + + if (method === 'OPTIONS') { + const res = respond.NoContent() + return addCorsHeaders(res, origin) + } // Build the request context const cookies = getCookies(req.headers) diff --git a/db/mod.ts b/db/mod.ts index 70ffc19..ebd6462 100644 --- a/db/mod.ts +++ b/db/mod.ts @@ -403,6 +403,20 @@ export const createTable = ( } } +/** + * Type definition for the `sql` template tag function. + * It allows executing SQL queries with parameter binding and retrieving results in various formats. + */ +export type Sql = < + T extends { [k in string]: unknown } | undefined, + P extends BindValue | BindParameters | undefined, +>(sqlArr: TemplateStringsArray, ...vars: unknown[]) => { + get: (params?: P) => T | undefined + all: (params?: P) => T[] + run: (params?: P) => void + value: (params?: P) => T[keyof T][] | undefined +} + /** * A template literal tag for executing arbitrary SQL queries. * @@ -417,15 +431,10 @@ export const createTable = ( * const result = sql`SELECT * FROM users WHERE id = ${1}`.get(); * ``` */ -export const sql = < +export const sql: Sql = < T extends { [k in string]: unknown } | undefined, P extends BindValue | BindParameters | undefined, ->(sqlArr: TemplateStringsArray, ...vars: unknown[]): { - get: (params?: P) => T | undefined - all: (params?: P) => T[] - run: (params?: P) => void - value: (params?: P) => T[keyof T][] | undefined -} => { +>(sqlArr: TemplateStringsArray, ...vars: unknown[]) => { const query = String.raw(sqlArr, ...vars) const stmt = db.prepare(query) return { diff --git a/types/router.d.ts b/types/router.d.ts index de93809..ded08eb 100644 --- a/types/router.d.ts +++ b/types/router.d.ts @@ -53,8 +53,38 @@ type SimpleHandler = ( ctx: RequestContext, payload: unknown, ) => Respond + +// deno-lint-ignore no-explicit-any +type ReservedRoutes = { + /** + * ⚠️ WARNING: You are overriding a default system route (Documentation). + * @deprecated + */ + 'GET/api/doc'?: Handler + + /** + * ⚠️ WARNING: You are overriding the system Health Check. + * @deprecated + */ + 'GET/api/health'?: Handler + + /** + * ⚠️ WARNING: You are overriding the system Dev SQL Execution route. + * @deprecated + */ + 'POST/api/execute-sql'?: Handler + + /** + * ⚠️ WARNING: You are overriding the system Dev Tools connection route. + * @deprecated + */ + 'POST/api/connect-devtools'?: Handler +} + // deno-lint-ignore no-explicit-any -export type GenericRoutes = Record< - RoutePattern, - Handler -> +export type GenericRoutes = + & Record< + RoutePattern, + Handler + > + & ReservedRoutes