diff options
| author | benj <benj@rse8.com> | 2026-05-01 09:36:21 +0800 |
|---|---|---|
| committer | benj <benj@rse8.com> | 2026-05-01 09:36:21 +0800 |
| commit | 850f4f826b536d913235e174dc07aef74e51bf60 (patch) | |
| tree | a2806da6c0ed5c48d21178e0c6c280d5a40ccd38 /web/api/src | |
| parent | 6605e2cc428e3bdaa174ccc432941eab8c5d61cb (diff) | |
| download | tidyindex-850f4f826b536d913235e174dc07aef74e51bf60.tar tidyindex-850f4f826b536d913235e174dc07aef74e51bf60.tar.gz tidyindex-850f4f826b536d913235e174dc07aef74e51bf60.tar.bz2 tidyindex-850f4f826b536d913235e174dc07aef74e51bf60.tar.lz tidyindex-850f4f826b536d913235e174dc07aef74e51bf60.tar.xz tidyindex-850f4f826b536d913235e174dc07aef74e51bf60.tar.zst tidyindex-850f4f826b536d913235e174dc07aef74e51bf60.zip | |
Diffstat (limited to 'web/api/src')
| -rw-r--r-- | web/api/src/auth.ts | 49 | ||||
| -rw-r--r-- | web/api/src/db.ts | 72 | ||||
| -rw-r--r-- | web/api/src/dev-server.ts | 16 | ||||
| -rw-r--r-- | web/api/src/index.ts | 30 |
4 files changed, 158 insertions, 9 deletions
diff --git a/web/api/src/auth.ts b/web/api/src/auth.ts new file mode 100644 index 0000000..d22f26d --- /dev/null +++ b/web/api/src/auth.ts @@ -0,0 +1,49 @@ +import { createHash } from 'node:crypto'; +import type { MiddlewareHandler } from 'hono'; + +import { lookupApiKey, type ApiKeyLookup } from './db'; + +export interface AuthVars { + apiKey: ApiKeyLookup; +} + +function sha256Hex(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +/** + * Hono middleware: require a valid `Authorization: Bearer ti_...` header. + * On success, attaches the resolved ApiKeyLookup to c.var.apiKey so the + * handler can see the caller's account + plan + key. + */ +export const auth: MiddlewareHandler<{ Variables: AuthVars }> = async ( + c, + next +) => { + const header = + c.req.header('authorization') ?? c.req.header('Authorization'); + if (!header) { + return c.json({ error: 'Missing Authorization header' }, 401); + } + + const match = /^Bearer\s+(\S+)$/i.exec(header); + if (!match) { + return c.json( + { error: 'Authorization header must be `Bearer <api_key>`' }, + 401 + ); + } + + const token = match[1]!; + if (!token.startsWith('ti_')) { + return c.json({ error: 'Invalid API key format' }, 401); + } + + const key = lookupApiKey(sha256Hex(token)); + if (!key) { + return c.json({ error: 'Invalid or revoked API key' }, 401); + } + + c.set('apiKey', key); + return next(); +}; diff --git a/web/api/src/db.ts b/web/api/src/db.ts new file mode 100644 index 0000000..39fe536 --- /dev/null +++ b/web/api/src/db.ts @@ -0,0 +1,72 @@ +import Database from 'better-sqlite3'; +import { resolve } from 'node:path'; + +// TEMPORARY dev wiring. The API reads directly from the SvelteKit UI's +// SQLite file so "create a key in the UI → use it in the API" works with +// zero infra. This whole module gets replaced when the API moves to +// @tidyindex/core + Drizzle + Neon (or self-hosted Postgres via Hyperdrive). +const DB_PATH = resolve( + process.env.DATABASE_PATH ?? '../ui/data/dashboard.db' +); + +export const db = new Database(DB_PATH); +db.pragma('foreign_keys = ON'); + +export interface ApiKeyLookup { + keyId: string; + name: string; + scopes: string[]; + account: { + id: string; + email: string | null; + plan: string; + }; +} + +const lookupStmt = db.prepare< + [string], + { + key_id: string; + key_name: string; + key_scopes: string; + account_id: string; + account_email: string | null; + account_plan: string; + } +>(` + SELECT + k.id AS key_id, + k.name AS key_name, + k.scopes AS key_scopes, + a.id AS account_id, + a.email AS account_email, + a.plan AS account_plan + FROM api_keys k + JOIN accounts a ON a.id = k.account_id + WHERE k.key_hash = ? AND k.active = 1 +`); + +const touchStmt = db.prepare<[number, string]>( + `UPDATE api_keys SET last_used_at = ? WHERE id = ?` +); + +export function lookupApiKey(hash: string): ApiKeyLookup | null { + const row = lookupStmt.get(hash); + if (!row) return null; + + // Update last_used_at so the UI shows the key as active. Cheap, + // synchronous, runs on the hot path — fine for dev; in prod this + // becomes a buffered write on the AccountMeter Durable Object. + touchStmt.run(Date.now(), row.key_id); + + return { + keyId: row.key_id, + name: row.key_name, + scopes: JSON.parse(row.key_scopes) as string[], + account: { + id: row.account_id, + email: row.account_email, + plan: row.account_plan + } + }; +} diff --git a/web/api/src/dev-server.ts b/web/api/src/dev-server.ts new file mode 100644 index 0000000..4ea4735 --- /dev/null +++ b/web/api/src/dev-server.ts @@ -0,0 +1,16 @@ +import { serve } from '@hono/node-server'; +import app from './index'; + +const PORT = Number(process.env.PORT ?? 5555); + +serve( + { + fetch: app.fetch, + port: PORT + }, + (info) => { + console.log( + `[tidyindex-api] listening on http://localhost:${info.port}` + ); + } +); diff --git a/web/api/src/index.ts b/web/api/src/index.ts index f30fda1..9e71dc5 100644 --- a/web/api/src/index.ts +++ b/web/api/src/index.ts @@ -1,20 +1,32 @@ import { Hono } from 'hono'; -import { CORE_VERSION } from '@tidyindex/core'; +import { cors } from 'hono/cors'; -export interface Env { - // DATABASE_URL: string; - // KEY_CACHE: KVNamespace; - // ACCOUNT_METER: DurableObjectNamespace; -} +import { auth, type AuthVars } from './auth'; -const app = new Hono<{ Bindings: Env }>(); +const app = new Hono<{ Variables: AuthVars }>(); +// Permissive CORS. The API is a public paid service authed by Bearer +// token, not cookies, so there is no origin trust boundary to defend. +app.use('*', cors({ origin: '*' })); + +// Unauthenticated liveness endpoint. app.get('/', (c) => c.json({ name: 'tidyindex-api', - version: '0', - core: CORE_VERSION + version: '0' }) ); +// Everything past here requires a valid API key. +app.use('/ping', auth); +app.get('/ping', (c) => { + const key = c.get('apiKey'); + return c.json({ + message: 'pong', + account: key.account.email ?? key.account.id, + plan: key.account.plan, + key: key.name + }); +}); + export default app; |
