Add link previews and icons; make PIG_INVITE_CODE actually work

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:43:10 -07:00
parent 93818a2d2c
commit 0551e8dd6e
19 changed files with 6855 additions and 53 deletions
+8 -3
View File
@@ -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));
}
+7
View File
@@ -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}`);
+79
View File
@@ -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<void> {
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.');
}