The seam existed with only a Supabase implementation, so an on-prem deployment
had no way to authenticate. A customer running PIG inside their own network
already has Okta, Entra, Keycloak, Auth0 or Google Workspace; asking them to
stand up a second identity system is a serious adoption tax and in a regulated
environment usually refused outright.
Setting PIG_OIDC_ISSUER is normally the whole configuration — the JWKS is
discovered from the issuer's well-known document. PIG_OIDC_JWKS_URI skips
discovery entirely for an air-gapped network. OIDC takes precedence over
Supabase so an on-prem install can leave the hosted values in its environment
file without them quietly taking over.
Three decisions worth stating:
Discovery is resolved lazily and the FAILURE is not cached. Doing it per
request would put the customer's identity provider on the critical path of
every API call; doing it eagerly at boot would mean their IdP rebooting takes
the CRM down with it. So it happens on first use and retries on the next
request.
The audience check is optional but warned about loudly. Without it, a token the
provider issued for ANY other application in the same tenant verifies here — a
token minted for an unrelated internal tool would be accepted as a PIG session.
It cannot be mandatory because some providers legitimately issue
single-audience tokens.
Email falls back through email, preferred_username and upn, because providers
disagree, but a preferred_username without an "@" is ignored — PIG keys
membership on the address, and a bare username must never become an account
identity.
Also fixed a warning that claimed "authentication is DISABLED" on a correctly
configured OIDC deployment. That is worse than silence: an operator who reads
it on a secure install learns to ignore the warnings. The dev bypass itself was
already correct — it keys on the resolved provider rather than on Supabase.
18 new tests, most of them about what the provider must REFUSE: a foreign
signing key, a foreign issuer, a token for a different application, an expired
token, a token with no subject, and a discovery outage that must not become
permanent. Keys are generated per test and the JWKS is served locally, so they
run offline.
Verified: production refuses to start with neither provider, starts with OIDC
alone, enforces 401 on an unauthenticated request, and warns only about the
genuinely missing admin list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deployment came up healthy, served a valid certificate, and returned 200 —
and was completely unreachable. Two distinct causes, both invisible from
inside the host:
1. Every other site on this proxy binds to a private VNIC address. Caddy
groups site blocks into servers BY listen address, so a block without
`bind` landed in a separate server on :443. The specific listener wins for
traffic arriving on that address, which is all public traffic after NAT, so
requests hit the server that had never heard of these hostnames and fell
through to an empty 200. Testing from the host with --resolve 127.0.0.1
worked perfectly, which is exactly why this was worth chasing from a third
machine instead of trusting a local check.
2. The CSP blocked the inline pre-paint theme script, so dark-mode users would
have seen a white flash on every load. Fixed with the script's hash rather
than 'unsafe-inline', which would have defeated the policy, and rather than
an external file, which would have reintroduced the flash. Editing that
script changes its hash and silently breaks it, so that is written down.
Verified from an independent host: health returns JSON, the app serves, an
unauthenticated API call is refused, the short alias redirects, security
headers are present, and the existing sites on the proxy are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One container plus a Postgres behind any TLS-terminating proxy. Nothing is
specific to a particular host.
The app and API are served from a SINGLE origin. This is not tidiness: browser
auth sessions live in per-origin storage, so splitting them across two
hostnames makes sign-in loop in a way that presents as a server fault. The
short alias redirects rather than serving a second origin.
Two safety properties verified by running the image, not by reading the code:
- With NODE_ENV=production and no SUPABASE_URL, the process refuses to start
and says why. Serving the whole CRM unauthenticated is a worse outcome than
failing to deploy, so the failure is deliberate and loud.
- In production the development auth bypass does not apply: an unauthenticated
request to /api/dashboard returns 401 rather than adopting the first user in
the table.
The Dockerfile typechecks all six packages as a build gate, so a deploy that
does not compile fails at build time rather than in front of a user. Runtime
runs unprivileged as `node`, and Postgres is not published to the host.
Docs cover the ontology and why it is shaped this way, agent connection for
Claude Code / Codex / prime-agent / Buzz, and the provenance rules governing
seed data about real people — including how to have your record removed.
Verified: image builds, container reports healthy, serves the SPA, enforces
auth, and the production guard exits non-zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>