From f32aee8f28c73ec50baf51c141629ec29f071b53 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Sun, 21 Dec 2025 11:55:39 +0000 Subject: [PATCH 1/3] feat: add developer access authorization and SQL execution route --- .github/workflows/ga-npm-publish.yml | 2 +- api/dev.ts | 70 ++++++++++++++++++++++++++++ api/env.ts | 14 ++++++ api/router.ts | 54 +++++++++++++++------ db/mod.ts | 6 +++ types/router.d.ts | 32 +++++++++++-- 6 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 api/dev.ts 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/api/dev.ts b/api/dev.ts new file mode 100644 index 0000000..95d9b6c --- /dev/null +++ b/api/dev.ts @@ -0,0 +1,70 @@ +import { APP_ENV, DEV_INTERNAL_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 `DEV_INTERNAL_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) => { + // Allow only in non-prod or with explicit token in prod (defense-in-depth) + const auth = req.headers.get('authorization') || '' + const bearer = auth.toLowerCase().startsWith('bearer ') + ? auth.slice(7).trim() + : '' + + if (!DEV_INTERNAL_TOKEN) { + if (APP_ENV === 'prod') { + throw new respond.UnauthorizedError({ message: 'Unauthorized access' }) + } + // In dev/test with no configured token, allow free access. + return + } + + if (bearer !== DEV_INTERNAL_TOKEN) { + 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', + }) +} diff --git a/api/env.ts b/api/env.ts index 2178075..9afb501 100644 --- a/api/env.ts +++ b/api/env.ts @@ -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 { DEV_INTERNAL_TOKEN } from '@01edu/api/env'; + * + * if (req.headers.get('Authorization') === `Bearer ${DEV_INTERNAL_TOKEN}`) { + * // Allow access + * } + * ``` + */ +export const DEV_INTERNAL_TOKEN: string = ENV('DEV_INTERNAL_TOKEN', '') + const forAppEnv = (env: AppEnvironments) => (key: string, fallback?: string): string => { const value = Deno.env.get(key) diff --git a/api/router.ts b/api/router.ts index a77c2da..008f4ac 100644 --- a/api/router.ts +++ b/api/router.ts @@ -22,6 +22,17 @@ 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 { createSqlDevRoute } from './dev.ts' + +/** + * Options for configuring the router. + */ +export type RouterOptions = { + log: Log + sql?: Sql + sensitiveKeys?: string[] +} /** * A declaration function for creating a route handler. @@ -96,30 +107,32 @@ 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) - - for (const key in defs) { - const slashIndex = key.indexOf('/') - const method = key.slice(0, slashIndex) as HttpMethod - const url = key.slice(slashIndex) + const registerRoute = (def: T[keyof T], peth: string) => { + const slashIndex = peth.indexOf('/') + const method = peth.slice(0, slashIndex) as HttpMethod + const url = peth.slice(slashIndex) if (!routeMaps[url]) { routeMaps[url] = Object.create(null) as Route routeMaps[`${url}/`] = routeMaps[url] } - const { fn, input, authorize } = defs[key] as Handler + const { fn, input, authorize } = def as Handler const handler = async ( ctx: RequestContext & { session: unknown }, payload?: unknown, @@ -167,6 +180,19 @@ export const makeRouter = ( } } + for (const key in defs) { + registerRoute(defs[key], key) + } + + if ( + !routeMaps['/api/execute-sql'] || !routeMaps['/api/execute-sql']['POST'] + ) { + registerRoute( + createSqlDevRoute(sql) as T[keyof T], + 'POST/api/execute-sql', + ) + } + return (ctx: RequestContext) => { const route = routeMaps[ctx.url.pathname] if (!route) return respond.NotFound() diff --git a/db/mod.ts b/db/mod.ts index 70ffc19..7b4f412 100644 --- a/db/mod.ts +++ b/db/mod.ts @@ -436,6 +436,12 @@ export const sql = < } } +/** + * 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 = typeof sql + /** * Creates a restore point for the database, for use in testing environments. * This function will throw an error if called in a production environment. diff --git a/types/router.d.ts b/types/router.d.ts index de93809..0570996 100644 --- a/types/router.d.ts +++ b/types/router.d.ts @@ -53,8 +53,32 @@ 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 +} + // deno-lint-ignore no-explicit-any -export type GenericRoutes = Record< - RoutePattern, - Handler -> +export type GenericRoutes = + & Record< + RoutePattern, + Handler + > + & ReservedRoutes From 7dcc00d90f1f3dc98e01e5cbc42aeb7059fc3b71 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Sun, 21 Dec 2025 12:53:49 +0000 Subject: [PATCH 2/3] feat: add API documentation generation and route support --- README.md | 4 +- api/dev.ts | 22 +++----- api/doc.ts | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++ api/env.ts | 12 ++--- api/log.ts | 40 +++++++++------ api/router.ts | 33 ++++++------ db/mod.ts | 29 ++++++----- 7 files changed, 209 insertions(+), 71 deletions(-) create mode 100644 api/doc.ts 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 index 95d9b6c..1e200f0 100644 --- a/api/dev.ts +++ b/api/dev.ts @@ -1,4 +1,4 @@ -import { APP_ENV, DEV_INTERNAL_TOKEN } from '@01edu/api/env' +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' @@ -7,30 +7,20 @@ import type { Sql } from '@01edu/db' /** * Authorizes access to developer routes. - * Checks for `DEV_INTERNAL_TOKEN` in the Authorization header. + * 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) => { - // Allow only in non-prod or with explicit token in prod (defense-in-depth) - const auth = req.headers.get('authorization') || '' + 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 (!DEV_INTERNAL_TOKEN) { - if (APP_ENV === 'prod') { - throw new respond.UnauthorizedError({ message: 'Unauthorized access' }) - } - // In dev/test with no configured token, allow free access. - return - } - - if (bearer !== DEV_INTERNAL_TOKEN) { - throw new respond.UnauthorizedError({ message: 'Unauthorized access' }) - } + if (bearer && bearer === DEVTOOL_ACCESS_TOKEN) return + throw new respond.UnauthorizedError({ message: 'Unauthorized access' }) } /** 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 9afb501..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. * @@ -91,14 +91,14 @@ export const DEVTOOL_URL: string = ENV('DEVTOOL_URL', '') * * @example * ```ts - * import { DEV_INTERNAL_TOKEN } from '@01edu/api/env'; + * import { DEVTOOL_ACCESS_TOKEN } from '@01edu/api/env'; * - * if (req.headers.get('Authorization') === `Bearer ${DEV_INTERNAL_TOKEN}`) { + * if (req.headers.get('Authorization') === `Bearer ${DEVTOOL_ACCESS_TOKEN}`) { * // Allow access * } * ``` */ -export const DEV_INTERNAL_TOKEN: string = ENV('DEV_INTERNAL_TOKEN', '') +export const DEVTOOL_ACCESS_TOKEN: string = ENV('DEVTOOL_ACCESS_TOKEN', '') const forAppEnv = (env: AppEnvironments) => (key: string, fallback?: string): string => { diff --git a/api/log.ts b/api/log.ts index 8ab28b4..20b2bd4 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, @@ -129,22 +150,9 @@ 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[] = [] if (APP_ENV === 'prod' && (!logToken || !logUrl)) { throw Error('DEVTOOLS configuration is required in production') diff --git a/api/router.ts b/api/router.ts index 008f4ac..779313d 100644 --- a/api/router.ts +++ b/api/router.ts @@ -24,6 +24,7 @@ import type { Log } from './log.ts' import { respond, ResponseError } from './response.ts' import type { Sql } from '@01edu/db' import { createSqlDevRoute } from './dev.ts' +import { createDocRoute } from './doc.ts' /** * Options for configuring the router. @@ -124,15 +125,24 @@ export const makeRouter = ( }: RouterOptions, ): (ctx: RequestContext) => Awaitable => { const routeMaps: Record = Object.create(null) - const registerRoute = (def: T[keyof T], peth: string) => { - const slashIndex = peth.indexOf('/') - const method = peth.slice(0, slashIndex) as HttpMethod - const url = peth.slice(slashIndex) + + if (!defs['POST/api/execute-sql']) { + defs['POST/api/execute-sql'] = createSqlDevRoute(sql) + } + + 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 + const url = key.slice(slashIndex) if (!routeMaps[url]) { routeMaps[url] = Object.create(null) as Route routeMaps[`${url}/`] = routeMaps[url] } - const { fn, input, authorize } = def as Handler + const { fn, input, authorize } = defs[key] as Handler const handler = async ( ctx: RequestContext & { session: unknown }, payload?: unknown, @@ -180,19 +190,6 @@ export const makeRouter = ( } } - for (const key in defs) { - registerRoute(defs[key], key) - } - - if ( - !routeMaps['/api/execute-sql'] || !routeMaps['/api/execute-sql']['POST'] - ) { - registerRoute( - createSqlDevRoute(sql) as T[keyof T], - 'POST/api/execute-sql', - ) - } - return (ctx: RequestContext) => { const route = routeMaps[ctx.url.pathname] if (!route) return respond.NotFound() diff --git a/db/mod.ts b/db/mod.ts index 7b4f412..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 { @@ -436,12 +445,6 @@ export const sql = < } } -/** - * 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 = typeof sql - /** * Creates a restore point for the database, for use in testing environments. * This function will throw an error if called in a production environment. From 1d2b88a3c08b5f22243eea92e26209106178dd44 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Tue, 30 Dec 2025 14:19:19 +0000 Subject: [PATCH 3/3] feat: Add DevTools handshake route, dynamic logging token, and enhanced CORS for remote logging. --- api/dev.ts | 23 +++++++++++++++++ api/log.ts | 64 ++++++++++++++++++++++++++++++++++++++++------- api/router.ts | 7 +++++- api/server.ts | 33 +++++++++++++++++++++--- types/router.d.ts | 6 +++++ 5 files changed, 120 insertions(+), 13 deletions(-) diff --git a/api/dev.ts b/api/dev.ts index 1e200f0..0769ce3 100644 --- a/api/dev.ts +++ b/api/dev.ts @@ -58,3 +58,26 @@ export const createSqlDevRoute = (sql?: Sql) => { 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/log.ts b/api/log.ts index 20b2bd4..b662de4 100644 --- a/api/log.ts +++ b/api/log.ts @@ -115,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'), @@ -122,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. @@ -154,10 +159,33 @@ export const logger = async ({ version = CI_COMMIT_SHA, }: 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', { @@ -180,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 = [] @@ -195,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 } @@ -208,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() @@ -221,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, @@ -234,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) }) } @@ -268,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 779313d..0798332 100644 --- a/api/router.ts +++ b/api/router.ts @@ -23,8 +23,9 @@ import type { import type { Log } from './log.ts' import { respond, ResponseError } from './response.ts' import type { Sql } from '@01edu/db' -import { createSqlDevRoute } from './dev.ts' +import { createDevToolsHandshakeRoute, createSqlDevRoute } from './dev.ts' import { createDocRoute } from './doc.ts' +import { APP_ENV } from '@01edu/api/env' /** * Options for configuring the router. @@ -130,6 +131,10 @@ export const makeRouter = ( 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) } 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/types/router.d.ts b/types/router.d.ts index 0570996..ded08eb 100644 --- a/types/router.d.ts +++ b/types/router.d.ts @@ -73,6 +73,12 @@ type ReservedRoutes = { * @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