From 12f04f2379e0c56485a20aca554e2c9842e08c1f Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Thu, 14 May 2026 17:21:33 +0000 Subject: [PATCH 1/3] Improve error handling and clarity in API responses --- api/auth.ts | 44 +++++++++---- api/clickhouse-client.ts | 10 ++- api/lib/google-oauth.ts | 19 ++++-- api/lmdb-store.ts | 24 +++---- api/picture.ts | 3 +- api/routes.ts | 137 ++++++++++++++++++++++++++------------- api/sql.ts | 52 ++++++++++----- 7 files changed, 189 insertions(+), 100 deletions(-) diff --git a/api/auth.ts b/api/auth.ts index 0ec9d3c..dc9f341 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -78,19 +78,27 @@ export async function handleGoogleCallback( } // Exchange the code for tokens - const tokenResponse = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - code, - client_id: GOOGLE_OAUTH_CONFIG.clientId, - client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, - redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, - grant_type: 'authorization_code', - }), - }) + let tokenResponse: Response + try { + tokenResponse = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + code, + client_id: GOOGLE_OAUTH_CONFIG.clientId, + client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + grant_type: 'authorization_code', + }), + }) + } catch (err) { + throw new respond.UnauthorizedError({ + message: 'Failed to contact Google OAuth', + details: err instanceof Error ? err.message : 'Network error', + }) + } if (!tokenResponse.ok) { const errorBody = await tokenResponse.text().catch(() => 'unknown') @@ -107,7 +115,15 @@ export async function handleGoogleCallback( const tokens = await tokenResponse.json() as GoogleTokens // Verify and decode the ID token - await verifyGoogleToken(tokens.id_token) + try { + await verifyGoogleToken(tokens.id_token) + } catch (err) { + throw new respond.UnauthorizedError({ + message: 'Failed to verify Google ID token', + details: err instanceof Error ? err.message : 'Unknown error', + }) + } + const userInfo = decodeGoogleJWT(tokens.id_token) as GoogleUserInfo userInfo.picture &&= await savePicture(userInfo.picture) const sessionId = await authenticateOauthUser(userInfo) diff --git a/api/clickhouse-client.ts b/api/clickhouse-client.ts index ce044f5..0a70e4f 100644 --- a/api/clickhouse-client.ts +++ b/api/clickhouse-client.ts @@ -84,7 +84,7 @@ async function insertLogs( data: LogsInput, ) { const logsToInsert = Array.isArray(data) ? data : [data] - if (logsToInsert.length === 0) throw respond.NoContent() + if (logsToInsert.length === 0) return respond.NoContent() const rows = logsToInsert.map((log) => { const traceHex = numberToHex128(log.trace_id) @@ -106,7 +106,9 @@ async function insertLogs( return respond.OK() } catch (error) { console.error('Error inserting logs into ClickHouse:', { error }) - throw respond.InternalServerError() + throw new respond.InternalServerErrorError({ + message: 'Failed to insert logs into ClickHouse', + }) } } @@ -213,7 +215,9 @@ async function getLogs(dep: string, data: FetchTablesParams) { return (await rs.json()).data } catch (e) { console.error('ClickHouse query failed', { error: e, query, params }) - throw respond.InternalServerError() + throw new respond.InternalServerErrorError({ + message: 'Failed to fetch logs from ClickHouse', + }) } } diff --git a/api/lib/google-oauth.ts b/api/lib/google-oauth.ts index 6682d9a..c2f61d8 100644 --- a/api/lib/google-oauth.ts +++ b/api/lib/google-oauth.ts @@ -74,13 +74,20 @@ export function getGoogleAuthUrl(state: string): string { } export async function verifyGoogleToken(idToken: string) { - const response = await fetch( - `https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`, - ) - if (!response.ok) { - throw new Error('Invalid token') + try { + const response = await fetch( + `https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`, + ) + if (!response.ok) { + throw new Error('Invalid token') + } + return response.json() + } catch (err) { + throw new Error( + 'Google token verification failed', + { cause: err }, + ) } - return response.json() } export function decodeGoogleJWT(idToken: string) { diff --git a/api/lmdb-store.ts b/api/lmdb-store.ts index 982aa15..54adac8 100644 --- a/api/lmdb-store.ts +++ b/api/lmdb-store.ts @@ -12,15 +12,19 @@ export const getOne = async ( if (res.status === 404) return null if (!res.ok) { log.error('store-get-one-failed', { path, id, status: res.status }) + throw new Error(`Store getOne failed (${path}/${id}) with status ${res.status}`) } - return res.json() + return await res.json() } catch (err) { log.error('store-get-one-error', { path, id, error: err instanceof Error ? err.message : String(err), }) - throw err + throw new Error( + `Store request failed (${path}/${id})`, + { cause: err }, + ) } } @@ -29,18 +33,6 @@ export const get = async ( params?: { q?: string; limit?: number; from?: number }, ): Promise => { const q = new URLSearchParams(params as unknown as Record) - const url = `${STORE_URL}/${path}/?${q}` - try { - const res = await fetch(url, { headers }) - if (!res.ok) { - log.error('store-get-failed', { path, status: res.status }) - } - return res.json() - } catch (err) { - log.error('store-get-error', { - path, - error: err instanceof Error ? err.message : String(err), - }) - throw err - } + const res = await fetch(`${STORE_URL}/${path}/?${q}`, { headers }) + return res.json() } diff --git a/api/picture.ts b/api/picture.ts index ff0de0c..1c4f73d 100644 --- a/api/picture.ts +++ b/api/picture.ts @@ -27,6 +27,7 @@ export const savePicture = async (url?: string) => { url, error: err instanceof Error ? err.message : String(err), }) + return undefined } } @@ -44,6 +45,6 @@ export const getPicture = async (hash: string) => { hash, error: err instanceof Error ? err.message : String(err), }) - throw err + return new Response('Picture not found', { status: 404 }) } } diff --git a/api/routes.ts b/api/routes.ts index e7e9820..fe9de3b 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -73,7 +73,7 @@ const withUserSession = async ({ cookies }: RequestContext) => { const session = await decodeSession(cookies.session) if (!session) { log.warn('auth-missing-session') - throw Error('Missing user session') + throw new respond.UnauthorizedError({ message: 'Missing user session' }) } const admin = AdminsCollection.get(session.id) return { ...session, isAdmin: !!admin } @@ -83,7 +83,7 @@ const withAdminSession = async (ctx: RequestContext) => { const session = await withUserSession(ctx) if (!session || !session.isAdmin) { log.warn('auth-admin-required', { userId: session?.id }) - throw Error('Admin access required') + throw new respond.ForbiddenError({ message: 'Admin access required' }) } } @@ -91,20 +91,28 @@ const withDeploymentSession = async (ctx: RequestContext) => { const token = ctx.req.headers.get('Authorization')?.replace(/^Bearer /i, '') if (!token) { log.warn('deployment-auth-missing-token') - throw Error('Missing token') + throw new respond.UnauthorizedError({ message: 'Missing token' }) } - const message = await decryptMessage(token) - if (!message) { - log.warn('deployment-auth-invalid-token') - throw Error('Invalid token') - } - const data = JSON.parse(message) - const dep = DeploymentsCollection.get(data?.url) - if (!dep || dep.tokenSalt !== data?.tokenSalt) { - log.warn('deployment-auth-token-mismatch', { url: data?.url }) - throw Error('Invalid token') + try { + const message = await decryptMessage(token) + if (!message) { + log.warn('deployment-auth-invalid-token') + throw new respond.UnauthorizedError({ message: 'Invalid token' }) + } + const data = JSON.parse(message) + const dep = DeploymentsCollection.get(data?.url) + if (!dep || dep.tokenSalt !== data?.tokenSalt) { + log.warn('deployment-auth-token-mismatch', { url: data?.url }) + throw new respond.UnauthorizedError({ message: 'Invalid token' }) + } + return dep + } catch (err) { + if (err instanceof respond.ResponseError) throw err + log.warn('deployment-auth-token-error', { + error: err instanceof Error ? err.message : String(err), + }) + throw new respond.UnauthorizedError({ message: 'Invalid token' }) } - return dep } const userInTeam = async (teamId: string, userId?: string) => { @@ -121,19 +129,21 @@ const withDeploymentTableAccess = async ( deployment: string, ) => { const dep = DeploymentsCollection.get(deployment) - if (!dep) throw respond.NotFound({ message: 'Deployment not found' }) + if (!dep) throw new respond.NotFoundError({ message: 'Deployment not found' }) if (!dep.databaseEnabled) { - throw respond.BadRequest({ + throw new respond.BadRequestError({ message: 'Database not enabled for deployment', }) } const project = ProjectsCollection.get(dep.projectId) - if (!project) throw respond.NotFound({ message: 'Project not found' }) + if (!project) { + throw new respond.NotFoundError({ message: 'Project not found' }) + } if (!project.isPublic && !ctx.session.isAdmin) { if (!(await userInTeam(project.teamId, ctx.session.id))) { - throw respond.Forbidden({ + throw new respond.ForbiddenError({ message: 'Access to project tables denied', }) } @@ -261,7 +271,7 @@ const defs = { authorize: withUserSession, fn: async (_ctx, { id }) => { const group = await getOne<{ name: string }>('google/group', id) - if (!group) throw respond.NotFound({ message: 'Team not found' }) + if (!group) throw new respond.NotFoundError({ message: 'Team not found' }) const members = await get<{ id: string; email: string }[]>( `google/group/${id}`, @@ -325,7 +335,9 @@ const defs = { authorize: withUserSession, fn: (_ctx, { slug }) => { const project = ProjectsCollection.get(slug) - if (!project) throw respond.NotFound({ message: 'Project not found' }) + if (!project) { + throw new respond.NotFoundError({ message: 'Project not found' }) + } return project }, input: OBJ({ slug: STR('The slug of the project') }), @@ -349,7 +361,9 @@ const defs = { authorize: withAdminSession, fn: (_ctx, { slug }) => { const project = ProjectsCollection.get(slug) - if (!project) throw respond.NotFound({ message: 'Project not found' }) + if (!project) { + throw new respond.NotFoundError({ message: 'Project not found' }) + } ProjectsCollection.delete(slug) log.info('project-deleted', { slug }) return true @@ -365,7 +379,7 @@ const defs = { d.projectId === project ) if (!deployments.length) { - throw respond.NotFound({ message: 'Deployments not found' }) + throw new respond.NotFoundError({ message: 'Deployments not found' }) } return deployments.map(({ tokenSalt: _, ...d }) => { return { @@ -384,7 +398,9 @@ const defs = { authorize: withAdminSession, fn: async (_ctx, { url }) => { const dep = DeploymentsCollection.get(url) - if (!dep) throw respond.NotFound() + if (!dep) { + throw new respond.NotFoundError({ message: 'Deployment not found' }) + } const { tokenSalt, ...deployment } = dep const token = await encryptMessage( JSON.stringify({ url: deployment.url, tokenSalt }), @@ -445,7 +461,9 @@ const defs = { authorize: withAdminSession, fn: async (_ctx, { url }) => { const dep = DeploymentsCollection.get(url) - if (!dep) throw respond.NotFound() + if (!dep) { + throw new respond.NotFoundError({ message: 'Deployment not found' }) + } const tokenSalt = performance.now().toString() log.info('deployment-token-regenerated', { url }) @@ -464,14 +482,18 @@ const defs = { authorize: withUserSession, fn: (_ctx, { url }) => { const dep = DeploymentsCollection.get(url) - if (!dep) throw respond.NotFound({ message: 'Deployment not found' }) + if (!dep) { + throw new respond.NotFoundError({ message: 'Deployment not found' }) + } if (!dep.databaseEnabled) { - throw respond.BadRequest({ + throw new respond.BadRequestError({ message: 'Database not enabled for deployment', }) } const schema = DatabaseSchemasCollection.get(url) - if (!schema) throw respond.NotFound({ message: 'Schema not cached yet' }) + if (!schema) { + throw new respond.NotFoundError({ message: 'Schema not cached yet' }) + } return schema }, input: OBJ({ url: STR('Deployment URL') }), @@ -501,7 +523,9 @@ const defs = { authorize: withAdminSession, fn: async (_ctx, input) => { const dep = DeploymentsCollection.get(input) - if (!dep) throw respond.NotFound() + if (!dep) { + throw new respond.NotFoundError({ message: 'Deployment not found' }) + } await DeploymentsCollection.delete(input) log.info('deployment-deleted', { url: input }) return respond.NoContent() @@ -512,7 +536,14 @@ const defs = { 'POST/api/logs': route({ authorize: withDeploymentSession, fn: (ctx, logs) => { - if (!ctx.session.url) throw respond.InternalServerError() + if (!ctx.session.url) { + log.error('deployment-session-missing-url', { + userId: ctx.session.id, + }) + throw new respond.InternalServerErrorError({ + message: 'Deployment URL missing from session', + }) + } const count = Array.isArray(logs) ? logs.length : 1 log.debug('logs-ingested', { deployment: ctx.session.url, count }) return insertLogs(ctx.session.url, logs) @@ -525,18 +556,22 @@ const defs = { fn: async (ctx, params) => { const deployment = DeploymentsCollection.get(params.deployment) if (!deployment) { - throw respond.NotFound({ message: 'Deployment not found' }) + throw new respond.NotFoundError({ message: 'Deployment not found' }) } if (!deployment.logsEnabled) { - throw respond.BadRequest({ + throw new respond.BadRequestError({ message: 'Logging not enabled for deployment', }) } const project = ProjectsCollection.get(deployment.projectId) - if (!project) throw respond.NotFound({ message: 'Project not found' }) + if (!project) { + throw new respond.NotFoundError({ message: 'Project not found' }) + } if (!project.isPublic && !ctx.session.isAdmin) { if (!(await userInTeam(project.teamId, ctx.session.email))) { - throw respond.Forbidden({ message: 'Access to project logs denied' }) + throw new respond.ForbiddenError({ + message: 'Access to project logs denied', + }) } } @@ -575,10 +610,14 @@ const defs = { const dep = await withDeploymentTableAccess(ctx, deployment) const schema = DatabaseSchemasCollection.get(deployment) - if (!schema) throw respond.NotFound({ message: 'Schema not cached yet' }) + if (!schema) { + throw new respond.NotFoundError({ message: 'Schema not cached yet' }) + } const tableDef = schema.tables.find((t) => t.table === table) if (!tableDef) { - throw respond.NotFound({ message: 'Table not found in schema' }) + throw new respond.NotFoundError({ + message: 'Table not found in schema', + }) } try { @@ -665,7 +704,7 @@ const defs = { deployment, ) if (!sqlEndpoint || !sqlToken) { - throw respond.BadRequest({ + throw new respond.BadRequestError({ message: 'SQL endpoint or token not configured for deployment', }) } @@ -713,15 +752,23 @@ const defs = { deployment, ) if (!sqlEndpoint || !sqlToken) { - throw respond.BadRequest({ + throw new respond.BadRequestError({ message: 'SQL endpoint or token not configured for deployment', }) } - return fetch(`${sqlEndpoint}/metrics`, { - method: 'GET', - headers: { Authorization: `Bearer ${sqlToken}` }, - }) + try { + return await fetch(`${sqlEndpoint}/metrics`, { + method: 'GET', + headers: { Authorization: `Bearer ${sqlToken}` }, + }) + } catch (err) { + throw new respond.InternalServerErrorError({ + message: err instanceof Error + ? err.message + : 'Failed to fetch SQL metrics', + }) + } }, input: OBJ({ deployment: STR("The deployment's URL") }), output: ARR(MetricSchema, 'Collected query metrics'), @@ -731,7 +778,9 @@ const defs = { authorize: withUserSession, fn: async (_ctx, { deployment }) => { const dep = DeploymentsCollection.get(deployment) - if (!dep) throw respond.NotFound({ message: 'Deployment not found' }) + if (!dep) { + throw new respond.NotFoundError({ message: 'Deployment not found' }) + } log.info('fetching-api-doc', { url: dep.url, }) @@ -748,7 +797,7 @@ const defs = { log.error('fetch-api-doc-error', { error: _err instanceof Error ? _err.stack : String(_err), }) - throw respond.InternalServerError({ + throw new respond.InternalServerErrorError({ message: 'Failed to fetch API documentation', }) } @@ -778,7 +827,7 @@ const defs = { metricId: id, error: err instanceof Error ? err.message : String(err), }) - throw respond.InternalServerError({ + throw new respond.InternalServerErrorError({ message: err instanceof Error ? err.message : String(err), }) } diff --git a/api/sql.ts b/api/sql.ts index a2baa6d..101675a 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -11,6 +11,7 @@ import { getProjectFunctions, } from '/api/lib/functions.ts' import { log } from '/api/lib/logger.ts' +import { respond } from '@01edu/api/response' export class SQLQueryError extends Error { constructor(message: string, body: string) { @@ -38,17 +39,26 @@ export async function runSQL( query: string, params?: unknown, ) { - const res = await fetch(`${endpoint}/execute`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ query, params }), - }) - const body = await res.text() - if (res.ok) return JSON.parse(body) - throw new SQLQueryError(`sql endpoint error ${res.status}`, body) + try { + const res = await fetch(`${endpoint}/execute`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query, params }), + }) + const body = await res.text() + if (res.ok) return JSON.parse(body) + throw new SQLQueryError(`sql endpoint error ${res.status}`, body) + } catch (err) { + if (err instanceof SQLQueryError) throw err + throw new respond.InternalServerErrorError({ + message: err instanceof Error + ? err.message + : 'Failed to connect to SQL endpoint', + }) + } } // Dialect detection attempts (run first successful) @@ -247,7 +257,9 @@ const constructWhereClause = ( const { key, comparator, value } = filter const column = columnsMap.get(key) if (!column) { - throw Error(`Invalid filter column: ${key}`) + throw new respond.BadRequestError({ + message: `Invalid filter column: ${key}`, + }) } const safeValue = value.replace(/'/g, "''") whereClauses.push(`${key} ${comparator} '${safeValue}'`) @@ -274,7 +286,9 @@ const constructOrderByClause = ( const { key, order } = sort const column = columnsMap.get(key) if (!column) { - throw Error(`Invalid sort column: ${key}`) + throw new respond.BadRequestError({ + message: `Invalid sort column: ${key}`, + }) } orderClauses.push(`${key} ${order}`) } @@ -287,7 +301,9 @@ export const fetchTablesData = async ( ) => { const { sqlEndpoint, sqlToken } = params.deployment if (!sqlToken || !sqlEndpoint) { - throw Error('Missing SQL endpoint or token') + throw new respond.BadRequestError({ + message: 'Missing SQL endpoint or token for this deployment', + }) } const projectFunctions = getProjectFunctions(params.deployment.projectId) @@ -355,7 +371,9 @@ export const insertTableData = async ( deployment: deployment.url, table, }) - throw Error('Missing SQL endpoint or token') + throw new respond.BadRequestError({ + message: 'Missing SQL endpoint or token for this deployment', + }) } const projectFunctions = getProjectFunctions(deployment.projectId) const transformedData = await applyWriteTransformers( @@ -399,7 +417,9 @@ export const updateTableData = async ( deployment: deployment.url, table, }) - throw Error('Missing SQL endpoint or token') + throw new respond.BadRequestError({ + message: 'Missing SQL endpoint or token for this deployment', + }) } const projectFunctions = getProjectFunctions(deployment.projectId) From f1514dc358236644da5ad394d3edd3561b61cc1e Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Mon, 18 May 2026 09:22:58 +0000 Subject: [PATCH 2/3] Enhance error handling with clearer messages for fetch operations and improve error logging in getOne function --- api/lib/fetcher.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++ api/lmdb-store.ts | 4 +- api/routes.ts | 2 +- 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 api/lib/fetcher.ts diff --git a/api/lib/fetcher.ts b/api/lib/fetcher.ts new file mode 100644 index 0000000..04998f2 --- /dev/null +++ b/api/lib/fetcher.ts @@ -0,0 +1,96 @@ +export class FetchNetworkError extends Error { + constructor( + public url: string, + public override cause?: unknown, + ) { + super(`Network error while fetching ${url}`) + } +} + +export class FetchHttpError extends Error { + constructor( + public url: string, + public status: number, + public body: string, + ) { + super(`HTTP error ${status} while fetching ${url}`) + } +} + +export class FetchJsonError extends Error { + constructor( + public url: string, + public status: number, + public body: string, + public override cause?: unknown, + ) { + super(`Failed to parse JSON from ${url} (status ${status})`) + } +} + +export class FetchTimeoutError extends Error { + constructor( + public url: string, + public timeoutMs: number, + ) { + super(`Request to ${url} timed out after ${timeoutMs}ms`) + } +} + +export function fetchErrorDetails(err: unknown): string { + if (err instanceof FetchHttpError) { + return `HTTP ${err.status}: ${err.body}` + } + if (err instanceof FetchJsonError) { + return `Invalid JSON response: ${err.body}` + } + if (err instanceof FetchNetworkError) { + return `Network error: ${err.message}` + } + if (err instanceof FetchTimeoutError) { + return `Request timeout after ${err.timeoutMs}ms` + } + return err instanceof Error ? err.message : String(err) +} + +export async function fetchJson( + url: string | URL, + init?: RequestInit & { timeoutMs?: number }, +): Promise { + const urlStr = String(url) + let res: Response + + const controller = new AbortController() + let timeoutId: number | undefined + if (init?.timeoutMs) { + timeoutId = setTimeout(() => controller.abort(), init.timeoutMs) + } + + try { + res = await fetch(url, { + ...init, + signal: controller.signal, + }) + } catch (err) { + if ( + init?.timeoutMs && err instanceof DOMException && err.name === 'AbortError' + ) { + throw new FetchTimeoutError(urlStr, init.timeoutMs) + } + throw new FetchNetworkError(urlStr, err) + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId) + } + + const body = await res.text() + + if (!res.ok) { + throw new FetchHttpError(urlStr, res.status, body) + } + + try { + return JSON.parse(body) as T + } catch (err) { + throw new FetchJsonError(urlStr, res.status, body, err) + } +} diff --git a/api/lmdb-store.ts b/api/lmdb-store.ts index 54adac8..d9f1f16 100644 --- a/api/lmdb-store.ts +++ b/api/lmdb-store.ts @@ -12,7 +12,9 @@ export const getOne = async ( if (res.status === 404) return null if (!res.ok) { log.error('store-get-one-failed', { path, id, status: res.status }) - throw new Error(`Store getOne failed (${path}/${id}) with status ${res.status}`) + throw new Error( + `Store getOne failed (${path}/${id}) with status ${res.status}`, + ) } return await res.json() } catch (err) { diff --git a/api/routes.ts b/api/routes.ts index fe9de3b..0018620 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -538,7 +538,7 @@ const defs = { fn: (ctx, logs) => { if (!ctx.session.url) { log.error('deployment-session-missing-url', { - userId: ctx.session.id, + userId: ctx.session.url, }) throw new respond.InternalServerErrorError({ message: 'Deployment URL missing from session', From 2b29c22f476856cf6a69f4b5fcfe6f529bd8c967 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Mon, 18 May 2026 10:18:56 +0000 Subject: [PATCH 3/3] Refactor error handling and improve clarity in API responses across multiple modules --- api/auth.ts | 58 +++++++++++++-------------- api/fix-query.ts | 56 ++++++++++++++------------- api/lib/fetcher.ts | 45 +-------------------- api/lib/google-oauth.ts | 17 -------- api/lmdb-store.ts | 42 ++++++++++++-------- api/routes.ts | 23 ++++++----- api/sql.ts | 35 ++++++++++------- deno.json | 4 +- deno.lock | 86 ++++++++++++++++++++++++++++++----------- 9 files changed, 185 insertions(+), 181 deletions(-) diff --git a/api/auth.ts b/api/auth.ts index dc9f341..3532e76 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -1,10 +1,10 @@ import { ORIGIN } from '/api/lib/env.ts' +import { fetchJson } from '/api/lib/fetcher.ts' import { decodeGoogleJWT, generateStateToken, getGoogleAuthUrl, GOOGLE_OAUTH_CONFIG, - verifyGoogleToken, verifyState, } from '/api/lib/google-oauth.ts' import { respond } from '@01edu/api/response' @@ -78,49 +78,45 @@ export async function handleGoogleCallback( } // Exchange the code for tokens - let tokenResponse: Response + let tokens: GoogleTokens try { - tokenResponse = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + tokens = await fetchJson( + GOOGLE_OAUTH_CONFIG.tokenEndpoint, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + code, + client_id: GOOGLE_OAUTH_CONFIG.clientId, + client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + grant_type: 'authorization_code', + }), }, - body: new URLSearchParams({ - code, - client_id: GOOGLE_OAUTH_CONFIG.clientId, - client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, - redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, - grant_type: 'authorization_code', - }), - }) + ) } catch (err) { - throw new respond.UnauthorizedError({ - message: 'Failed to contact Google OAuth', - details: err instanceof Error ? err.message : 'Network error', - }) - } - - if (!tokenResponse.ok) { - const errorBody = await tokenResponse.text().catch(() => 'unknown') log.error('oauth-token-exchange-failed', { - status: tokenResponse.status, - body: errorBody, + error: err, }) + const message = err instanceof Error ? err.message : 'Unknown error' throw new respond.UnauthorizedError({ - message: 'Failed to exchange authorization code', - details: 'Could not obtain access token from Google', + message: `Failed to exchange authorization code: ${message}`, + error: err, }) } - const tokens = await tokenResponse.json() as GoogleTokens - // Verify and decode the ID token try { - await verifyGoogleToken(tokens.id_token) + await fetchJson( + `https://oauth2.googleapis.com/tokeninfo?id_token=${tokens.id_token}`, + ) } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' throw new respond.UnauthorizedError({ - message: 'Failed to verify Google ID token', - details: err instanceof Error ? err.message : 'Unknown error', + message: `Failed to verify Google ID token: ${message}`, + error: err, }) } diff --git a/api/fix-query.ts b/api/fix-query.ts index 8097acb..6813a6a 100644 --- a/api/fix-query.ts +++ b/api/fix-query.ts @@ -1,8 +1,10 @@ import { render } from '@deno/gfm' import { promptTemplate } from '/api/fix-query-prompt.ts' +import { fetchJson } from '/api/lib/fetcher.ts' import { GEMINI_API_KEY, GEMINI_MODEL } from '/api/lib/env.ts' import { AIAnalysisCacheCollection } from '/api/schema.ts' import { log } from '/api/lib/logger.ts' +import { respond } from '@01edu/api/response' const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:streamGenerateContent?alt=json&key=${GEMINI_API_KEY}` @@ -35,33 +37,35 @@ async function callGemini(payload: string, thinkingLevel: string) { promptLength: prompt.length, }) - const res = await fetch(GEMINI_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contents: [{ role: 'user', parts: [{ text: prompt }] }], - generationConfig: { thinkingConfig: { thinkingLevel } }, - }), - }) - - if (!res.ok) { - const body = await res.text() - log.error('gemini-request-failed', { status: res.status, body }) - throw new Error(`Gemini API error ${res.status}: ${body}`) + try { + const chunks = await fetchJson<{ + candidates?: { + content?: { parts?: { text?: string }[] } + }[] + }[]>(GEMINI_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { thinkingConfig: { thinkingLevel } }, + }), + }) + + const markdown = chunks + .flatMap((chunk) => chunk.candidates ?? []) + .flatMap((candidate) => candidate.content?.parts ?? []) + .map((part) => part.text ?? '') + .join('') + + return render(markdown) + } catch (err) { + log.error('gemini-request-failed', { error: err }) + const message = err instanceof Error ? err.message : 'Unknown error' + throw new respond.InternalServerErrorError({ + message: `Gemini request failed: ${message}`, + error: err, + }) } - - // streamGenerateContent with alt=json returns a JSON array of response chunks - const chunks: { - candidates?: { content?: { parts?: { text?: string }[] } }[] - }[] = await res.json() - - const markdown = chunks - .flatMap((chunk) => chunk.candidates ?? []) - .flatMap((candidate) => candidate.content?.parts ?? []) - .map((part) => part.text ?? '') - .join('') - - return render(markdown) } export async function analyzeQueryWithAI( diff --git a/api/lib/fetcher.ts b/api/lib/fetcher.ts index 04998f2..8730b3d 100644 --- a/api/lib/fetcher.ts +++ b/api/lib/fetcher.ts @@ -28,58 +28,17 @@ export class FetchJsonError extends Error { } } -export class FetchTimeoutError extends Error { - constructor( - public url: string, - public timeoutMs: number, - ) { - super(`Request to ${url} timed out after ${timeoutMs}ms`) - } -} - -export function fetchErrorDetails(err: unknown): string { - if (err instanceof FetchHttpError) { - return `HTTP ${err.status}: ${err.body}` - } - if (err instanceof FetchJsonError) { - return `Invalid JSON response: ${err.body}` - } - if (err instanceof FetchNetworkError) { - return `Network error: ${err.message}` - } - if (err instanceof FetchTimeoutError) { - return `Request timeout after ${err.timeoutMs}ms` - } - return err instanceof Error ? err.message : String(err) -} - export async function fetchJson( url: string | URL, - init?: RequestInit & { timeoutMs?: number }, + init?: RequestInit, ): Promise { const urlStr = String(url) let res: Response - const controller = new AbortController() - let timeoutId: number | undefined - if (init?.timeoutMs) { - timeoutId = setTimeout(() => controller.abort(), init.timeoutMs) - } - try { - res = await fetch(url, { - ...init, - signal: controller.signal, - }) + res = await fetch(url, init) } catch (err) { - if ( - init?.timeoutMs && err instanceof DOMException && err.name === 'AbortError' - ) { - throw new FetchTimeoutError(urlStr, init.timeoutMs) - } throw new FetchNetworkError(urlStr, err) - } finally { - if (timeoutId !== undefined) clearTimeout(timeoutId) } const body = await res.text() diff --git a/api/lib/google-oauth.ts b/api/lib/google-oauth.ts index c2f61d8..40f43e8 100644 --- a/api/lib/google-oauth.ts +++ b/api/lib/google-oauth.ts @@ -73,23 +73,6 @@ export function getGoogleAuthUrl(state: string): string { return `${GOOGLE_OAUTH_CONFIG.authEndpoint}?${params.toString()}` } -export async function verifyGoogleToken(idToken: string) { - try { - const response = await fetch( - `https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`, - ) - if (!response.ok) { - throw new Error('Invalid token') - } - return response.json() - } catch (err) { - throw new Error( - 'Google token verification failed', - { cause: err }, - ) - } -} - export function decodeGoogleJWT(idToken: string) { const [, payload] = idToken.split('.') if (!payload) throw new Error('Invalid ID token format') diff --git a/api/lmdb-store.ts b/api/lmdb-store.ts index d9f1f16..21ce954 100644 --- a/api/lmdb-store.ts +++ b/api/lmdb-store.ts @@ -1,5 +1,7 @@ +import { FetchHttpError, fetchJson } from '/api/lib/fetcher.ts' import { STORE_SECRET, STORE_URL } from '/api/lib/env.ts' import { log } from '/api/lib/logger.ts' +import { respond } from '@01edu/api/response' const headers = { authorization: `Bearer ${STORE_SECRET}` } export const getOne = async ( @@ -8,25 +10,21 @@ export const getOne = async ( ): Promise => { const url = `${STORE_URL}/${path}/${encodeURIComponent(String(id))}` try { - const res = await fetch(url, { headers }) - if (res.status === 404) return null - if (!res.ok) { - log.error('store-get-one-failed', { path, id, status: res.status }) - throw new Error( - `Store getOne failed (${path}/${id}) with status ${res.status}`, - ) - } - return await res.json() + return await fetchJson(url, { headers }) } catch (err) { + if (err instanceof FetchHttpError && err.status === 404) { + return null + } log.error('store-get-one-error', { path, id, - error: err instanceof Error ? err.message : String(err), + error: err, + }) + const message = err instanceof Error ? err.message : 'Unknown error' + throw new respond.InternalServerErrorError({ + message: `Store request failed: ${message}`, + error: err, }) - throw new Error( - `Store request failed (${path}/${id})`, - { cause: err }, - ) } } @@ -35,6 +33,18 @@ export const get = async ( params?: { q?: string; limit?: number; from?: number }, ): Promise => { const q = new URLSearchParams(params as unknown as Record) - const res = await fetch(`${STORE_URL}/${path}/?${q}`, { headers }) - return res.json() + try { + return await fetchJson(`${STORE_URL}/${path}/?${q}`, { headers }) + } catch (err) { + log.error('store-get-error', { + path, + params, + error: err, + }) + const message = err instanceof Error ? err.message : 'Unknown error' + throw new respond.InternalServerErrorError({ + message: `Store request failed: ${message}`, + error: err, + }) + } } diff --git a/api/routes.ts b/api/routes.ts index 0018620..16328c2 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -1,5 +1,6 @@ import { makeRouter, route } from '@01edu/api/router' import type { RequestContext } from '@01edu/api/context' +import { fetchJson } from '/api/lib/fetcher.ts' import { handleGoogleCallback, initiateGoogleAuth } from '/api/auth.ts' import { AdminsCollection, @@ -622,7 +623,7 @@ const defs = { try { const columnsMap = new Map(tableDef.columns.map((c) => [c.name, c])) - return fetchTablesData( + return await fetchTablesData( { ...input, deployment: dep, table }, columnsMap, ) @@ -711,7 +712,11 @@ const defs = { try { const startTime = performance.now() - const data = await runSQL(sqlEndpoint, sqlToken, sql) + const data = await runSQL[]>( + sqlEndpoint, + sqlToken, + sql, + ) const duration = (performance.now() - startTime) / 1000 log.info('sql-query-executed', { deployment, duration }) return { duration, rows: data } @@ -788,17 +793,15 @@ const defs = { const urlStr = dep.url.startsWith('http') ? dep.url : `https://${dep.url}` - const res = await fetch(`${urlStr}/api/doc`, { + return await fetchJson(`${urlStr}/api/doc`, { method: 'GET', }) - if (!res.ok) throw new Error(`Status ${res.status}`) - return await res.json() - } catch (_err) { - log.error('fetch-api-doc-error', { - error: _err instanceof Error ? _err.stack : String(_err), - }) + } catch (err) { + log.error('fetch-api-doc-error', { error: err }) + const message = err instanceof Error ? err.message : 'Unknown error' throw new respond.InternalServerErrorError({ - message: 'Failed to fetch API documentation', + message: `Failed to fetch API documentation: ${message}`, + error: err, }) } }, diff --git a/api/sql.ts b/api/sql.ts index 101675a..704bfc8 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -10,6 +10,7 @@ import { applyWriteTransformers, getProjectFunctions, } from '/api/lib/functions.ts' +import { FetchHttpError, fetchJson, FetchJsonError } from '/api/lib/fetcher.ts' import { log } from '/api/lib/logger.ts' import { respond } from '@01edu/api/response' @@ -33,14 +34,14 @@ export class SQLQueryError extends Error { sqlMessage: string } -export async function runSQL( +export async function runSQL( endpoint: string, token: string, query: string, params?: unknown, -) { +): Promise { try { - const res = await fetch(`${endpoint}/execute`, { + return await fetchJson(`${endpoint}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,15 +49,17 @@ export async function runSQL( }, body: JSON.stringify({ query, params }), }) - const body = await res.text() - if (res.ok) return JSON.parse(body) - throw new SQLQueryError(`sql endpoint error ${res.status}`, body) } catch (err) { - if (err instanceof SQLQueryError) throw err + if (err instanceof FetchHttpError) { + throw new SQLQueryError(`sql endpoint error ${err.status}`, err.body) + } + if (err instanceof FetchJsonError) { + throw new SQLQueryError(`sql endpoint error ${err.status}`, err.body) + } + const message = err instanceof Error ? err.message : 'Unknown error' throw new respond.InternalServerErrorError({ - message: err instanceof Error - ? err.message - : 'Failed to connect to SQL endpoint', + message: `Failed to execute SQL query: ${message}`, + error: err, }) } } @@ -73,7 +76,7 @@ const DETECTION_QUERIES: { name: string; sql: string; matcher: RegExp }[] = [ async function detectDialect(endpoint: string, token: string): Promise { for (const d of DETECTION_QUERIES) { try { - const rows = await runSQL(endpoint, token, d.sql) + const rows = await runSQL(endpoint, token, d.sql) log.debug('dialect-detection', { dialect: d.name, rows }) if (rows.length) { const text = JSON.stringify(rows[0]) @@ -341,7 +344,11 @@ export const fetchTablesData = async ( } ${whereClause} ${orderByClause} ${limitOffsetClause}` const countQuery = `SELECT COUNT(*) as count FROM ${params.table} ${whereClause}` - const rows = await runSQL(sqlEndpoint, sqlToken, query) + const rows = await runSQL[]>( + sqlEndpoint, + sqlToken, + query, + ) // Apply read transformer pipeline const transformedRows = await applyReadTransformers( @@ -355,7 +362,9 @@ export const fetchTablesData = async ( return { rows: transformedRows, totalRows: limit > 0 - ? ((await runSQL(sqlEndpoint, sqlToken, countQuery))[0].count) as number + ? ((await runSQL<{ count: number }[]>(sqlEndpoint, sqlToken, countQuery))[ + 0 + ].count) as number : rows.length, } } diff --git a/deno.json b/deno.json index 2974c97..09b72af 100644 --- a/deno.json +++ b/deno.json @@ -33,11 +33,9 @@ "./": "./", "/": "./", "@01edu/api": "jsr:@01edu/api@^0.2.7", - "@01edu/api": "jsr:@01edu/api@^0.2.7", "@01edu/api-client": "jsr:@01edu/api-client@^0.2.6", "@01edu/api-proxy": "jsr:@01edu/api-proxy@^0.2.1", "@01edu/signal-router": "npm:@01edu/signal-router@^0.2.3", - "@01edu/signal-router": "npm:@01edu/signal-router@^0.2.3", "@01edu/time": "jsr:@01edu/time@^0.1.0", "@deno/vite-plugin": "npm:@deno/vite-plugin@^2.0.2", "@std/assert": "jsr:@std/assert@^1.0.19", @@ -49,7 +47,7 @@ "@std/path": "jsr:@std/path@^1.1.4", "@std/testing": "jsr:@std/testing@^1.0.18", "vite": "npm:vite@^8.0.13", - "preact": "npm:preact@^10.29.1", + "preact": "npm:preact@^10.29.2", "@preact/preset-vite": "npm:@preact/preset-vite@^2.10.5", "@preact/signals": "npm:@preact/signals@^2.9.0", "@clickhouse/client": "npm:@clickhouse/client@^1.18.5", diff --git a/deno.lock b/deno.lock index 744d22d..3d15a9b 100644 --- a/deno.lock +++ b/deno.lock @@ -5,15 +5,21 @@ "jsr:@01edu/api-proxy@~0.2.1": "0.2.1", "jsr:@01edu/api@~0.2.7": "0.2.7", "jsr:@01edu/time@0.1": "0.1.0", + "jsr:@01edu/types@~0.2.6": "0.2.6", + "jsr:@cd/sqlite@~0.13.1": "0.13.1", "jsr:@deno/gfm@0.12.0": "0.12.0", "jsr:@denosaurs/emoji@~0.3.1": "0.3.1", + "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@std/assert@^1.0.19": "1.0.19", "jsr:@std/cli@^1.0.29": "1.0.29", "jsr:@std/crypto@^1.1.0": "1.1.0", "jsr:@std/data-structures@^1.0.11": "1.0.11", + "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@1": "1.0.10", "jsr:@std/fmt@^1.0.10": "1.0.10", "jsr:@std/fmt@^1.0.9": "1.0.10", + "jsr:@std/fs@1": "1.0.23", "jsr:@std/fs@^1.0.23": "1.0.23", "jsr:@std/html@^1.0.6": "1.0.6", "jsr:@std/http@^1.0.25": "1.1.0", @@ -22,35 +28,39 @@ "jsr:@std/internal@^1.0.13": "1.0.13", "jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@1": "1.1.4", + "jsr:@std/path@1.0": "1.0.9", "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/streams@^1.1.0": "1.1.0", "jsr:@std/testing@^1.0.18": "1.0.18", - "npm:@01edu/signal-router@~0.2.3": "0.2.3_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1", + "npm:@01edu/signal-router@~0.2.3": "0.2.3_@preact+signals@2.9.0__preact@10.29.2_preact@10.29.2", "npm:@clickhouse/client@^1.18.5": "1.18.5", "npm:@deno/vite-plugin@^2.0.2": "2.0.2_vite@8.0.13", - "npm:@preact/preset-vite@^2.10.5": "2.10.5_@babel+core@7.29.0_vite@8.0.13_preact@10.29.1", - "npm:@preact/signals@^2.9.0": "2.9.0_preact@10.29.1", + "npm:@preact/preset-vite@^2.10.5": "2.10.5_vite@8.0.13_preact@10.29.2", + "npm:@preact/signals@^2.9.0": "2.9.0_preact@10.29.2", "npm:@tailwindcss/vite@^4.3.0": "4.3.0_vite@8.0.13", "npm:daisyui@^5.5.19": "5.5.19", "npm:github-slugger@2": "2.0.0", "npm:he@^1.2.0": "1.2.0", "npm:katex@0.16": "0.16.46", - "npm:lucide-preact@^1.16.0": "1.16.0_preact@10.29.1", + "npm:lucide-preact@^1.16.0": "1.16.0_preact@10.29.2", "npm:marked-alert@^2.1.2": "2.1.2_marked@17.0.6", "npm:marked-footnote@^1.4.0": "1.4.0_marked@17.0.6", "npm:marked-gfm-heading-id@^4.1.3": "4.1.4_marked@17.0.6", "npm:marked@^17.0.1": "17.0.6", - "npm:preact@^10.29.1": "10.29.1", + "npm:preact@^10.29.2": "10.29.2", "npm:prismjs@^1.30.0": "1.30.0", "npm:sanitize-html@^2.17.0": "2.17.4", "npm:tailwindcss@^4.3.0": "4.3.0", - "npm:vite@^8.0.13": "8.0.13" + "npm:vite@^8.0.13": "8.0.13", + "npm:vite@^8.0.3": "8.0.13" }, "jsr": { "@01edu/api@0.2.7": { "integrity": "17198ab087829f38dafc17e08cd7fd72ce04118a0058019d6af055f5ab3ea244", "dependencies": [ "jsr:@01edu/time", + "jsr:@01edu/types", "jsr:@std/fmt@^1.0.9", "jsr:@std/http@^1.0.25" ] @@ -58,15 +68,32 @@ "@01edu/api-client@0.2.6": { "integrity": "b5cd8e30259735734c2345312f335ada0ba4dfe28fc03e33f3ae478b7c810173", "dependencies": [ + "jsr:@01edu/types", "npm:@preact/signals" ] }, "@01edu/api-proxy@0.2.1": { - "integrity": "ea188b029a324c920c22dfe52f6cd389fcd3764124dc405874cdfc9f4d78f271" + "integrity": "ea188b029a324c920c22dfe52f6cd389fcd3764124dc405874cdfc9f4d78f271", + "dependencies": [ + "npm:vite@^8.0.3" + ] }, "@01edu/time@0.1.0": { "integrity": "638ea7d2d00bfbf487e5262b4d6207de7f2101793d0d27a63d492e113a07dcb2" }, + "@01edu/types@0.2.6": { + "integrity": "a4ef7157a43b1fce692687c131a456957395a2ab94ff2d0c593ad0e8d805232f", + "dependencies": [ + "jsr:@cd/sqlite" + ] + }, + "@cd/sqlite@0.13.1": { + "integrity": "b1399921167e28857f15ad2728759d83b28471d6571a4a9ef8cb434fa0a8a9fe", + "dependencies": [ + "jsr:@denosaurs/plug", + "jsr:@std/path@1.0" + ] + }, "@deno/gfm@0.12.0": { "integrity": "9b2d8f3e3d5673da5b2e8613d36cf38619ac5e3bdeb895d35472a16870fb147a", "dependencies": [ @@ -85,6 +112,15 @@ "@denosaurs/emoji@0.3.1": { "integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b" }, + "@denosaurs/plug@1.1.0": { + "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", + "dependencies": [ + "jsr:@std/encoding@1", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", + "jsr:@std/path@1" + ] + }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ @@ -110,7 +146,7 @@ "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ "jsr:@std/internal@^1.0.12", - "jsr:@std/path" + "jsr:@std/path@^1.1.4" ] }, "@std/html@1.0.6": { @@ -120,13 +156,13 @@ "integrity": "265cd9a589fea924c5bb0bbed8bebb4bb2fa19129f760bd014e78dbd7a365a51", "dependencies": [ "jsr:@std/cli", - "jsr:@std/encoding", + "jsr:@std/encoding@^1.0.10", "jsr:@std/fmt@^1.0.10", - "jsr:@std/fs", + "jsr:@std/fs@^1.0.23", "jsr:@std/html", "jsr:@std/media-types", "jsr:@std/net", - "jsr:@std/path", + "jsr:@std/path@^1.1.4", "jsr:@std/streams" ] }, @@ -139,6 +175,9 @@ "@std/net@1.0.6": { "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" }, + "@std/path@1.0.9": { + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + }, "@std/path@1.1.4": { "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", "dependencies": [ @@ -153,14 +192,14 @@ "dependencies": [ "jsr:@std/assert", "jsr:@std/data-structures", - "jsr:@std/fs", + "jsr:@std/fs@^1.0.23", "jsr:@std/internal@^1.0.13", - "jsr:@std/path" + "jsr:@std/path@^1.1.4" ] } }, "npm": { - "@01edu/signal-router@0.2.3_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1": { + "@01edu/signal-router@0.2.3_@preact+signals@2.9.0__preact@10.29.2_preact@10.29.2": { "integrity": "sha512-Eg2ORuigaA8i3vM+Lr4k0P7+A53vZ3mKb2wsVnbBslkItaebEFwPUDxXiz2zDCJYHd06wLdvd64r/VRQQw6XFw==", "dependencies": [ "@preact/signals", @@ -424,7 +463,7 @@ "@oxc-project/types@0.130.0": { "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==" }, - "@preact/preset-vite@2.10.5_@babel+core@7.29.0_vite@8.0.13_preact@10.29.1": { + "@preact/preset-vite@2.10.5_vite@8.0.13_preact@10.29.2": { "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", "dependencies": [ "@babel/core", @@ -444,7 +483,7 @@ "@preact/signals-core@1.14.2": { "integrity": "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==" }, - "@preact/signals@2.9.0_preact@10.29.1": { + "@preact/signals@2.9.0_preact@10.29.2": { "integrity": "sha512-hYrY0KyUqkDgOl1qba/JGn6y81pXnurn21PMaxfcMwdncdZ3M/oVdmpTvEnsGjh48dIwDVc7bjWHqIsngSjYug==", "dependencies": [ "@preact/signals-core", @@ -454,7 +493,7 @@ "@prefresh/babel-plugin@0.5.3": { "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==" }, - "@prefresh/core@1.5.10_preact@10.29.1": { + "@prefresh/core@1.5.10_preact@10.29.2": { "integrity": "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w==", "dependencies": [ "preact" @@ -463,7 +502,7 @@ "@prefresh/utils@1.2.1": { "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==" }, - "@prefresh/vite@2.4.12_preact@10.29.1_vite@8.0.13": { + "@prefresh/vite@2.4.12_preact@10.29.2_vite@8.0.13": { "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", "dependencies": [ "@babel/core", @@ -942,7 +981,7 @@ "yallist" ] }, - "lucide-preact@1.16.0_preact@10.29.1": { + "lucide-preact@1.16.0_preact@10.29.2": { "integrity": "sha512-Sa1XSig6iWCFhdrqS+vT1EFJH4Yos2Q7H/EMawql1K+D9qjNRzjMutPySak0iMYto6PneHYAWoxnDBBlryjngw==", "dependencies": [ "preact" @@ -1020,8 +1059,8 @@ "source-map-js" ] }, - "preact@10.29.1": { - "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==" + "preact@10.29.2": { + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==" }, "prismjs@1.30.0": { "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==" @@ -1140,6 +1179,9 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" } }, + "remote": { + "https://gistcdn.githack.com/kigiri/21df06d173fcdced5281b86ba6ac1382/raw/crypto.js": "e85976e655898538dbade9d87b05ca0a6bb167b3128cd4098622000a582f5f6d" + }, "workspace": { "dependencies": [ "jsr:@01edu/api-client@~0.2.6", @@ -1163,7 +1205,7 @@ "npm:@tailwindcss/vite@^4.3.0", "npm:daisyui@^5.5.19", "npm:lucide-preact@^1.16.0", - "npm:preact@^10.29.1", + "npm:preact@^10.29.2", "npm:tailwindcss@^4.3.0", "npm:vite@^8.0.13" ]