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. +

+
+
+ + + +
+ + + + +
+ Which team are you on? +
+ {TEAMS.map((value) => ( + + ))} +
+

+ You can be added to more teams later. +

+
+ + {config.inviteRequired ? ( + + ) : null} + + {error ?

{error}

: null} + + +
+
+
+
+
+ ); +} 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:');