aboutsummaryrefslogtreecommitdiff
path: root/web/api
diff options
context:
space:
mode:
authorbenj <benj@rse8.com>2026-05-01 09:36:21 +0800
committerbenj <benj@rse8.com>2026-05-01 09:36:21 +0800
commit850f4f826b536d913235e174dc07aef74e51bf60 (patch)
treea2806da6c0ed5c48d21178e0c6c280d5a40ccd38 /web/api
parent6605e2cc428e3bdaa174ccc432941eab8c5d61cb (diff)
downloadtidyindex-master.tar
tidyindex-master.tar.gz
tidyindex-master.tar.bz2
tidyindex-master.tar.lz
tidyindex-master.tar.xz
tidyindex-master.tar.zst
tidyindex-master.zip
irs 990 doc prarsers and some web stuffHEADmaster
Diffstat (limited to 'web/api')
-rw-r--r--web/api/.env.example8
-rw-r--r--web/api/.gitignore3
-rw-r--r--web/api/package.json8
-rw-r--r--web/api/src/auth.ts49
-rw-r--r--web/api/src/db.ts72
-rw-r--r--web/api/src/dev-server.ts16
-rw-r--r--web/api/src/index.ts30
-rw-r--r--web/api/tsconfig.json6
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"]
}