Authentication

There are two kinds of caller, and the difference decides everything else.

A person signs in with an email address and a password and gets an HttpOnly session cookie. That is how the dashboard works; no script on the page can read the credential.

A machine — CI, a script, git itself — sends a bearer token of the form weft_<id>_<secret>. Only a hash of the secret is stored; the plaintext is shown exactly once at mint time.

Both resolve to the same authority model, so every endpoint accepts either. When both are presented the bearer token wins, so a developer with the dashboard open in the same browser can still test a token by pasting it into a request and get that token’s authority.

People, roles and orgs

A person belongs to one or more orgs, at one role in each:

Role Can
viewer read every repo in the org
member everything a viewer can, plus push, commit and create repos
admin everything, including managing people and credentials
owner the same as admin; an org must always have at least one

An org can never be left without an owner: removing or demoting the last one answers 409.

A per-repo grant replaces the org role on one repo — in either direction. Granting member to a viewer opens exactly that repo; granting viewer to an admin holds them down on exactly that repo, and nowhere else. One call can name several people at once:

curl -X POST https://api.weft.sh/v1/orgs/acme/repos/widget/grants \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "user_ids": ["01hx…", "01hy…"], "role": "member" }'

A batch is all-or-nothing. If any id in it is not a member of the org, nothing is granted — a half-applied change is one you cannot reason about afterwards.

Teams

“The payments squad can write here” is one statement about the org. Saying it person by person means it drifts the moment somebody joins, so a team can be granted a role on a repo directly:

curl -X POST https://api.weft.sh/v1/orgs/acme/teams \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "payments", "description": "the squad" }'

curl -X PUT https://api.weft.sh/v1/orgs/acme/teams/$TEAM/members/$USER \
  -H "Authorization: Bearer $ADMIN_TOKEN"

curl -X POST https://api.weft.sh/v1/orgs/acme/repos/widget/grants \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "team_id": "'"$TEAM"'", "role": "member" }'

A team grant only ever raises. Being in a team is how people get more access on a repo; it is never how they quietly lose some, because nobody reads a team’s grant list before adding a colleague to it. Lowering somebody stays a deliberate, per-person act.

So three rules decide what you can do on a repo, in this order:

  1. A grant naming you — that role, outright, up or down.
  2. Otherwise the highest of your org role and every team grant on that repo. Two teams disagreeing takes the higher.
  3. No org membership at all — no access, whatever the grants say. Team membership requires org membership, so a grant can never outlive it.

Deleting a team withdraws its membership and every grant it carries, on the very next request. Teams are named per org, case-folded, so Payments and payments cannot both exist.

Which rule applied to whom is a question worth answering directly, and GET …/repos/:repo/access answers it — every person who can reach the repo, their role there, and where it came from:

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://api.weft.sh/v1/orgs/acme/repos/widget/access
{ "people": [ { "email": "dev@acme.dev", "role": "member",
                "source": "team", "team_name": "payments" } ],
  "teams":  [ { "team_name": "payments", "role": "member",
                "member_count": 4 } ] }

In the dashboard the same two things are Settings → Teams and the Access panel on a repo.

People join by invitation, which is emailed when a mail transport is configured. The link works once and expires after seven days:

curl -X POST https://api.weft.sh/v1/orgs/acme/invites \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "email": "dev@acme.dev", "role": "member" }'
{ "id": "01hx…", "email": "dev@acme.dev", "role": "member",
  "expires_at": 1787428539000,
  "invite_link": "stinv_01hx…_…",
  "mail": { "sent": true } }

The link comes back as well as being sent. A relay that is down, or a server with no transport configured, must not stop you onboarding somebody — so mail.sent tells you whether to deliver it yourself, and mail.error says what went wrong when something did. sent: false with no error means no transport is configured.

The link lands on a screen that says what is being joined. It asks the server first:

curl -X POST https://api.weft.sh/v1/auth/invite/preview \
  -H "Content-Type: application/json" \
  -d '{ "invite": "stinv_01hx…_…" }'
{ "org": "acme", "role": "member", "email": "dev@acme.dev",
  "expires_at": 1787428539000 }

