diff --git a/bun.lock b/bun.lock index 99fb3b26..06189305 100644 --- a/bun.lock +++ b/bun.lock @@ -90,9 +90,11 @@ "version": "0.0.0", "dependencies": { "drizzle-orm": "^0.44.6", + "hono": "catalog:", "ioredis": "^5.8.2", "postgres": "^3.4.7", "ulid": "^3.0.1", + "xdg-basedir": "^5.1.0", "zod": "catalog:", "zod-openapi": "^5.4.3", }, @@ -109,7 +111,7 @@ "name": "@nestri/function", "dependencies": { "@openauthjs/openauth": "catalog:", - "hono": "^4.10.1", + "hono": "catalog:", "hono-openapi": "^1.1.0", "zod": "catalog:", }, @@ -249,6 +251,7 @@ "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.0", "@types/node": "22.13.9", + "hono": "^4.10.1", "steam-session": "1.9.3", "zod": "^4.1.12", }, @@ -2939,6 +2942,8 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], diff --git a/package.json b/package.json index ad57dafe..a6b1581d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/bun": "1.3.0", "@types/node": "22.13.9", "@tsconfig/node22": "22.0.2", + "hono": "^4.10.1", "steam-session": "1.9.3", "zod": "^4.1.12" } diff --git a/packages/core/package.json b/packages/core/package.json index beff35ba..6ec41e62 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,9 +20,11 @@ }, "dependencies": { "drizzle-orm": "^0.44.6", + "hono": "catalog:", "ioredis": "^5.8.2", "postgres": "^3.4.7", "ulid": "^3.0.1", + "xdg-basedir": "^5.1.0", "zod": "catalog:", "zod-openapi": "^5.4.3" } diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts index ccb7e04a..bac29ee7 100644 --- a/packages/core/src/error.ts +++ b/packages/core/src/error.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ContentfulStatusCode } from "hono/utils/http-status"; /** * Standard error response schema used for OpenAPI documentation @@ -108,7 +109,7 @@ export class VisibleError extends Error { /** * Convert this error to an HTTP status code */ - public statusCode(): number { + public statusCode(): ContentfulStatusCode { switch (this.type) { case "validation": return 400; diff --git a/packages/core/src/global/index.ts b/packages/core/src/global/index.ts new file mode 100644 index 00000000..1165a70b --- /dev/null +++ b/packages/core/src/global/index.ts @@ -0,0 +1,50 @@ +import fs from "fs/promises"; +import path from "path"; +import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"; + +const app = "nestri"; + +const data = path.join(xdgData!, app); +const cache = path.join(xdgCache!, app); +const config = path.join(xdgConfig!, app); +const state = path.join(xdgState!, app); + +export namespace Global { + export const Path = { + data, + bin: path.join(data, "bin"), + log: path.join(data, "log"), + cache, + config, + state, + } as const; +} + +await Promise.all([ + fs.mkdir(Global.Path.data, { recursive: true }), + fs.mkdir(Global.Path.config, { recursive: true }), + fs.mkdir(Global.Path.state, { recursive: true }), + fs.mkdir(Global.Path.log, { recursive: true }), + fs.mkdir(Global.Path.bin, { recursive: true }), +]); + +const CACHE_VERSION = "0"; + +const version = await Bun.file(path.join(Global.Path.cache, "version")) + .text() + .catch(() => "0"); + +if (version !== CACHE_VERSION) { + try { + const contents = await fs.readdir(Global.Path.cache); + await Promise.all( + contents.map((item) => + fs.rm(path.join(Global.Path.cache, item), { + recursive: true, + force: true, + }), + ), + ); + } catch (e) {} + await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION); +} diff --git a/packages/core/src/utils/log.ts b/packages/core/src/utils/log.ts index 710c2dd1..c2224a5e 100644 --- a/packages/core/src/utils/log.ts +++ b/packages/core/src/utils/log.ts @@ -1,55 +1,192 @@ +import z from "zod/v4"; +import path from "path"; +import fs from "fs/promises"; import { Context } from "../context.js"; +import { Global } from "../global/index.js"; export namespace Log { const ctx = Context.create<{ tags: Record; }>(); + export const Level = z + .enum(["DEBUG", "INFO", "WARN", "ERROR"]) + .meta({ ref: "LogLevel", description: "Log level" }); + export type Level = z.infer; + + const levelPriority: Record = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + }; + + let level: Level = "INFO"; + + function shouldLog(input: Level): boolean { + return levelPriority[input] >= levelPriority[level]; + } + + export type Logger = { + debug(message?: any, extra?: Record): void; + info(message?: any, extra?: Record): void; + error(message?: any, extra?: Record): void; + warn(message?: any, extra?: Record): void; + tag(key: string, value: string): Logger; + clone(): Logger; + time( + message: string, + extra?: Record, + ): { + stop(): void; + [Symbol.dispose](): void; + }; + }; + + const loggers = new Map(); + + export const Default = create({ service: "default" }); + + export interface Options { + print: boolean; + dev?: boolean; + level?: Level; + } + + let logpath = ""; + export function file() { + return logpath; + } + + export async function init(options: Options) { + if (options.level) level = options.level; + cleanup(Global.Path.log); + if (options.print) return; + logpath = path.join( + Global.Path.log, + options.dev + ? "dev.log" + : `${new Date().toISOString().split(".")[0]?.replace(/:/g, "")}.log`, + ); + const logfile = Bun.file(logpath); + await fs.truncate(logpath).catch(() => {}); + const writer = logfile.writer(); + process.stderr.write = (msg) => { + writer.write(msg); + writer.flush(); + return true; + }; + } + + async function cleanup(dir: string) { + const glob = new Bun.Glob("????-??-??T??????.log"); + const files = await Array.fromAsync( + glob.scan({ + cwd: dir, + absolute: true, + }), + ); + if (files.length <= 5) return; + + const filesToDelete = files.slice(0, -10); + await Promise.all( + filesToDelete.map((file) => fs.unlink(file).catch(() => {})), + ); + } + + function formatError(error: Error, depth = 0): string { + const result = error.message; + return error.cause instanceof Error && depth < 10 + ? result + " Caused by: " + formatError(error.cause, depth + 1) + : result; + } + + let last = Date.now(); export function create(tags?: Record) { tags = tags || {}; - const result = { - info(msg: string, extra?: Record) { - const prefix = Object.entries({ - ...use().tags, - ...tags, - ...extra, + const service = tags["service"]; + if (service && typeof service === "string") { + const cached = loggers.get(service); + if (cached) { + return cached; + } + } + + function build(message: any, extra?: Record) { + const prefix = Object.entries({ + ...use().tags, + ...tags, + ...extra, + }) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + const prefix = `${key}=`; + if (value instanceof Error) return prefix + formatError(value); + if (typeof value === "object") return prefix + JSON.stringify(value); + return prefix + value; }) - .map(([key, value]) => `${key}=${value}`) - .join(" "); - console.log(prefix, msg); - return result; + .join(" "); + const next = new Date(); + const diff = next.getTime() - last; + last = next.getTime(); + return ( + [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message] + .filter(Boolean) + .join(" ") + "\n" + ); + } + const result: Logger = { + debug(message?: any, extra?: Record) { + if (shouldLog("DEBUG")) { + process.stderr.write("DEBUG " + build(message, extra)); + } }, - warn(msg: string, extra?: Record) { - const prefix = Object.entries({ - ...use().tags, - ...tags, - ...extra, - }) - .map(([key, value]) => `${key}=${value}`) - .join(" "); - console.warn(prefix, msg); - return result; + info(message?: any, extra?: Record) { + if (shouldLog("INFO")) { + process.stderr.write("INFO " + build(message, extra)); + } }, - error(error: Error) { - const prefix = Object.entries({ - ...use().tags, - ...tags, - }) - .map(([key, value]) => `${key}=${value}`) - .join(" "); - console.error(prefix, error); - return result; + error(message?: any, extra?: Record) { + if (shouldLog("ERROR")) { + process.stderr.write("ERROR " + build(message, extra)); + } + }, + warn(message?: any, extra?: Record) { + if (shouldLog("WARN")) { + process.stderr.write("WARN " + build(message, extra)); + } }, tag(key: string, value: string) { - tags[key] = value; + if (tags) tags[key] = value; return result; }, clone() { return Log.create({ ...tags }); }, + time(message: string, extra?: Record) { + const now = Date.now(); + result.info(message, { status: "started", ...extra }); + function stop() { + result.info(message, { + status: "completed", + duration: Date.now() - now, + ...extra, + }); + } + return { + stop, + [Symbol.dispose]() { + stop(); + }, + }; + }, }; + if (service && typeof service === "string") { + loggers.set(service, result); + } + return result; } diff --git a/packages/function/package.json b/packages/function/package.json index 04c37298..72a47a82 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@openauthjs/openauth": "catalog:", - "hono": "^4.10.1", + "hono": "catalog:", "hono-openapi": "^1.1.0", "zod": "catalog:" } diff --git a/packages/function/src/api/index.ts b/packages/function/src/api/index.ts index 2a0a3049..f53b3bd2 100644 --- a/packages/function/src/api/index.ts +++ b/packages/function/src/api/index.ts @@ -1,69 +1,48 @@ -import { subjects } from "../subjects"; -import { MiddlewareHandler } from "hono"; -import { Actor } from "@nestri/core/actor"; -import { Api } from "@nestri/core/api/index"; +import { Hono } from "hono"; +import { auth } from "./utils/auth"; +import { logger } from "hono/logger"; import { Log } from "@nestri/core/utils/log"; -import { createClient } from "@openauthjs/openauth/client"; +import { HTTPException } from "hono/http-exception"; import { VisibleError, ErrorCodes } from "@nestri/core/error"; -const client = createClient({ - clientID: "api", - issuer: process.env.AUTH_URL, - subjects, -}); +const log = Log.create({ service: "api" }); -const log = Log.create({ namespace: "api" }); +export const app = new Hono(); +app + .use(logger()) + .use(async (c, next) => { + c.header("Cache-Control", "no-store"); + return next(); + }) + .use(auth); -const auth: MiddlewareHandler = async (c, next) => { - const authHeader = - c.req.query("authorization") ?? c.req.header("authorization"); - if (authHeader) { - const match = authHeader.match(/^Bearer (.+)$/); - if (!match || !match[1]) { - throw new VisibleError( - "authentication", - ErrorCodes.Authentication.UNAUTHORIZED, - "Bearer token not found or improperly formatted", - ); - } - const bearerToken = match[1]; - - if (bearerToken?.startsWith("nst_")) { - const token = await Api.Personal.fromToken(bearerToken); - if (!token) - throw new VisibleError( - "authentication", - ErrorCodes.Authentication.INVALID_TOKEN, - "Invalid personal access token", - ); - return Actor.provide( - "token", - { - teamID: token.teamID, - tokenID: token.id, - }, - next, - ); - } - - const result = await client.verify(bearerToken!); - if (result.err) - throw new VisibleError( - "authentication", - ErrorCodes.Authentication.INVALID_TOKEN, - "Invalid bearer token", - ); - if (result.subject.type === "team") { - return Actor.provide( - "team", - { - email: result.subject.properties.email, - teamID: result.subject.properties.teamID, - }, - next, - ); - } +app.onError((error, c) => { + // Handle our custom VisibleError + if (error instanceof VisibleError) { + return c.json(error.toResponse(), error.statusCode()); } - return Actor.provide("public", {}, next); -}; + // Handle HTTP exceptions + if (error instanceof HTTPException) { + console.error("http error:", error); + return c.json( + { + type: "validation", + code: ErrorCodes.Validation.INVALID_PARAMETER, + message: "Invalid request", + }, + 400, + ); + } + + // Handle any other errors as internal server errors + log.error("Unhandled API error:", { error }); + return c.json( + { + type: "internal", + code: ErrorCodes.Server.INTERNAL_ERROR, + message: "Internal server error", + }, + 500, + ); +}); diff --git a/packages/function/src/api/utils/auth.ts b/packages/function/src/api/utils/auth.ts new file mode 100644 index 00000000..bfa57e87 --- /dev/null +++ b/packages/function/src/api/utils/auth.ts @@ -0,0 +1,67 @@ +import { subjects } from "@/subjects"; +import { MiddlewareHandler } from "hono"; +import { Actor } from "@nestri/core/actor"; +import { Api } from "@nestri/core/api/index"; +import { createClient } from "@openauthjs/openauth/client"; +import { VisibleError, ErrorCodes } from "@nestri/core/error"; + +const client = createClient({ + clientID: "api", + issuer: process.env.AUTH_URL, + subjects, +}); + +export const auth: MiddlewareHandler = async (c, next) => { + const authHeader = + c.req.query("authorization") ?? c.req.header("authorization"); + if (authHeader) { + const match = authHeader.match(/^Bearer (.+)$/); + if (!match || !match[1]) { + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.UNAUTHORIZED, + "Bearer token not found or improperly formatted", + ); + } + + const bearerToken = match[1]; + + if (bearerToken?.startsWith("nst_")) { + const token = await Api.Personal.fromToken(bearerToken); + if (!token) + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.INVALID_TOKEN, + "Invalid personal access token", + ); + return Actor.provide( + "token", + { + teamID: token.teamID, + tokenID: token.id, + }, + next, + ); + } + + const result = await client.verify(bearerToken!); + if (result.err) + throw new VisibleError( + "authentication", + ErrorCodes.Authentication.INVALID_TOKEN, + "Invalid bearer token", + ); + if (result.subject.type === "team") { + return Actor.provide( + "team", + { + email: result.subject.properties.email, + teamID: result.subject.properties.teamID, + }, + next, + ); + } + } + + return Actor.provide("public", {}, next); +}; diff --git a/packages/function/src/auth/index.ts b/packages/function/src/auth/index.ts index 4c669454..9825bd9c 100644 --- a/packages/function/src/auth/index.ts +++ b/packages/function/src/auth/index.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { RedisClient } from "bun"; -import { subjects } from "../subjects.js"; +import { logger } from "hono/logger"; +import { subjects } from "@/subjects.js"; import { issuer } from "@openauthjs/openauth"; import { Team } from "@nestri/core/team/index"; import { RedisStorage } from "./src/storage.js"; @@ -62,12 +63,10 @@ const app = issuer({ async allow(input) { const url = new URL(input.redirectURI); return ( - url.hostname.endsWith("localhost") || - url.hostname.endsWith("nestri.io") || - url.hostname.endsWith("console.nestri.io") + url.hostname.endsWith("localhost") || url.hostname.endsWith("nestri.io") ); }, -}); +}).use(logger()); export default { port: 3000,