Ghostlight/web/src/heimdall-access-adapter.ts
Meta Vens e42e1c1b31 Finish browser sign-in by attempt id, and on returning to the tab
Heimdall's Discord callback issues a random completion code distinct from
the attempt id, but the vendored adapter required the two to be equal and
rejected every real return as a malformed witness, so no player could sign
in. Re-vendored from Heimdall e8e2832, which completes by attempt id and
never reads the completion code; the test fixture issues a distinct code as
the server does.

A player who finishes Discord elsewhere and switches back to this tab never
reloads it, so the load-time resume never ran. The page now reruns the same
resume when it becomes visible with an attempt in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:51:20 +02:00

139 lines
5.8 KiB
TypeScript

// Vendored from GameCult/Heimdall commit e8e2832, plugin
// gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the
// adapter because its release must remain self-contained.
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
export interface HeimdallAccessResumeTarget {
complete(handle: string): Promise<void>;
}
export interface HeimdallAccessResumeOptions {
appSlug: string;
}
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
return {
pluginId: "gamecult.heimdall.access",
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
schemas: ["heimdall.access_gate_state.v1"],
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
return value;
},
renderComponent(
component: { id?: string },
props: Record<string, unknown>,
): HTMLElement {
const view = document.createElement("section");
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
if (component.id) view.id = component.id;
view.setAttribute("role", "status");
view.setAttribute("aria-live", "polite");
const title = document.createElement("h2");
title.textContent = String(props.title || "Access");
const detail = document.createElement("p");
detail.textContent = String(props.detail || "");
view.append(title, detail);
const displayName = String(props.displayName || "");
if (displayName) {
const identity = document.createElement("p");
identity.className = "heimdall-identity";
identity.textContent = displayName;
view.append(identity);
}
return view;
},
async consumeCommandResult(
pluginPayload: { schemaId: string; payload: Record<string, unknown> },
): Promise<void> {
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") {
const handle = String(pluginPayload.payload.handle || "");
const navigation = object(pluginPayload.payload.navigation);
const url = new URL(String(navigation.url || ""));
const allowedOrigins = Array.isArray(navigation.allowedOrigins)
? navigation.allowedOrigins.filter((value): value is string => typeof value === "string")
: [];
if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) {
throw new Error("Heimdall returned an unsafe authentication navigation receipt.");
}
sessionStorage.setItem(ATTEMPT_KEY, handle);
window.location.assign(url.toString());
return;
}
if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
const status = String(pluginPayload.payload.status || "");
if (status === "authenticated" || status === "denied" || status === "expired") {
sessionStorage.removeItem(ATTEMPT_KEY);
}
}
},
};
}
export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
});
export async function resumeHeimdallAccess(
target: HeimdallAccessResumeTarget,
options: HeimdallAccessResumeOptions,
): Promise<boolean> {
const returned = readHeimdallBrowserReturn(window.location.href, options.appSlug);
const storedHandle = sessionStorage.getItem(ATTEMPT_KEY);
if (returned) {
clearHeimdallBrowserReturn();
if (returned.status === "error") {
sessionStorage.removeItem(ATTEMPT_KEY);
throw new Error(returned.message);
}
if (storedHandle && storedHandle !== returned.handle) {
sessionStorage.removeItem(ATTEMPT_KEY);
throw new Error("Heimdall returned an authentication attempt other than the one this browser started.");
}
sessionStorage.setItem(ATTEMPT_KEY, returned.handle);
}
const handle = returned?.status === "success" ? returned.handle : storedHandle;
if (!handle) return false;
await target.complete(handle);
return true;
}
export function readHeimdallBrowserReturn(
href: string,
appSlug: string,
): { status: "success"; handle: string } | { status: "error"; message: string } | undefined {
const url = new URL(href);
const parameters = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
const status = parameters.get("heimdall_status");
if (!status) return undefined;
if (parameters.get("heimdall_handoff_kind") !== "browser_completion"
|| parameters.get("heimdall_app_slug") !== appSlug) {
return { status: "error", message: "Heimdall returned an authentication result for another application or handoff mode." };
}
if (status !== "success") {
return {
status: "error",
message: parameters.get("heimdall_error_description")
|| parameters.get("heimdall_error")
|| "Heimdall authentication failed.",
};
}
// The attempt id is the handle the app's backend completes over Heimdall's
// private command plane. The completion code in the same fragment is a
// distinct random secret for the HTTP redeem route; this adapter neither
// needs nor forwards it.
const attemptId = parameters.get("heimdall_attempt_id") || "";
if (!attemptId) {
return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." };
}
return { status: "success", handle: attemptId };
}
function clearHeimdallBrowserReturn(): void {
history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
}
function object(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}