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
+
+
);
}
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() {
>
-
-
+
- {createInstanceSubmission.result?.status === "error"
- ? createInstanceSubmission.result.message
- : ""}
+ {errorMessage()}
diff --git a/packages/web/src/routes/workspace/index.tsx b/packages/web/src/routes/workspace/index.tsx
index 5f34bfc..6f88df8 100644
--- a/packages/web/src/routes/workspace/index.tsx
+++ b/packages/web/src/routes/workspace/index.tsx
@@ -14,11 +14,58 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import { graphql } from "relay-runtime";
+import { For, Show } from "solid-js";
+import { createFragment, createLazyLoadQuery } from "solid-relay";
+
+import type { InstanceSummary_instance$key } from "./__generated__/InstanceSummary_instance.graphql";
+import type { WorkspaceQuery } from "./__generated__/WorkspaceQuery.graphql";
+
+const workspaceQuery = graphql`
+ query WorkspaceQuery {
+ viewer {
+ instances(first: 100) {
+ edges {
+ node {
+ ...InstanceSummary_instance
+ }
+ }
+ }
+ }
+ }
+`;
+
+function InstanceSummary(props: { $instance: InstanceSummary_instance$key }) {
+ const data = createFragment(
+ graphql`
+ fragment InstanceSummary_instance on Instance {
+ id
+ host
+ }
+ `,
+ () => props.$instance,
+ );
+
+ return {data()?.host}
;
+}
+
export default function WorkspacePage() {
+ const query = createLazyLoadQuery(
+ workspaceQuery,
+ {},
+ { fetchPolicy: "store-and-network" },
+ );
+
return (
- Nothing Yet but it is a workspace for you.
+ You're not signed in.
}>
+ {(viewer) => (
+
+ {(edge) => }
+
+ )}
+
);
From 8d05fa799f0405573303ce072cac8f5d59ec97b8 Mon Sep 17 00:00:00 2001
From: Jiwon Kwon
Date: Fri, 28 Aug 2026 01:43:47 +0900
Subject: [PATCH 6/9] Use Solid Relay for sign-in requests
Replace the sign-in server action and its separate Relay environment with Solid Relay createMutation. Submit the form in the browser, derive the verification URL from the public browser origin, and use reactive state for mutation progress and success or error notices.
Keep login completion server-side so the bearer access token continues to be written directly to the HttpOnly session cookie without passing through browser JavaScript.
Checks:
- Regenerated Relay artifacts with relay-compiler.
- Ran Oxfmt and Oxlint on the sign-in route.
- Ran the @drfed/web TypeScript check.
- Manually completed the sign-in workflow through the Tailscale hostname.
AI provenance: I asked Codex to analyze which parts of sign-in should use Solid Relay, explain the generated mutation type and error-handling options, review my incremental implementation, and apply the remaining callback and UI state changes. Codex reviewed the implementation, completed the client-side mutation wiring, and ran the focused checks. I chose to keep operation-specific local result state rather than introduce a shared mutation wrapper, reviewed the changes, and manually verified sign-in over Tailscale.
Assisted-by: Codex:gpt-5.6-sol
---
packages/web/src/routes/sign-in.tsx | 108 +++++++++++++---------------
1 file changed, 49 insertions(+), 59 deletions(-)
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) => (
Date: Fri, 28 Aug 2026 15:54:42 +0900
Subject: [PATCH 7/9] Improve login confirmation feedback
Normalize Relay GraphQL errors before deciding whether login completion failed, so an empty error list is not treated as an error. Simplify the confirmation result type and show a pending message while the server action completes.
Keep login completion in a per-request server Relay environment so the returned bearer access token is written directly to the HttpOnly session cookie and is never returned to browser JavaScript.
Checks:
- Ran Oxfmt and Oxlint on the confirmation route.
- Ran the @drfed/web TypeScript check.
- Manually completed the sign-in workflow through the Tailscale hostname.
AI provenance: I asked Codex to review whether login confirmation could follow the client-side Solid Relay mutation pattern, explain the server-boundary and cache considerations, inspect my cleanup, and add pending feedback. Codex identified the GraphQL error-list edge case, recommended retaining the per-request server action to protect the bearer token, applied the pending output, and ran the focused checks. I simplified the result type, reviewed and formatted the changes, and manually verified sign-in over Tailscale.
Assisted-by: Codex:gpt-5.6-sol
---
packages/web/src/routes/confirm/[token].tsx | 25 +++++++++------------
1 file changed, 10 insertions(+), 15 deletions(-)
diff --git a/packages/web/src/routes/confirm/[token].tsx b/packages/web/src/routes/confirm/[token].tsx
index 9f10c69..ae05ba3 100644
--- a/packages/web/src/routes/confirm/[token].tsx
+++ b/packages/web/src/routes/confirm/[token].tsx
@@ -32,15 +32,10 @@ const completeLoginChallengeMutation = graphql`
}
`;
-type CompleteLogInResult =
- | {
- message: string;
- status: "error";
- }
- | {
- message: string;
- status: "success";
- };
+interface CompleteLoginResult {
+ message: string;
+ status: "error" | "success";
+}
const completeLoginChallengeAction = action(
async ({ token, code }: { token: string; code: string }) => {
@@ -48,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;
@@ -108,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() {
@@ -139,7 +134,7 @@ export default function ConfirmPage() {
});
return (
-
+ Signing in…}>
{(value) => }
);
From f3f10ef2a3f188853b202d6c8e70e7dc5fad6096 Mon Sep 17 00:00:00 2001
From: Jiwon Kwon
Date: Fri, 28 Aug 2026 17:36:35 +0900
Subject: [PATCH 8/9] Use Relay type names for instance mutation results
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Handle createInstance results using Relay’s generated type-name discriminant instead of checking whether individual fields exist. Alias __typename to satisfy lint, handle the future union member, and stop requesting the unused error type.
AI provenance: I asked Codex to analyze the inconsistent instance creation error-handling style and explain how the Relay-generated mutation union should be narrowed. Codex identified the field-presence checks and missing future-type handling, suggested switching on an aliased __typename, explained the repository’s switch-case lint requirements, reviewed the final diff, regenerated Relay artifacts, and ran the focused checks. I applied the result-type switch and reviewed the cleanup.
Assisted-by: Codex:gpt-5.6-sol
---
.../src/routes/workspace/create/instance.tsx | 29 +++++++++++--------
1 file changed, 17 insertions(+), 12 deletions(-)
diff --git a/packages/web/src/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx
index 9f53c44..51f4c81 100644
--- a/packages/web/src/routes/workspace/create/instance.tsx
+++ b/packages/web/src/routes/workspace/create/instance.tsx
@@ -26,12 +26,11 @@ import type { CreateInstanceMutation } from "./__generated__/CreateInstanceMutat
const createInstanceMutation = graphql`
mutation CreateInstanceMutation($slug: String!) {
createInstance(slug: $slug) {
- __typename
+ resultType: __typename
... on Instance {
id
}
... on CreateInstanceError {
- type
message
}
}
@@ -66,17 +65,23 @@ export default function CreateInstancePage() {
return;
}
- const result = response.createInstance;
- if ("message" in result) {
- setErrorMessage(result.message);
- return;
- }
- if (!("id" in result)) {
- setErrorMessage("Unable to create the instance.");
- return;
+ 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.");
+ }
}
-
- navigate("/workspace/");
},
onError: (error) => {
setErrorMessage(error.message);
From 42826abaf120de9f2ea2fbd363f99eb8d138f04b Mon Sep 17 00:00:00 2001
From: Jiwon Kwon
Date: Fri, 28 Aug 2026 18:15:05 +0900
Subject: [PATCH 9/9] Remove incomplete actor.tsx to avoid confusion
Remove the incomplete actor route because its GraphQL operations are not available yet and its placeholder implementation no longer reflects the current frontend pattern.
AI provenance: Codex checked that the placeholder route had no references. I decided on and performed the removal.
Assisted-by: Codex:gpt-5.6-sol
---
.../workspace/[instanceSlug]/create/actor.tsx | 146 ------------------
1 file changed, 146 deletions(-)
delete 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
deleted file mode 100644
index 619f447..0000000
--- a/packages/web/src/routes/workspace/[instanceSlug]/create/actor.tsx
+++ /dev/null
@@ -1,146 +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 { 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 an instance — DrFed
-
-
-
- );
-}