diff --git a/docs/create-account-flow.md b/docs/create-account-flow.md index db65bd4..92af772 100644 --- a/docs/create-account-flow.md +++ b/docs/create-account-flow.md @@ -1,8 +1,8 @@ # create-account flow -Status: implementation handoff. This document describes the current boundary -for someone building a resident-facing account creation flow. It separates -facts about ZDS from product choices the next engineer still needs to make. +Status: implemented. `/signup` is the resident-facing account creation page, +served from `src/internal/signup.zig`. This document records the boundary the +page is built against and the product decisions that shaped it. ## current server surface @@ -23,11 +23,12 @@ ZDS already implements the protocol account-creation backend: The production instance advertises `.pds.zat.dev`, requires an invite, sends mail through Comail, and has wildcard DNS/TLS for hosted account handles. -There is no resident-facing create-account page today. The root page, OAuth -login, and `/account` management hub are server-rendered in -`src/internal/account.zig`; XRPC dispatch is in `src/atproto/server.zig`, route -metadata is in `src/http/router.zig`, and account/invite persistence is in -`src/storage/store.zig`. +The resident-facing create-account page is `/signup` +(`src/internal/signup.zig`). The root page links to it, and the `/account` hub +adopts the session it hands off. The root page, OAuth login, and `/account` +management hub are server-rendered in `src/internal/account.zig`; XRPC dispatch +is in `src/atproto/server.zig`, route metadata is in `src/http/router.zig`, and +account/invite persistence is in `src/storage/store.zig`. ## invariants for a UI @@ -52,21 +53,21 @@ state. Add an end-to-end smoke case for the browser flow rather than relying only on DOM tests. Use a disposable local database for destructive tests and a fresh invite for each attempted creation. -## product questions - -These are intentionally not answered by the server implementation: - -- whether the public page asks for a full handle or a short name plus an - operator-selected suffix -- how an invite is delivered and explained -- whether email verification is requested immediately after creation -- whether passkey registration is offered after the password-backed account is - established -- what recovery and migration language is appropriate for an experimental PDS - -The existing `/account` pages establish the visual and accessibility baseline. -Account creation should feel like the beginning of that resident workflow, not -an unrelated marketing page. +## product decisions + +Decisions made for `/signup` (layout studied against selfhosted.social's join +page): + +- the page asks for a short name; the suffix comes from `describeServer` and is + rendered as an attached segment with a live "you'll be name.suffix" preview + (a select appears if more than one suffix is advertised) +- the invite is a visible form field, shown only when `inviteCodeRequired`, and + prefillable via `/signup?code=...` so invites can be shared as links +- on success the session is handed to `/account` via a one-shot + `sessionStorage` key (`zds-signup-session`); email verification and passkey + registration are offered there by the existing manage and security sections +- migration is kept out of the form as a notice panel linking to PDS Moover +- the page carries a short honest note that this is an experimental PDS ## checks and references diff --git a/src/http/landing/mod.zig b/src/http/landing/mod.zig index 1a1108a..10c3c01 100644 --- a/src/http/landing/mod.zig +++ b/src/http/landing/mod.zig @@ -139,7 +139,7 @@ fn render(allocator: std.mem.Allocator) ![]const u8 { \\ \\
\\

{s}

- \\

an atproto personal data server.

+ \\

an atproto personal data server. join.

