2026-10-07 16:26:39 +02:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Capability tokens for rendering a local HTML file.
|
|
|
|
|
*
|
|
|
|
|
* `file-raw` never serves HTML as HTML: a page rendered from Codeman's own origin
|
|
|
|
|
* could read the Codeman document and drive the agent-spawning API. So an HTML
|
|
|
|
|
* file is rendered from `/html-view/:cap/*` instead, with a `Content-Security-
|
|
|
|
|
* Policy: sandbox` header that puts the document in an OPAQUE origin no matter how
|
|
|
|
|
* it is opened (iframe or top-level tab). That opaque origin is also why the route
|
|
|
|
|
* cannot authenticate on the session cookie: its subresource requests are
|
|
|
|
|
* cross-site, so the `SameSite=lax` cookie is never attached (the same reason the
|
|
|
|
|
* web-tab proxy uses capabilities, see webview-capabilities.ts).
|
|
|
|
|
*
|
|
|
|
|
* A capability is minted by an authenticated `POST /api/sessions/:id/html-view` and
|
|
|
|
|
* grants exactly one thing: GET access to the files in ONE directory (the HTML
|
|
|
|
|
* file's own), so a page's relative `style.css` / `chart.js` / images resolve.
|
|
|
|
|
*
|
|
|
|
|
* - 192 bits of `randomBytes` entropy, base64url.
|
|
|
|
|
* - Held in memory only. A restart invalidates every outstanding capability.
|
|
|
|
|
* - Rolling TTL, revoked on logout, admin logout and user deletion (`revokeOwner`).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { randomBytes } from 'node:crypto';
|
|
|
|
|
import { StaleExpirationMap } from './utils/index.js';
|
|
|
|
|
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
|
|
|
|
|
|
|
|
|
/** URL prefix the rendered files are served under. */
|
|
|
|
|
export const HTML_VIEW_PREFIX = '/html-view';
|
|
|
|
|
|
|
|
|
|
export interface HtmlViewCapabilityRecord {
|
|
|
|
|
/** Realpath of the directory the capability serves. */
|
|
|
|
|
rootDir: string;
|
|
|
|
|
/** Username that minted it (multi-user); undefined in single-user mode. */
|
|
|
|
|
owner?: string;
|
2026-10-13 17:09:00 +02:00
|
|
|
/**
|
|
|
|
|
* Whether the directory lies outside the minting session's workspace (admitted
|
|
|
|
|
* under the attachment guard). Recorded so the serving route can refuse it the
|
|
|
|
|
* moment workspace confinement is switched on, as the attachment routes do.
|
|
|
|
|
*/
|
|
|
|
|
outsideWorkspace: boolean;
|
2026-10-07 16:26:39 +02:00
|
|
|
createdAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class HtmlViewCapabilityStore {
|
|
|
|
|
private readonly capabilities: StaleExpirationMap<string, HtmlViewCapabilityRecord>;
|
|
|
|
|
/** Reverse index so re-opening a page reuses its capability instead of leaking one per click. */
|
|
|
|
|
private readonly byRoot = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
|
|
|
|
this.capabilities = new StaleExpirationMap<string, HtmlViewCapabilityRecord>({
|
|
|
|
|
ttlMs,
|
|
|
|
|
refreshOnGet: true,
|
|
|
|
|
onExpire: (token, record) => {
|
2026-10-13 17:09:00 +02:00
|
|
|
const key = rootKey(record.rootDir, record.owner, record.outsideWorkspace);
|
2026-10-07 16:26:39 +02:00
|
|
|
if (this.byRoot.get(key) === token) this.byRoot.delete(key);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Mint (or reuse) a capability for a directory. Returns the token. */
|
2026-10-13 17:09:00 +02:00
|
|
|
mint(rootDir: string, owner?: string, outsideWorkspace = false): string {
|
|
|
|
|
// The flag is part of the key: an out-of-workspace mint must never reuse a
|
|
|
|
|
// capability recorded as in-workspace, which confinement would not stop.
|
|
|
|
|
const key = rootKey(rootDir, owner, outsideWorkspace);
|
2026-10-07 16:26:39 +02:00
|
|
|
const existing = this.byRoot.get(key);
|
|
|
|
|
if (existing && this.capabilities.get(existing)) return existing;
|
|
|
|
|
if (existing) this.byRoot.delete(key);
|
|
|
|
|
|
|
|
|
|
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
|
|
|
|
|
|
|
|
|
const token = randomBytes(24).toString('base64url');
|
2026-10-13 17:09:00 +02:00
|
|
|
this.capabilities.set(token, { rootDir, owner, outsideWorkspace, createdAt: Date.now() });
|
2026-10-07 16:26:39 +02:00
|
|
|
this.byRoot.set(key, token);
|
|
|
|
|
return token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
|
|
|
|
resolve(token: string): HtmlViewCapabilityRecord | undefined {
|
|
|
|
|
if (!token) return undefined;
|
|
|
|
|
return this.capabilities.get(token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Revoke every capability bound to an identity (logout, admin logout, user deletion). */
|
|
|
|
|
revokeOwner(owner: string | undefined): number {
|
|
|
|
|
let revoked = 0;
|
|
|
|
|
for (const [key, token] of [...this.byRoot]) {
|
|
|
|
|
const record = this.capabilities.peek(token);
|
|
|
|
|
if (!record || record.owner === owner) {
|
|
|
|
|
this.capabilities.delete(token);
|
|
|
|
|
this.byRoot.delete(key);
|
|
|
|
|
if (record) revoked++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return revoked;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get size(): number {
|
|
|
|
|
return this.capabilities.size;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dispose(): void {
|
|
|
|
|
this.capabilities.dispose();
|
|
|
|
|
this.byRoot.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 17:09:00 +02:00
|
|
|
function rootKey(rootDir: string, owner: string | undefined, outsideWorkspace: boolean): string {
|
|
|
|
|
return `${owner ?? ''}\0${outsideWorkspace ? 'out' : 'in'}\0${rootDir}`;
|
2026-10-07 16:26:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The capability token in an `/html-view/<cap>/...` path, or null. */
|
|
|
|
|
export function capabilityFromHtmlViewPath(pathname: string): string | null {
|
|
|
|
|
if (typeof pathname !== 'string') return null;
|
|
|
|
|
const prefix = `${HTML_VIEW_PREFIX}/`;
|
|
|
|
|
if (!pathname.startsWith(prefix)) return null;
|
|
|
|
|
const rest = pathname.slice(prefix.length);
|
|
|
|
|
const slash = rest.indexOf('/');
|
|
|
|
|
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
|
|
|
|
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Process-wide store, shared by the routes and the auth middleware (see webviewCapabilities). */
|
|
|
|
|
export const htmlViewCapabilities = new HtmlViewCapabilityStore();
|