Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
This commit is contained in:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -77,6 +77,7 @@ import { createPushRuntime } from './lib/notifications/push-runtime.js';
|
||||
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
|
||||
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
|
||||
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
|
||||
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
|
||||
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
|
||||
import webPush from 'web-push';
|
||||
@@ -263,6 +264,7 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
||||
const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json');
|
||||
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json');
|
||||
const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
|
||||
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION = 1;
|
||||
@@ -815,6 +817,12 @@ const staticRoutesRuntime = createStaticRoutesRuntime({
|
||||
normalizePwaAppName,
|
||||
normalizePwaOrientation,
|
||||
});
|
||||
const remoteClientAuthRuntime = createRemoteClientAuthRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
storePath: REMOTE_CLIENTS_FILE_PATH,
|
||||
});
|
||||
const featureRoutesRuntime = createFeatureRoutesRuntime({
|
||||
clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
});
|
||||
@@ -1040,6 +1048,7 @@ async function main(options = {}) {
|
||||
const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
|
||||
const host = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined;
|
||||
const tryCfTunnel = options.tryCfTunnel === true;
|
||||
const apiOnly = options.apiOnly === true || isEnvFlagEnabled(process.env.OPENCHAMBER_API_ONLY);
|
||||
const shouldUseCanonicalTunnelConfig = typeof options.tunnelMode === 'string'
|
||||
|| typeof options.tunnelProvider === 'string'
|
||||
|| options.tunnelConfigPath === null
|
||||
@@ -1081,7 +1090,24 @@ async function main(options = {}) {
|
||||
|
||||
const app = express();
|
||||
const serverStartedAt = new Date().toISOString();
|
||||
const packagedClientOrigins = new Set(['openchamber-ui://app']);
|
||||
app.set('trust proxy', true);
|
||||
app.use((req, res, next) => {
|
||||
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
|
||||
if (packagedClientOrigins.has(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory');
|
||||
res.setHeader('Access-Control-Expose-Headers', 'x-next-cursor');
|
||||
res.setHeader('Vary', 'Origin');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(compression({
|
||||
filter: (req, res) => {
|
||||
if (shouldSkipCompression(req, res)) return false;
|
||||
@@ -1126,11 +1152,13 @@ async function main(options = {}) {
|
||||
bunBinaryResolved: resolvedBunBinary || null,
|
||||
desktopNotifyEnabled: ENV_DESKTOP_NOTIFY,
|
||||
planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED,
|
||||
apiOnly,
|
||||
};
|
||||
},
|
||||
verboseRequestLogs: OPENCHAMBER_VERBOSE_REQUEST_LOGS,
|
||||
uiPassword,
|
||||
tunnelAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
sayTTSCapability,
|
||||
@@ -1260,6 +1288,7 @@ async function main(options = {}) {
|
||||
onTunnelReady,
|
||||
tunnelRuntimeContext,
|
||||
attachSignals,
|
||||
apiOnly,
|
||||
});
|
||||
terminalRuntime = startupPipelineResult.terminalRuntime;
|
||||
messageStreamRuntime = startupPipelineResult.messageStreamRuntime;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
const STORE_VERSION = 1;
|
||||
const TOKEN_PREFIX = 'oc_client_';
|
||||
const TOKEN_BYTES = 32;
|
||||
const MAX_LABEL_LENGTH = 80;
|
||||
const LAST_USED_WRITE_INTERVAL_MS = 60_000;
|
||||
|
||||
const normalizeLabel = (value) => {
|
||||
if (typeof value !== 'string') return 'Remote client';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return 'Remote client';
|
||||
return trimmed.length > MAX_LABEL_LENGTH ? trimmed.slice(0, MAX_LABEL_LENGTH) : trimmed;
|
||||
};
|
||||
|
||||
const normalizeTimestamp = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const time = Date.parse(trimmed);
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
||||
};
|
||||
|
||||
const normalizeOptionalString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const safeJsonParse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const constantTimeEqual = (left, right, crypto) => {
|
||||
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
||||
const leftBuffer = Buffer.from(left, 'hex');
|
||||
const rightBuffer = Buffer.from(right, 'hex');
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
};
|
||||
|
||||
export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storePath }) => {
|
||||
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const generateId = () => crypto.randomBytes(12).toString('hex');
|
||||
const generateToken = () => `${TOKEN_PREFIX}${crypto.randomBytes(TOKEN_BYTES).toString('base64url')}`;
|
||||
let storeMutationQueue = Promise.resolve();
|
||||
|
||||
const withStoreMutation = async (fn) => {
|
||||
const previous = storeMutationQueue;
|
||||
let release;
|
||||
storeMutationQueue = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeStore = (payload) => ({
|
||||
version: STORE_VERSION,
|
||||
clients: Array.isArray(payload?.clients)
|
||||
? payload.clients
|
||||
.filter((client) => client && typeof client === 'object')
|
||||
.map((client) => ({
|
||||
id: typeof client.id === 'string' ? client.id : generateId(),
|
||||
label: normalizeLabel(client.label),
|
||||
tokenHash: typeof client.tokenHash === 'string' ? client.tokenHash : '',
|
||||
createdAt: typeof client.createdAt === 'string' ? client.createdAt : nowIso(),
|
||||
lastUsedAt: typeof client.lastUsedAt === 'string' ? client.lastUsedAt : null,
|
||||
revokedAt: typeof client.revokedAt === 'string' ? client.revokedAt : null,
|
||||
expiresAt: normalizeTimestamp(client.expiresAt),
|
||||
clientKind: normalizeOptionalString(client.clientKind),
|
||||
dedupeKey: normalizeOptionalString(client.dedupeKey),
|
||||
}))
|
||||
.filter((client) => client.tokenHash.length > 0)
|
||||
: [],
|
||||
});
|
||||
|
||||
const readStore = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(storePath, 'utf8');
|
||||
return normalizeStore(safeJsonParse(raw));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return normalizeStore(null);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStore = async (store) => {
|
||||
await fsPromises.mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 });
|
||||
await fsPromises.writeFile(storePath, JSON.stringify(normalizeStore(store), null, 2), { mode: 0o600 });
|
||||
if (typeof fsPromises.chmod === 'function') {
|
||||
await fsPromises.chmod(storePath, 0o600).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const publicClient = (client) => ({
|
||||
id: client.id,
|
||||
label: client.label,
|
||||
createdAt: client.createdAt,
|
||||
lastUsedAt: client.lastUsedAt,
|
||||
revokedAt: client.revokedAt,
|
||||
expiresAt: client.expiresAt,
|
||||
clientKind: client.clientKind,
|
||||
});
|
||||
|
||||
const listClients = async () => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
return store.clients.map(publicClient);
|
||||
});
|
||||
};
|
||||
|
||||
const createClient = async ({ label, expiresAt, clientKind, dedupeKey } = {}) => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
|
||||
const token = generateToken();
|
||||
const client = {
|
||||
id: generateId(),
|
||||
label: normalizeLabel(label),
|
||||
tokenHash: hashToken(token),
|
||||
createdAt: nowIso(),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: normalizeTimestamp(expiresAt),
|
||||
clientKind: normalizeOptionalString(clientKind),
|
||||
dedupeKey: normalizedDedupeKey,
|
||||
};
|
||||
if (normalizedDedupeKey) {
|
||||
store.clients = store.clients.filter((entry) => entry.dedupeKey !== normalizedDedupeKey);
|
||||
}
|
||||
store.clients.push(client);
|
||||
await writeStore(store);
|
||||
return { client: publicClient(client), token };
|
||||
});
|
||||
};
|
||||
|
||||
const revokeClient = async (id) => {
|
||||
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||
return { revoked: false };
|
||||
}
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const client = store.clients.find((entry) => entry.id === id);
|
||||
if (!client) return { revoked: false };
|
||||
if (!client.revokedAt) client.revokedAt = nowIso();
|
||||
await writeStore(store);
|
||||
return { revoked: true, client: publicClient(client) };
|
||||
});
|
||||
};
|
||||
|
||||
const purgeRevokedClients = async () => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const before = store.clients.length;
|
||||
store.clients = store.clients.filter((entry) => !entry.revokedAt);
|
||||
const purged = before - store.clients.length;
|
||||
if (purged > 0) {
|
||||
await writeStore(store);
|
||||
}
|
||||
return { purged };
|
||||
});
|
||||
};
|
||||
|
||||
const authenticateBearerToken = async (token) => {
|
||||
if (typeof token !== 'string' || !token.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return withStoreMutation(async () => {
|
||||
const tokenHash = hashToken(token);
|
||||
const store = await readStore();
|
||||
const client = store.clients.find((entry) => !entry.revokedAt && constantTimeEqual(entry.tokenHash, tokenHash, crypto));
|
||||
if (!client) return null;
|
||||
if (client.expiresAt && Date.parse(client.expiresAt) <= Date.now()) return null;
|
||||
const now = Date.now();
|
||||
const lastUsedAt = Date.parse(client.lastUsedAt || '');
|
||||
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS) {
|
||||
client.lastUsedAt = new Date(now).toISOString();
|
||||
await writeStore(store);
|
||||
}
|
||||
return { ok: true, clientId: client.id, sessionToken: client.id, client: publicClient(client) };
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
authenticateBearerToken,
|
||||
createClient,
|
||||
listClients,
|
||||
purgeRevokedClients,
|
||||
revokeClient,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { createRemoteClientAuthRuntime } from './remote-clients.js';
|
||||
|
||||
const createRuntime = async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-remote-clients-test-'));
|
||||
const runtime = createRemoteClientAuthRuntime({
|
||||
fsPromises: fs,
|
||||
path,
|
||||
crypto,
|
||||
storePath: path.join(dir, 'remote-clients.json'),
|
||||
});
|
||||
return { dir, runtime };
|
||||
};
|
||||
|
||||
describe('remote client auth runtime', () => {
|
||||
it('creates, authenticates, lists, and revokes client tokens', async () => {
|
||||
const { dir, runtime } = await createRuntime();
|
||||
try {
|
||||
const created = await runtime.createClient({ label: 'Laptop' });
|
||||
expect(created.token.startsWith('oc_client_')).toBe(true);
|
||||
expect(created.client.label).toBe('Laptop');
|
||||
|
||||
const listed = await runtime.listClients();
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].id).toBe(created.client.id);
|
||||
expect('tokenHash' in listed[0]).toBe(false);
|
||||
|
||||
const authenticated = await runtime.authenticateBearerToken(created.token);
|
||||
expect(authenticated?.ok).toBe(true);
|
||||
expect(authenticated?.clientId).toBe(created.client.id);
|
||||
|
||||
const afterUse = await runtime.listClients();
|
||||
expect(typeof afterUse[0].lastUsedAt).toBe('string');
|
||||
|
||||
const revoked = await runtime.revokeClient(created.client.id);
|
||||
expect(revoked.revoked).toBe(true);
|
||||
expect(await runtime.authenticateBearerToken(created.token)).toBe(null);
|
||||
|
||||
const purged = await runtime.purgeRevokedClients();
|
||||
expect(purged.purged).toBe(1);
|
||||
expect(await runtime.listClients()).toHaveLength(0);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects expired client tokens', async () => {
|
||||
const { dir, runtime } = await createRuntime();
|
||||
try {
|
||||
const expired = await runtime.createClient({ label: 'Expired', expiresAt: '2000-01-01T00:00:00.000Z' });
|
||||
expect(expired.client.expiresAt).toBe('2000-01-01T00:00:00.000Z');
|
||||
expect(await runtime.authenticateBearerToken(expired.token)).toBe(null);
|
||||
|
||||
const active = await runtime.createClient({ label: 'Active', expiresAt: '2999-01-01T00:00:00.000Z' });
|
||||
const authenticated = await runtime.authenticateBearerToken(active.token);
|
||||
expect(authenticated?.ok).toBe(true);
|
||||
expect(authenticated?.clientId).toBe(active.client.id);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps one client per dedupe key', async () => {
|
||||
const { dir, runtime } = await createRuntime();
|
||||
try {
|
||||
const first = await runtime.createClient({ label: 'Desktop', clientKind: 'desktop-local', dedupeKey: 'desktop-local' });
|
||||
const second = await runtime.createClient({ label: 'Desktop', clientKind: 'desktop-local', dedupeKey: 'desktop-local' });
|
||||
|
||||
expect(await runtime.authenticateBearerToken(first.token)).toBe(null);
|
||||
const authenticated = await runtime.authenticateBearerToken(second.token);
|
||||
expect(authenticated?.ok).toBe(true);
|
||||
|
||||
const listed = await runtime.listClients();
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].id).toBe(second.client.id);
|
||||
expect(listed[0].clientKind).toBe('desktop-local');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the token store private on disk', async () => {
|
||||
const { dir, runtime } = await createRuntime();
|
||||
try {
|
||||
await runtime.createClient({ label: 'Laptop' });
|
||||
const stat = await fs.stat(path.join(dir, 'remote-clients.json'));
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not resurrect revoked clients after concurrent auth traffic', async () => {
|
||||
const { dir, runtime } = await createRuntime();
|
||||
try {
|
||||
const created = await runtime.createClient({ label: 'Laptop' });
|
||||
await Promise.all([
|
||||
...Array.from({ length: 20 }, () => runtime.authenticateBearerToken(created.token)),
|
||||
runtime.revokeClient(created.client.id),
|
||||
]);
|
||||
|
||||
expect(await runtime.authenticateBearerToken(created.token)).toBe(null);
|
||||
const clients = await runtime.listClients();
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(typeof clients[0].revokedAt).toBe('string');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ This module provides notification message preparation utilities for the web serv
|
||||
- session parent cache for subtask suppression
|
||||
- template resolution and fallback behavior
|
||||
- native notification fanout and web push payload fanout
|
||||
- push suppression while any fresh UI visibility heartbeat reports a focused client
|
||||
|
||||
### Push runtime API (push-runtime.js)
|
||||
- `createPushRuntime(dependencies)`: creates runtime for web push and UI visibility state.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
||||
const UI_VISIBILITY_TTL_MS = 30_000;
|
||||
|
||||
const isLoopbackHttpOrigin = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -24,7 +25,13 @@ export const createPushRuntime = (deps) => {
|
||||
let pushInitialized = false;
|
||||
|
||||
const uiVisibilityByToken = new Map();
|
||||
let globalVisibilityState = false;
|
||||
const pruneUiVisibility = (now = Date.now()) => {
|
||||
for (const [token, state] of uiVisibilityByToken) {
|
||||
if (!state || now - state.updatedAt > UI_VISIBILITY_TTL_MS) {
|
||||
uiVisibilityByToken.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readPushSubscriptionsFromDisk = async () => {
|
||||
try {
|
||||
@@ -235,12 +242,25 @@ export const createPushRuntime = (deps) => {
|
||||
const now = Date.now();
|
||||
const nextVisible = Boolean(visible);
|
||||
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
|
||||
globalVisibilityState = nextVisible;
|
||||
};
|
||||
|
||||
const isAnyUiVisible = () => globalVisibilityState === true;
|
||||
const isAnyUiVisible = () => {
|
||||
const now = Date.now();
|
||||
pruneUiVisibility(now);
|
||||
for (const state of uiVisibilityByToken.values()) {
|
||||
if (state.visible === true && now - state.updatedAt <= UI_VISIBILITY_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true;
|
||||
const isUiVisible = (token) => {
|
||||
const now = Date.now();
|
||||
pruneUiVisibility(now);
|
||||
const state = uiVisibilityByToken.get(token);
|
||||
return state?.visible === true && now - state.updatedAt <= UI_VISIBILITY_TTL_MS;
|
||||
};
|
||||
|
||||
const resolveVapidSubject = async () => {
|
||||
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createPushRuntime } from './push-runtime.js';
|
||||
|
||||
const createRuntime = () => createPushRuntime({
|
||||
fsPromises: {
|
||||
mkdir: vi.fn(async () => {}),
|
||||
readFile: vi.fn(async () => JSON.stringify({ version: 1, subscriptionsBySession: {} })),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
},
|
||||
path: { dirname: () => '/tmp' },
|
||||
webPush: {
|
||||
generateVAPIDKeys: vi.fn(() => ({ publicKey: 'public', privateKey: 'private' })),
|
||||
sendNotification: vi.fn(async () => {}),
|
||||
setVapidDetails: vi.fn(),
|
||||
},
|
||||
PUSH_SUBSCRIPTIONS_FILE_PATH: '/tmp/push-subscriptions.json',
|
||||
readSettingsFromDiskMigrated: vi.fn(async () => ({})),
|
||||
writeSettingsToDisk: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('push runtime visibility tracking', () => {
|
||||
it('keeps visible UI state when another client reports hidden', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||
|
||||
const runtime = createRuntime();
|
||||
|
||||
runtime.updateUiVisibility('visible-client', true);
|
||||
runtime.updateUiVisibility('hidden-client', false);
|
||||
|
||||
expect(runtime.isAnyUiVisible()).toBe(true);
|
||||
expect(runtime.isUiVisible('visible-client')).toBe(true);
|
||||
expect(runtime.isUiVisible('hidden-client')).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(30_001);
|
||||
|
||||
expect(runtime.isAnyUiVisible()).toBe(false);
|
||||
expect(runtime.isUiVisible('visible-client')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,15 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
||||
};
|
||||
|
||||
const extractDirectoryFromPayload = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return undefined;
|
||||
const props = payload.properties;
|
||||
const directory = props?.directory ?? props?.info?.directory;
|
||||
if (typeof directory !== 'string') return undefined;
|
||||
const trimmed = directory.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const formatMode = (raw) => {
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
const normalized = value.length > 0 ? value : 'agent';
|
||||
@@ -197,6 +206,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
maybeCacheSessionParentFromPayload(payload);
|
||||
|
||||
const sessionId = extractSessionIdFromPayload(payload);
|
||||
const notificationDirectory = extractDirectoryFromPayload(payload);
|
||||
if (payload.type === 'message.updated') {
|
||||
const info = payload.properties?.info;
|
||||
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
||||
@@ -266,6 +276,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
tag: `ready-${sessionId}`,
|
||||
kind: 'ready',
|
||||
sessionId,
|
||||
directory: notificationDirectory,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
};
|
||||
emitDesktopNotification(notificationPayload);
|
||||
@@ -327,6 +338,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
tag: `error-${sessionId}`,
|
||||
kind: 'error',
|
||||
sessionId,
|
||||
directory: notificationDirectory,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
};
|
||||
emitDesktopNotification(notificationPayload);
|
||||
@@ -402,6 +414,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
body,
|
||||
tag: `question-${sessionId}`,
|
||||
sessionId,
|
||||
directory: notificationDirectory,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
|
||||
@@ -411,6 +424,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
body,
|
||||
tag: `question-${sessionId}`,
|
||||
sessionId,
|
||||
directory: notificationDirectory,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
}
|
||||
@@ -522,6 +536,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
body,
|
||||
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
||||
sessionId,
|
||||
directory: notificationDirectory,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
|
||||
@@ -531,6 +546,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
body,
|
||||
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
||||
sessionId,
|
||||
directory: notificationDirectory,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createNotificationTemplateRuntime } from './template-runtime.js';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const createRuntime = (settings = {}) => createNotificationTemplateRuntime({
|
||||
readSettingsFromDisk: async () => settings,
|
||||
persistSettings: vi.fn(async () => {}),
|
||||
@@ -11,6 +13,10 @@ const createRuntime = (settings = {}) => createNotificationTemplateRuntime({
|
||||
});
|
||||
|
||||
describe('notification template runtime zen models', () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('returns no selectable zen models after provider retirement', async () => {
|
||||
const runtime = createRuntime();
|
||||
const models = await runtime.fetchFreeZenModels();
|
||||
|
||||
@@ -21,6 +21,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
verboseRequestLogs,
|
||||
uiPassword,
|
||||
tunnelAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
sayTTSCapability,
|
||||
@@ -65,6 +66,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
const uiAuthController = createUiAuth({
|
||||
password: uiPassword,
|
||||
readSettingsFromDiskMigrated,
|
||||
clientAuthController: remoteClientAuthRuntime,
|
||||
});
|
||||
if (uiAuthController.enabled) {
|
||||
console.log('UI password protection enabled for browser sessions');
|
||||
@@ -74,6 +76,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
express,
|
||||
tunnelAuthController,
|
||||
uiAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export const runCliEntryIfMain = (dependencies) => {
|
||||
attachSignals: true,
|
||||
exitOnShutdown: true,
|
||||
uiPassword: cliOptions.uiPassword,
|
||||
apiOnly: cliOptions.apiOnly,
|
||||
}).catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -19,6 +19,7 @@ export const parseServeCliOptions = ({
|
||||
: undefined;
|
||||
const envTunnelToken = env.OPENCHAMBER_TUNNEL_TOKEN || undefined;
|
||||
const envTunnelHostname = env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined;
|
||||
const envApiOnly = env.OPENCHAMBER_API_ONLY === '1' || env.OPENCHAMBER_API_ONLY === 'true';
|
||||
|
||||
const options = {
|
||||
port: defaultPort,
|
||||
@@ -30,6 +31,7 @@ export const parseServeCliOptions = ({
|
||||
tunnelConfigPath: envTunnelConfig,
|
||||
tunnelToken: envTunnelToken,
|
||||
tunnelHostname: envTunnelHostname,
|
||||
apiOnly: envApiOnly,
|
||||
};
|
||||
|
||||
const consumeValue = (currentIndex, inlineValue) => {
|
||||
@@ -75,6 +77,11 @@ export const parseServeCliOptions = ({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'api-only') {
|
||||
options.apiOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'try-cf-tunnel') {
|
||||
options.tryCfTunnel = true;
|
||||
continue;
|
||||
|
||||
@@ -22,6 +22,42 @@ const parseLoopbackUrl = (rawUrl) => {
|
||||
return url;
|
||||
};
|
||||
|
||||
const getRequestPathname = (req) => {
|
||||
const rawUrl = req?.originalUrl || req?.url || '';
|
||||
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return '';
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getQueryParam = (req, name) => {
|
||||
const rawUrl = req?.originalUrl || req?.url || '';
|
||||
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return '';
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').searchParams.get(name)?.trim() || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getCookieValue = (req, name) => {
|
||||
const cookieHeader = req?.headers?.cookie;
|
||||
if (typeof cookieHeader !== 'string' || cookieHeader.length === 0) return '';
|
||||
for (const segment of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValueParts] = segment.split('=');
|
||||
if (rawName?.trim() !== name) continue;
|
||||
return rawValueParts.join('=').trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const hasPreviewProxyCredential = (req) => {
|
||||
if (!getRequestPathname(req).startsWith('/api/preview/proxy/')) return false;
|
||||
return Boolean(getQueryParam(req, 'oc_preview_token') || getCookieValue(req, 'oc_preview_token'));
|
||||
};
|
||||
|
||||
export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
const {
|
||||
express,
|
||||
@@ -56,6 +92,19 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
});
|
||||
};
|
||||
|
||||
const compatibility = {
|
||||
apiVersion: 1,
|
||||
minClientApiVersion: 1,
|
||||
capabilities: [
|
||||
'api.health.v1',
|
||||
'api.runtime-url.v1',
|
||||
'api.raw-file.v1',
|
||||
'realtime.sse.v1',
|
||||
'realtime.websocket.global-events.v1',
|
||||
'terminal.websocket.v1',
|
||||
],
|
||||
};
|
||||
|
||||
const isDevShutdownAllowed = () => {
|
||||
// Dev-only escape hatch: allow terminating the whole dev process group.
|
||||
// This should never be enabled in production runtimes.
|
||||
@@ -166,10 +215,23 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
openchamberVersion,
|
||||
runtime: runtimeName,
|
||||
compatibility,
|
||||
...getHealthSnapshot(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/version', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
openchamberVersion,
|
||||
runtime: runtimeName,
|
||||
startedAt: serverStartedAt,
|
||||
compatibility,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/system/shutdown', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
gracefulShutdown({ exitProcess: true }).catch((error) => {
|
||||
@@ -271,11 +333,69 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
express,
|
||||
tunnelAuthController,
|
||||
uiAuthController,
|
||||
remoteClientAuthRuntime,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
} = dependencies;
|
||||
|
||||
const runWithUiAuth = async (req, res, next, handler, options = {}) => {
|
||||
try {
|
||||
const requireAuth = options.sessionOnly === true && typeof uiAuthController.requireSessionAuth === 'function'
|
||||
? uiAuthController.requireSessionAuth
|
||||
: uiAuthController.requireAuth;
|
||||
await requireAuth(req, res, async () => {
|
||||
await handler();
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const runWithClientManagementAuth = async (req, res, next, handler) => {
|
||||
try {
|
||||
if (typeof uiAuthController.resolveAuthContext === 'function') {
|
||||
const context = await uiAuthController.resolveAuthContext(req, res, {
|
||||
allowClientAuth: true,
|
||||
allowUrlToken: false,
|
||||
});
|
||||
if (context?.type === 'session' || context?.type === 'client') {
|
||||
await handler(context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await runWithUiAuth(req, res, next, async () => {
|
||||
await handler({ type: 'session' });
|
||||
}, { sessionOnly: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const clientIdFromAuthContext = (context) => {
|
||||
const raw = context?.client?.id || context?.clientId;
|
||||
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
||||
};
|
||||
|
||||
const clientRecordFromAuthContext = async (context) => {
|
||||
if (context?.client && typeof context.client === 'object') {
|
||||
return context.client;
|
||||
}
|
||||
const clientId = clientIdFromAuthContext(context);
|
||||
if (!clientId) return null;
|
||||
const clients = await remoteClientAuthRuntime.listClients();
|
||||
return clients.find((client) => client.id === clientId) || null;
|
||||
};
|
||||
|
||||
const requireApiAuth = async (req, res, next) => {
|
||||
// Preview proxy requests carry a target-scoped capability token that the
|
||||
// preview proxy validates against the registered target id/TTL. Let those
|
||||
// requests reach that stricter check instead of failing the global UI auth
|
||||
// gate when the short-lived browser URL auth token expires.
|
||||
if (hasPreviewProxyCredential(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return tunnelAuthController.requireTunnelSession(req, res, next);
|
||||
@@ -309,6 +429,14 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return uiAuthController.handleSessionCreate(req, res);
|
||||
});
|
||||
|
||||
app.post('/auth/url-token', async (req, res, next) => {
|
||||
try {
|
||||
await uiAuthController.handleUrlAuthToken(req, res);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/auth/passkey/status', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
@@ -339,7 +467,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.requireSessionAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRegistrationOptions(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -353,7 +481,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.requireSessionAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRegistrationVerify(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -367,7 +495,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.requireSessionAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyList(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -381,7 +509,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.requireSessionAuth(req, res, async () => {
|
||||
await uiAuthController.handlePasskeyRevoke(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -395,7 +523,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
return res.status(403).json({ error: 'Global sign-out is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
try {
|
||||
await uiAuthController.requireAuth(req, res, async () => {
|
||||
await uiAuthController.requireSessionAuth(req, res, async () => {
|
||||
await uiAuthController.handleResetAuth(req, res);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -403,6 +531,52 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/client-auth/clients', async (req, res, next) => {
|
||||
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
||||
if (authContext.type === 'client') {
|
||||
const client = await clientRecordFromAuthContext(authContext);
|
||||
return res.json({ clients: client ? [client] : [] });
|
||||
}
|
||||
const clients = await remoteClientAuthRuntime.listClients();
|
||||
res.json({ clients });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => {
|
||||
await runWithUiAuth(req, res, next, async () => {
|
||||
const result = await remoteClientAuthRuntime.createClient({
|
||||
label: req.body?.label,
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.status(201).json(result);
|
||||
}, { sessionOnly: true });
|
||||
});
|
||||
|
||||
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
|
||||
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
||||
if (authContext.type === 'client') {
|
||||
const clientId = clientIdFromAuthContext(authContext);
|
||||
if (!clientId || clientId !== req.params?.id) {
|
||||
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
|
||||
}
|
||||
}
|
||||
const result = await remoteClientAuthRuntime.revokeClient(req.params?.id);
|
||||
if (!result.revoked) {
|
||||
return res.status(404).json({ revoked: false, error: 'Client not found' });
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/client-auth/clients', async (req, res, next) => {
|
||||
await runWithUiAuth(req, res, next, async () => {
|
||||
const result = await remoteClientAuthRuntime.purgeRevokedClients();
|
||||
res.json(result);
|
||||
}, { sessionOnly: true });
|
||||
});
|
||||
|
||||
app.get('/connect', async (req, res) => {
|
||||
try {
|
||||
const token = typeof req.query?.t === 'string' ? req.query.t : '';
|
||||
|
||||
@@ -83,4 +83,190 @@ describe('core-routes', () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should let preview proxy credentials reach preview proxy validation', async () => {
|
||||
const app = express();
|
||||
const requireAuth = vi.fn((_req, res) => res.status(401).type('text/plain').send('Authentication required'));
|
||||
|
||||
registerAuthAndAccessRoutes(app, {
|
||||
express,
|
||||
tunnelAuthController: {
|
||||
classifyRequestScope: () => 'local',
|
||||
requireTunnelSession: vi.fn(),
|
||||
getTunnelSessionFromRequest: vi.fn(),
|
||||
clearTunnelSessionCookie: vi.fn(),
|
||||
exchangeBootstrapToken: vi.fn(),
|
||||
},
|
||||
uiAuthController: {
|
||||
requireAuth,
|
||||
handleSessionStatus: vi.fn(),
|
||||
handleSessionCreate: vi.fn(),
|
||||
handlePasskeyStatus: vi.fn(),
|
||||
handlePasskeyAuthenticationOptions: vi.fn(),
|
||||
handlePasskeyAuthenticationVerify: vi.fn(),
|
||||
handlePasskeyRegistrationOptions: vi.fn(),
|
||||
handlePasskeyRegistrationVerify: vi.fn(),
|
||||
handlePasskeyList: vi.fn(),
|
||||
handlePasskeyRevoke: vi.fn(),
|
||||
handleResetAuth: vi.fn(),
|
||||
},
|
||||
readSettingsFromDiskMigrated: vi.fn(async () => ({})),
|
||||
normalizeTunnelSessionTtlMs: vi.fn(),
|
||||
});
|
||||
|
||||
app.use('/api/preview/proxy', (_req, res) => res.json({ reached: true }));
|
||||
|
||||
await request(app)
|
||||
.get('/api/preview/proxy/abc123/?oc_preview_token=preview-secret')
|
||||
.expect(200, { reached: true });
|
||||
|
||||
await request(app)
|
||||
.get('/api/preview/proxy/abc123/')
|
||||
.set('Cookie', 'oc_preview_token=preview-secret')
|
||||
.expect(200, { reached: true });
|
||||
|
||||
await request(app)
|
||||
.get('/api/preview/proxy/abc123/')
|
||||
.expect(401, 'Authentication required');
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client auth routes', () => {
|
||||
const createDependencies = (options = {}) => {
|
||||
const clients = [];
|
||||
const requireAuth = vi.fn((_req, _res, next) => next());
|
||||
const requireSessionAuth = vi.fn((_req, _res, next) => next());
|
||||
const resolveAuthContext = vi.fn(options.resolveAuthContext || (async () => ({ type: 'session' })));
|
||||
return {
|
||||
express,
|
||||
tunnelAuthController: {
|
||||
classifyRequestScope: () => 'local',
|
||||
getTunnelSessionFromRequest: () => null,
|
||||
clearTunnelSessionCookie: () => {},
|
||||
requireTunnelSession: (_req, _res, next) => next(),
|
||||
},
|
||||
uiAuthController: {
|
||||
handleSessionStatus: (_req, res) => res.json({ authenticated: true }),
|
||||
handleSessionCreate: (_req, res) => res.json({ authenticated: true }),
|
||||
handlePasskeyStatus: (_req, res) => res.json({ enabled: false }),
|
||||
handlePasskeyAuthenticationOptions: (_req, res) => res.json({}),
|
||||
handlePasskeyAuthenticationVerify: (_req, res) => res.json({ authenticated: true }),
|
||||
requireAuth,
|
||||
requireSessionAuth,
|
||||
resolveAuthContext,
|
||||
handlePasskeyRegistrationOptions: (_req, res) => res.json({}),
|
||||
handlePasskeyRegistrationVerify: (_req, res) => res.json({}),
|
||||
handlePasskeyList: (_req, res) => res.json({ passkeys: [] }),
|
||||
handlePasskeyRevoke: (_req, res) => res.json({ revoked: true }),
|
||||
handleResetAuth: (_req, res) => res.json({ cleared: true }),
|
||||
},
|
||||
remoteClientAuthRuntime: {
|
||||
listClients: async () => clients,
|
||||
createClient: async ({ label, clientKind }) => {
|
||||
const client = {
|
||||
id: `client-${clients.length + 1}`,
|
||||
label: label || 'Remote client',
|
||||
createdAt: 'now',
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
clientKind: clientKind || null,
|
||||
};
|
||||
clients.push(client);
|
||||
return { client, token: 'oc_client_secret' };
|
||||
},
|
||||
revokeClient: async (id) => {
|
||||
const client = clients.find((entry) => entry.id === id);
|
||||
if (!client) return { revoked: false };
|
||||
client.revokedAt = 'revoked';
|
||||
return { revoked: true, client };
|
||||
},
|
||||
purgeRevokedClients: async () => {
|
||||
const before = clients.length;
|
||||
for (let index = clients.length - 1; index >= 0; index -= 1) {
|
||||
if (clients[index].revokedAt) clients.splice(index, 1);
|
||||
}
|
||||
return { purged: before - clients.length };
|
||||
},
|
||||
},
|
||||
readSettingsFromDiskMigrated: async () => ({}),
|
||||
normalizeTunnelSessionTtlMs: () => 1000,
|
||||
testHooks: { clients, requireAuth, requireSessionAuth, resolveAuthContext },
|
||||
};
|
||||
};
|
||||
|
||||
it('creates, lists, and revokes remote client tokens', async () => {
|
||||
const app = express();
|
||||
const dependencies = createDependencies();
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
|
||||
const created = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'Laptop' });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.token).toBe('oc_client_secret');
|
||||
expect(created.headers['cache-control']).toBe('no-store');
|
||||
|
||||
const listed = await request(app).get('/api/client-auth/clients');
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.clients).toHaveLength(1);
|
||||
expect(listed.body.clients[0]).not.toHaveProperty('token');
|
||||
|
||||
const revoked = await request(app).delete('/api/client-auth/clients/client-1');
|
||||
expect(revoked.status).toBe(200);
|
||||
expect(revoked.body.revoked).toBe(true);
|
||||
|
||||
const purged = await request(app).delete('/api/client-auth/clients');
|
||||
expect(purged.status).toBe(200);
|
||||
expect(purged.body.purged).toBe(1);
|
||||
|
||||
const listedAfterPurge = await request(app).get('/api/client-auth/clients');
|
||||
expect(listedAfterPurge.body.clients).toHaveLength(0);
|
||||
expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalled();
|
||||
expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows client credentials to list and revoke only the authenticated client', async () => {
|
||||
const app = express();
|
||||
let authContext = { type: 'session' };
|
||||
const dependencies = createDependencies({
|
||||
resolveAuthContext: async () => authContext,
|
||||
});
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
|
||||
const current = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
|
||||
const other = await request(app)
|
||||
.post('/api/client-auth/clients')
|
||||
.send({ label: 'Other device' });
|
||||
|
||||
authContext = { type: 'client', clientId: current.body.client.id, client: current.body.client };
|
||||
|
||||
const listed = await request(app).get('/api/client-auth/clients');
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.clients).toEqual([current.body.client]);
|
||||
|
||||
const denied = await request(app).delete(`/api/client-auth/clients/${other.body.client.id}`);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.revoked).toBe(false);
|
||||
|
||||
const revoked = await request(app).delete(`/api/client-auth/clients/${current.body.client.id}`);
|
||||
expect(revoked.status).toBe(200);
|
||||
expect(revoked.body.revoked).toBe(true);
|
||||
expect(revoked.body.client.id).toBe(current.body.client.id);
|
||||
});
|
||||
|
||||
it('requires UI-session auth for passkey registration management routes', async () => {
|
||||
const app = express();
|
||||
const dependencies = createDependencies();
|
||||
registerAuthAndAccessRoutes(app, dependencies);
|
||||
|
||||
await request(app).post('/auth/passkey/register/options').expect(200);
|
||||
await request(app).post('/auth/passkey/register/verify').expect(200);
|
||||
|
||||
expect(dependencies.testHooks.requireSessionAuth).toHaveBeenCalledTimes(2);
|
||||
expect(dependencies.testHooks.requireAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,10 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
||||
restartCmdFallback += ` --ui-password '${escapedPw}'`;
|
||||
}
|
||||
}
|
||||
if (storedOptions.apiOnly === true) {
|
||||
restartCmdPrimary += ' --api-only';
|
||||
restartCmdFallback += ' --api-only';
|
||||
}
|
||||
const restartCmd = isForegroundService ? '' : `(${restartCmdPrimary}) || (${restartCmdFallback})`;
|
||||
const updateLogPath = path.join(openchamberDataDir, 'update-install.log');
|
||||
const logPreamble = [
|
||||
|
||||
@@ -123,6 +123,61 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
realpath: fs?.promises?.realpath?.bind(fs.promises),
|
||||
});
|
||||
|
||||
const hasParsedBodyValue = (body) => {
|
||||
if (body === undefined || body === null) return false;
|
||||
if (Buffer.isBuffer(body)) return body.length > 0;
|
||||
if (typeof body === 'string') return body.length > 0;
|
||||
if (Array.isArray(body)) return body.length > 0;
|
||||
if (typeof body === 'object') return Object.keys(body).length > 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getContentType = (proxyReq, req) => {
|
||||
const value = proxyReq.getHeader?.('content-type') ?? req.headers?.['content-type'] ?? '';
|
||||
if (Array.isArray(value)) return value[0] || '';
|
||||
return String(value || '');
|
||||
};
|
||||
|
||||
const serializeUrlEncodedBody = (body) => {
|
||||
if (!body || typeof body !== 'object' || Buffer.isBuffer(body)) {
|
||||
return String(body ?? '');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
if (entry !== undefined && entry !== null) params.append(key, String(entry));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
params.append(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
const serializeParsedBody = (req, proxyReq) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return null;
|
||||
if (req.body === undefined || req.body === null) return null;
|
||||
const originalContentLength = Number.parseInt(req.headers?.['content-length'] || '0', 10) || 0;
|
||||
if (!hasParsedBodyValue(req.body) && originalContentLength <= 0) return null;
|
||||
|
||||
const contentType = getContentType(proxyReq, req).toLowerCase();
|
||||
if (Buffer.isBuffer(req.body)) return req.body;
|
||||
if (contentType.includes('application/json')) return Buffer.from(JSON.stringify(req.body));
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) return Buffer.from(serializeUrlEncodedBody(req.body));
|
||||
if (typeof req.body === 'string') return Buffer.from(req.body);
|
||||
return null;
|
||||
};
|
||||
|
||||
const replayParsedBody = (proxyReq, req) => {
|
||||
const body = serializeParsedBody(req, proxyReq);
|
||||
if (!body) return;
|
||||
proxyReq.setHeader('content-length', String(body.length));
|
||||
proxyReq.write(body);
|
||||
};
|
||||
|
||||
const normalizeProxyTarget = (candidate) => {
|
||||
if (typeof candidate !== 'string') {
|
||||
return null;
|
||||
@@ -417,7 +472,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
// Dynamic target — port can change after restart
|
||||
router: () => resolveProxyTarget(),
|
||||
on: {
|
||||
proxyReq: (proxyReq) => {
|
||||
proxyReq: (proxyReq, req) => {
|
||||
// Inject OpenCode auth headers
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
if (authHeaders.Authorization) {
|
||||
@@ -427,6 +482,8 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
// Defensive: request identity encoding from upstream OpenCode.
|
||||
// This avoids compressed-body/header mismatches in multi-proxy setups.
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
|
||||
replayParsedBody(proxyReq, req);
|
||||
},
|
||||
proxyRes: (proxyRes) => {
|
||||
for (const key of Object.keys(proxyRes.headers || {})) {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
||||
onTunnelReady,
|
||||
tunnelRuntimeContext,
|
||||
attachSignals,
|
||||
apiOnly,
|
||||
} = options;
|
||||
|
||||
const terminalRuntime = createTerminalRuntime({
|
||||
@@ -88,7 +89,11 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
||||
scheduleOpenCodeApiDetection();
|
||||
void bootstrapOpenCodeAtStartup();
|
||||
|
||||
staticRoutesRuntime.registerStaticRoutes(app);
|
||||
if (apiOnly) {
|
||||
staticRoutesRuntime.registerApiOnlyFallbackRoutes(app);
|
||||
} else {
|
||||
staticRoutesRuntime.registerStaticRoutes(app);
|
||||
}
|
||||
|
||||
const serverStartupRuntime = createServerStartupRuntime({
|
||||
process,
|
||||
|
||||
@@ -59,7 +59,207 @@ export const createStaticRoutesRuntime = (dependencies) => {
|
||||
});
|
||||
};
|
||||
|
||||
const registerApiOnlyFallbackRoutes = (app) => {
|
||||
app.get(/^(?!\/api|\/auth|\/health|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
||||
const command = 'openchamber connect-url --help';
|
||||
res.status(200).format({
|
||||
html: () => {
|
||||
res.send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenChamber API-only mode</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--surface-background: #151313;
|
||||
--surface-elevated: #1c1b1a;
|
||||
--surface-foreground: #cdccc3;
|
||||
--surface-muted-foreground: #b6b4ab;
|
||||
--interactive-border: rgba(57,56,54,.72);
|
||||
--primary-base: #edb449;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-background: oklch(0.97 0.02 85);
|
||||
--surface-elevated: oklch(0.99 0.01 90);
|
||||
--surface-foreground: oklch(0.25 0.02 40);
|
||||
--surface-muted-foreground: oklch(0.45 0.02 50);
|
||||
--interactive-border: rgba(194,151,77,.22);
|
||||
--primary-base: oklch(0.65 0.2 55);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: var(--surface-background);
|
||||
color: var(--surface-foreground);
|
||||
padding: 32px;
|
||||
}
|
||||
main {
|
||||
width: min(448px, 100%);
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
margin: 0 auto 28px;
|
||||
display: block;
|
||||
color: var(--surface-foreground);
|
||||
opacity: .88;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.025em;
|
||||
}
|
||||
p {
|
||||
margin: 10px auto 0;
|
||||
max-width: 400px;
|
||||
color: var(--surface-muted-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.command {
|
||||
margin: 24px auto 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
max-width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--interactive-border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--surface-background) 60%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
code {
|
||||
color: var(--surface-foreground);
|
||||
font: 13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
text-align: left;
|
||||
}
|
||||
button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--surface-muted-foreground);
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
button:hover { color: var(--surface-foreground); }
|
||||
button svg { width: 16px; height: 16px; display: block; }
|
||||
.check-icon { display: none; }
|
||||
button[data-copied="true"] .copy-icon { display: none; }
|
||||
button[data-copied="true"] .check-icon { display: block; color: var(--primary-base); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<svg class="logo" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber logo">
|
||||
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="currentColor" fill-opacity=".15" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M8.432 26 L18.824 32 L18.824 44 L8.432 38 Z" fill="currentColor" fill-opacity=".2"/>
|
||||
<path d="M18.824 32 L29.216 38 L29.216 50 L18.824 44 Z" fill="currentColor" fill-opacity=".45"/>
|
||||
<path d="M29.216 38 L39.608 44 L39.608 56 L29.216 50 Z" fill="currentColor" fill-opacity=".15"/>
|
||||
<path d="M39.608 44 L50 50 L50 62 L39.608 56 Z" fill="currentColor" fill-opacity=".55"/>
|
||||
<path d="M8.432 38 L18.824 44 L18.824 56 L8.432 50 Z" fill="currentColor" fill-opacity=".35"/>
|
||||
<path d="M18.824 44 L29.216 50 L29.216 62 L18.824 56 Z" fill="currentColor" fill-opacity=".1"/>
|
||||
<path d="M29.216 50 L39.608 56 L39.608 68 L29.216 62 Z" fill="currentColor" fill-opacity=".5"/>
|
||||
<path d="M39.608 56 L50 62 L50 74 L39.608 68 Z" fill="currentColor" fill-opacity=".25"/>
|
||||
<path d="M8.432 50 L18.824 56 L18.824 68 L8.432 62 Z" fill="currentColor" fill-opacity=".4"/>
|
||||
<path d="M18.824 56 L29.216 62 L29.216 74 L18.824 68 Z" fill="currentColor" fill-opacity=".3"/>
|
||||
<path d="M29.216 62 L39.608 68 L39.608 80 L29.216 74 Z" fill="currentColor" fill-opacity=".45"/>
|
||||
<path d="M39.608 68 L50 74 L50 86 L39.608 80 Z" fill="currentColor" fill-opacity=".15"/>
|
||||
<path d="M8.432 62 L18.824 68 L18.824 80 L8.432 74 Z" fill="currentColor" fill-opacity=".55"/>
|
||||
<path d="M18.824 68 L29.216 74 L29.216 86 L18.824 80 Z" fill="currentColor" fill-opacity=".2"/>
|
||||
<path d="M29.216 74 L39.608 80 L39.608 92 L29.216 86 Z" fill="currentColor" fill-opacity=".35"/>
|
||||
<path d="M39.608 80 L50 86 L50 98 L39.608 92 Z" fill="currentColor" fill-opacity=".1"/>
|
||||
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="currentColor" fill-opacity=".15" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="currentColor" fill-opacity=".3"/>
|
||||
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="currentColor" fill-opacity=".15"/>
|
||||
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="currentColor" fill-opacity=".45"/>
|
||||
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="currentColor" fill-opacity=".25"/>
|
||||
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="currentColor" fill-opacity=".5"/>
|
||||
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="currentColor" fill-opacity=".35"/>
|
||||
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="currentColor" fill-opacity=".1"/>
|
||||
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="currentColor" fill-opacity=".4"/>
|
||||
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="currentColor" fill-opacity=".2"/>
|
||||
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="currentColor" fill-opacity=".55"/>
|
||||
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="currentColor" fill-opacity=".3"/>
|
||||
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="currentColor" fill-opacity=".15"/>
|
||||
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="currentColor" fill-opacity=".45"/>
|
||||
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="currentColor" fill-opacity=".25"/>
|
||||
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="currentColor" fill-opacity=".4"/>
|
||||
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="currentColor" fill-opacity=".2"/>
|
||||
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g transform="matrix(.866 .5 -.866 .5 50 26) scale(.75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="currentColor"/>
|
||||
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="currentColor" fill-opacity=".4"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1>OpenChamber is running in headless mode</h1>
|
||||
<p>This server is ready. Open it from the OpenChamber desktop or mobile app to use it.</p>
|
||||
<div class="command">
|
||||
<code id="connect-command">${command}</code>
|
||||
<button type="button" id="copy-command" aria-label="Copy command" title="Copy command">
|
||||
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 7.2C8 6.08 8 5.52 8.218 5.092a2 2 0 0 1 .874-.874C9.52 4 10.08 4 11.2 4h5.6c1.12 0 1.68 0 2.108.218a2 2 0 0 1 .874.874C20 5.52 20 6.08 20 7.2v5.6c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874C18.48 16 17.92 16 16.8 16h-5.6c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874C8 14.48 8 13.92 8 12.8V7.2Z" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M4 8v8.8C4 17.92 4 18.48 4.218 18.908a2 2 0 0 0 .874.874C5.52 20 6.08 20 7.2 20H16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg class="check-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12.5 9.5 17 19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const button = document.getElementById('copy-command');
|
||||
const command = document.getElementById('connect-command');
|
||||
let copyTimer;
|
||||
button?.addEventListener('click', async () => {
|
||||
const text = command?.textContent || '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.dataset.copied = 'true';
|
||||
window.clearTimeout(copyTimer);
|
||||
copyTimer = window.setTimeout(() => {
|
||||
button.dataset.copied = 'false';
|
||||
}, 1400);
|
||||
} catch {
|
||||
button.dataset.copied = 'false';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
},
|
||||
json: () => {
|
||||
res.json({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
|
||||
},
|
||||
default: () => {
|
||||
res.type('text/plain').send('OpenChamber is running in API-only mode');
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerApiOnlyFallbackRoutes,
|
||||
registerStaticRoutes,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createStaticRoutesRuntime } from './static-routes-runtime.js';
|
||||
|
||||
const createRuntime = () => createStaticRoutesRuntime({
|
||||
fs: { existsSync: () => false },
|
||||
path: { join: (...parts) => parts.join('/'), resolve: (value) => value, sep: '/' },
|
||||
process: { env: {} },
|
||||
__dirname: '/server',
|
||||
express,
|
||||
resolveProjectDirectory: () => '',
|
||||
buildOpenCodeUrl: () => '',
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
readSettingsFromDiskMigrated: async () => ({}),
|
||||
normalizePwaAppName: (value) => value,
|
||||
normalizePwaOrientation: (value) => value,
|
||||
});
|
||||
|
||||
describe('static routes runtime', () => {
|
||||
it('returns API-only HTML fallback for browser UI routes', async () => {
|
||||
const app = express();
|
||||
createRuntime().registerApiOnlyFallbackRoutes(app);
|
||||
|
||||
const response = await request(app).get('/sessions/abc').set('Accept', 'text/html');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('OpenChamber is running in headless mode');
|
||||
expect(response.text).toContain('Open it from the OpenChamber desktop or mobile app');
|
||||
expect(response.text).toContain('openchamber connect-url --help');
|
||||
expect(response.text).toContain('Copy command');
|
||||
});
|
||||
|
||||
it('returns API-only info JSON for JSON clients', async () => {
|
||||
const app = express();
|
||||
createRuntime().registerApiOnlyFallbackRoutes(app);
|
||||
|
||||
const response = await request(app).get('/sessions/abc').set('Accept', 'application/json');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
ok: true,
|
||||
mode: 'api-only',
|
||||
message: 'OpenChamber is running in API-only mode',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not intercept API, auth, or health routes in API-only mode', async () => {
|
||||
const app = express();
|
||||
createRuntime().registerApiOnlyFallbackRoutes(app);
|
||||
|
||||
const api = await request(app).get('/api/version');
|
||||
const auth = await request(app).get('/auth/session');
|
||||
const health = await request(app).get('/health');
|
||||
|
||||
expect(api.body).not.toEqual({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
|
||||
expect(auth.body).not.toEqual({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
|
||||
expect(health.body).not.toEqual({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
const DEFAULT_TARGET_TTL_MS = 30 * 60 * 1000;
|
||||
const TOKEN_COOKIE_NAME = 'oc_preview_token';
|
||||
const TOKEN_QUERY_PARAM = 'oc_preview_token';
|
||||
const CLIENT_TOKEN_QUERY_PARAM = 'oc_client_token';
|
||||
const URL_AUTH_TOKEN_QUERY_PARAM = 'oc_url_token';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set([
|
||||
'localhost',
|
||||
@@ -177,11 +180,23 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
|
||||
let nativeMatchMedia = null;
|
||||
const colorSchemeListeners = new Set();
|
||||
|
||||
const parentOrigin = (() => {
|
||||
try {
|
||||
const ancestorOrigins = window.location && window.location.ancestorOrigins;
|
||||
const ancestorOrigin = ancestorOrigins && ancestorOrigins.length > 0 ? ancestorOrigins[0] : '';
|
||||
if (ancestorOrigin && ancestorOrigin !== 'null') return ancestorOrigin;
|
||||
const origin = document.referrer ? new URL(document.referrer).origin : '';
|
||||
return origin && origin !== 'null' ? origin : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
const post = (payload) => {
|
||||
try {
|
||||
if (window.parent && typeof window.parent.postMessage === 'function') {
|
||||
if (parentOrigin && window.parent && typeof window.parent.postMessage === 'function') {
|
||||
const message = Object.assign({ source: SOURCE, version: VERSION }, payload || {});
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
window.parent.postMessage(message, parentOrigin);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -435,6 +450,9 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
|
||||
const proxyMatch = window.location.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
|
||||
if (!proxyMatch) return;
|
||||
const proxyBase = proxyMatch[1] + '/';
|
||||
const currentSearchParams = new URL(window.location.href).searchParams;
|
||||
const previewToken = currentSearchParams.get('oc_preview_token') || '';
|
||||
const urlAuthToken = currentSearchParams.get('oc_url_token') || '';
|
||||
let reloadTimer = 0;
|
||||
|
||||
const schedulePreviewReload = () => {
|
||||
@@ -454,8 +472,11 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
|
||||
try {
|
||||
const parsed = new URL(String(url), window.location.href);
|
||||
if (parsed.host !== window.location.host) return url;
|
||||
if (parsed.pathname.indexOf(proxyBase) === 0) return url;
|
||||
parsed.pathname = proxyBase;
|
||||
if (parsed.pathname.indexOf(proxyBase) !== 0) {
|
||||
parsed.pathname = proxyBase;
|
||||
}
|
||||
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
|
||||
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
@@ -496,6 +517,23 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
|
||||
const proxyMatch = window.location.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
|
||||
if (!proxyMatch) return;
|
||||
const proxyBase = proxyMatch[1];
|
||||
const currentSearchParams = new URL(window.location.href).searchParams;
|
||||
const previewToken = currentSearchParams.get('oc_preview_token') || '';
|
||||
const urlAuthToken = currentSearchParams.get('oc_url_token') || '';
|
||||
|
||||
const withProxyAuth = (value) => {
|
||||
if (typeof value !== 'string' || value.indexOf(proxyBase) !== 0) return value;
|
||||
if (!previewToken && !urlAuthToken) return value;
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin);
|
||||
parsed.searchParams.delete('oc_client_token');
|
||||
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
|
||||
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
|
||||
return parsed.pathname + parsed.search + parsed.hash;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldProxyPath = (pathname) => {
|
||||
if (typeof pathname !== 'string' || !pathname.startsWith('/') || pathname.startsWith('//')) return false;
|
||||
@@ -506,14 +544,15 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
|
||||
const proxiedUrl = (value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
if (value.startsWith('/')) {
|
||||
if (value.indexOf(proxyBase) === 0) return withProxyAuth(value);
|
||||
if (!shouldProxyPath(value)) return value;
|
||||
return proxyBase + value;
|
||||
return withProxyAuth(proxyBase + value);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value, window.location.href);
|
||||
if (parsed.origin === window.location.origin && shouldProxyPath(parsed.pathname)) {
|
||||
return proxyBase + parsed.pathname + parsed.search + parsed.hash;
|
||||
return withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -529,12 +568,47 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
|
||||
const isWebSocketProtocol = parsed.protocol === 'ws:' || parsed.protocol === 'wss:';
|
||||
if (sameHost && isWebSocketProtocol && shouldProxyPath(parsed.pathname)) {
|
||||
parsed.pathname = proxyBase + parsed.pathname;
|
||||
parsed.searchParams.delete('oc_client_token');
|
||||
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
|
||||
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch {}
|
||||
return value;
|
||||
};
|
||||
|
||||
const proxiedNavigationUrl = (value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
const parsed = new URL(value, window.location.href);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return value;
|
||||
if (parsed.origin === window.location.origin && parsed.pathname.indexOf(proxyBase) === 0) {
|
||||
return withProxyAuth(parsed.pathname + parsed.search + parsed.hash);
|
||||
}
|
||||
const host = parsed.hostname;
|
||||
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
|
||||
if (!isLoopback && parsed.origin !== window.location.origin) return value;
|
||||
if (!shouldProxyPath(parsed.pathname)) return value;
|
||||
return withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
|
||||
} catch {
|
||||
return proxiedUrl(value);
|
||||
}
|
||||
};
|
||||
|
||||
if (window.history && typeof window.history.pushState === 'function') {
|
||||
const nativePushState = window.history.pushState.bind(window.history);
|
||||
window.history.pushState = function(state, unused, url) {
|
||||
return nativePushState(state, unused, url === undefined ? url : proxiedNavigationUrl(String(url)));
|
||||
};
|
||||
}
|
||||
|
||||
if (window.history && typeof window.history.replaceState === 'function') {
|
||||
const nativeReplaceState = window.history.replaceState.bind(window.history);
|
||||
window.history.replaceState = function(state, unused, url) {
|
||||
return nativeReplaceState(state, unused, url === undefined ? url : proxiedNavigationUrl(String(url)));
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window.fetch === 'function') {
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = function(input, init) {
|
||||
@@ -545,7 +619,7 @@ export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) =>
|
||||
try {
|
||||
const parsed = new URL(input.url);
|
||||
if (parsed.origin === window.location.origin && shouldProxyPath(parsed.pathname)) {
|
||||
const nextUrl = proxyBase + parsed.pathname + parsed.search + parsed.hash;
|
||||
const nextUrl = withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
|
||||
return nativeFetch(new Request(nextUrl, input), init);
|
||||
}
|
||||
} catch {}
|
||||
@@ -912,9 +986,28 @@ export const normalizeProxyTargetUrl = (rawUrl, { allowExternal = false } = {})
|
||||
return { ok: true, origin: url.origin };
|
||||
};
|
||||
|
||||
const appendProxyAuthToProxyUrl = (value, { previewToken = '', urlAuthToken = '' } = {}) => {
|
||||
if (typeof value !== 'string' || !value) return value;
|
||||
const needsQueryRewrite = previewToken
|
||||
|| urlAuthToken
|
||||
|| value.includes(CLIENT_TOKEN_QUERY_PARAM)
|
||||
|| value.includes(URL_AUTH_TOKEN_QUERY_PARAM);
|
||||
if (!needsQueryRewrite) return value;
|
||||
try {
|
||||
const parsed = new URL(value, 'http://openchamber-preview.local');
|
||||
parsed.searchParams.delete(CLIENT_TOKEN_QUERY_PARAM);
|
||||
parsed.searchParams.delete(URL_AUTH_TOKEN_QUERY_PARAM);
|
||||
if (previewToken) parsed.searchParams.set(TOKEN_QUERY_PARAM, previewToken);
|
||||
if (urlAuthToken) parsed.searchParams.set(URL_AUTH_TOKEN_QUERY_PARAM, urlAuthToken);
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLoopbackUrl = (rawUrl) => normalizeProxyTargetUrl(rawUrl, { allowExternal: false });
|
||||
|
||||
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind }) => {
|
||||
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind, previewToken = '', urlAuthToken = '' }) => {
|
||||
if (typeof bodyText !== 'string' || bodyText.length === 0) {
|
||||
return bodyText;
|
||||
}
|
||||
@@ -935,13 +1028,13 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
|
||||
const rewriteResourceUrl = (value) => {
|
||||
if (typeof value !== 'string' || value.length === 0) return value;
|
||||
if (value.startsWith('/') && !value.startsWith('//')) {
|
||||
if (value.startsWith('/api/preview/proxy/')) return value;
|
||||
return `${prefix}${value}`;
|
||||
if (value.startsWith('/api/preview/proxy/')) return appendProxyAuthToProxyUrl(value, { previewToken, urlAuthToken });
|
||||
return appendProxyAuthToProxyUrl(`${prefix}${value}`, { previewToken, urlAuthToken });
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (isSameTargetOrigin(parsed)) {
|
||||
return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
return appendProxyAuthToProxyUrl(`${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`, { previewToken, urlAuthToken });
|
||||
}
|
||||
} catch {
|
||||
return value;
|
||||
@@ -963,6 +1056,9 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
|
||||
}).join(', ');
|
||||
return `srcset=${quote}${rewritten}${quote}`;
|
||||
});
|
||||
const stripPreviewCspMeta = (text) => text
|
||||
.replace(/<meta\b(?=[^>]*\bhttp-equiv\s*=\s*(['"])content-security-policy\1)[^>]*>/gi, '')
|
||||
.replace(/<meta\b(?=[^>]*\bhttp-equiv\s*=\s*content-security-policy\b)[^>]*>/gi, '');
|
||||
const rewriteCss = (text) => text
|
||||
.replace(/url\((['"]?)([^)'"]*)\1\)/gi, (_match, quote, value) => {
|
||||
const q = quote || '';
|
||||
@@ -982,12 +1078,67 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
|
||||
return `import(${quote}${rewriteResourceUrl(`/${path}`)}${quote})`;
|
||||
});
|
||||
|
||||
if (kind === 'html') return rewriteHtml(bodyText);
|
||||
if (kind === 'html') return stripPreviewCspMeta(rewriteHtml(bodyText));
|
||||
if (kind === 'css') return rewriteCss(bodyText);
|
||||
if (kind === 'javascript') return rewriteJavaScript(bodyText);
|
||||
return bodyText;
|
||||
};
|
||||
|
||||
// Rewrite a dev server's CSP so the injected preview bridge can run via a
|
||||
// per-response nonce, while keeping the dev server's own script restrictions.
|
||||
// frame-ancestors is dropped (it blocks embedding) and require-trusted-types-for
|
||||
// is dropped (it can block the bridge's DOM use); everything else is preserved.
|
||||
export const rewritePreviewCspHeader = (cspValue, nonce) => {
|
||||
if (typeof cspValue !== 'string' || cspValue.length === 0) return cspValue;
|
||||
const nonceSource = nonce ? `'nonce-${nonce}'` : '';
|
||||
const directives = cspValue
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const tokens = part.split(/\s+/);
|
||||
return { name: (tokens[0] || '').toLowerCase(), tokens };
|
||||
})
|
||||
.filter((directive) => directive.name !== 'frame-ancestors' && directive.name !== 'require-trusted-types-for');
|
||||
|
||||
if (nonceSource) {
|
||||
const byName = new Map(directives.map((directive) => [directive.name, directive]));
|
||||
const allowNonce = (directive) => {
|
||||
// Drop a lone 'none' so the nonce takes effect, then add our nonce.
|
||||
directive.tokens = directive.tokens.filter((token) => token.toLowerCase() !== "'none'");
|
||||
if (!directive.tokens.includes(nonceSource)) directive.tokens.push(nonceSource);
|
||||
};
|
||||
const scriptElem = byName.get('script-src-elem');
|
||||
const scriptSrc = byName.get('script-src');
|
||||
if (scriptElem) allowNonce(scriptElem);
|
||||
if (scriptSrc) allowNonce(scriptSrc);
|
||||
if (!scriptElem && !scriptSrc && byName.has('default-src')) {
|
||||
const base = byName.get('default-src').tokens.slice(1).filter((token) => token.toLowerCase() !== "'none'");
|
||||
directives.push({ name: 'script-src', tokens: ['script-src', ...base, nonceSource] });
|
||||
}
|
||||
}
|
||||
|
||||
const rebuilt = directives.map((directive) => directive.tokens.join(' '));
|
||||
return rebuilt.length > 0 ? rebuilt.join('; ') : null;
|
||||
};
|
||||
|
||||
export const rewritePreviewRedirectLocation = ({ location, proxyBasePath, targetOrigin, previewToken = '', urlAuthToken = '' }) => {
|
||||
if (typeof location !== 'string' || !location) return location;
|
||||
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
|
||||
const target = targetOrigin ? new URL(targetOrigin) : null;
|
||||
if (!target) return location;
|
||||
try {
|
||||
const parsed = new URL(location, target);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return location;
|
||||
const host = parsed.hostname;
|
||||
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
|
||||
if (!isLoopback || parsed.port !== target.port) return location;
|
||||
return appendProxyAuthToProxyUrl(`${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`, { previewToken, urlAuthToken });
|
||||
} catch {
|
||||
return location;
|
||||
}
|
||||
};
|
||||
|
||||
export const createPreviewProxyRuntime = ({
|
||||
crypto,
|
||||
URL,
|
||||
@@ -1050,7 +1201,7 @@ export const createPreviewProxyRuntime = ({
|
||||
}
|
||||
|
||||
const cookies = parseCookieHeader(req.headers?.cookie);
|
||||
const token = cookies.get(TOKEN_COOKIE_NAME) || '';
|
||||
const token = parsed.searchParams.get(TOKEN_QUERY_PARAM) || cookies.get(TOKEN_COOKIE_NAME) || '';
|
||||
if (!token || token !== entry.token) {
|
||||
return { ok: false, status: 403, error: 'Preview token missing' };
|
||||
}
|
||||
@@ -1079,31 +1230,9 @@ export const createPreviewProxyRuntime = ({
|
||||
return parts.length > 0 ? `?${parts.join('&')}` : '';
|
||||
};
|
||||
|
||||
// Strip the `frame-ancestors` directive from a CSP header value while
|
||||
// preserving every other directive. Returns null if no directives remain.
|
||||
const removeFrameAncestorsDirective = (cspValue) => {
|
||||
if (typeof cspValue !== 'string' || cspValue.length === 0) {
|
||||
return cspValue;
|
||||
}
|
||||
const directives = cspValue
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
const filtered = directives.filter((directive) => {
|
||||
const name = directive.split(/\s+/, 1)[0]?.toLowerCase() ?? '';
|
||||
return name !== 'frame-ancestors';
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filtered.join('; ');
|
||||
};
|
||||
|
||||
// Drop response headers that prevent the dev server from being framed.
|
||||
// The proxy itself is same-origin, so embedding is otherwise safe.
|
||||
const stripFrameBustingHeaders = (headers) => {
|
||||
// Drop only CSP directives that prevent framing or the injected preview bridge.
|
||||
// Preview targets are restricted to loopback dev servers.
|
||||
const stripFrameBustingHeaders = (headers, bridgeNonce) => {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return;
|
||||
}
|
||||
@@ -1119,7 +1248,7 @@ export const createPreviewProxyRuntime = ({
|
||||
const original = headers[key];
|
||||
const values = Array.isArray(original) ? original : [original];
|
||||
const rewritten = values
|
||||
.map((value) => removeFrameAncestorsDirective(value))
|
||||
.map((value) => rewritePreviewCspHeader(value, bridgeNonce))
|
||||
.filter((value) => typeof value === 'string' && value.length > 0);
|
||||
if (rewritten.length === 0) {
|
||||
delete headers[key];
|
||||
@@ -1139,13 +1268,14 @@ export const createPreviewProxyRuntime = ({
|
||||
}) => {
|
||||
ensureSweeper();
|
||||
|
||||
const injectPreviewBridge = (bodyText, targetOrigin) => {
|
||||
const injectPreviewBridge = (bodyText, targetOrigin, bridgeNonce) => {
|
||||
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
|
||||
return bodyText;
|
||||
}
|
||||
|
||||
const targetOriginScript = `<script>window.__openchamberPreviewTargetOrigin=${JSON.stringify(targetOrigin || '')};</script>`;
|
||||
const script = `${targetOriginScript}<script id="${PREVIEW_BRIDGE_SCRIPT_ID}">${PREVIEW_BRIDGE_SCRIPT}</script>`;
|
||||
const nonceAttr = bridgeNonce ? ` nonce="${bridgeNonce}"` : '';
|
||||
const targetOriginScript = `<script${nonceAttr}>window.__openchamberPreviewTargetOrigin=${JSON.stringify(targetOrigin || '')};</script>`;
|
||||
const script = `${targetOriginScript}<script id="${PREVIEW_BRIDGE_SCRIPT_ID}"${nonceAttr}>${PREVIEW_BRIDGE_SCRIPT}</script>`;
|
||||
if (/<head(?:\s[^>]*)?>/i.test(bodyText)) {
|
||||
return bodyText.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${script}`);
|
||||
}
|
||||
@@ -1216,6 +1346,7 @@ export const createPreviewProxyRuntime = ({
|
||||
return res.json({
|
||||
id: target.id,
|
||||
proxyBasePath: cookiePath,
|
||||
previewToken: target.token,
|
||||
expiresAt: target.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1260,7 +1391,11 @@ export const createPreviewProxyRuntime = ({
|
||||
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
|
||||
// Never forward our auth cookie token to the dev server.
|
||||
const strippedPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
||||
return `${strippedPath}${removeRawQueryParam(parsed.search, 'ocPreview')}`;
|
||||
const withoutReloadParam = removeRawQueryParam(parsed.search, 'ocPreview');
|
||||
const withoutPreviewToken = removeRawQueryParam(withoutReloadParam, TOKEN_QUERY_PARAM);
|
||||
const withoutClientToken = removeRawQueryParam(withoutPreviewToken, CLIENT_TOKEN_QUERY_PARAM);
|
||||
const withoutUrlAuthToken = removeRawQueryParam(withoutClientToken, URL_AUTH_TOKEN_QUERY_PARAM);
|
||||
return `${strippedPath}${withoutUrlAuthToken}`;
|
||||
},
|
||||
on: {
|
||||
proxyReq: (proxyReq) => {
|
||||
@@ -1271,10 +1406,30 @@ export const createPreviewProxyRuntime = ({
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
},
|
||||
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req) => {
|
||||
// Per-response nonce lets the injected bridge run under the dev
|
||||
// server's CSP without dropping its script restrictions wholesale.
|
||||
const bridgeNonce = crypto.randomBytes(16).toString('base64');
|
||||
// Allow the dev server response to be framed inside OpenChamber even
|
||||
// if it normally sets X-Frame-Options or a CSP frame-ancestors rule.
|
||||
// The proxy is same-origin so embedding is otherwise safe.
|
||||
stripFrameBustingHeaders(proxyRes.headers);
|
||||
stripFrameBustingHeaders(proxyRes.headers, bridgeNonce);
|
||||
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
return responseBuffer;
|
||||
}
|
||||
|
||||
const proxyBasePath = `/api/preview/proxy/${resolved.id}`;
|
||||
const urlAuthToken = resolved.parsed.searchParams.get(URL_AUTH_TOKEN_QUERY_PARAM) || '';
|
||||
if (typeof proxyRes.headers?.location === 'string') {
|
||||
proxyRes.headers.location = rewritePreviewRedirectLocation({
|
||||
location: proxyRes.headers.location,
|
||||
proxyBasePath,
|
||||
targetOrigin: resolved.entry.origin,
|
||||
previewToken: resolved.entry.token,
|
||||
urlAuthToken,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = String(proxyRes.headers?.['content-type'] || '').toLowerCase();
|
||||
const isHtml = contentType.includes('text/html');
|
||||
@@ -1290,12 +1445,6 @@ export const createPreviewProxyRuntime = ({
|
||||
delete proxyRes.headers.etag;
|
||||
delete proxyRes.headers['last-modified'];
|
||||
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
return responseBuffer;
|
||||
}
|
||||
|
||||
const proxyBasePath = `/api/preview/proxy/${resolved.id}`;
|
||||
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
|
||||
const upstreamPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
||||
if (isJavaScript && upstreamPath === '/@vite/client') {
|
||||
@@ -1304,6 +1453,8 @@ export const createPreviewProxyRuntime = ({
|
||||
proxyBasePath,
|
||||
targetOrigin: resolved.entry.origin,
|
||||
kind: 'javascript',
|
||||
previewToken: resolved.entry.token,
|
||||
urlAuthToken,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1312,8 +1463,10 @@ export const createPreviewProxyRuntime = ({
|
||||
proxyBasePath,
|
||||
targetOrigin: resolved.entry.origin,
|
||||
kind: isHtml ? 'html' : isCss ? 'css' : 'javascript',
|
||||
previewToken: resolved.entry.token,
|
||||
urlAuthToken,
|
||||
});
|
||||
return isHtml ? injectPreviewBridge(rewrittenBody, resolved.entry.origin) : rewrittenBody;
|
||||
return isHtml ? injectPreviewBridge(rewrittenBody, resolved.entry.origin, bridgeNonce) : rewrittenBody;
|
||||
}),
|
||||
error: (err, _req, res) => {
|
||||
const isDev = typeof process !== 'undefined'
|
||||
@@ -1366,12 +1519,6 @@ export const createPreviewProxyRuntime = ({
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
if (uiAuthController?.enabled) {
|
||||
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
|
||||
if (!sessionToken) {
|
||||
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
const originAllowed = await isRequestOriginAllowed(req);
|
||||
if (!originAllowed) {
|
||||
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
||||
@@ -1384,6 +1531,10 @@ export const createPreviewProxyRuntime = ({
|
||||
req.originalUrl = rawUrl;
|
||||
const parsed = new URL(rawUrl, 'http://localhost');
|
||||
const nextPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
||||
parsed.searchParams.delete('ocPreview');
|
||||
parsed.searchParams.delete(TOKEN_QUERY_PARAM);
|
||||
parsed.searchParams.delete(CLIENT_TOKEN_QUERY_PARAM);
|
||||
parsed.searchParams.delete(URL_AUTH_TOKEN_QUERY_PARAM);
|
||||
const search = parsed.searchParams.toString();
|
||||
req.url = `${nextPath}${search ? `?${search}` : ''}`;
|
||||
proxy.upgrade(req, socket, head);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { classifyPreviewNavigation, classifyPreviewResourceError, normalizeProxyTargetUrl, rewritePreviewBody } from './proxy-runtime.js';
|
||||
import {
|
||||
classifyPreviewNavigation,
|
||||
classifyPreviewResourceError,
|
||||
normalizeProxyTargetUrl,
|
||||
rewritePreviewBody,
|
||||
rewritePreviewCspHeader,
|
||||
rewritePreviewRedirectLocation,
|
||||
} from './proxy-runtime.js';
|
||||
|
||||
const rewrite = (bodyText, kind) => rewritePreviewBody({
|
||||
bodyText,
|
||||
@@ -90,6 +97,29 @@ describe('preview body URL rewriting', () => {
|
||||
expect(output).toContain('const url = "/api/data";');
|
||||
});
|
||||
|
||||
it('removes CSP meta tags that block the preview bridge', () => {
|
||||
const input = '<meta http-equiv="Content-Security-Policy" content="script-src \'self\'"><div>Preview</div>';
|
||||
const output = rewrite(input, 'html');
|
||||
|
||||
expect(output).not.toContain('Content-Security-Policy');
|
||||
expect(output).toContain('<div>Preview</div>');
|
||||
});
|
||||
|
||||
it('adds preview and URL auth tokens to rewritten proxy resources when provided', () => {
|
||||
const output = rewritePreviewBody({
|
||||
bodyText: '<script src="/entry.js"></script><a href="http://localhost:3000/docs?x=1&oc_client_token=legacy">Docs</a>',
|
||||
kind: 'html',
|
||||
proxyBasePath: '/api/preview/proxy/abc123',
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
previewToken: 'preview-secret',
|
||||
urlAuthToken: 'url-secret',
|
||||
});
|
||||
|
||||
expect(output).toContain('src="/api/preview/proxy/abc123/entry.js?oc_preview_token=preview-secret&oc_url_token=url-secret"');
|
||||
expect(output).toContain('href="/api/preview/proxy/abc123/docs?x=1&oc_preview_token=preview-secret&oc_url_token=url-secret"');
|
||||
expect(output).not.toContain('oc_client_token');
|
||||
});
|
||||
|
||||
it('rewrites only CSS imports and url references in CSS responses', () => {
|
||||
const input = '@import "/theme.css"; .hero { background: url(/hero.png); } .copy::after { content: "/not-a-url"; }';
|
||||
const output = rewrite(input, 'css');
|
||||
@@ -108,6 +138,66 @@ describe('preview body URL rewriting', () => {
|
||||
expect(output).toContain('const url = "/api/data"');
|
||||
expect(output).toContain('fetch("/api/data")');
|
||||
});
|
||||
|
||||
it('adds URL auth tokens to CSS and JavaScript rewritten resources', () => {
|
||||
const cssOutput = rewritePreviewBody({
|
||||
bodyText: '@import "/theme.css"; .hero { background: url(/hero.png); }',
|
||||
kind: 'css',
|
||||
proxyBasePath: '/api/preview/proxy/abc123',
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
previewToken: 'preview-secret',
|
||||
urlAuthToken: 'url-secret',
|
||||
});
|
||||
const jsOutput = rewritePreviewBody({
|
||||
bodyText: 'import("/entry.js"); import value from "/module.js";',
|
||||
kind: 'javascript',
|
||||
proxyBasePath: '/api/preview/proxy/abc123',
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
previewToken: 'preview-secret',
|
||||
urlAuthToken: 'url-secret',
|
||||
});
|
||||
|
||||
expect(cssOutput).toContain('@import "/api/preview/proxy/abc123/theme.css?oc_preview_token=preview-secret&oc_url_token=url-secret"');
|
||||
expect(cssOutput).toContain('url(/api/preview/proxy/abc123/hero.png?oc_preview_token=preview-secret&oc_url_token=url-secret)');
|
||||
expect(jsOutput).toContain('import("/api/preview/proxy/abc123/entry.js?oc_preview_token=preview-secret&oc_url_token=url-secret")');
|
||||
expect(jsOutput).toContain('from "/api/preview/proxy/abc123/module.js?oc_preview_token=preview-secret&oc_url_token=url-secret"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview redirect URL rewriting', () => {
|
||||
it('rewrites loopback redirects through the preview proxy', () => {
|
||||
expect(rewritePreviewRedirectLocation({
|
||||
location: 'http://localhost:3000/login?next=%2F#top',
|
||||
proxyBasePath: '/api/preview/proxy/abc123',
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
})).toBe('/api/preview/proxy/abc123/login?next=%2F#top');
|
||||
});
|
||||
|
||||
it('leaves external redirects unchanged', () => {
|
||||
expect(rewritePreviewRedirectLocation({
|
||||
location: 'https://example.com/login',
|
||||
proxyBasePath: '/api/preview/proxy/abc123',
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
})).toBe('https://example.com/login');
|
||||
});
|
||||
|
||||
it('adds proxy auth tokens to loopback redirects when provided', () => {
|
||||
expect(rewritePreviewRedirectLocation({
|
||||
location: 'http://localhost:3000/login?next=%2F#top',
|
||||
proxyBasePath: '/api/preview/proxy/abc123',
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
previewToken: 'preview-secret',
|
||||
urlAuthToken: 'url-secret',
|
||||
})).toBe('/api/preview/proxy/abc123/login?next=%2F&oc_preview_token=preview-secret&oc_url_token=url-secret#top');
|
||||
});
|
||||
|
||||
it('leaves redirects unchanged when no target origin is provided', () => {
|
||||
expect(rewritePreviewRedirectLocation({
|
||||
location: 'http://localhost:5174/callback',
|
||||
proxyBasePath: '/api/preview/proxy/abc123',
|
||||
previewToken: 'preview-secret',
|
||||
})).toBe('http://localhost:5174/callback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview navigation policy', () => {
|
||||
@@ -187,3 +277,41 @@ describe('proxy target normalization (SSRF guard)', () => {
|
||||
expect(normalizeProxyTargetUrl('http://[::ffff:127.0.0.1]/', { allowExternal: true }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview CSP rewrite', () => {
|
||||
it('drops frame-ancestors and require-trusted-types-for but keeps the rest', () => {
|
||||
const result = rewritePreviewCspHeader(
|
||||
"default-src 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'",
|
||||
'abc123',
|
||||
);
|
||||
expect(result).not.toContain('frame-ancestors');
|
||||
expect(result).not.toContain('require-trusted-types-for');
|
||||
expect(result).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
it('adds the nonce to an existing script-src instead of removing it', () => {
|
||||
const result = rewritePreviewCspHeader("script-src 'self'", 'abc123');
|
||||
expect(result).toContain("script-src 'self' 'nonce-abc123'");
|
||||
});
|
||||
|
||||
it('adds the nonce to script-src-elem when present', () => {
|
||||
const result = rewritePreviewCspHeader("script-src-elem 'self'", 'abc123');
|
||||
expect(result).toContain("script-src-elem 'self' 'nonce-abc123'");
|
||||
});
|
||||
|
||||
it('synthesizes script-src from default-src when no script directive exists', () => {
|
||||
const result = rewritePreviewCspHeader("default-src 'self' https://cdn.example.com", 'abc123');
|
||||
expect(result).toContain("default-src 'self' https://cdn.example.com");
|
||||
expect(result).toContain("script-src 'self' https://cdn.example.com 'nonce-abc123'");
|
||||
});
|
||||
|
||||
it("drops a lone 'none' so the nonce takes effect", () => {
|
||||
const result = rewritePreviewCspHeader("script-src 'none'", 'abc123');
|
||||
expect(result).toBe("script-src 'nonce-abc123'");
|
||||
});
|
||||
|
||||
it('returns empty/unset CSP values unchanged', () => {
|
||||
expect(rewritePreviewCspHeader('', 'abc123')).toBe('');
|
||||
expect(rewritePreviewCspHeader(undefined, 'abc123')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const createRequestSecurityRuntime = (deps) => {
|
||||
const { readSettingsFromDiskMigrated } = deps;
|
||||
const packagedClientOrigins = new Set(['openchamber-ui://app']);
|
||||
|
||||
const getUiSessionTokenFromRequest = (req) => {
|
||||
const cookieHeader = req?.headers?.cookie;
|
||||
@@ -96,6 +97,10 @@ export const createRequestSecurityRuntime = (deps) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (packagedClientOrigins.has(originHeader)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let normalizedOrigin = '';
|
||||
try {
|
||||
normalizedOrigin = new URL(originHeader).origin;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createRequestSecurityRuntime } from './request-security.js';
|
||||
|
||||
const createRuntime = () => createRequestSecurityRuntime({
|
||||
readSettingsFromDiskMigrated: async () => ({}),
|
||||
});
|
||||
|
||||
describe('request security runtime', () => {
|
||||
test('allows packaged client origin for remote client transports', async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runtime.isRequestOriginAllowed({
|
||||
headers: {
|
||||
origin: 'openchamber-ui://app',
|
||||
host: '192.168.1.130:1202',
|
||||
},
|
||||
socket: {},
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { createUiPasskeys } from './ui-passkeys.js';
|
||||
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const TRUSTED_DEVICE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const URL_AUTH_TOKEN_TTL_MS = 60 * 1000;
|
||||
const URL_AUTH_TOKEN_PREFIX = 'oc_url_';
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
||||
@@ -243,6 +245,74 @@ const parseCookies = (cookieHeader) => {
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getBearerTokenFromRequest = (req) => {
|
||||
const header = req?.headers?.authorization;
|
||||
const value = Array.isArray(header) ? header[0] : header;
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/^Bearer\s+(.+)$/i);
|
||||
const token = match?.[1]?.trim() || '';
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getUrlAuthTokenFromRequest = (req) => {
|
||||
const queryToken = req?.query?.oc_url_token;
|
||||
let token = Array.isArray(queryToken) ? queryToken[0] : queryToken;
|
||||
if (typeof token !== 'string' && typeof req?.url === 'string') {
|
||||
try {
|
||||
token = new URL(req.url, 'http://localhost').searchParams.get('oc_url_token') || undefined;
|
||||
} catch {
|
||||
token = undefined;
|
||||
}
|
||||
}
|
||||
return typeof token === 'string' && token.trim() ? token.trim() : null;
|
||||
};
|
||||
|
||||
const getRequestPathname = (req) => {
|
||||
if (typeof req?.path === 'string' && req.path) return req.path;
|
||||
const rawUrl = req?.originalUrl || req?.url;
|
||||
if (typeof rawUrl !== 'string' || !rawUrl) return '';
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isWebSocketUpgrade = (req) => {
|
||||
const upgrade = req?.headers?.upgrade;
|
||||
const upgradeValue = Array.isArray(upgrade) ? upgrade[0] : upgrade;
|
||||
return String(upgradeValue || '').toLowerCase() === 'websocket';
|
||||
};
|
||||
|
||||
const isUrlAuthReadableHttpPath = (pathname) => {
|
||||
return pathname === '/api/event'
|
||||
|| pathname === '/api/global/event'
|
||||
|| pathname === '/api/openchamber/events'
|
||||
|| pathname === '/api/notifications/stream'
|
||||
|| pathname === '/api/fs/raw'
|
||||
|| pathname.startsWith('/api/preview/proxy/')
|
||||
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname)
|
||||
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname);
|
||||
};
|
||||
|
||||
const isUrlAuthWebSocketPath = (pathname) => {
|
||||
return pathname === '/api/event/ws'
|
||||
|| pathname === '/api/global/event/ws'
|
||||
|| pathname === '/api/terminal/ws'
|
||||
|| pathname.startsWith('/api/preview/proxy/');
|
||||
};
|
||||
|
||||
const canUseUrlAuthTokenForRequest = (req) => {
|
||||
const method = typeof req?.method === 'string' ? req.method.toUpperCase() : 'GET';
|
||||
const pathname = getRequestPathname(req);
|
||||
if (isWebSocketUpgrade(req)) {
|
||||
return isUrlAuthWebSocketPath(pathname);
|
||||
}
|
||||
return method === 'GET' && isUrlAuthReadableHttpPath(pathname);
|
||||
};
|
||||
|
||||
const buildCookie = ({
|
||||
name,
|
||||
value,
|
||||
@@ -330,8 +400,79 @@ export const createUiAuth = ({
|
||||
cookieName = SESSION_COOKIE_NAME,
|
||||
sessionTtlMs = SESSION_TTL_MS,
|
||||
readSettingsFromDiskMigrated,
|
||||
clientAuthController = null,
|
||||
requireClientAuth = false,
|
||||
} = {}) => {
|
||||
const normalizedPassword = normalizePassword(password);
|
||||
const urlAuthTokens = new Map();
|
||||
|
||||
const sweepUrlAuthTokens = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of urlAuthTokens.entries()) {
|
||||
if (!entry || entry.expiresAt <= now) {
|
||||
urlAuthTokens.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const issueUrlAuthTokenForSession = (sessionToken) => {
|
||||
sweepUrlAuthTokens();
|
||||
const token = `${URL_AUTH_TOKEN_PREFIX}${crypto.randomBytes(24).toString('base64url')}`;
|
||||
const expiresAt = Date.now() + URL_AUTH_TOKEN_TTL_MS;
|
||||
urlAuthTokens.set(token, { sessionToken, expiresAt });
|
||||
return { token, expiresAt };
|
||||
};
|
||||
|
||||
const authenticateUrlAuthToken = (req) => {
|
||||
if (!canUseUrlAuthTokenForRequest(req)) return null;
|
||||
const token = getUrlAuthTokenFromRequest(req);
|
||||
if (!token || !token.startsWith(URL_AUTH_TOKEN_PREFIX)) return null;
|
||||
const entry = urlAuthTokens.get(token);
|
||||
if (!entry || entry.expiresAt <= Date.now()) {
|
||||
urlAuthTokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
return { ok: true, sessionToken: entry.sessionToken || 'url:authenticated' };
|
||||
};
|
||||
|
||||
const authenticateClientRequest = async (req, { allowUrlToken = true } = {}) => {
|
||||
if (allowUrlToken) {
|
||||
const urlAuth = authenticateUrlAuthToken(req);
|
||||
if (urlAuth) return urlAuth;
|
||||
}
|
||||
const token = getBearerTokenFromRequest(req);
|
||||
if (!token || typeof clientAuthController?.authenticateBearerToken !== 'function') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const result = await clientAuthController.authenticateBearerToken(token, req);
|
||||
if (result?.ok) {
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clientSessionToken = (clientAuth) => {
|
||||
const raw = clientAuth?.sessionToken || clientAuth?.clientId || clientAuth?.id;
|
||||
if (typeof raw === 'string' && (raw.startsWith('client:') || raw.startsWith('url:'))) return raw;
|
||||
return typeof raw === 'string' && raw.length > 0 ? `client:${raw}` : 'client:authenticated';
|
||||
};
|
||||
|
||||
const clientAuthClientId = (clientAuth) => {
|
||||
const raw = clientAuth?.client?.id || clientAuth?.clientId || clientAuth?.id || clientAuth?.sessionToken;
|
||||
if (typeof raw !== 'string' || raw.length === 0) return null;
|
||||
return raw.startsWith('client:') ? raw.slice('client:'.length) : raw;
|
||||
};
|
||||
|
||||
const clientAuthContext = (clientAuth) => ({
|
||||
type: 'client',
|
||||
token: clientSessionToken(clientAuth),
|
||||
clientId: clientAuthClientId(clientAuth),
|
||||
client: clientAuth?.client || null,
|
||||
});
|
||||
|
||||
if (!normalizedPassword) {
|
||||
const setSessionCookie = (req, res, token, ttlMs = sessionTtlMs) => {
|
||||
@@ -356,15 +497,77 @@ export const createUiAuth = ({
|
||||
return token;
|
||||
};
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
if (!requireClientAuth) {
|
||||
return next();
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
|
||||
};
|
||||
|
||||
const requireSessionAuth = async (req, res, next) => {
|
||||
if (!requireClientAuth) {
|
||||
return next();
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ error: 'UI session authentication required', locked: true });
|
||||
};
|
||||
|
||||
const resolveAuthContext = async (req, res, { allowClientAuth = true, allowUrlToken = true } = {}) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (cookies[cookieName]) {
|
||||
return { type: 'session', token: cookies[cookieName] };
|
||||
}
|
||||
if (allowClientAuth) {
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
|
||||
if (clientAuth) return clientAuthContext(clientAuth);
|
||||
}
|
||||
if (!requireClientAuth) {
|
||||
const token = await ensureSessionToken(req, res);
|
||||
return { type: 'session', token };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
requireAuth: (_req, _res, next) => next(),
|
||||
handleSessionStatus: (_req, res) => {
|
||||
requireAuth,
|
||||
requireSessionAuth,
|
||||
resolveAuthContext,
|
||||
handleSessionStatus: async (req, res) => {
|
||||
if (requireClientAuth) {
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
return res.json({ authenticated: true, disabled: true, scope: 'client' });
|
||||
}
|
||||
return res.status(401).json({ authenticated: false, locked: true, clientAuthRequired: true });
|
||||
}
|
||||
res.json({ authenticated: true, disabled: true });
|
||||
},
|
||||
handleSessionCreate: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handleUrlAuthToken: async (req, res) => {
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken: false });
|
||||
if (clientAuth) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json(issueUrlAuthTokenForSession(clientSessionToken(clientAuth)));
|
||||
}
|
||||
if (requireClientAuth) {
|
||||
return res.status(401).json({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
|
||||
}
|
||||
const sessionToken = await ensureSessionToken(req, res);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json(issueUrlAuthTokenForSession(sessionToken));
|
||||
},
|
||||
handlePasskeyStatus: (_req, res) => {
|
||||
res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null });
|
||||
},
|
||||
@@ -389,7 +592,11 @@ export const createUiAuth = ({
|
||||
handleResetAuth: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
ensureSessionToken,
|
||||
ensureSessionToken: async (req, res) => {
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) return clientSessionToken(clientAuth);
|
||||
return ensureSessionToken(req, res);
|
||||
},
|
||||
dispose: () => {
|
||||
|
||||
},
|
||||
@@ -418,6 +625,7 @@ export const createUiAuth = ({
|
||||
const rotateJwtSecret = () => {
|
||||
const nextSecret = crypto.randomBytes(32).toString('hex');
|
||||
jwtSecret = persistJwtSecret(nextSecret);
|
||||
urlAuthTokens.clear();
|
||||
rebuildPasskeyController();
|
||||
};
|
||||
|
||||
@@ -496,7 +704,7 @@ export const createUiAuth = ({
|
||||
const respondUnauthorized = (req, res) => {
|
||||
res.status(401);
|
||||
const acceptsJson = req.headers.accept?.includes('application/json');
|
||||
if (acceptsJson || req.path.startsWith('/api')) {
|
||||
if (acceptsJson || req.path?.startsWith('/api')) {
|
||||
res.json({ error: 'UI authentication required', locked: true });
|
||||
} else {
|
||||
res.type('text/plain').send('Authentication required');
|
||||
@@ -504,6 +712,22 @@ export const createUiAuth = ({
|
||||
};
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
return next();
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
return next();
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
return respondUnauthorized(req, res);
|
||||
};
|
||||
|
||||
const requireSessionAuth = async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
@@ -521,10 +745,44 @@ export const createUiAuth = ({
|
||||
res.json({ authenticated: true });
|
||||
return;
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
res.json({ authenticated: true, scope: 'client' });
|
||||
return;
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
res.status(401).json({ authenticated: false, locked: true });
|
||||
};
|
||||
|
||||
const resolveAuthenticatedSessionToken = async (req, { allowUrlToken = true } = {}) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
return token;
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
|
||||
return clientAuth ? clientSessionToken(clientAuth) : null;
|
||||
};
|
||||
|
||||
const resolveAuthContext = async (req, _res, { allowClientAuth = true, allowUrlToken = true } = {}) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
return { type: 'session', token };
|
||||
}
|
||||
if (!allowClientAuth) return null;
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
|
||||
return clientAuth ? clientAuthContext(clientAuth) : null;
|
||||
};
|
||||
|
||||
const handleUrlAuthToken = async (req, res) => {
|
||||
const sessionToken = await resolveAuthenticatedSessionToken(req, { allowUrlToken: false });
|
||||
if (!sessionToken) {
|
||||
clearSessionCookie(req, res);
|
||||
return respondUnauthorized(req, res);
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json(issueUrlAuthTokenForSession(sessionToken));
|
||||
};
|
||||
|
||||
const handleSessionCreate = async (req, res) => {
|
||||
const rateLimitResult = await checkRateLimit(req);
|
||||
|
||||
@@ -551,10 +809,23 @@ export const createUiAuth = ({
|
||||
|
||||
await clearRateLimit(req);
|
||||
|
||||
await issueSession(req, res, {
|
||||
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
|
||||
const trustDevice = isTrustedDeviceRequest(req.body?.trustDevice);
|
||||
const ttlMs = resolveSessionTtlMs(trustDevice);
|
||||
await issueSession(req, res, { trustDevice });
|
||||
let clientTokenResult = null;
|
||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||
clientTokenResult = await clientAuthController.createClient({
|
||||
label: req.body?.clientLabel,
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
});
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
authenticated: true,
|
||||
...(clientTokenResult?.token ? { clientToken: clientTokenResult.token, client: clientTokenResult.client } : {}),
|
||||
});
|
||||
res.json({ authenticated: true });
|
||||
};
|
||||
|
||||
const respondPasskeyError = (res, error) => {
|
||||
@@ -601,10 +872,22 @@ export const createUiAuth = ({
|
||||
const handlePasskeyAuthenticationVerify = async (req, res) => {
|
||||
try {
|
||||
await passkeyController.finishAuthentication(req.body);
|
||||
await issueSession(req, res, {
|
||||
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
|
||||
const trustDevice = isTrustedDeviceRequest(req.body?.trustDevice);
|
||||
const ttlMs = resolveSessionTtlMs(trustDevice);
|
||||
await issueSession(req, res, { trustDevice });
|
||||
let clientTokenResult = null;
|
||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||
clientTokenResult = await clientAuthController.createClient({
|
||||
label: req.body?.clientLabel,
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
authenticated: true,
|
||||
...(clientTokenResult?.token ? { clientToken: clientTokenResult.token, client: clientTokenResult.client } : {}),
|
||||
});
|
||||
res.json({ authenticated: true });
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
@@ -654,8 +937,11 @@ export const createUiAuth = ({
|
||||
return {
|
||||
enabled: true,
|
||||
requireAuth,
|
||||
requireSessionAuth,
|
||||
resolveAuthContext,
|
||||
handleSessionStatus,
|
||||
handleSessionCreate,
|
||||
handleUrlAuthToken,
|
||||
handlePasskeyStatus,
|
||||
handlePasskeyRegistrationOptions,
|
||||
handlePasskeyRegistrationVerify,
|
||||
@@ -665,8 +951,7 @@ export const createUiAuth = ({
|
||||
handlePasskeyRevoke,
|
||||
handleResetAuth,
|
||||
ensureSessionToken: async (req, _res) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
return (await isSessionValid(token)) ? token : null;
|
||||
return resolveAuthenticatedSessionToken(req);
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ui-auth-test-'));
|
||||
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const loadCreateUiAuth = async () => {
|
||||
const module = await import('./ui-auth.js');
|
||||
return module.createUiAuth;
|
||||
};
|
||||
|
||||
const createResponse = () => {
|
||||
let statusCode = 200;
|
||||
let body = null;
|
||||
const headers = new Map();
|
||||
return {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
body = payload;
|
||||
return this;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
return this;
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
get body() {
|
||||
return body;
|
||||
},
|
||||
getHeader(name) {
|
||||
return headers.get(name.toLowerCase());
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('ui auth client credential seam', () => {
|
||||
it('accepts bearer client credentials when UI password auth is enabled', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const req = { method: 'GET', headers: { authorization: 'Bearer client-token' } };
|
||||
const res = createResponse();
|
||||
let called = false;
|
||||
|
||||
await auth.requireAuth(req, res, () => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
expect(called).toBe(true);
|
||||
expect(await auth.ensureSessionToken(req, res)).toBe('client:device-1');
|
||||
expect(await auth.resolveAuthContext(req, res, { allowUrlToken: false })).toMatchObject({
|
||||
type: 'client',
|
||||
clientId: 'device-1',
|
||||
token: 'client:device-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not accept bearer client credentials for UI-session-only auth', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const clientReq = { method: 'GET', path: '/api/client-auth/clients', headers: { authorization: 'Bearer client-token' } };
|
||||
const clientRes = createResponse();
|
||||
let clientCalled = false;
|
||||
await auth.requireSessionAuth(clientReq, clientRes, () => {
|
||||
clientCalled = true;
|
||||
});
|
||||
expect(clientCalled).toBe(false);
|
||||
expect(clientRes.statusCode).toBe(401);
|
||||
|
||||
const loginReq = { method: 'POST', headers: {}, body: { password: 'secret' } };
|
||||
const loginRes = createResponse();
|
||||
await auth.handleSessionCreate(loginReq, loginRes);
|
||||
const sessionCookie = String(loginRes.getHeader('set-cookie') || '').split(';', 1)[0];
|
||||
expect(sessionCookie.startsWith('oc_ui_session=')).toBe(true);
|
||||
|
||||
const sessionReq = { method: 'GET', path: '/api/client-auth/clients', headers: { cookie: sessionCookie } };
|
||||
const sessionRes = createResponse();
|
||||
let sessionCalled = false;
|
||||
await auth.requireSessionAuth(sessionReq, sessionRes, () => {
|
||||
sessionCalled = true;
|
||||
});
|
||||
expect(sessionCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('can require bearer client credentials when UI password is disabled', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
requireClientAuth: true,
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, sessionToken: 'remote-session' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const allowedReq = { method: 'GET', headers: { authorization: 'Bearer client-token' } };
|
||||
const allowedRes = createResponse();
|
||||
let called = false;
|
||||
await auth.requireAuth(allowedReq, allowedRes, () => {
|
||||
called = true;
|
||||
});
|
||||
expect(called).toBe(true);
|
||||
expect(await auth.ensureSessionToken(allowedReq, allowedRes)).toBe('client:remote-session');
|
||||
|
||||
const deniedReq = { method: 'GET', headers: {} };
|
||||
const deniedRes = createResponse();
|
||||
await auth.requireAuth(deniedReq, deniedRes, () => {});
|
||||
expect(deniedRes.statusCode).toBe(401);
|
||||
expect(deniedRes.body).toEqual({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
|
||||
});
|
||||
|
||||
it('reports authenticated client session status with bearer credentials', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
const req = { method: 'GET', headers: { authorization: 'Bearer client-token' } };
|
||||
const res = createResponse();
|
||||
|
||||
await auth.handleSessionStatus(req, res);
|
||||
|
||||
expect(res.body).toEqual({ authenticated: true, scope: 'client' });
|
||||
});
|
||||
|
||||
it('exchanges bearer credentials for short-lived URL auth tokens', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const oldQueryReq = { method: 'GET', path: '/api/config/settings', url: '/api/config/settings?oc_client_token=client-token', headers: { accept: 'application/json' } };
|
||||
const oldQueryRes = createResponse();
|
||||
let oldQueryCalled = false;
|
||||
await auth.requireAuth(oldQueryReq, oldQueryRes, () => {
|
||||
oldQueryCalled = true;
|
||||
});
|
||||
expect(oldQueryCalled).toBe(false);
|
||||
expect(oldQueryRes.statusCode).toBe(401);
|
||||
|
||||
const mintReq = { method: 'POST', path: '/auth/url-token', headers: { authorization: 'Bearer client-token', accept: 'application/json' } };
|
||||
const mintRes = createResponse();
|
||||
await auth.handleUrlAuthToken(mintReq, mintRes);
|
||||
expect(typeof mintRes.body.token).toBe('string');
|
||||
expect(mintRes.body.token.startsWith('oc_url_')).toBe(true);
|
||||
expect(mintRes.body.expiresAt).toBeGreaterThan(Date.now());
|
||||
expect(mintRes.getHeader('cache-control')).toBe('no-store');
|
||||
|
||||
const urlToken = mintRes.body.token;
|
||||
const urlReq = { method: 'GET', path: '/api/fs/raw', url: `/api/fs/raw?path=%2Ftmp%2Fimage.png&oc_url_token=${encodeURIComponent(urlToken)}`, headers: {} };
|
||||
const urlRes = createResponse();
|
||||
let urlCalled = false;
|
||||
await auth.requireAuth(urlReq, urlRes, () => {
|
||||
urlCalled = true;
|
||||
});
|
||||
expect(urlCalled).toBe(true);
|
||||
expect(await auth.ensureSessionToken(urlReq, urlRes)).toBe('client:device-1');
|
||||
expect(await auth.resolveAuthContext(urlReq, urlRes, { allowUrlToken: false })).toBe(null);
|
||||
|
||||
const arbitraryGetReq = { method: 'GET', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||
const arbitraryGetRes = createResponse();
|
||||
let arbitraryGetCalled = false;
|
||||
await auth.requireAuth(arbitraryGetReq, arbitraryGetRes, () => {
|
||||
arbitraryGetCalled = true;
|
||||
});
|
||||
expect(arbitraryGetCalled).toBe(false);
|
||||
expect(arbitraryGetRes.statusCode).toBe(401);
|
||||
|
||||
const postReq = { method: 'POST', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||
const postRes = createResponse();
|
||||
let postCalled = false;
|
||||
await auth.requireAuth(postReq, postRes, () => {
|
||||
postCalled = true;
|
||||
});
|
||||
expect(postCalled).toBe(false);
|
||||
expect(postRes.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('issues desktop client tokens with the UI session expiry', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
let createClientInput = null;
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
sessionTtlMs: 123_000,
|
||||
clientAuthController: {
|
||||
createClient: async (input) => {
|
||||
createClientInput = input;
|
||||
return {
|
||||
token: 'client-token',
|
||||
client: {
|
||||
id: 'device-1',
|
||||
label: input.label,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const before = Date.now();
|
||||
const req = {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: {
|
||||
password: 'secret',
|
||||
issueClientToken: true,
|
||||
clientLabel: 'OpenChamber Desktop',
|
||||
},
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await auth.handleSessionCreate(req, res);
|
||||
|
||||
expect(res.body.clientToken).toBe('client-token');
|
||||
expect(createClientInput.label).toBe('OpenChamber Desktop');
|
||||
const expiresAt = Date.parse(createClientInput.expiresAt);
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 122_000);
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 124_000);
|
||||
});
|
||||
});
|
||||
@@ -148,4 +148,137 @@ describe('OpenCode proxy SSE forwarding', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true, source: 'external-host' });
|
||||
});
|
||||
|
||||
it('replays parsed urlencoded bodies to generic API proxy requests', async () => {
|
||||
const upstream = express();
|
||||
upstream.post('/form', express.urlencoded({ extended: true }), (req, res) => {
|
||||
res.json({ body: req.body });
|
||||
});
|
||||
upstreamServer = await listen(upstream);
|
||||
const upstreamPort = upstreamServer.address().port;
|
||||
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||
|
||||
const app = express();
|
||||
app.use('/api', express.urlencoded({ extended: true }));
|
||||
registerOpenCodeProxy(app, {
|
||||
fs: {},
|
||||
os: {},
|
||||
path,
|
||||
OPEN_CODE_READY_GRACE_MS: 0,
|
||||
getRuntime: () => ({
|
||||
openCodePort: upstreamPort,
|
||||
openCodeBaseUrl: externalBaseUrl,
|
||||
isOpenCodeReady: true,
|
||||
openCodeNotReadySince: 0,
|
||||
isRestartingOpenCode: false,
|
||||
}),
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||
ensureOpenCodeApiPrefix: () => {},
|
||||
});
|
||||
proxyServer = await listen(app);
|
||||
const proxyPort = proxyServer.address().port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/form`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ messageID: 'msg_1' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ body: { messageID: 'msg_1' } });
|
||||
});
|
||||
|
||||
it('replays parsed JSON bodies to generic API proxy requests', async () => {
|
||||
const upstream = express();
|
||||
upstream.post('/session/abc/prompt_async', express.json(), (req, res) => {
|
||||
res.json({
|
||||
body: req.body,
|
||||
authorization: req.headers.authorization,
|
||||
contentLength: req.headers['content-length'],
|
||||
});
|
||||
});
|
||||
upstreamServer = await listen(upstream);
|
||||
const upstreamPort = upstreamServer.address().port;
|
||||
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||
|
||||
const app = express();
|
||||
app.use('/api', express.json());
|
||||
registerOpenCodeProxy(app, {
|
||||
fs: {},
|
||||
os: {},
|
||||
path,
|
||||
OPEN_CODE_READY_GRACE_MS: 0,
|
||||
getRuntime: () => ({
|
||||
openCodePort: upstreamPort,
|
||||
openCodeBaseUrl: externalBaseUrl,
|
||||
isOpenCodeReady: true,
|
||||
openCodeNotReadySince: 0,
|
||||
isRestartingOpenCode: false,
|
||||
}),
|
||||
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer replay-token' }),
|
||||
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||
ensureOpenCodeApiPrefix: () => {},
|
||||
});
|
||||
proxyServer = await listen(app);
|
||||
const proxyPort = proxyServer.address().port;
|
||||
|
||||
const payload = { messageID: 'msg_1', parts: [{ type: 'text', text: 'hello' }] };
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/session/abc/prompt_async`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.body).toEqual(payload);
|
||||
expect(data.authorization).toBe('Bearer replay-token');
|
||||
expect(Number(data.contentLength)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('forwards unparsed SDK JSON bodies to generic API proxy requests', async () => {
|
||||
const upstream = express();
|
||||
upstream.post('/session/abc/revert', express.json(), (req, res) => {
|
||||
res.json({
|
||||
body: req.body,
|
||||
contentLength: req.headers['content-length'],
|
||||
});
|
||||
});
|
||||
upstreamServer = await listen(upstream);
|
||||
const upstreamPort = upstreamServer.address().port;
|
||||
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||
|
||||
const app = express();
|
||||
registerOpenCodeProxy(app, {
|
||||
fs: {},
|
||||
os: {},
|
||||
path,
|
||||
OPEN_CODE_READY_GRACE_MS: 0,
|
||||
getRuntime: () => ({
|
||||
openCodePort: upstreamPort,
|
||||
openCodeBaseUrl: externalBaseUrl,
|
||||
isOpenCodeReady: true,
|
||||
openCodeNotReadySince: 0,
|
||||
isRestartingOpenCode: false,
|
||||
}),
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||
ensureOpenCodeApiPrefix: () => {},
|
||||
});
|
||||
proxyServer = await listen(app);
|
||||
const proxyPort = proxyServer.address().port;
|
||||
|
||||
const payload = { messageID: 'msg_1' };
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/session/abc/revert`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.body).toEqual(payload);
|
||||
expect(Number(data.contentLength)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user