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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
Reference in New Issue
Block a user