feat(forwarding): retain sessions for eight minutes / 端口转发离页保持八分钟

This commit is contained in:
gdz66601 committed 2026-09-25 09:09:22 +08:00
1 parent 5d65b34907
commit afef04bf8d
9 files changed
+237 -23

No files matched your search

+1 -1
View File
@@ -76,7 +76,7 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入
主站与预览必须跨 site:自定义域名加默认 `workers.dev` 可行;自定义域名加同站自定义域名会拒绝;同账户双 `workers.dev` 也会拒绝,主站只有 `workers.dev` 时需独立自定义域名。专用 preview 同一 origin 内不同目标网站不相互隔离,切换前关闭旧预览窗口。
操作步骤:在端口转发页面选择主机和端口转发类型(如 `127.0.0.1` HTTP)→ 选择 `trusted` 或 `isolated`(trusted 需勾选界面信任确认,isolated 不需要)→ 确认指纹并连接。连接期间禁用模式切换,需先点击停止再切换。停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后预览失效;仅关闭目标预览 tab 不保证停止。isolated 票据 60 秒内只能兑换一次,兑换后的授权最长 1 小时;trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。
操作步骤:在端口转发页面选择主机和端口转发类型(如 `127.0.0.1` HTTP)→ 选择 `trusted` 或 `isolated`(trusted 需勾选界面信任确认,isolated 不需要)→ 确认指纹并连接。连接期间禁用模式切换,需先点击停止再切换。离开管理页或刷新后,Worker 保持 SSH 转发 8 分钟;期限内重新进入可恢复显示、重新打开预览或立即停止。8 分钟到期、用户点击停止、SSH 断线或 Durable Object 重启后预览失效;仅关闭目标预览 tab 不保证停止。isolated 票据 60 秒内只能兑换一次,兑换后的授权最长 1 小时;trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。
标准实现改写 HTML 属性、`srcset`、CSS URL、`Location`、Cookie 名称与 Path,并注入常见 `fetch`/XHR/EventSource/history/cookie 兼容脚本;不承诺任意网站透明代理。严格 CSP、动态 ES 模块、写死的 location、复杂 inline CSS/JS 框架仍可能需要 baseURL 配置,优先使用 isolated。仅支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;HTTP 上游限 `127.0.0.1`,上传 16 MiB,CSS 重写 2 MiB,最多 24 个并发通道,通道闲置 60 秒。HTTPS 上游、WebSocket、Service Worker、写死 `localhost`、OAuth 固定 callback 不支持;SFTP 上传仍为 64 MiB。
+1 -1
View File
@@ -229,7 +229,7 @@ isolated 模式凭据只在 URL fragment 中短暂传递,随后立即换成 Ht
### 端口转发
在端口转发页面选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。管理页会保留在原窗口;用户停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后,预览立即失效;仅关闭目标预览 tab 不保证停止。预览票据只使用一次,不能分享给他人。
在端口转发页面选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。离开管理页或刷新后,Worker 会继续保持 SSH 转发 8 分钟;在期限内重新进入页面,可看到原端口、模式和截止时间,并可重新打开预览或立即停止。8 分钟到期、用户点击停止、SSH 断线或 Durable Object 重启后,预览失效;仅关闭目标预览 tab 不保证停止。预览票据只使用一次,不能分享给他人。
### 安全边界
+86 -5
View File
@@ -1,6 +1,16 @@
import { api, hostCredentials, type CloudHost } from './cloud-api';
import './forward-page.css';
const FORWARD_SESSION_STORAGE_KEY = 'edgessh.forwarding-session';
const FORWARD_RETENTION_MS = 8 * 60 * 1000;
interface ForwardingStatus {
active: boolean;
port?: number;
mode?: 'trusted' | 'isolated';
expiresAt?: number;
}
/** 转发持有独立 SSH 连接;标准模式与主站同源,不能当作恶意脚本的安全边界。 */
export class ForwardPage {
readonly root = document.createElement('main');
@@ -8,10 +18,13 @@ export class ForwardPage {
private socket?: WebSocket;
private sessionId?: string;
private generation = 0;
private restoreGeneration = 0;
private busy = false;
private ready = false;
private popup: Window | null = null;
private deadline?: ReturnType<typeof setTimeout>;
private retentionDeadline?: number;
private retentionTimer?: ReturnType<typeof setTimeout>;
private readonly select: HTMLSelectElement;
private readonly port: HTMLInputElement;
private readonly startButton: HTMLButtonElement;
@@ -56,7 +69,7 @@ export class ForwardPage {
<section class="forward-explanation" aria-label="隔离与使用说明">
<h2>可信网站简单用,不可信网站单独隔离</h2>
<p>标准转发使用当前 Worker 的独立路径,不增加部署资源。隔离预览使用可选的独立 Worker 和跨站域名,不共享主站登录 Cookie。</p>
<ol><li>选择云端主机,输入网站的 HTTP 端口。</li><li>核对 SSH 主机指纹,连接后自动打开预览。</li><li>用完点击停止;离开此页面、刷新或断线后,转发立即失效。</li></ol>
<ol><li>选择云端主机,输入网站的 HTTP 端口。</li><li>核对 SSH 主机指纹,连接后自动打开预览。</li><li>离开此页面后转发保持 8 分钟;重新进入可查看或立即停止。</li></ol>
<p class="forward-hint">支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。授权最长 1 小时,上传最多 16 MiB。不支持 HTTPS 上游、WebSocket、Service Worker。标准模式兼容常见 fetch / XHR,但不是任意网站的透明代理;复杂 SPA、动态模块或硬编码跳转可能需要网站配置 base URL。同一隔离预览域名一次只使用一个网站,切换前关闭旧窗口。</p>
</section>
<dialog class="host-dialog" aria-labelledby="forward-key-heading">
@@ -91,7 +104,7 @@ export class ForwardPage {
this.reservePopup();
void this.launch(this.generation);
});
window.addEventListener('pagehide', () => this.stop());
window.addEventListener('pagehide', () => this.suspend());
window.addEventListener('auth-required', () => this.stop());
}
@@ -112,10 +125,12 @@ export class ForwardPage {
void api<{ previewAvailable: boolean }>('/api/forwarding').then((config) => {
this.previewAvailable = config.previewAvailable === true; this.render();
}).catch(() => { this.previewAvailable = false; this.render(); });
void this.restore(++this.restoreGeneration);
}
hide(): void {
if (this.root.hidden) return;
this.stop();
this.restoreGeneration++;
this.suspend();
this.root.hidden = true;
}
@@ -218,28 +233,94 @@ export class ForwardPage {
});
if (generation !== this.generation) return;
this.ready = true;
this.rememberSession();
if (!this.socket) this.scheduleRetention(expiresAt);
const link = this.get<HTMLAnchorElement>('[data-preview-link]');
link.href = url;
if (this.popup && !this.popup.closed) this.popup.location.replace(url);
else link.hidden = false;
this.popup = null;
this.message(`已转发 127.0.0.1:${this.port.value} · 授权至 ${new Date(expiresAt).toLocaleTimeString()} · 请保持本页打开`);
this.message(`已转发 127.0.0.1:${this.port.value} · 授权至 ${new Date(expiresAt).toLocaleTimeString()} · 离开页面后保持 8 分钟`);
} catch (error) {
if (generation === this.generation) this.stop(error instanceof Error ? error.message : '创建转发失败。');
} finally { if (generation === this.generation) { this.busy = false; this.render(); } }
}
stop(message = '已停止转发,预览授权已失效。'): void {
const sessionId = this.sessionId;
this.generation++;
this.restoreGeneration++;
clearTimeout(this.deadline);
clearTimeout(this.retentionTimer);
this.retentionTimer = undefined; this.retentionDeadline = undefined;
this.pendingKey = undefined; this.keyDialog.close();
this.popup?.close(); this.popup = null;
this.socket?.close(); this.socket = undefined;
// 主 WebSocket 关闭就是撤销操作;无需依赖卸载页面时不可靠的异步 fetch。
this.sessionId = undefined; this.busy = false; this.ready = false;
localStorage.removeItem(FORWARD_SESSION_STORAGE_KEY);
if (sessionId) void api(`/api/forwarding?session=${sessionId}`, 'DELETE').catch(() => undefined);
this.trust.checked = false;
this.get('[data-preview-link]').hidden = true;
this.get<HTMLAnchorElement>('[data-preview-link]').removeAttribute('href');
this.message(message); this.render();
}
private suspend(): void {
if (!this.ready || !this.sessionId) {
if (this.busy) this.stop();
return;
}
this.generation++;
clearTimeout(this.deadline);
this.pendingKey = undefined; this.keyDialog.close();
this.popup?.close(); this.popup = null;
this.socket?.close(); this.socket = undefined;
this.busy = false;
this.rememberSession();
this.scheduleRetention(Date.now() + FORWARD_RETENTION_MS);
this.message(`后台保持 127.0.0.1:${this.port.value} 至 ${new Date(this.retentionDeadline!).toLocaleTimeString()},重新进入可继续查看或停止。`);
this.render();
}
private rememberSession(): void {
// 这里只保存无权访问 SSH 的 DO 标识;账户校验和 8 分钟期限仍由 Worker 决定。
if (this.sessionId) localStorage.setItem(FORWARD_SESSION_STORAGE_KEY, this.sessionId);
}
private scheduleRetention(expiresAt: number): void {
clearTimeout(this.retentionTimer);
this.retentionDeadline = expiresAt;
this.retentionTimer = setTimeout(() => this.stop('后台保持 8 分钟已结束,转发已自动停止。'),
Math.max(0, expiresAt - Date.now()));
}
private async restore(generation: number): Promise<void> {
const sessionId = this.sessionId ?? localStorage.getItem(FORWARD_SESSION_STORAGE_KEY) ?? undefined;
if (!sessionId || this.socket) return;
this.busy = true; this.render();
try {
const status = await api<ForwardingStatus>(`/api/forwarding?session=${sessionId}`);
if (generation !== this.restoreGeneration) return;
if (!status.active || !status.port || !status.mode || !status.expiresAt) {
localStorage.removeItem(FORWARD_SESSION_STORAGE_KEY);
if (this.sessionId === sessionId) this.stop('之前保持的转发已结束。');
else this.message('之前保持的转发已结束。');
return;
}
this.sessionId = sessionId;
this.port.value = String(status.port);
this.mode.value = status.mode;
this.trust.checked = status.mode === 'trusted';
this.ready = true;
this.scheduleRetention(status.expiresAt);
this.message(`正在保持 127.0.0.1:${status.port} 至 ${new Date(status.expiresAt).toLocaleTimeString()},可重新打开预览或立即停止。`);
} catch {
if (generation !== this.restoreGeneration) return;
localStorage.removeItem(FORWARD_SESSION_STORAGE_KEY);
this.sessionId = undefined; this.ready = false;
this.message('之前保持的转发已结束。');
} finally {
if (generation === this.restoreGeneration) { this.busy = false; this.render(); }
}
}
}
+45 -5
View File
@@ -4,7 +4,7 @@ import { assertPublicTarget, toSocketHostname } from './security';
import { createTicket, verifyTicket } from './security';
import { SSHSession } from './session';
import { ForwardingState } from '../forwarding/state';
import { previewError } from '../forwarding/security';
import { FORWARD_RETENTION_MS, previewError } from '../forwarding/security';
interface MainAttachment { role: 'main'; phase: 'waiting' | 'connecting' | 'connected' }
interface SFTPAttachment { role: 'sftp'; phase: 'connected' }
@@ -27,6 +27,7 @@ type AuxiliaryAttachToken = SFTPAttachToken;
const TICKET_STORAGE_KEY = 'session-ticket';
const SFTP_ATTACH_TOKEN_TTL_MS = 10 * 60 * 1000;
const SFTP_ATTACH_TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
interface RetainedForwarding { expiresAt: number; timeout: ReturnType<typeof setTimeout> }
export class SSHSessionDO implements DurableObject {
private readonly state: DurableObjectState;
@@ -45,6 +46,7 @@ export class SSHSessionDO implements DurableObject {
private readonly processSessions = new Map<WebSocket, SSHSession>();
private readonly processOwners = new Map<WebSocket, WebSocket>();
private readonly processWebSocketsByMain = new Map<WebSocket, Set<WebSocket>>();
private readonly retainedForwarding = new Map<WebSocket, RetainedForwarding>();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
@@ -88,8 +90,13 @@ export class SSHSessionDO implements DurableObject {
}
if (url.pathname === '/forward-http') return this.forwarding.trusted(request);
if (url.pathname === '/forward') {
if (request.method === 'GET') {
return Response.json(this.forwarding.status(this.forwardingRetentionDeadline()), {
headers: { 'Cache-Control': 'no-store' },
});
}
if (request.method === 'DELETE') {
this.forwarding.stop();
this.stopForwarding();
return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
}
if (request.method !== 'POST') return previewError('Method not allowed', 405);
@@ -99,7 +106,10 @@ export class SSHSessionDO implements DurableObject {
const origin = request.headers.get('x-preview-origin');
if (!Number.isInteger(port) || port < 1 || port > 65535 || !origin
|| (mode !== 'trusted' && mode !== 'isolated')) return previewError('Invalid forwarding configuration', 400);
try { return await this.forwarding.create(session, port, origin, this.state.id.toString(), mode); }
try {
return await this.forwarding.create(session, port, origin, this.state.id.toString(), mode,
this.forwardingRetentionDeadline(session));
}
catch { return previewError('远端端口不可达或 SSH 服务未允许 TCP 转发。', 502); }
}
if (url.pathname === '/sftp') return this.attachSFTP(request);
@@ -439,8 +449,21 @@ export class SSHSessionDO implements DurableObject {
this.deadlines.delete(ws);
}
private cleanup(ws: WebSocket): void {
this.forwarding.clear();
private cleanup(ws: WebSocket, force = false): void {
const session = this.sessions.get(ws);
if (!force && session && this.forwarding.owns(session)) {
if (!this.retainedForwarding.has(ws)) {
// 管理页断开不等于用户停止;短暂保留底层 SSH,允许刷新或切页后回来主动关闭。
const expiresAt = Date.now() + FORWARD_RETENTION_MS;
const timeout = setTimeout(() => this.cleanup(ws, true), FORWARD_RETENTION_MS);
this.retainedForwarding.set(ws, { expiresAt, timeout });
}
return;
}
const retained = this.retainedForwarding.get(ws);
if (retained) clearTimeout(retained.timeout);
this.retainedForwarding.delete(ws);
if (!session || this.forwarding.currentSession() === session) this.forwarding.clear();
this.clearDeadline(ws);
const token = this.sftpTokenByMainWebSocket.get(ws);
if (token) this.deleteSFTPAttachToken(token);
@@ -472,6 +495,23 @@ export class SSHSessionDO implements DurableObject {
this.sessions.delete(ws);
}
private forwardingRetentionDeadline(session = this.forwarding.currentSession()): number | undefined {
if (!session) return undefined;
for (const [ws, retained] of this.retainedForwarding) {
if (this.sessions.get(ws) === session) return retained.expiresAt;
}
return undefined;
}
private stopForwarding(): void {
const session = this.forwarding.currentSession();
this.forwarding.stop();
if (!session) return;
for (const [ws, current] of this.sessions) {
if (current === session) this.cleanup(ws, true);
}
}
private cleanupSFTPWebSocket(ws: WebSocket): void {
const session = this.sftpSessions.get(ws);
if (!session) return;
+8 -1
View File
@@ -6,7 +6,15 @@ import { TRUSTED_PREFIX } from './mount.ts';
/** 只有主站的认证 API 能创建或撤销转发;预览站没有 SSH 控制接口。 */
export async function forwardingRoute(request: Request, env: Env, accountId: string): Promise<Response> {
const url = new URL(request.url);
if (request.method === 'GET') {
const session = url.searchParams.get('session');
if (session) {
if (!/^[a-f0-9]{64}$/.test(session)) return jsonError('Invalid session identifier', 400);
return env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(session)).fetch(new Request('https://session.internal/forward', {
headers: { 'x-account-id': accountId },
}));
}
let previewAvailable = false;
try {
previewOrigin(env.PREVIEW_ORIGIN, new URL(request.url).origin);
@@ -16,7 +24,6 @@ export async function forwardingRoute(request: Request, env: Env, accountId: str
return Response.json({ previewAvailable }, { headers: { 'Cache-Control': 'no-store' } });
}
if (request.method !== 'POST' && request.method !== 'DELETE') return jsonError('Method not allowed', 405);
const url = new URL(request.url);
const session = url.searchParams.get('session');
if (!session || !/^[a-f0-9]{64}$/.test(session)) return jsonError('Invalid session identifier', 400);
let origin = url.origin;
+1
View File
@@ -1,6 +1,7 @@
export const PREVIEW_COOKIE = '__Host-edgessh-preview';
export const PREVIEW_PREFIX = '/__edgessh/';
export const FORWARD_TTL_MS = 60 * 60 * 1000;
export const FORWARD_RETENTION_MS = 8 * 60 * 1000;
export const LAUNCH_TTL_MS = 60_000;
// workers.dev 是公共后缀,但同账户的两个 Worker 仍共享 account.workers.dev。
+32 -4
View File
@@ -13,9 +13,17 @@ interface Grant {
token: string;
expires: number;
base: string;
mode: 'trusted' | 'isolated';
}
/** 授权仅存在 SSH 会话内存里;DO 重启、断线或显式停止都会让预览 Cookie 失效。 */
export interface ForwardingStatus {
active: boolean;
port?: number;
mode?: 'trusted' | 'isolated';
expiresAt?: number;
}
/** 授权仅存在 SSH 会话内存里;DO 重启、远端 SSH 断线或显式停止都会让预览 Cookie 失效。 */
export class ForwardingState {
private grant?: Grant;
private timer?: ReturnType<typeof setTimeout>;
@@ -34,7 +42,27 @@ export class ForwardingState {
session?.close(true);
}
async create(session: SSHSession, port: number, origin: string, sessionId: string, mode: 'trusted' | 'isolated' = 'isolated'): Promise<Response> {
owns(session: SSHSession): boolean {
return this.grant?.session === session && session.isForwardReady();
}
currentSession(): SSHSession | undefined {
return this.grant?.session;
}
status(expiresAtLimit?: number): ForwardingStatus {
const grant = this.grant;
if (!grant || !grant.session.isForwardReady() || grant.expires <= Date.now()) return { active: false };
return {
active: true,
port: grant.port,
mode: grant.mode,
expiresAt: Math.min(grant.expires, expiresAtLimit ?? grant.expires),
};
}
async create(session: SSHSession, port: number, origin: string, sessionId: string,
mode: 'trusted' | 'isolated' = 'isolated', expiresAtLimit?: number): Promise<Response> {
this.clear();
const revision = this.revision;
// 创建时先探测 direct-tcpip 权限和监听端口,避免给用户一个必定失败的链接。
@@ -43,8 +71,8 @@ export class ForwardingState {
if (revision !== this.revision || !session.isForwardReady()) return previewError('转发已取消。', 409);
const grant: Grant = {
session, port, origin, launchToken: crypto.randomUUID(), launchExpires: Date.now() + LAUNCH_TTL_MS,
token: crypto.randomUUID(), expires: Date.now() + FORWARD_TTL_MS,
base: mode === 'trusted' ? trustedBase(sessionId) : '',
token: crypto.randomUUID(), expires: Math.min(Date.now() + FORWARD_TTL_MS, expiresAtLimit ?? Number.MAX_SAFE_INTEGER),
base: mode === 'trusted' ? trustedBase(sessionId) : '', mode,
};
this.grant = grant;
this.timer = setTimeout(() => this.stop(), FORWARD_TTL_MS);
+33 -5
View File
@@ -14,27 +14,40 @@ async function forwardingFixture(page: Page, previewAvailable = true, noHosts =
const calls: Array<Record<string, unknown>> = [];
const sockets: WebSocketRoute[] = [];
const closed: boolean[] = [];
let active = false;
let retainedExpiresAt = 0;
await page.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname;
const requestURL = new URL(route.request().url());
const path = requestURL.pathname;
if (path === '/api/auth/me') return route.fulfill({ json: { account: { username: 'Administrator' }, provider: 'cloudflare' } });
if (path === '/api/hosts') return route.fulfill({ json: { hosts: noHosts ? [] : [host] } });
if (path.endsWith('/credentials')) return route.fulfill({ json: { password: 'test-only-password' } });
if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId } });
if (path === '/api/forwarding' && route.request().method() === 'GET') {
if (requestURL.searchParams.has('session')) {
return route.fulfill({ json: active
? { active: true, port: 8080, mode: 'trusted', expiresAt: retainedExpiresAt || Date.now() + 480000 }
: { active: false } });
}
return route.fulfill({ json: { previewAvailable } });
}
if (path === '/api/forwarding' && route.request().method() === 'POST') {
calls.push({ type: 'forwarding', body: route.request().postDataJSON() });
active = true;
const mode = route.request().postDataJSON().mode;
return route.fulfill({ json: { url: mode === 'isolated' ? 'https://isolated.example.net/start#xxx' : trustedURL, expiresAt: Date.now() + 3600000 } });
}
if (path === '/api/forwarding' && route.request().method() === 'DELETE') {
active = false; calls.push({ type: 'delete' });
return route.fulfill({ json: { ok: true } });
}
return route.fulfill({ json: {} });
});
await page.routeWebSocket('**/api/ssh?*', (ws) => {
sockets.push(ws);
const index = sockets.length - 1;
closed[index] = false;
ws.onClose(() => { closed[index] = true; void ws.close(); });
ws.onClose(() => { closed[index] = true; retainedExpiresAt = Date.now() + 480000; void ws.close(); });
ws.onMessage((raw) => {
const message = JSON.parse(String(raw));
calls.push(message);
@@ -120,15 +133,30 @@ test('连接转发发送 forward 协议并提供弹窗拦截回退链接,停
await page.screenshot({ path: testInfo.outputPath('forwarding-desktop.png'), fullPage: true });
});
test('离开总览会关闭独立转发 WebSocket,返回页面保持未连接', async ({ page }) => {
const { sockets, closed } = await forwardingFixture(page);
test('离开转发页后保持 8 分钟,返回页面可查看并手动停止', async ({ page }) => {
const { calls, sockets, closed } = await forwardingFixture(page);
await startForward(page);
await expect.poll(() => sockets.length).toBe(1);
await page.locator('#rail-overview').click();
await expect.poll(() => closed[0]).toBe(true);
await page.locator('#rail-forward').click();
await expect(page.locator('[data-status]')).not.toContainText('已转发');
await expect(page.locator('[data-status]')).toContainText('正在保持 127.0.0.1:8080');
await expect(page.locator('[data-stop]')).toBeEnabled();
await page.locator('[data-stop]').click();
await expect(page.locator('[data-stop]')).toBeDisabled();
await expect.poll(() => calls.some((call) => call.type === 'delete')).toBe(true);
});
test('刷新后可从短期会话句柄恢复正在保持的转发', async ({ page }) => {
const { sockets } = await forwardingFixture(page);
await startForward(page);
await expect.poll(() => sockets.length).toBe(1);
await page.reload();
await page.locator('#rail-forward').click();
await expect(page.locator('[data-status]')).toContainText('正在保持 127.0.0.1:8080');
await expect(page.locator('.forward-page input[name="port"]')).toHaveValue('8080');
await expect(page.locator('.forward-page select[name="mode"]')).toHaveValue('trusted');
await page.locator('[data-stop]').click();
});
test('空主机禁用连接按钮且桌面与 375px 手机布局没有横向溢出', async ({ page }, testInfo) => {
+30 -1
View File
@@ -25,6 +25,17 @@ test('GET reports preview availability only when configured', async () => {
assert.deepEqual(await (await forwardingRoute(new Request('https://main.test/api/forward'), makeEnv('https://preview.test').env, 'acct')).json(), { previewAvailable: true });
});
test('GET with a session queries the owned Durable Object', async () => {
const state = makeEnv();
const response = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`), state.env, 'acct');
assert.equal(response.status, 200);
assert.equal(state.calls.length, 1);
assert.equal(state.calls[0].id, sessionId);
assert.equal(state.calls[0].request.method, 'GET');
assert.equal(state.calls[0].request.headers.get('x-account-id'), 'acct');
assert.equal((await forwardingRoute(new Request('https://main.test/api/forward?session=bad'), state.env, 'acct')).status, 400);
});
test('trusted POST requires explicit confirmation and forwards trusted mode', async () => {
const missing = makeEnv();
const rejected = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, {
@@ -77,7 +88,12 @@ test('trusted forwarding validates session, strips token, derives path, and pres
});
function fakeSession() {
return { isForwardReady: () => true, openForward: async () => ({ close: async () => {} }) } as never;
let ready = true;
return {
isForwardReady: () => ready,
openForward: async () => ({ close: async () => {} }),
close: () => { ready = false; },
} as never;
}
test('ForwardingState rejects cross-mode grants and clear invalidates both paths', async () => {
@@ -91,3 +107,16 @@ test('ForwardingState rejects cross-mode grants and clear invalidates both paths
assert.equal((await state.trusted(new Request('https://main.test/'))).status, 410);
assert.equal((await state.preview(new Request('https://main.test/'))).status, 410);
});
test('ForwardingState reports restorable metadata and applies the retention deadline', async () => {
const state = new ForwardingState();
const session = fakeSession();
const retainedUntil = Date.now() + 8 * 60_000;
await state.create(session, 8080, 'https://main.test', sessionId, 'trusted', retainedUntil);
assert.equal(state.owns(session), true);
assert.deepEqual(state.status(retainedUntil), {
active: true, port: 8080, mode: 'trusted', expiresAt: retainedUntil,
});
state.stop();
assert.deepEqual(state.status(), { active: false });
});