tamis WIP #
Lightweight reverse proxy for atproto PDS record writes
(com.atproto.repo.createRecord / putRecord / deleteRecord).
tamis resolves each user's PDS from their DID document, keeps a per-user OAuth
session in a supervised OTP actor, signs each write with a DPoP proof, and
transparently refreshes + retries once on ExpiredToken. A client authenticates
to tamis with a per-user API key; tamis authenticates to the PDS with the
OAuth session.
Onboarding & admin UI #
Users self-serve through a small web UI (server-rendered with Lustre):
- Log in at
/with atproto OAuth (handle or DID). DPoP/PKCE/PAR are handled byatproto_client. tamis is a confidential client (private_key_jwt): it authenticates to each authorization server with an assertion signed by a key it generates on first run and persists, publishing the public half at/oauth/jwks.jsonand its metadata at/oauth-client-metadata.json. - That login is the onboarding — the resulting token set (DPoP-bound access token + rotating refresh token) becomes tamis's write credential, and tamis mints you an API key. No app-password is involved.
- Set your OAuth scope on the dashboard (e.g.
atproto transition:generic, or narrower). It's requested at your next login and the PDS enforces it on every write. Change it and re-connect to widen or narrow what tamis can do.
The session is kept alive indefinitely by a background liveness job (every 72h) that refreshes every stored session, rotating its refresh token and resetting the ~180-day refresh window long before it can lapse. A session ends only if the user revokes the grant or the server is down continuously for the whole window.
The single admin (the DID in ADMIN_DID) additionally gets an allow-list panel to
authorize/remove DIDs; each authorized user connects by logging in. The allow-list,
API keys, and OAuth sessions persist to the JSON file at TAMIS_STATE.
Writing through tamis #
Send the record-write XRPC calls to tamis with your API key as a bearer token:
curl -X POST "$TAMIS/xrpc/com.atproto.repo.createRecord" \
-H "authorization: Bearer $TAMIS_API_KEY" \
-H 'content-type: application/json' \
-d '{"repo":"did:plc:you…","collection":"app.bsky.feed.post","record":{…}}'
The repo must be your own DID (the key's owner); writing to any other repo is
403. A missing/invalid key is 401. Only createRecord, putRecord, and
deleteRecord are proxied. Rotating the key on the dashboard revokes the old one.
Configure #
All configuration is via the environment. For convenience a .env file is loaded
at startup (path is the first CLI argument, default .env); real environment
variables take precedence over it, and a missing file is ignored.
TAMIS_PUBLIC_URL must be the URL the PDS's authorization server uses to reach
tamis. In production that's a public https:// URL (the auth server fetches
/oauth-client-metadata.json and /oauth/jwks.json from it), and tamis acts as a
confidential client. For local development, set it to an http loopback origin
(e.g. http://127.0.0.1:3000) and tamis automatically falls back to atproto's
"localhost" public dev client — no hosted metadata, keyset, or tunnel needed.
Use 127.0.0.1 (not localhost) and drive the browser flow through the same
origin so the redirect and session cookie line up; the auth server must permit
localhost dev clients (Bluesky's does).
| Env var | Default | Meaning |
|---|---|---|
ADMIN_DID |
— (required) | DID granted admin rights in the UI |
TAMIS_PUBLIC_URL |
— (required) | public base URL; derives Oauth client_id/redirect |
TAMIS_SECRET |
— (required) | secret for signing session cookies |
TAMIS_ADDR |
127.0.0.1:3000 |
listen address |
TAMIS_STATE |
tamis-state.json |
persisted allow-list + OAuth sessions |
TAMIS_CLIENT_KEY |
tamis-client-key.pem |
confidential-client signing key (generated if absent) |
TAMIS_PLC_URL |
https://plc.directory |
PLC directory for did:plc lookup (write-proxying) |
TAMIS_OAUTH_RESOLVER |
https://slingshot.microcosm.blue |
handle/DID → PDS resolver for OAuth login |
TAMIS_OAUTH_SCOPE |
atproto |
default OAuth scope (per-user, editable in the UI) |
TAMIS_ALLOW_HTTP |
unset (https-only) | set to allow plain-http upstreams (dev) |
Develop #
nix develop # gleam + erlang + rebar3
gleam test
gleam format
ADMIN_DID=did:plc:... TAMIS_PUBLIC_URL=https://... TAMIS_SECRET=... gleam run
gleam run # or put the vars in a .env file
gleam run -- prod.env # ...or point at a specific env file
nix run .#e2e # full local PDS+PLC end-to-end suite (Linux only)
License #
MIT — see LICENSE.