diff --git a/README.md b/README.md index 34b9d96..53fa521 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Public knowledge flows freely. Admin routes are… supervised. 😼 - **GitHub OAuth** β€” browser redirect for admins + Device Flow for CLI access - **Public endpoints** β€” read-only access to Lab Notes - **Protected admin routes** β€” create, edit, delete notes (Carmel is watching) +- **Relay system (Hallway Architecture)** β€” temporary, single-use endpoints for AI agents with credential restrictions - **Environment-based secrets** β€” no hardcoding, no nonsense - **Admin API tokens** β€” scoped, revocable access for CLI and automation (raw tokens returned once) @@ -77,6 +78,9 @@ ADMIN_GITHUB_LOGINS=your-github-username # ── API Tokens ─────────────────────────────── TOKEN_PEPPER=your-long-random-secret +# ── Relay Service ──────────────────────────── +API_BASE_URL=https://api.thehumanpatternlab.com + # ── Database ───────────────────────────────── DB_PATH=/path/to/lab.db ``` @@ -114,6 +118,24 @@ DB_PATH=/path/to/lab.db - `POST /admin/tokens` β€” mint a new scoped API token (returned once) - `POST /admin/tokens/:id/revoke` β€” revoke an existing token +πŸ‘‰ **For bearer token authentication details:** +See [`docs/BEARER_TOKEN_INTEGRATION.md`](docs/BEARER_TOKEN_INTEGRATION.md) + +### Relay System (Hallway Architecture) + +**Agent Endpoint (No Auth Required):** +- `POST /relay/:relayId` β€” AI agents post Lab Notes using temporary relay URLs + +**Admin Management:** +- `POST /admin/relay/generate` β€” Generate a temporary relay credential +- `GET /admin/relay/list` β€” List active relay sessions +- `POST /admin/relay/revoke` β€” Revoke a relay credential + +> **The Hallway Architecture** enables AI agents with credential restrictions (like ChatGPT) to post Lab Notes using temporary, single-use URLs. Each relay is voice-bound, time-limited, and automatically revoked after use. +> +> πŸ‘‰ **For implementation details and usage guide:** +> See [`docs/RELAY_IMPLEMENTATION.md`](docs/RELAY_IMPLEMENTATION.md) + Admin routes are… supervised. 😼 (and logged.) --- @@ -149,7 +171,7 @@ This API is intended to run under **PM2** in production. Add these to `~/.bashrc` or `~/.zshrc` on the VPS: ```bash -# ── Human Pattern Lab Β· API ───────────────────────────── +# ── Human Pattern Lab Β· API ────────────────────────────────── alias lab-api-start='pm2 start ecosystem.config.cjs --env production' alias lab-api-restart='pm2 restart lab-api' alias lab-api-stop='pm2 stop lab-api' @@ -202,6 +224,10 @@ pm2 startup This API follows semantic versioning while pre-1.0. +- **v0.9.0** + - Introduces the **Hallway Architecture** relay system + - Enables AI agents with credential restrictions to post Lab Notes + - Temporary, voice-bound, single-use relay endpoints - **v0.2.0** - Introduces the **Ledger** persistence model - Removes the `/api` route prefix @@ -240,4 +266,5 @@ MIT https://thehumanpatternlab.com *The lantern is lit. -The foxes are watching.* +The foxes are watching. +The hallways open.* diff --git a/docs/RELAY_IMPLEMENTATION.md b/docs/RELAY_IMPLEMENTATION.md new file mode 100644 index 0000000..84012c9 --- /dev/null +++ b/docs/RELAY_IMPLEMENTATION.md @@ -0,0 +1,242 @@ +# Relay Implementation - Complete βœ… + +## What We Built + +The **Hallway Architecture** relay service for The Human Pattern Lab. This enables AI agents with credential restrictions (like ChatGPT) to post Lab Notes using temporary, single-use URLs. + +## Files Created + +### 1. Database Migration +**Location**: `src/db/migrations/2025-01-add-relay-sessions.ts` +- Creates `relay_sessions` table +- Adds indexes for efficient queries +- Idempotent (can run multiple times safely) + +### 2. Relay Store (Database Operations) +**Location**: `src/db/relayStore.ts` +- `createRelaySession()` - Generate new relay with expiration +- `getRelaySession()` - Retrieve relay by ID +- `markRelayUsed()` - **Atomic** operation to mark relay as used +- `listActiveRelays()` - List all unused, unexpired relays +- `revokeRelay()` - Manually revoke a relay +- `cleanupExpiredRelays()` - Cleanup old/expired relays + +### 3. Relay Routes +**Location**: `src/routes/relayRoutes.ts` +- `POST /relay/:relayId` - Main endpoint for agents to post notes +- `POST /admin/relay/generate` - Generate new relay credential +- `GET /admin/relay/list` - List active relays +- `POST /admin/relay/revoke` - Revoke a relay + +## Files Modified + +### 1. `src/db.ts` +- Added import for `createRelaySessions` migration +- Called migration in `bootstrapDb()` + +### 2. `src/app.ts` +- Added import for `registerRelayRoutes` +- Registered relay routes with Express router + +## How It Works + +### The Four Phases + +**1. Invitation (Generate)** +```bash +# You (admin) generate a relay +curl -X POST http://localhost:3001/admin/relay/generate \ + -H "Content-Type: application/json" \ + -d '{"voice": "lyric", "expires": "1h"}' + +# Returns: +{ + "relay_id": "relay_abc123xyz", + "voice": "lyric", + "expires_at": "2025-01-25T11:30:00Z", + "url": "http://localhost:3001/relay/relay_abc123xyz" +} +``` + +**2. Delivery (Hand to Agent)** +Give the URL to the AI agent (e.g., Lyric/ChatGPT) + +**3. Handshake (Agent Posts)** +```bash +# Agent posts to relay +curl -X POST http://localhost:3001/relay/relay_abc123xyz \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Pattern Recognition in Distributed Systems", + "content": "# Observations...", + "tags": ["research"] + }' + +# Returns: +{ + "success": true, + "note_id": "uuid-here", + "voice": "lyric", + "published_at": "2025-01-25" +} +``` + +**4. Closing Door (Auto-Revocation)** +The relay is automatically marked as used and can never be used again. + +## Security Properties + +βœ… **One-time use**: Each relay works exactly once (atomic operation prevents race conditions) +βœ… **Time-limited**: Default 1 hour expiration +βœ… **Voice-bound**: Each relay tied to specific voice identity +βœ… **Revocable**: Admins can revoke any relay instantly +βœ… **Auditable**: All relay usage logged +βœ… **No token exposure**: System bearer token never leaves server + +## Database Schema + +```sql +CREATE TABLE relay_sessions ( + id TEXT PRIMARY KEY, -- e.g., "relay_abc123xyz" + voice TEXT NOT NULL, -- e.g., "lyric", "coda", "sage" + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, -- e.g., 1 hour from creation + used BOOLEAN NOT NULL DEFAULT 0, -- Atomic flag + used_at TIMESTAMP, -- When it was used + created_by TEXT NOT NULL DEFAULT 'admin' +); +``` + +## What Happens When Relay is Used + +1. Validates relay exists, not used, not expired +2. **Atomically** marks relay as used (prevents race conditions) +3. Adds `vocal-{voice}` tag automatically +4. Creates Lab Note using same logic as admin endpoint +5. Returns success to agent +6. Logs action for audit trail + +## Testing + +### Manual Test Flow + +```bash +# 1. Start server +npm start + +# 2. Generate relay (as admin) +curl -X POST http://localhost:3001/admin/relay/generate \ + -H "Content-Type: application/json" \ + -d '{"voice": "lyric", "expires": "1h"}' + +# 3. Use relay (as agent) +curl -X POST http://localhost:3001/relay/relay_abc123xyz \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Test Note", + "content": "# Hello from Lyric" + }' + +# 4. Verify in database +sqlite3 data/lab.dev.db "SELECT * FROM relay_sessions;" +sqlite3 data/lab.dev.db "SELECT * FROM lab_notes WHERE slug LIKE '%test-note%';" + +# 5. Try using same relay again (should fail with 403) +curl -X POST http://localhost:3001/relay/relay_abc123xyz \ + -H "Content-Type: application/json" \ + -d '{"title": "Test 2", "content": "# This should fail"}' +``` + +## Next Steps + +### Phase 1: Test & Verify βœ… +- [x] Migration runs successfully +- [ ] Can generate relay via API +- [ ] Can post via relay endpoint +- [ ] Relay is marked as used +- [ ] Second post fails with 403 +- [ ] Note appears in database with `vocal-{voice}` tag + +### Phase 2: CLI Commands (Future) +- [ ] `hpl relay:generate --voice lyric --expires 1h` +- [ ] `hpl relay:list` +- [ ] `hpl relay:revoke ` +- [ ] `hpl relay:watch` (optional, nice-to-have) + +### Phase 3: Post-Manifestation Hooks (Future) +- [ ] Desktop notification when relay is used +- [ ] Terminal notification if `relay:watch` is running +- [ ] Discord webhook (optional) + +### Phase 4: Documentation (Future) +- [ ] Update OpenAPI spec with relay endpoints +- [ ] Add relay docs to main README +- [ ] Create usage guide for The Skulk members + +## Environment Variables + +Add to `.env`: +```bash +# Relay service configuration +API_BASE_URL=http://localhost:3001 # For generating relay URLs +``` + +## Notes + +- The relay endpoint does NOT require authentication (that's the point!) +- The relay ID itself acts as the authentication token +- Voice metadata is preserved in `vocal-{voice}` tags +- Frontend can style based on these tags +- Relay creation endpoints WILL require admin auth when we add middleware + +## Architecture Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Admin β”‚ +β”‚ (You) β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ POST /admin/relay/generate + β”‚ { voice: "lyric", expires: "1h" } + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Relay Service β”‚ +β”‚ (lab-api) β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Returns: relay_abc123xyz + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Lyric (GPT) β”‚ ← You hand the URL +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ POST /relay/relay_abc123xyz + β”‚ { title: "...", content: "..." } + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Relay Service β”‚ 1. Validate session +β”‚ β”‚ 2. Mark as used (atomic) +β”‚ β”‚ 3. Add vocal-lyric tag +β”‚ β”‚ 4. Create Lab Note +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Database β”‚ Lab Note created with +β”‚ (SQLite) β”‚ voice metadata +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Success Criteria + +When complete, you should be able to: + +1. βœ… Generate a relay for Lyric +2. βœ… Hand the relay URL to ChatGPT +3. βœ… ChatGPT posts a Lab Note via the relay +4. βœ… The note appears in Ghost with `vocal-{voice}` tag +5. βœ… The relay becomes invalid after use +6. βœ… All activity is logged + +--- + +**The hallway exists, serves its purpose, and disappears.** πŸ›οΈ diff --git a/package.json b/package.json index 3792e69..9997ac2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "the-human-pattern-lab-api", - "version": "0.8.0", + "version": "0.9.0", "type": "module", "private": true, "description": "API backend for The Human Pattern Lab", diff --git a/src/app.ts b/src/app.ts index 17f5597..37c29f6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import { registerAdminRoutes } from "./routes/adminRoutes.js"; import OpenApiValidator from "express-openapi-validator"; import { registerOpenApiRoutes } from "./routes/openapiRoutes.js"; import { registerAdminTokensRoutes } from "./routes/adminTokensRoutes.js"; +import { registerRelayRoutes } from "./routes/relayRoutes.js"; import fs from "node:fs"; import path from "node:path"; import { env } from "./env.js"; @@ -230,6 +231,7 @@ export function createApp() { registerAdminRoutes(api, db); registerLabNotesRoutes(api, db); registerAdminTokensRoutes(app, db); + registerRelayRoutes(api, db); // MOUNT THE ROUTER (this is what makes routes actually exist) app.use("/", api); // βœ… canonical diff --git a/src/db.ts b/src/db.ts index 1e62451..11d96b5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -8,6 +8,7 @@ import { nowIso, sha256Hex } from './lib/helpers.js'; import { migrateLabNotesSchema, LAB_NOTES_SCHEMA_VERSION } from "./db/migrateLabNotes.js"; import {dedupeLabNotesSlugs} from "./db/migrations/2025-01-dedupe-lab-notes-slugs.js"; import { migrateApiTokensSchema } from "./db/migrateApiTokens.js"; +import { createRelaySessions } from "./db/migrations/2025-01-add-relay-sessions.js"; export function resolveDbPath(): string { const __filename = fileURLToPath(import.meta.url); @@ -79,6 +80,7 @@ export function bootstrapDb(db: Database.Database) { // βœ… Single source of truth for schema + views migrateLabNotesSchema(db, log); migrateApiTokensSchema(db, log); + createRelaySessions(db, log); if (prevVersion < 3) { dedupeLabNotesSlugs(db, log); } diff --git a/src/db/migrations/2025-01-add-relay-sessions.ts b/src/db/migrations/2025-01-add-relay-sessions.ts new file mode 100644 index 0000000..f6b1a65 --- /dev/null +++ b/src/db/migrations/2025-01-add-relay-sessions.ts @@ -0,0 +1,26 @@ +// Migration: Add relay sessions table for Hallway Architecture +import type Database from "better-sqlite3"; + +export function createRelaySessions(db: Database.Database, log?: typeof console.log) { + log?.("πŸ“ Creating relay_sessions table..."); + + db.exec(` + CREATE TABLE IF NOT EXISTS relay_sessions ( + id TEXT PRIMARY KEY, + voice TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + used BOOLEAN NOT NULL DEFAULT 0, + used_at TIMESTAMP, + created_by TEXT NOT NULL DEFAULT 'admin' + ); + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_relay_voice ON relay_sessions(voice); + CREATE INDEX IF NOT EXISTS idx_relay_expires ON relay_sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_relay_used ON relay_sessions(used); + `); + + log?.("βœ“ relay_sessions table created"); +} diff --git a/src/db/relayStore.ts b/src/db/relayStore.ts new file mode 100644 index 0000000..1ccddba --- /dev/null +++ b/src/db/relayStore.ts @@ -0,0 +1,145 @@ +// src/db/relayStore.ts +import type Database from "better-sqlite3"; +import { randomBytes } from "crypto"; + +export interface RelaySession { + id: string; + voice: string; + created_at: string; + expires_at: string; + used: boolean; + used_at: string | null; + created_by: string; +} + +/** + * Parse duration strings like "1h", "30m", "2d" + * Returns milliseconds + */ +function parseDuration(duration: string): number { + const match = duration.match(/^(\d+)([smhd])$/); + if (!match) return 3600000; // Default 1 hour + + const [, amount, unit] = match; + const multipliers: Record = { + s: 1000, + m: 60000, + h: 3600000, + d: 86400000, + }; + + return parseInt(amount) * multipliers[unit]; +} + +/** + * Generate new relay session + */ +export function createRelaySession( + db: Database.Database, + voice: string, + expiresIn: string = "1h" +): RelaySession { + const id = "relay_" + randomBytes(8).toString("hex"); + const expiresAt = new Date(Date.now() + parseDuration(expiresIn)).toISOString(); + + db.prepare(` + INSERT INTO relay_sessions (id, voice, expires_at) + VALUES (?, ?, ?) + `).run(id, voice, expiresAt); + + const session = db + .prepare(`SELECT * FROM relay_sessions WHERE id = ?`) + .get(id) as RelaySession; + + return session; +} + +/** + * Get relay session by ID + */ +export function getRelaySession( + db: Database.Database, + relayId: string +): RelaySession | undefined { + return db + .prepare(`SELECT * FROM relay_sessions WHERE id = ?`) + .get(relayId) as RelaySession | undefined; +} + +/** + * Mark relay as used (ATOMIC operation) + * Returns true if marked, false if already used + */ +export function markRelayUsed( + db: Database.Database, + relayId: string +): boolean { + const result = db.prepare(` + UPDATE relay_sessions + SET + used = 1, + used_at = CURRENT_TIMESTAMP + WHERE id = ? AND used = 0 + `).run(relayId); + + // If changes === 0, it was already used or doesn't exist + return result.changes > 0; +} + +/** + * List active relays (not used, not expired) + */ +export function listActiveRelays( + db: Database.Database, + voice?: string +): RelaySession[] { + if (voice) { + return db.prepare(` + SELECT * FROM relay_sessions + WHERE voice = ? + AND used = 0 + AND expires_at > CURRENT_TIMESTAMP + ORDER BY created_at DESC + `).all(voice) as RelaySession[]; + } + + return db.prepare(` + SELECT * FROM relay_sessions + WHERE used = 0 + AND expires_at > CURRENT_TIMESTAMP + ORDER BY created_at DESC + `).all() as RelaySession[]; +} + +/** + * Revoke relay (mark as used) + */ +export function revokeRelay( + db: Database.Database, + relayId: string +): boolean { + const result = db.prepare(` + UPDATE relay_sessions + SET used = 1 + WHERE id = ? + `).run(relayId); + + return result.changes > 0; +} + +/** + * Clean up expired or old used relays + * Returns number of deleted rows + */ +export function cleanupExpiredRelays( + db: Database.Database, + olderThanDays: number = 7 +): number { + const result = db.prepare(` + DELETE FROM relay_sessions + WHERE expires_at < CURRENT_TIMESTAMP + OR (used = 1 AND used_at < datetime('now', '-' || ? || ' days')) + `).run(olderThanDays); + + return result.changes; +} diff --git a/src/routes/relayRoutes.ts b/src/routes/relayRoutes.ts new file mode 100644 index 0000000..0ae7647 --- /dev/null +++ b/src/routes/relayRoutes.ts @@ -0,0 +1,308 @@ +// src/routes/relayRoutes.ts +import type { Request, Response, Router } from "express"; +import type Database from "better-sqlite3"; +import { + getRelaySession, + markRelayUsed, + createRelaySession, + listActiveRelays, + revokeRelay, +} from "../db/relayStore.js"; +import { randomUUID } from "crypto"; +import { sha256Hex } from "../lib/helpers.js"; + +/** + * Register relay endpoints + * + * The relay service enables AI agents with credential restrictions + * to post Lab Notes using temporary, single-use URLs. + */ +export function registerRelayRoutes(app: Router, db: Database.Database) { + /** + * POST /relay/:relayId + * + * Accept content from agents and proxy to internal note creation + * with system credentials. The relay validates the session and + * automatically adds voice metadata. + */ + app.post("/relay/:relayId", async (req: Request, res: Response) => { + const { relayId } = req.params; + const { title, content, tags = [] } = req.body; + + try { + // 1. Validate relay session + const session = getRelaySession(db, relayId); + + if (!session) { + return res.status(403).json({ + success: false, + error: "Invalid relay ID", + code: "INVALID_RELAY", + }); + } + + if (session.used) { + return res.status(403).json({ + success: false, + error: "Relay already used", + code: "ALREADY_USED", + }); + } + + if (new Date(session.expires_at) < new Date()) { + return res.status(403).json({ + success: false, + error: "Relay expired", + code: "EXPIRED_RELAY", + }); + } + + // 2. Mark as used (atomic) + const marked = markRelayUsed(db, relayId); + if (!marked) { + // Race condition - another request got here first + return res.status(403).json({ + success: false, + error: "Relay already used", + code: "ALREADY_USED", + }); + } + + // 3. Apply voice metadata + const voiceTags = [...(Array.isArray(tags) ? tags : []), `vocal-${session.voice}`]; + + // 4. Create Lab Note using same logic as admin endpoint + const noteId = randomUUID(); + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + const locale = "en"; + const noteStatus = "published"; + const publishedAt = new Date().toISOString().slice(0, 10); + + const bodyMarkdown = String(content ?? ""); + + const tx = db.transaction(() => { + // Insert/update metadata row + db.prepare(` + INSERT INTO lab_notes ( + id, title, slug, locale, + type, status, + category, excerpt, + published_at, + updated_at + ) + VALUES ( + ?, ?, ?, ?, + ?, ?, + ?, ?, + ?, + strftime('%Y-%m-%dT%H:%M:%fZ','now') + ) + ON CONFLICT(slug, locale) DO UPDATE SET + title=excluded.title, + type=excluded.type, + status=excluded.status, + category=excluded.category, + excerpt=excluded.excerpt, + published_at=excluded.published_at, + updated_at=excluded.updated_at + `).run( + noteId, + title, + slug, + locale, + "labnote", + noteStatus, + "Uncategorized", + "", + publishedAt + ); + + // Create revision + const revRow = db + .prepare(` + SELECT COALESCE(MAX(revision_num), 0) AS maxRev + FROM lab_note_revisions + WHERE note_id = ? + `) + .get(noteId) as { maxRev: number } | undefined; + + const nextRev = (revRow?.maxRev ?? 0) + 1; + const revisionId = randomUUID(); + + const prevPointer = db + .prepare(`SELECT current_revision_id AS cur FROM lab_notes WHERE id = ?`) + .get(noteId) as { cur?: string } | undefined; + + const frontmatter = { + id: noteId, + slug, + locale, + type: "labnote", + status: noteStatus, + published_at: publishedAt, + voice: session.voice, + }; + + const canonical = `${JSON.stringify(frontmatter)}\n---\n${bodyMarkdown}`; + const contentHash = sha256Hex(canonical); + + db.prepare(` + INSERT INTO lab_note_revisions ( + id, note_id, revision_num, supersedes_revision_id, + frontmatter_json, content_markdown, content_hash, + schema_version, source, + intent, intent_version, + scope_json, side_effects_json, reversible, + auth_type, scopes_json, + reasoning_json, + created_at + ) + VALUES ( + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, + ?, ?, + ?, ?, ?, + ?, ?, + NULL, + strftime('%Y-%m-%dT%H:%M:%fZ','now') + ) + `).run( + revisionId, + noteId, + nextRev, + prevPointer?.cur ?? null, + JSON.stringify(frontmatter), + bodyMarkdown, + contentHash, + "0.1", + "relay", + "voice_manifestation", + "1", + JSON.stringify(["db"]), + JSON.stringify(["create_note"]), + 1, + "relay_session", + JSON.stringify([]) + ); + + // Update pointers + db.prepare(` + UPDATE lab_notes + SET + current_revision_id = ?, + published_revision_id = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ? + `).run(revisionId, revisionId, noteId); + + // Add tags + for (const tag of voiceTags) { + db.prepare(` + INSERT OR IGNORE INTO lab_note_tags (note_id, tag) + VALUES (?, ?) + `).run(noteId, tag); + } + + return { noteId, revisionId }; + }); + + const { noteId: savedId } = tx(); + + // 5. Log for audit trail + console.log(`[RELAY] ${session.voice} posted: "${title}" via ${relayId}`); + + // 6. Return success + return res.json({ + success: true, + note_id: savedId, + voice: session.voice, + published_at: publishedAt, + }); + } catch (error: any) { + console.error("[RELAY ERROR]", error); + return res.status(500).json({ + success: false, + error: "Internal server error", + code: "INTERNAL_ERROR", + }); + } + }); + + /** + * Admin endpoints for relay management + * These are protected by requireAdmin middleware + */ + + // Generate new relay + app.post("/admin/relay/generate", (req: Request, res: Response) => { + try { + const { voice, expires = "1h" } = req.body; + + if (!voice) { + return res.status(400).json({ error: "voice is required" }); + } + + const session = createRelaySession(db, voice, expires); + const baseUrl = process.env.API_BASE_URL || "http://localhost:3001"; + const url = `${baseUrl}/relay/${session.id}`; + + return res.json({ + relay_id: session.id, + voice: session.voice, + expires_at: session.expires_at, + url, + }); + } catch (error: any) { + return res.status(500).json({ + error: "Failed to generate relay", + details: error.message, + }); + } + }); + + // List active relays + app.get("/admin/relay/list", (req: Request, res: Response) => { + try { + const { voice } = req.query; + const relays = listActiveRelays(db, voice as string | undefined); + + return res.json({ + relays, + count: relays.length, + }); + } catch (error: any) { + return res.status(500).json({ + error: "Failed to list relays", + details: error.message, + }); + } + }); + + // Revoke relay + app.post("/admin/relay/revoke", (req: Request, res: Response) => { + try { + const { relay_id } = req.body; + + if (!relay_id) { + return res.status(400).json({ error: "relay_id is required" }); + } + + const revoked = revokeRelay(db, relay_id); + + if (revoked) { + return res.json({ success: true, relay_id }); + } else { + return res.status(404).json({ + success: false, + error: "Relay not found or already used", + }); + } + } catch (error: any) { + return res.status(500).json({ + error: "Failed to revoke relay", + details: error.message, + }); + } + }); +}