From f33ea2f45e147f833aa55c03dfb4834c4d3325b1 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 13 Aug 2026 15:48:45 +0900 Subject: [PATCH 1/9] Add empty actor create page --- .../workspace/[instanceSlug]/create/actor.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx diff --git a/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx b/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx new file mode 100644 index 0000000..4ea23b5 --- /dev/null +++ b/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx @@ -0,0 +1,25 @@ +// 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 { Title } from "@solidjs/meta"; + +export default function CreateActorPage() { + return ( +
+ Create Actors — DrFed +
+ ); +} From 70a50d858afb353290334ddc15cd91e45b70fb6b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 21 Aug 2026 17:34:49 +0900 Subject: [PATCH 2/9] Add actor creating form ui boilerplate --- .../workspace/[instanceSlug]/create/actor.tsx | 123 +++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx b/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx index 4ea23b5..619f447 100644 --- a/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx +++ b/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx @@ -15,11 +15,132 @@ // along with this program. If not, see . import { Title } from "@solidjs/meta"; +import { action, useSubmissions } from "@solidjs/router"; +import { getRequestEvent } from "solid-js/web"; + +// FIXME: use mutation to send graphql query +// const genActorsMutation = graphql` +// mutation genActorsMutation( +// $instance: ID! $size: Int! +// ) { +// genActors(instance: $instance, size: $size) { +// resultType: __typename + +// ... on CreateActorsSuccess { +// actors { +// username +// } +// } +// ... on CreateActorsError { +// type +// message +// } +// } +// } +// )`; + +type CreateActorsResult = + | { + payload: { + id: string; + }; + status: "success"; + } + | { + message: string; + status: "error"; + }; + +const createActorsAction = action(async (formData: FormData) => { + "use server"; + + const rawSize = formData.get("size"); + if (typeof rawSize !== "string" || rawSize) { + return { + message: "Enter a valid number.", + status: "error", + } satisfies CreateActorsResult; + } + const size = Number(rawSize); + if (!Number.isInteger(size) || size < 1) { + return { + message: "Size must be an integer greater than or equal to 1.", + status: "error", + } satisfies CreateActorsResult; + } + + const request = getRequestEvent()?.request; + if (request === undefined) { + return { + message: "Unable to determine the application URL.", + status: "error", + } satisfies CreateActorsResult; + } + + // FIXME: fetch mutation from relay enviroment + + const result = await Promise.resolve({ + message: "Error occured.", + status: "error", + }); + return result; +}, "create-actors"); export default function CreateActorPage() { + const createActorsSubmission = useSubmissions(createActorsAction); + + const buttonLabel = () => { + if (createActorsSubmission.pending) { + return "Creating actors..."; + } + return "Create actors"; + }; return (
- Create Actors — DrFed + Create an instance — DrFed + +
+
+

Create actors

+

Enter how many actors to create

