Authentication
Pitchbox ships unauthenticated by default for single-user self-host. Opt in with:
PITCHBOX_AUTH=onWhen on, hooks.server.ts checks the pitchbox_session cookie on every non-exempt request:
- HTML navigations without a valid session → redirect to
/login?next=<path>. /api/*calls without a valid session →401 unauthenticated./api/extension/*and/api/auth/*remain exempt by design. The extension authenticates with a per-device bearer token minted viaPOST /api/extension/auto-pair(which itself reads the dashboard session cookie).
First-run bootstrap
If no user exists in the users table, the first POST to /api/auth/login creates that user with the credentials you submit. Subsequent logins verify credentials with scrypt against the password_hash.
Sessions
createSession() mints a 32-byte hex token, stores it in the sessions table with a 30-day expiry, and sets it as an httpOnly cookie. loadSession() joins sessions × users and only returns non-expired rows. Logging out deletes the row and clears the cookie.
On a successful login the route rotates the session id: any existing session row matching the inbound cookie is deleted and a fresh id is minted before the cookie is set. This neutralises session-fixation attempts that try to pre-seed a known cookie value in the victim's browser.
Rate-limit, lockout, and generic errors
/api/auth/login always returns the same { "error": "invalid_credentials" } body with status 401 for both "user not found" and "wrong password" so an attacker can't probe for valid usernames.
Every failed attempt appends two rows to auth_failures - one keyed by ip:<client-ip> and one by user:<submitted-username>. If either bucket has at least max_attempts failures within window_minutes, further attempts return 429 { "error": "rate_limited", "retry_after_seconds": N } until lockout_minutes have elapsed since the most recent failure. A successful login clears both buckets.
Policy lives in app_config.auth_policy (JSON). Defaults:
{
"max_attempts": 5,
"window_minutes": 5,
"lockout_minutes": 15
}Override by inserting/updating that row directly - the login route reads it on each request, so changes take effect immediately.
Security settings page
/settings/security lists the last 50 entries in auth_failures and exposes an Unlock account action that clears the user:<username> bucket via POST /api/auth/unlock. The IP bucket is not cleared by default - pass { "ip": "..." } to clear that too.
Organizations and memberships
The schema already carries organizations + memberships. On a fresh install a single default org is seeded, and the first user is auto-joined as owner. The data model is the same across editions - a single-org self-host is just a multi-tenant cloud install with one tenant.
projects.organization_id is the root tenant pointer; every other tenant-scoped row reaches the org through its project FK. Future phases tighten data access so server routes always filter by the caller's org membership.
Edition flag and private submodule
PITCHBOX_EDITION switches between self-hosted (default) and cloud. Cloud-only code lives in a private submodule under cloud/ (or private/) that the OSS repo never embeds - those paths are gitignored. Build tooling treats the submodule as optional: if absent, the OSS edition builds and runs unchanged; if present, cloud features are wired in at build time.
A cloud agent-runner adapter is already registered alongside claude-code / codex / opencode. In the OSS build it throws on instantiation with an actionable message; the real implementation ships from the private submodule.
Phase 2: tenant isolation, invites, members
Phase 2 wires the multi-tenant model into the request pipeline:
- Org middleware.
web/src/hooks.server.tsresolves the caller's primary org frommembershipsand stashes it onevent.locals.org = { id, slug, role }. Authenticated requests with no membership return 404 (not 403) for any route outside/login,/api/auth/*,/invite/*,/api/orgs/*, and static. Returning 404 avoids leaking the existence of orgs the user can't see. - Scoped queries. Server routes resolve
resolveOrgId(event)and either filter directly (projects.organization_id = $org) or check viaprojectBelongsToOrg/campaignBelongsToOrg/draftBelongsToOrgfrom@pitchbox/shared/orgsbefore returning a row. The membership-aware helpers live inshared/src/orgs.ts. - Invites. See orgs.md.
POST /api/orgs/[slug]/invites(admin only) mints a single-use token; the invitee follows/invite/<token>(login if necessary) and a membership is created.
What's deferred
Provider adapters beyond the local username/password flow (Google, magic-link, SSO), billing hooks, observability, and the per-org runner-quota layer are tracked as separate phases. Strict tenant scoping on every server route is in progress - today, scope is enforced where projects already mediate the query (most paths); a follow-up tightens the remaining edges.