In your project
Sign-in settings (the function that sends your sign-in emails, link URLs, token
lifetimes) live in the [auth] table of substrio.toml and ship with
substrio push (project-files.md). Your roles ([roles.NAME])
and social sign-in providers ([auth.providers.NAME], the secret taken from an
environment variable) are declared there too and applied by substrio deploy and
substrio push; the function that sends the sign-in emails receives the event
described in The mail function: email_function.
To move an existing user base and sign-in flow, see
bring-your-project.md.
User Management
Sign Up
curl -X POST $SUBSTRIO_URL/v1/_auth/register \
-H "X-Substrio-Tenant: my-app" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "secret123"}'
Passwords are stored hashed, never in clear.
Login
curl -X POST $SUBSTRIO_URL/v1/_auth/login \
-H "X-Substrio-Tenant: my-app" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "secret123"}'
Returns the session token and the user's id:
{"success": true, "user_id": 1, "token": "p4sH7M...", "keyring": "0xffffffffffffffff"}
Why a sign-up or sign-in failed
Each cause is its own code, and its error is the sentence to show the person as it is:
{"error": "That email already has an account. Sign in instead.", "code": "EMAIL_TAKEN", "status": 409,
"retryable": false, "next_action": "sign_in"}
| Code | Value | HTTP | next_action |
error |
When |
|---|---|---|---|---|---|
EMAIL_TAKEN |
0xA01D |
409 | sign_in |
That email already has an account. Sign in instead. | auth.register for an address that has an account; a provider's sign-in with an address it has not verified, when a person already holds it |
CREDENTIALS_MISMATCH |
0xA01E |
401 | correct_request |
That email and password do not match. | auth.login with a wrong password, or an address nobody holds (the two read the same, so neither tells who has an account) |
PASSWORD_LENGTH |
0xA01F |
400 | correct_request |
Use a password of 8 to 128 characters. | a password out of length, or none; field is password |
EMAIL_INVALID |
0xA020 |
400 | correct_request |
Enter a valid email address. | no address, or one past 255 bytes (and, for auth.email.code_request, one without an @); field is email |
ACCOUNT_LOCKED |
0xA022 |
423 | choose_supported_operation |
This account is locked. Reset your password to sign in again. | an admin's lock, or 10 failed sign-ins; auth.password.reset lifts it |
ACCOUNT_SUSPENDED |
0xA004 |
403 | request_permission |
This account is suspended. | the person is suspended or deleted |
USER_NOT_FOUND |
0xA021 |
404 | correct_request |
We could not find that person. | a sign-in, reset or impersonation for an id no person holds |
A failure costs no string on the server: the reason is a 2-byte code, and the answer is that code's row.
The session token is opaque, not a JWT
The token is one opaque string (no dots, no header, no claims): nothing in it can be decoded in the browser. It names a session the server holds, so a logout ends it at once, which a signed, self-contained token could not do. Never parse it; keep it and send it back.
What a JWT would have carried comes from one call, made once per session (and again when the page needs fresh values):
curl $SUBSTRIO_URL/v1/_auth/me \
-H "X-Substrio-Tenant: my-app" \
-H "Authorization: Bearer <token>"
{"user_id": 1, "email": "[email protected]", "keyring": "0xffffffffffffffff",
"email_verified": false, "mfa_enabled": false, "locked": false, "disabled": false,
"admin": false, "superadmin": false, "source": "native",
"created_at": 1791374435, "last_login": 1791374435}
The user's id, email, whether the email is verified, and the role flags
(admin, superadmin, keyring) are the claims an app needs. A client reads
role or isSystemAdmin from this answer, not from a decoded token
(substrio op auth.me is the same call). A key is not a user:
/v1/_auth/me with an app key or an account key answers 403.
One login across apps
A person signs in once, at the identity app, and every app registered as its client (auth.client.*) signs them in
without a password. The app sends them to the identity app's
/oidc/authorize with an S256 PKCE challenge, then trades the code on the
callback for a session of its own:
curl -X POST $SUBSTRIO_URL/v1/_auth/substrio \
-H "X-Substrio-Tenant: my-app" \
-H "Content-Type: application/json" \
-d '{"code": "...", "redirect_uri": "https://my-app.example/callback", "code_verifier": "..."}'
It answers like a password login (success, user_id, token,
keyring). The code is spent in process and works once. The app's user is
found by its link to the identity user, then by the identity app's verified
email (a user who signed up with a password is linked, not duplicated), or
is created.
A function sees the signed-in caller as request.auth:
{user_id, email_verified, expires_at, accounts}. accounts lists the
substrio accounts the person behind that user belongs to, as GET /v1/_accounts
shows them (id, name, created_at, services, suspended,
require_mfa, personal, and role: owner, admin or developer). It is the
identity user's own list for the identity app, the linked identity user's in
any other app, and empty for a user who never signed in through the identity
app. Your app's rules and functions define what each role may do.
A session token from the identity app also works directly against any other app your account owns: present it there and it is treated as the account's own key, admin of that app, whatever role (owner, admin or developer) you hold in the account. A token from any other app, or an account you hold no role in, is refused.
A function never reads a person's permissions itself. A route that needs to
know whether its caller holds one declares it in its gate: "GET /hosts/mine auth?ADMIN" is an auth route (a caller who is not signed in is refused
with 401) that also asks, and refuses nobody for the name. The platform
answers in request.auth.holds: true when the session holds ADMIN
(directly, through a permission that implies it, or through the catalog's
all name), false otherwise. A route that asks nothing has no holds
member, and neither has a call made with an account key (a key is not a
person and holds no permissions). A name the permission catalog does not
declare fails the deploy. "POST /notes public?NOTES_EDIT" is the same
asking, on a route that refuses nobody (public's own 401 is never given):
a signed-in caller gets holds the same way, and request.auth is null
with no credential, as public always answers.
An account key scoped to the app calls auth and verified routes: the app's consumers call its keyed API.
A key holds no permission, so an auth:<name> route answers a key only
when it belongs to the account that owns the app; any other key gets 403
PERMISSION_DENIED. "GET /contact-requests owner" is the gate for what
only the app's owner may read: it answers a key of the owning account
and nobody else (401 CREDENTIAL_REQUIRED with no credential, 403
PERMISSION_DENIED for any other key or any person).
"GET /reports plan:exports" answers a caller whose plan at the app turns
the feature exports on: the plan in force from every subscription they hold
there, else the app's default plan (the subscriptions guide, "The gate word plan:feature").
A key is judged as its account, a person as themselves. A plan without it
answers 403 PLAN_FEATURE_REQUIRED; a feature the app's plans file does not
declare, 403 PLAN_FEATURE_UNKNOWN; no credential, 401.
A publishable account key (made by account.key.publish, safe in a page's
source) calls only routes whose gate is
publishable, as in "GET /tiles.json publishable", and only from one of
its origins: any other route, origin or platform call answers 403
KEY_ORIGIN_REFUSED. For every other caller publishable is auth.
A browser may send its publishable key as ?access_token=<key> instead of an
Authorization header, so a page's GETs need no CORS preflight (one round
trip each, not two). It is read only on a publishable route with no key in a
header, under the same origin and app checks; the app's request.url never
holds it. A secret key in the URL is refused:
| Code | Value | HTTP | next_action |
When |
|---|---|---|---|---|
SECRET_KEY_IN_URL |
0xA023 |
400 | correct_request |
a secret account key (scoped or not) sent as ?access_token= on a publishable route: send the publishable key there, or the secret key in Authorization from a server |
KEY_ORIGIN_REFUSED |
0xA016 |
403 | request_permission |
a publishable key, in a header or the URL, at an Origin that is not one of its origins, or at a route that does not take one |
MapLibre with a publishable key: every request to the API carries it in the query, none carries a header.
new maplibregl.Map({
container: 'map',
style: 'https://api.mapjet.net/styles/atlas',
transformRequest: (url) =>
url.startsWith('https://api.mapjet.net/')
? { url: url + (url.includes('?') ? '&' : '?') + 'access_token=' + PUBLISHABLE_KEY }
: undefined,
});
Some routes cannot name their organization in the path: the body names a document, or the handler reads a row, and that row names the organization. A gate that reads the path cannot protect those. Check the permission in the handler, on the value it read:
const NOTES_EDIT = db.permission('NOTES_EDIT'); // once, when the module loads
module.exports = async function (request) {
const note = db.collection('notes').get(request.params.id);
db.requirePermission(NOTES_EDIT, request.auth, note.team);
// only a caller who may edit this team's notes gets here
};
db.permission(name) resolves a declared permission once (a name the
catalog does not declare is a TypeError when the module loads).
db.requirePermission(permission, request.auth, organization) takes the
organization's document id as text, the value of the reference a membership
names. It returns when the caller's membership in that organization holds
the permission, or when the route asked auth?<that name> (or the
catalog's all name) and holds answered true. Otherwise
the call is answered there with the same refusal a route
gate gives: 403 PERMISSION_DENIED, or 401 CREDENTIAL_REQUIRED when
nobody is signed in. It is an answer, not a failed function: nothing is
recorded as a function error, and code after the call does not run. Do not
catch it. A row that names no organization is held by nobody but a holder
everywhere. db.requirePersonOrPermission(person, permission, request.auth, organization) passes a row's own person (request.auth.user_id === person)
as well as anyone db.requirePermission would: a route whose
handler may act on what it owns or on a permission others hold, in one call.
db.requireVerified(request.auth) is the verified route gate as a call, for
a handler whose need for a proven address depends on a row it reads: it
passes a signed in person whose session carries the verified flag
(request.auth.email_verified) and an app key, and answers 401
CREDENTIAL_REQUIRED for nobody and 403 EMAIL_VERIFICATION_REQUIRED for
anyone else.
Declare the route auth?<the name the handler requires> so that a person
who holds the permission everywhere passes without a membership. On a
route that asks nothing, only a membership passes. A member pays one
membership lookup per call; a holder everywhere pays none; a route that does
not call it pays nothing.
A route gate names the organization the permission must be held in:
auth:[email protected] takes it from the path parameter org,
auth:EDIT@orgs.?org from the query parameter org (absent or empty is
refused), and auth:EDIT@orgs.??org from the query parameter org when it
is given: absent or empty, any signed in caller passes and the handler
answers for what the caller reaches itself; given, a membership in it is
needed as above. auth:EDIT@* passes a caller who holds the permission in
at least one organization. Each is decided before the handler runs: one
membership lookup a call, none when the optional parameter is absent.
A handler that found a person by a lookup (an address, a field the app
declared on _users) and needs that person's number, the one
request.auth.user_id holds, gets it with db.userIdOf:
const person = db.collection('_users').eq('email', db.arg(0)).first(address);
const userId = db.userIdOf(person); // a number; no read
db.userIdOf(person) takes a record of _users and reads nothing: a person
is keyed by the user id, and a record's own _id is that key as text. Anything that is not a person is a TypeError.
A person the identity app creates, by any path (a password sign-up, a
social or identity-app sign-in), gets an
account of their own at once, with them as its owner and personal: true,
named after their display name or their email's local part. It is made
once and never again; it goes with them when they are hard-deleted while it
has no other member and owns no apps.
Your account opens to 2 GB when you sign in with a provider
A new account holds 500 MB across all its projects. It opens to its plan's space (Free: 2 GB) the first time its owner signs in with Google, GitHub or another provider. An email code, a password, a passkey or an agent's console link does not open it: an email address costs nothing to make, and a provider account is harder to fake.
- Which accounts start at 500 MB: every account made with an owner. That
includes the personal account of a person who signs up by email code or
password, an account an agent makes with
agent.signup.finish, and an account a person makes withPOST /v1/_accounts. A person who signs up with a provider gets their plan's space at once. - What opens it: a sign in at the identity app with a third-party
identity provider: Google, GitHub, Apple, Microsoft, Facebook, X, Discord,
or an OIDC provider (
/v1/_auth/google,/v1/_auth/github,/v1/_auth/oauth). Every account the person owns opens at that sign in, once. An account where they are only a member stays as it is; its owner opens it. - What never opens it: an email code (
auth.email.code_verify), a password (auth.login, a reset), a passkey, 2FA, an agent's console link (auth.link.redeem), and the identity app's own sign in at another app (/v1/_auth/substrio).
Space here is enforced the same way as everywhere else
(SPACE_FULL, see plans and limits).
Signing out at the identity app (/v1/_auth/logout or logout_all) signs
the person out of every app they reached through it, at each app's next
request: a linked user's token issued before the sign-out is refused, by
every request and by /v1/_auth/refresh. No app is called; the check is cheap.
Console sign-in: a link, or the code beside it
The console signs a person in by email, with no password. Its first step asks
for one thing, the email address, and its button is "Send login link". One
auth.email.code_request at the identity app mails the person a link and a
six-digit code together, and the next step, "Check your email", waits for
either:
- The link. It opens the console at
#verify=<token>(the identity app'sverify_urlishttps://substrio.com/app/#verify={token}). The console reads the fragment once, removes it from the address (history.replaceState) before anything else, and spends it with oneauth.email.code_verify{token}, sending no other credential. The fragment never reaches a server. - The code. It is typed on the "Check your email" step, and the last digit
signs in on its own with one
auth.email.code_verify{email, code}. There is no button to press.
A person nobody held yet (created: true) is made and signed in at once, with
no 2FA asked at sign-up; the Customer page offers it later. A person
with 2FA enabled gets its challenge under mfa instead of a token, and the
console asks for that code (or sets one up, when the account requires it).
"Send again" requests a new pair. Inside the app's request interval the request is refused 429, which the console reads as "We just sent one. Check your inbox." "Use a different email" goes back to the first step.
The password stays one link away, "Use a password instead": the email and
password step (auth.login), with "Create an account" (auth.register) beside
it. Its refusals read as their codes say them (above).
The server's own sign-in pages sign in the same way and look like the console:
the OAuth consent page an MCP client opens (/oauth/consent, on
mcp.substrio.com for a platform account and on every app's host for that
app's users), the approval page an agent's irreversible call links to, and
the identity app's /oidc/signin and /oidc/invite. A mailed code is the
default step (auth.email.code_request, then auth.email.code_verify on the
sixth digit), "Use a password instead" is one link away (auth.login), and a
two-step code follows either one (auth.mfa, or a recovery code). An app
with no email_function refuses the code (PROVIDER_NOT_CONFIGURED), and the
page opens its password step with that message. The pages type the code
rather than follow the mail's link, so they work whatever the app's
verify_url names. See MCP: the built-in sign-in.
The console costs 1 request to send and 1 to verify. The code entry is one
component the Customer page shares for proving an email. lib/link.ts is the
one owner of a sign-in in the fragment (#link= below, #verify= here): a
mail link that proves nothing leaves its reason (already used, expired, too
many tries) for the sign-in page to show, as a refused #link= does.
A console sign-in link from an agent: account.console.link
An agent that signed its person up (the mailed code: auth.email.code_verify
or agent.signup.finish) can open the person's browser straight onto their
dashboard, signed in, without a password. It mints a one-time link and opens
its url:
| Mint | account.console.link, POST /v1/_accounts/console/link, MCP substrio_account_console_link, no body |
| Answer | {url, expires_at}: url is the console with the link in its fragment, https://substrio.com/app/#link=<token> |
| Redeem | auth.link.redeem, POST /v1/_auth/link/redeem with {token}, at the identity app: the console calls it, never the agent |
Only a person mints a link: an agent session, for the member it acts as, in
its account; or the person signed in to the identity app as themselves. A key
(an app's key, an account or agent key) and an agent token
are no person and are refused CONSOLE_LINK_NEEDS_PERSON.
The link signs in once and lives 5 minutes. A new link for the same person replaces the last, so a person holds one live link. Only the token's SHA-256 is stored, under the person's id. Nothing is added to any other request.
The token rides in the URL's fragment (#link=), which a browser never
sends to a server, so no request line, access log or Referer carries it.
The console reads the fragment once, removes it from the address
(history.replaceState), redeems it and lands signed in. The redeem answers
what auth.email.code_verify signs in with: {user_id, account_id, token, keyring}; for a person with 2FA enabled (or whose account requires it)
it answers the 2FA challenge under mfa instead of the token,
which auth.mfa trades for the session as after a password.
A session a link signed in is the person, with an agent key's approvals. Its token carries a signed mark (a bit of its signed flags byte, so it cannot be stripped; a refresh keeps it). With it:
- every call it may make runs as the person's, except an irreversible one
(
account.key.create, an app's deletion), which waits for an approval, exactly as an agent key's does (APPROVAL_REQUIRED, then the same call repeated once it is approved); - it cannot list, approve or deny an approval (
agent.approval.list,approve,deny) and cannot make an automation key: those answerCONSOLE_LINK_STEP_UP.
The approval opens in the account the call acts in (the app's account, or the account the route names) and names the person and the session's token, so the person, or an admin or owner of that account, decides it. A refresh is a new token, so a call waiting before it is asked again after it.
To take full authority the person steps up: they sign in again by the email
code (auth.email.code_verify) or with 2FA (auth.mfa).
That is a fresh session with no mark. The console does it for them: a link's
session that meets CONSOLE_LINK_STEP_UP or an approval's wait shows the
email code step, and the page comes back once the code matches. This keeps
the link from turning an agent into the person: the agent holds the URL, so
it could redeem the link itself, but the session it gets cannot approve its
own irreversible calls.
| Code | Value | HTTP | When |
|---|---|---|---|
CONSOLE_LINK_NEEDS_PERSON |
0xC0B8 |
403 | The minting caller is no person (a key or an agent token) |
CONSOLE_LINK_UNKNOWN |
0xC0B9 |
404 | No live link has this token: mistyped, made up, or replaced by a newer link |
CONSOLE_LINK_USED |
0xC0BA |
410 | The link already signed its person in once |
CONSOLE_LINK_EXPIRED |
0xC0BB |
410 | The link is past its life |
CONSOLE_LINK_STEP_UP |
0xC0BC |
403 | A link's session asked to decide an approval or make an automation key: sign in again by the email code or 2FA |
Each refusal's next_action is reauthenticate: the person signs in
another way, or the agent mints a new link. A refused redeem changes nothing.
The mark costs nothing on any request: it rides the token the redeem writes
anyway, the session reads it from the byte it already loads, and the
approval branch's one test of the session's access byte covers it with the
agent key's bit. Only the approval operations add a test.
# The agent, with the session its sign-up gave it: mint.
curl -X POST https://mcp.substrio.com/v1/_accounts/console/link \
-H "Authorization: Bearer $SESSION"
# {"url":"https://substrio.com/app/#link=substrio_cl_000000000000002a...","expires_at":1793145600}
# The console, in the person's browser, with the fragment's token (no credential).
curl -X POST https://mcp.substrio.com/v1/_auth/link/redeem \
-H "X-Substrio-Tenant: substrio" -H "Content-Type: application/json" \
-d '{"token": "substrio_cl_000000000000002a..."}'
# {"user_id":42,"account_id":7,"token":"...","keyring":"0x..."}
Two flags set the console address and the link's life:
--console-link-url (default https://substrio.com/app/#link={token}; its
{token} must follow a #, or the server does not start) and
--console-link-seconds (default 300, at least 60).
A credential in the URL's path or query, and a signed stateless token that needs no store, were refused: the first reaches servers and logs, and the second could be replayed until it expired.
Multi-factor sign-in
A user turns on TOTP (RFC 6238: SHA-1, 30 s steps, six digits, one step of drift either way) with an authenticator app:
# Start: the secret and the otpauth:// URI to scan. MFA is still off.
curl -X POST $SUBSTRIO_URL/v1/_auth/mfa/totp -H "X-Substrio-Tenant: my-app" -H "Authorization: Bearer $TOKEN"
# Confirm with a code: MFA is on, and the answer holds a new session and ten
# recovery codes, shown this once.
curl -X POST $SUBSTRIO_URL/v1/_auth/mfa/totp/confirm -H "X-Substrio-Tenant: my-app" \
-H "Authorization: Bearer $TOKEN" -d '{"code": "123456"}'
From then on a sign-in (password or social) answers 401 with
{"error": "mfa_required", "challenge": "substrio_mfa_...", "expires_at": ..., "attempts_left": ...}
and no session. The challenge and a code, or a recovery code, are traded for
the session:
curl -X POST $SUBSTRIO_URL/v1/_auth/mfa -H "X-Substrio-Tenant: my-app" \
-d '{"challenge": "substrio_mfa_...", "code": "123456"}'
- A challenge lives 5 minutes, works once, and is spent after 5 wrong codes.
- A code's time step counts once: the same code, or an earlier one, is
refused (
reused_code). - A recovery code works once.
POST /v1/_auth/mfa/recovery_codes(with a code) replaces them all;POST /v1/_auth/mfa/disable(with a code or a recovery code) turns MFA off. Both takemfa_attemptswrong codes in a row per user (attempts_leftin each401); then both answer429mfa_locked, whatever the code, formfa_lockout_seconds(default 900). - Turning MFA on or off, or replacing the recovery codes, ends every other session of the user at its next request, and at the identity app every app session signed in through it. The session that made the change keeps working (the user's cutoff record keeps its signature).
- The secret is sealed to the server's own seal key (
seal_key_file, AES-256-GCM) in a platform record; only SHA-256 digests of the recovery codes are stored.
An account can require MFA of its members (PUT /v1/_accounts/{id}/mfa
{"required": true}). A member without it then gets 403
{"error": "mfa_enrollment_required", "challenge": ...} at the identity app
and enrols with that challenge in place of a session (challenge in the
body of /v1/_auth/mfa/totp and /confirm); the confirm returns the
session. The identity app's sign-in page (/oidc/signin) and the agent
consent page (/oauth/consent) ask for the code; a consent is answered only
by a session that passed MFA, so apps signed in through POST /v1/_auth/substrio
trust the identity app's 2FA and do not ask again.
Using Tokens
Include the token in subsequent requests:
Authorization: Bearer <token>
The server looks the token up on each request, which is why a logout takes effect immediately.
The mail function: email_function
substrio does not send mail itself. email_function in [auth] names one of your
functions, and substrio calls it whenever a sign-in flow needs a mail sent: a
password reset, an email verification, an account invite.
[auth]
email_function = "send_auth_mail"
reset_url = "https://app.example/reset?token={token}"
verify_url = "https://app.example/verify?token={token}"
The function is called with one event (its event argument) shaped like this:
{"kind": "password_reset", "email": "[email protected]", "token": "substrio_pr_...",
"link": "https://app.example/reset?token=substrio_pr_...", "expires_at": 1791378035,
"app": "my-app", "user_id": 1}
| Field | Meaning |
|---|---|
kind |
password_reset, email_verify or account_invite. |
email |
Where to send it. |
token |
The single-use token; it is redeemed by POST /v1/_auth/password/reset or /v1/_auth/email/verify with this value. |
link |
reset_url (password reset) or verify_url (verification) with {token} replaced by the token; empty when the setting is empty. An invite's link is built from invite_url. |
expires_at |
When the token stops working, in Unix seconds (reset_token_seconds, verify_token_seconds). |
app |
The app's slug. |
user_id |
The user the token was issued to (not for account_invite). |
account_name, inviter_email, role |
Only for account_invite, in place of user_id. |
For a password reset, a verification or an invite, what the function returns is
not read: substrio does not wait for it, and a failure inside it does not reach
the person who asked. A sign-in code (auth.email.code_request) is the one mail
whose answer waits for the function, so the function can refuse it.
Refusing a sign-in code: RECIPIENT_UNSUBSCRIBED
A person who turned your app's email off (an unsubscribe link, for example) gets no code. The function says so by returning a 409 Response:
return new Response(JSON.stringify({
code: "RECIPIENT_UNSUBSCRIBED",
resubscribe_url: "https://mail.example/u/a42.b2ZmQGV4YW1wbGUuY29t.c2lnbmVk",
}), { status: 409, headers: { "content-type": "application/json" } });
The code request then answers that refusal, carrying the function's link, which
your sign-in page shows the person ("You turned off our email. Send me a link to
turn it back on."). Sign-in in the console and the sign-in pages the server serves
(OAuth consent, OpenID sign-in) already do: under the refusal they show "Turn this
app's email back on", a plain link to resubscribe_url (https only), whose page
asks the person to confirm:
{"error": "You turned off this app's email, so no code was sent. Ask for a link to turn it back on.",
"code": "RECIPIENT_UNSUBSCRIBED", "status": 409, "retryable": false, "next_action": "inspect_detail",
"resubscribe_url": "https://mail.example/u/a42.b2ZmQGV4YW1wbGUuY29t.c2lnbmVk"}
| Code | Value | HTTP | next_action |
error |
When |
|---|---|---|---|---|---|
RECIPIENT_UNSUBSCRIBED |
0xA024 |
409 | inspect_detail |
You turned off this app's email, so no code was sent. Ask for a link to turn it back on. | the app's email function answered 409 with that code and an https:// resubscribe_url (at most 2,048 bytes) |
Anything else the function does (any other answer, a 409 with another code or a
link that is not https://, a throw, a timeout) answers the usual
202 {"accepted": true, "expires_at": ...}. SendJet's send_auth_mail answers
this refusal for a person who unsubscribed from the app, and its link mails them
a way to turn it back on. The refusal tells the caller that this address turned
this one app's email off, which the sign-in page needs to say why no code came;
it says nothing about whether the address has an account.
The code request waits for the function's run (its mail send included), but no server thread does: the run's completion answers the request. A 202 costs one compare of the run's status more than before; the reply is parsed only on a 409.
A reset request answers
202 {"accepted": true} at once, whether or not the email belongs to a user
(so an address cannot be probed), and only a known, active user gets a mail.
Read a failed send in the function's logs (substrio logs --function send_auth_mail).
With no email_function set, a request for a reset or verification mail
answers 409.
Roles
| Role | Inherits | Grants |
|---|---|---|
guest |
none | limited public access |
viewer |
guest |
read-only access |
editor |
viewer |
create and modify documents |
admin |
editor |
manage collections, users and settings |
owner |
admin |
full control of the project |
A role holds everything the role it inherits holds. Create a custom role
(role.create) naming the role it inherits (default viewer) and, for your
own bookkeeping, the operations it grants and denies: these two lists are
stored and returned but never interpreted, since what a role may actually do
comes from its inherited keyring, not from these strings. See
permissions.md for the keyring model.
curl -X POST $SUBSTRIO_URL/v1/_roles \
-H "X-Substrio-Tenant: my-app" \
-H "Authorization: Bearer $SUBSTRIO_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "support",
"inherits": "viewer",
"grants": ["data.read"],
"denies": ["data.delete"]
}'
Assign a role to a user with role.assign:
curl -X PUT $SUBSTRIO_URL/v1/_users/42/role \
-H "X-Substrio-Tenant: my-app" \
-H "Authorization: Bearer $SUBSTRIO_KEY" \
-H "Content-Type: application/json" \
-d '{"role": "editor"}'
Check whether a user can perform an operation on a collection:
curl "$SUBSTRIO_URL/v1/_users/42/can?operation=write&collection=posts" \
-H "X-Substrio-Tenant: my-app" \
-H "Authorization: Bearer $SUBSTRIO_KEY"
That answers {"allowed": true} or {"allowed": false}.
Collection and record-level access
Collection access compiles down to ACL flag bitmasks (read_keyring,
write_keyring, list_keyring, create_keyring, and per-operation
deny/auth/role/owner/allow flags in CollectionACL), not a JSON list of role
names. collection.create's optional acl_flags sets the starting bits, and
declarative per-collection rules (security.rules.set, POST /v1/_rules) are
compiled into the same flags with zero per-request evaluation cost. See
permissions.md for the full layer model, the rules JSON
shape, and the owner/auth/role/allow rule values.
Authentication flow
A call to /v1/_auth/login with credentials returns an opaque session token
once the password matches. Send that token as Authorization: Bearer <token> on every later request. Each request looks the token up (a
logged-out or expired one is refused) and resolves the session's user and
role (GET /v1/_auth/me returns them to the client), then checks the
collection and record ACLs for the operation: a request that fails either
check answers 403 Forbidden.
AI control-plane credentials
An AI agent uses the same authentication contract as every other client. An
agent working on one app should use a short-lived bearer session or the app's
key and send X-Substrio-Tenant on every request. Its GET /v1/_tools response is
partition-bounded and contains only admin_scope: tenant tools.
After authentication, an agent discovers /v1/_capabilities, then
/v1/_tools, and invokes a discovered operation through
POST /v1/_tools/invoke or its concrete OpenAPI route. Tool operation IDs,
typed route captures, grant bits, and structured error codes are
authoritative: an agent must not infer permissions or next steps by matching
response text. See agents and MCP for the full
workflow and credential-handling rules.