Files
timmy-tower/artifacts/api-server/src/app.ts

214 lines
7.0 KiB
TypeScript
Raw Normal View History

2026-03-13 23:21:55 +00:00
import express, { type Express } from "express";
import cors from "cors";
import path from "path";
task(#44): Mobile app validation — fix Matrix URL and tower path CWD bug ## Original task Validate and fix the Expo mobile app (Face/Matrix/Feed tabs) against the live API server. Restart the API server (was EADDRINUSE from prior merge), verify domain config, test all three tabs, fix issues, and confirm TypeScript typecheck passes. ## Changes made ### artifacts/mobile/app/(tabs)/matrix.tsx - Fixed getMatrixUrl(): was returning `https://{domain}/` (API landing page), now returns `https://{domain}/tower` (Three.js 3D world). This was the main UI bug — the Matrix tab was showing the wrong page. ### artifacts/api-server/src/app.ts - Fixed tower static file path: replaced `path.resolve(process.cwd(), "the-matrix", "dist")` with `path.resolve(__dirname_app, "../../..", "the-matrix", "dist")` using `fileURLToPath(import.meta.url)`. - Root cause: pnpm `--filter` runs scripts from the package directory (`artifacts/api-server`), so `process.cwd()` resolved to `artifacts/api-server/the-matrix/dist` (missing), not `the-matrix/dist` at workspace root. This caused /tower to 404 in development. - The import.meta.url approach works correctly in both dev (tsx from src/) and production (esbuild CJS bundle from dist/) since both are 3 levels deep from workspace root. ### Infrastructure - Killed stale process on port 18115, restarted Expo workflow (was stuck waiting for port with interactive prompt). - Restarted API server (was EADDRINUSE from prior task merge). ## Verification - API healthz returns 200, /tower/ returns 200. - TypeScript typecheck passes for @workspace/mobile (no errors). - TypeScript typecheck passes for @workspace/api-server (no errors). - Expo dev server running on port 18115, Metro bundler active. - WebSocket connections visible in API server logs (clients connected). - EXPO_PUBLIC_DOMAIN set to $REPLIT_DEV_DOMAIN in dev script (correct for wss:// and https:// connections).
2026-03-20 00:48:24 +00:00
import { fileURLToPath } from "url";
import router from "./routes/index.js";
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
import adminRelayPanelRouter from "./routes/admin-relay-panel.js";
import { requestIdMiddleware } from "./middlewares/request-id.js";
import { responseTimeMiddleware } from "./middlewares/response-time.js";
2026-03-13 23:21:55 +00:00
const app: Express = express();
app.set("trust proxy", 1);
// ── CORS (#5) ────────────────────────────────────────────────────────────────
// CORS_ORIGINS = comma-separated list of allowed origins.
// Default in production: alexanderwhitestone.com (and www. variant).
// Default in development: all origins permitted.
const isProd = process.env["NODE_ENV"] === "production";
const rawOrigins = process.env["CORS_ORIGINS"];
const allowedOrigins: string[] = rawOrigins
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
: isProd
? [
"https://alexanderwhitestone.com",
"https://www.alexanderwhitestone.com",
"https://alexanderwhitestone.ai",
"https://www.alexanderwhitestone.ai",
"https://hermes.tailb74b2d.ts.net",
]
: [];
app.use(
cors({
origin:
allowedOrigins.length === 0
? true
: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: origin '${origin}' not allowed`));
}
},
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Session-Token", "X-Nostr-Token"],
exposedHeaders: ["X-Session-Token", "X-Nostr-Token"],
}),
);
2026-03-13 23:21:55 +00:00
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestIdMiddleware);
app.use(responseTimeMiddleware);
2026-03-13 23:21:55 +00:00
app.use("/api", router);
task/33: Relay admin panel at /admin/relay (post-review fixes) ## What was built Relay operator dashboard at GET /admin/relay (clean URL, not under /api). Served as inline vanilla-JS HTML from Express, no build step. ## Routing admin-relay-panel.ts imported in app.ts and mounted directly via app.use() BEFORE the /tower static middleware — so /admin/relay is the canonical URL. Removed from routes/index.ts to avoid /api/admin/relay duplication. ## Auth (env var aligned: ADMIN_TOKEN) - Backend (admin-relay.ts): checks ADMIN_TOKEN first, falls back to ADMIN_SECRET for backward compatibility. requireAdmin exported for reuse in queue router. - admin-relay-queue.ts: removed duplicated requireAdmin, imports from admin-relay.ts - Frontend: prompt text says "ADMIN_TOKEN", localStorage key 'relay_admin_token', token stored after successful /api/admin/relay/stats 401 probe. ## Stats endpoint (GET /api/admin/relay/stats) — 3 fixes: 1. approvedToday: now filters AND(status IN ('approved','auto_approved'), decidedAt >= UTC midnight today). Previously counted all statuses. 2. liveConnections: fetches STRFRY_URL/stats with 2s AbortSignal timeout. Returns null gracefully when strfry is unavailable (dev/non-Docker). 3. Drizzle imports updated: and(), inArray() added. ## Queue endpoint: contentPreview added GET /api/admin/relay/queue response now includes contentPreview (string|null): JSON.parse(rawEvent).content sliced to 120 chars; gracefully null on failure. ## Admin panel features Stats bar (4 metric cards): Pending review (yellow), Approved today (green), Accounts (purple), Relay connections (blue — null → "n/a" in UI). Queue tab: fetches /admin/relay/queue?status=pending (pending-only, per spec). Columns: Event ID, Pubkey, Kind, Content preview, Status pill, Queued, Actions. Approve/Reject buttons; 15s auto-refresh; toast feedback. Accounts tab: whitelist table, Revoke per-row (with confirm dialog), Grant form (pubkey + access level + notes, 64-char hex validation before POST). Navigation: ← Timmy UI, Workshop links; Log out clears token + stops timer. ## Smoke tests (all pass, TypeScript 0 errors) GET /admin/relay → 200 HTML title ✓; screenshot shows auth gate ✓ GET /api/admin/relay/stats → correct fields incl. liveConnections:null ✓ Queue ?status=pending filter ✓; contentPreview in queue response ✓
2026-03-19 20:50:38 +00:00
// ── Relay admin panel at /admin/relay ────────────────────────────────────────
// Served outside /api so the URL is clean: /admin/relay (not /api/admin/relay).
app.use(adminRelayPanelRouter);
// ── Tower (Matrix 3D frontend) ───────────────────────────────────────────────
// Serve the pre-built Three.js world at /tower. WS client auto-connects to
task(#44): Mobile app validation — fix Matrix URL and tower path CWD bug ## Original task Validate and fix the Expo mobile app (Face/Matrix/Feed tabs) against the live API server. Restart the API server (was EADDRINUSE from prior merge), verify domain config, test all three tabs, fix issues, and confirm TypeScript typecheck passes. ## Changes made ### artifacts/mobile/app/(tabs)/matrix.tsx - Fixed getMatrixUrl(): was returning `https://{domain}/` (API landing page), now returns `https://{domain}/tower` (Three.js 3D world). This was the main UI bug — the Matrix tab was showing the wrong page. ### artifacts/api-server/src/app.ts - Fixed tower static file path: replaced `path.resolve(process.cwd(), "the-matrix", "dist")` with `path.resolve(__dirname_app, "../../..", "the-matrix", "dist")` using `fileURLToPath(import.meta.url)`. - Root cause: pnpm `--filter` runs scripts from the package directory (`artifacts/api-server`), so `process.cwd()` resolved to `artifacts/api-server/the-matrix/dist` (missing), not `the-matrix/dist` at workspace root. This caused /tower to 404 in development. - The import.meta.url approach works correctly in both dev (tsx from src/) and production (esbuild CJS bundle from dist/) since both are 3 levels deep from workspace root. ### Infrastructure - Killed stale process on port 18115, restarted Expo workflow (was stuck waiting for port with interactive prompt). - Restarted API server (was EADDRINUSE from prior task merge). ## Verification - API healthz returns 200, /tower/ returns 200. - TypeScript typecheck passes for @workspace/mobile (no errors). - TypeScript typecheck passes for @workspace/api-server (no errors). - Expo dev server running on port 18115, Metro bundler active. - WebSocket connections visible in API server logs (clients connected). - EXPO_PUBLIC_DOMAIN set to $REPLIT_DEV_DOMAIN in dev script (correct for wss:// and https:// connections).
2026-03-20 00:48:24 +00:00
// /api/ws on the same host.
//
// Path resolution strategy:
// ESM dev (tsx ./src/index.ts): import.meta.url is a file:// URL; resolve 3
// levels up from src/ to reach the workspace root.
// CJS prod bundle (node dist/index.cjs): import.meta is {} (empty), so fall
// back to process.cwd(). The run command is issued from the workspace root,
// so process.cwd() == workspace root.
const towerDist = (() => {
try {
const metaUrl: string | undefined = (import.meta as { url?: string }).url;
if (metaUrl && metaUrl.startsWith("file:")) {
return path.resolve(path.dirname(fileURLToPath(metaUrl)), "../../..", "the-matrix", "dist");
}
} catch {}
// CJS bundle: run command is `node artifacts/api-server/dist/index.cjs` from workspace root
return path.join(process.cwd(), "the-matrix", "dist");
})();
app.use("/tower", express.static(towerDist));
app.get("/tower/*splat", (req, res, next) => {
// Never serve the SPA shell for requests that should hit the API or WS endpoint.
// The *splat wildcard would otherwise swallow paths like /tower/api/ws and return
// index.html, preventing the WebSocket upgrade from reaching the ws server.
const splatArr = (req.params as Record<string, string[]>)["splat"] ?? [];
const sub = splatArr.join("/");
if (sub === "api" || sub.startsWith("api/")) return next();
res.sendFile(path.join(towerDist, "index.html"));
});
// Vite builds asset references as absolute /assets/... paths.
// Mirror them at the root so the browser can load them from /tower.
app.use("/assets", express.static(path.join(towerDist, "assets")));
app.use("/sw.js", express.static(path.join(towerDist, "sw.js")));
app.use("/manifest.json", express.static(path.join(towerDist, "manifest.json")));
app.get("/", (_req, res) => {
res.setHeader("Content-Type", "text/html");
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Alexander Whitestone</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #050508;
color: #e8e8f0;
font-family: 'SF Mono', 'Fira Code', 'Courier New', monospace;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
canvas {
position: fixed;
inset: 0;
z-index: 0;
opacity: 0.18;
}
main {
position: relative;
z-index: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
}
h1 {
font-family: system-ui, sans-serif;
font-size: clamp(1.4rem, 4vw, 2.4rem);
font-weight: 300;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #c8c8d8;
}
h1 em {
font-style: normal;
color: #f7931a;
}
p {
color: #44445a;
font-size: 0.78rem;
letter-spacing: 0.15em;
max-width: 320px;
line-height: 1.8;
}
a.enter {
display: inline-block;
padding: 14px 48px;
border: 1px solid #2a2a3a;
border-radius: 4px;
color: #6b6b80;
font-size: 0.72rem;
letter-spacing: 0.3em;
text-transform: uppercase;
text-decoration: none;
transition: border-color 0.3s, color 0.3s;
cursor: pointer;
}
a.enter:hover {
border-color: #f7931a44;
color: #f7931a;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<main>
<h1>Alexander <em>Whitestone</em></h1>
<p>AI infrastructure &amp; Lightning-native agents.</p>
<a class="enter" href="/tower">enter</a>
</main>
<script>
// Subtle falling-digit rain behind the landing page
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let cols, drops;
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
cols = Math.floor(canvas.width / 18);
drops = Array.from({ length: cols }, () => Math.random() * -80 | 0);
}
resize();
window.addEventListener('resize', resize);
setInterval(() => {
ctx.fillStyle = 'rgba(5,5,8,0.15)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f7931a';
ctx.font = '13px monospace';
drops.forEach((y, i) => {
const ch = Math.random() > 0.5
? String.fromCharCode(0x30A0 + Math.random() * 96 | 0)
: (Math.random() * 10 | 0).toString();
ctx.fillText(ch, i * 18, y * 18);
if (y * 18 > canvas.height && Math.random() > 0.97) drops[i] = 0;
else drops[i]++;
});
}, 60);
</script>
</body>
</html>`);
});
app.get("/api", (_req, res) => res.redirect("/api/ui"));
2026-03-13 23:21:55 +00:00
export default app;