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