\\ {s} \\
\\ {s} diff --git a/src/http/router.zig b/src/http/router.zig index f2fabc2..bc14201 100644 --- a/src/http/router.zig +++ b/src/http/router.zig @@ -9,6 +9,7 @@ pub const Route = enum { api_openapi, stats_page, account_page, + signup_page, spaces_redirect, favicon, og_image, @@ -117,6 +118,7 @@ pub const endpoints = [_]Endpoint{ .{ .route = .api_openapi, .method = "HEAD", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI export probe without a response body." }, .{ .route = .stats_page, .method = "GET", .path = "/stats", .group = "zds", .auth = "public", .summary = "Operational health and route latency page." }, .{ .route = .stats_page, .method = "HEAD", .path = "/stats", .group = "zds", .auth = "public", .summary = "Stats page probe without a response body." }, + .{ .route = .signup_page, .method = "GET", .path = "/signup", .group = "account", .auth = "public", .summary = "Public account creation page." }, .{ .route = .account_page, .method = "GET", .path = "/account", .group = "account", .auth = "resident", .summary = "Resident account control center." }, .{ .route = .account_page, .method = "GET", .path = "/account/security", .group = "account", .auth = "resident", .summary = "Resident passkey and app-password management." }, .{ .route = .account_page, .method = "GET", .path = "/account/sessions", .group = "account", .auth = "resident", .summary = "Resident app access and direct API token view." }, @@ -287,6 +289,7 @@ test "routes pds probes" { try std.testing.expectEqual(Route.api_openapi, route(.HEAD, "/api/openapi.json")); try std.testing.expectEqual(Route.stats_page, route(.GET, "/stats")); try std.testing.expectEqual(Route.stats_page, route(.HEAD, "/stats")); + try std.testing.expectEqual(Route.signup_page, route(.GET, "/signup")); try std.testing.expectEqual(Route.account_page, route(.GET, "/account")); try std.testing.expectEqual(Route.account_page, route(.GET, "/account/security")); try std.testing.expectEqual(Route.account_page, route(.GET, "/account/sessions")); diff --git a/src/http/server.zig b/src/http/server.zig index 355867d..3f453f0 100644 --- a/src/http/server.zig +++ b/src/http/server.zig @@ -13,6 +13,7 @@ const log = @import("../core/log.zig"); const http_api = @import("api.zig"); const docs = @import("docs.zig"); const account_page = @import("../internal/account.zig"); +const signup_page = @import("../internal/signup.zig"); const passkeys = @import("../internal/passkeys.zig"); const request_context = @import("../internal/request_context.zig"); const landing = @import("landing/mod.zig"); @@ -81,6 +82,7 @@ const App = struct { .api_openapi => try docs.serveOpenApi(request), .stats_page => try stats.serve(request), .account_page => try account_page.page(request), + .signup_page => try signup_page.page(request), .spaces_redirect => try account_page.redirectToSpaces(request), .favicon => try landing.serveFavicon(request), .og_image => try landing.serveOgImage(request), diff --git a/src/internal/account.zig b/src/internal/account.zig index a76ee16..082106a 100644 --- a/src/internal/account.zig +++ b/src/internal/account.zig @@ -173,6 +173,7 @@ const html = \\$('activate-account').onclick=async()=>{try{await xrpc('/xrpc/com.atproto.server.activateAccount',{}, {method:'POST'});setStatus('account reactivated','ok');await loadAccountData()}catch(err){setStatus(err.message,'error')}} \\$('deactivate-account').onclick=async()=>{try{if(!confirm('Deactivate this account? You can reactivate by signing back in.'))return;await xrpc('/xrpc/com.atproto.server.deactivateAccount',{}, {method:'POST'});setStatus('account deactivated','warn');await loadAccountData()}catch(err){setStatus(err.message,'error')}} \\showView(); + \\try{const raw=sessionStorage.getItem('zds-signup-session');if(raw){sessionStorage.removeItem('zds-signup-session');const handoff=JSON.parse(raw);if(handoff&&handoff.accessJwt){accessJwt=handoff.accessJwt;account=handoff;loginForm.classList.add('off');resident.classList.add('on');loadAccountData().catch(err=>setStatus(err.message,'error'))}}}catch{} \\ \\ \\ diff --git a/src/internal/signup.zig b/src/internal/signup.zig new file mode 100644 index 0000000..87684a5 --- /dev/null +++ b/src/internal/signup.zig @@ -0,0 +1,100 @@ +const std = @import("std"); +const http_api = @import("../http/api.zig"); + +const http = std.http; + +pub fn page(request: *http_api.Request) !void { + try http_api.respond(request, .ok, html, &html_headers); +} + +const html_headers = [_]http.Header{ + .{ .name = "content-type", .value = "text/html; charset=utf-8" }, + .{ .name = "cache-control", .value = "no-store" }, + .{ .name = "access-control-allow-origin", .value = "*" }, + .{ .name = "access-control-allow-private-network", .value = "true" }, + .{ .name = "connection", .value = "close" }, +}; + +const html = + \\ + \\ + \\ + \\ + \\ + \\join zds + \\ + \\ + \\ + \\ + \\ + \\
+ \\zds + \\

join

+ \\

Create a new account on this PDS.

+ \\
+ \\

This creates a brand new account. Already have an atproto account you want to bring here? Use a migration tool such as PDS Moover instead — migration is a different flow with different identity semantics.

+ \\
+ \\
+ \\
+ \\ + \\
+ \\ + \\… + \\ + \\
+ \\

you'll be …

+ \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\

This is an experimental personal data server. Your account and repo live here; you can migrate them elsewhere later.

+ \\ + \\
+ \\

+ \\
+ \\

Already have an account here? Sign in.

+ \\
+ \\ + \\ + \\ +; diff --git a/tools/smoke-permissioned.sh b/tools/smoke-permissioned.sh index 0838985..725c0db 100755 --- a/tools/smoke-permissioned.sh +++ b/tools/smoke-permissioned.sh @@ -31,7 +31,9 @@ zig build dpop_proof() { ./zig-out/bin/zds-space-dpop "$@" 2>&1 } -ZDS_PERMISSIONED_DATA=true zig build run -- \ +# run the installed binary directly so $! is the server itself; killing the +# `zig build run` wrapper orphans zds and leaves the port taken for the next run +ZDS_PERMISSIONED_DATA=true ./zig-out/bin/zds \ --host 127.0.0.1 \ --port "$port" \ --db "$db" \ diff --git a/tools/smoke.sh b/tools/smoke.sh index d4693e8..e42f504 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -13,6 +13,10 @@ cleanup() { kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true fi + if [ -n "${plc_stub_pid:-}" ]; then + kill "$plc_stub_pid" 2>/dev/null || true + wait "$plc_stub_pid" 2>/dev/null || true + fi } trap cleanup EXIT INT TERM @@ -22,7 +26,26 @@ rm -rf "$blob_root" mkdir -p "$blob_root" zig build -zig build run -- \ + +# stand-in PLC directory so createAccount genesis never reaches plc.directory +plc_port="${ZDS_SMOKE_PLC_PORT:-2586}" +python3 -c " +from http.server import BaseHTTPRequestHandler, HTTPServer +class Stub(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get('content-length', 0))) + self.send_response(200) + self.end_headers() + def log_message(self, *args): + pass +HTTPServer(('127.0.0.1', $plc_port), Stub).serve_forever() +" & +plc_stub_pid=$! + +# run the installed binary directly so $! is the server itself; killing the +# `zig build run` wrapper orphans zds and leaves the port taken for the next run +ZDS_PLC_ROTATION_KEY=1111111111111111111111111111111111111111111111111111111111111111 \ +./zig-out/bin/zds \ --host 127.0.0.1 \ --port "$port" \ --db "$db" \ @@ -32,6 +55,7 @@ zig build run -- \ --handle-domains .test \ --invite-required \ --admin-token smoke-admin-token \ + --plc-directory "http://127.0.0.1:${plc_port}" \ >"$log" 2>&1 & server_pid=$! @@ -99,6 +123,33 @@ printf '%s' "$invite" | grep -q '"code":"127-0-0-1-' invites=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.server.getAccountInviteCodes") printf '%s' "$invites" | grep -q '"available":1' +signup_page=$(curl -fsS "$base/signup") +printf '%s' "$signup_page" | grep -q 'join zds' +printf '%s' "$signup_page" | grep -q 'com.atproto.server.describeServer' +printf '%s' "$signup_page" | grep -q 'com.atproto.server.createAccount' +signup_invite=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createInviteCode" \ + -H "authorization: Bearer smoke-admin-token" \ + -H 'content-type: application/json' \ + --data '{"useCount":1}' | jq -r .code) +signup=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createAccount" \ + -H 'content-type: application/json' \ + --data "$(printf '{"handle":"signup-smoke.test","email":"signup-smoke@test.com","password":"signup-password","inviteCode":"%s"}' "$signup_invite")") +printf '%s' "$signup" | grep -q '"handle":"signup-smoke.test"' +printf '%s' "$signup" | grep -q '"accessJwt":' +printf '%s' "$signup" | grep -q '"refreshJwt":' +signup_did=$(printf '%s' "$signup" | jq -r .did) +test -n "$signup_did" +signup_taken_invite=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createInviteCode" \ + -H "authorization: Bearer smoke-admin-token" \ + -H 'content-type: application/json' \ + --data '{"useCount":1}' | jq -r .code) +signup_taken_status=$(curl -sS -o /tmp/zds-signup-taken.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.server.createAccount" \ + -H 'content-type: application/json' \ + --data "$(printf '{"handle":"signup-smoke.test","email":"signup-smoke-2@test.com","password":"signup-password","inviteCode":"%s"}' "$signup_taken_invite")") +test "$signup_taken_status" = "400" +! grep -q '"error":"InvalidInviteCode"' /tmp/zds-signup-taken.json +grep -q '"error":' /tmp/zds-signup-taken.json + curl -fsS -X POST "$base/xrpc/app.bsky.actor.putPreferences" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \