diff --git a/packages/client/src/components/Login/Normal.tsx b/packages/client/src/components/Login/Normal.tsx
index f2ee23db5c2667626e4edb8a8153a5eff2d374c7..29838792aac4ea55ab3c0599808106ffd6edf982 100644
--- a/packages/client/src/components/Login/Normal.tsx
+++ b/packages/client/src/components/Login/Normal.tsx
@@ -3,13 +3,16 @@ import { type DialogData } from "../../contexts/DialogContext";
import { useCallback, useState } from "react";
import { RECOMMENDED_INSTANCES } from "../../lib/constants";
import { Button } from "../core/Button";
+import { ProgrammingDevSignup } from "./ProgrammingDevSignup";
export const Normal = ({
state,
}: {
state: DialogData<"LOGIN_INFO"> & { mode: "NORMAL" };
}) => {
- const [step, setStep] = useState<"WELCOME" | "NEW">("WELCOME");
+ const [step, setStep] = useState<
+ "WELCOME" | "NEW" | "PROGRAMMING_DEV_SIGNUP"
+ >("WELCOME");
const WelcomeStep = useCallback(
() => (
@@ -62,7 +65,7 @@ export const Normal = ({
>
),
- []
+ [],
);
const NewStep = useCallback(
@@ -96,9 +99,15 @@ export const Normal = ({
Return to this page after registering an account
>
),
- []
+ [],
);
+ if (step === "PROGRAMMING_DEV_SIGNUP") {
+ return (
+ setStep("NEW")} />
+ );
+ }
+
return (
<>
@@ -108,7 +117,9 @@ export const Normal = ({
{step === "WELCOME" && (
<>
-
+
diff --git a/packages/client/src/components/Login/ProgrammingDevSignup.tsx b/packages/client/src/components/Login/ProgrammingDevSignup.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..541e3631a107fe4e935bdd90706a13eac7bac414
--- /dev/null
+++ b/packages/client/src/components/Login/ProgrammingDevSignup.tsx
@@ -0,0 +1,549 @@
+import { Alert, Input, Link, ModalBody, ModalFooter, Spinner } from "@heroui/react";
+import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Button } from "../core/Button";
+
+type SignupView =
+ | "FORM"
+ | "REGISTERING"
+ | "WAITING_ACCOUNT_CREATION"
+ | "SENDING_HANDOFF"
+ | "WAITING_HANDOFF_REPLY"
+ | "REDIRECTING"
+ | "ERROR"
+ | "TIMED_OUT";
+
+type MetadataResponse = {
+ success: true;
+ instance: {
+ captchaEnabled: boolean;
+ registrationMode?: string;
+ requireEmailVerification: boolean;
+ };
+ handoff: {
+ enabled: boolean;
+ account?: string;
+ };
+};
+
+type Captcha = {
+ uuid: string;
+ png: string;
+ wav?: string;
+};
+
+type CaptchaResponse = {
+ success: true;
+ captcha: Captcha;
+};
+
+type RegisterResponse = {
+ success: true;
+ state: "ACCOUNT_READY" | "WAITING_ACCOUNT_CREATION";
+};
+
+type CheckResponse =
+ | {
+ success: true;
+ state: "WAITING_ACCOUNT_CREATION" | "WAITING_HANDOFF_REPLY";
+ }
+ | {
+ success: true;
+ state: "HANDOFF_READY";
+ redirect: string;
+ };
+
+type ErrorResponse = {
+ success: false;
+ error?: string;
+ error_message?: string;
+};
+
+const POLL_INTERVALS_MS = [3_000, 5_000, 10_000, 20_000, 30_000];
+const POLL_TIMEOUT_MS = 15 * 60 * 1_000;
+
+export const ProgrammingDevSignup = ({
+ loginUrl,
+ onBack,
+}: {
+ loginUrl: string;
+ onBack: () => void;
+}) => {
+ const [view, setView] = useState("FORM");
+ const [metadata, setMetadata] = useState();
+ const [captcha, setCaptcha] = useState();
+ const [loadingMetadata, setLoadingMetadata] = useState(true);
+ const [loadingCaptcha, setLoadingCaptcha] = useState(false);
+ const [error, setError] = useState();
+ const [registered, setRegistered] = useState(false);
+
+ const [username, setUsername] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [passwordVerify, setPasswordVerify] = useState("");
+ const [captchaAnswer, setCaptchaAnswer] = useState("");
+
+ const passwordRef = useRef(password);
+ const pollTimer = useRef(undefined);
+ const pollAttempt = useRef(0);
+ const pollStartedAt = useRef(undefined);
+
+ useEffect(() => {
+ passwordRef.current = password;
+ }, [password]);
+
+ const clearPollTimer = useCallback(() => {
+ if (typeof pollTimer.current !== "undefined") {
+ window.clearTimeout(pollTimer.current);
+ pollTimer.current = undefined;
+ }
+ }, []);
+
+ const blockingProblem = useMemo(() => {
+ if (!metadata) return undefined;
+ if (metadata.handoff.enabled !== true || !metadata.handoff.account) {
+ return "Fediverse Auth handoff is not available right now.";
+ }
+ if (metadata.instance.registrationMode !== "RequireApplication") {
+ return "programming.dev changed its registration flow, so automatic signup is paused.";
+ }
+ if (metadata.instance.requireEmailVerification) {
+ return "programming.dev now requires email verification, so this signup flow needs to be updated first.";
+ }
+ if (!metadata.instance.captchaEnabled) {
+ return "programming.dev changed its captcha settings, so automatic signup is paused.";
+ }
+ return undefined;
+ }, [metadata]);
+
+ const reloadCaptcha = useCallback(async () => {
+ setLoadingCaptcha(true);
+ setError(undefined);
+
+ try {
+ const data = await requestJson(
+ "/api/signup/programming-dev/captcha",
+ );
+ setCaptcha(data.captcha);
+ setCaptchaAnswer("");
+ } catch (e) {
+ setError(getErrorMessage(e, "Could not load captcha."));
+ setView("ERROR");
+ } finally {
+ setLoadingCaptcha(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const load = async () => {
+ setLoadingMetadata(true);
+ setError(undefined);
+
+ try {
+ const data = await requestJson(
+ "/api/signup/programming-dev/metadata",
+ );
+ if (cancelled) return;
+
+ setMetadata(data);
+ if (data.instance.captchaEnabled) {
+ await reloadCaptcha();
+ }
+ } catch (e) {
+ if (!cancelled) {
+ setError(getErrorMessage(e, "Could not load signup metadata."));
+ setView("ERROR");
+ }
+ } finally {
+ if (!cancelled) setLoadingMetadata(false);
+ }
+ };
+
+ void load();
+
+ return () => {
+ cancelled = true;
+ clearPollTimer();
+ void fetch("/api/signup/programming-dev", { method: "DELETE" });
+ };
+ }, [clearPollTimer, reloadCaptcha]);
+
+ const pollCheck = useCallback(async () => {
+ clearPollTimer();
+
+ if (!pollStartedAt.current) pollStartedAt.current = Date.now();
+
+ if (Date.now() - pollStartedAt.current > POLL_TIMEOUT_MS) {
+ setPassword("");
+ setView("TIMED_OUT");
+ return;
+ }
+
+ if (!passwordRef.current) {
+ setView("TIMED_OUT");
+ return;
+ }
+
+ setView((current) =>
+ current === "WAITING_ACCOUNT_CREATION" ||
+ current === "WAITING_HANDOFF_REPLY"
+ ? current
+ : "SENDING_HANDOFF",
+ );
+
+ try {
+ const data = await requestJson(
+ "/api/signup/programming-dev/check",
+ {
+ method: "POST",
+ body: JSON.stringify({ password: passwordRef.current }),
+ },
+ );
+
+ if (data.state === "HANDOFF_READY") {
+ clearPollTimer();
+ setView("REDIRECTING");
+ window.location.assign(data.redirect);
+ return;
+ }
+
+ setView(data.state);
+
+ const attempt = pollAttempt.current++;
+ const delay =
+ attempt < POLL_INTERVALS_MS.length
+ ? POLL_INTERVALS_MS[attempt]
+ : 60_000;
+ pollTimer.current = window.setTimeout(() => void pollCheck(), delay);
+ } catch (e) {
+ setError(getErrorMessage(e, "Could not check the account yet."));
+ setView("ERROR");
+ }
+ }, [clearPollTimer]);
+
+ const startPolling = useCallback(() => {
+ pollAttempt.current = 0;
+ pollStartedAt.current = Date.now();
+ void pollCheck();
+ }, [pollCheck]);
+
+ const cancelSignup = useCallback(() => {
+ clearPollTimer();
+ void fetch("/api/signup/programming-dev", { method: "DELETE" });
+ onBack();
+ }, [clearPollTimer, onBack]);
+
+ const submitRegister = useCallback(
+ async (event: FormEvent) => {
+ event.preventDefault();
+
+ if (blockingProblem) {
+ setError(blockingProblem);
+ setView("ERROR");
+ return;
+ }
+
+ if (password !== passwordVerify) {
+ setError("Passwords do not match.");
+ setView("ERROR");
+ return;
+ }
+
+ if (!captcha) {
+ setError("Captcha is not ready yet.");
+ setView("ERROR");
+ return;
+ }
+
+ setView("REGISTERING");
+ setError(undefined);
+
+ try {
+ const data = await requestJson(
+ "/api/signup/programming-dev/register",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ username,
+ email: email || undefined,
+ password,
+ passwordVerify,
+ captchaUuid: captcha.uuid,
+ captchaAnswer,
+ }),
+ },
+ );
+
+ setRegistered(true);
+ setView(
+ data.state === "ACCOUNT_READY"
+ ? "SENDING_HANDOFF"
+ : "WAITING_ACCOUNT_CREATION",
+ );
+ startPolling();
+ } catch (e) {
+ setError(getErrorMessage(e, "Could not create the account."));
+ setView("ERROR");
+ void reloadCaptcha();
+ }
+ },
+ [
+ blockingProblem,
+ captcha,
+ captchaAnswer,
+ email,
+ password,
+ passwordVerify,
+ reloadCaptcha,
+ startPolling,
+ username,
+ ],
+ );
+
+ const resumePolling = useCallback(() => {
+ if (!password) {
+ setError("Enter the password for the programming.dev account to keep checking.");
+ return;
+ }
+
+ setError(undefined);
+ setView("SENDING_HANDOFF");
+ startPolling();
+ }, [password, startPolling]);
+
+ const busy =
+ view === "REGISTERING" ||
+ view === "SENDING_HANDOFF" ||
+ view === "WAITING_ACCOUNT_CREATION" ||
+ view === "WAITING_HANDOFF_REPLY" ||
+ view === "REDIRECTING";
+
+ return (
+ <>
+
+
+
Create a Fediverse account
+
+ Canvas will create a programming.dev account, then login through
+ Fediverse Auth.
+
+
+
+ {loadingMetadata && (
+
+ Loading programming.dev signup...
+
+ )}
+
+ {error && }
+ {blockingProblem && !error && (
+
+ )}
+
+ {(view === "FORM" || (!registered && view === "ERROR") || view === "REGISTERING") && (
+
+ )}
+
+ {busy && view !== "REGISTERING" && (
+
+
+ {getBusyTitle(view)}
+
+ )}
+
+ {view === "ERROR" && registered && (
+
+
+
+
+
+
+
+ )}
+
+ {view === "TIMED_OUT" && (
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {!busy && (
+
+ )}
+ {busy && }
+
+ >
+ );
+};
+
+function getBusyTitle(view: SignupView): string {
+ switch (view) {
+ case "WAITING_ACCOUNT_CREATION":
+ return "Waiting for account creation...";
+ case "SENDING_HANDOFF":
+ return "Sending Fediverse Auth handoff...";
+ case "WAITING_HANDOFF_REPLY":
+ return "Waiting for Fediverse Auth reply...";
+ case "REDIRECTING":
+ return "Redirecting...";
+ default:
+ return "Working...";
+ }
+}
+
+
+
+async function requestJson(url: string, init: RequestInit = {}): Promise {
+ const headers = new Headers(init.headers);
+ if (init.body && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const response = await fetch(url, {
+ ...init,
+ credentials: "same-origin",
+ headers,
+ });
+ const data = (await response.json().catch(() => ({}))) as T | ErrorResponse;
+
+ if (!response.ok || (isObject(data) && data.success === false)) {
+ throw new Error(getErrorMessage(data, response.statusText));
+ }
+
+ return data as T;
+}
+
+function getErrorMessage(error: unknown, fallback: string): string {
+ if (error instanceof Error && error.message) return error.message;
+ if (isObject(error)) {
+ const message = error.error_message || error.error;
+ if (typeof message === "string" && message) return message;
+ }
+ return fallback;
+}
+
+function isObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/packages/server/src/api/client/index.ts b/packages/server/src/api/client/index.ts
index 4b77e8d487682c44bd02010e4213bace972bd3ee..dc5d508752ba442c57e1db9ea174a4c492e5ac70 100644
--- a/packages/server/src/api/client/index.ts
+++ b/packages/server/src/api/client/index.ts
@@ -4,6 +4,7 @@ import { OpenIDController } from "../../controllers/OpenIDController";
import { prisma } from "../../lib/prisma";
import { AuthEndpoints } from "./auth";
import { CanvasEndpoints } from "./canvas";
+import { ProgrammingDevSignupEndpoints } from "./programming_dev_signup";
import { ReportEndpoints } from "./reports";
import SentryRouter from "./sentry";
import { UserEndpoints } from "./user";
@@ -20,6 +21,7 @@ app.use(SentryRouter);
app.use(new AuthEndpoints().router);
app.use("/reports", new ReportEndpoints().router);
+app.use("/signup/programming-dev", new ProgrammingDevSignupEndpoints().router);
app.use("/canvas", new CanvasEndpoints().router);
app.use("/user", new UserEndpoints().router);
app.use("/share", ShareEndpoints);
diff --git a/packages/server/src/api/client/programming_dev_signup.ts b/packages/server/src/api/client/programming_dev_signup.ts
new file mode 100644
index 0000000000000000000000000000000000000000..faf14b4d3ba8ceab7d7dac95e7cd452728932971
--- /dev/null
+++ b/packages/server/src/api/client/programming_dev_signup.ts
@@ -0,0 +1,693 @@
+import { z } from "zod/v4";
+
+import {
+ InvalidAuthMode,
+ OpenIDController,
+} from "../../controllers/OpenIDController";
+import { Router } from "../lib/router";
+
+const PROGRAMMING_DEV_ORIGIN = "https://programming.dev";
+const PROGRAMMING_DEV_API = `${PROGRAMMING_DEV_ORIGIN}/api/v3`;
+const REQUIRED_APPLICATION_ANSWER = "I agree to the TOS and Privacy Policy";
+const HANDOFF_REPLY_GRACE_MS = 30_000;
+const HANDOFF_RESEND_AFTER_MS = 60_000;
+
+const registerSchema = z
+ .object({
+ username: z.string().trim().min(3).max(50),
+ password: z.string().min(10),
+ passwordVerify: z.string().min(10),
+ email: z.string().email().optional().or(z.literal("")),
+ captchaUuid: z.string().min(1),
+ captchaAnswer: z.string().trim().min(1),
+ })
+ .refine((data) => data.password === data.passwordVerify, {
+ message: "Passwords do not match",
+ path: ["passwordVerify"],
+ });
+
+const checkSchema = z.object({
+ password: z.string().min(1),
+});
+
+type LemmyErrorResponse = {
+ error?: string;
+ message?: string;
+};
+
+type LemmyLoginResponse = {
+ jwt?: string;
+};
+
+type LemmySiteResponse = {
+ site_view: {
+ local_site: {
+ captcha_enabled?: boolean;
+ registration_mode?: string;
+ require_email_verification?: boolean;
+ };
+ };
+ my_user?: {
+ local_user_view: {
+ local_user: {
+ accepted_application: boolean;
+ };
+ person: {
+ banned: boolean;
+ };
+ };
+ };
+};
+
+type LemmyCaptchaResponse = {
+ ok?: {
+ png: string;
+ wav: string;
+ uuid: string;
+ };
+};
+
+type LemmyResolveObjectResponse = {
+ person?: {
+ person: {
+ id: number;
+ actor_id: string;
+ };
+ };
+};
+
+type LemmyPrivateMessagesResponse = {
+ private_messages: Array<{
+ private_message: {
+ content: string;
+ published: string;
+ deleted: boolean;
+ };
+ creator: {
+ id: number;
+ actor_id: string;
+ };
+ }>;
+};
+
+type FediverseAuthMetadata = {
+ fediverse?: {
+ account?: string;
+ };
+ handoff?:
+ | {
+ enabled: true;
+ token: string;
+ }
+ | {
+ enabled: false;
+ };
+};
+
+type HandoffActor = {
+ id: number;
+ actorId: string;
+};
+
+class LemmyRequestError extends Error {
+ constructor(
+ readonly status: number,
+ readonly body: LemmyErrorResponse,
+ ) {
+ super(body.error || body.message || `Lemmy request failed (${status})`);
+ }
+}
+
+export class ProgrammingDevSignupEndpoints extends Router {
+ @Router.handler("get", "/metadata")
+ @Router.ratelimit("HIGH")
+ async metadata(_req: Router.Request, res: Router.Response) {
+ try {
+ const [site, handoff] = await Promise.all([
+ lemmyRequest("/site"),
+ fetchFediAuthMetadata(),
+ ]);
+
+ res.json({
+ success: true,
+ instance: {
+ captchaEnabled: Boolean(site.site_view.local_site.captcha_enabled),
+ registrationMode: site.site_view.local_site.registration_mode,
+ requireEmailVerification: Boolean(
+ site.site_view.local_site.require_email_verification,
+ ),
+ },
+ handoff: {
+ enabled: handoff.handoff?.enabled === true,
+ account: handoff.fediverse?.account,
+ },
+ });
+ } catch (e) {
+ res.status(502).json({
+ success: false,
+ error: "metadata_fetch_failed",
+ error_message: e instanceof Error ? e.message : "Unknown error",
+ });
+ }
+ }
+
+ @Router.handler("get", "/captcha")
+ @Router.ratelimit("HIGH")
+ async captcha(_req: Router.Request, res: Router.Response) {
+ try {
+ const data = await lemmyRequest(
+ "/user/get_captcha",
+ );
+
+ if (!data.ok) {
+ res.status(502).json({
+ success: false,
+ error: "captcha_unavailable",
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ captcha: data.ok,
+ });
+ } catch (e) {
+ res.status(502).json({
+ success: false,
+ error: "captcha_fetch_failed",
+ error_message: e instanceof Error ? e.message : "Unknown error",
+ });
+ }
+ }
+
+ @Router.handler("post", "/register")
+ @Router.ratelimit("HIGH")
+ @Router.body(registerSchema)
+ async register(
+ req: Router.Request>,
+ res: Router.Response,
+ ) {
+ const username = req.body.username.trim();
+ const email = req.body.email?.trim() || undefined;
+ const captchaAnswer = req.body.captchaAnswer.trim();
+
+ try {
+ const result = await lemmyRequest("/user/register", {
+ method: "POST",
+ body: {
+ username,
+ password: req.body.password,
+ password_verify: req.body.passwordVerify,
+ email,
+ captcha_uuid: req.body.captchaUuid,
+ captcha_answer: captchaAnswer,
+ answer: REQUIRED_APPLICATION_ANSWER,
+ honeypot: "",
+ },
+ });
+
+ req.session.programmingDevSignup = {
+ username,
+ registeredAt: new Date().toISOString(),
+ };
+ await saveSession(req);
+
+ res.json({
+ success: true,
+ state: result.jwt ? "ACCOUNT_READY" : "WAITING_ACCOUNT_CREATION",
+ });
+ } catch (e) {
+ const payload = normalizeLemmyError(e, "registration_failed");
+ res.status(payload.status).json(payload.body);
+ }
+ }
+
+ @Router.handler("post", "/check")
+ @Router.ratelimit("HIGH")
+ @Router.body(checkSchema)
+ async check(
+ req: Router.Request>,
+ res: Router.Response,
+ ) {
+ const signup = req.session.programmingDevSignup;
+
+ if (!signup) {
+ res.status(400).json({
+ success: false,
+ error: "signup_session_missing",
+ });
+ return;
+ }
+
+ let jwt: string;
+ try {
+ const login = await lemmyRequest("/user/login", {
+ method: "POST",
+ body: {
+ username_or_email: signup.username,
+ password: req.body.password,
+ },
+ });
+
+ if (!login.jwt) {
+ res.json({
+ success: true,
+ state: "WAITING_ACCOUNT_CREATION",
+ });
+ return;
+ }
+
+ jwt = login.jwt;
+ } catch (e) {
+ if (isPendingApprovalError(e)) {
+ res.json({
+ success: true,
+ state: "WAITING_ACCOUNT_CREATION",
+ });
+ return;
+ }
+
+ const payload = normalizeLemmyError(e, "login_failed");
+ res.status(payload.status).json(payload.body);
+ return;
+ }
+
+ let site: LemmySiteResponse;
+ try {
+ site = await lemmyRequest("/site", {
+ auth: jwt,
+ });
+ } catch (e) {
+ const payload = normalizeLemmyError(e, "account_lookup_failed");
+ res.status(payload.status).json(payload.body);
+ return;
+ }
+
+ const localUser = site.my_user?.local_user_view.local_user;
+ const person = site.my_user?.local_user_view.person;
+
+ if (!localUser || !person) {
+ res.status(502).json({
+ success: false,
+ error: "account_lookup_failed",
+ });
+ return;
+ }
+
+ if (person.banned) {
+ res.status(403).json({
+ success: false,
+ error: "account_banned",
+ });
+ return;
+ }
+
+ if (!localUser.accepted_application) {
+ res.json({
+ success: true,
+ state: "WAITING_ACCOUNT_CREATION",
+ });
+ return;
+ }
+
+ let handoffMetadata: FediverseAuthMetadata;
+ try {
+ handoffMetadata = await fetchFediAuthMetadata();
+ } catch (e) {
+ res.status(502).json({
+ success: false,
+ error: "handoff_metadata_failed",
+ error_message: e instanceof Error ? e.message : "Unknown error",
+ });
+ return;
+ }
+
+ if (
+ handoffMetadata.handoff?.enabled !== true ||
+ !handoffMetadata.handoff.token ||
+ !handoffMetadata.fediverse?.account
+ ) {
+ res.status(502).json({
+ success: false,
+ error: "handoff_unavailable",
+ });
+ return;
+ }
+
+ let actor: HandoffActor | null;
+ try {
+ actor = await getOrResolveHandoffActor(
+ req,
+ jwt,
+ handoffMetadata.fediverse.account,
+ );
+ } catch (e) {
+ const payload = normalizeLemmyError(e, "handoff_actor_lookup_failed");
+ res.status(payload.status).json(payload.body);
+ return;
+ }
+
+ if (!actor) {
+ res.status(502).json({
+ success: false,
+ error: "handoff_actor_not_found",
+ });
+ return;
+ }
+
+ let handoffMessageSentAt = parseOptionalDate(
+ req.session.programmingDevSignup?.handoffMessageSentAt,
+ );
+
+ if (!handoffMessageSentAt) {
+ try {
+ handoffMessageSentAt = await sendHandoffMessage(
+ req,
+ jwt,
+ actor,
+ handoffMetadata.handoff.token,
+ );
+ } catch (e) {
+ const payload = normalizeLemmyError(e, "handoff_message_failed");
+ res.status(payload.status).json(payload.body);
+ return;
+ }
+ }
+
+ let reply: URL | null;
+ try {
+ reply = await findHandoffReply(jwt, actor, handoffMessageSentAt);
+ } catch (e) {
+ const payload = normalizeLemmyError(e, "handoff_reply_lookup_failed");
+ res.status(payload.status).json(payload.body);
+ return;
+ }
+
+ if (!reply) {
+ if (
+ Date.now() - handoffMessageSentAt.getTime() >
+ HANDOFF_RESEND_AFTER_MS
+ ) {
+ try {
+ await sendHandoffMessage(
+ req,
+ jwt,
+ actor,
+ handoffMetadata.handoff.token,
+ );
+ } catch (e) {
+ const payload = normalizeLemmyError(e, "handoff_message_failed");
+ res.status(payload.status).json(payload.body);
+ return;
+ }
+ }
+
+ res.json({
+ success: true,
+ state: "WAITING_HANDOFF_REPLY",
+ });
+ return;
+ }
+
+ try {
+ const redirect = buildHandoffRedirect(reply);
+ res.json({
+ success: true,
+ state: "HANDOFF_READY",
+ redirect,
+ });
+ } catch (e) {
+ if (e instanceof InvalidAuthMode) {
+ res.status(400).json({
+ success: false,
+ error: "auth_mode_invalid",
+ error_message: e.message,
+ });
+ return;
+ }
+
+ res.status(500).json({
+ success: false,
+ error: "handoff_redirect_failed",
+ });
+ }
+ }
+
+ @Router.handler("delete", "/")
+ @Router.ratelimit("HIGH")
+ clear(req: Router.Request, res: Router.Response) {
+ req.session.programmingDevSignup = undefined;
+ req.session.save(() => {
+ res.json({ success: true });
+ });
+ }
+}
+
+async function getOrResolveHandoffActor(
+ req: Router.Request,
+ jwt: string,
+ handle: string,
+): Promise {
+ const existing = req.session.programmingDevSignup;
+ if (
+ existing?.handoffActorId &&
+ existing.handoffActorActorId &&
+ existing.handoffActorHandle === handle
+ ) {
+ return {
+ id: existing.handoffActorId,
+ actorId: existing.handoffActorActorId,
+ };
+ }
+
+ const resolved = await lemmyRequest(
+ "/resolve_object",
+ {
+ auth: jwt,
+ query: { q: handle },
+ },
+ );
+
+ const person = resolved.person?.person;
+ if (!person) return null;
+
+ req.session.programmingDevSignup = {
+ ...req.session.programmingDevSignup!,
+ handoffActorHandle: handle,
+ handoffActorId: person.id,
+ handoffActorActorId: person.actor_id,
+ };
+ await saveSession(req);
+
+ return {
+ id: person.id,
+ actorId: person.actor_id,
+ };
+}
+
+async function sendHandoffMessage(
+ req: Router.Request,
+ jwt: string,
+ actor: HandoffActor,
+ token: string,
+): Promise {
+ const sentAt = new Date();
+
+ await lemmyRequest("/private_message", {
+ method: "POST",
+ auth: jwt,
+ body: {
+ recipient_id: actor.id,
+ content: [
+ "Canvas is requesting a Fediverse Auth handoff for this newly-created account.",
+ "",
+ token,
+ "",
+ `Sent by Canvas${process.env.VERSION ? ` ${process.env.VERSION}` : ""}`,
+ ].join("\n"),
+ },
+ });
+
+ req.session.programmingDevSignup = {
+ ...req.session.programmingDevSignup!,
+ handoffMessageSentAt: sentAt.toISOString(),
+ };
+ await saveSession(req);
+
+ return sentAt;
+}
+
+async function findHandoffReply(
+ jwt: string,
+ actor: HandoffActor,
+ sentAt: Date,
+): Promise {
+ const messages = await lemmyRequest(
+ "/private_message/list",
+ {
+ auth: jwt,
+ query: {
+ unread_only: false,
+ creator_id: actor.id,
+ limit: 20,
+ },
+ },
+ );
+
+ const earliest = sentAt.getTime() - HANDOFF_REPLY_GRACE_MS;
+ const authOrigin = new URL(process.env.AUTH_ENDPOINT).origin;
+
+ for (const view of messages.private_messages) {
+ if (view.private_message.deleted) continue;
+ if (view.creator.actor_id !== actor.actorId) continue;
+ if (Date.parse(view.private_message.published) < earliest) continue;
+
+ const url = extractHandoffUrl(view.private_message.content);
+ if (!url) continue;
+ if (url.origin !== authOrigin) continue;
+
+ return url;
+ }
+
+ return null;
+}
+
+function parseOptionalDate(value: string | undefined): Date | null {
+ if (!value) return null;
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return null;
+
+ return date;
+}
+
+function buildHandoffRedirect(handoffUrl: URL): string {
+ const url = new URL(handoffUrl);
+ url.searchParams.set(
+ "return_uri",
+ OpenIDController.get().getAuthorizationURL(),
+ );
+ return url.toString();
+}
+
+function extractHandoffUrl(content: string): URL | null {
+ const text = content
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/<[^>]*>/g, " ");
+ const match = text.match(
+ /https?:\/\/[^\s<>'"]+\/handoff\/[0-9a-zA-Z-]+[^\s<>'"]*/,
+ );
+ if (!match) return null;
+
+ try {
+ return new URL(match[0].replace(/[),.;]+$/, ""));
+ } catch {
+ return null;
+ }
+}
+
+async function fetchFediAuthMetadata(): Promise {
+ const endpoint = new URL(
+ "/.well-known/com.sc07.fediverse-auth",
+ process.env.AUTH_ENDPOINT,
+ );
+ const response = await fetch(endpoint);
+ if (!response.ok) {
+ throw new Error(`Fediverse Auth metadata returned ${response.status}`);
+ }
+
+ return (await response.json()) as FediverseAuthMetadata;
+}
+
+async function lemmyRequest(
+ path: string,
+ options: {
+ method?: "GET" | "POST";
+ body?: Record;
+ query?: Record;
+ auth?: string;
+ } = {},
+): Promise {
+ const url = new URL(PROGRAMMING_DEV_API + path);
+
+ for (const [key, value] of Object.entries(options.query || {})) {
+ if (typeof value !== "undefined") url.searchParams.set(key, String(value));
+ }
+
+ const headers: Record = {
+ Accept: "application/json",
+ };
+
+ if (options.body) headers["Content-Type"] = "application/json";
+ if (options.auth) headers.Authorization = `Bearer ${options.auth}`;
+
+ const response = await fetch(url, {
+ method: options.method || (options.body ? "POST" : "GET"),
+ headers,
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ const data = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ throw new LemmyRequestError(response.status, data as LemmyErrorResponse);
+ }
+
+ return data as T;
+}
+
+function normalizeLemmyError(
+ error: unknown,
+ fallback: string,
+): {
+ status: number;
+ body: { success: false; error: string; error_message?: string };
+} {
+ if (error instanceof LemmyRequestError) {
+ return {
+ status: error.status,
+ body: {
+ success: false,
+ error: error.body.error || fallback,
+ error_message: error.body.message,
+ },
+ };
+ }
+
+ return {
+ status: 500,
+ body: {
+ success: false,
+ error: fallback,
+ error_message: error instanceof Error ? error.message : "Unknown error",
+ },
+ };
+}
+
+function isPendingApprovalError(error: unknown): boolean {
+ if (!(error instanceof LemmyRequestError)) return false;
+
+ const message = [error.body.error, error.body.message]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+
+ return (
+ message.includes("application") ||
+ message.includes("registration") ||
+ message.includes("not_approved") ||
+ message.includes("not approved") ||
+ message.includes("email_not_verified")
+ );
+}
+
+function saveSession(req: Router.Request): Promise {
+ return new Promise((resolve, reject) => {
+ req.session.save((err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+}
diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts
index fb954df7c12475aa49f0f9dea1f7ece62faba4a3..faf309751dc9db68c0f261dfaef594b6155dbcf2 100644
--- a/packages/server/src/types.ts
+++ b/packages/server/src/types.ts
@@ -5,6 +5,14 @@ import session from "express-session";
declare module "express-session" {
interface SessionData {
user: AuthSession;
+ programmingDevSignup?: {
+ username: string;
+ registeredAt: string;
+ handoffActorHandle?: string;
+ handoffActorId?: number;
+ handoffActorActorId?: string;
+ handoffMessageSentAt?: string;
+ };
}
}