The backend is a small set of Netlify functions (netlify/functions/) that host
drill file storage, deep links and a catalog feed. See architecture.md
for the wider system and ADR-0039 for the
origin split.
| Origin | Use |
|---|---|
https://ringdrill.app |
Public apex. /api/*, /d/*, /i/* and /brief/* are reverse-proxied to the API by the workers/apex-proxy/ Worker, keeping the apex URL stable for share and App/Universal Links. |
https://api.ringdrill.app |
The API directly (Netlify functions). The Flutter web app calls this origin. |
Both serve the same paths. Each /api/ endpoint has two equivalent spellings —
a dash form (/api/market-feed) that the app uses, and a slash form
(/api/market/feed). The native path /.netlify/functions/<name> also works.
An interactive API reference (Swagger UI) is served at
/api/docs, backed by the OpenAPI spec at
/api/openapi.json. The spec is
generated by netlify/functions/openapi.js; keep it in sync when endpoints
change. The tables below are the human-readable summary.
| Method | Path | Returns |
|---|---|---|
GET |
/api/market-feed?limit=50&cursor= |
Published catalog feed. { items: [...], nextCursor? } |
GET, HEAD |
/api/drills-head/{slug} |
Latest version metadata in headers (ETag, Content-Length, Content-Type, x-version). Empty body |
GET, HEAD |
/api/drills-head/{slug}@{version} |
Metadata for a specific version |
GET |
/d/{slug} |
Downloads the latest .drill (application/vnd.ringdrill+zip, Content-Disposition: attachment, x-version header) |
GET |
/d/{slug}@{version} |
Downloads a specific version |
GET |
/i/{slug} |
HTML install/preview page for a shared drill |
GET |
/brief/{uuid} |
302 to web.ringdrill.app/brief/{uuid} (interim, ADR-0041) |
GET |
/brief/plan/{uuid} |
302 to web.ringdrill.app/brief/plan/{uuid} |
POST |
/mcp (alias /api/mcp) |
The hosted MCP endpoint — MCP over Streamable HTTP. GET answers 405: the server is stateless and pushes nothing |
POST /mcp speaks JSON-RPC 2.0 per the MCP Streamable HTTP transport, exposing the
source-format tools an AI assistant uses to draft a drill plan
(ADR-0060, tool list in
mcp/README.md). Notable properties, all deliberate:
- Unauthenticated. Every tool maps to a public operation and
publishis absent, so there is nothing to authorize. Abuse is bounded instead: 1 MB body, 512 KB source document, 10 s compile. - Stateless. No
Mcp-Session-Id, no SSE stream. A batch of only notifications answers202. - Nothing is persisted. The endpoint compiles what it is sent and answers; the only storage it touches is a read of the public catalog.
Cache-Control: no-store— a result depends on the request body.
Require Authorization: Bearer <ADMIN_TOKEN>. The operation is chosen by the
action query parameter.
| Method | Path | Action |
|---|---|---|
GET |
/api/drills-admin?action=listall&limit=50&cursor= |
List every catalog entry (published or not) |
GET |
/api/drills-admin?action=versions&slug={slug} |
List versions for a slug |
POST |
/api/drills-admin?action=publish&slug={slug} |
Publish a slug |
POST |
/api/drills-admin?action=unpublish&slug={slug} |
Unpublish a slug |
POST |
/api/drills-admin?action=deleteversion&slug={slug}&version={v} |
Delete one version |
POST |
/api/drills-admin?action=deleteall&slug={slug} |
Delete a slug and all versions |
| Method | Path | Returns |
|---|---|---|
POST |
/api/drills-upload?slug={slug}&published=true |
Upload a .drill (request body is the zip). name, description and tags are read from program.json in the archive (ADR-0043) |
Authorisation follows ADR-0025's matrix, applied before OCC:
| Plan | Who may publish |
|---|---|
| New slug, anonymous | Anyone. Lands as anon-owned with accessPolicy: public |
| New slug, authenticated | Claimed for the active account at accessPolicy: account |
public |
Anyone, signed in or not — the wiki model, kept as a first-class option |
account |
Any member of the owning account, at any role |
shared |
Any member of the owning account or of a granted account |
Anonymous publishing keeps working, deliberately: signing in buys
protection, it is not the price of publishing. ownerId is taken from the
verified principal, and the legacy ?ownerId= parameter is ignored — it is
accepted as a no-op for one release so older clients are not broken.
A signed-in user may still publish openly. Two ways, both intact:
- Overwrite an existing public plan — a
publicplan stays writable by anyone, signed in or not. The upload looks for the slug in the caller's own namespace first and then inanon, so signing in never costs you write access to a plan you have been co-editing. - Publish a new plan openly — pass
?accessPolicy=public. It is owned by your account but writable by anyone. Without the parameter a new plan defaults toaccount, which is the protective choice;sharedis refused here, since it names specific grantee accounts and is set afterwards through/api/drills/policy.
A requested policy applies to a new plan only. An existing plan keeps the policy it has, so an ordinary update can never widen access as a side effect.
Concurrency is still guarded by If-Match (OCC), which runs after
authorisation.
| Method | Path | Returns |
|---|---|---|
POST |
/api/drills/policy?slug={slug} |
Changes a plan's access policy. Body: { "accessPolicy": "account"|"shared"|"public", "sharedAccountIds": [...] } |
Owner-only — the one operation where MemberRole rank matters. Every member
of an owning account may publish; only an owner may re-decide who can see the
result.
It is a separate endpoint rather than a parameter on upload, deliberately: publishing a new version and changing who may read a plan are different decisions, and folding them together is exactly how an ordinary update ends up silently widening access.
sharedrequires a non-emptysharedAccountIds. Storing it empty would read as "shared" in the UI while behaving asaccount, so it is a400.- Moving away from
sharedclears the grantee list, so a stale entry cannot keep granting access the UI no longer shows. - An
anon-owned plan has no owner to check, so its policy cannot be changed (403). There is no path from anonymous to owned. - A concurrent change answers
412rather than overwriting — re-read and retry.
curl -X POST "https://ringdrill.app/api/drills/policy?slug=lsor-eidene-2026" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"accessPolicy":"shared","sharedAccountIds":["a_redcross_fjell"]}'New in the accounts work (ADR-0024, DESIGN-015). An account is optional — the app works fully without one, and that stays the default.
One function, five routes, because they share the token minting and the membership lookup; splitting them would mean five copies of the claim assembly, which is the one part where a mistake is a security bug rather than a 500.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/auth/start-email |
Begin email sign-in — sends a magic link and a code |
POST |
/api/auth/callback |
Exchange a provider result or an email code for tokens |
POST |
/api/auth/refresh |
Refresh the access token |
POST |
/api/auth/logout |
End this session. Proves ownership with the access token or the session's refresh token |
POST |
/api/auth/sessions/revoke |
End another of this user's sessions — the sessions list's "log out this device" |
GET |
/api/auth/me |
The current principal, its accounts and roles |
GET |
/api/auth/providers |
Which third-party providers are configured, and the authorize URL to open for each |
GET, POST |
/api/auth/callback/{provider} |
Where a provider redirects the browser. Redirects into the app with a single-use handoff code |
acts and roles are read from stored memberships every time a token is
minted, so a role change takes effect on the next refresh rather than needing
a sign-out. That is why the token carries the map rather than the server looking
it up per request.
Third-party sign-in runs entirely server-side. The app holds no client id,
no client secret and no provider SDK: it asks /api/auth/providers at runtime,
opens the returned URL in a system browser, and later exchanges a one-minute
single-use handoff code for its session. The user still authenticates on the
provider's own page, on the provider's own domain — "server-side" describes
where the authorization code is exchanged, not where the human signs in.
Because the exchange happens on our server these are confidential OAuth
clients; an app using a native SDK could only ever be a public one. Setup is in
identity-provider-setup.md.
Behaviour depends on AUTH_MODE
(ADR-0073) — live, mock or off.
It is a deployment mode, not a feature flag: mock accepts test.-prefixed
tokens so the backend can be exercised without a live IAM instance, and mints
only what the active mode can verify.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/accounts |
Create an organisation, or upgrade a personal account (upgradeAccountId) |
GET |
/api/accounts/lookup?handle={handle} |
Resolve a handle to the account id a shared plan stores |
GET |
/api/accounts/{id}/members |
The roster, with each row's state |
POST |
/api/accounts/{id}/members |
Invite by email — owner only |
PATCH |
/api/accounts/{id}/members/{userId} |
Change a role — owner only |
DELETE |
/api/accounts/{id}/members/{userId} |
Remove a member, or leave |
GET |
/api/accounts/{id}/plans |
The account's plans — the Library's fourth tab |
DELETE |
/api/accounts/{id} |
Delete an organisation, or the caller's own account |
Three rules are enforced here rather than assumed by callers:
- Only an owner administers. Publishing follows from membership — every
member publishes, guests included — so
ownerdoes not mean "can do more with plans", it means "can decide who else is here". - An organisation always keeps one accepted owner. Demoting or removing the last one is refused, not offered and then failed.
- Invited is a state, not a role. The role is chosen at invite time and confers nothing until the invitation is accepted.
lookup is exact-match and requires signing in. That is the whole of the
enumeration answer: a handle is already public — it appears in
/d/<handle>/<slug> on every shared link — so resolving one reveals nothing
that trying the URL would not. A prefix or fuzzy search endpoint would be a
different thing entirely, a tool for listing which organisations exist, and is
deliberately not offered. Only the id and display name come back.
It exists because sharing a plan has to name another account and ids are what gets stored (ADR-0074 — handles change, ids do not). Asking a person for an opaque id is asking them to fetch something they have never seen; the handle is the name already in their plan links. A retired handle still resolves and reports the current one, so the UI can say which account it actually found.
What deletion removes, keeps and expires is enumerated in
data-retention.md, which is the document to check
against a GDPR question rather than this one.
Deletion keeps three things, and each would be a mistake to remove:
- Published plans. Other people have installed them, so the entry stays and
only loses its owner — it then behaves like an anonymous plan.
shareddoes not survive: its grantee list names accounts granted access by an owner who no longer exists. - The URL. The index key holds the account id, so the entry is not moved
into
anon/; that would change/d/<handle>/<slug>and break every link already shared. - The handle, as a tombstone. Releasing it for reuse would point somebody's shared link at a stranger's plan.
Deleting a personal account is refused with 409 sole_owner_of_organisation
while its user is the only owner of an organisation — the refusal names them.
Allowing it would reach DESIGN-015 §4.4's
unrecoverable state through a button.
GET .../plans lists unpublished plans too — an account library showing
only what had been published would omit exactly the drafts the tab exists for —
and each item says which it is. Guests see the list: guest is a personal-data
tier, so what a guest does not get is the roster inside a plan, enforced on the
download path (ADR-0072), not by
hiding the plan.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/invitations/{token} |
What this invitation is, and what state it is in. Works signed out |
POST |
/api/invitations/{token}/accept |
Accept it. Requires signing in |
DELETE |
/api/accounts/{id}/members/pending:{email} |
Withdraw an unanswered invitation — owner only |
Two properties, both security properties rather than conveniences:
- The link is not a credential.
ringdrill.app/invite/<token>identifies which invitation is being answered and grants nothing on its own. That is what reconciles an emailed link with DESIGN-015 §2.1's rejection of unauthenticated bearer URLs for sharing plans: a forwarded plan link hands over content, a forwarded invite link gets the holder a sign-in prompt they cannot satisfy. - The invited address is what binds. Accepting requires the signed-in user
to hold a verified identity for the address the invitation was sent to.
Someone invited at
ola@example.comwho signs in as somebody else gets a403naming both remedies — sign in with the invited address, or ask the owner to re-invite the one they actually use.
The describe route is deliberately anonymous: the landing page has to say "sign in as ola@example.com to accept" before anyone has signed in, which it cannot do if reading the invitation already requires being the right person.
state is one of pending, accepted, withdrawn, expired or
organisation_deleted, and accept adds wrong_identity. Each is reported by
name because the page renders a different message for each. Accepting is
single-use, but the token is marked rather than deleted — the same link is
routinely opened twice on two devices, and the second visit should say "already
accepted", not "no such invitation".
Never conflate these. MemberRole is the account/administration axis;
StaffRole is the roster and device-edit gate
(ADR-0057). Somebody can be an account guest and
a drill director at the same time.
| Values | Governs | |
|---|---|---|
MemberRole |
owner, member, guest |
Who administers the account, and who sees its people |
StaffRole |
director, instructor, actor, other |
What somebody does in a drill |
Unknown slug → 404. Unknown /api/* path → 404 with { "error": "not_found" }.
Admin request without a valid bearer token → 401. If-None-Match that matches
the current ETag → 304. Wrong method → 405. Upstream/storage failure → 500.
# Catalog feed (public)
curl "https://ringdrill.app/api/market-feed?limit=3"
# Latest version metadata (public, headers only)
curl -I "https://ringdrill.app/api/drills-head/lsor-eidene-2026"
# Download the latest .drill (public)
curl -L -o lsor-eidene-2026.drill "https://ringdrill.app/d/lsor-eidene-2026"
# List the whole catalog (admin)
curl "https://ringdrill.app/api/drills-admin?action=listall&limit=50" \
-H "Authorization: Bearer $RINGDRILL_ADMIN_TOKEN"
# Publish a slug (admin)
curl -X POST "https://ringdrill.app/api/drills-admin?action=publish&slug=lsor-eidene-2026" \
-H "Authorization: Bearer $RINGDRILL_ADMIN_TOKEN"
# Upload / replace a drill (anonymous — lands as a public, anon-owned plan)
curl -X POST "https://ringdrill.app/api/drills-upload?slug=lsor-eidene-2026&published=true" \
-H "Content-Type: application/vnd.ringdrill+zip" \
--data-binary @lsor-eidene-2026.drillarchitecture.md— system overview and backend contract- ADR-0039 — site/PWA/API origin split
netlify.toml— the public path aliases and the/api/*404 catch-alllib/data/drill_client.dart— the Dart-side client