Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions artifacts/api-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@clerk/express": "^2.1.61",
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"cookie-parser": "^1.4.7",
Expand Down
21 changes: 13 additions & 8 deletions artifacts/api-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import cors from "cors";
import pinoHttp from "pino-http";
import router from "./routes";
import { logger } from "./lib/logger";
import { installStagingAccess } from "./middleware/staging-access";

const app: Express = express();

Expand All @@ -30,18 +31,22 @@ app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

installStagingAccess(app);

app.use("/api", router);

// Railway runs the API and web client as a single service. This keeps relative
// /api requests on the canonical domain when the built Vite client is served.
const clientDist = process.env["CLIENT_DIST"] ?? path.resolve(
import.meta.dirname,
"..",
"..",
"structured-liquidity",
"dist",
"public",
);
const clientDist =
process.env["CLIENT_DIST"] ??
path.resolve(
import.meta.dirname,
"..",
"..",
"structured-liquidity",
"dist",
"public",
);

app.use(express.static(clientDist));
app.use((req, res, next) => {
Expand Down
230 changes: 230 additions & 0 deletions artifacts/api-server/src/middleware/staging-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { clerkClient, clerkMiddleware, getAuth } from "@clerk/express";
import type { Express, NextFunction, Request, Response } from "express";

const STAGING_ROBOTS = "noindex, nofollow, noarchive, nosnippet";
const AUTHORIZATION_CACHE_TTL_MS = 60_000;

type StagingAccessConfig = {
allowedEmails: Set<string>;
authorizedParties: string[];
canonicalOrigin: string;
publishableKey: string;
secretKey: string;
signInUrl: string;
};

type CachedAuthorization = {
allowed: boolean;
expiresAt: number;
};

const authorizationCache = new Map<string, CachedAuthorization>();

function parseCsv(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}

function parseHttpsUrl(value: string, name: string): string {
let url: URL;

try {
url = new URL(value);
} catch {
throw new Error(`${name} must be an absolute URL`);
}

if (url.protocol !== "https:") {
throw new Error(`${name} must use https`);
}

return (
url.origin + (url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""))
);
}

function readConfig(): StagingAccessConfig {
const publishableKey = process.env["CLERK_PUBLISHABLE_KEY"]?.trim();
const secretKey = process.env["CLERK_SECRET_KEY"]?.trim();
const signInUrlValue = process.env["CLERK_SIGN_IN_URL"]?.trim();
const canonicalOriginValue = process.env["STAGING_CANONICAL_ORIGIN"]?.trim();
const allowedEmails = new Set(
parseCsv(process.env["STAGING_ALLOWED_EMAILS"]).map((email) =>
email.toLowerCase(),
),
);
const authorizedParties = parseCsv(
process.env["STAGING_AUTHORIZED_PARTIES"],
).map((party) => parseHttpsUrl(party, "STAGING_AUTHORIZED_PARTIES"));

if (
!publishableKey ||
!secretKey ||
!signInUrlValue ||
!canonicalOriginValue
) {
throw new Error(
"Staging auth requires CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, CLERK_SIGN_IN_URL, and STAGING_CANONICAL_ORIGIN",
);
}

if (allowedEmails.size === 0 || authorizedParties.length === 0) {
throw new Error(
"Staging auth requires non-empty STAGING_ALLOWED_EMAILS and STAGING_AUTHORIZED_PARTIES",
);
}

const canonicalOrigin = parseHttpsUrl(
canonicalOriginValue,
"STAGING_CANONICAL_ORIGIN",
);
const signInUrl = parseHttpsUrl(signInUrlValue, "CLERK_SIGN_IN_URL");

if (!authorizedParties.includes(canonicalOrigin)) {
throw new Error(
"STAGING_AUTHORIZED_PARTIES must include STAGING_CANONICAL_ORIGIN exactly",
);
}

return {
allowedEmails,
authorizedParties,
canonicalOrigin,
publishableKey,
secretKey,
signInUrl,
};
}

function isHealthRequest(req: Request): boolean {
return req.path === "/api/health" || req.path === "/api/healthz";
}

function isApiRequest(req: Request): boolean {
return req.path === "/api" || req.path.startsWith("/api/");
}

function isPublicAuthSurfaceRequest(req: Request): boolean {
return (
req.path === "/sign-in" ||
req.path.startsWith("/sign-in/") ||
req.path === "/sign-up" ||
req.path.startsWith("/sign-up/") ||
req.path.startsWith("/assets/") ||
req.path === "/favicon.svg" ||
req.path === "/favicon.ico"
);
}

function signInRedirect(config: StagingAccessConfig, req: Request): string {
const signInUrl = new URL(config.signInUrl);
const returnUrl = new URL(req.originalUrl || "/", config.canonicalOrigin);
signInUrl.searchParams.set("redirect_url", returnUrl.toString());
return signInUrl.toString();
}

async function isAllowedUser(
userId: string,
allowedEmails: Set<string>,
): Promise<boolean> {
const cached = authorizationCache.get(userId);
const now = Date.now();

if (cached && cached.expiresAt > now) {
return cached.allowed;
}

const user = await clerkClient.users.getUser(userId);
const allowed = user.emailAddresses.some((email) =>
allowedEmails.has(email.emailAddress.toLowerCase()),
);

authorizationCache.set(userId, {
allowed,
expiresAt: now + AUTHORIZATION_CACHE_TTL_MS,
});

return allowed;
}

export function installStagingAccess(app: Express): void {
if (process.env["STAGING_AUTH_REQUIRED"] !== "true") return;

const config = readConfig();
const canonicalHostname = new URL(config.canonicalOrigin).hostname;

app.use((req, res, next) => {
res.setHeader("X-Robots-Tag", STAGING_ROBOTS);
res.setHeader("Referrer-Policy", "same-origin");

if (!isHealthRequest(req)) {
res.setHeader("Cache-Control", "private, no-store");
}

next();
});

app.get("/robots.txt", (_req, res) => {
res.type("text/plain").send("User-agent: *\nDisallow: /\n");
});

app.use((req, res, next) => {
if (
!isHealthRequest(req) &&
!isApiRequest(req) &&
req.hostname !== canonicalHostname
) {
const destination = new URL(
req.originalUrl || "/",
config.canonicalOrigin,
);
res.redirect(308, destination.toString());
return;
}

next();
});

app.use(
clerkMiddleware({
authorizedParties: config.authorizedParties,
publishableKey: config.publishableKey,
secretKey: config.secretKey,
signInUrl: config.signInUrl,
}),
);

app.use(async (req: Request, res: Response, next: NextFunction) => {
if (isHealthRequest(req) || isPublicAuthSurfaceRequest(req)) {
next();
return;
}

const { userId } = getAuth(req);

if (!userId) {
if (isApiRequest(req)) {
res.status(401).json({ error: "Authentication required" });
return;
}

res.redirect(302, signInRedirect(config, req));
return;
}

try {
if (!(await isAllowedUser(userId, config.allowedEmails))) {
res
.status(403)
.send("This account does not have access to Structured staging.");
return;
}

next();
} catch (error) {
next(error);
}
});
}
3 changes: 3 additions & 0 deletions artifacts/structured-liquidity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,8 @@
"vite": "catalog:",
"wouter": "^3.3.5",
"zod": "catalog:"
},
"dependencies": {
"@clerk/react": "^6.14.5"
}
}
2 changes: 1 addition & 1 deletion artifacts/structured-liquidity/public/r/ai-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"files": [
{
"path": "registry/ui/ai-chat.tsx",
"content": "import * as React from \"react\";\nimport { Sparkles, Paperclip, Mic, ArrowUp } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ChatMessage {\n from: \"bot\" | \"me\";\n text: React.ReactNode;\n}\n\nconst DEFAULT_MESSAGES: ChatMessage[] = [\n { from: \"bot\", text: \"Hey, I'm Vector. Ask me to draft, summarize, or name anything.\" },\n { from: \"me\", text: \"Write a title for a late-night ambient mix.\" },\n { from: \"bot\", text: 'How about \"Low Tide, 3AM\"? I can give you a few more.' },\n];\n\nconst DEFAULT_SUGGESTIONS = [\"More title ideas\", \"Make it moodier\", \"Write a blurb\"];\n\nexport interface AIChatProps extends React.HTMLAttributes<HTMLDivElement> {\n name?: string;\n role?: string;\n placeholder?: string;\n messages?: ChatMessage[];\n suggestions?: string[];\n}\n\n/** Structured Liquidity AI chat — a rigid conversation panel with glass bubbles, animated by the kit script. */\nexport const AIChat = React.forwardRef<HTMLDivElement, AIChatProps>(\n (\n {\n name = \"Vector\",\n role = \"AI assistant\",\n placeholder = \"Ask Vector anything\",\n messages = DEFAULT_MESSAGES,\n suggestions = DEFAULT_SUGGESTIONS,\n className,\n ...props\n },\n ref,\n ) => (\n <div ref={ref} className={cn(\"sl-chat\", className)} data-chat {...props}>\n <div className=\"ch-head\">\n <span className=\"ch-ava\">\n <Sparkles />\n </span>\n <span className=\"ch-id\">\n <strong>{name}</strong>\n <span>{role}</span>\n </span>\n <span className=\"ch-dot\" title=\"Online\" aria-hidden=\"true\" />\n </div>\n <div className=\"ch-log\" data-chat-log role=\"log\" aria-live=\"polite\">\n {messages.map((m, i) => (\n <div className={cn(\"ch-msg\", m.from)} key={i}>\n {m.from === \"bot\" && (\n <span className=\"ch-b-ava\">\n <Sparkles />\n </span>\n )}\n <div className=\"ch-bubble\">{m.text}</div>\n </div>\n ))}\n </div>\n <div className=\"ch-suggest\" data-chat-suggest>\n {suggestions.map((s, i) => (\n <button type=\"button\" className=\"ch-chip\" key={i}>\n {s}\n </button>\n ))}\n </div>\n <form className=\"ch-form\" data-chat-form>\n <span className=\"ch-attach\" aria-hidden=\"true\">\n <Paperclip />\n </span>\n <input\n className=\"ch-input\"\n data-chat-input\n type=\"text\"\n placeholder={placeholder}\n aria-label={`Message ${name}`}\n autoComplete=\"off\"\n />\n <span className=\"ch-mic\" aria-hidden=\"true\">\n <Mic />\n </span>\n <button type=\"submit\" className=\"ch-send\" aria-label=\"Send message\">\n <ArrowUp />\n </button>\n </form>\n </div>\n ),\n);\nAIChat.displayName = \"AIChat\";\n",
"content": "import * as React from \"react\";\nimport { Sparkles, Paperclip, Mic, ArrowUp } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ChatMessage {\n from: \"bot\" | \"me\";\n text: React.ReactNode;\n}\n\nconst DEFAULT_MESSAGES: ChatMessage[] = [\n {\n from: \"bot\",\n text: \"Hey, I'm Vector. Ask me to draft, summarize, or name anything.\",\n },\n { from: \"me\", text: \"Summarize the latest transit observation.\" },\n {\n from: \"bot\",\n text: \"One stable orbit, two anomalies, and a clean signal window at 03:20 UTC.\",\n },\n];\n\nconst DEFAULT_SUGGESTIONS = [\"Show anomalies\", \"Compare orbits\", \"Draft a log\"];\n\nexport interface AIChatProps extends React.HTMLAttributes<HTMLDivElement> {\n name?: string;\n role?: string;\n placeholder?: string;\n messages?: ChatMessage[];\n suggestions?: string[];\n}\n\n/** Structured Liquidity AI chat — a rigid conversation panel with glass bubbles, animated by the kit script. */\nexport const AIChat = React.forwardRef<HTMLDivElement, AIChatProps>(\n (\n {\n name = \"Vector\",\n role = \"AI assistant\",\n placeholder = \"Ask Vector anything\",\n messages = DEFAULT_MESSAGES,\n suggestions = DEFAULT_SUGGESTIONS,\n className,\n ...props\n },\n ref,\n ) => (\n <div ref={ref} className={cn(\"sl-chat\", className)} data-chat {...props}>\n <div className=\"ch-head\">\n <span className=\"ch-ava\">\n <Sparkles />\n </span>\n <span className=\"ch-id\">\n <strong>{name}</strong>\n <span>{role}</span>\n </span>\n <span className=\"ch-dot\" title=\"Online\" aria-hidden=\"true\" />\n </div>\n <div className=\"ch-log\" data-chat-log role=\"log\" aria-live=\"polite\">\n {messages.map((m, i) => (\n <div className={cn(\"ch-msg\", m.from)} key={i}>\n {m.from === \"bot\" && (\n <span className=\"ch-b-ava\">\n <Sparkles />\n </span>\n )}\n <div className=\"ch-bubble\">{m.text}</div>\n </div>\n ))}\n </div>\n <div className=\"ch-suggest\" data-chat-suggest>\n {suggestions.map((s, i) => (\n <button type=\"button\" className=\"ch-chip\" key={i}>\n {s}\n </button>\n ))}\n </div>\n <form className=\"ch-form\" data-chat-form>\n <span className=\"ch-attach\" aria-hidden=\"true\">\n <Paperclip />\n </span>\n <input\n className=\"ch-input\"\n data-chat-input\n type=\"text\"\n placeholder={placeholder}\n aria-label={`Message ${name}`}\n autoComplete=\"off\"\n />\n <span className=\"ch-mic\" aria-hidden=\"true\">\n <Mic />\n </span>\n <button type=\"submit\" className=\"ch-send\" aria-label=\"Send message\">\n <ArrowUp />\n </button>\n </form>\n </div>\n ),\n);\nAIChat.displayName = \"AIChat\";\n",
"type": "registry:ui",
"target": "components/ui/ai-chat.tsx"
}
Expand Down
Loading
Loading