diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 1660a04..7d137ca 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -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 {
diff --git a/apps/api/src/routes/register.ts b/apps/api/src/routes/register.ts
new file mode 100644
index 0000000..dd33a8a
--- /dev/null
+++ b/apps/api/src/routes/register.ts
@@ -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;
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 3de2721..11ab6ce 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -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 Registration is not open here
+ This deployment cannot create accounts on its own. Ask an administrator to
+ provision one for you, then sign in.
+
+ Use any email you like — your invite code is what grants access.
+ Create your account
+
+ Have an invite code but no account? +
+ +PIG is invite-only. An account alone does not grant access.