Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt

Use this file to discover all available pages before exploring further.

Authentication

Authentication surface in Crewship is split across several flows. The interactive web flow is /api/auth/* (NextAuth-compatible — see Internal API); everything covered on this page is the /api/v1/auth/* surface that wraps Google sign-in, CLI pairing, active session listing/revocation, long-lived CLI tokens, and password recovery. All /api/v1/auth/* routes are mounted behind a rate limit of 10 req/min/IP. Routes marked auth required read the caller’s identity from the session cookie issued by /api/auth/callback/credentials; the rest are intentionally unauthenticated because the request body (a state token, pairing code, or reset token) IS the credential.

Google sign-in

Three endpoints implement the OAuth2 redirect dance. They are wired only when GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set at startup; otherwise /redirect and /callback are not registered at all and /status reports enabled: false.

GET /api/v1/auth/google/status

Probe that tells the login page whether to render the “Continue with Google” button. Auth: none. Response: 200 OK
{ "enabled": true }
FieldTypeDescription
enabledbooleantrue when both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set on the server.

GET /api/v1/auth/google/redirect

Starts the OAuth2 flow. The handler mints a single-use state token (16 random bytes, stored in oauth_states with the requested post-login redirect, 15-min TTL) and 302s the browser to accounts.google.com. Auth: none. Query parameters:
ParamTypeDescription
redirectstringOptional post-login destination. Validated by isSafeRedirect; anything that escapes the local origin falls back to /.
Response: 307 Temporary Redirect to the Google authorization URL. Errors land as 404 (“Google sign-in not configured”) when OAuth is disabled or 500 on a state-insert failure.

GET /api/v1/auth/google/callback

Google’s redirect target. Atomically consumes the state token (single-use via DELETE ... RETURNING), exchanges the auth code for an access token, fetches the user’s profile from https://www.googleapis.com/oauth2/v3/userinfo, upserts the row in users + accounts, and mints a fresh user_sessions row plus access/refresh cookies. Then 307s the browser to the validated redirect_uri captured during /redirect. Auth: none. Query parameters:
ParamTypeDescription
statestringRequired. The opaque token from /redirect.
codestringRequired. Google’s authorization code.
StatusCondition
307Success — session cookies set, browser redirected.
400Missing state/code, unknown state, state older than 15 min, or Google code exchange failed.
404Google sign-in not configured (env vars missing).
502Userinfo fetch or JSON decode failed.
500DB insert, session create, or token mint failed (a ghost session, if created, is revoked before responding).

Device pairing

A device-code flow (RFC 8628 in spirit) that hands a Crewship session to a locally-installed CLI without copy-pasting a long token. The UI calls /pair/start to mint a human-typeable code, polls /pair/poll for status, and the CLI on the operator’s machine exchanges the same code at /pair/redeem for a long-lived CLI token. Codes are 8 chars of Crockford-ish base32 (no 0/O/1/I/L) formatted as XXXX-XXXX, 10-minute TTL.

POST /api/v1/auth/pair/start

Mint a new pairing code for the authenticated user. Auth: required. Request body (optional):
{ "adapter_hint": "CLAUDE_CODE" }
FieldTypeDescription
adapter_hintstringOptional. [A-Z_0-9] up to 32 chars; anything else is stripped. Telemetry-only — the backend never routes on it.
Response: 200 OK
{
  "code": "K3F9-X2NM",
  "expires_at": "2026-05-20T08:50:00Z"
}

GET /api/v1/auth/pair/poll

UI polls this every ~2s while showing the code to the user. The row is reported as expired when (a) the code doesn’t exist, (b) it belongs to a different user, or (c) the TTL has passed — the three cases are indistinguishable on purpose so /poll cannot enumerate other users’ codes. Auth: required. Query parameters:
ParamTypeDescription
codestringRequired. The code returned by /pair/start. Case- and dash-insensitive.
Response: 200 OK
{
  "status": "pending",
  "adapter_hint": "CLAUDE_CODE",
  "expires_at": "2026-05-20T08:50:00Z"
}
FieldTypeDescription
statusstringOne of pending, consumed, expired.
adapter_hintstring?Echo of the hint stored on /start. Omitted when empty.
expires_atstringRFC3339.

POST /api/v1/auth/pair/redeem

The CLI calls this from the operator’s machine to exchange the code for a fresh cli_tokens row. Unauthenticated by design — the code IS the credential. Single-use (the UPDATE filters on status='pending' so two concurrent redeems can’t both win), 10-min TTL, and the route inherits the 10 req/min/IP cap from the auth-tier rate limiter. Auth: none. Request body:
{
  "code": "K3F9-X2NM",
  "adapter_hint": "CLAUDE_CODE"
}
FieldTypeDescription
codestringRequired. The pairing code, case- and dash-insensitive.
adapter_hintstringOptional. Same [A-Z_0-9]≤32 sanitisation as /start.
Response: 200 OK
{
  "cli_token": "crewship_cli_3f9a4d2b8e7c1f5a0d6b9e2c4f8a1d3b5e7c9f1a",
  "user_id": "u_01HVY...",
  "email": "petra@example.com"
}
StatusCondition
400Invalid JSON, missing/normalised code, unknown code, status not pending, or TTL expired.
500DB read/write failure (token is not minted on error).

Active session management

The Settings → Sessions UI uses these endpoints to show the user every device currently signed in, with a “revoke” button per row. Revoking the caller’s own session is allowed; the frontend handles the resulting 401 by hard-redirecting to /login.

GET /api/v1/auth/sessions

Lists the caller’s active (non-revoked) sessions, newest-active first. Auth: required. Response: 200 OK
[
  {
    "id": "sess_01HVZ...",
    "created_at": "2026-05-20T07:55:14Z",
    "last_used_at": "2026-05-20T08:31:02Z",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)",
    "ip": "203.0.113.42",
    "is_current": true
  }
]
FieldTypeDescription
idstringuser_sessions.id. Pass back to /revoke.
created_atstringRFC3339.
last_used_atstringRFC3339, refreshed by the auth middleware on every authenticated request.
user_agentstringCaptured at session create. Omitted when empty.
ipstringClient IP recorded at session create. Omitted when empty.
is_currentbooleantrue for the session backing this very request — the UI uses this to add a “this device” badge and warn before revoking.

POST /api/v1/auth/sessions/{id}/revoke

Flips revoked_at on a single session owned by the caller. The 404 path covers both “doesn’t exist” and “doesn’t belong to you” so callers can’t enumerate other users’ session ids by guessing. Auth: required. Path parameters:
ParamDescription
iduser_sessions.id from the list endpoint.
Response: 200 OK
{
  "ok": true,
  "id": "sess_01HVZ...",
  "is_current": false
}
StatusCondition
400Empty id.
404Session doesn’t exist or belongs to a different user.
500DB error during the revoke write.

CLI tokens

Long-lived bearer tokens used by crewship CLI invocations and CI agents. Tokens are minted once, only the SHA-256 hash is stored, and the raw value is returned exactly once in the create response — the rest of the surface returns metadata only. Token format: crewship_cli_ + 40 hex chars (20 random bytes). IsCLIToken is the helper the auth middleware uses to detect this prefix before falling through to JWE validation.

POST /api/v1/auth/cli-token

Mints a new token for the calling user. Auth: required. Request body (optional):
{ "name": "ci-runner" }
FieldTypeDescription
namestringOptional. Defaults to "CLI token". Free-form label rendered in the Settings list.
Response: 200 OK
{
  "token": "crewship_cli_3f9a4d2b8e7c1f5a0d6b9e2c4f8a1d3b5e7c9f1a",
  "id": "clt_01HVZ...",
  "name": "ci-runner",
  "created_at": "2026-05-20T08:31:02Z"
}
The token field is the only time the raw value leaves the server. Store it immediately — there is no recovery path. Lost tokens must be revoked and replaced.

GET /api/v1/auth/cli-token/validate

Confirms the current request’s CLI token (the Authorization: Bearer ... header) is valid. Used by crewship login --status and the CLI’s startup self-check. Auth: required (call this with the token you want to verify). Response: 200 OK
{
  "valid": true,
  "user_id": "u_01HVY...",
  "user_email": "petra@example.com"
}
A revoked or unknown token never reaches this handler — the auth middleware rejects with 401 before dispatch.

GET /api/v1/auth/cli-tokens

Lists all CLI tokens belonging to the calling user, newest first. The plaintext is never returned. Auth: required. Response: 200 OK
{
  "data": [
    {
      "id": "clt_01HVZ...",
      "name": "ci-runner",
      "created_at": "2026-05-20T08:31:02Z",
      "last_used_at": "2026-05-20T08:42:15Z"
    },
    {
      "id": "clt_01HVX...",
      "name": "pair-claude_code",
      "created_at": "2026-05-19T14:02:11Z",
      "revoked_at": "2026-05-19T19:18:00Z"
    }
  ]
}
FieldTypeDescription
idstringToken id.
namestringOperator-supplied label. Pairing-flow tokens are named pair[-<adapter_hint>].
created_atstringRFC3339.
last_used_atstring?RFC3339 of the last successful auth. Updated asynchronously by ValidateCLIToken. Omitted on tokens never used.
revoked_atstring?RFC3339 when /auth/cli-tokens/{tokenId} was called. Omitted on still-active tokens.

DELETE /api/v1/auth/cli-tokens/{tokenId}

Marks a token revoked. Subsequent uses are rejected by the auth middleware (ValidateCLIToken returns “CLI token revoked”). Auth: required. Path parameters:
ParamDescription
tokenIdcli_tokens.id from the list endpoint.
Response: 200 OK
{ "status": "revoked" }
StatusCondition
404Token doesn’t exist or belongs to a different user.
500DB error during the revoke write.

Password recovery

Email-based recovery for users who don’t have shell access to the box. Admins should prefer crewship admin reset-password — it writes directly to the DB and doesn’t require a mailer. The mailer transport is wired at startup from RESEND_API_KEY + RESEND_FROM. The reset link is built from CREWSHIP_PUBLIC_URL (a validated http(s)://host value parsed once at boot). When the public URL is unset or malformed, /forgot silently no-ops — without it there’s no safe way to build a link that isn’t Host-header-injection-controllable.

POST /api/v1/auth/forgot

Issues a single-use, 30-min reset token if the email matches a user and a mailer transport is configured. Always returns 200 with the same JSON body regardless — the endpoint cannot be used to enumerate accounts. Auth: none. Request body:
{ "email": "petra@example.com" }
Response: 200 OK
{
  "ok": true,
  "message": "If an account exists for that email and email is configured on this server, a reset link has been sent. Self-hosted administrators without email configured should run `crewship admin reset-password` on the server."
}
The response body is byte-for-byte identical for “no such email”, “mailer disabled”, “public URL unset”, and a successful send — the only signal a successful send happened is the user receiving the email.

POST /api/v1/auth/reset

Consumes the reset token and sets a new password. The token row is burned inside a transaction (race-protected: two concurrent calls with the same token serialise on the DELETE, only one wins). On success, every active session for the user is revoked so a stolen cookie can’t outlive the reset. Auth: none — the token IS the credential. Request body:
{
  "token": "EXAMPLE_64_HEX_PASSWORD_RESET_TOKEN_NOT_REAL",
  "new_password": "correct horse battery staple"
}
FieldTypeDescription
tokenstringRequired. The raw 32-byte hex token from the email link. Server compares its SHA-256 against verification_tokens.token.
new_passwordstringRequired. Minimum 8 characters. Hashed with bcrypt (cost 12) before storage.
Response: 200 OK
{ "ok": true }
StatusCondition
400Invalid JSON, missing token, password shorter than 8 chars, unknown/expired token, or token already consumed by a parallel call.
500bcrypt failure, DB error, or session revocation failed after the password write committed (the body explains the user must retry login; the password was updated).
Password reset is one of the few endpoints where a 500 with a non-generic message is intentional: if the password write commits but session revocation fails, leaving the user with valid old cookies would silently undo the security purpose of the reset. The 500 forces an admin-visible log entry.

See also

  • Internal API — the NextAuth-compatible /api/auth/* surface used by the browser session cookie flow.
  • Security overview — how the session token feeds into RBAC.
  • crewship login — CLI commands that consume /pair/start + /pair/redeem and store the resulting token in ~/.crewship/cli-config.yaml.