+
+ +
+ + + +
+ + {/*FIXME: add submission result handling*/} + {/* + + */} +
); } From f4d58d51afa4824e929f11f2c40962e11af719df Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Aug 2026 16:38:50 +0900 Subject: [PATCH 3/9] Run Relay requests through a server function - Move GraphQL transport into a SolidStart server function so Relay operations can use the HttpOnly session cookie. - Read the session cookie through SolidStart's H3-backed getCookie helper and forward it to GraphQL as a Bearer token. - Validate that Relay operations contain query text and report unsuccessful HTTP responses. - Build the GraphQL proxy URL from the server request URL rather than a browser location fallback. - Keep Relay environments and stores factory-created so SSR requests do not share cached data. AI provenance: Codex was asked to analyze DrFed's CORS, Nitro proxy, SolidStart server-action, session-cookie, and Solid Relay architecture, and later to help move authentication into the Relay network layer. Codex inspected the relevant frontend and GraphQL code and the Solid Relay mutation guidance, then generated the initial fetchGraphQL server-function split, H3-backed cookie lookup, Bearer authorization handling, and removal of browser credential forwarding. I applied guidance from the Solid Relay maintainer's onboarding example, added operation-text validation and HTTP failure handling, reviewed the Relay environment lifetime, and directed the work incrementally. Codex subsequently replaced the browser location fallback with SolidStart's getRequestURL helper, formatted the file, and ran the focused web TypeScript and formatting checks. I reviewed and understood the implementation and manually verified and wrote error handling, using the methods from the library I chose. Assisted-by: Codex:gpt-5.6-sol --- packages/web/src/RelayEnvironment.ts | 38 +++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/web/src/RelayEnvironment.ts b/packages/web/src/RelayEnvironment.ts index 8daffd5..49e86a4 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, getRequestURL } 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", getRequestURL()); 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), From 47ee5fbf0c160a107797ccbc7d1e65411c7eca67 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 26 Aug 2026 18:23:02 +0900 Subject: [PATCH 4/9] Keep login access tokens on the server The login-completion action returned the access token to browser JavaScript, which then posted it to the public /session API only to store it as an HttpOnly cookie. This exposed the token before the cookie protection applied and made a public endpoint responsible for accepting client-supplied session credentials. I decided to move the cookie logic from routes/session.ts into a server-only session helper and remove the public /session API. The login-completion action now sets the cookie before returning, so the browser receives only the success or error result. Server-side Relay requests now use the configured GraphQL backend URL instead of deriving it from the incoming public request. This prevents internal GraphQL requests from being routed back through the externally advertised application origin when DrFed runs behind a reverse proxy. AI provenance: I asked Codex to analyze DrFed's session flow and prevent the login access token from passing through browser JavaScript. Codex inspected the login, Relay, GraphQL, and cookie paths; generated the server-only cookie helper; integrated it into login completion; removed the public session route; and corrected Relay's backend URL handling behind a reverse proxy. I directed the work incrementally, decided to replace the public session API with an internal server-only helper, and reviewed the security and Relay environment decisions. I manually verified login, browser cookie creation, and an authenticated viewer query through a reverse-proxied frontend. Focused Oxfmt, Oxlint, and web TypeScript checks passed. Assisted-by: Codex:gpt-5.6-sol --- packages/web/src/RelayEnvironment.ts | 4 +- packages/web/src/routes/confirm/[token].tsx | 32 ++---- packages/web/src/routes/session.ts | 111 -------------------- packages/web/src/session.ts | 50 +++++++++ 4 files changed, 63 insertions(+), 134 deletions(-) delete mode 100644 packages/web/src/routes/session.ts create mode 100644 packages/web/src/session.ts diff --git a/packages/web/src/RelayEnvironment.ts b/packages/web/src/RelayEnvironment.ts index 49e86a4..808e421 100644 --- a/packages/web/src/RelayEnvironment.ts +++ b/packages/web/src/RelayEnvironment.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { getCookie, getRequestURL } from "@solidjs/start/http"; +import { getCookie } from "@solidjs/start/http"; import { Environment, type FetchFunction, @@ -42,7 +42,7 @@ const fetchGraphQL = async ( headers.Authorization = `Bearer ${accessToken}`; } - const url = new URL("/graphql", getRequestURL()); + const url = new URL("/graphql", import.meta.env.VITE_BACKEND_URL); const response = await fetch(url, { method: "POST", diff --git a/packages/web/src/routes/confirm/[token].tsx b/packages/web/src/routes/confirm/[token].tsx index 36b53f5..9f10c69 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"; @@ -37,8 +38,6 @@ type CompleteLogInResult = status: "error"; } | { - accessToken: string; - expires: string; message: string; status: "success"; }; @@ -76,9 +75,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", }); @@ -116,23 +123,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({ 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/session.ts b/packages/web/src/session.ts new file mode 100644 index 0000000..696fba2 --- /dev/null +++ b/packages/web/src/session.ts @@ -0,0 +1,50 @@ +// 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 . + +// oxlint-disable-next-line import/no-unassigned-import -- Environment marker. +import "server-only"; +import { getRequestProtocol, setCookie } from "@solidjs/start/http"; + +const SESSION_COOKIE = "session"; +const ACCESS_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u; + +export function setSessionCookie( + accessToken: string, + expiresValue: string, +): void { + if (!ACCESS_TOKEN_PATTERN.test(accessToken)) { + throw new TypeError("Invalid session access token."); + } + + let expires: Temporal.Instant; + try { + expires = Temporal.Instant.from(expiresValue); + } catch { + throw new TypeError("Invalid session expiration."); + } + + if (expires.epochNanoseconds <= Temporal.Now.instant().epochNanoseconds) { + throw new TypeError("Session expiration must be in the future."); + } + + setCookie(SESSION_COOKIE, accessToken, { + expires: new Date(expires.epochMilliseconds), + httpOnly: true, + path: "/", + sameSite: "lax", + secure: getRequestProtocol() === "https", + }); +} From 689b7f40abf7d9be656cd47c14b4abd27e91c46d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 27 Aug 2026 16:00:46 +0900 Subject: [PATCH 5/9] Create instances with Solid Relay Replace the instance creation server action and its separate Relay environment with Solid Relay createMutation. GraphQL requests continue through the server-only fetch helper, while the form now handles schema and transport errors, mutation progress, and client-side workspace navigation. Remove the unused name input. Add a store-and-network workspace query and an InstanceSummary fragment that render instance hosts after creation. Mark creator memberships as accepted immediately so Account.instances includes newly created instances, and assert the acceptance timestamp in the GraphQL test. Checks: - Ran mise run check. - Ran mise run test, including Relay generation, the production web build, and package tests. - Manually confirmed an unauthenticated mutation displays an authorization error. - Manually signed in over Tailscale, created instances, confirmed the redirect to /workspace/, and confirmed the new hosts appeared there. AI provenance: I asked Codex to review the server-action instance creation flow against the Solid Relay onboarding pattern, convert it to a client mutation, add the workspace refresh and fragment, and diagnose why created instances did not appear after redirecting. Codex implemented the frontend changes, identified that creator memberships remained unaccepted, added the acceptance fix and regression assertion, and ran the automated checks. I chose refetch-on-navigation, removed the unused name field, reviewed the changes step by step, and manually tested authenticated and unauthenticated workflows over Tailscale. Assisted-by: Codex:gpt-5.6-sol --- packages/graphql/src/instance.test.ts | 1 + packages/graphql/src/instance.ts | 1 + .../src/routes/workspace/create/instance.tsx | 148 ++++++------------ packages/web/src/routes/workspace/index.tsx | 49 +++++- 4 files changed, 101 insertions(+), 98 deletions(-) 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/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx index 7e09a6b..9f53c44 100644 --- a/packages/web/src/routes/workspace/create/instance.tsx +++ b/packages/web/src/routes/workspace/create/instance.tsx @@ -16,107 +16,76 @@ import { faker } from "@faker-js/faker"; import { Title } from "@solidjs/meta"; -import { action, redirect, useSubmission } from "@solidjs/router"; -import { commitMutation, graphql } from "relay-runtime"; -import { Show } from "solid-js"; -import { getRequestEvent } from "solid-js/web"; - -import { createRelayEnvironment } from "~/RelayEnvironment"; +import { useNavigate } from "@solidjs/router"; +import { graphql } from "relay-runtime"; +import { Show, createSignal } from "solid-js"; +import { createMutation } from "solid-relay"; import type { CreateInstanceMutation } from "./__generated__/CreateInstanceMutation.graphql"; const createInstanceMutation = graphql` - mutation CreateInstanceMutation( - $slug: String! # $name: String - ) { + mutation CreateInstanceMutation($slug: String!) { createInstance(slug: $slug) { + __typename ... on Instance { id } + ... on CreateInstanceError { + type + message + } } } `; -type CreateInstanceResult = - | { - payload: { - id: string; - }; - status: "success"; +export default function CreateInstancePage() { + const navigate = useNavigate(); + const [errorMessage, setErrorMessage] = createSignal(); + 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", - }); + const result = response.createInstance; + if ("message" in result) { + setErrorMessage(result.message); + return; } + if (!("id" in result)) { + setErrorMessage("Unable to create the instance."); + return; + } + + navigate("/workspace/"); }, 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 +101,10 @@ export default function CreateInstancePage() { >

Create an instance

-

Name your new ActivityPub testing environment.

+

Review the generated identifier for your new instance.

-
- +