No credentials: the token in the body is the credential, so this tells its holder nothing they were not already sent. It is a question rather than an action — previewing does not spend the link — and every dead shape (malformed, unknown, expired, already accepted, wrong secret) answers the same 404, so a link cannot be used to ask which invitations exist. It is a POST, not a GET, because a token in a path or query lands in every access log between the browser and here.

Configuring mail

STRATUM_MAIL_TRANSPORT picks one of four, and STRATUM_MAIL_FROM is the sender address for all but the first two.

Transport What it does Also needs
null (default) drops every message
capture writes each message to a file, for local development and tests STRATUM_MAIL_DIR
smtp delivers through a relay STRATUM_MAIL_SMTP_HOST (host or host:port)
ses Amazon SES v2, signed with the instance’s own credentials STRATUM_MAIL_SES_REGION (defaults to AWS_REGION)

A name that is none of those is a boot failure, not a silent fallback to dropping mail: a typo in a deployment variable must not look like a working system.

SMTP has no STARTTLS. Nothing in the server links a TLS client library, so this transport speaks cleartext — which is the normal self-hosting arrangement (a relay on localhost, or a sidecar on a private network) and fine until credentials are involved. Setting STRATUM_MAIL_SMTP_USER and STRATUM_MAIL_SMTP_PASSWORD for a non-loopback host is refused at boot unless you state that the link is already private with STRATUM_MAIL_SMTP_ALLOW_CLEARTEXT_AUTH=1. For hosted deployments use ses, which is HTTPS.

Signing yourself up

curl -X POST https://api.weft.sh/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{ "email": "you@example.dev", "name": "Your Name",
        "password": "a long enough password", "handle": "you" }'

The handle is your personal namespace — the you in /you/repo. It is asked for rather than derived from your address, because it appears in every clone URL you ever hand out. It is validated and its refusals are plain (400 for a bad shape or a reserved word, 409 for one already taken): a namespace name is a public URL, so “that one is taken” is not a secret.

Everything after the handle is uniform. Signup always answers 202 with the same body, whether a confirmation message was sent, the address already has an account, or you have asked too many times — any difference would be a way to ask who has an account here. When the address is already registered, its owner gets a message saying so and that nothing was created; that way somebody who forgot they had an account is not left staring at “check your email” with an empty inbox.

Rate limits are per address and global, not per source: behind a proxy this server does not see a peer address it can trust, and a limit that X-Forwarded-For can bypass is worse than an honest global one.

Confirming, and what is blocked until you do

curl -X POST https://api.weft.sh/v1/auth/verify \
  -H "Content-Type: application/json" -d '{ "token": "weftv_…" }'

Confirming signs you in — somebody holding a link from their own inbox has proved as much as the sign-in form asks for.

An unconfirmed account may sign in, look around, and read whatever its role allows. It may not create a repository or a mirror. That is the line: everything cheap stays open, everything that costs storage or an outbound fetch does not. POST /v1/auth/resend-verification sends another link, and issuing one spends the previous one.

Service tokens are exempt. A token with no user behind it was minted by somebody who is confirmed, and every token minted before addresses were proved at all is one of these — CI does not stop working because a colleague has not read their email.

Forgotten passwords

curl -X POST https://api.weft.sh/v1/auth/forgot-password \
  -H "Content-Type: application/json" -d '{ "email": "you@example.dev" }'
curl -X POST https://api.weft.sh/v1/auth/reset-password \
  -H "Content-Type: application/json" \
  -d '{ "token": "weftrs_…", "new_password": "a different long password" }'

A reset link lives for an hour (a confirmation link, for a day), works once, and ends every other session on the account — whoever asked for it may have done so because somebody else was signed in. It also counts as proof of the address, so it finishes a signup that was abandoned. A verification link presented to the reset endpoint is refused, and the reverse too: the token’s row says what it is for.

A disabled account gets no reset link. Recovering an account an operator switched off would undo the switching off.

The very first account is created from the command line, because there is nobody yet to invite it:

stratum-server admin user-create --org acme \
  --email you@acme.dev --name "Your Name" --password '…' --role owner

Minting and revoking tokens

A signed-in person mints a personal access token for themselves — no administrator needed:

curl -X POST https://api.weft.sh/v1/orgs/acme/tokens \
  -b "$COOKIE_JAR" -H "Content-Type: application/json" \
  -d '{ "scopes": ["repo:write"], "label": "laptop" }'

