fix(FN-2525): harden remote access auth and tunnel regression coverage

- Add regression tests across CLI, core, dashboard, and engine for remote access auth, settings parity, and serve/TUI callback wiring
- Expand dashboard route and modal coverage for remote settings/auth flows including node environment behaviors
- Redact provider-switch failure details in tunnel process manager to avoid leaking sensitive provider diagnostics
- Update route registration and engine lifecycle tests to lock in remote-access behavior under real execution paths
This commit is contained in:
Fusion
2026-04-26 06:35:46 -07:00
committed by gsxdsm
parent 559e908865
commit f09346a9da
13 changed files with 815 additions and 19 deletions

View File

@@ -1363,5 +1363,76 @@ describe("SettingsModal", () => {
expect(await screen.findByRole("img", { name: "Remote access QR code" })).toBeInTheDocument();
expect(screen.getByText("Scan this QR code on your phone")).toBeInTheDocument();
});
it("saves both provider configs and preserves inactive provider values when switching active provider", async () => {
const addToast = vi.fn();
renderModal({ addToast });
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /Remote Access/ }));
await userEvent.selectOptions(screen.getByLabelText("Active provider"), "tailscale");
await userEvent.clear(screen.getByLabelText("Hostname label"));
await userEvent.type(screen.getByLabelText("Hostname label"), "tail-new.ts.net");
await userEvent.clear(screen.getByLabelText("Tunnel name"));
await userEvent.type(screen.getByLabelText("Tunnel name"), "cf-preserved");
await userEvent.clear(screen.getByLabelText("Ingress URL"));
await userEvent.type(screen.getByLabelText("Ingress URL"), "https://cf-preserved.example.com");
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
await waitFor(() => {
expect(mockUpdateRemoteSettings).toHaveBeenCalledTimes(1);
});
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteActiveProvider: "tailscale",
remoteTailscaleHostname: "tail-new.ts.net",
remoteCloudflareTunnelName: "cf-preserved",
remoteCloudflareIngressUrl: "https://cf-preserved.example.com",
}),
undefined,
);
expect(addToast).toHaveBeenCalledWith("Remote settings saved", "success");
});
it("handles tunnel lifecycle and token action errors without exposing raw token values", async () => {
const addToast = vi.fn();
mockFetchRemoteStatus
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
.mockResolvedValueOnce({ provider: "tailscale", state: "starting", url: null, lastError: null })
.mockResolvedValue({ provider: "tailscale", state: "stopped", url: null, lastError: null });
mockGenerateShortLivedRemoteToken.mockRejectedValueOnce(new Error("TTL must be between 60000 and 86400000ms"));
renderModal({ addToast });
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /Remote Access/ }));
await userEvent.click(screen.getByRole("button", { name: "Start tunnel" }));
await waitFor(() => {
expect(mockStartRemoteTunnel).toHaveBeenCalledWith(undefined);
});
expect(addToast).toHaveBeenCalledWith("Remote tunnel start requested", "success");
await userEvent.click(screen.getByRole("button", { name: "Stop tunnel" }));
await waitFor(() => {
expect(mockStopRemoteTunnel).toHaveBeenCalledWith(undefined);
});
expect(addToast).toHaveBeenCalledWith("Remote tunnel stopped", "success");
fireEvent.change(screen.getByLabelText("Short-lived TTL (ms)"), { target: { value: "1000" } });
await userEvent.click(screen.getByRole("button", { name: "Generate short-lived token" }));
await waitFor(() => {
expect(mockGenerateShortLivedRemoteToken).toHaveBeenCalledWith(1000, undefined);
});
expect(addToast).toHaveBeenCalledWith("TTL must be between 60000 and 86400000ms", "error");
expect(addToast.mock.calls.flat().join(" ")).not.toContain("frt_");
await userEvent.click(screen.getByRole("button", { name: "Regenerate persistent token" }));
await waitFor(() => {
expect(mockRegenerateRemotePersistentToken).toHaveBeenCalledWith(undefined);
});
});
});
});

View File

