@anchrd/gate-api
v0.9.1
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-api
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/gate-apiWhat 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).
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-api/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, Cloudflare Access)
Users can sign in with Google, Microsoft, or Cloudflare Access for SaaS (OIDC). Cloudflare Access
is the federation layer in front of an upstream identity provider, not an identity origin of its
own. 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_SECRET
wrangler secret put CLOUDFLARE_ACCESS_CLIENT_ID
wrangler secret put CLOUDFLARE_ACCESS_CLIENT_SECRETFor Cloudflare, also set the non-secret Zero Trust team name as
CLOUDFLARE_ACCESS_TEAM_NAME in wrangler.jsonc. Create an Access for SaaS application in
Zero Trust → Access controls → Applications, choose OIDC, and copy its client ID and secret. gate
derives all three Access endpoints at runtime from the team name and client ID:
https://<TEAM>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<CLIENT_ID>/authorization
https://<TEAM>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<CLIENT_ID>/token
https://<TEAM>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<CLIENT_ID>/jwksRegister 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 |
| Cloudflare Access | Zero Trust → Access controls → Applications → SaaS application → OIDC | https://gate.your-customer.dev/oauth2/callback/cloudflare |
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, team name, or secret),
so the UI knows which button and Better Auth mechanism to use. 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 and Cloudflare Access are forced to emailVerified: false, so gate's own
verification mail must establish ownership before an invitation can be accepted.
Admin UI (optional, same-origin)
The admin SPA (@anchrd/gate-ui) ships 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 @anchrd/gate-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-ui @anchrd/gate-sdk
npx gate build # -> .gate/ui (reads your gate.json; see the ui README)
wrangler deployRouting 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 is a separate package. It is consumed as its own package (@anchrd/gate-ui)
instead of being bundled into @anchrd/gate-api: the API tarball stays lean and runtime-neutral
(no HTML/JS payload in a package that also runs headless), and the UI versions independently of
the API. It ships as source, not as a built dist — your theme and your languages are applied
at build time, which a prebuilt bundle could not do. The repo's reference consumer
(workers/api) skips the install and points its assets.directory straight
at the workspace build (../../packages/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/applications | Machine principals with scoped API keys (CI/CLI). |
| /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 and thegateCLI.@anchrd/gate-contract— the shared wire formats.
