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, 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 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 /api/v1/* without a session returns 401. Gateway /v1/* traffic keeps the API-key rule.
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>.
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).
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 aGET /api/v1/auth/verify/:tokenflipsemailVerifiedto true and consumes
new org (named from the email domain, or "{name}'s org" for generic providers), owner membership, sends a verifyEmail, and logs the user in.
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,
- 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. A failed send never throws the request (invites/verifications are non-critical).
| Env var | Default | Purpose |
|---|---|---|
PUNK_ALLOW_PUBLIC_SIGNUP | false | enable open self-serve signup + email verify |
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 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.