Merge branch 'dev' into remote-ssh-local-dashboard-support

This commit is contained in:
Konsti Wohlwend 2026-06-02 18:08:08 -07:00 committed by GitHub
commit 04113b5352
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 421 additions and 63 deletions

View File

@ -36,12 +36,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Clean
run: pnpm run clean
- name: Install dependencies (after clean)
run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm build:packages

View File

@ -230,7 +230,10 @@ async function createDnsimpleZone(subdomain: string): Promise<DnsimpleZone> {
async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "deleted" | "not_found" }> {
const dnsimpleBaseUrl = getDnsimpleBaseUrl();
const dnsimpleAccountId = getDnsimpleAccountId();
const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}`, {
// A zone is created by creating a domain (see createDnsimpleZone), and DNSimple has no
// DELETE endpoint for zones — the symmetric teardown is to delete the owning domain,
// which removes its hosted DNS zone too.
const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/domains/${encodeURIComponent(zoneName)}`, {
method: "DELETE",
headers: getDnsimpleHeaders(),
});
@ -239,7 +242,7 @@ async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "de
}
if (!response.ok) {
const responseBody = await response.text();
throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email zone`, {
throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email domain`, {
zoneName,
status: response.status,
responseBody,
@ -643,10 +646,19 @@ export async function applyManagedEmailProvider(options: {
if (!domain || domain.tenancyId !== options.tenancy.id || !domain.isActive) {
throw new StatusError(404, "Managed domain not found for this project/branch");
}
if (domain.status === "applied") {
// Only short-circuit when the rendered config already points at this exact domain.
// If the row is marked "applied" but the config has since drifted (e.g. the user
// switched to a different provider and back), we must fall through and re-provision
// so the config is rewritten with the full, valid managed server settings — otherwise
// the config is left missing required fields like password/senderName.
if (domain.status === "applied" && isManagedEmailDomainInUseForTenancy({
tenancy: options.tenancy,
subdomain: domain.subdomain,
senderLocalPart: domain.senderLocalPart,
})) {
return { status: "applied" };
}
if (domain.status !== "verified") {
if (domain.status !== "verified" && domain.status !== "applied") {
throw new StatusError(409, "Managed domain is not verified yet");
}

View File

@ -310,6 +310,7 @@ function ManagedDomainSetupDialog(props: {
onOpenChange: (open: boolean) => void,
initialState: SetupState | null,
onCompleted: () => void,
onApply: (domainId: string) => void,
}) {
const stackAdminApp = useAdminApp();
const { toast } = useToast();
@ -416,21 +417,14 @@ function ManagedDomainSetupDialog(props: {
}
}, [setupState, stackAdminApp, toast, props]);
const handleApply = useCallback(async () => {
const handleApply = useCallback(() => {
if (!setupState) return;
setSubmitting(true);
setError(null);
try {
await stackAdminApp.applyManagedEmailProvider({ domainId: setupState.domainId });
toast({ title: "Domain applied", description: `Sending emails from ${setupState.senderLocalPart}@${setupState.subdomain}.`, variant: "success" });
props.onCompleted();
props.onOpenChange(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not apply domain");
} finally {
setSubmitting(false);
}
}, [setupState, stackAdminApp, toast, props]);
// Don't apply immediately — stage the selection so the parent surfaces the
// standard "unsaved changes" save bar, consistent with the other providers.
// The actual applyManagedEmailProvider call happens when the user hits Save.
props.onApply(setupState.domainId);
props.onOpenChange(false);
}, [setupState, props]);
const steps = [
{ n: 1 as const, title: "Your domain" },
@ -698,8 +692,7 @@ function ManagedDomainSetupDialog(props: {
{stage === 3 && (
<DesignButton
size="sm"
loading={submitting}
onClick={() => runAsynchronouslyWithAlert(handleApply)}
onClick={() => handleApply()}
>
Use this domain
</DesignButton>
@ -731,6 +724,9 @@ export function DomainSettings() {
const [loadingDomains, setLoadingDomains] = useState(false);
const [dialog, setDialog] = useState<{ initialState: SetupState | null } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<ManagedDomain | null>(null);
// The managed domain the user has staged to apply but not yet saved. `null` means
// "no pending change" — the active domain (from config) is used as the baseline.
const [draftManagedDomainId, setDraftManagedDomainId] = useState<string | null>(null);
const refreshDomains = useCallback(async () => {
setLoadingDomains(true);
@ -748,6 +744,15 @@ export function DomainSettings() {
}
}, [serverType, refreshDomains]);
// The managed domain currently applied per the saved config (the baseline).
const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
? domains.find((d) =>
d.subdomain === emailConfig.managedSubdomain
&& d.senderLocalPart === emailConfig.managedSenderLocalPart
)?.domainId ?? null
: null;
const isManagedDirty = serverType === "managed" && draftManagedDomainId != null && draftManagedDomainId !== activeManagedDomainId;
const isShared = serverType === "shared";
const visibleSenderFields = serverType === "resend" || serverType === "standard";
@ -768,6 +773,7 @@ export function DomainSettings() {
const isDirty = useMemo(() => {
if (serverType !== savedServerType) return true;
if (isManagedDirty) return true;
const keys = new Set<string>();
if (visibleSenderFields) {
keys.add("senderEmail");
@ -778,7 +784,7 @@ export function DomainSettings() {
if ((formValues[k] || "") !== (savedValues[k] || "")) return true;
}
return false;
}, [serverType, savedServerType, formValues, savedValues, visibleSenderFields, configFields]);
}, [serverType, savedServerType, isManagedDirty, formValues, savedValues, visibleSenderFields, configFields]);
const updateField = useCallback((key: string, value: string) => {
setFormValues(prev => ({ ...prev, [key]: value }));
@ -793,6 +799,7 @@ export function DomainSettings() {
const handleDiscard = useCallback(() => {
setServerType(savedServerType);
setFormValues(savedValues);
setDraftManagedDomainId(null);
setSaveError(null);
}, [savedServerType, savedValues]);
@ -809,6 +816,22 @@ export function DomainSettings() {
pushable: false,
});
toast({ title: "Email server updated", variant: "success" });
} else if (serverType === "managed") {
const domainId = draftManagedDomainId;
if (!domainId) throwErr("Select a domain to use");
const selected = domains.find((d) => d.domainId === domainId);
if (!selected) throwErr("Selected domain is no longer available");
// applyManagedEmailProvider provisions the scoped sending key and writes the complete,
// valid managed config server-side — it owns the managed config entirely. We must NOT
// write a partial config from here: a partial managed override renders to a config
// missing required fields (password/senderName), which breaks every project read.
// The empty updateConfig is a no-op write whose only purpose is to refresh the reactive
// config cache (via _refreshProjectConfig) so the UI reflects the newly-active domain.
await stackAdminApp.applyManagedEmailProvider({ domainId });
await updateConfig({ adminApp: stackAdminApp, configUpdate: {}, pushable: false });
setDraftManagedDomainId(null);
await refreshDomains();
toast({ title: "Domain applied", description: `Sending emails from ${selected.senderLocalPart}@${selected.subdomain}.`, variant: "success" });
} else {
const requireField = (key: string, label: string): string => {
const val = formValues[key];
@ -875,7 +898,7 @@ export function DomainSettings() {
} finally {
setSaving(false);
}
}, [serverType, formValues, stackAdminApp, updateConfig, toast]);
}, [serverType, formValues, stackAdminApp, updateConfig, toast, draftManagedDomainId, domains, refreshDomains]);
if (isEmulator) {
return (
@ -898,12 +921,15 @@ export function DomainSettings() {
...configFields.filter(f => !(formValues[f.key] || "").trim()).map(f => f.label),
] : [];
const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0;
const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
? domains.find((d) =>
d.subdomain === emailConfig.managedSubdomain
&& d.senderLocalPart === emailConfig.managedSenderLocalPart
)?.domainId
// Managed requires a staged domain selection before it can be saved.
const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0
// For managed, also wait until the domains list has finished loading: a just-staged
// domain (e.g. one added via the setup dialog, which kicks off refreshDomains) may not
// be in `domains` yet, and saving early would spuriously fail with "Selected domain is
// no longer available". loadingDomains flips true synchronously when a refresh starts.
&& (serverType !== "managed" || (draftManagedDomainId != null && !loadingDomains));
const draftManagedDomain = draftManagedDomainId != null
? domains.find((d) => d.domainId === draftManagedDomainId) ?? null
: null;
return (
@ -971,14 +997,27 @@ export function DomainSettings() {
})}
</div>
{isDirty && serverType !== "managed" && (
{isDirty && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/[0.06] p-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-foreground">
<WarningDiamond className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" weight="fill" />
<span>
Unsaved changes previewing{" "}
<span className="font-semibold">{PROVIDERS.find((p) => p.value === serverType)?.label}</span>.
Changes don&apos;t take effect until you save.
{serverType === "managed" ? (
draftManagedDomain ? (
<>
Unsaved changes save to start sending from{" "}
<span className="font-semibold">{draftManagedDomain.senderLocalPart}@{draftManagedDomain.subdomain}</span>.
</>
) : (
<>Switching to a managed domain select a verified domain below, then save.</>
)
) : (
<>
Unsaved changes previewing{" "}
<span className="font-semibold">{PROVIDERS.find((p) => p.value === serverType)?.label}</span>.
Changes don&apos;t take effect until you save.
</>
)}
</span>
</div>
<div className="flex gap-2 shrink-0">
@ -1030,11 +1069,12 @@ export function DomainSettings() {
) : (
<div className="rounded-lg border border-border/60 divide-y divide-border/50 overflow-hidden">
{domains.map((d) => {
const isInUse = d.domainId === activeManagedDomainId;
const isReadyButUnused = !isInUse && (d.status === "verified" || d.status === "applied");
const isActiveSaved = d.domainId === activeManagedDomainId;
const isDraftSelected = draftManagedDomainId != null && d.domainId === draftManagedDomainId && draftManagedDomainId !== activeManagedDomainId;
const isReadyButUnused = !isActiveSaved && !isDraftSelected && (d.status === "verified" || d.status === "applied");
const isPending = d.status === "pending_dns" || d.status === "pending_verification" || d.status === "failed";
const displayStatus: ManagedDomainStatus = isInUse ? "applied" : isReadyButUnused ? "verified" : d.status;
const displayLabel = isInUse ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus];
const displayStatus: ManagedDomainStatus = isActiveSaved ? "applied" : isReadyButUnused ? "verified" : d.status;
const displayLabel = isActiveSaved ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus];
return (
<div key={d.domainId} className="flex items-center justify-between px-4 py-3 gap-3 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
@ -1044,30 +1084,41 @@ export function DomainSettings() {
{d.senderLocalPart}@{d.subdomain}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{isInUse ? "In use for this project" : "Not in use"}
{isActiveSaved ? "In use for this project" : isDraftSelected ? "Selected — save to apply" : "Not in use"}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap",
MANAGED_DOMAIN_STATUS_COLORS[displayStatus],
)}>
{displayLabel}
</span>
{isDraftSelected ? (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap text-amber-700 dark:text-amber-400 bg-amber-500/10">
Selected
</span>
) : (
<span className={cn(
"text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap",
MANAGED_DOMAIN_STATUS_COLORS[displayStatus],
)}>
{displayLabel}
</span>
)}
{isReadyButUnused && (
<DesignButton
size="sm"
variant="secondary"
onClick={() => runAsynchronouslyWithAlert(async () => {
await stackAdminApp.applyManagedEmailProvider({ domainId: d.domainId });
toast({ title: "Domain applied", description: `Sending from ${d.senderLocalPart}@${d.subdomain}.`, variant: "success" });
await refreshDomains();
})}
onClick={() => setDraftManagedDomainId(d.domainId)}
>
Use this domain
</DesignButton>
)}
{isDraftSelected && (
<DesignButton
size="sm"
variant="secondary"
onClick={() => setDraftManagedDomainId(null)}
>
Deselect
</DesignButton>
)}
{isPending && (
<DesignButton
size="sm"
@ -1083,7 +1134,7 @@ export function DomainSettings() {
View DNS
</DesignButton>
)}
{!isInUse && (
{!isActiveSaved && (
<button
type="button"
title="Remove domain"
@ -1174,6 +1225,11 @@ export function DomainSettings() {
onOpenChange={(o) => { if (!o) setDialog(null); }}
initialState={dialog?.initialState ?? null}
onCompleted={() => { runAsynchronouslyWithAlert(refreshDomains); }}
onApply={(domainId) => {
setServerType("managed");
setDraftManagedDomainId(domainId);
runAsynchronouslyWithAlert(refreshDomains);
}}
/>
<ActionDialog
@ -1185,9 +1241,11 @@ export function DomainSettings() {
label: "Remove",
onClick: async () => {
if (!confirmDelete) return;
const removed = confirmDelete;
runAsynchronouslyWithAlert(async () => {
await stackAdminApp.deleteManagedEmailDomain({ resendDomainId: confirmDelete.domainId });
toast({ title: "Domain removed", description: `${confirmDelete.senderLocalPart}@${confirmDelete.subdomain} was removed.`, variant: "success" });
await stackAdminApp.deleteManagedEmailDomain({ resendDomainId: removed.domainId });
toast({ title: "Domain removed", description: `${removed.senderLocalPart}@${removed.subdomain} was removed.`, variant: "success" });
if (draftManagedDomainId === removed.domainId) setDraftManagedDomainId(null);
await refreshDomains();
});
},

View File

@ -529,6 +529,7 @@ it("starts nested cross-domain auth from the target domain", async ({ expect })
state: "nested-state",
codeChallenge: "nested-code-challenge",
});
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
globalThis.document = createMockDocument();
globalThis.window = {
@ -559,6 +560,74 @@ it("starts nested cross-domain auth from the target domain", async ({ expect })
});
});
it("carries hosted sign-in return state on the nested OAuth redirect URI", async ({ expect }) => {
await withHostedDomainSuffix(async () => {
const projectId = "14141414-1414-4414-8414-141414141414";
const clientApp = createClientApp(projectId);
const sourceRefreshTokenId = "source-session";
const handoffState = "state-from-hosted-sign-in";
const handoffCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
const returnToAppUrl = `${localRedirectUrl}/handler/sign-in`;
const redirectBackUrl = new URL(`${localRedirectUrl}/handler/sign-in`);
redirectBackUrl.searchParams.set("hexclave_cross_domain_auth", "1");
redirectBackUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
redirectBackUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
redirectBackUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", returnToAppUrl);
const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
currentUrl.searchParams.set("after_auth_return_to", redirectBackUrl.toString());
currentUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", returnToAppUrl);
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", sourceRefreshTokenId);
currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", `${localRedirectUrl}/handler/sign-in`);
const expectedAfterCallbackRedirectUrl = new URL(currentUrl);
expectedAfterCallbackRedirectUrl.searchParams.delete("stack_nested_cross_domain_auth_refresh_token_id");
expectedAfterCallbackRedirectUrl.searchParams.delete("stack_nested_cross_domain_auth_callback_url");
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
let redirectedUrl = "";
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
state: "nested-state",
codeChallenge: "nested-code-challenge",
});
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
globalThis.document = createMockDocument();
globalThis.window = {
location: {
href: currentUrl.toString(),
replace: (url: string) => {
redirectedUrl = url;
throw new Error("INTENTIONAL_TEST_ABORT");
},
},
} as any;
try {
await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
}
const redirectUrl = new URL(redirectedUrl);
expect(redirectUrl.pathname).toBe(new URL(`${localRedirectUrl}/handler/sign-in`).pathname);
expect(redirectUrl.searchParams.get("after_callback_redirect_url")).toBe(expectedAfterCallbackRedirectUrl.toString());
const redirectUri = new URL(redirectUrl.searchParams.get("redirect_uri") ?? "");
expect(redirectUri.origin).toBe(`https://${projectId}.example-stack-hosted.test`);
expect(redirectUri.pathname).toBe("/handler/sign-in");
expect(redirectUrl.searchParams.get("state")).toBe("nested-state");
expect(redirectUrl.searchParams.get("code_challenge")).toBe("nested-code-challenge");
expect(redirectUri.searchParams.get("after_auth_return_to")).toBe(redirectBackUrl.toString());
expect(redirectUri.searchParams.get("hexclave_cross_domain_state")).toBe(handoffState);
expect(redirectUri.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(handoffCodeChallenge);
expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe(returnToAppUrl);
});
});
it("continues nested cross-domain auth on the source domain", async ({ expect }) => {
await withHostedDomainSuffix(async () => {
const projectId = "88888888-8888-4888-8888-888888888888";

View File

@ -7,7 +7,7 @@
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rimraf .next && rimraf node_modules",
"dev": "NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT=${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}42 pnpm -w run cli -- dev --config-file=./hexclave.config.ts -- pnpm --dir examples/demo run dev:inner",
"dev": "NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT=${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}42 node scripts/dev-with-retry.mjs",
"dev:inner": "next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}03",
"build": "next build",
"start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}03",

View File

@ -0,0 +1,119 @@
#!/usr/bin/env node
// Resilient wrapper for the demo dev command.
//
// The demo dev flow runs `pnpm -w run cli`, which internally builds the CLI
// package (and its dependency, dashboard build:rde-standalone). If that build
// fails, this process would normally exit, and because the root dev script uses
// `concurrently -k`, the entire dev server would die.
//
// This wrapper catches non-zero exits and watches for file changes in the
// dashboard and packages directories before retrying, so a transient build
// failure doesn't tear down the whole dev server.
import { spawn } from "node:child_process";
import { watch } from "node:fs";
import { join, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
const scriptDir = import.meta.dirname;
const demoRoot = resolve(scriptDir, "..");
const repoRoot = resolve(demoRoot, "../..");
const LOG_PREFIX = "[Hexclave dev-retry] ";
const RETRY_DEBOUNCE_MS = 2_000;
function log(message) {
console.error(`${LOG_PREFIX}${message}`);
}
function runCliDev() {
return new Promise((resolvePromise, reject) => {
const child = spawn("pnpm", [
"-w", "run", "cli", "--",
"dev",
"--config-file=./hexclave.config.ts",
"--",
"pnpm", "--dir", "examples/demo", "run", "dev:inner",
], {
stdio: "inherit",
env: process.env,
});
let signalled = false;
const forwardSigint = () => { signalled = true; child.kill("SIGINT"); };
const forwardSigterm = () => { signalled = true; child.kill("SIGTERM"); };
process.on("SIGINT", forwardSigint);
process.on("SIGTERM", forwardSigterm);
child.on("close", (code) => {
process.off("SIGINT", forwardSigint);
process.off("SIGTERM", forwardSigterm);
resolvePromise({ code: code ?? 1, signalled });
});
child.on("error", (err) => {
process.off("SIGINT", forwardSigint);
process.off("SIGTERM", forwardSigterm);
reject(err);
});
});
}
function waitForFileChanges() {
return new Promise((resolvePromise) => {
const watchDirs = [
join(repoRoot, "apps", "dashboard"),
join(repoRoot, "packages"),
];
const watchers = [];
let resolved = false;
const done = () => {
if (resolved) return;
resolved = true;
for (const w of watchers) {
try { w.close(); } catch { /* ignore */ }
}
resolvePromise();
};
for (const dir of watchDirs) {
try {
const w = watch(dir, { recursive: true }, done);
w.on("error", () => { /* ignore watch errors */ });
watchers.push(w);
} catch {
// directory might not exist yet
}
}
// Fallback: if no watchers could be set up, resolve after a timeout so we
// don't block forever.
if (watchers.length === 0) {
log("Could not set up file watchers. Will retry after a delay.");
setTimeout(done, 10_000);
}
});
}
async function main() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const { code, signalled } = await runCliDev();
if (signalled || code === 0) {
process.exit(code);
}
log(`Dev command exited with code ${code}. Watching for file changes before retrying...`);
await waitForFileChanges();
log(`Change detected. Retrying in ${RETRY_DEBOUNCE_MS / 1000}s...`);
await sleep(RETRY_DEBOUNCE_MS);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -5,7 +5,7 @@ import { FilterUndefined, filterUndefined } from "@hexclave/shared/dist/utils/ob
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { getRelativePart } from "@hexclave/shared/dist/utils/urls";
import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useSyncExternalStore } from 'react';
/* IF_PLATFORM react
import { useRef } from 'react';
// END_PLATFORM */
@ -225,6 +225,7 @@ function renderComponent(props: {
export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps> & { location?: string }) {
// Use hooks to get app
const stackApp = useStackApp();
const clientOrigin = useClientOriginAfterHydration();
// IF_PLATFORM next
const pathname = usePathname();
@ -270,7 +271,7 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
return !isLocalHandlerUrlTarget({
targetUrl: url,
handlerPath,
currentOrigin: typeof window === "undefined" ? undefined : window.location.origin,
currentOrigin: clientOrigin,
});
};
@ -362,3 +363,13 @@ function filterUndefinedINU<T extends {}>(value: T | undefined): FilterUndefined
function toAbsoluteOrRelativeRedirectTarget(url: URL): string {
return url.origin === "http://example.com" ? getRelativePart(url) : url.toString();
}
function useClientOriginAfterHydration(): string | undefined {
// The first hydrated render must match SSR. After hydration, React re-checks
// the snapshot and lets us distinguish same-path cross-origin handler URLs.
return useSyncExternalStore(
() => () => {},
() => window.location.origin,
() => undefined,
);
}

View File

@ -1,6 +1,22 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { StackClientApp } from "../interfaces/client-app";
function createMockDocument(): Document {
const cookieJar = new Map<string, string>();
return {
get cookie() {
return [...cookieJar.entries()].map(([key, value]) => `${key}=${value}`).join("; ");
},
set cookie(str: string) {
const [nameValue] = str.split(";");
const eqIndex = nameValue.indexOf("=");
if (eqIndex < 0) return;
cookieJar.set(nameValue.slice(0, eqIndex).trim(), nameValue.slice(eqIndex + 1).trim());
},
createElement: () => ({}),
} as any;
}
describe("StackClientApp cross-domain auth", () => {
it("uses the fresh post-auth refresh token when minting a cross-domain handoff", async () => {
const clientApp = new StackClientApp({
@ -58,4 +74,64 @@ describe("StackClientApp cross-domain auth", () => {
expect(capturedRefreshTokens).toEqual(["fresh-refresh-token"]);
});
it("uses a fresh nested OAuth state while preserving the outer cross-domain return state", async () => {
const projectId = "00000000-0000-4000-8000-000000000002";
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId,
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "window",
urls: {
default: { type: "hosted" },
},
noAutomaticPrefetch: true,
});
const outerState = "outer-cross-domain-state";
const outerCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
currentUrl.searchParams.set("after_auth_return_to", `https://demo.stack-auth.com/?hexclave_cross_domain_auth=1&hexclave_cross_domain_state=${outerState}`);
currentUrl.searchParams.set("hexclave_cross_domain_state", outerState);
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", outerCodeChallenge);
currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://demo.stack-auth.com/");
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-session");
currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/");
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
let redirectedUrl = "";
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
vi.spyOn(clientApp as any, "_getNestedCrossDomainAuthParamsForRedirect").mockResolvedValue({
state: "fresh-nested-state",
codeChallenge: "fresh-nested-code-challenge",
});
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
globalThis.document = createMockDocument();
globalThis.window = {
location: {
href: currentUrl.toString(),
replace: (url: string) => {
redirectedUrl = url;
throw new Error("INTENTIONAL_TEST_ABORT");
},
},
} as any;
try {
await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
}
const redirectUrl = new URL(redirectedUrl);
expect(redirectUrl.searchParams.get("state")).toBe("fresh-nested-state");
expect(redirectUrl.searchParams.get("code_challenge")).toBe("fresh-nested-code-challenge");
const redirectUri = new URL(redirectUrl.searchParams.get("redirect_uri") ?? "");
expect(redirectUri.searchParams.get("hexclave_cross_domain_state")).toBe(outerState);
expect(redirectUri.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(outerCodeChallenge);
expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://demo.stack-auth.com/");
});
});

View File

@ -947,10 +947,25 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const afterCallbackRedirectUrl = new URL(currentUrl);
afterCallbackRedirectUrl.searchParams.delete(nestedCrossDomainAuthQueryParams.refreshTokenId);
afterCallbackRedirectUrl.searchParams.delete(nestedCrossDomainAuthQueryParams.callbackUrl);
const { state: newState, codeChallenge: newCodeChallenge } = await this._getCrossDomainHandoffParamsForRedirect(currentUrl);
const { state: newState, codeChallenge: newCodeChallenge } = await this._getNestedCrossDomainAuthParamsForRedirect();
const nestedRedirectUri = new URL(this._getOAuthCallbackRedirectUri(), currentUrl);
nestedRedirectUri.searchParams.delete(nestedCrossDomainAuthQueryParams.refreshTokenId);
nestedRedirectUri.searchParams.delete(nestedCrossDomainAuthQueryParams.callbackUrl);
for (const param of [
"after_auth_return_to",
crossDomainAuthQueryParams.marker,
crossDomainAuthQueryParams.state,
crossDomainAuthQueryParams.codeChallenge,
crossDomainAuthQueryParams.afterCallbackRedirectUrl,
]) {
const value = currentUrl.searchParams.get(param);
if (value != null && !nestedRedirectUri.searchParams.has(param)) {
nestedRedirectUri.searchParams.set(param, value);
}
}
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.refreshTokenId, refreshTokenId);
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.redirectUri, new URL(this._getOAuthCallbackRedirectUri(), currentUrl).toString());
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.redirectUri, nestedRedirectUri.toString());
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.state, newState);
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.codeChallenge, newCodeChallenge);
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.codeChallengeMethod, "S256");
@ -959,6 +974,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
return true;
}
protected async _getNestedCrossDomainAuthParamsForRedirect(): Promise<CrossDomainHandoffParams> {
return await saveVerifierAndState();
}
/**
* Cloudflare workers does not allow use of randomness on the global scope (on which the Stack app is probably
* initialized). For that reason, we generate the unique identifier lazily when it is first needed instead of in the