feat(FN-2586): merge fusion/fn-2586
This commit is contained in:
@@ -296,6 +296,7 @@ describe("Auth middleware integration with createServer", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo-tunnel",
|
||||
tunnelToken: "cf-secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -85,6 +85,7 @@ describe("remote access headless parity", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://demo.example.com",
|
||||
|
||||
@@ -18,6 +18,7 @@ function buildRemoteAccessSettings(overrides: Record<string, unknown> = {}) {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo-tunnel",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -24,6 +24,7 @@ function createRemoteSettings(overrides: Partial<RemoteAccessProjectSettings> =
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: false,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
|
||||
@@ -18,6 +18,7 @@ function buildRemoteAccessSettings() {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo-tunnel",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
@@ -98,12 +99,14 @@ describe("remote access API route contracts", () => {
|
||||
settings: expect.objectContaining({
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "cloudflare",
|
||||
remoteCloudflareQuickTunnel: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteCloudflareQuickTunnel: true,
|
||||
remoteShortLivedEnabled: true,
|
||||
remoteShortLivedTtlMs: 180000,
|
||||
});
|
||||
@@ -113,10 +116,19 @@ describe("remote access API route contracts", () => {
|
||||
settings: expect.objectContaining({
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteCloudflareQuickTunnel: true,
|
||||
remoteShortLivedEnabled: true,
|
||||
remoteShortLivedTtlMs: 180000,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
remoteAccess: expect.objectContaining({
|
||||
providers: expect.objectContaining({
|
||||
cloudflare: expect.objectContaining({ quickTunnel: true }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("supports provider activation and tunnel lifecycle endpoints", async () => {
|
||||
@@ -159,6 +171,66 @@ describe("remote access API route contracts", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses live tunnel URL for cloudflare quick tunnel link generation", async () => {
|
||||
const quickTunnelSettings = {
|
||||
...buildRemoteAccessSettings(),
|
||||
providers: {
|
||||
...buildRemoteAccessSettings().providers,
|
||||
cloudflare: {
|
||||
...buildRemoteAccessSettings().providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
ingressUrl: "",
|
||||
tunnelToken: null,
|
||||
tunnelName: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: quickTunnelSettings }),
|
||||
});
|
||||
const engine = {
|
||||
getRemoteTunnelManager: () => ({
|
||||
getStatus: () => ({ url: "https://demo.trycloudflare.com" }),
|
||||
}),
|
||||
};
|
||||
const { app } = createApp({ store, engine });
|
||||
|
||||
const urlRes = await REQUEST(app, "GET", "/api/remote/url?tokenType=persistent");
|
||||
expect(urlRes.status).toBe(200);
|
||||
expect(urlRes.body.url).toContain("https://demo.trycloudflare.com/remote-login?rt=");
|
||||
});
|
||||
|
||||
it("returns 409 when quick tunnel URL is requested before cloudflared reports URL", async () => {
|
||||
const quickTunnelSettings = {
|
||||
...buildRemoteAccessSettings(),
|
||||
providers: {
|
||||
...buildRemoteAccessSettings().providers,
|
||||
cloudflare: {
|
||||
...buildRemoteAccessSettings().providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
ingressUrl: "",
|
||||
tunnelToken: null,
|
||||
tunnelName: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: quickTunnelSettings }),
|
||||
});
|
||||
const engine = {
|
||||
getRemoteTunnelManager: () => ({
|
||||
getStatus: () => ({ url: null }),
|
||||
}),
|
||||
};
|
||||
const { app } = createApp({ store, engine });
|
||||
|
||||
const urlRes = await REQUEST(app, "GET", "/api/remote/url?tokenType=persistent");
|
||||
expect(urlRes.status).toBe(409);
|
||||
expect(urlRes.body.error).toContain("quick tunnel has not started yet");
|
||||
});
|
||||
|
||||
it("supports persistent and short-lived token endpoints plus URL/QR contracts", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
|
||||
@@ -13207,6 +13207,7 @@ describe("PUT /settings", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: false,
|
||||
tunnelName: "existing-tunnel",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
@@ -13680,6 +13681,7 @@ describe("GET /settings/scopes", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: false,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
@@ -18031,6 +18033,7 @@ describe("remote access auth login-url endpoints", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "tunnel",
|
||||
tunnelToken: "cf-secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("createServer health and headless mode", () => {
|
||||
activeProvider: "cloudflare",
|
||||
providers: {
|
||||
tailscale: { enabled: false, hostname: "", targetPort: 4040, acceptRoutes: false },
|
||||
cloudflare: { enabled: true, tunnelName: "demo", tunnelToken: "cf-secret", ingressUrl: "https://remote.example.com" },
|
||||
cloudflare: { enabled: true, quickTunnel: false, tunnelName: "demo", tunnelToken: "cf-secret", ingressUrl: "https://remote.example.com" },
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: { enabled: true, token: "frt_persistent_token" },
|
||||
@@ -2206,6 +2206,7 @@ describe("GET /remote-login", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "tunnel",
|
||||
tunnelToken: "secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -50,20 +50,33 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
|
||||
|
||||
function resolveRemoteBaseUrl(remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>): URL {
|
||||
function resolveRemoteBaseUrl(
|
||||
remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>,
|
||||
tunnelUrl?: string | null,
|
||||
): URL {
|
||||
if (!remoteAccess.activeProvider) {
|
||||
throw new ApiError(409, "No active remote provider configured", { code: "REMOTE_PROVIDER_NOT_CONFIGURED" });
|
||||
}
|
||||
|
||||
if (remoteAccess.activeProvider === "cloudflare") {
|
||||
const ingressUrl = remoteAccess.providers.cloudflare.ingressUrl?.trim();
|
||||
if (!ingressUrl) {
|
||||
const cloudflare = remoteAccess.providers.cloudflare;
|
||||
const ingressUrl = cloudflare.ingressUrl?.trim();
|
||||
const candidateUrl = cloudflare.quickTunnel === true && !ingressUrl
|
||||
? (tunnelUrl?.trim() ?? "")
|
||||
: ingressUrl;
|
||||
|
||||
if (!candidateUrl) {
|
||||
if (cloudflare.quickTunnel === true) {
|
||||
throw new ApiError(409, "Cloudflare quick tunnel has not started yet", {
|
||||
code: "REMOTE_URL_NOT_READY",
|
||||
});
|
||||
}
|
||||
throw new ApiError(409, "Cloudflare ingress URL is not configured", { code: "REMOTE_URL_NOT_CONFIGURED" });
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(ingressUrl);
|
||||
parsed = new URL(candidateUrl);
|
||||
} catch {
|
||||
throw new ApiError(409, "Cloudflare ingress URL is invalid", { code: "REMOTE_URL_INVALID" });
|
||||
}
|
||||
@@ -115,9 +128,17 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
return token;
|
||||
}
|
||||
|
||||
function getCurrentTunnelUrl(engine: unknown): string | null {
|
||||
const manager = (engine as {
|
||||
getRemoteTunnelManager?: () => { getStatus?: () => { url?: string | null } } | undefined;
|
||||
} | undefined)?.getRemoteTunnelManager?.();
|
||||
return manager?.getStatus?.().url ?? null;
|
||||
}
|
||||
|
||||
async function buildRemoteLoginUrlForTokenType(
|
||||
scopedStore: typeof store,
|
||||
mode: "persistent" | "short-lived",
|
||||
tunnelUrl?: string | null,
|
||||
): Promise<{ loginUrl: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
@@ -126,7 +147,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
throw new ApiError(409, "No remote provider is enabled", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
|
||||
const baseUrl = resolveRemoteBaseUrl(remoteAccess);
|
||||
const baseUrl = resolveRemoteBaseUrl(remoteAccess, tunnelUrl);
|
||||
|
||||
if (mode === "persistent") {
|
||||
if (!remoteAccess.tokenStrategy.persistent.enabled) {
|
||||
@@ -274,6 +295,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
remoteTailscaleTargetPort: Number(remoteAccess.providers.tailscale.targetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(remoteAccess.providers.tailscale.acceptRoutes),
|
||||
remoteCloudflareEnabled: Boolean(remoteAccess.providers.cloudflare.enabled),
|
||||
remoteCloudflareQuickTunnel: Boolean(remoteAccess.providers.cloudflare.quickTunnel),
|
||||
remoteCloudflareTunnelName: remoteAccess.providers.cloudflare.tunnelName,
|
||||
remoteCloudflareTunnelToken: remoteAccess.providers.cloudflare.tunnelToken,
|
||||
remoteCloudflareIngressUrl: remoteAccess.providers.cloudflare.ingressUrl,
|
||||
@@ -330,6 +352,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
cloudflare: {
|
||||
...remoteAccess.providers.cloudflare,
|
||||
enabled: body.remoteCloudflareEnabled === undefined ? remoteAccess.providers.cloudflare.enabled : Boolean(body.remoteCloudflareEnabled),
|
||||
quickTunnel: body.remoteCloudflareQuickTunnel === undefined
|
||||
? Boolean(remoteAccess.providers.cloudflare.quickTunnel)
|
||||
: Boolean(body.remoteCloudflareQuickTunnel),
|
||||
tunnelName: body.remoteCloudflareTunnelName === undefined ? remoteAccess.providers.cloudflare.tunnelName : String(body.remoteCloudflareTunnelName ?? ""),
|
||||
tunnelToken: body.remoteCloudflareTunnelToken === undefined
|
||||
? remoteAccess.providers.cloudflare.tunnelToken
|
||||
@@ -553,8 +578,8 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
throw new ApiError(400, "mode must be 'persistent' or 'short-lived'", { code: "INVALID_REMOTE_AUTH_MODE" });
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, mode);
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, mode, getCurrentTunnelUrl(engine ?? options?.engine));
|
||||
res.json({
|
||||
loginUrl: payload.loginUrl,
|
||||
tokenType: payload.tokenType,
|
||||
@@ -568,9 +593,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
router.get("/remote/url", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType, getCurrentTunnelUrl(engine ?? options?.engine));
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -580,10 +605,10 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
router.get("/remote/qr", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||
const format = req.query.format === "image/svg" ? "image/svg" : "text";
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType, getCurrentTunnelUrl(engine ?? options?.engine));
|
||||
if (format === "image/svg") {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="white"/><text x="10" y="42" font-size="12" fill="black">${payload.loginUrl.replace(/&/g, "&").replace(/</g, "<")}</text></svg>`;
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: svg });
|
||||
|
||||
Reference in New Issue
Block a user