Skip to content
Merged
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
8 changes: 5 additions & 3 deletions plugins/chassis/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export const CORE_API_URL = (process.env.CORE_API_URL ?? "http://localhost:8080").replace(/\/$/, "");
export const CORE_ORG_ID = process.env.CORE_ORG_ID ?? "acme";
export const CORE_SIGNING_SECRET = process.env.CORE_SIGNING_SECRET;
export const PORTAL_IDENTITY_SECRET = process.env.PORTAL_IDENTITY_SECRET ?? CORE_SIGNING_SECRET;
if (!process.env.PORTAL_IDENTITY_SECRET && CORE_SIGNING_SECRET) {
const secret = (raw: string | undefined): string | undefined => (raw?.trim() ? raw : undefined);

export const CORE_SIGNING_SECRET = secret(process.env.CORE_SIGNING_SECRET);
export const PORTAL_IDENTITY_SECRET = secret(process.env.PORTAL_IDENTITY_SECRET) ?? CORE_SIGNING_SECRET;
if (!secret(process.env.PORTAL_IDENTITY_SECRET) && CORE_SIGNING_SECRET) {
console.warn(
"[chassis] PORTAL_IDENTITY_SECRET unset — signing portal identity with CORE_SIGNING_SECRET (dev fallback)",
);
Expand Down
34 changes: 25 additions & 9 deletions plugins/web-ui/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,14 @@ function conversationForScope(
return { kind, channelRef: ref, threadRef, ...(channelName ? { channelName } : {}) };
}

function resolveIdentity(
req: IncomingMessage,
): { user: string; name: string | null; impersonator: string | null } | null {
interface Identity {
user: string;
name: string | null;
impersonator: string | null;
}
type Denial = "unauthenticated" | "not_allowed";

function authenticate(req: IncomingMessage): { identity: Identity } | { denied: Denial } {
let user: string | null | undefined;
let name: string | null | undefined;
let impersonator: string | null | undefined;
Expand All @@ -254,20 +259,31 @@ function resolveIdentity(
name = claims.n ?? null;
impersonator = claims.imp ?? null;
} else {
if (!COOKIE_AUTH) return null;
if (!COOKIE_AUTH) return { denied: "unauthenticated" };
user = cookie(req, "webuiuser");
name = cookie(req, "webuiuser_name");
impersonator = cookie(req, "webui_impersonator");
}
if (!user) return null;
if (ALLOW.length > 0 && !ALLOW.includes(user)) return null;
return { user, name: name?.trim() || null, impersonator: impersonator ?? null };
if (!user) return { denied: "unauthenticated" };
if (ALLOW.length > 0 && !ALLOW.includes(user)) return { denied: "not_allowed" };
return { identity: { user, name: name?.trim() || null, impersonator: impersonator ?? null } };
}

function resolveIdentity(req: IncomingMessage): Identity | null {
const outcome = authenticate(req);
return "identity" in outcome ? outcome.identity : null;
}

function cookieUser(req: IncomingMessage): string | null {
return resolveIdentity(req)?.user ?? null;
}

function unauthorized(res: ServerResponse, req: IncomingMessage): void {
const outcome = authenticate(req);
const denied = "denied" in outcome ? outcome.denied : "unauthenticated";
return json(res, 401, { error: "sign in", mode: AUTH_MODE, reason: denied });
}

const SESSION_TTL_S = 90 * 24 * 60 * 60;
function sessionCookie(id: string): string {
return `webuiuser=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_TTL_S}`;
Expand Down Expand Up @@ -749,7 +765,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => {

if (path === "/me" || path.startsWith("/api/")) {
const user = cookieUser(req);
if (!user) return json(res, 401, { error: "sign in", mode: AUTH_MODE });
if (!user) return unauthorized(res, req);

if (path === "/me") {
res.setHeader("set-cookie", sessionCookie(user));
Expand Down Expand Up @@ -1833,7 +1849,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => {

if (method === "GET" && path.startsWith("/deployments/")) {
const user = cookieUser(req);
if (!user) return json(res, 401, { error: "sign in", mode: AUTH_MODE });
if (!user) return unauthorized(res, req);
const rest = path.slice("/deployments/".length);
const slash = rest.indexOf("/");
const id = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash));
Expand Down
21 changes: 20 additions & 1 deletion plugins/web-ui/src/core-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,10 @@ async function toCoreAttachment(a: PiAttachment): Promise<CoreAttachment> {
headers: { "content-type": "application/octet-stream" },
body: bytes as unknown as BodyInit,
});
if (!r.ok) throw new ApiError(`attachment upload failed: HTTP ${r.status}`, r.status);
if (!r.ok) {
if (r.status === 401) reportSigninRequired(await r.json().catch(() => ({})));
throw new ApiError(`attachment upload failed: HTTP ${r.status}`, r.status);
}
const { blobId, sizeBytes } = (await r.json()) as { blobId: string; sizeBytes: number };
return { name: a.fileName, mimetype: a.mimeType, sizeBytes: sizeBytes ?? a.size, blobId };
}
Expand All @@ -358,6 +361,21 @@ export class ApiError extends Error {
}
}

export interface SigninRequired {
mode?: "portal" | "dev";
reason?: "unauthenticated" | "not_allowed";
}

let onSigninRequired: ((detail: SigninRequired) => void) | null = null;

export function setSigninRequiredHandler(fn: (detail: SigninRequired) => void): void {
onSigninRequired = fn;
}

export function reportSigninRequired(detail: SigninRequired): void {
onSigninRequired?.(detail);
}

export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(withBase(path), { headers: { "content-type": "application/json" }, ...init });
const text = await r.text();
Expand All @@ -368,6 +386,7 @@ export async function api<T = unknown>(path: string, init?: RequestInit): Promis
swallow("web-ui: parse api response body", e);
}
if (!r.ok) {
if (r.status === 401 && path !== "/signin") reportSigninRequired(body as SigninRequired);
const msg =
(body as { error?: string; message?: string })?.message ??
(body as { error?: string })?.error ??
Expand Down
5 changes: 3 additions & 2 deletions plugins/web-ui/src/files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { html, nothing, render } from "lit";
import { File, Image, Upload } from "lucide";
import { api, withBase } from "./core-bridge";
import { api, reportSigninRequired, type SigninRequired, withBase } from "./core-bridge";
import { errMessage } from "../../chassis/src/errors";
import { browserRenderableImage, formatBytes, icon, relTime } from "./ui";
import { contextsState, ensureContexts, personalScopeId, scopeChip, scopeFilterControl } from "./contexts";
Expand Down Expand Up @@ -221,7 +221,8 @@ async function uploadOne(file: globalThis.File): Promise<void> {
const text = await r.text();
let message = `Upload failed (${r.status})`;
try {
const parsed = JSON.parse(text) as { message?: string; error?: string };
const parsed = JSON.parse(text) as { message?: string; error?: string } & SigninRequired;
if (r.status === 401) reportSigninRequired(parsed);
message = parsed.message ?? parsed.error ?? message;
} catch {
if (text.trim()) message = text.trim();
Expand Down
8 changes: 2 additions & 6 deletions plugins/web-ui/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import "dockview-core/dist/styles/dockview.css";
import "./shell.css";
import { swallow } from "../../chassis/src/errors";
import { appState, boot, renderAuthGate } from "./shell";
import { bootSafely } from "./shell";
import { closeFormMenus } from "./ui";
import { drawActiveChat } from "./chat";
import { composerState, slashQuery } from "./composer";
Expand Down Expand Up @@ -45,7 +44,4 @@ document.addEventListener("keydown", (e) => {
if (changed) drawActiveChat();
});

void boot().catch((e: unknown) => {
if (appState.me) swallow("web-ui: boot", e);
else renderAuthGate({ kind: "unreachable" });
});
void bootSafely();
Loading