@anchrd/gate
v0.40.0
Published
**gate** — login and permissions as a finished product. A Cloudflare Worker holds the logic, [D1](https://developers.cloudflare.com/d1/) holds everything as data. Deployable per customer, steerable from the outside over **REST** and **MCP**.
Readme
@anchrd/gate
gate — login and permissions as a finished product. A Cloudflare Worker holds the logic, D1 holds everything as data. Deployable per customer, steerable from the outside over REST and MCP.
Built on Better Auth, Hono, Drizzle, and Zod.
Install
npm i @anchrd/gateWhat it is
One question matters: can(interface, function) — may this user perform this action?
gate holds users, roles with their permissions, registered interfaces, and an append-only audit log. A customer edge
asks /api/v1/authorization once per request and gets back { identity, rules } — you never write code
inside gate; everything is data.
The most convenient way to steer and consume gate is @anchrd/gate-sdk (client + gate CLI).
Where this package stops. It needs a D1 binding and a rate-limit binding, and it needs
BETTER_AUTH_URL and BETTER_AUTH_SECRET; without them it does not come up in a usable state. It
is not a library you call in-process — every consumer talks HTTP. It does not run your service's
authorization for you: it answers what may this identity do, and the decision is made in your own
code by can(). It carries the admin UI but does not serve it unless you ask
for it (the assets binding below is optional), and it stores no business data — only principals,
roles, permissions, registered interfaces and the audit log.
Setup (Cloudflare Worker)
The core is a factory (createGate(deps)); a thin worker shell injects the bindings.
The ready-made ./cloudflare export is exactly that shell — your worker entry re-exports it:
// api.ts — your wrangler `main`
import worker from "@anchrd/gate/cloudflare";
export default worker;// wrangler.jsonc
{
"name": "gate",
"main": "api.ts",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [{ "binding": "DB", "database_name": "gate" }],
"ratelimits": [{ "name": "RATE_LIMIT", "simple": { "limit": 100, "period": 60 } }],
"vars": {
"BETTER_AUTH_URL": "https://gate.your-customer.dev",
"GATE_ADMIN_EMAIL": "",
"GATE_OAUTH_AUDIENCES": "https://operations-dwh.example.com/mcp,https://operations-intel.example.com/mcp"
}
}One required secret (not in vars):
wrangler secret put BETTER_AUTH_SECRET # ≥ 32 bytes of randomnessApply the migrations and deploy. The first human who signs up becomes administrator and can sign in without email verification; every later account must verify its address normally. Between deployment and that first sign-up, the publicly reachable installation can be claimed. Sign up first, then announce or expose the installation to its intended audience.
GATE_ADMIN_EMAIL is an optional emergency exit, not bootstrap configuration. Leave it empty during
normal operation. If all administrator roles are lost, set it to the email address of an existing
account; the shared admin guard then admits that account so roles can be repaired. Remove the value
again after recovery.
GATE_OAUTH_AUDIENCES is an optional, comma-separated allowlist for MCP resource servers hosted
outside gate. Add their exact public resource URLs (including /mcp when applicable). Gate then
accepts RFC 8707 resource parameters for those URLs and issues access tokens with the requested
audience; every URL not on the allowlist remains rejected. Without this value, only
BETTER_AUTH_URL is a valid audience.
When registering such a service, set its oauthResource to the same exact URL. The service key then
accepts only JWT access tokens minted for that resource; a token for another configured resource is
rejected.
Hosting elsewhere means swapping the ./cloudflare shell plus the db/mailer adapters; the core beneath knows no env.
Social sign-in (optional: Google, Microsoft)
Users can sign in with Google or Microsoft (OIDC). A provider is active exactly when all its
values are set — there is no second switch. Set none and gate behaves as before (the login page
shows only email/password). Credentials are worker configuration, never gate.json: that file lives
in your git and is baked into the UI at build time; a client secret has no place there and the
worker could not read it anyway.
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put MICROSOFT_CLIENT_ID
wrangler secret put MICROSOFT_CLIENT_SECRETRegister these redirect URIs at the provider (they are <BETTER_AUTH_URL>/callback/<provider>):
| Provider | Where | Redirect URI |
|---|---|---|
| Google | Google Cloud Console → OAuth client → Authorized redirect URIs | https://gate.your-customer.dev/callback/google |
| Microsoft | Entra ID → App registration → Authentication → Redirect URIs (Web) | https://gate.your-customer.dev/callback/microsoft |
Swap gate.your-customer.dev for your BETTER_AUTH_URL. The public GET /api/v1/auth-options
returns only each active provider's name and sign-in path (no client ID or secret), so the UI
knows which button to show. The first human account becomes administrator regardless of whether it
is created through OIDC or email/password. Every later OIDC account holds no role by default,
just like every later email sign-up. Google can supply a verified email; Microsoft is forced to
emailVerified: false, so gate's own verification mail must establish ownership before an
invitation can be accepted.
Cloudflare Access as a way IN has been removed (
#446).CLOUDFLARE_ACCESS_CLIENT_ID,CLOUDFLARE_ACCESS_CLIENT_SECRETandCLOUDFLARE_ACCESS_TEAM_NAMEare no longer read; an installation that still sets them is not broken, they are simply ignored. Existingaccountrows for the provider are kept, and the account screen still lists and unlinks the leftover way.⚠️ Order matters, and there is no rescue afterwards. An account whose only way in was Access and that carries no password is locked out once the provider is gone:
sendResetPassworddeliberately sends no reset link to an account without acredentialrow, only a hint pointing at/account, and/accountneeds a session. Such an account must set a password throughPOST /set-passwordwhile the provider is still configured.⚠️ This does not touch the opposite direction: gate as an identity provider for others.
/oauth2/authorizeand the ES256 key set at/jwksare unchanged. What did go, with#531, are therolesandmcpclaims in the ID token: they existed for Cloudflare Access in front of the MCP portal, which is no longer part of the product. A policy that still reads either claim finds nothing and has to be moved to a product-side check (POST /api/v1/authorization).
Second factor (optional, per person)
Anyone with an account can switch on a second factor for themselves under Account: a passkey (WebAuthn, bound to the device) plus a set of backup codes. Nothing is switched on for anyone by gate, and no role or setting can require it.
- The passkey answers the challenge. Signing in stays password-first: the password is checked, and gate then asks for the passkey before it hands out a session. The passkey is the second factor, not a replacement for the first one.
- The backup codes are the way back when the device is gone: sign in with the password as usual, then answer the same challenge with one code instead. Each code works once.
- They are shown once, at setup. gate stores them hashed and has no endpoint that reads them
back;
POST /two-factor/generate-backup-codesreplaces the whole set with a new one. - There is no code by email, and no TOTP app. As long as the mailbox can reset the password, a code sent to that same mailbox is the same factor twice, not a second one.
Two refusals exist so that nobody can lock themselves out, and both answer 409:
| You try to | gate answers | |---|---| | switch the factor on without a passkey | refused — the backup codes are the rescue for a passkey, not a factor of their own | | remove your last passkey while the factor is on | refused — switch the factor off first, then remove the passkey |
On the API surface,
POST /passkey/verify-authenticationdoes create a session on its own, the way the Better Auth plugin ships it. The interface gate delivers does not offer that as a way in: there is no "sign in with a passkey" button before the password, and the passkey is only ever offered as the second step. Anything built on top of the API should say which of the two it means.
⚠️ A second factor protects the sign-in in a BROWSER. It does not protect the machine door. The challenge hangs on
/sign-in/email,/sign-in/usernameand/sign-in/phone-numberand on nothing else — a request carrying anx-api-key, a bearer token or an OAuth access token is never asked for a second factor, and its behaviour is unchanged by this feature.That is deliberate rather than an omission: a machine principal cannot carry a second factor, because there is nobody present to answer the challenge. Any future rule that makes a second factor mandatory has to exempt machine principals explicitly, or it locks out the very agents the installation runs on.
Admin UI (optional, same-origin)
The admin SPA travels inside this package, under ui/, and is served as Workers Static Assets
on this same worker — one deploy per customer, no separate UI worker. Same-origin means the Better
Auth session cookie just works: no CORS, no SameSite pain. Add an assets binding pointing at the
built UI:
// wrangler.jsonc — add alongside the config above
{
"assets": {
"directory": ".gate/ui", // where `gate build` puts the built UI (see below)
"binding": "ASSETS" // exposes env.ASSETS to the shell for the SPA fallback
}
}Build the UI, then deploy the worker — wrangler dev/deploy serves UI and API from one
process:
npm i @anchrd/gate
npx gate build # -> .gate/ui (reads your gate.json)
wrangler deployThe UI ships as Vite/React source, not as a prebuilt bundle: gate build applies your
gate.json — theme, logo, favicon, language catalogs — and builds it. A prebuilt dist could not
do that. The output belongs to you and is written outside node_modules, which the next
npm ci would wipe:
| the UI is found in | output |
|---|---|
| node_modules/@anchrd/gate/ui (installed) | .gate/ui in your project root |
| packages/gate/ui (this workspace) | packages/gate/ui/dist |
You never import from the UI in your own code — building it is the whole interface. The same
command mirrors this package's D1 migrations to .gate/migrations, so your wrangler.jsonc needs
no path into a package's insides.
Routing is automatic and needs no main changes: Cloudflare's asset router serves existing files
(/, /assets/*) before the worker; the API routes (/api/*, /oauth2/*, /.well-known/*,
/mcp, /consent, …) keep priority; and a browser navigation to a client route (/grants,
/roles, …) falls back to index.html so deep-links and reloads work. The customer picks the
domain (e.g. admin.gate.your-customer.dev) in their own wrangler config. Omit the assets
binding and gate is a pure API — env.ASSETS is absent and the fallback is skipped.
Why the UI travels in here. Until 0.26.0 it was a package of its own (@anchrd/gate-ui),
versioned separately from the API. That bought a leaner tarball for a consumer who runs gate
headless — and there has never been one. What it cost was real: an installation had to pin two
version numbers for one product, and the two drifted apart. Since 0.28.0 there is one number.
⚠️ The price is named, not hidden: whoever installs this package pulls the UI's build
dependencies (vite, react, tailwindcss) even if they never run gate build. The repo's
reference consumer (workers/api) points its assets.directory straight at
the workspace build (../../packages/gate/ui/dist).
Surface
| Path | |
|---|---|
| POST /api/v1/authorization | Bearer → { identity, rules }, scoped to the calling service (service key; stamps "last seen"). |
| GET /api/v1/schema | The calling service's own interfaces, for codegen (gate init). |
| PUT/DELETE /api/v1/service/interfaces/:handle | Idempotent self-management of the calling app service's own interfaces. The service key supplies the scope; no service id is accepted. |
| /api/v1/services · /roles · /users | Administration (admin). A service owns its interfaces (/services/:id/interfaces) and carries the one service key. A role owns its permissions: /roles/:role/permissions reads them as a tree (service → interface → function) and sets/unsets a single (role, interfaceId, function). |
| /api/v1/users/:id/disable · /enable | Lock a human account and release it again. Sessions are deleted, sign-in is refused, an already issued token stops resolving at the next protected access — identity and roles are kept. Nobody locks themselves, and nobody locks the last operational human admin. |
| /api/v1/applications | Machine principals with scoped API keys (CI/CLI), including /:id/disable and /:id/enable. |
| /api/v1/oauth-clients | The clients that registered themselves at the open POST /oauth2/register — list them with their registration date, DELETE one together with its tokens and consents. There is no create path here: the open endpoint is the only way in. |
| /mcp | The same administration actions as MCP tools. |
| /auth/* · /oauth2/* · /.well-known/* | Better Auth: login, OAuth provider, DCR, PKCE, discovery. |
| /health | Whether the gate itself is alive. |
Two kinds of auth: authorization, schema, and interface self-management accept the
service key; /api/v1/auth-options is public. Everything else under /api/v1 requires a
user bearer with admin rights (or an application key holding the admin role).
Every service-key door is scoped to the calling service. The key identifies a service, and gate
answers only about that service's own interfaces: /authorization intersects the user's permissions with
them (a data warehouse backend never learns what the same user may do on the dashboard MCP), and
/schema generates types for those interfaces alone. This is also what keeps a handle unambiguous —
two services may carry the same handle, but one response only ever describes one service.
The single exception is POST /api/v1/users/invite/accept: an invitation is accepted by the invitee,
who is not an admin. It still requires an authenticated session, and the role it grants comes from the
stored invitation — never from the request body. Accepting only works on an account whose email is
verified, which is what stops a stranger from pre-registering an address about to be invited.
Sign-up stays open, sign-in requires a verified email. A newly created user holds no role and
therefore no access; POST /sign-up/email no longer returns a session, and the first sign-in attempt
sends the verification mail through the injected mailer port. Sign-in, sign-up and invite are rate
limited through the RATE_LIMIT binding.
Related
@anchrd/gate-sdk— the client, the shared wire formats and thegateCLI. That is the package a consumer of a gate installs; this one is what an operator installs, and it depends on the SDK.
