eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(dashboard): WIP add atproto test client and account management

+839 -1
+158
PDS-INTEGRATION-NOTES.md
··· 1 + ## PDS integration overview 2 + 3 + ### End-to-end flow 4 + 5 + - **Stripe checkout** 6 + - User selects a plan on the pricing page. 7 + - Plan context is passed via URL params (`auto_checkout`, `pds_plan`, `pds_disksize_gb`, etc.) through `/signup` or `/login` to `/dashboard`. 8 + - `DashboardClient` calls `createSubscriptionCheckout(priceId, options)` with: 9 + - `username` (optional, normalized) 10 + - `hostname` (optional, cleaned; falls back to `<username>.eny.k8s.frx.pub`) 11 + - `disksizeGb` (derived from plan or override) 12 + - `createSubscriptionCheckout` encodes this into Stripe Checkout metadata: 13 + - `user_id`, `user_email` 14 + - `pds_username` 15 + - `pds_hostname_base` 16 + - `pds_disksize_gb` 17 + 18 + - **Stripe webhook → provisioning** 19 + - Stripe sends `checkout.session.completed` to `/api/webhooks` (Vercel URL, optionally with `x-vercel-protection-bypass` query param). 20 + - The webhook: 21 + - Verifies the event using `STRIPE_WEBHOOK_SECRET`. 22 + - Stores `user_id ↔ stripe_customer_id` in `subscriptions` (minimal mapping). 23 + - Derives provisioning parameters: 24 + - `pds_username` (normalized from metadata or `user_email`) 25 + - `pds_hostname_base` (metadata or `<username>.eny.k8s.frx.pub`) 26 + - `disksizeGb` (metadata or `"10"`). 27 + - Calls `provisionPdsForUser` with: 28 + - `userId`, `userEmail` 29 + - `pdsUsername`, `pdsHostnameBase`, `disksizeGb`. 30 + 31 + - **`provisionPdsForUser`** 32 + - Uses Supabase admin client (`NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`) to read/write `pds_services`. 33 + - Idempotency: 34 + - If `pds_services` already has a row with a non-null `pds_service_id`, it skips redeploy. 35 + - If `status` is **not** in `{ "deploy_failed", "deploy_succeeded_no_id" }`, it also skips redeploy. 36 + - Otherwise it proceeds to deploy again. 37 + - Calls `POST https://k8s-pds.frx.pub/api/v1/deploy` with Bearer **`PDS_API_TOKEN`** and JSON body: 38 + 39 + ```json 40 + { 41 + "username": "<pdsUsername>", 42 + "password": "<generated base64url>", 43 + "email": "<userEmail>", 44 + "hostname": "<pdsHostnameBase>", 45 + "disksize": <number in GiB> 46 + } 47 + ``` 48 + 49 + - Backend currently expects `hostname` to be a bare FQDN (no scheme, no path). 50 + 51 + - Expects a 2xx response with JSON containing an id somewhere. Current backend returns: 52 + 53 + ```json 54 + { 55 + "...": "...", 56 + "serviceId": 800 57 + } 58 + ``` 59 + 60 + - The code extracts `maybeServiceId` from (in order): 61 + - `service_id` 62 + - `serviceId` 63 + - `id` 64 + - `service.id` 65 + - `data.id` 66 + - `data.serviceId` 67 + - It upserts into `pds_services`: 68 + - `user_id` 69 + - `pds_service_id` (number or `null`) 70 + - `hostname` 71 + - `status`: 72 + - `"provisioning"` when `pds_service_id` is set. 73 + - `"deploy_succeeded_no_id"` when deploy succeeded but no id was found. 74 + - `"deploy_failed"` on deploy error. 75 + 76 + ### Database schema (`pds_services`) 77 + 78 + ```sql 79 + CREATE TABLE IF NOT EXISTS pds_services ( 80 + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, 81 + pds_service_id BIGINT, 82 + hostname TEXT, 83 + status TEXT NOT NULL DEFAULT 'provisioning', 84 + created_at TIMESTAMPTZ DEFAULT NOW(), 85 + updated_at TIMESTAMPTZ DEFAULT NOW() 86 + ); 87 + 88 + ALTER TABLE pds_services ENABLE ROW LEVEL SECURITY; 89 + 90 + DROP POLICY IF EXISTS "Users can view own pds services" ON pds_services; 91 + CREATE POLICY "Users can view own pds services" 92 + ON pds_services 93 + FOR SELECT 94 + USING (auth.uid() = user_id); 95 + 96 + CREATE OR REPLACE FUNCTION update_pds_services_updated_at_column() 97 + RETURNS TRIGGER AS $$ 98 + BEGIN 99 + NEW.updated_at = NOW(); 100 + RETURN NEW; 101 + END; 102 + $$ language 'plpgsql'; 103 + 104 + DROP TRIGGER IF EXISTS update_pds_services_updated_at ON pds_services; 105 + CREATE TRIGGER update_pds_services_updated_at 106 + BEFORE UPDATE ON pds_services 107 + FOR EACH ROW 108 + EXECUTE FUNCTION update_pds_services_updated_at_column(); 109 + ``` 110 + 111 + - One PDS per `user_id` (PK on `user_id`). 112 + - `status` values used today: `"provisioning"`, `"deploy_failed"`, `"deploy_succeeded_no_id"`. 113 + 114 + ### PDS service fetch (`app/api/pds/service/route.ts`) 115 + 116 + - Authenticates the current user with the normal Supabase client. 117 + - Reads `pds_services` row for `user_id`: 118 + - If no row or `pds_service_id` is `null`, returns `404` with a simple message. 119 + - Builds `GET` URL to PDS API: 120 + 121 + ```text 122 + GET https://k8s-pds.frx.pub/api/v1/service/{pds_service_id} 123 + ``` 124 + 125 + - Sends: 126 + - `Accept: application/json` 127 + - `X-Requested-With: XMLHttpRequest` 128 + - `Authorization: Bearer PDS_API_TOKEN` 129 + - If `content-type` is JSON: 130 + - Parses body. 131 + - Handles double-encoded JSON strings by `JSON.parse` when possible. 132 + - On non-2xx, returns `502` with `{ error, status, body }` for debugging. 133 + - On 2xx, returns the JSON as-is to the dashboard. 134 + - If non-JSON, returns `502` with `{ error, status, contentType, bodyPreview }`. 135 + 136 + ### Dashboard UI 137 + 138 + - `Usage summary` section uses `ServiceDetailsClient mode="stats"` and is fed from `/api/pds/service` (or mock when `PDS_USE_MOCK=true`). 139 + - Details section uses `ServiceDetailsClient mode="details"`: 140 + - Shows `id`, `service`, `namespace`, `state`, `kubeconfig_id`. 141 + - Shows `encrypted_config` fields (hostname, email settings, storage size) with `adminPassword` masked. 142 + - Shows `install_cmd` and timestamps. 143 + 144 + ### Env vars (server vs client) 145 + 146 + - **Server-only (secret)**: 147 + - `SUPABASE_SERVICE_ROLE_KEY` 148 + - `STRIPE_SECRET_KEY` 149 + - `STRIPE_WEBHOOK_SECRET` 150 + - `PDS_API_TOKEN` 151 + 152 + - **Client-safe (`NEXT_PUBLIC_*`)**: 153 + - `NEXT_PUBLIC_SUPABASE_URL` 154 + - `NEXT_PUBLIC_SUPABASE_ANON_KEY` 155 + - `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` 156 + - `NEXT_PUBLIC_STRIPE_PRICE_ID` 157 + 158 + These are configured in Vercel for **all environments** (or at least Preview/Production). Webhook and PDS routes only use the server-only keys.
+106
app/api/pds/atproto/create-account/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + 3 + import { createClient } from "@/lib/supabase/server"; 4 + 5 + import { 6 + getPdsBaseUrlFromService, 7 + getPdsServiceForCurrentUser, 8 + } from "../helpers"; 9 + 10 + export async function POST(req: Request) { 11 + try { 12 + const body = (await req.json()) as { 13 + email?: string; 14 + handle: string; 15 + password: string; 16 + inviteCode: string; 17 + }; 18 + 19 + if (!body?.handle || !body?.password || !body?.inviteCode) { 20 + return NextResponse.json( 21 + { message: "Missing required fields: handle, password, inviteCode" }, 22 + { status: 400 }, 23 + ); 24 + } 25 + 26 + const { service, pdsServiceId } = await getPdsServiceForCurrentUser(); 27 + 28 + const requiredServiceIdRaw = process.env.NEXT_PUBLIC_PDS_TEST_SERVICE_ID; 29 + if (requiredServiceIdRaw) { 30 + const requiredServiceId = Number(requiredServiceIdRaw); 31 + if (pdsServiceId !== requiredServiceId) { 32 + return NextResponse.json( 33 + { 34 + message: `PDS service id mismatch: expected ${requiredServiceId}, got ${pdsServiceId}`, 35 + }, 36 + { status: 409 }, 37 + ); 38 + } 39 + } 40 + 41 + // Ensure we always have `https://...` for fetch 42 + const pdsBaseUrl = getPdsBaseUrlFromService(service); 43 + 44 + let emailToUse = body.email; 45 + if (!emailToUse) { 46 + // Reuse the user's own Supabase email for testing, but add a +alias suffix 47 + // to avoid collisions if the backend enforces uniqueness. 48 + const supabase = await createClient(); 49 + const { 50 + data: { user }, 51 + } = await supabase.auth.getUser(); 52 + 53 + if (!user?.email) { 54 + return NextResponse.json( 55 + { message: "Missing email (neither request body nor Supabase user email found)" }, 56 + { status: 400 }, 57 + ); 58 + } 59 + 60 + const baseEmail = user.email; 61 + const [local, domain] = baseEmail.split("@"); 62 + const alias = `${local}+atproto-test-${Date.now()}`; 63 + emailToUse = `${alias}@${domain}`; 64 + } 65 + 66 + const res = await fetch( 67 + `${pdsBaseUrl}/xrpc/com.atproto.server.createAccount`, 68 + { 69 + method: "POST", 70 + headers: { 71 + "Content-Type": "application/json", 72 + }, 73 + body: JSON.stringify({ 74 + email: emailToUse, 75 + handle: body.handle, 76 + password: body.password, 77 + inviteCode: body.inviteCode, 78 + }), 79 + }, 80 + ); 81 + 82 + const contentType = res.headers.get("content-type") || ""; 83 + const payload = contentType.includes("application/json") 84 + ? await res.json() 85 + : await res.text().catch(() => ""); 86 + 87 + if (!res.ok) { 88 + return NextResponse.json( 89 + { message: "Failed to create account", status: res.status, payload }, 90 + { status: 502 }, 91 + ); 92 + } 93 + 94 + // Return the email that we used, so the UI mirrors the real admin workflow. 95 + if (payload && typeof payload === "object") { 96 + return NextResponse.json({ ...(payload as any), emailUsed: emailToUse }); 97 + } 98 + 99 + return NextResponse.json({ payload, emailUsed: emailToUse }); 100 + } catch (error) { 101 + const message = error instanceof Error ? error.message : "Unknown error"; 102 + const status = (error as any)?.status ?? 500; 103 + return NextResponse.json({ message }, { status }); 104 + } 105 + } 106 +
+72
app/api/pds/atproto/create-session/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + 3 + import { 4 + getPdsBaseUrlFromService, 5 + getPdsServiceForCurrentUser, 6 + } from "../helpers"; 7 + 8 + export async function POST(req: Request) { 9 + try { 10 + const body = (await req.json()) as { 11 + identifier: string; 12 + password: string; 13 + }; 14 + 15 + if (!body?.identifier || !body?.password) { 16 + return NextResponse.json( 17 + { message: "Missing required fields: identifier, password" }, 18 + { status: 400 }, 19 + ); 20 + } 21 + 22 + const { service, pdsServiceId } = await getPdsServiceForCurrentUser(); 23 + 24 + const requiredServiceIdRaw = process.env.NEXT_PUBLIC_PDS_TEST_SERVICE_ID; 25 + if (requiredServiceIdRaw) { 26 + const requiredServiceId = Number(requiredServiceIdRaw); 27 + if (pdsServiceId !== requiredServiceId) { 28 + return NextResponse.json( 29 + { 30 + message: `PDS service id mismatch: expected ${requiredServiceId}, got ${pdsServiceId}`, 31 + }, 32 + { status: 409 }, 33 + ); 34 + } 35 + } 36 + 37 + const pdsBaseUrl = getPdsBaseUrlFromService(service); 38 + 39 + const res = await fetch( 40 + `${pdsBaseUrl}/xrpc/com.atproto.server.createSession`, 41 + { 42 + method: "POST", 43 + headers: { 44 + "Content-Type": "application/json", 45 + }, 46 + body: JSON.stringify({ 47 + identifier: body.identifier, 48 + password: body.password, 49 + }), 50 + }, 51 + ); 52 + 53 + const contentType = res.headers.get("content-type") || ""; 54 + const payload = contentType.includes("application/json") 55 + ? await res.json() 56 + : await res.text().catch(() => ""); 57 + 58 + if (!res.ok) { 59 + return NextResponse.json( 60 + { message: "Failed to create session", status: res.status, payload }, 61 + { status: 502 }, 62 + ); 63 + } 64 + 65 + return NextResponse.json(payload); 66 + } catch (error) { 67 + const message = error instanceof Error ? error.message : "Unknown error"; 68 + const status = (error as any)?.status ?? 500; 69 + return NextResponse.json({ message }, { status }); 70 + } 71 + } 72 +
+105
app/api/pds/atproto/helpers.ts
··· 1 + import { createClient } from "@/lib/supabase/server"; 2 + 3 + const PDS_API_BASE_URL = "https://k8s-pds.frx.pub/api/v1"; 4 + 5 + function parseMaybeDoubleEncodedJson(input: unknown) { 6 + if (typeof input === "string") { 7 + try { 8 + return JSON.parse(input); 9 + } catch { 10 + return input; 11 + } 12 + } 13 + return input; 14 + } 15 + 16 + export async function getPdsServiceForCurrentUser(): Promise<{ 17 + pdsServiceId: number; 18 + service: any; 19 + }> { 20 + const supabase = await createClient(); 21 + const { 22 + data: { user }, 23 + } = await supabase.auth.getUser(); 24 + 25 + if (!user) { 26 + return Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 })); 27 + } 28 + 29 + const { data: pdsServiceRow } = await supabase 30 + .from("pds_services") 31 + .select("pds_service_id") 32 + .eq("user_id", user.id) 33 + .maybeSingle(); 34 + 35 + const forcedServiceIdRaw = 36 + process.env.PDS_FORCE_SERVICE_ID === "true" 37 + ? process.env.PDS_TEST_SERVICE_ID 38 + : undefined; 39 + const forcedServiceId = forcedServiceIdRaw ? Number(forcedServiceIdRaw) : null; 40 + 41 + const pdsServiceId = (forcedServiceId !== null && Number.isFinite(forcedServiceId) 42 + ? forcedServiceId 43 + : pdsServiceRow?.pds_service_id) as number | null | undefined; 44 + 45 + if (!pdsServiceId) { 46 + return Promise.reject( 47 + Object.assign(new Error("No provisioned PDS found for this user"), { status: 404 }), 48 + ); 49 + } 50 + 51 + const apiToken = process.env.PDS_API_TOKEN; 52 + if (!apiToken) { 53 + return Promise.reject( 54 + Object.assign(new Error("Missing PDS_API_TOKEN env var"), { status: 500 }), 55 + ); 56 + } 57 + 58 + const res = await fetch(`${PDS_API_BASE_URL}/service/${pdsServiceId}`, { 59 + cache: "no-store", 60 + headers: { 61 + Accept: "application/json", 62 + "X-Requested-With": "XMLHttpRequest", 63 + Authorization: `Bearer ${apiToken}`, 64 + }, 65 + }); 66 + 67 + const contentType = res.headers.get("content-type") || ""; 68 + if (!res.ok) { 69 + let body: unknown = null; 70 + try { 71 + body = await res.json(); 72 + } catch { 73 + body = await res.text().catch(() => ""); 74 + } 75 + return Promise.reject( 76 + Object.assign( 77 + new Error(`Upstream PDS service fetch failed (${res.status})`), 78 + { status: 502, body: parseMaybeDoubleEncodedJson(body), contentType }, 79 + ), 80 + ); 81 + } 82 + 83 + if (!contentType.includes("application/json")) { 84 + return Promise.reject( 85 + Object.assign(new Error("Upstream did not return JSON"), { status: 502 }), 86 + ); 87 + } 88 + 89 + const dataRaw: unknown = await res.json(); 90 + const service = parseMaybeDoubleEncodedJson(dataRaw); 91 + 92 + return { pdsServiceId: Number(pdsServiceId), service }; 93 + } 94 + 95 + export function getPdsBaseUrlFromService(service: any): string { 96 + const raw = service?.encrypted_config?.hostname as string | undefined; 97 + if (!raw) { 98 + throw new Error("Missing PDS host"); 99 + } 100 + 101 + // Backend might return either `https://host` or just `host`. 102 + const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; 103 + return withScheme.replace(/\/+$/, ""); 104 + } 105 +
+82
app/api/pds/atproto/invite/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + 3 + import { createHash } from "crypto"; 4 + 5 + import { getPdsBaseUrlFromService, getPdsServiceForCurrentUser } from "../helpers"; 6 + 7 + function toBasicAuth(user: string, pass: string) { 8 + return `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`; 9 + } 10 + 11 + export async function POST(req: Request) { 12 + try { 13 + const { useCount } = (await req.json()) as { useCount?: number }; 14 + 15 + const { service, pdsServiceId } = await getPdsServiceForCurrentUser(); 16 + const requiredServiceIdRaw = process.env.NEXT_PUBLIC_PDS_TEST_SERVICE_ID; 17 + if (requiredServiceIdRaw) { 18 + const requiredServiceId = Number(requiredServiceIdRaw); 19 + if (pdsServiceId !== requiredServiceId) { 20 + return NextResponse.json( 21 + { 22 + message: `PDS service id mismatch: expected ${requiredServiceId}, got ${pdsServiceId}`, 23 + }, 24 + { status: 409 }, 25 + ); 26 + } 27 + } 28 + 29 + const adminPassword = service?.encrypted_config?.adminPassword as 30 + | string 31 + | undefined; 32 + 33 + if (!service?.encrypted_config || !adminPassword) { 34 + return NextResponse.json( 35 + { message: "Missing PDS host/admin credentials" }, 36 + { status: 500 }, 37 + ); 38 + } 39 + 40 + const trimmedAdminPassword = String(adminPassword).trim(); 41 + const adminPasswordHashPrefix = createHash("sha256") 42 + .update(trimmedAdminPassword) 43 + .digest("hex") 44 + .slice(0, 10); 45 + 46 + // PDS scripts use `admin:${PDS_ADMIN_PASSWORD}` 47 + const authHeader = toBasicAuth("admin", trimmedAdminPassword); 48 + 49 + const pdsBaseUrl = getPdsBaseUrlFromService(service); 50 + 51 + const res = await fetch( 52 + `${pdsBaseUrl}/xrpc/com.atproto.server.createInviteCode`, 53 + { 54 + method: "POST", 55 + headers: { 56 + "Content-Type": "application/json", 57 + Authorization: authHeader, 58 + }, 59 + body: JSON.stringify({ useCount: useCount ?? 1 }), 60 + }, 61 + ); 62 + 63 + const contentType = res.headers.get("content-type") || ""; 64 + const payload = contentType.includes("application/json") 65 + ? await res.json() 66 + : await res.text().catch(() => ""); 67 + 68 + if (!res.ok) { 69 + return NextResponse.json( 70 + { message: "Failed to create invite", status: res.status, payload }, 71 + { status: 502 }, 72 + ); 73 + } 74 + 75 + return NextResponse.json(payload); 76 + } catch (error) { 77 + const message = error instanceof Error ? error.message : "Unknown error"; 78 + const status = (error as any)?.status ?? 500; 79 + return NextResponse.json({ message }, { status }); 80 + } 81 + } 82 +
+24 -1
app/api/pds/service/route.ts
··· 121 121 .eq("user_id", user.id) 122 122 .maybeSingle(); 123 123 124 - const pdsServiceId = pdsServiceRow?.pds_service_id; 124 + const forcedServiceIdRaw = process.env.PDS_FORCE_SERVICE_ID === "true" 125 + ? process.env.PDS_TEST_SERVICE_ID 126 + : undefined; 127 + const forcedServiceId = forcedServiceIdRaw ? Number(forcedServiceIdRaw) : null; 128 + 129 + const pdsServiceId = (forcedServiceId !== null && Number.isFinite(forcedServiceId) 130 + ? forcedServiceId 131 + : pdsServiceRow?.pds_service_id) as number | null | undefined; 125 132 126 133 if (!pdsServiceId) { 127 134 return NextResponse.json( ··· 166 173 }, 167 174 { status: 502 }, 168 175 ); 176 + } 177 + 178 + // Redact sensitive secrets before sending to the browser. 179 + if (data && typeof data === "object") { 180 + const d: any = data; 181 + if (d.encrypted_config && typeof d.encrypted_config === "object") { 182 + if ("adminPassword" in d.encrypted_config) { 183 + d.encrypted_config.adminPassword = "redacted"; 184 + } 185 + if ("jwtSecret" in d.encrypted_config) { 186 + d.encrypted_config.jwtSecret = "redacted"; 187 + } 188 + if ("plcRotationKey" in d.encrypted_config) { 189 + d.encrypted_config.plcRotationKey = "redacted"; 190 + } 191 + } 169 192 } 170 193 171 194 return NextResponse.json(data);
+289
app/dashboard/atproto-test-client.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useMemo, useState } from "react"; 4 + import { Heading } from "@/components/heading"; 5 + import { Paragraph } from "@/components/paragraph"; 6 + import { Button } from "@/actions/components/ui/button"; 7 + 8 + type ServiceResponse = { 9 + encrypted_config?: { 10 + hostname?: string; 11 + }; 12 + state?: number | string; 13 + }; 14 + 15 + function redactJwt(token: string | undefined) { 16 + if (!token) return ""; 17 + if (token.length <= 20) return token; 18 + return `${token.slice(0, 15)}...${token.slice(-10)}`; 19 + } 20 + 21 + function stripScheme(hostname?: string) { 22 + if (!hostname) return ""; 23 + return hostname.replace(/^https?:\/\//i, "").replace(/\/.*$/, ""); 24 + } 25 + 26 + export function AtprotoTestClient() { 27 + const [pdsHost, setPdsHost] = useState<string>(""); 28 + const [pdsState, setPdsState] = useState<number | string | null>(null); 29 + const [inviteCode, setInviteCode] = useState<string>(""); 30 + 31 + const [email, setEmail] = useState<string>(""); 32 + const [handle, setHandle] = useState<string>(""); 33 + const [newPassword, setNewPassword] = useState<string>(""); 34 + 35 + const [sessionIdentifier, setSessionIdentifier] = useState<string>(""); 36 + const [sessionPassword, setSessionPassword] = useState<string>(""); 37 + 38 + const [loading, setLoading] = useState(false); 39 + const [output, setOutput] = useState<any>(null); 40 + const [error, setError] = useState<string | null>(null); 41 + 42 + const pdsBareHost = useMemo(() => stripScheme(pdsHost), [pdsHost]); 43 + const pdsStateNum = useMemo(() => { 44 + if (pdsState === null) return null; 45 + const n = typeof pdsState === "number" ? pdsState : Number(pdsState); 46 + return Number.isFinite(n) ? n : null; 47 + }, [pdsState]); 48 + const isPdsReady = pdsStateNum !== null && pdsStateNum >= 3; 49 + const createAccountDisabled = 50 + loading || !inviteCode || !handle || !newPassword || !isPdsReady; 51 + const createSessionDisabled = 52 + loading || !handle || !newPassword || !isPdsReady; 53 + 54 + useEffect(() => { 55 + const load = async () => { 56 + try { 57 + const res = await fetch("/api/pds/service", { method: "GET" }); 58 + if (!res.ok) throw new Error(`Failed to load service (${res.status})`); 59 + const data = (await res.json()) as ServiceResponse; 60 + const host = data?.encrypted_config?.hostname || ""; 61 + setPdsHost(host); 62 + setPdsState(data?.state ?? null); 63 + 64 + if (!handle && host) { 65 + const bare = stripScheme(host); 66 + setHandle(`user1.${bare}`); 67 + setSessionIdentifier(`user1.${bare}`); 68 + } 69 + } catch (e) { 70 + setError(e instanceof Error ? e.message : "Unknown error"); 71 + } 72 + }; 73 + load(); 74 + }, []); 75 + 76 + const call = async (path: string, body: any) => { 77 + setLoading(true); 78 + setError(null); 79 + setOutput(null); 80 + try { 81 + const res = await fetch(path, { 82 + method: "POST", 83 + headers: { "Content-Type": "application/json" }, 84 + body: JSON.stringify(body), 85 + }); 86 + 87 + const contentType = res.headers.get("content-type") || ""; 88 + const payload = contentType.includes("application/json") 89 + ? await res.json() 90 + : await res.text().catch(() => ""); 91 + 92 + if (!res.ok) { 93 + if (typeof payload === "string") { 94 + throw new Error(payload); 95 + } 96 + 97 + // Prefer the nested upstream error if present. 98 + const upstreamMessage = 99 + payload?.payload?.message || 100 + payload?.payload?.error || 101 + payload?.message; 102 + 103 + const details = { 104 + route: path, 105 + httpStatus: res.status, 106 + upstream: payload?.payload, 107 + message: upstreamMessage || "Request failed", 108 + }; 109 + 110 + throw new Error(JSON.stringify(details, null, 2)); 111 + } 112 + 113 + return payload; 114 + } finally { 115 + setLoading(false); 116 + } 117 + }; 118 + 119 + const createInvite = async () => { 120 + const payload = await call("/api/pds/atproto/invite", { useCount: 1 }); 121 + const code = payload?.code || payload?.inviteCode || ""; 122 + setInviteCode(code); 123 + setOutput(payload); 124 + }; 125 + 126 + const createAccount = async () => { 127 + const payload = await call("/api/pds/atproto/create-account", { 128 + email, 129 + handle, 130 + password: newPassword, 131 + inviteCode, 132 + }); 133 + setOutput(payload); 134 + setSessionIdentifier(handle); 135 + setSessionPassword(newPassword); 136 + }; 137 + 138 + const createSession = async () => { 139 + const payload = await call("/api/pds/atproto/create-session", { 140 + identifier: sessionIdentifier || handle, 141 + password: sessionPassword || newPassword, 142 + }); 143 + setOutput(payload); 144 + }; 145 + 146 + return ( 147 + <section className="space-y-3 rounded-md border border-white/10 bg-white/5 p-4 text-white backdrop-blur-xl"> 148 + <Heading 149 + as="h2" 150 + className="text-sm font-semibold uppercase tracking-wide text-white/80" 151 + > 152 + AT Protocol (test) 153 + </Heading> 154 + 155 + {pdsHost ? ( 156 + <Paragraph className="text-sm text-white/70"> 157 + PDS endpoint:{" "} 158 + <span className="font-mono text-white">{pdsHost}</span> 159 + </Paragraph> 160 + ) : ( 161 + <Paragraph className="text-sm text-white/70">Loading PDS endpoint…</Paragraph> 162 + )} 163 + 164 + {pdsStateNum !== null && !isPdsReady && ( 165 + <Paragraph className="text-sm text-amber-100/90"> 166 + PDS not ready yet (state={pdsStateNum}). Waiting for provisioning to 167 + finish. 168 + </Paragraph> 169 + )} 170 + 171 + <div className="space-y-2 text-sm"> 172 + <div className="flex flex-wrap gap-2"> 173 + <Button 174 + onClick={createInvite} 175 + disabled={loading || !pdsBareHost || !isPdsReady} 176 + className="rounded-full" 177 + > 178 + {loading ? "Working..." : "Create invite"} 179 + </Button> 180 + </div> 181 + 182 + {inviteCode && ( 183 + <div className="space-y-1"> 184 + <Paragraph className="text-xs font-medium text-white/60"> 185 + Invite code 186 + </Paragraph> 187 + <Paragraph className="font-mono text-white break-all"> 188 + {inviteCode} 189 + </Paragraph> 190 + </div> 191 + )} 192 + </div> 193 + 194 + <div className="grid gap-3 md:grid-cols-2"> 195 + <label className="space-y-1"> 196 + <Paragraph className="text-xs font-medium text-white/60"> 197 + Email (optional) 198 + </Paragraph> 199 + <input 200 + value={email} 201 + onChange={(e) => setEmail(e.target.value)} 202 + className="w-full rounded-md border border-white/20 bg-transparent px-3 py-2 text-sm text-white placeholder:text-white/50" 203 + placeholder="leave blank to reuse your own email" 204 + /> 205 + </label> 206 + 207 + <label className="space-y-1"> 208 + <Paragraph className="text-xs font-medium text-white/60">Handle</Paragraph> 209 + <input 210 + value={handle} 211 + onChange={(e) => setHandle(e.target.value)} 212 + className="w-full rounded-md border border-white/20 bg-transparent px-3 py-2 text-sm text-white placeholder:text-white/50" 213 + placeholder={`user1.${pdsBareHost || "eny.k8s.frx.pub"}`} 214 + /> 215 + </label> 216 + 217 + <label className="space-y-1 md:col-span-2"> 218 + <Paragraph className="text-xs font-medium text-white/60"> 219 + Password 220 + </Paragraph> 221 + <input 222 + type="password" 223 + value={newPassword} 224 + onChange={(e) => setNewPassword(e.target.value)} 225 + className="w-full rounded-md border border-white/20 bg-transparent px-3 py-2 text-sm text-white placeholder:text-white/50" 226 + placeholder="new account password" 227 + /> 228 + </label> 229 + </div> 230 + 231 + <div className="flex flex-wrap gap-2"> 232 + <Button 233 + onClick={createAccount} 234 + disabled={createAccountDisabled} 235 + className={ 236 + createAccountDisabled 237 + ? "rounded-full bg-emerald-400/10 border border-emerald-200/10 opacity-60 cursor-not-allowed" 238 + : "rounded-full bg-emerald-400/20 hover:bg-emerald-400/35 border border-emerald-200/25" 239 + } 240 + > 241 + Create user 242 + </Button> 243 + <Button 244 + onClick={createSession} 245 + disabled={createSessionDisabled} 246 + className={ 247 + createSessionDisabled 248 + ? "rounded-full bg-sky-400/10 border border-sky-200/10 opacity-60 cursor-not-allowed" 249 + : "rounded-full bg-sky-400/20 hover:bg-sky-400/35 border border-sky-200/25" 250 + } 251 + > 252 + Login (create session) 253 + </Button> 254 + </div> 255 + 256 + {error && ( 257 + <Paragraph className="text-sm text-rose-100 bg-rose-950/40 rounded p-3"> 258 + {error} 259 + </Paragraph> 260 + )} 261 + 262 + {output && ( 263 + <div className="space-y-2"> 264 + <Paragraph className="text-xs font-medium text-white/60">Response</Paragraph> 265 + <pre className="max-h-64 overflow-auto rounded bg-neutral-900/90 p-3 text-xs text-neutral-100"> 266 + {output?.emailUsed && ( 267 + <Paragraph className="mb-2 text-xs text-white/70"> 268 + Email used:{" "} 269 + <span className="font-mono">{output.emailUsed}</span> 270 + </Paragraph> 271 + )} 272 + {output?.accessJwt 273 + ? JSON.stringify( 274 + { 275 + ...output, 276 + accessJwt: redactJwt(output.accessJwt), 277 + refreshJwt: redactJwt(output.refreshJwt), 278 + }, 279 + null, 280 + 2, 281 + ) 282 + : JSON.stringify(output, null, 2)} 283 + </pre> 284 + </div> 285 + )} 286 + </section> 287 + ); 288 + } 289 +
+3
app/dashboard/page.tsx
··· 13 13 import { Paragraph } from "@/components/paragraph"; 14 14 import DashboardClient from "./dashboard-client"; 15 15 import { ServiceDetailsClient } from "./service-details-client"; 16 + import { AtprotoTestClient } from "./atproto-test-client"; 16 17 17 18 type DashboardPageProps = { 18 19 searchParams?: { ··· 105 106 <hr className="my-6" /> 106 107 107 108 <ServiceDetailsClient mode="details" /> 109 + 110 + <AtprotoTestClient /> 108 111 109 112 <section className="space-y-2 text-white"> 110 113 <Heading