#!/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
? `
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
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.Check your email
${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 home in the atmosphere.
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(substitute(step.body, vars))}
your home in the atmosphere.
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();