An authenticated GET to any unrecognised API route — a typo, a renamed
endpoint, an older client — fell through to the SPA fallback and returned
200 text/html containing the app shell.
This is close to the worst failure shape for an API consumer. `response.ok` is
true, so nothing treats it as an error; the caller then dies on `JSON.parse`
with "Unexpected token '<'" far from the actual cause. The MCP server, the CLI
and Piggy all consume this API and would all have hit it. It was masked from
casual testing because unauthenticated requests are rejected earlier by the
auth middleware, so it only appears once you hold a valid token.
Found by probing production with Scott's token: GET /api/keys (the real path is
/api/api-keys) returned 200 text/html.
The static-file middleware already carried this guard — added for the same
reason when og.png was being served as HTML — but the SPA fallback beneath it
did not. Same guard, one place missing.
Verified: unknown API paths now return 404 application/json, real API paths
still answer, client-side routes still receive the shell, and static assets
still serve with their own content types. Typecheck clean, 124 unit tests and
the e2e suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server mounted static files at /assets only, so every top-level file fell
through to the SPA catch-all. og.png, apple-touch-icon.png, icon-192.png and
manifest.webmanifest each returned `200 text/html` containing the app shell.
This is a nearly invisible failure. The app works. The tab shows an icon,
because browsers cache the SVG. Nothing errors anywhere. But a link shared to
iMessage, Slack or X fetches og.png, receives HTML, and renders a card with no
image — which is the entire point of having made one.
Now the whole dist directory is served, with /api excluded so routes are never
answered from disk, and the SPA fallback still catching client-side routes that
have no file behind them.
Found by fetching the asset from an outside host instead of trusting that
adding the file was enough. Verified: og.png is image/png at 53KB, the icons
and manifest carry their real types, /margin and /capacity still receive the
shell, and the API is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
packages/prime — a hand-written typed client, because the first-party SDK is
Python only. Deliberately narrow: PIG reads availability and nothing else, and
the key it holds should be scoped so it could not provision even if the code
tried. Rate limits are undocumented upstream, so it backs off empirically with
full jitter and honours Retry-After. Unknown fields survive in `raw` rather
than being dropped.
apps/api — Hono, with authentication and authorization kept firmly apart. A
verified JWT proves someone has an account in the identity project, which may
be shared with other applications; it does NOT prove they belong here. Access
requires a row in PIG's own users table, and a token without one gets 403
needs_profile rather than entry.
The capacity service is the business logic: availability counts sold and held
separately, so a live hold removes inventory from everyone else's availability
without inflating utilisation. Expired holds are ignored at read time, so the
numbers stay right even when the sweeper is behind. Matching treats
interconnect as a hard filter and excludes Unknown as well as Ethernet —
unverified is not the same as adequate.
apps/mcp — nine tools over stdio, so a team member drives PIG from Claude
Code, Codex, prime-agent, or a Buzz agent. It holds an API key and calls the
same HTTP API the browser does, with no database credentials, so an agent can
never reach further than the person it acts for. Results are formatted as
prose rather than raw JSON.
Theme preferences live in the database rather than localStorage, so a chosen
accent follows someone from laptop to phone. Status colours stay independent
of the accent: if "at risk" re-tinted to whatever a user picked, the signal
would be gone.
Note on the SDK import: its package exports use a `./*` wildcard whose types
entry resolves server/mcp.js to server/mcp.js.d.ts, which does not exist. The
runtime specifier must keep the .js suffix, so the types are mapped via
tsconfig paths rather than by writing an import that would fail at runtime.
Verified: all five packages typecheck; the MCP server constructs and registers
its tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>