Updated 2026-10-15 · reference: what protects each door, and the open list before real customers.
How NextUp keeps companies apart, what protects each door, and what has to land before
real customers depend on it. Read this before touching login, /admin, stages or the API.
Every company has a stage (src/features/admin/stages.ts). The code is identical for all four;
what changes is what the stage allows. policyFor(stage) is the one place that decides, and
the server actions check it - hiding a button is only a courtesy.
demo |
sandbox · pilot · live |
|
|---|---|---|
| Whose data | Ours - made-up people and cases | Theirs - real people, real cases |
"View the demo as..." list on the login page (LOGIN_DEMO_FILL) |
yes | no - never shown, demoSignIn refuses |
| How people log in | their personal code or Microsoft - or, on a demo box, the list above | their personal code or Microsoft only - the staff list never reaches the browser |
Login codes shown on admin.sellux.ch ("Demo logins", server/demo-logins-sync.ts) |
yes - on request the stack gives everyone a new code and sends them, so the team can test the real login | no - the stack never asks, never sends; admin refuses them too |
| Dev panel: switch person, +1 day, reset, delete added | yes (managers for the last three) | no - panel hidden, actions refuse |
| People sent to the browser | the whole company (the dev panel needs them) | the signed-in viewer only |
Move back to demo |
- | refused - create a separate demo company instead |
An unknown stage gets the real-people policy: fail closed. Creating a company in /admin asks
"Who logs in" - demo or real people (sandbox).
| Door | Lock | Brake (src/server/throttle.ts) |
|---|---|---|
/[company]/login |
personal login code per person (~59 bits, sha256 stored, src/features/auth/login-code.ts) - the code alone says who you are |
10 tries / 10 min per address+company; 200 / 10 min per company |
/[company]/login/microsoft |
Entra ID OpenID Connect, code flow + PKCE, state + nonce in a signed 10-min cookie; token's tid must equal the company's tenant; person matched by oid (bound on first sign-in by email) - src/features/auth/entra.ts |
Microsoft's own |
/admin/login |
ADMIN_ACCESS_CODE, constant-time compare |
5 tries / 15 min per address |
/contact |
honeypot + server-side validation | 5 / hour per address |
/api/[company]/events |
bearer token (sha256 hash stored), scoped to one company | - (token is 192 bits) |
/api/client-errors |
public on purpose (a crash on the login page must report too): Sec-Fetch-Site: same-origin or Origin = APP_ORIGIN, 8 KB, zod-validated; message scrubbed and route made a pattern before it is kept (src/features/errors); no cookie, session or address stored |
30 / 10 min per address, then silently 204 |
| Every server action | re-checks the session for the slug; never trusts the client for actor or company | - |
| Every page that loads its own data | guardPage (src/server/page-guard.ts) re-checks the session and the role - the (app) layout's check does not keep a page's output out of the response, and the proxy is routing, not a boundary |
- |
| Case and idea events | mayAppend (src/features/cases/permissions.ts): only whoever holds a case (or a manager) decides, hands or asks; only the raiser answers; only managers re-route or move the clock; no re-raising an existing id |
- |
| Discussions | mayAppend: only whoever holds the case (or a manager) starts one, invites or checks in; past the company's last round only a manager adds one; only the raiser revises, and only before the decision. Behind the discussion flag (appendEventAction) |
- |
| Projects | mayAppendProject (src/features/projects/permissions.ts): a yes opens a project only as the case's first decision; the lead (or a manager) runs it - baseline before the start, measures only once a check opens; the team adds steps, a step's owner or the lead moves it; only a manager records a reward, only an author claims it. Behind the projects flag |
- |
n8n.<domain>, mail.<domain> |
Caddy basic_auth from OPS_USER / OPS_PASSWORD_HASH; unset = 401 for everyone. With N8N_WEBHOOKS=public, POST /webhook/<letters, digits, - and _> on n8n.<domain> skips it (raw request line, ops/caddy/Caddyfile) |
- |
A company stack's n8n (stack/) |
loopback only. With N8N_WEBHOOKS=public in the stack's .env, its Caddy passes POST /webhook/<letters, digits, - and _> on the app's host to n8n, without the app's cookies and with Content-Security-Policy: sandbox (stack/caddy/Caddyfile). Each active Webhook node: Header Auth |
- |
Sessions are HMAC-signed cookies (src/features/auth/cookie.ts), HttpOnly, SameSite=Lax, and
Secure whenever COOKIE_SECURE=true or PUBLIC_SCHEME=https. Post-login redirects only go
to same-site paths (safeNextPath). next.config.ts sends a strict CSP, frame-ancestors 'none',
HSTS, nosniff and a referrer policy.
The brakes count an IPv6 address by its /64 (clientAddress), so one line cannot rotate through
fresh addresses. On a box served over https the change-me placeholder from .env.example opens
nothing: not /admin as ADMIN_ACCESS_CODE, and as AUTH_SECRET it neither signs a cookie nor
is a cookie signed with it accepted - by the proxy, getViewer() or isAdmin() (usableSecret,
src/features/admin/environment.ts). Over plain http - a laptop - it still works. The proxy skips
static files by their extension, not every path with a dot, so a person's page
(/acme/people/T.%20Vogel) meets the login check like any other.
Per-person login.Done: a personal login code per person (issued and replaced in/admin-> Companies -> People & sign-in) and "Continue with Microsoft" per company. The shared company code opens nothing any more (Company.accessCodeHashis kept, nullable, until old rows are gone). Still open: team leaders issuing codes themselves instead of the admin, and an Entra app registration for the production box. A stack takes it withstack/install.sh --entra-client-id+NEXTUP_ENTRA_CLIENT_SECRET; the ID token signature is verified against the tenant's published keys (docs/plans/2026-10-22_sso-and-data-access.md, phase 1). The customer's IT assigns the Entra app rolesNextUp.Manager/NextUp.Leader/NextUp.Member; therolesclaim sets the role at every Microsoft sign-in (the most powerful wins) and is the only thing that creates a person on first sign-in. No role claim: a known person keeps the stored role, a new one is refused.Session revocation.Done:getViewerForchecks every session against the database (same company id, the person still exists with the same role,Company.sessionEpochunchanged - a stage move bumps it, so demo sessions end when real people move in - and no login code issued since the cookie was signed,User.loginCodeAt). Issuing someone a new code ends every session they had, Microsoft ones included; "New demo codes" ends a demo's sessions. Offboarding is deleting the person.- Admin as people, not a shared code. One
ADMIN_ACCESS_CODEfor everyone means no audit of who did what. Named admin users (magic link or SSO) plus 2FA. Started: on a company stack, a Microsoft account with the Entra app roleNextUp.Adminopens/admin; the cookie carries who (shown in the header, logged at sign-in) and the shared code is logged as break-glass. Still open: 2FA of our own, switching the shared code off, naming the admin on each action (item 4), and the platform admin on admin.sellux.ch. Customer setup:docs/customer/ENTRA_SETUP.md. - Audit log for admin actions - create, stage move, issue login code, issue token, delete - as
events, like everything else.
4b. Microsoft 365 connections (phase 2 of
docs/plans/2026-10-22_sso-and-data-access.md). A person's refresh token for their own files (Files.Read.All+offline_access, nothing else) is stored sealed: AES-256-GCM withCONNECTOR_VAULT_KEY, bound to company and person, so a database dump or a token copied into another row opens nothing. Off per company until its admin turns it on; the Microsoft account must be the signed-in person's own; disconnecting deletes the row, and "disconnect everyone" is one click in/admin/assistant. Losing the key disconnects everyone - back it up with the other stack secrets. - Shared rate-limit store. The throttle counts in process memory: right for one Hetzner box,
per-instance on Vercel. Move the
Mapto a Postgres table when there is more than one process. - Role-scoped data. The whole case log goes to every signed-in browser; roles only choose which pages show it. If members must not see something, filter it on the server.
- GDPR basics - a processing record, a DPA with each customer, data export and deletion per company (delete exists; export does not), backup encryption and retention.
- In CI:
npm audit --omit=dev --audit-level=highfails a PR on high or critical findings. On 28 Sep 2026 there were none. The old findings in Prisma's CLI tooling (mysql2,deepmerge-ts) are pinned away byoverridesin the rootpackage.json.- Dependabot opens weekly update PRs.
- Trivy scans the app image before it's pushed. A fixable HIGH/CRITICAL finding stops the release.
- Demo shortcuts:
LOGIN_DEMO_FILLonly on demo stages,ADMIN_DEMO_FILLonly on demo boxes, demo login codes to admin.sellux.ch only from a demo-stage company (policyFor(stage).shareDemoCodes; the stage is checked again in the transaction that replaces the codes). The static demo (demo.sellux.ch) has no login at all./admin/connectionsflags both. Company stacks (stack/) never passADMIN_DEMO_FILLat all. - Anything published by Docker on a laptop binds to
127.0.0.1, never all interfaces. - A pentest by an outside firm before
live.
-
uploads/in the git history (24 Sep audit; commitse8c110b..d8ebf99, public repo). Deleted from the tree on 14 Sep, still in history. Checked on 28 Sep 2026:- Two phone screenshots of Pinterest/Dribbble design inspiration, one of them committed twice.
- A hand-drawn concept diagram.
- A Miro stock template.
- A whiteboard photo from a brainstorm.
skunk-works-model.md.
No personal data: no people, faces, names or customer screens. The only business detail is one company named on the whiteboard as a possible target customer. It isn't repeated here on purpose, and it appears nowhere else in the repo. Decision (Kevin): no history rewrite. A rewrite would change every commit hash, force re-clones and need GitHub support to purge caches, for one name on a whiteboard photo that has been public since 10 Sep.
uploads/is gitignored, and CI's guard blocks images under it, so it can't happen again. -
On-demand TLS (
ops/caddy/Caddyfile.ondemand,/api/tls-check): removed in #90. The endpoint told anyone which companies exist. -
n8n encryption key: each company stack generates its own (
stack/install.sh), and backups include it (stack/backup.sh, #87). -
Login audit, 11 Oct 2026. Each hole reproduced against a production build, then closed:
adminSignOutmoved the admin revocation watermark for anyone. Called without a session, it logged every admin out, as often as the caller liked. Now only a signed-in admin moves it./[company]/people/[name]showed a person's email, role and department to a visitor with no session: names like "B. Hartmann" carry a dot, so the proxy never saw the request, and the layout's redirect did not keep the page out of the response. The same held for a session the database had already ended. It and the routing table now check for themselves (guardPage).- The
change-meplaceholder was accepted asADMIN_ACCESS_CODEandAUTH_SECRETon a public box. - A new login code left the person's existing sessions running for up to eight hours.
- The proxy skipped every path with a dot - a person's page among them - so in subdomain and single mode such a page was never rewritten to its company and answered 404.