@@ -1,3 +1,5 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Request, Response, NextFunction } from "express";
import { createAuthMiddleware, isDaemonAuthActive } from "../auth-middleware.js";
@@ -123,6 +125,16 @@ describe("createAuthMiddleware", () => {
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFn).not.toHaveBeenCalled();
});
it("does not accept remote-login rt query tokens for non-remote API auth", () => {
mockReq.url = "/api/tasks?rt=frt_persistent_token";
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFn).not.toHaveBeenCalled();
});
});
describe("isDaemonAuthActive", () => {

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { RemoteAccessProjectSettings } from "@fusion/core";
// @vitest-environment node
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { RemoteAccessProjectSettings, TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { request as performRequest, get as performGet } from "../test-request.js";
import {
__resetRemoteAuthStateForTests,
constantTimeEqual,
@@ -46,6 +50,33 @@ function createRemoteSettings(overrides: Partial<RemoteAccessProjectSettings> =
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({ remoteAccess: createRemoteSettings() }),
updateSettings: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
logEntry: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
describe("remote-auth", () => {
beforeEach(() => {
__resetRemoteAuthStateForTests();
@@ -144,3 +175,85 @@ describe("remote-auth", () => {
expect(maskRemoteToken("frt_abcdefghijklmnop")).toBe("frt_…mnop");
});
});
describe("remote auth route contracts", () => {
beforeEach(() => {
__resetRemoteAuthStateForTests();
});
it("returns login-url payload with token shape for both persistent and short-lived modes", async () => {
const app = createServer(createMockStore(), { noAuth: true });
const persistent = await performRequest(
app,
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "persistent" }),
{ "Content-Type": "application/json" },
);
expect(persistent.status).toBe(200);
expect(persistent.body).toMatchObject({
tokenType: "persistent",
loginUrl: expect.stringContaining("/remote-login?rt="),
});
const shortLived = await performRequest(
app,
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "short-lived" }),
{ "Content-Type": "application/json" },
);
expect(shortLived.status).toBe(200);
expect(shortLived.body).toMatchObject({
tokenType: "short-lived",
loginUrl: expect.stringContaining("/remote-login?rt="),
expiresAt: expect.any(String),
});
});
it("validates /remote-login?rt= for persistent, short-lived, expired, missing, and malformed tokens", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
const app = createServer(createMockStore(), { daemon: { token: "fn_daemon_token" } });
const persistentValid = await performGet(app, "/remote-login?rt=frt_persistent_token");
expect(persistentValid.status).toBe(302);
expect(persistentValid.headers.location).toBe("/?token=fn_daemon_token");
const issueShortLived = await performRequest(
app,
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "short-lived" }),
{ "Content-Type": "application/json", Authorization: "Bearer fn_daemon_token" },
);
expect(issueShortLived.status).toBe(200);
const issued = new URL(String((issueShortLived.body as Record<string, unknown>).loginUrl));
const shortToken = issued.searchParams.get("rt");
expect(shortToken).toBeTruthy();
const shortValid = await performGet(app, `/remote-login?rt=${shortToken}`);
expect(shortValid.status).toBe(302);
vi.advanceTimersByTime(121000);
const shortExpired = await performGet(app, `/remote-login?rt=${shortToken}`);
expect(shortExpired.status).toBe(401);
expect(shortExpired.body).toEqual({ error: "Unauthorized", code: "remote_token_expired" });
const missing = await performGet(app, "/remote-login");
expect(missing.status).toBe(401);
expect(missing.body).toEqual({ error: "Unauthorized", code: "remote_token_missing" });
const malformed = await performGet(app, "/remote-login?rt=not-a-valid-token");
expect(malformed.status).toBe(401);
expect(malformed.body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" });
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,214 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { request as performRequest } from "../test-request.js";
function buildRemoteAccessSettings() {
return {
enabled: true,
activeProvider: "cloudflare" as const,
providers: {
tailscale: {
enabled: true,
hostname: "tail.example.ts.net",
targetPort: 4040,
acceptRoutes: false,
},
cloudflare: {
enabled: true,
tunnelName: "demo-tunnel",
tunnelToken: "cf-secret-token",
ingressUrl: "https://remote.example.com",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: "frt_persistent_token",
},
shortLived: {
enabled: true,
ttlMs: 120000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: true,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
updateSettings: vi.fn(async (patch: Record<string, unknown>) => patch),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
prepare: vi.fn().mockReturnValue({ run: vi.fn(), get: vi.fn(), all: vi.fn() }),
exec: vi.fn(),
}),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(),
updateTask: vi.fn(),
moveTask: vi.fn(),
logEntry: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function createApp(opts: { store?: TaskStore; engine?: Record<string, unknown> } = {}) {
const store = opts.store ?? createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { engine: opts.engine as any }));
return { app, store };
}
async function REQUEST(app: express.Express, method: string, path: string, body?: unknown) {
return performRequest(
app,
method,
path,
body === undefined ? undefined : JSON.stringify(body),
body === undefined ? {} : { "Content-Type": "application/json" },
);
}
describe("remote access API route contracts", () => {
it("supports GET and PUT /api/remote/settings", async () => {
const store = createMockStore({
updateSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
getSettings: vi.fn()
.mockResolvedValueOnce({ remoteAccess: buildRemoteAccessSettings() })
.mockResolvedValueOnce({ remoteAccess: { ...buildRemoteAccessSettings(), activeProvider: "tailscale" } }),
});
const { app } = createApp({ store });
const getRes = await REQUEST(app, "GET", "/api/remote/settings");
expect(getRes.status).toBe(200);
expect(getRes.body).toMatchObject({
settings: expect.objectContaining({
remoteEnabled: true,
remoteActiveProvider: "cloudflare",
}),
});
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
remoteEnabled: true,
remoteActiveProvider: "tailscale",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 180000,
});
expect(putRes.status).toBe(200);
expect(putRes.body).toMatchObject({
settings: expect.objectContaining({
remoteEnabled: true,
remoteActiveProvider: "tailscale",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 180000,
}),
});
});
it("supports provider activation and tunnel lifecycle endpoints", async () => {
const engine = {
startRemoteTunnel: vi.fn().mockResolvedValue({
state: "running",
provider: "cloudflare",
url: "https://remote.example.com",
lastError: null,
}),
stopRemoteTunnel: vi.fn().mockResolvedValue({
state: "stopped",
provider: null,
url: null,
lastError: null,
}),
};
const updateSettings = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({ updateSettings });
const { app } = createApp({ store, engine });
const activateRes = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "tailscale" });
expect(activateRes.status).toBe(200);
expect(activateRes.body).toEqual({ activeProvider: "tailscale" });
const startRes = await REQUEST(app, "POST", "/api/remote/tunnel/start", {});
expect(startRes.status).toBe(200);
expect(["starting", "running"]).toContain(startRes.body.state);
expect(startRes.body.provider).toBe("cloudflare");
const stopRes = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {});
expect(stopRes.status).toBe(200);
expect(stopRes.body.state).toBe("stopped");
expect(stopRes.body.provider).toBe("cloudflare");
expect(engine.startRemoteTunnel.mock.calls.length).toBeLessThanOrEqual(1);
expect(engine.stopRemoteTunnel.mock.calls.length).toBeLessThanOrEqual(1);
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
remoteAccess: expect.objectContaining({ activeProvider: "tailscale" }),
}));
});
it("supports persistent and short-lived token endpoints plus URL/QR contracts", async () => {
const { app } = createApp();
const persistent = await REQUEST(app, "POST", "/api/remote/token/persistent/regenerate", {});
expect(persistent.status).toBe(200);
expect(persistent.body).toMatchObject({
token: expect.stringMatching(/^frt_[A-Za-z0-9_-]+$/),
maskedToken: expect.any(String),
});
const shortLived = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 120000 });
expect(shortLived.status).toBe(200);
expect(shortLived.body).toMatchObject({
token: expect.stringMatching(/^frt_[A-Za-z0-9_-]+$/),
expiresAt: expect.any(String),
});
expect(shortLived.body.ttlMs).toBeGreaterThanOrEqual(119000);
expect(shortLived.body.ttlMs).toBeLessThanOrEqual(120000);
const shortLivedBounded = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 1000 });
expect(shortLivedBounded.status).toBe(200);
expect(shortLivedBounded.body.ttlMs).toBeGreaterThanOrEqual(60000);
const shortLivedMaxBounded = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 200000000 });
expect(shortLivedMaxBounded.status).toBe(200);
expect(shortLivedMaxBounded.body.ttlMs).toBeLessThanOrEqual(86400000);
const urlRes = await REQUEST(app, "GET", "/api/remote/url?tokenType=short-lived");
expect(urlRes.status).toBe(200);
expect(urlRes.body).toMatchObject({
url: expect.stringContaining("/remote-login?rt="),
tokenType: "short-lived",
expiresAt: expect.any(String),
});
const qrText = await REQUEST(app, "GET", "/api/remote/qr?format=text&tokenType=persistent");
expect(qrText.status).toBe(200);
expect(qrText.body).toMatchObject({
format: "text",
data: expect.stringContaining("/remote-login?rt="),
tokenType: "persistent",
});
const qrSvg = await REQUEST(app, "GET", "/api/remote/qr?format=image/svg&tokenType=short-lived");
expect(qrSvg.status).toBe(200);
expect(qrSvg.body).toMatchObject({
format: "image/svg",
data: expect.stringContaining("<svg"),
tokenType: "short-lived",
});
});
});

