Accounts & Organizations
Punk has two coexisting identities. API keys (bearer pk_ tokens) are the identity for SDK and integration 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, password recovery, and the email transport that carries identity links.
Accounts
A user has an email (unique, lowercased), a name, a platform role (admin | member), and an emailVerified flag. Passwords are hashed before storage.
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 headerless /api/v1/* and /v1/* traffic returns 401. Agents authenticate with a tenant API key. PUNK_ALLOW_OPEN_GATEWAY=true is the explicit development-only escape hatch.
Multi-org (first-class)
A user can belong to multiple organizations. Each organization is a tenant boundary. Tenant-scoped data such as runs, workflows, agents, conversations, keys, and 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 & path | Who | Effect |
|---|---|---|
GET /api/v1/orgs | session | your orgs (name + role) with an active flag |
POST /api/v1/orgs/switch {tenantId} | member of it | set the session's active org → 200 (403 if not a member) |
POST /api/v1/orgs {name} | session | create a new org; you become its owner and it becomes active |
GET /api/v1/orgs/active | session | active org name + members + your role |
PATCH /api/v1/orgs/active {name} | owner/admin | rename the active org |
DELETE /api/v1/orgs/active/members/:userId | owner | remove a member; purges their sessions |
DELETE /api/v1/orgs/active {confirm:true} | owner | delete the org (cascade); refuses the default tenant |
Removing members & deleting organizations
Member removal and org deletion are owner-only lifecycle actions; admin and member cannot perform them (a 403 otherwise).
Removing a member (DELETE /api/v1/orgs/active/members/:userId): drops the org_members row and purges that user's sessions (deleteUserSessions), so they lose access on their next request, immediately. The **last owner can never be removed** (409); an org must always have an owner. After removal the plan's seat count frees naturally (seats are counted live as members + pending invites, no extra bookkeeping). Audited as org.member_remove.
Access is rechecked on every request. Removed members cannot continue using a stale dashboard session for the organization.
Deleting an org (DELETE /api/v1/orgs/active with {confirm:true}): owner only, and it refuses the default tenant ("default", the bootstrap / open-dev org) with a 400. Missing confirm is a 400. Deleting an organization removes its tenant-scoped data, including runs, workflow data, conversations, keys, settings, approvals, stored credentials, usage, and invites. User accounts are not deleted because a user may belong to other organizations; they can be re-invited later if needed. Sessions pointed at the deleted organization are reset on their next request. The action is audited as org.delete.
In the dashboard (Governance → Organization), owners see a Remove button per member row (hidden for the last owner) and a Delete organization danger action behind a typed-confirm modal (you must type the org name); the action is never shown for the default org. After delete the app redirects to /, which re-resolves a surviving org or the login page.
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 theGET /api/v1/orgs/active/inviteslists invites;POST …/:id/revokerevokes aGET /api/v1/invites/:token(public) validates a token and returnsPOST /api/v1/invites/:token/accept(public): for a new email it creates
invite and sends an inviteEmail whose acceptUrl is ${PUNK_APP_BASE_URL}/accept-invite?token=<raw>. If delivery fails, Punk revokes the unusable invite, records deny audit evidence, and returns a retryable 503 instead of reporting a pending invite nobody can accept.
pending one.
{valid, orgName, email, role, userExists}.
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).
Password recovery
The login page links to /reset-password. Logged-out users can request recovery regardless of whether public signup is enabled:
POST /api/v1/auth/password-reset/requestwith{email}returns the same 202- Reset links expire after one hour. Only the token's sha256 hash is stored.
POST /api/v1/auth/password-reset/confirmwith{token, password}atomically
response for known and unknown accounts. A known account receives a passwordResetEmail linking to ${PUNK_APP_BASE_URL}/reset-password?token=<raw>.
consumes the token, changes the password, clears mustChangePassword, deletes every other reset token, and deletes every existing session for the user. After a successful reset, reusing that link or any other outstanding reset link fails.
Both public endpoints are database-rate-limited to five attempts per minute per caller IP. Request responses use the same body, status, and bounded response-time floor for known and unknown normalized emails.
Public signup (flag-gated) + 30-day trial
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- Trial-level quota and semantic-cache serving require a verified owner email.
GET /api/v1/auth/verify/:tokenflipsemailVerifiedto true and consumesPOST /api/v1/auth/verification/resendlets an authenticated unverified user
new org (named from the email domain, or "{name}'s org" for generic providers), owner membership, starts a 30-day Pro trial, sends a verifyEmail, and logs the user in.
Until verification, the user can finish dashboard onboarding but the effective billing plan remains Free.
the token. Verification activates the remaining Pro trial window.
replace a lost or expired message without changing the account email. The route accepts an empty body, sends only to the stored address, and is limited to three deliveries per user every 15 minutes. If delivery fails, Punk removes the newly minted token and returns a retryable 503 response.
/health advertises {publicSignup: bool, selfServeTrial: {enabled, days, plan}}; login.html reveals the public trial link when public signup is true. A ?signup=1 deep link can open the signup form directly, but the signup endpoint still returns 403 unless PUNK_ALLOW_PUBLIC_SIGNUP=true. Invitations always work regardless of the flag.
For the complete hosted trial path, including verification, API key creation, SDK setup, and first agent traffic, see Self-Serve Trial Setup.
Email configuration
Email is pluggable and zero-config in dev (@punk/email):
- Console transport (default): emails are logged to the gateway console,
- Resend transport: set
RESEND_API_KEYto send via
including the accept/verify URLs, so local flows work with no configuration.
https://api.resend.com/emails. The sender is PUNK_EMAIL_FROM (default Punk <noreply@punktechnologies.com>).
Templates (inviteEmail, verifyEmail, passwordResetEmail) are pure functions returning a plaintext + PUNK-branded HTML message. Signup remains available when delivery fails because an authenticated verification resend can replace the token. Invite creation instead returns a retryable delivery error and revokes the unusable row because its one-time accept token exists only in the undelivered message.
| Env var | Default | Purpose |
|---|---|---|
PUNK_ALLOW_PUBLIC_SIGNUP | false | enable open self-serve signup + 30-day Pro trial |
PUNK_APP_BASE_URL | https://app.punktechnologies.com | base URL for accept/verify links |
RESEND_API_KEY | unset | send via Resend (else console transport) |
PUNK_EMAIL_FROM | Punk <noreply@punktechnologies.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 5-step checklist (try chat, run an agent, view savings, watch it learn, read docs); it is dismissable per-org (localStorage). POST /api/v1/orgs/active/seed-demo (owner/admin) instantiates an MCP-backed vendor risk workflow template + a demo agent (idempotent-ish: it skips a same-named row).
Auditing & rate limits
Invites, accepts, signups, verifications, password resets, org switches, org creates/renames, and seed-demo all write audit events. Signup, invite creation, invite acceptance, and password recovery use database-backed per-IP limits on top of the standard /api/v1/* limiter, so multiple API processes share one budget. Forwarded IP and HTTPS headers count only when PUNK_TRUST_PROXY_HEADERS=true, the runtime can report the request's direct TCP peer, and that peer matches an explicit PUNK_TRUSTED_PROXY_CIDRS entry. One-time tokens are compared via sha256 hash lookups; raw tokens are never returned after creation.