#!/usr/bin/env node // Minimal public-facing self-serve signup form — the missing piece between // onboard.js's `create` (admin-run CLI) and an actual invite flow a new // member can complete themselves. This form calls the exact same // createSignup() the CLI uses. Auto-provisioning the blento profile is a // separate, later step the member triggers themselves (see oauth.js) — // this form's job ends once the account is created. // // IMPORTANT: this form's invite-code field is only as strong as // tranquil-pds's own `inviteCodeRequired` server setting — confirm that's // actually enabled (bean haiku.garden-dwuy) before treating this as a real // access gate. createSignup() requires a non-empty invite code client-side, // but the PDS itself is what has to reject an invalid/reused one; if // invite codes aren't enforced server-side, this form is wide open. // // Deliberately NOT included (see haiku.garden-a6ay): CAPTCHA/rate-limiting // and an invite-code generator/tracker UI. Fine for a first small batch of // Season 2 invites; revisit if abuse or invite-code sprawl shows up. import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { parse as parseQuery } from 'node:querystring'; import path from 'node:path'; import { createSignup, onboardDir } from './onboard.js'; import { SCRIPT_VERSION, substitute, loadJourney } from './shared.js'; import { oauthClientMetadata, handleFinishProfile, handleOAuthCallback, finishProfileUrl, handleJourney } from './oauth.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT) || 8787; const PDS_URL = process.env.PDS_URL || 'https://haiku.garden'; const PUBLIC_URL = process.env.PUBLIC_URL || 'https://join.haiku.garden'; const HANDLE_DOMAIN = 'haiku.garden'; // Every member gets the "general" theme now (simplified 2026-08-04 — the // journey no longer varies by signup theme either, see shared.js's // loadJourney). No profile-type choice for the member to make; themes/ // musician.json and haiku-poet.json stay in the repo, just unused by this // form (onboard.js's CLI --theme flag still takes any of the three). const DEFAULT_THEME = 'general'; const ASSET_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' }; async function resolveHandleToDid(handle) { const res = await fetch(`${PDS_URL}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); const json = await res.json().catch(() => ({})); if (!res.ok || !json.did) throw new Error(`Could not resolve handle "${handle}"`); return json.did; } function esc(str = '') { return String(str).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } // createSignup()/xrpc() surface raw server errors meant for an admin's // terminal (status codes, JSON bodies, --flag names). Translate the ones a // public visitor can actually cause into plain sentences. function friendlyError(err) { const msg = err.message || ''; const jsonMatch = msg.match(/\{.*\}$/s); let body = {}; if (jsonMatch) { try { body = JSON.parse(jsonMatch[0]); } catch { /* not JSON, ignore */ } } const code = body.error || ''; const detail = `${body.message || ''}`; if (/^Missing required fields/i.test(msg)) { return 'Please fill in every required field.'; } if (code === 'InvalidInviteCode' || /invite.?code/i.test(detail)) { return "That invite code isn't valid, or it's already been used — double-check it, or ask whoever invited you for a fresh one."; } if (code === 'HandleNotAvailable' || code === 'InvalidHandle' || /handle/i.test(detail)) { return 'That handle is already taken, or not a valid handle — letters, numbers, and hyphens only.'; } if (code === 'InvalidEmail' || /email/i.test(detail)) { return "That doesn't look like a valid email address — double-check it."; } if (code === 'InvalidPassword' || /password/i.test(detail)) { return detail || 'That password doesn\'t meet the requirements — at least 8 characters, with an uppercase letter, a lowercase letter, and a number.'; } return detail || 'Something went wrong creating your account. Please try again in a moment.'; } function renderPage({ prefill = {}, error, success }) { const banner = success ? `

Check your email

We just sent a verification link to ${esc(success.email)}. Click it to confirm your account — you can already log in with the password you chose.

Once you've verified, come back to this link whenever you're ready to finish setting up your profile at ${esc(success.handle)}. It'll ask you to log in with your handle and password and approve a narrow permission (just enough to write your starter profile, nothing else) — takes a few seconds. No rush, come back whenever.

Your journey page — what to do once you're set up (plyr.fm, this week's haiku challenge, chat, and more) — lives at ${esc(PUBLIC_URL)}/journey?handle=${esc(success.handle)}. No need to bookmark it — it's just your handle, so you can always get back to it from memory, whether or not you've finished the profile setup step above yet.

` : ''; const form = success ? '' : ` ${error ? `
${esc(error)}
` : ''}
`; return ` Join haiku.garden

your home in the atmosphere.

Join haiku.garden

Naviar Haiku community members can request an account below. You'll need an invite code from whoever invited you.

