diff --git a/apps/api/src/routes/signup.ts b/apps/api/src/routes/signup.ts index 1334494..06c6d16 100644 --- a/apps/api/src/routes/signup.ts +++ b/apps/api/src/routes/signup.ts @@ -24,7 +24,7 @@ */ import { Hono } from 'hono'; import { createRemoteJWKSet, jwtVerify } from 'jose'; -import { and, eq, isNull, or, sql } from 'drizzle-orm'; +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'; @@ -123,6 +123,11 @@ export function createSignupRoute(config: Config, db: Database) { .update(parsed.data.inviteCode.trim()) .digest('hex'); + // Availability is decided by `usesRemaining`, NOT by `redeemedAt`. + // A shared workspace code records who redeemed it most recently while + // remaining valid for the next person; keying off `redeemedAt` would + // make every reusable invite single-use, which is a confusing way to + // lock a team out. const [invite] = await db .select() .from(invites) @@ -130,7 +135,7 @@ export function createSignupRoute(config: Config, db: Database) { and( eq(invites.codeHash, codeHash), isNull(invites.revokedAt), - isNull(invites.redeemedAt), + gt(invites.usesRemaining, 0), ), ) .limit(1); @@ -184,7 +189,7 @@ export function createSignupRoute(config: Config, db: Database) { .set({ redeemedByUserId: created.id, redeemedAt: new Date(), - usesRemaining: sql`GREATEST(0, (${invites.usesRemaining})::int - 1)`, + usesRemaining: sql`GREATEST(0, ${invites.usesRemaining} - 1)`, }) .where(eq(invites.id, inviteId)); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index af3a0e1..d7047a2 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -14,6 +14,7 @@ import { createDatabase } from '@pig/db'; import { createApp } from './app'; import { loadConfig } from './lib/config'; import { startPrimeSync } from './services/sync'; +import { reconcileInviteCode } from './services/bootstrap'; import { CapacityService } from './services/capacity'; const config = loadConfig(); @@ -29,6 +30,12 @@ if (existsSync(webDist)) { console.log('[pig] serving front end from', webDist); } +// Reconcile configuration into the database before accepting traffic, so a +// freshly set invite code works on the first request rather than the second. +await reconcileInviteCode(config, db).catch((error) => + console.error('[pig] could not reconcile the invite code:', error), +); + const server = serve({ fetch: app.fetch, port: config.PIG_PORT }, (info) => { console.log(`[pig] listening on http://localhost:${info.port}`); console.log(`[pig] environment: ${config.NODE_ENV}`); diff --git a/apps/api/src/services/bootstrap.ts b/apps/api/src/services/bootstrap.ts new file mode 100644 index 0000000..99c4985 --- /dev/null +++ b/apps/api/src/services/bootstrap.ts @@ -0,0 +1,79 @@ +/** + * Boot-time reconciliation of configuration into the database. + * + * This exists to close a gap that was genuinely confusing: `PIG_INVITE_CODE` + * looked like it gated signup, but signup validates against the `invites` + * table, so setting the variable only flipped a label in the UI. An operator + * would set it, hand the code to a colleague, and watch them be rejected. + * + * Now the variable is reconciled into a real, reusable invite row at every + * boot. Changing the value in the environment and restarting revokes the old + * code and issues the new one, which is the behaviour anyone setting an + * environment variable would reasonably expect. + */ +import { and, eq, isNull, ne } from 'drizzle-orm'; +import { createHash } from 'node:crypto'; +import type { Database } from '@pig/db'; +import { invites } from '@pig/db'; +import type { Config } from '../lib/config'; + +/** The label marking the invite owned by configuration rather than by a person. */ +const ENV_INVITE_LABEL = 'env:PIG_INVITE_CODE'; + +export async function reconcileInviteCode(config: Config, db: Database): Promise { + const code = config.PIG_INVITE_CODE?.trim(); + + // No code configured: revoke any previously configured one, so removing the + // variable actually closes the door rather than leaving it ajar. + if (!code) { + const revoked = await db + .update(invites) + .set({ revokedAt: new Date() }) + .where(and(eq(invites.scopeNote, ENV_INVITE_LABEL), isNull(invites.revokedAt))) + .returning({ id: invites.id }); + if (revoked.length) { + console.log('[pig] PIG_INVITE_CODE is unset — revoked the previous configured invite.'); + } + return; + } + + const codeHash = createHash('sha256').update(code).digest('hex'); + + const [existing] = await db + .select() + .from(invites) + .where(and(eq(invites.codeHash, codeHash), isNull(invites.revokedAt))) + .limit(1); + + if (existing) { + // Already present and live. Nothing to do — restarts must be idempotent. + return; + } + + // Revoke any older configured invite before issuing the new one, so a + // rotated code genuinely replaces its predecessor. + await db + .update(invites) + .set({ revokedAt: new Date() }) + .where( + and( + eq(invites.scopeNote, ENV_INVITE_LABEL), + isNull(invites.revokedAt), + ne(invites.codeHash, codeHash), + ), + ); + + await db.insert(invites).values({ + codeHash, + scopeNote: ENV_INVITE_LABEL, + // Effectively unlimited. A shared workspace code is meant to onboard a + // team, not one person; per-person invites are issued from the UI with + // their own limits. + // + // One million rather than Number.MAX_SAFE_INTEGER, which overflows a + // 32-bit integer column and fails at insert. + usesRemaining: 1_000_000, + }); + + console.log('[pig] Configured invite code is active. Rotate it by changing the variable.'); +} diff --git a/apps/web/index.html b/apps/web/index.html index 62d5103..ee57059 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -22,10 +22,50 @@ + - + + + pig — Prime Intellect Growth + + + + + + + + + + + + + + + + +