Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ga-npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quote style was changed from double quotes to single quotes. While this change is cosmetic, it should be consistent with the project's style guidelines. Ensure this aligns with the project's formatting standards.

Suggested change
with: { node-version: 24, registry-url: 'https://registry.npmjs.org' }
with: { node-version: 24, registry-url: "https://registry.npmjs.org" }

Copilot uses AI. Check for mistakes.

- name: 🦖 Set up Deno
uses: denoland/setup-deno@v2
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
83 changes: 83 additions & 0 deletions api/dev.ts
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using template literals for SQL execution with interpolated variables is dangerous and can lead to SQL injection vulnerabilities. The query string is being directly interpolated into the SQL template. The parameters should be passed through the params binding mechanism instead.

Suggested change
return sql`${query}`.all(params)
return sql(query).all(params)

Copilot uses AI. Check for mistakes.
} 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',
})
Comment on lines +67 to +82
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line has inconsistent indentation (extra leading spaces). The indentation should match the surrounding code.

Suggested change
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',
})
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',
})

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +82
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines have inconsistent indentation (extra leading spaces). The indentation should match the surrounding code.

Suggested change
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',
})
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',
})

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +82
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line has inconsistent indentation (extra leading spaces). The indentation should match the surrounding code.

Suggested change
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',
})
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',
})

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +82
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line has inconsistent indentation (extra leading spaces). The indentation should match the surrounding code.

Suggested change
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',
})
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',
})

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +82
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines have inconsistent indentation (extra leading spaces). The indentation should match the surrounding code.

Suggested change
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',
})
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',
})

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +82
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing a hidden global method via globalThis bypasses TypeScript's type system and creates a non-standard coupling between modules. This pattern makes the code harder to maintain and test. Consider using a more explicit dependency injection pattern or returning a reference to the setToken function from the logger initialization.

Suggested change
*/
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',
})
*
* @param setToken - Optional function used to update the logger token.
*/
export const createDevToolsHandshakeRoute = (setToken?: (token: string) => void) => {
return route({
fn: (_, { 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',
})

Copilot uses AI. Check for mistakes.
}
140 changes: 140 additions & 0 deletions api/doc.ts
Original file line number Diff line number Diff line change
@@ -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<DefBase['type'], 'object' | 'array' | 'list' | 'union'> }
| { type: 'object'; properties: Record<string, Documentation> }
| { 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<string, Documentation> = {}
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<typeof defs[keyof typeof defs]>(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',
})
}
20 changes: 17 additions & 3 deletions api/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Comment on lines +90 to +97
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Internal token for dev access in production" which is confusing. If it's for "dev access" it's unclear why it would be used in production. The purpose and usage context of this token should be clarified in the documentation.

Suggested change
* 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
* Authentication token used to protect production-hosted developer tooling
* or diagnostic endpoints (for example, internal dashboards or report hooks).
*
* This token is intended for restricted operational use only and must not be
* used for end-user authentication or as a general backdoor into the
* application. Rotate and store it securely (e.g. via your secret manager).
*
* @example
* ```ts
* import { DEVTOOL_ACCESS_TOKEN } from '@01edu/api/env';
*
* if (req.headers.get('Authorization') === `Bearer ${DEVTOOL_ACCESS_TOKEN}`) {
* // Allow access to an internal developer tooling endpoint

Copilot uses AI. Check for mistakes.
* }
* ```
*/
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)
Expand Down
Loading