From 0551e8dd6e08f23b7421f668c44996db0eea79e4 Mon Sep 17 00:00:00 2001 From: karti Date: Wed, 12 Aug 2026 19:43:10 -0700 Subject: [PATCH] Add link previews and icons; make PIG_INVITE_CODE actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Social and icons. A 1200x630 card, apple-touch-icon, and maskable PWA icons, generated from an HTML template by a script so the mark, wordmark and tagline cannot drift from the product. The apple-touch-icon referenced in index.html was a 404 until now. Icons are drawn on an opaque plate with inset because iOS rounds corners and Android may apply a circle — an edge-to-edge mark loses its ears to that crop. og:image is absolute, which is the single most common reason a card unfurls blank. PIG_INVITE_CODE was a lie. 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. It is now reconciled into a real invite row at boot: setting it issues, changing it rotates and revokes the predecessor, and removing it revokes. Verified all three, plus that a restart with an unchanged code does not duplicate. Two bugs found while doing that: - `uses_remaining` was jsonb, so the SQL decrement could never have worked. Now integer. The generated migration failed because Postgres has no implicit jsonb->integer cast, so the USING clause is hand-written. - Redemption keyed off `redeemedAt`, which would have made every reusable invite single-use — a confusing way to lock a team out. Availability now comes from `usesRemaining`, and redemption records who used it most recently without consuming it. Sign-in gains a password option alongside the magic link, defaulting to password since that is the daily path. PIG stores neither; both are handled by the identity provider and PIG only ever sees the resulting token. autocomplete is set so password managers and iOS can fill. Verified: full migration chain applies to a fresh Postgres, the CSP hash for the inline theme script is unchanged by the rebuild. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/routes/signup.ts | 11 +- apps/api/src/server.ts | 7 + apps/api/src/services/bootstrap.ts | 79 + apps/web/index.html | 42 +- apps/web/public/apple-touch-icon.png | Bin 0 -> 4612 bytes apps/web/public/icon-192.png | Bin 0 -> 5162 bytes apps/web/public/icon-512.png | Bin 0 -> 14484 bytes apps/web/public/icon-maskable-512.png | Bin 0 -> 11762 bytes apps/web/public/manifest.webmanifest | 7 +- apps/web/public/og.png | Bin 0 -> 53331 bytes apps/web/src/pages/SignIn.tsx | 185 +- apps/web/tools/og-template.html | 125 + apps/web/tools/render-assets.mjs | 109 + package-lock.json | 48 + package.json | 5 +- packages/db/migrations/0002_invite_reuse.sql | 14 + .../db/migrations/meta/0002_snapshot.json | 6250 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema/identity.ts | 19 +- 19 files changed, 6855 insertions(+), 53 deletions(-) create mode 100644 apps/api/src/services/bootstrap.ts create mode 100644 apps/web/public/apple-touch-icon.png create mode 100644 apps/web/public/icon-192.png create mode 100644 apps/web/public/icon-512.png create mode 100644 apps/web/public/icon-maskable-512.png create mode 100644 apps/web/public/og.png create mode 100644 apps/web/tools/og-template.html create mode 100644 apps/web/tools/render-assets.mjs create mode 100644 packages/db/migrations/0002_invite_reuse.sql create mode 100644 packages/db/migrations/meta/0002_snapshot.json 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 + + + + + + + + + + + + + + + + +