Let people register with a personal email and an invite code

The invite gate was unreachable. PIG shares its identity provider with another
application, and self-registration there is deliberately switched off — so
somebody with a personal address and a valid invite code could never obtain a
token, and therefore could never reach the endpoint that accepts the code. The
gate was real and nothing could ever arrive at it.

Opening self-registration on the provider would have opened it for the
neighbouring application too, which is precisely why it was closed. So PIG now
mints the account itself through the provider's admin API, and only after the
invite validates. The provider stays shut; the invite becomes the actual gate.

Order is deliberate: validate the invite, create the auth account, create the
profile, consume the invite. If the profile write fails the auth account is
deleted again — otherwise someone could sign in with no profile and no way to
obtain one, because their invite would look spent.

An administrator's address does NOT bypass this. The bypass in /api/signup
exists to bootstrap the first admin from an account that already exists; here
an account is created from nothing, and an ungated version of that is simply
an open registration endpoint.

Registration signs the person in on success rather than returning them to a
login form to retype the password they entered ten seconds earlier. An address
that already exists in the provider but has no PIG profile is detected and
pointed at sign-in, since /api/signup handles that case properly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 20:15:32 -07:00
parent 045e51bc5c
commit 7719850fc5
6 changed files with 514 additions and 4 deletions
+21 -1
View File
@@ -42,6 +42,7 @@ import type { Config } from './lib/config';
import { AuthError, createAuthenticator, type Principal } from './lib/auth';
import { CapacityService } from './services/capacity';
import { createSignupRoute } from './routes/signup';
import { createRegisterRoute } from './routes/register';
type Env = { Variables: { principal: Principal } };
@@ -69,6 +70,14 @@ export function createApp(config: Config, db: Database) {
*/
app.route('/', createSignupRoute(config, db));
/*
* Registration. Also before the auth middleware, and necessarily so: the
* caller has no account yet, so there is no token to present. The invite
* code is the only gate, which is why it is validated before anything is
* created anywhere.
*/
app.route('/', createRegisterRoute(config, db));
/** Liveness. Unauthenticated by design so a load balancer can reach it. */
app.get('/api/health', (c) => c.json({ ok: true, service: 'pig', version: '0.1.0' }));
@@ -83,6 +92,9 @@ export function createApp(config: Config, db: Database) {
supabaseAnonKey: config.SUPABASE_ANON_KEY ?? null,
authDisabled: !config.SUPABASE_URL,
inviteRequired: Boolean(config.PIG_INVITE_CODE),
// Whether someone with an invite can create an account outright, or must
// be provisioned by an administrator first.
canSelfRegister: Boolean(config.SUPABASE_URL && config.SUPABASE_SERVICE_KEY),
accents: ACCENTS.map((a) => ({ key: a.key, label: a.label })),
teams: TEAMS,
}),
@@ -91,7 +103,15 @@ export function createApp(config: Config, db: Database) {
// Everything below requires a principal.
app.use('/api/*', async (c, next) => {
const path = new URL(c.req.url).pathname;
if (path === '/api/health' || path === '/api/config' || path === '/api/signup') {
// Public by necessity: health for load balancers, config for the front
// end before sign-in, and the two join routes for people who are not yet
// members. Each verifies whatever it needs itself.
if (
path === '/api/health' ||
path === '/api/config' ||
path === '/api/signup' ||
path === '/api/register'
) {
return next();
}
try {
+226
View File
@@ -0,0 +1,226 @@
/**
* Registration — create an account and a profile in one step, gated on an
* invite code.
*
* **Why this exists.** PIG shares its identity provider with another
* application, and self-registration there is deliberately switched off. That
* left the invite code unreachable: a new person with a personal address could
* not obtain a token, so they could never get as far as the endpoint that
* accepts the code. The gate was real but nothing could ever reach it.
*
* Opening self-registration on the provider would have opened it for the
* neighbouring application too. So instead PIG mints the account itself, using
* the provider's admin API, and **only after the invite code has been
* validated**. The provider stays closed; the invite becomes the real gate.
*
* The order matters and is deliberate:
*
* 1. Validate the invite. No invite, nothing happens at all.
* 2. Create the identity-provider account.
* 3. Create the PIG profile and team membership.
* 4. Consume the invite.
*
* If step 3 fails the provider account is removed again, so a half-registered
* person cannot exist — able to sign in, but with no profile and no way to get
* one because the invite is spent.
*/
import { Hono } from 'hono';
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { createHash } from 'node:crypto';
import type { Database } from '@pig/db';
import { invites, teamMemberships, users } from '@pig/db';
import { TEAMS } from '@pig/core';
import type { Config } from '../lib/config';
const bodySchema = z.object({
email: z.string().email().max(200),
// Eight is the floor. The provider's own default is six, which is too short
// for an account that reaches commercial data.
password: z.string().min(8, 'Use at least 8 characters.').max(200),
name: z.string().min(1).max(120),
team: z.enum(TEAMS),
inviteCode: z.string().min(1).max(200),
title: z.string().max(160).optional(),
});
export function createRegisterRoute(config: Config, db: Database) {
const app = new Hono();
app.post('/api/register', async (c) => {
if (!config.SUPABASE_URL || !config.SUPABASE_SERVICE_KEY) {
// Without the admin credential PIG cannot mint accounts. Say so plainly
// rather than failing in a way that looks like the invite is wrong.
return c.json(
{
error:
'Self-registration is not configured on this deployment. Ask an ' +
'administrator to create your account.',
code: 'registration_unavailable',
},
503,
);
}
const parsed = bodySchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) {
return c.json(
{ error: parsed.error.issues[0]?.message ?? 'Invalid details', code: 'invalid_body' },
400,
);
}
const email = parsed.data.email.trim().toLowerCase();
// --- 1. The invite, before anything is created anywhere ----------------
//
// An administrator's address does NOT bypass this. The bypass in
// /api/signup exists to bootstrap the first admin from an account that
// already exists; here we are creating an account from nothing, and an
// ungated version of that is an open registration endpoint.
const codeHash = createHash('sha256').update(parsed.data.inviteCode.trim()).digest('hex');
const [invite] = await db
.select()
.from(invites)
.where(
and(
eq(invites.codeHash, codeHash),
isNull(invites.revokedAt),
gt(invites.usesRemaining, 0),
),
)
.limit(1);
if (!invite) {
return c.json({ error: 'That invite code is not valid.', code: 'invalid_invite' }, 403);
}
if (invite.expiresAt && invite.expiresAt < new Date()) {
return c.json({ error: 'That invite code has expired.', code: 'expired_invite' }, 403);
}
if (invite.email && invite.email.toLowerCase() !== email) {
return c.json(
{ error: 'That invite code was issued to a different address.', code: 'invite_mismatch' },
403,
);
}
// Already a PIG member? Send them to sign in rather than creating a second
// account they cannot use.
const [existingProfile] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existingProfile) {
return c.json(
{ error: 'That address is already a member. Sign in instead.', code: 'already_member' },
409,
);
}
// --- 2. The identity-provider account ---------------------------------
const adminHeaders = {
apikey: config.SUPABASE_SERVICE_KEY,
authorization: `Bearer ${config.SUPABASE_SERVICE_KEY}`,
'content-type': 'application/json',
};
let authSubject: string;
try {
const response = await fetch(`${config.SUPABASE_URL}/auth/v1/admin/users`, {
method: 'POST',
headers: adminHeaders,
body: JSON.stringify({
email,
password: parsed.data.password,
// Confirmed on creation: the invite already proved this person was
// vouched for, and requiring a second confirmation email would strand
// anyone whose address cannot receive mail from this project.
email_confirm: true,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
// An address that already exists in the provider but has no PIG
// profile is a real case — someone with an account from the sibling
// application. Direct them to sign in and complete a profile, which
// /api/signup handles.
if (response.status === 422 || detail.includes('already been registered')) {
return c.json(
{
error:
'An account already exists for that address. Sign in with it, and ' +
'you will be asked to set up your PIG profile.',
code: 'account_exists',
},
409,
);
}
console.error('[pig] provider rejected account creation:', response.status, detail.slice(0, 200));
return c.json({ error: 'Could not create the account.', code: 'provider_error' }, 502);
}
const created = (await response.json()) as { id?: string };
if (!created.id) throw new Error('provider returned no user id');
authSubject = created.id;
} catch (error) {
console.error('[pig] account creation failed:', error);
return c.json({ error: 'Could not create the account.', code: 'provider_error' }, 502);
}
// --- 3. The PIG profile ------------------------------------------------
try {
const [user] = await db
.insert(users)
.values({
authSubject,
email,
name: parsed.data.name,
title: parsed.data.title,
// Admin rights come only from server configuration, never from a
// registration payload.
isPlatformAdmin: config.adminEmails.includes(email),
})
.returning();
if (!user) throw new Error('profile insert returned nothing');
await db
.insert(teamMemberships)
.values({
userId: user.id,
team: parsed.data.team,
role: config.adminEmails.includes(email) ? 'admin' : 'member',
isPrimary: true,
})
.onConflictDoNothing();
// --- 4. Consume the invite -------------------------------------------
await db
.update(invites)
.set({
redeemedByUserId: user.id,
redeemedAt: new Date(),
usesRemaining: sql`GREATEST(0, ${invites.usesRemaining} - 1)`,
})
.where(eq(invites.id, invite.id));
return c.json({ ok: true, email: user.email }, 201);
} catch (error) {
// Roll the provider account back. Without this, the person could sign in
// but would have no profile and no way to obtain one, because their
// invite would look spent — a genuinely stuck state.
console.error('[pig] profile creation failed, removing the auth account:', error);
await fetch(`${config.SUPABASE_URL}/auth/v1/admin/users/${authSubject}`, {
method: 'DELETE',
headers: adminHeaders,
}).catch(() => {});
return c.json({ error: 'Could not complete registration.', code: 'profile_error' }, 500);
}
});
return app;
}
+19 -1
View File
@@ -15,6 +15,7 @@ import { Accounts } from '@/pages/Accounts';
import { Margin } from '@/pages/Margin';
import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile';
import { Register } from '@/pages/Register';
import { PiggyMark } from '@/components/PiggyMark';
import { EmptyState } from '@/components/ui';
import { usePageTitle } from '@/lib/title';
@@ -86,6 +87,10 @@ export function App() {
* the default behaviour if both are treated as "auth error".
*/
function AuthGate({ config }: { config: PublicConfig }) {
// Which unauthenticated screen to show. Kept in state rather than a route so
// that a half-filled registration form is not lost to an accidental Back.
const [showRegister, setShowRegister] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['me'],
queryFn: () => get<{ id: string; name: string }>('/api/me'),
@@ -116,7 +121,20 @@ function AuthGate({ config }: { config: PublicConfig }) {
if (isLoading) return <Splash />;
if (error instanceof ApiError) {
if (error.needsSignIn) return <SignIn config={config} />;
if (error.needsSignIn) {
return showRegister ? (
<Register
config={config}
onBack={() => setShowRegister(false)}
onRegistered={() => {
setShowRegister(false);
void refetch();
}}
/>
) : (
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
);
}
// Authenticated but not a member. This is a step in the flow, not an
// error — sending them back to a login screen they have already completed
// would be a loop with no exit.
+1
View File
@@ -15,6 +15,7 @@ export interface PublicConfig {
supabaseAnonKey: string | null;
authDisabled: boolean;
inviteRequired: boolean;
canSelfRegister: boolean;
accents: { key: string; label: string }[];
teams: string[];
}
+226
View File
@@ -0,0 +1,226 @@
/**
* Create an account.
*
* For someone who has an invite code but no account at all — the common case,
* because the identity provider's own self-registration is deliberately shut.
* PIG mints the account server-side once the invite validates, then signs the
* person straight in so they land on the dashboard rather than being returned
* to a login form having just typed their password.
*/
import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, getSupabase, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
import { usePageTitle } from '@/lib/title';
export function Register({
config,
onBack,
onRegistered,
}: {
config: PublicConfig;
onBack: () => void;
onRegistered: () => void;
}) {
usePageTitle('Create account');
const [form, setForm] = useState({
name: '',
email: '',
password: '',
inviteCode: '',
title: '',
});
const [team, setTeam] = useState<Team>('demand');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const set = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }));
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
try {
await post('/api/register', {
email: form.email.trim(),
password: form.password,
name: form.name.trim(),
team,
inviteCode: form.inviteCode.trim(),
title: form.title.trim() || undefined,
});
// Sign in immediately. Making someone re-enter the password they typed
// ten seconds ago reads as though registration silently failed.
const supabase = getSupabase();
if (supabase) {
await supabase.auth.signInWithPassword({
email: form.email.trim(),
password: form.password,
});
}
onRegistered();
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Could not create your account. Try again.',
);
} finally {
setBusy(false);
}
}
if (!config.canSelfRegister) {
return (
<Centered>
<Card>
<CardContent className="space-y-3 pt-5 text-center">
<p className="font-medium">Registration is not open here</p>
<p className="text-sm text-muted">
This deployment cannot create accounts on its own. Ask an administrator to
provision one for you, then sign in.
</p>
<Button variant="outline" onClick={onBack} className="w-full">
Back to sign in
</Button>
</CardContent>
</Card>
</Centered>
);
}
return (
<Centered>
<div className="mb-6 flex flex-col items-center gap-3 text-center">
<PiggyMark className="h-12 w-12 text-fg" title="pig" />
<div>
<h1 className="text-xl font-semibold tracking-tight">Create your account</h1>
<p className="mt-1 text-sm text-muted">
Use any email you like your invite code is what grants access.
</p>
</div>
</div>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<label className="block">
<span className="mb-1 block text-sm font-medium">Invite code</span>
<Input
required
value={form.inviteCode}
onChange={set('inviteCode')}
placeholder="Ask whoever invited you"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium">Your name</span>
<Input required value={form.name} onChange={set('name')} autoComplete="name" />
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium">Email</span>
<Input
type="email"
required
value={form.email}
onChange={set('email')}
placeholder="you@anywhere.com"
autoComplete="email"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium">Password</span>
<Input
type="password"
required
minLength={8}
value={form.password}
onChange={set('password')}
// `new-password` is what prompts a password manager to offer a
// generated one rather than autofilling an existing login.
autoComplete="new-password"
/>
<span className="mt-1 block text-xs text-muted">At least 8 characters.</span>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span>
</span>
<Input value={form.title} onChange={set('title')} placeholder="Head of Growth" />
</label>
<fieldset>
<legend className="mb-2 text-sm font-medium">Which team are you on?</legend>
<div className="space-y-2">
{TEAMS.map((value) => (
<label
key={value}
className={[
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
team === value
? 'border-accent bg-accent-subtle'
: 'border-border hover:bg-surface-2',
].join(' ')}
>
<input
type="radio"
name="team"
checked={team === value}
onChange={() => setTeam(value)}
className="mt-0.5 h-4 w-4 accent-[hsl(var(--accent))]"
/>
<span className="min-w-0">
<span className="block text-sm font-medium">{TEAM_LABELS[value]}</span>
<span className="block text-xs text-muted">{TEAM_DESCRIPTIONS[value]}</span>
</span>
</label>
))}
</div>
</fieldset>
{error ? (
<p className="text-sm text-danger" role="alert">
{error}
</p>
) : null}
<Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating your account…' : 'Create account'}
</Button>
<button
type="button"
onClick={onBack}
className="tap flex w-full items-center justify-center gap-1.5 text-sm font-medium text-muted"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
I already have an account
</button>
</form>
</CardContent>
</Card>
</Centered>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
<div className="w-full max-w-sm">{children}</div>
</div>
);
}
+21 -2
View File
@@ -19,7 +19,13 @@ import { PiggyMark } from '@/components/PiggyMark';
type Method = 'password' | 'link';
export function SignIn({ config }: { config: PublicConfig }) {
export function SignIn({
config,
onCreateAccount,
}: {
config: PublicConfig;
onCreateAccount: () => void;
}) {
const [method, setMethod] = useState<Method>('password');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -183,7 +189,20 @@ export function SignIn({ config }: { config: PublicConfig }) {
</p>
) : null}
{config.inviteRequired ? (
{config.canSelfRegister ? (
<div className="border-t border-border pt-3 text-center">
<p className="text-sm text-muted">
Have an invite code but no account?
</p>
<button
type="button"
onClick={onCreateAccount}
className="tap mt-1 text-sm font-medium text-accent-fg"
>
Create an account
</button>
</div>
) : config.inviteRequired ? (
<p className="pt-1 text-center text-xs text-muted">
PIG is invite-only. An account alone does not grant access.
</p>