${banner} ${form} `; } // The journey page (haiku.garden-k41j) — a flat, ungated list of steps, // the same for every member regardless of their signup theme, with // {{handle}}/etc. substituted in. Deliberately no progress tracking (no // checkmarks, nothing to verify against third-party lexicons yet) — see // haiku.garden-wzlf's "Progress tracking" decision. Reuses the same visual // language as the signup form. function renderJourneyPage({ steps, vars, handle }) { const cards = steps .map((step) => { const links = step.links .map((link) => `${esc(link.label)}`) .join('\n'); return `

${esc(step.title)}

${esc(substitute(step.body, vars))}

`; }) .join('\n'); return ` Your haiku.garden journey

your home in the atmosphere.

Welcome, ${esc(handle)}

Here's what there is to do — in whatever order suits you, whenever you're ready. Nothing here expires or locks; come back to this page any time.

${cards} `; } async function serveAsset(name, res) { const ext = path.extname(name); const type = ASSET_TYPES[ext]; if (!type || name.includes('..') || name.includes('/')) { res.writeHead(404); res.end('Not found'); return; } try { const data = await readFile(path.join(onboardDir, 'assets', name)); res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'public, max-age=86400' }); res.end(data); } catch { res.writeHead(404); res.end('Not found'); } } function readBody(req) { return new Promise((resolve, reject) => { let data = ''; req.on('data', (chunk) => { data += chunk; if (data.length > 10_000) req.destroy(new Error('Body too large')); }); req.on('end', () => resolve(parseQuery(data))); req.on('error', reject); }); } async function main() { const server = createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (req.method === 'GET' && url.pathname.startsWith('/assets/')) { await serveAsset(url.pathname.slice('/assets/'.length), res); return; } if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(renderPage({})); return; } if (req.method === 'POST' && url.pathname === '/') { const form = await readBody(req); const handleLabel = (form.handle || '').trim().toLowerCase(); const fields = { theme: DEFAULT_THEME, handle: handleLabel ? `${handleLabel}.${HANDLE_DOMAIN}` : '', email: (form.email || '').trim(), password: form.password || '', displayName: (form.displayName || '').trim(), bio: (form.bio || '').trim(), inviteCode: (form.inviteCode || '').trim() }; try { const result = await createSignup(fields, PDS_URL); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(renderPage({ success: result })); } catch (err) { console.error(`[signup] failed for ${fields.handle || '(no handle)'}: ${err.message}`); res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); const { password: _password, ...formWithoutPassword } = form; res.end( renderPage({ prefill: { ...formWithoutPassword, handle: handleLabel }, error: friendlyError(err) }) ); } return; } if (req.method === 'GET' && url.pathname === '/oauth-client-metadata.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(oauthClientMetadata(PUBLIC_URL))); return; } if (req.method === 'GET' && url.pathname === '/finish-profile') { try { const { redirectUrl } = await handleFinishProfile(url.searchParams.get('token'), PUBLIC_URL); res.writeHead(302, { Location: redirectUrl }); res.end(); } catch (err) { console.error(`[finish-profile] failed: ${err.message}`); res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end( renderPage({ error: "That link isn't valid or has expired — please use the link from your original confirmation page, or sign up again." }) ); } return; } if (req.method === 'GET' && url.pathname === '/oauth/callback') { try { const { redirectUrl, error } = await handleOAuthCallback(url.searchParams, PUBLIC_URL); if (error === 'not-verified') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end( renderPage({ error: 'Please verify your email first (check your inbox for the link from tranquil-pds), then use the "finish profile" link again.' }) ); return; } res.writeHead(302, { Location: redirectUrl }); res.end(); } catch (err) { console.error(`[oauth-callback] failed: ${err.message}`); res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end( renderPage({ error: 'Something went wrong finishing your profile setup. Please try the "finish profile" link again.' }) ); } return; } if (req.method === 'GET' && url.pathname === '/journey') { try { const handleParam = url.searchParams.get('handle'); // Two ways in: the signed token (carries did/theme/vars from // signup, used right after finish-profile) or just a bare // ?handle= — journey.json only ever substitutes {{handle}}, // so a handle alone is enough to render the same page with no // token to lose or bookmark. did is resolved fresh each time // (no signature needed — this route is read-only content, not // an authenticated action). const claims = handleParam ? { handle: handleParam, did: await resolveHandleToDid(handleParam), theme: DEFAULT_THEME, vars: { handle: handleParam } } : handleJourney(url.searchParams.get('token')); const steps = await loadJourney(); const vars = { ...claims.vars, finishProfileUrl: finishProfileUrl(claims, PUBLIC_URL) }; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(renderJourneyPage({ steps, vars, handle: claims.handle })); } catch (err) { console.error(`[journey] failed: ${err.message}`); res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end( renderPage({ error: "That link isn't valid — please use the link from your original confirmation page or email." }) ); } return; } res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); }); server.listen(PORT, () => { console.log(`Signup form ${SCRIPT_VERSION} listening on http://localhost:${PORT} (PDS: ${PDS_URL})`); }); } main();