A personal token carries your authority now, not the authority you had when it was minted. The scopes you mint it with are a ceiling; what it actually does on a given repo is that ceiling intersected with your effective role there. So:

You may mint any scope you can exercise somewhere in the org — your org role, a per-repo grant, or a grant to a team you are in, if one gives you more. A viewer with a member grant on one repo can hold a repo:write token; it writes there and reads everywhere else. Asking for more than that is refused at mint time, because a credential that silently does less than it says is worse than no credential.

An org service token belongs to nobody and is the right shape for CI. Minting one needs org:admin:

curl -X POST https://api.weft.sh/v1/orgs/acme/tokens \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "scopes": ["repo:read"], "repo": "widget", "label": "ci-runner" }'

Revocation is instant — every request verifies against the control plane, so a revoked token fails on its very next use:

curl -X DELETE https://api.weft.sh/v1/orgs/acme/tokens/$TOKEN_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN"

GET /v1/orgs/acme/tokens lists tokens without their secrets — every token in the org for an admin, your own for a member. Somebody else’s token answers 404 rather than 403, so a member cannot use revocation to discover which token ids exist.

Scopes

Scope Grants
repo:read clone/fetch and all read endpoints
repo:write everything in repo:read, plus push, commits, refs, repo create/delete
org:read listings, metrics, usage, audit queries
org:admin everything, including token management

A token minted with "repo": "<name>" is bound to that repo: it can’t touch any other repo and can’t perform org-level operations. This is the right shape for per-runner and per-agent credentials.

On the git wire

git sends credentials over HTTP Basic; put the token in either field:

git clone https://x:weft_…@api.weft.sh/acme/widget.git

Unauthenticated requests to private resources answer 401 (so git retries with credentials); valid credentials without access answer 404 — one org can never learn what exists in another.

Repos created with "public": true allow anonymous reads, and reads with any valid credential: a token minted in your own namespace clones and fetches a public repository in somebody else’s, which is how a fork is kept current with its upstream. The REST API keeps the same promise, and a person reads as themselves: a browser session or personal token with no role in the org still holds repo:read on a public repository, so what they do there — open a change from their fork, comment on it, tick the files they have read — is attributed to them rather than to nobody. A service token from another organisation reads a public repository the way anyone does, anonymously. A push by somebody who can read a repository but not write to it is refused with the reason — you can read acme/widget but not push to it; fork it and open a change from your fork, or ask an owner for write access — as an in-band error on the advert (git prints it as remote error:) and as 403 on the RPC itself. Only a repository you cannot read at all is masked as 404.

SSH keys

Deployments that expose the SSH front door also accept git clone ssh://git@host:port/acme/widget.git, authenticated by public key instead of a pasted token. A key never carries permissions of its own; it names something that does, and the row says which.

A personal key names you. Add it from the dashboard’s Settings → SSH keys, or over the API while signed in — no token id anywhere:

curl -X POST https://api.weft.sh/v1/orgs/acme/ssh-keys \
  -b "$COOKIE_JAR" -H "Content-Type: application/json" \
  -d "{ \"public_key\": \"$(cat ~/.ssh/id_ed25519.pub)\", \"label\": \"laptop\" }"

Its authority is re-resolved from your role on every connection, per-repo grants included. An administrator changes your role and the next git push from that laptop obeys it — there is no key to re-issue and nothing cached to expire.

A deploy key names a token instead, which is what an unattended machine wants. It inherits that token’s scopes and repo binding, and creating one needs org:admin:

curl -X POST https://api.weft.sh/v1/orgs/acme/ssh-keys \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{ \"public_key\": \"$(cat deploy.pub)\", \"token_id\": \"$TOKEN_ID\", \"label\": \"ci\" }"

Either way, revoking the key (DELETE /v1/orgs/acme/ssh-keys/$KEY_ID), the token, the membership or the account cuts SSH access on the very next connection — the fingerprint is resolved against the control plane every time, never cached. GET /v1/orgs/acme/ssh-keys lists keys with their OpenSSH SHA-256 fingerprints (compare with ssh-keygen -lf): every key in the org for an admin, your own for a member. Accepted key types: ed25519, ECDSA (P-256/384/521), and RSA.