View File

@@ -308,6 +308,39 @@ describe("createServer health and headless mode", () => {
const rootRes = await GET(app, "/");
expect(rootRes.status).toBe(404);
});
it("wires remote settings/auth routes in both dashboard and headless modes", async () => {
const remoteAccess = {
enabled: true,
activeProvider: "cloudflare",
providers: {
tailscale: { enabled: false, hostname: "", targetPort: 4040, acceptRoutes: false },
cloudflare: { enabled: true, tunnelName: "demo", tunnelToken: "cf-secret", ingressUrl: "https://remote.example.com" },
},
tokenStrategy: {
persistent: { enabled: true, token: "frt_persistent_token" },
shortLived: { enabled: true, ttlMs: 120000, maxTtlMs: 86400000 },
},
lifecycle: { rememberLastRunning: false, wasRunningOnShutdown: false, lastRunningProvider: null },
};
const dashboard = createServer(createMockStore({ getSettings: vi.fn().mockResolvedValue({ remoteAccess }) }));
const headless = createServer(createMockStore({ getSettings: vi.fn().mockResolvedValue({ remoteAccess }) }), { headless: true });
const [dashSettings, headlessSettings, dashLoginUrl, headlessLoginUrl] = await Promise.all([
GET(dashboard, "/api/remote/settings"),
GET(headless, "/api/remote/settings"),
REQUEST(headless, "POST", "/api/remote-access/auth/login-url", JSON.stringify({ mode: "persistent" }), { "Content-Type": "application/json" }),
REQUEST(dashboard, "POST", "/api/remote-access/auth/login-url", JSON.stringify({ mode: "persistent" }), { "Content-Type": "application/json" }),
]);
expect(dashSettings.status).toBe(200);
expect(headlessSettings.status).toBe(200);
expect(dashLoginUrl.status).toBe(200);
expect(headlessLoginUrl.status).toBe(200);
expect(String((dashLoginUrl.body as Record<string, unknown>).loginUrl)).toContain("/remote-login?rt=");
expect(String((headlessLoginUrl.body as Record<string, unknown>).loginUrl)).toContain("/remote-login?rt=");
});
});
describe("API Error Handling Middleware", () => {

View File

@@ -263,30 +263,113 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
// ── Remote Access Routes ────────────────────────────────────────────
function toRemoteSettingsPayload(remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>) {
const persistentToken = remoteAccess.tokenStrategy.persistent.token?.trim() ?? "";
return {
remoteEnabled: Boolean(remoteAccess.enabled),
remoteActiveProvider: remoteAccess.activeProvider ?? null,
remoteTailscaleEnabled: Boolean(remoteAccess.providers.tailscale.enabled),
remoteTailscaleHostname: remoteAccess.providers.tailscale.hostname,
remoteTailscaleTargetPort: Number(remoteAccess.providers.tailscale.targetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(remoteAccess.providers.tailscale.acceptRoutes),
remoteCloudflareEnabled: Boolean(remoteAccess.providers.cloudflare.enabled),
remoteCloudflareTunnelName: remoteAccess.providers.cloudflare.tunnelName,
remoteCloudflareTunnelToken: remoteAccess.providers.cloudflare.tunnelToken,
remoteCloudflareIngressUrl: remoteAccess.providers.cloudflare.ingressUrl,
remoteShortLivedEnabled: Boolean(remoteAccess.tokenStrategy.shortLived.enabled),
remoteShortLivedTtlMs: Number(remoteAccess.tokenStrategy.shortLived.ttlMs ?? 900_000),
remoteShortLivedMaxTtlMs: Number(remoteAccess.tokenStrategy.shortLived.maxTtlMs ?? 86_400_000),
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
remoteRememberLastRunning: Boolean(remoteAccess.lifecycle.rememberLastRunning),
remoteWasRunningOnShutdown: Boolean(remoteAccess.lifecycle.wasRunningOnShutdown),
remoteLastStartedProvider: remoteAccess.lifecycle.lastRunningProvider ?? null,
};
}
router.get("/remote/settings", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const remoteAccess = settings.remoteAccess;
const persistentToken = remoteAccess?.tokenStrategy.persistent.token?.trim() ?? "";
res.json({
settings: {
remoteEnabled: Boolean(remoteAccess?.enabled),
remoteActiveProvider: remoteAccess?.activeProvider ?? null,
remoteTailscaleEnabled: Boolean(remoteAccess?.providers.tailscale.enabled),
remoteCloudflareEnabled: Boolean(remoteAccess?.providers.cloudflare.enabled),
remoteShortLivedEnabled: Boolean(remoteAccess?.tokenStrategy.shortLived.enabled),
remoteShortLivedTtlMs: Number(remoteAccess?.tokenStrategy.shortLived.ttlMs ?? 900_000),
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
},
});
if (!remoteAccess) {
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
}
res.json({ settings: toRemoteSettingsPayload(remoteAccess) });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to load remote settings");
}
});
router.put("/remote/settings", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const remoteAccess = settings.remoteAccess;
if (!remoteAccess) {
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const nextRemoteAccess = {
...remoteAccess,
enabled: body.remoteEnabled === undefined ? remoteAccess.enabled : Boolean(body.remoteEnabled),
activeProvider: body.remoteActiveProvider === undefined
? remoteAccess.activeProvider
: (body.remoteActiveProvider as "tailscale" | "cloudflare" | null),
providers: {
tailscale: {
...remoteAccess.providers.tailscale,
enabled: body.remoteTailscaleEnabled === undefined ? remoteAccess.providers.tailscale.enabled : Boolean(body.remoteTailscaleEnabled),
hostname: body.remoteTailscaleHostname === undefined ? remoteAccess.providers.tailscale.hostname : String(body.remoteTailscaleHostname ?? ""),
targetPort: body.remoteTailscaleTargetPort === undefined ? remoteAccess.providers.tailscale.targetPort : Number(body.remoteTailscaleTargetPort ?? 4040),
acceptRoutes: body.remoteTailscaleAcceptRoutes === undefined ? remoteAccess.providers.tailscale.acceptRoutes : Boolean(body.remoteTailscaleAcceptRoutes),
},
cloudflare: {
...remoteAccess.providers.cloudflare,
enabled: body.remoteCloudflareEnabled === undefined ? remoteAccess.providers.cloudflare.enabled : Boolean(body.remoteCloudflareEnabled),
tunnelName: body.remoteCloudflareTunnelName === undefined ? remoteAccess.providers.cloudflare.tunnelName : String(body.remoteCloudflareTunnelName ?? ""),
tunnelToken: body.remoteCloudflareTunnelToken === undefined
? remoteAccess.providers.cloudflare.tunnelToken
: (body.remoteCloudflareTunnelToken ? String(body.remoteCloudflareTunnelToken) : null),
ingressUrl: body.remoteCloudflareIngressUrl === undefined ? remoteAccess.providers.cloudflare.ingressUrl : String(body.remoteCloudflareIngressUrl ?? ""),
},
},
tokenStrategy: {
persistent: {
...remoteAccess.tokenStrategy.persistent,
enabled: body.remotePersistentEnabled === undefined ? remoteAccess.tokenStrategy.persistent.enabled : Boolean(body.remotePersistentEnabled),
token: body.remotePersistentToken === undefined
? remoteAccess.tokenStrategy.persistent.token
: (body.remotePersistentToken ? String(body.remotePersistentToken) : null),
},
shortLived: {
...remoteAccess.tokenStrategy.shortLived,
enabled: body.remoteShortLivedEnabled === undefined ? remoteAccess.tokenStrategy.shortLived.enabled : Boolean(body.remoteShortLivedEnabled),
ttlMs: body.remoteShortLivedTtlMs === undefined ? remoteAccess.tokenStrategy.shortLived.ttlMs : Number(body.remoteShortLivedTtlMs ?? 900_000),
maxTtlMs: body.remoteShortLivedMaxTtlMs === undefined ? remoteAccess.tokenStrategy.shortLived.maxTtlMs : Number(body.remoteShortLivedMaxTtlMs ?? 86_400_000),
},
},
lifecycle: {
...remoteAccess.lifecycle,
rememberLastRunning: body.remoteRememberLastRunning === undefined ? remoteAccess.lifecycle.rememberLastRunning : Boolean(body.remoteRememberLastRunning),
wasRunningOnShutdown: body.remoteWasRunningOnShutdown === undefined ? remoteAccess.lifecycle.wasRunningOnShutdown : Boolean(body.remoteWasRunningOnShutdown),
lastRunningProvider: body.remoteLastStartedProvider === undefined
? remoteAccess.lifecycle.lastRunningProvider
: (body.remoteLastStartedProvider as "tailscale" | "cloudflare" | null),
},
};
await scopedStore.updateSettings({ remoteAccess: nextRemoteAccess });
res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update remote settings");
}
});
router.get("/remote/status", async (req, res) => {
try {
const { store: scopedStore, engine } = await getProjectContext(req);
@@ -452,7 +535,10 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
: remoteAccess;
const issued = issueRemoteAuthToken("short-lived", modeSettings);
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: modeSettings.tokenStrategy.shortLived.ttlMs });
const effectiveTtlMs = issued.expiresAt
? Math.max(0, Date.parse(issued.expiresAt) - Date.now())
: modeSettings.tokenStrategy.shortLived.ttlMs;
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: effectiveTtlMs });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate short-lived token");