diff options
Diffstat (limited to 'web/api')
| -rw-r--r-- | web/api/.env.example | 8 | ||||
| -rw-r--r-- | web/api/.gitignore | 3 | ||||
| -rw-r--r-- | web/api/package.json | 8 | ||||
| -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 | ||||
| -rw-r--r-- | web/api/tsconfig.json | 6 |
8 files changed, 179 insertions, 13 deletions
diff --git a/web/api/.env.example b/web/api/.env.example new file mode 100644 index 0000000..84d6d73 --- /dev/null +++ b/web/api/.env.example @@ -0,0 +1,8 @@ +# Port the dev API server listens on. +PORT=5555 + +# Path to the SvelteKit UI's SQLite DB. The API reads this directly +# during bootstrap so keys created in the UI work immediately in the API. +# Temporary — replaced when the API moves to Neon/Postgres. Relative to +# web/api/ when running `pnpm dev`. +DATABASE_PATH=../ui/data/dashboard.db diff --git a/web/api/.gitignore b/web/api/.gitignore index a934447..bf5ae24 100644 --- a/web/api/.gitignore +++ b/web/api/.gitignore @@ -1,3 +1,6 @@ .wrangler/ .dev.vars +.env +.env.* +!.env.example node_modules/ diff --git a/web/api/package.json b/web/api/package.json index 108f52b..9a6d0e2 100644 --- a/web/api/package.json +++ b/web/api/package.json @@ -4,16 +4,22 @@ "private": true, "type": "module", "scripts": { - "dev": "wrangler dev", + "dev": "tsx watch src/dev-server.ts", + "dev:worker": "wrangler dev", "deploy": "wrangler deploy", "check": "tsc --noEmit" }, "dependencies": { + "@hono/node-server": "^1.13.0", "@tidyindex/core": "workspace:*", + "better-sqlite3": "^11.3.0", "hono": "^4.6.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240925.0", + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^22.7.0", + "tsx": "^4.19.0", "typescript": "^5.6.0", "wrangler": "^3.78.0" } 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; diff --git a/web/api/tsconfig.json b/web/api/tsconfig.json index 7446044..9c6750a 100644 --- a/web/api/tsconfig.json +++ b/web/api/tsconfig.json @@ -3,10 +3,10 @@ "compilerOptions": { "rootDir": "src", "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], + "types": ["node"], "noEmit": true, - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" + "declaration": false, + "declarationMap": false }, "include": ["src/**/*.ts"] } |
