//DOCS Accounts & Orgs

Accounts, multi-org, invitations, public signup, and email configuration.
Open App

Accounts & Organizations

Punk has two coexisting identities. API keys (bearer pk_ tokens) are the identity for SDK, MCP, and PunkBar traffic: tenant-pinned, never affected by anything on this page. User accounts (email + password, an HttpOnly punk_session cookie) are the identity for humans in the dashboard. This page covers the human side: accounts, multi-org, invitations, public signup, and the email transport that carries invite and verification links.

Accounts

A user has an email (unique, lowercased), a name, a platform role (admin | member), and an emailVerified flag. Passwords are argon2id (via Bun.password), hashed in the app layer, so the store only ever sees the hash.

The bootstrap admin is ensured idempotently at boot from PUNK_ADMIN_EMAIL + PUNK_ADMIN_PASSWORD: the user is created (or lifted to admin) with emailVerified: true and owner membership on the default tenant (named "Default"). An existing user's changed password is never reset.

Open dev mode (no PUNK_API_KEY, zero users, PUNK_REQUIRE_LOGIN unset) treats every request as an admin of the default tenant. The moment the first user exists, the dashboard requires login and /api/v1/ without a session returns 401. Gateway /v1/ traffic keeps the API-key rule.

Multi-org (first-class)

A user can belong to N organizations. Every organization is a tenant with a row in organizations ({ tenantId, name, createdAt }). All tenant-scoped data (runs, workflows, agents, conversations, keys, settings) follows the active org.

The active org is carried on the session (sessions.active_tenant_id). On each request, AuthContext.tenantId = the session's active org when the user is still a member of it, otherwise the user's first membership. A stale active org (the user left it) self-heals back to the primary membership. admin is computed against the resolved org's role (owner/admin) or the platform role admin.

The sidebar shows an org switcher above the nav: the active org with a dropdown of all your orgs (the active one checked) plus a "New org…" action. Switching reloads the dashboard under the new org. Governance gains an Organization panel: the org name (renamable by owner/admin), the members table with per-org roles, and your role.

Endpoints

Method & pathWhoEffect
GET /api/v1/orgssessionyour orgs (name + role) with an active flag
POST /api/v1/orgs/switch {tenantId}member of itset the session's active org → 200 (403 if not a member)
POST /api/v1/orgs {name}sessioncreate a new org; you become its owner and it becomes active
GET /api/v1/orgs/activesessionactive org name + members + your role
PATCH /api/v1/orgs/active {name}owner/adminrename the active org

Invitations (the enterprise on-ramp)

Organizations grow by inviting members. An invite (org_invites) carries the email, the role to grant, a hashed one-time token, the inviter, a status (pending | accepted | revoked | expired), and a 7-day expiry. The raw token is returned once (in the accept URL); only its sha256 hash is stored, and it is never echoed after creation.

  • POST /api/v1/orgs/active/invites {email, role} (owner/admin) creates the
  • invite and sends an inviteEmail whose acceptUrl is ${PUNK_APP_BASE_URL}/accept-invite?token=<raw>.

  • GET /api/v1/orgs/active/invites lists invites; POST …/:id/revoke revokes a
  • pending one.

  • GET /api/v1/invites/:token (public) validates a token and returns
  • {valid, orgName, email, role, userExists}.

  • POST /api/v1/invites/:token/accept (public): for a new email it creates
  • the user ({name, password}, emailVerified: true since the link proves the address) and logs them in via a session cookie; for an existing account the caller must be signed in as that user (avoids a credential oracle). Either way it adds the membership, marks the invite accepted, and lands the user in the org. The accept page is /accept-invite (PUNK-branded).

The Governance → Organization panel has the invite form + a pending-invites table (email, role, status, revoke).

Public signup (flag-gated) + email verification

Signup is invite-first. Public self-serve signup is gated by PUNK_ALLOW_PUBLIC_SIGNUP (default false). When off, POST /api/v1/auth/signup returns 403. When on:

  • POST /api/v1/auth/signup {email, name, password} creates the user plus a
  • new org (named from the email domain, or "{name}'s org" for generic providers), owner membership, sends a verifyEmail, and logs the user in.

  • GET /api/v1/auth/verify/:token flips emailVerified to true and consumes
  • the token. Verification gates nothing critical in v1; an unverified user can use the dashboard, and just sees a "verify your email" banner.

/health advertises {publicSignup: bool}; login.html shows a "Create account" form only when it is true. Invitations always work regardless of the flag.

Email configuration

Email is pluggable and zero-config in dev (@punk/email):

  • Console transport (default): emails are logged to the gateway console,
  • including the accept/verify URLs, so local flows work with no configuration.

  • Resend transport: set RESEND_API_KEY to send via
  • https://api.resend.com/emails. The sender is PUNK_EMAIL_FROM (default Punk <noreply@cheaperfastersafer.com>).

Templates (inviteEmail, verifyEmail, passwordResetEmail) are pure functions returning a plaintext + PUNK-branded HTML message. A failed send never throws the request (invites/verifications are non-critical).

Env varDefaultPurpose
PUNK_ALLOW_PUBLIC_SIGNUPfalseenable open self-serve signup + email verify
PUNK_APP_BASE_URLhttps://app.cheaperfastersafer.combase URL for accept/verify links
RESEND_API_KEYunsetsend via Resend (else console transport)
PUNK_EMAIL_FROMPunk <noreply@cheaperfastersafer.com>Resend sender identity

New-org onboarding

A brand-new org lands in a getting-started state. GET /api/v1/orgs/active/onboarding returns {workflowCount, agentCount, hasRuns, dismissed}. The dashboard shows a Getting started panel (overview, zero workflows+agents) with a SEED DEMO button and a 4-step checklist (try chat, run an agent, view savings, read docs); it is dismissable per-org (localStorage). POST /api/v1/orgs/active/seed-demo (owner/admin) instantiates the support-triage workflow template + a demo agent (idempotent-ish: it skips a same-named row).

Auditing & rate limits

Invites, accepts, signups, verifications, org switches, org creates/renames, and seed-demo all write audit events. The signup, invite-create, and invite-accept endpoints share a per-IP token bucket (10/min) on top of the standard /api/v1/* limiter. One-time tokens are compared via sha256 hash lookups; raw tokens are never returned after creation.