diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index ed2c60c..1660a04 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -41,6 +41,7 @@ import {
import type { Config } from './lib/config';
import { AuthError, createAuthenticator, type Principal } from './lib/auth';
import { CapacityService } from './services/capacity';
+import { createSignupRoute } from './routes/signup';
type Env = { Variables: { principal: Principal } };
@@ -61,6 +62,13 @@ export function createApp(config: Config, db: Database) {
}),
);
+ /*
+ * Profile creation. Mounted BEFORE the auth middleware because it is the
+ * route that turns an authenticated stranger into a member — requiring
+ * membership to reach it would be circular. It verifies the token itself.
+ */
+ app.route('/', createSignupRoute(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' }));
diff --git a/apps/api/src/routes/signup.ts b/apps/api/src/routes/signup.ts
new file mode 100644
index 0000000..1334494
--- /dev/null
+++ b/apps/api/src/routes/signup.ts
@@ -0,0 +1,196 @@
+/**
+ * Profile creation — how an authenticated person becomes a PIG user.
+ *
+ * This closes the gap between the two halves of access control. Signing in
+ * with the identity provider proves who someone is; it does not make them a
+ * member of this workspace. This route is the deliberate step in between,
+ * and it is the only way a `users` row is created for a real person.
+ *
+ * Two ways to pass:
+ *
+ * 1. **A valid invite code.** The normal path. Codes are stored hashed, can
+ * be pinned to an address, can expire, and are consumed on redemption.
+ *
+ * 2. **Being listed in `PIG_ADMIN_EMAILS`.** The bootstrap path, which
+ * exists to solve the first-administrator problem: on a fresh deployment
+ * nobody can issue an invite because nobody can sign in to issue one.
+ *
+ * The bootstrap path is the dangerous one, so it is narrow by construction:
+ * the address must be listed in configuration by whoever controls the server,
+ * AND must match the verified `email` claim on the token. Listing an address
+ * nobody owns is a standing offer of admin rights to whoever registers it
+ * first — which is why the deployment docs say every entry must already have
+ * an account, and why the server warns at boot.
+ */
+import { Hono } from 'hono';
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+import { and, eq, 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({
+ name: z.string().min(1).max(120),
+ team: z.enum(TEAMS),
+ inviteCode: z.string().min(1).max(200).optional(),
+ title: z.string().max(160).optional(),
+});
+
+export function createSignupRoute(config: Config, db: Database) {
+ const app = new Hono();
+
+ const jwks = config.SUPABASE_URL
+ ? createRemoteJWKSet(new URL(`${config.SUPABASE_URL}/auth/v1/.well-known/jwks.json`))
+ : null;
+
+ app.post('/api/signup', async (c) => {
+ const parsed = bodySchema.safeParse(await c.req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return c.json({ error: 'Invalid profile', issues: parsed.error.issues }, 400);
+ }
+
+ // Identity must be proven before anything else is considered.
+ const header = c.req.header('authorization');
+ if (!header?.startsWith('Bearer ')) {
+ return c.json({ error: 'Sign in first.', code: 'no_token' }, 401);
+ }
+
+ let subject: string;
+ let email: string;
+
+ if (!config.SUPABASE_URL) {
+ // Development only. `loadConfig` refuses to start in production without
+ // an identity provider, so this branch cannot exist in a real deployment.
+ if (config.isProduction) {
+ return c.json({ error: 'Authentication is not configured.' }, 500);
+ }
+ subject = '00000000-0000-0000-0000-000000000000';
+ email = 'dev@localhost';
+ } else {
+ try {
+ const { payload } = await jwtVerify(header.slice(7).trim(), jwks!, {
+ issuer: `${config.SUPABASE_URL}/auth/v1`,
+ });
+ if (!payload.sub || typeof payload.email !== 'string') {
+ throw new Error('token missing subject or email');
+ }
+ subject = payload.sub;
+ email = payload.email.toLowerCase();
+ } catch {
+ return c.json({ error: 'Invalid or expired token.', code: 'invalid_token' }, 401);
+ }
+ }
+
+ // Already a member? Return the existing row rather than erroring — a
+ // double-submitted form should be harmless.
+ const [existing] = await db
+ .select()
+ .from(users)
+ .where(or(eq(users.authSubject, subject), eq(users.email, email)))
+ .limit(1);
+
+ if (existing) {
+ // An invited-but-never-signed-in row is claimed here, binding it to the
+ // identity that just proved ownership of the address.
+ if (!existing.authSubject) {
+ const [claimed] = await db
+ .update(users)
+ .set({ authSubject: subject, updatedAt: new Date() })
+ .where(eq(users.id, existing.id))
+ .returning();
+ return c.json({ user: claimed, claimed: true });
+ }
+ return c.json({ user: existing, alreadyMember: true });
+ }
+
+ const isBootstrapAdmin = config.adminEmails.includes(email);
+
+ // Resolve the invite before writing anything, so a failed redemption
+ // leaves no partial user behind.
+ let inviteId: string | null = null;
+ if (!isBootstrapAdmin) {
+ if (!parsed.data.inviteCode) {
+ return c.json(
+ { error: 'An invite code is required to join this workspace.', code: 'invite_required' },
+ 403,
+ );
+ }
+
+ 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),
+ isNull(invites.redeemedAt),
+ ),
+ )
+ .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);
+ }
+ // A pinned invite may only be redeemed by the address it names.
+ if (invite.email && invite.email.toLowerCase() !== email) {
+ return c.json(
+ { error: 'That invite code was issued to a different address.', code: 'invite_mismatch' },
+ 403,
+ );
+ }
+ inviteId = invite.id;
+ }
+
+ const [created] = await db
+ .insert(users)
+ .values({
+ authSubject: subject,
+ email,
+ name: parsed.data.name,
+ title: parsed.data.title,
+ // Admin rights are NOT taken from the request. They come from the
+ // server's own configuration, so a crafted payload cannot grant them.
+ isPlatformAdmin: isBootstrapAdmin,
+ })
+ .returning();
+
+ if (!created) return c.json({ error: 'Could not create the profile.' }, 500);
+
+ await db
+ .insert(teamMemberships)
+ .values({
+ userId: created.id,
+ team: parsed.data.team,
+ role: isBootstrapAdmin ? 'admin' : 'member',
+ isPrimary: true,
+ })
+ .onConflictDoNothing();
+
+ if (inviteId) {
+ // Consume the invite. Decrementing rather than deleting keeps the audit
+ // trail of who redeemed what.
+ await db
+ .update(invites)
+ .set({
+ redeemedByUserId: created.id,
+ redeemedAt: new Date(),
+ usesRemaining: sql`GREATEST(0, (${invites.usesRemaining})::int - 1)`,
+ })
+ .where(eq(invites.id, inviteId));
+ }
+
+ return c.json({ user: created, bootstrapAdmin: isBootstrapAdmin }, 201);
+ });
+
+ return app;
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 5feda59..049a699 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -14,6 +14,7 @@ import { Settings } from '@/pages/Settings';
import { Accounts } from '@/pages/Accounts';
import { Margin } from '@/pages/Margin';
import { SignIn } from '@/pages/SignIn';
+import { CreateProfile } from '@/pages/CreateProfile';
import { PiggyMark } from '@/components/PiggyMark';
import { EmptyState } from '@/components/ui';
@@ -115,15 +116,11 @@ function AuthGate({ config }: { config: PublicConfig }) {
if (error instanceof ApiError) {
if (error.needsSignIn) return ;
+ // 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.
if (error.needsProfile) {
- return (
-
-
-
- );
+ return void refetch()} />;
}
}
diff --git a/apps/web/src/pages/CreateProfile.tsx b/apps/web/src/pages/CreateProfile.tsx
new file mode 100644
index 0000000..758cb47
--- /dev/null
+++ b/apps/web/src/pages/CreateProfile.tsx
@@ -0,0 +1,152 @@
+/**
+ * Profile creation.
+ *
+ * Shown when someone is authenticated but is not yet a member — the state that
+ * exists precisely because PIG shares its identity provider with other
+ * applications. Without this screen that state is a dead end, which is a
+ * miserable thing to hand someone who has just clicked a sign-in link.
+ */
+import { useState } from 'react';
+import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
+import { ApiError, post, type PublicConfig } from '@/lib/api';
+import { Button, Card, CardContent, Input } from '@/components/ui';
+import { PiggyMark } from '@/components/PiggyMark';
+
+export function CreateProfile({
+ config,
+ onCreated,
+}: {
+ config: PublicConfig;
+ onCreated: () => void;
+}) {
+ const [name, setName] = useState('');
+ const [title, setTitle] = useState('');
+ const [team, setTeam] = useState('demand');
+ const [inviteCode, setInviteCode] = useState('');
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ setBusy(true);
+ setError(null);
+ try {
+ await post('/api/signup', {
+ name,
+ team,
+ title: title || undefined,
+ inviteCode: inviteCode || undefined,
+ });
+ onCreated();
+ } catch (err) {
+ // The server distinguishes "no code", "wrong code", "expired" and
+ // "issued to someone else". All four are actionable, so all four are
+ // shown rather than flattened into "access denied".
+ setError(err instanceof ApiError ? err.message : 'Could not create your profile.');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
Set up your profile
+
+ You're signed in. One more step to join the workspace.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/db/src/seed/index.ts b/packages/db/src/seed/index.ts
index 437d35e..85e5a5a 100644
--- a/packages/db/src/seed/index.ts
+++ b/packages/db/src/seed/index.ts
@@ -321,8 +321,12 @@ async function seed() {
// Only when the table is empty. With authentication disabled in development
// the API adopts the first user it finds, so creating one unconditionally
// could hand a local session to the wrong identity.
+ //
+ // Skipped entirely in production: a platform-admin row with no auth subject
+ // is unreachable (nobody can sign in as it), but leaving an admin-flagged
+ // placeholder in a real deployment is untidy at best and a trap at worst.
const existing = await db.select({ id: users.id }).from(users).limit(1);
- if (existing.length === 0) {
+ if (existing.length === 0 && process.env.NODE_ENV !== 'production') {
const [devUser] = await db
.insert(users)
.values({
@@ -343,6 +347,11 @@ async function seed() {
}
console.log(' Development user created (dev@localhost), on all three teams.');
}
+ } else if (existing.length === 0) {
+ console.log(
+ ' Skipped the development user (NODE_ENV=production). Sign in and create a ' +
+ 'profile; an address in PIG_ADMIN_EMAILS bootstraps the first admin.',
+ );
}
console.log('\nUnresolved names, recorded rather than invented:');