diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts index d707ce6..cd092c4 100644 --- a/packages/graphql/src/instance.test.ts +++ b/packages/graphql/src/instance.test.ts @@ -179,6 +179,7 @@ describe("Mutation.createInstance", () => { assert.equal(members.length, 1); assert.equal(members[0]?.accountId, accountId); assert.equal(members[0]?.instanceId, instances[0]?.id); + assert.ok(members[0]?.accepted instanceof Date); }); }); diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts index f778f83..d5a2d05 100644 --- a/packages/graphql/src/instance.ts +++ b/packages/graphql/src/instance.ts @@ -267,6 +267,7 @@ builder.mutationFields((t) => ({ await tx.insert(schema.instanceMembers).values({ instanceId: instance.id, accountId: account.id, + accepted: new Date(), }); const instances = await tx.$count( schema.instanceMembers, diff --git a/packages/web/src/RelayEnvironment.ts b/packages/web/src/RelayEnvironment.ts index 8daffd5..808e421 100644 --- a/packages/web/src/RelayEnvironment.ts +++ b/packages/web/src/RelayEnvironment.ts @@ -14,47 +14,61 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { getCookie } from "@solidjs/start/http"; import { Environment, type FetchFunction, + type GraphQLResponse, Network, RecordSource, Store, } from "relay-runtime"; -import { getRequestEvent } from "solid-js/web"; -import { readSessionCookie } from "./routes/session.ts"; +const SESSION_COOKIE = "session"; +const ACCESS_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u; // oxlint-disable no-async-await -const fetchFn: FetchFunction = async (params, variables) => { - const event = getRequestEvent(); - const accessToken = readSessionCookie(event?.request); +const fetchGraphQL = async ( + query: string, + variables: Parameters[1], +): Promise => { + "use server"; + + const accessToken = getCookie(SESSION_COOKIE); const headers: Record = { "Content-Type": "application/json", }; - if (accessToken !== undefined) { + if (accessToken !== undefined && ACCESS_TOKEN_PATTERN.test(accessToken)) { headers.Authorization = `Bearer ${accessToken}`; } - const url = new URL( - "/graphql", - event?.request.url ?? globalThis.location.href, - ); + const url = new URL("/graphql", import.meta.env.VITE_BACKEND_URL); const response = await fetch(url, { method: "POST", headers, body: JSON.stringify({ - query: params.text, + query, variables, }), - credentials: "include", }); + if (!response.ok) { + throw new Error(`GraphQL request failed (${response.status})`); + } + // oxlint-disable return-await no-unsafe-return return await response.json(); }; +const fetchFn: FetchFunction = async (params, variables) => { + if (params.text == undefined || params.text == "") { + throw new Error(`Relay operation ${params.name} has no query text.`); + } + const graphQLResponse = await fetchGraphQL(params.text, variables); + return graphQLResponse; +}; + export function createRelayEnvironment() { return new Environment({ network: Network.create(fetchFn), diff --git a/packages/web/src/routes/confirm/[token].tsx b/packages/web/src/routes/confirm/[token].tsx index 36b53f5..ae05ba3 100644 --- a/packages/web/src/routes/confirm/[token].tsx +++ b/packages/web/src/routes/confirm/[token].tsx @@ -19,6 +19,7 @@ import { commitMutation, graphql } from "relay-runtime"; import { Show, createSignal, onMount } from "solid-js"; import { createRelayEnvironment } from "~/RelayEnvironment"; +import { setSessionCookie } from "~/session"; import type { CompleteLoginChallenge } from "./__generated__/CompleteLoginChallenge.graphql.ts"; @@ -31,17 +32,10 @@ const completeLoginChallengeMutation = graphql` } `; -type CompleteLogInResult = - | { - message: string; - status: "error"; - } - | { - accessToken: string; - expires: string; - message: string; - status: "success"; - }; +interface CompleteLoginResult { + message: string; + status: "error" | "success"; +} const completeLoginChallengeAction = action( async ({ token, code }: { token: string; code: string }) => { @@ -49,16 +43,16 @@ const completeLoginChallengeAction = action( const environment = createRelayEnvironment(); - const result = await new Promise((resolve) => { + const result = await new Promise((resolve) => { commitMutation(environment, { mutation: completeLoginChallengeMutation, variables: { token, code }, onCompleted: (response, errors) => { - const errorMessage = errors?.map((e) => e.message).join("\n"); + const graphQLErrors = errors ?? []; - if (errorMessage !== undefined) { + if (graphQLErrors.length > 0) { resolve({ - message: errorMessage, + message: graphQLErrors.map((error) => error.message).join("\n"), status: "error", }); return; @@ -76,9 +70,17 @@ const completeLoginChallengeAction = action( return; } + try { + setSessionCookie(session.accessToken, session.expires); + } catch { + resolve({ + message: "Unable to save a session.", + status: "error", + }); + return; + } + resolve({ - accessToken: session.accessToken, - expires: session.expires, message: "Signing in…", status: "success", }); @@ -101,7 +103,7 @@ export default function ConfirmPage() { const params = useParams<{ token: string }>(); const [searchParams] = useSearchParams<{ code?: string }>(); const completeLoginChallenge = useAction(completeLoginChallengeAction); - const [result, setResult] = createSignal(); + const [result, setResult] = createSignal(); onMount(() => { async function complete() { @@ -116,23 +118,6 @@ export default function ConfirmPage() { return; } - const response = await fetch("/session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - accessToken: completeResult.accessToken, - expires: completeResult.expires, - }), - }); - - if (!response.ok) { - setResult({ - message: "Unable to save a session.", - status: "error", - }); - return; - } - globalThis.location.assign("/"); } catch (error) { setResult({ @@ -149,7 +134,7 @@ export default function ConfirmPage() { }); return ( - + Signing in…}> {(value) => {value().message}} ); diff --git a/packages/web/src/routes/session.ts b/packages/web/src/routes/session.ts deleted file mode 100644 index 8e1c11a..0000000 --- a/packages/web/src/routes/session.ts +++ /dev/null @@ -1,111 +0,0 @@ -// DrFed: A web-based platform for developing and debugging ActivityPub apps -// Copyright (C) 2026 DrFed team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import type { APIEvent } from "@solidjs/start/server"; - -const SESSION_COOKIE = "session"; -const ACCESS_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u; - -// Note: setCookie(nativeEvent, ...) from @solidjs/start/http is intentionally -// NOT used here. In @solidjs/start 2.0.0-alpha.2, that function produces a -// malformed Set-Cookie header in both "use server" RPCs and POST API route -// handlers — the cookie name becomes "[METHOD] URL" instead of the intended -// name. -export async function POST({ request }: APIEvent) { - let body: unknown; - try { - body = await request.json(); - } catch { - return new Response(undefined, { status: 400 }); - } - if (typeof body !== "object" || body == undefined) { - return new Response(undefined, { status: 400 }); - } - - const accessToken = - "accessToken" in body && typeof body.accessToken === "string" - ? body.accessToken - : undefined; - const expiresValue = - "expires" in body && typeof body.expires === "string" - ? body.expires - : undefined; - - if (accessToken == undefined || expiresValue == undefined) { - return new Response(undefined, { status: 400 }); - } - - if (!ACCESS_TOKEN_PATTERN.test(accessToken)) { - return new Response(undefined, { status: 400 }); - } - - let expires: Temporal.Instant; - - try { - expires = Temporal.Instant.from(expiresValue); - } catch { - return new Response(undefined, { status: 400 }); - } - - if (expires.epochNanoseconds <= Temporal.Now.instant().epochNanoseconds) { - return new Response(undefined, { status: 400 }); - } - - const cookie = [ - `${SESSION_COOKIE}=${encodeURIComponent(accessToken)}`, - "HttpOnly", - "Path=/", - "SameSite=Lax", - `Expires=${new Date(expires.epochMilliseconds).toUTCString()}`, - ...(new URL(request.url).protocol === "https:" ? ["Secure"] : []), - ].join("; "); - - return new Response(undefined, { - status: 204, - headers: { "Set-Cookie": cookie }, - }); -} - -export function readSessionCookie( - request: Request | undefined, -): string | undefined { - const cookieHeader = request?.headers.get("cookie"); - - if (cookieHeader == undefined || cookieHeader == "") { - return undefined; - } - for (const part of cookieHeader.split(";")) { - const eq = part.indexOf("="); - if (eq === -1) { - continue; - } - if (part.slice(0, eq).trim() !== SESSION_COOKIE) { - continue; - } - const raw = part.slice(eq + 1).trim(); - if (raw === "") { - return undefined; - } - let decoded: string; - try { - decoded = decodeURIComponent(raw); - } catch { - return undefined; - } - return ACCESS_TOKEN_PATTERN.test(decoded) ? decoded : undefined; - } - return undefined; -} diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index 778e751..ed712be 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -15,12 +15,11 @@ // along with this program. If not, see . import { Title } from "@solidjs/meta"; -import { action, useSubmission } from "@solidjs/router"; -import { commitMutation, graphql } from "relay-runtime"; -import { Show } from "solid-js"; -import { getRequestEvent } from "solid-js/web"; +import { graphql } from "relay-runtime"; +import { Show, createSignal } from "solid-js"; +import { createMutation } from "solid-relay"; -import { createRelayEnvironment } from "~/RelayEnvironment"; +import type { SignInMutation } from "./__generated__/SignInMutation.graphql"; import "./sign-in.css"; @@ -37,65 +36,51 @@ interface SignInResult { status: "error" | "success"; } -const signInAction = action(async (formData: FormData) => { - "use server"; - - const email = formData.get("email"); - if (typeof email !== "string" || email === "") { - return { - message: "Enter a valid email address.", - status: "error", - } satisfies SignInResult; - } - - const request = getRequestEvent()?.request; - if (request === undefined) { - return { - message: "Unable to determine the application URL.", - status: "error", - } satisfies SignInResult; - } - - const environment = createRelayEnvironment(); - const verifyUrl = `${new URL(request.url).origin}/confirm/{token}?code={code}`; +export default function SignInPage() { + const [result, setResult] = createSignal(); + const [commitSignIn, isSigningIn] = + createMutation(signInMutation); + + const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { + event.preventDefault(); + + const verifyUrl = `${globalThis.location.origin}/confirm/{token}?code={code}`; + const formData = new FormData(event.currentTarget); + const email = formData.get("email"); + if (typeof email !== "string" || email === "") { + setResult({ + message: "Enter a valid email address.", + status: "error", + }); + return; + } - const result = await new Promise((resolve) => { - commitMutation(environment, { - mutation: signInMutation, + setResult(undefined); + commitSignIn({ variables: { email, verifyUrl }, onCompleted: (_response, errors) => { - const errorMessage = errors?.map((e) => e.message).join("\n"); - - resolve({ + const graphQLErrors = errors ?? []; + if (graphQLErrors.length > 0) { + setResult({ + message: graphQLErrors.map((error) => error.message).join("\n"), + status: "error", + }); + return; + } + + setResult({ message: - errorMessage ?? "Check your inbox for a secure sign-in link. You can close this page.", - status: errorMessage === undefined ? "success" : "error", + status: "success", }); }, onError: (error) => { - resolve({ + setResult({ message: error.message, status: "error", }); }, }); - }); - - return result; -}, "sign-in"); - -export default function SignInPage() { - const signInSubmission = useSubmission(signInAction); - - const buttonLabel = () => { - if (signInSubmission.pending === true) { - return "Sending link…"; - } - if (signInSubmission.result?.status === "success") { - return "Resend sign-in link"; - } - return "Send sign-in link"; }; return ( @@ -111,7 +96,7 @@ export default function SignInPage() {

Enter your email address to receive a secure sign-in link.

-
+ -
- + {(formResult) => (

(); + const [commitCreateInstance, isCreatingInstance] = + createMutation(createInstanceMutation); + + const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + const slug = formData.get("slug"); + if (typeof slug !== "string" || slug === "") { + setErrorMessage("Enter a valid slug."); + return; } - | { - message: string; - status: "error"; - }; - -const createInstanceAction = action(async (formData: FormData) => { - "use server"; - - const slug = formData.get("slug"); - if (typeof slug !== "string" || slug === "") { - return { - message: "Enter a valid slug.", - status: "error", - } satisfies CreateInstanceResult; - } - const request = getRequestEvent()?.request; - if (request === undefined) { - return { - message: "Unable to determine the application URL.", - status: "error", - } satisfies CreateInstanceResult; - } - - const environment = createRelayEnvironment(); - - const result = await new Promise((resolve) => { - commitMutation(environment, { - mutation: createInstanceMutation, + setErrorMessage(undefined); + commitCreateInstance({ variables: { slug }, onCompleted: (response, errors) => { - const errorMessage = errors?.map((e) => e.message).join("\n"); + const graphQLErrors = errors ?? []; + if (graphQLErrors.length > 0) { + setErrorMessage( + graphQLErrors.map((error) => error.message).join("\n"), + ); + return; + } - if (typeof errorMessage == "string") { - resolve({ - message: errorMessage, - status: "error", - }); - } else if (response.createInstance.id === undefined) { - resolve({ - message: "Empty ID Returned", - status: "error", - }); - } else { - resolve({ - payload: { - id: response.createInstance.id, - }, - status: "success", - }); + switch (response.createInstance.resultType) { + case "CreateInstanceError": { + setErrorMessage(response.createInstance.message); + return; + } + case "Instance": { + navigate("/workspace/"); + return; + } + case "%other": { + setErrorMessage("Unable to create the instance."); + return; + } + default: { + setErrorMessage("Unable to create the instance."); + } } }, onError: (error) => { - resolve({ - message: error.message, - status: "error", - }); + setErrorMessage(error.message); }, }); - }); - - if (result.status === "error") { - return result; - } - - return redirect(`/workspace/`); -}, "create-instance"); - -export default function CreateInstancePage() { - const createInstanceSubmission = useSubmission(createInstanceAction); + }; const buttonLabel = () => { - if (createInstanceSubmission.pending === true) { + if (isCreatingInstance()) { return "Creating instance…"; } return "Create instance"; @@ -132,23 +106,10 @@ export default function CreateInstancePage() { >

Create an instance

-

Name your new ActivityPub testing environment.

+

Review the generated identifier for your new instance.

-
- +