Concepts
Sign-in for your app's users
A project can let dabadata keep the accounts of its own users: sign up with an email and a password, sign in, sign-in links by mail, password resets, with hosted pages if you want them and db.auth in the JavaScript client if you do not. You need no auth provider and no backend of your own. A signed-in user gets an ordinary dabadata user token, so rules, the trace and live subscriptions work exactly as with any other token.
What you get
- Accounts per environment. Live and test each have their own users; an account on test does not exist on live. These are your app's users, never people with a console login (Accounts and teams).
- Normal tokens. Signing in answers the same pair as
POST /v1/users/token: an access token for 1 hour and a refresh token for 30 days, for the user{ id, name, role, issuer: "hosted" }. Ids look likeusr_k3x9m2p7q4w8z1ab. Refreshing works as always. - Hosted pages at
/auth/<project id>/for sign in, sign up, links and resets, or the JS client to build your own forms. - Social sign-in with Google, Apple, Microsoft, GitHub and more, through your own OAuth apps, when you add
providers(Sign in with Google, Apple and more). - A role per user, one of your project's roles. New users get
defaultRole; you change it in the console or with the secret key.
Turn it on
Add an auth.users block to the environment's definition, next to tables:
"auth": {
"users": {
"enabled": true,
"signup": "open",
"defaultRole": "editor",
"verifyEmail": true,
"redirectUrls": ["http://localhost:5173/", "https://tasks.example.com/"]
}
}| Key | Default | Meaning |
|---|---|---|
enabled | false | Only true switches the routes and the hosted pages on. Off, every route and page answers 404. |
signup | "open" | "open": anyone may sign up. "invite": only people you add (Managing users); sign-up answers 403 signup_closed. |
defaultRole | required | A role the definition declares. Every new user gets it. |
verifyEmail | true | Sign-in waits until the address is confirmed by a mailed link. false: sign-up signs in at once. |
redirectUrls | [] | Where links and hosted pages may send people back: absolute http(s) URLs without a #, at most 20. Anything else asked for is refused. |
invite | none | { rule, roles? }: which signed-in users may invite people from your app, and which roles they may give (Invite people from your app). Without it nobody invites from the app. |
manage | none | A rule: which signed-in users may list, change and remove users from your app (App admins). Without it nobody manages users from the app. |
manageRoles | every role | With manage: the roles a manager may give, and the roles of the users they may change. |
profiles | none | { read, fields? }: a read-only table users with every user's id, name (and role, if you ask) that rows and pages can show (Profiles). |
onSignUp | none | { table, values }: one row the server inserts whenever a user is created (A row for every new user). |
providers | none | Sign-in with Google, Apple, Microsoft, GitHub, GitLab, Discord, LinkedIn and Slack, through your own OAuth app at each: { google: { clientId, secret }, ... } (Sign in with Google, Apple and more). |
Unknown keys are errors. auth.issuers may sit beside users or be left out. Edit it on the console's Definition page, or apply it with the environment's secret key: PUT /v1/schema takes the whole definition, so send the tables too - here the tasks template every new account starts with:
curl -X PUT https://dabadata.com/v1/schema \
-H "Authorization: Bearer $DABADATA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{ "definition": {
"roles": ["admin", "editor", "viewer"],
"tables": {
"tasks": {
"columns": { "title": { "type": "text" }, "done": { "type": "bool", "default": false },
"owner": { "type": "userId" }, "due": { "type": "date", "optional": true },
"notes": { "type": "json", "optional": true } },
"rules": { "read": "authenticated", "create": { "role": ["admin", "editor"] },
"update": { "any": [{ "role": "admin" }, { "owner": "owner" }] }, "delete": { "role": "admin" } }
}
},
"auth": { "users": { "enabled": true, "defaultRole": "editor",
"redirectUrls": ["http://localhost:5173/"] } }
}
}
EOFDo it per environment: test first, live when you are ready. The console's Users page shows the block while hosted accounts are off, and the list of users once they are on.
The routes
Every route is under /v1/auth/users/ and needs only the environment's publishable key (X-Api-Key); me, signout, the invite routes and the admin/ routes also need the user's token. Bodies are JSON.
| Route | Body | Answer |
|---|---|---|
POST signup | { email, password, name?, redirectUrl? } | { ok, verify: true, signupSecret } and a confirmation mail; with verifyEmail: false, { ok, verify: false, tokens, user } |
POST verify | { token, signupSecret? } | { tokens, user, redirectUrl } |
POST signin | { email, password } | { tokens, user } |
POST magic | { email, redirectUrl? } | { ok: true }, and a 15-minute sign-in link by mail |
POST magic/verify | { token } | { tokens, user, redirectUrl } |
POST forgot | { email, redirectUrl? } | { ok: true }, and a 1-hour reset link |
POST reset | { token, password } | { tokens, user, redirectUrl }; every earlier token of the user stops working |
GET me | - | { user } |
PATCH me | { name?, password?, currentPassword? } | { user }, plus new tokens after a password change |
POST signout | { refreshToken? } or { everywhere: true } | { ok, revoked: { tokens, refreshTokens } } |
POST invite | { email, name?, role?, redirectUrl? } | { ok: true }, whether or not the address has an account (invites) |
GET invites | - | { invites: [{ id, email, name, role, createdAt, expiresAt }] }: the caller's open invites |
DELETE invites/:id | - | { ok: true }: cancels one of them |
GET admin/users | - | { users, next }: the users, for app admins (?query=&limit=&after=) |
PATCH admin/users/:id | { role?, name?, disabled? } | { user } |
DELETE admin/users/:id | - | { ok, revoked } |
user is { id, email, name, role, emailVerified, hasPassword, createdAt }. hasPassword is false for a user who signed in by link or with a provider and never set a password: show them a "set a password" form if your app wants one. Passwords follow the console's rules: 10 to 200 characters, not one character repeated, not the email. signup, magic and forgot answer the same whether or not the address has an account; a known address that signs up again gets a "you already have an account" mail with a sign-in link, never a second account. Every route, with its errors, is in the HTTP API reference.
Hosted pages
With users on, the environment has sign-in pages on the API's host, titled with your project's name (rename it to your app's name):
https://dabadata.com/auth/prj_example12345/ sign in (live)
https://dabadata.com/auth/prj_example12345_test/ sign in (test)
https://dabadata.com/auth/prj_example12345/signup sign up
https://dabadata.com/auth/prj_example12345/magic email me a sign-in link
https://dabadata.com/auth/prj_example12345/forgot forgot passwordWith providers set, the sign-in and sign-up pages also show a "Continue with Google" (GitHub, ...) button for each provider that is on; it keeps the page's ?redirect=. The verify, reset and invite views are where mailed links land; they show a button, so a mail scanner that opens the link does not use it up. Add ?redirect=<one of your redirectUrls> and, after a successful sign-in, the page sends the person there with the tokens in the URL fragment:
https://tasks.example.com/#token=...&refreshToken=...&expiresAt=...&refreshExpiresAt=...Call db.auth.completeLink() on that page and the client takes the tokens and clears the fragment. Without a redirect the page simply says "You are signed in". The pages are plain HTML with one script and no third-party code, under a strict Content Security Policy; they cannot be framed and send no referrer.
Your app's name and logo in mails and pages
Every mail your app's users get (sign-in links, confirmations, password resets, invitations) and every hosted page is titled with the project's name, not your site's name or domain: "Sign in to my-first-app", with the first letter in a square as the logo. A new project is called my-first-app or whatever you named it when you created it, so rename it to the name your users know before you invite anyone:
- In the console: Settings > Project > General, change the name, save.
- Or with the secret key:
PATCH /v1/project{ "title": "TripMates" }(1 to 80 characters).
The name is shared by live and test and takes effect for the next mail. Only the title changes: the project id, the keys and the site's address stay the same. Mails come from dabadata's no-reply address; the name is in the subject, the heading and the footer ("Sent by dabadata on behalf of TripMates").
Add a logo and it replaces the letter: next to the name at the top of every mail, and above the name on the hosted pages. A square PNG or JPEG, 128 to 512 px, at most 256 KB, shared by live and test:
- In the console: Settings > Project > General > Upload logo (Replace logo, Remove).
- Or with the secret key:
PUT /v1/project/logo{ "contentType": "image/png", "data": "<base64>" };DELETE /v1/project/logoremoves it.
The logo is served publicly at /v1/logos/<project id>/<hash> (the project's logoUrl); a new logo gets a new address, so mails already sent keep showing the logo they were sent with until it is replaced. Mail apps that block images until the reader allows them show the name's first letter instead. While the project is still called my-first-app the console says so on Overview, Authentication and Sites, and Authentication > Sign-in methods shows the mail's header as your users will see it.
Redirect URLs and app mode
A link in a mail goes to one of two places:
redirectUrl you send | The link in the mail | Then |
|---|---|---|
| none, or a listed URL | The hosted page: /auth/<env>/verify?token=... | The person presses the button; the page signs them in and sends them to redirectUrl with the tokens, if there is one. |
a listed URL plus ?mode=app | Your page: <listed URL>#token=...&type=verify | Your page posts the token itself: db.auth.completeLink() does it. type is verify, magic, reset or invite. |
The URL without mode=app must be in redirectUrls exactly (the query compared in normal form), else the call is a 400 whose message lists the URLs that are allowed, e.g. redirectUrl is not one of the project's auth.users.redirectUrls (listed: http://localhost:5173/). http://localhost:5173/ and http://127.0.0.1:5173/ are different URLs: list the one your dev server opens. The page's origin must also be in the environment's allowed origins, or the browser blocks the call before you can read that message. The console's Authentication - URL configuration - App URLs sets both for an address in one step. A link answers the redirectUrl it was requested with only if that URL is still listed when it is used.
The JavaScript client
Give createClient the environment's publishable key, and db.auth does the rest: it calls the routes, stores the tokens, refreshes them and reconnects your live subscriptions as the new user.
| Method | Does |
|---|---|
signUp({ email, password, name?, redirectUrl? }) | Creates the account. Resolves { user, verify, signupSecret }: with verify a confirmation link is on its way and user is null; without, the person is signed in. |
signIn({ email, password }) | Signs in and resolves the user. |
signInWithLink({ email, redirectUrl? }) | Mails a 15-minute sign-in link. |
providers(), signInWithProvider(provider, { redirectUrl? }) | Which providers are on, and leaving the page to sign in with one (Sign in with Google, Apple and more). |
signInWithIdToken({ provider, idToken, nonce?, name? }) | Signs in with a Google or Apple ID token your app got itself (native apps). |
completeLink({ url?, password?, signupSecret? }) | Finishes whatever #token= the page URL carries: the hosted pages' redirect, or an app-mode link. Resolves the user, or null when there is no token. Reset and invite links need password (else it rejects with code: "password_required" and keeps the link). |
resetPassword({ email, redirectUrl? }) | Mails a 1-hour reset link. |
signOut({ everywhere? }) | Signs out this session (or all of the user's), forgets the tokens and reconnects as anonymous. |
user({ force? }) | The signed-in user, or null. Cached; force asks the server again. |
update({ name?, password?, currentPassword? }) | Changes the signed-in user's name and / or password and resolves the user. A new password needs currentPassword when there is one; it signs every other session out, and the client keeps the new token pair it gets back, so this page stays signed in. |
onAuthChange(cb) | cb(user, event) on "signIn", "signOut", "refresh" and "update" (after update()). Returns the function that stops it. |
invite({ email, name?, role?, redirectUrl? }) | Invites someone as the signed-in user (below). |
invites() | The signed-in user's open invites, newest first. |
cancelInvite(id) | Cancels one of them. |
admin.listUsers({ query?, limit?, after? }) | For app admins: resolves { users, next }, newest first. |
admin.setRole(id, role), admin.setName(id, name) | Changes a user's role or name; resolves the user. |
admin.disable(id), admin.enable(id) | Disables a user (signed out everywhere) or lets them in again. |
admin.remove(id) | Deletes a user. |
The session is kept in localStorage (option storage: "local", the default) and picked up by the next page load, or only in memory with storage: "memory". A refresh the server refuses ends the session and fires signOut. The client reference has every option.
A complete example
One HTML file: sign up, finish the mailed link, sign in, show who is signed in, sign out, and a task list that fills in once someone is signed in. It assumes the definition above on the test environment, with the page served at http://localhost:5173/ (any static server will do).
<!doctype html>
<meta charset="utf-8">
<title>Tasks</title>
<p id="who">Loading...</p>
<form id="signup">
<input name="email" type="email" placeholder="Email" required>
<input name="password" type="password" placeholder="Password, 10+ characters" minlength="10" required>
<button>Sign up</button>
</form>
<form id="signin">
<input name="email" type="email" placeholder="Email" required>
<input name="password" type="password" placeholder="Password" required>
<button>Sign in</button>
</form>
<button id="signout" hidden>Sign out</button>
<form id="add" hidden><input name="title" placeholder="New task" required> <button>Add</button></form>
<ul id="tasks"></ul>
<script src="https://dabadata.com/client/dist/dabadata.iife.js?v=5bce207b"></script>
<script>
const db = dabadata.createClient({
url: "https://dabadata.com",
key: "pk_test_EXAMPLEexample0000000000", // your test publishable key
});
const $ = (id) => document.getElementById(id);
function show(user) {
$("who").textContent = user ? `Signed in as ${user.email} (${user.role})` : "Not signed in";
$("signout").hidden = $("add").hidden = !user;
$("signup").hidden = $("signin").hidden = !!user;
}
db.auth.onAuthChange((user) => show(user));
// A mailed link (app mode) or the hosted pages brought us back with #token=...
db.auth.completeLink()
.then(() => db.auth.user())
.then(show)
.catch((err) => { alert(err.message); db.auth.user().then(show); });
$("signup").onsubmit = async (e) => {
e.preventDefault();
const f = new FormData(e.target);
try {
const res = await db.auth.signUp({
email: f.get("email"),
password: f.get("password"),
redirectUrl: location.origin + "/?mode=app", // the link comes straight back here
});
if (res.verify) alert("Check your mail and open the link in this browser.");
} catch (err) { alert(err.message); }
};
$("signin").onsubmit = async (e) => {
e.preventDefault();
const f = new FormData(e.target);
try {
await db.auth.signIn({ email: f.get("email"), password: f.get("password") });
} catch (err) {
// invalid_login, email_unverified, user_disabled, rate_limited
alert(err.code === "email_unverified" ? "Confirm your email first." : err.message);
}
};
$("signout").onclick = () => db.auth.signOut();
$("add").onsubmit = async (e) => {
e.preventDefault();
await db.insert("tasks", { title: new FormData(e.target).get("title") });
e.target.reset();
};
// Live list. Signed out, the read rule ("authenticated") refuses it;
// after a sign-in the client subscribes again as the user and the rows arrive.
const tasks = new Map();
const render = () => $("tasks").replaceChildren(
...[...tasks.values()].map((t) => Object.assign(document.createElement("li"), { textContent: t.title })));
db.subscribe("tasks", {}, {
onRows(rows) { tasks.clear(); for (const r of rows) tasks.set(r.id, r); render(); },
onChange({ op, row }) { if (op === "delete") tasks.delete(row.id); else tasks.set(row.id, row); render(); },
onError() { tasks.clear(); render(); },
});
</script>Open the page in two browsers with two accounts: a task added in one appears in the other. Each insert is in the trace with the user's id, their role editor and the issuer hosted.
The sign-up secret
Anyone can sign up with any address and a password of their choosing - including an address that belongs to someone else. So the password chosen at sign-up only survives if the confirmation comes from where the sign-up was made:
- Every sign-up that waits for confirmation answers a
signupSecret(and sets it as a cookie for the hosted pages). The client keeps it for 24 hours andcompleteLink()sends it with the confirmation link: the password and name stay. - If the address is first proven any other way - the confirmation link opened in another browser, a sign-in link, a wrong secret - the user is still confirmed and signed in, but without the password and name from the sign-up. Whoever signed up may not own the address. The user that comes back has
hasPassword: false; offer a "set a password" form and calldb.auth.update({ password })(nocurrentPasswordis needed while there is none), or let the person use "forgot password" later. - If your app carries the secret itself (say, to a native app), pass it:
completeLink({ signupSecret }). - For the same reason the right password on an unconfirmed account answers
email_unverifiedand mails nothing; the owner can always ask for a sign-in link.
Invite people from your app
Your app's own signed-in users can invite others, with only the publishable key in the browser and no backend of yours. You decide who may invite, and which roles an invite may give, in auth.users.invite:
"auth": {
"users": {
"enabled": true,
"defaultRole": "viewer",
"redirectUrls": ["http://localhost:5173/"],
"invite": { "rule": { "role": ["admin", "editor"] }, "roles": ["viewer", "editor"] }
}
}ruleis a rule like a table's ("authenticated",{ "role": [...] },any,all), checked against the inviter's current role.ownerhas no row to look at here and is refused. Without aninviteblock nobody invites from the app: 403invite_not_allowed, the same as a rule that says no.rolesare the roles an invite may give; the default isdefaultRolealone. An invite withoutrolegivesdefaultRole; any role not listed answers 403role_not_allowed.
With someone signed in through db.auth, a SolidJS component that invites and lists the open invites:
// db.ts: export const db = dabadata.createClient({ url: "https://dabadata.com", key: "pk_test_..." });
import { createResource, createSignal, For, Show } from "solid-js";
import { db } from "./db";
export function Invites() {
const [invites, { refetch }] = createResource(() => db.auth.invites());
const [email, setEmail] = createSignal("");
const [error, setError] = createSignal("");
async function send(e: SubmitEvent) {
e.preventDefault();
setError("");
try {
await db.auth.invite({
email: email(),
role: "viewer",
redirectUrl: location.origin + "/?mode=app", // the link comes back to this app
});
setEmail("");
refetch();
} catch (err: any) {
setError(err.message); // invite_not_allowed, role_not_allowed, rate_limited, validation
}
}
return (
<section>
<form onSubmit={send}>
<input type="email" required placeholder="Email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
<button>Invite</button>
</form>
<Show when={error()}><p role="alert">{error()}</p></Show>
<ul>
<For each={invites()}>
{(i) => (
<li>
{i.email} ({i.role}), open until {new Date(i.expiresAt).toLocaleDateString()}
<button onClick={() => db.auth.cancelInvite(i.id).then(refetch)}>Cancel</button>
</li>
)}
</For>
</ul>
</section>
);
}What the invited address gets:
- A new address: an account with the role and name you sent, no password and the address not yet confirmed, and a 7-day link by mail - "Dana invited you to Shop", with the inviter's name (or email). The link opens the hosted
invitepage, or with?mode=appyour own page with#token=...&type=invite. Choosing a password confirms the address, signs the person in and sends them toredirectUrl. - An address invited before that never signed in: the old link stops working and a new one is mailed. The account itself (role, name) stays as it was.
- An address with an account: nothing changes. The mail says they already have an account and links to the sign-in page, never with a token. A disabled account gets no mail.
The answer is { ok: true } every time, so inviting does not tell anyone whether an address has an account. (The inviter's own list can: an address that already had one never shows up in it.) invites() holds the signed-in user's open invites - not used, not expired, the person has not signed in - newest first, and cancelInvite(id) drops the link and removes the account if the person never got in. Each user sees and cancels only their own.
On the page the link opens in app mode, finish it with a password:
// The page the invite link opens in app mode: #token=...&type=invite
const type = new URLSearchParams(location.hash.slice(1)).get("type");
if (type === "invite" || type === "reset") {
// Show a "choose a password" form, then:
await db.auth.completeLink({ password }); // address confirmed, signed in
} else {
await db.auth.completeLink();
}Limits per day: 20 invites per inviter, 3 per invited address and 200 per environment.
App admins
An admin page in your app can list the users, change their roles and names, and disable or remove them, with nothing but the admin's own sign-in. Say who may, with a rule:
"auth": {
"users": {
"enabled": true,
"defaultRole": "member",
"manage": { "role": "admin" },
"manageRoles": ["member", "moderator"]
}
}manageis a rule like a table's ("authenticated",{ "role": [...] },any,all), checked against the caller's current role.ownerhas no row to look at here, so it is an error.manageRoles(default: every declared role) are the roles a manager may give, and the roles of the users they may change, disable or remove. Above, an admin manages members and moderators but cannot touch another admin, nor make anyone an admin.- A manager cannot change their own role, nor disable or remove themselves (403
self_not_allowed); their own name they may change. - Everything else is as the secret-key routes of Managing users: disabling signs the user out everywhere, a new role or name reaches their next call, and every change is in the workspace's audit log with the admin as the actor.
// On the admin page, signed in as an admin.
const { users, next } = await db.auth.admin.listUsers({ query: "example.com" });
await db.auth.admin.setRole(users[0].id, "moderator");
await db.auth.admin.disable(users[1].id); // signed out everywhere
await db.auth.admin.enable(users[1].id);
await db.auth.admin.remove(users[2].id);The list holds each user's email, role, disabled, lastSignInAt, hasPassword and providers. Errors: 403 manage_not_allowed (no manage, or the rule refuses the caller), 403 role_not_allowed, 403 self_not_allowed, 404 for an unknown user.
Profiles: the users table
A comment, an attendee list or a "created by" needs other people's names, but an account is private. profiles gives your rules a table to read instead:
"users": {
"enabled": true,
"defaultRole": "member",
"profiles": { "read": "authenticated", "fields": ["name", "role"] }
}- The environment gets a read-only table
users: one row per user,id(the user's id, the same valueuserIdcolumns hold),name,created_at, androleonly whenfieldslists it (default["name"]). Never the email. readis the table's read rule (a rule on the caller;owneris an error). Nobody writes it, not even the secret key: the server keeps it, as users sign up, are invited, renamed, given a role or deleted. Disabled and invited-but-not-yet-joined users have a row too.- Read it like any table:
db.query("users"),GET /v1/db/users,db.subscribe("users")for names that change live. A rename reaches every subscriber. - With
profileson, no declared table may be calledusers. Turning it on fills the table from the existing users. Auserstable already in the database that dabadata did not make (an old declared table) stops the apply until you drop it; turningprofilesoff leaves the table behind as drift.
// Show the author's name next to each comment.
const names = new Map((await db.query("users")).map((u) => [u.id, u.name]));
for (const c of await db.query("comments")) console.log(names.get(c.author) ?? "someone", c.body);A row for every new user (onSignUp)
Many apps keep a row per member (a points balance, settings, a profile with more than a name). onSignUp makes dabadata insert it, with values the client cannot fake:
"onSignUp": {
"table": "members",
"values": { "user": "$user.id", "email": "$user.email", "name": "$user.name",
"joined": "$now", "points": 0 }
}- It runs whenever a user is created: sign-up, a sign-in link that creates the account, an invite from the app, and a user you add (console or
POST /v1/project/users). valuesare literals or one of$user.id(auserIdortextcolumn),$user.email(text),$user.name(text; the column must beoptional, a user may have no name) and$now(datetimeordate). Every required column without a default must be given; the definition is refused otherwise.- The row is written as the server: the table's rules are not asked, so
createcan stay"nobody". It goes in the same transaction as the account: if the row fails (a unique or check constraint), no account is made and the call answers 409on_signup_failed. - The values are the ones at creation; a later name change is not copied (use profiles for names that follow). The insert is in the trace with
values._onSignUp= the new user's id.
Managing users
In the console, Users lists the environment's accounts (viewers can read it; developers and up change it): search by email or name, add someone, change a role, disable, delete. With the secret key, the same under /v1/project/users:
# list, newest first; ?query= searches email and name, ?after= pages
curl "https://dabadata.com/v1/project/users?query=example.com&limit=50" \
-H "Authorization: Bearer $DABADATA_SECRET_KEY"
# add someone and mail them a 7-day link to choose a password
curl -X POST https://dabadata.com/v1/project/users \
-H "Authorization: Bearer $DABADATA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Someone","role":"viewer","invite":true}'
# change a role, or disable (signs them out everywhere)
curl -X PATCH https://dabadata.com/v1/project/users/usr_k3x9m2p7q4w8z1ab \
-H "Authorization: Bearer $DABADATA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"disabled":true}'- Each user in the list (and the answers of
PATCH) carriesdisabled,lastSignInAt,hasPasswordandproviders: the sign-in providers linked to the user, such as["google", "github"], in the order of the provider table. - Users you add count as confirmed: the project vouches for the address. Give a
password, orinvite: trueto mail a link to the hostedinvitepage. This is how people join whensignupis"invite". Withinvite: true,redirectUrl(one of yours;?mode=appsends the link straight to your page) is where the person lands once their password is set. - Disabling revokes every token of the user and drops their unused links; they cannot sign in until you enable them again. Deleting removes the account and its tokens; rows they own stay in your tables.
- A new role or name reaches the user's next call and next refresh; a socket already open keeps the identity it opened with.
- Changes made in the console are in the workspace's audit log.
Resending an invitation
Someone who was invited but never chose a password shows as invited in the console's Users. If their mail got lost or the 7-day link expired, press Resend invite (or POST /v1/project/users/:id/invite with the secret key): a new link goes to the same account, so whatever your app linked to it stays, and the old link stops working. They can also type their address on your sign-in screen and use Forgot password, which works for an account with no password yet.
Copy users to live
When an app built on test goes live, the people who used it there can come along. In the console, on the test environment's Users, tick the users (or none, for everyone) and press Copy to live. Each gets a live account with the same email, name and role, and live mails them an invitation to choose a password.
- Passwords are never copied, nor Google or other linked sign-ins: everyone chooses a new password from the mail.
- The test accounts stay, and so do the rows they own in test; nothing else moves to live.
- Skipped, with the reason shown: disabled users, addresses live already has, and roles the live definition does not declare. Apply your definition to live first.
- Console only (developers and up), at most 200 users at a time:
POST /v1/project/users/to-live.
The test inbox
In a test environment, every mail your users would get - the confirmation, sign-in links, password resets and invitations - is also kept in the environment's test inbox for 7 days (the newest 200). Open it in the console under Authentication - Test inbox: each message with its link, whether the link is unused, used or expired, and buttons to open or copy it.
- Stop sending test mail. Turn off "Also send test mail to the real inboxes" (or set
"testMail": "inbox"inauth.users): test sign-ups then need no real address, and nothing leaves dabadata. Live environments ignore the setting; their mail always goes out and is never kept. - Try the failure answers. "Make it expired" and "Mark it used" change a mailed link on the spot, so your app's handling of
400 link_invalidcan be tested without waiting 24 hours. "Copy a broken link" gives the same link with one character of its token changed.
A script or a test suite can read it too, with the test environment's secret key:
curl "https://dabadata.com/v1/project/[email protected]&limit=1" \
-H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY"
# -> { "messages": [ { "id": "tm_...", "to": "[email protected]", "subject": "Confirm your email for Pulse",
# "purpose": "verify", "link": "https://dabadata.com/auth/prj_..._test/verify?token=...",
# "linkState": { "state": "unused", "expiresAt": "...", "usedAt": null }, "createdAt": "..." } ],
# "sending": false }
curl -X POST https://dabadata.com/v1/project/test-mail/tm_.../link \
-H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY" -H "Content-Type: application/json" \
-d '{"state":"expired"}'
# After a test run: delete its messages only (or one: DELETE .../test-mail/tm_...)
curl -X DELETE "https://dabadata.com/v1/project/[email protected]" \
-H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY"The messages hold working sign-in links, so only the secret key and console members with the developer role or above can read them. Details: HTTP API: test inbox.
Rate limits and link lifetimes
| Route | Limit (per environment) |
|---|---|
signup | 20 per IP address per hour |
magic | 20 per IP address per hour |
signup and magic together | 5 per email address per hour |
signin (and the current-password check of PATCH me) | 10 per email address and 30 per IP address per 15 minutes, every attempt counted |
forgot | 5 per email address per hour |
invite | 20 per inviter, 3 per invited address and 200 in all, per day |
Over a limit: 429 { "error": "rate_limited", "retryAfter" } with Retry-After. The routes also count toward the publishable key's request rate. Links work once: a confirmation link for 24 hours, a sign-in link for 15 minutes, a reset link for an hour and an invitation for 7 days.
Errors
| Status | error | When |
|---|---|---|
| 400 | validation | A malformed address, a password that breaks the rules, a redirectUrl that is not listed. Every problem in messages. |
| 400 | link_invalid | The link's token is unknown, used or expired. |
| 401 | invalid_login | Wrong email or password (the same answer for an address with no account). |
| 401 | unauthorized | me, signout, an invite or admin/ route without a live token of a hosted user of this environment; a wrong publishable key. |
| 403 | email_unverified | The right password, but the address is not confirmed yet. |
| 403 | user_disabled | The account is disabled. |
| 403 | signup_closed | Sign-up is "invite". |
| 403 | invalid_login | PATCH me: the current password is wrong. |
| 403 | invite_not_allowed | The project has no auth.users.invite, or its rule refuses this user. |
| 403 | role_not_allowed | An invite asked for a role outside auth.users.invite.roles; an app admin gave a role, or touched a user whose role is, outside auth.users.manageRoles. |
| 403 | manage_not_allowed | The project has no auth.users.manage, or its rule refuses this user. |
| 403 | self_not_allowed | An app admin tried to change their own role, or disable or remove themselves. |
| 409 | on_signup_failed | The onSignUp row could not be inserted; the account was not created. |
| 404 | not found | Hosted accounts are not enabled in this environment; DELETE invites/:id for an invite that is not one of yours, or no longer open. |
| 429 | rate_limited | See above. |
With identity providers
Hosted accounts are one more way in, next to the others: a provider's JWT, a token your backend mints, and anonymous. To dabadata a hosted user is simply a user whose issuer is hosted.
- Pick hosted accounts when your app has no sign-in yet. Keep your provider (Google, Clerk, Auth0, Firebase, Supabase, your own) when it already has one: declare it under
auth.issuersand send its tokens as they are. - Social sign-in or a provider's tokens? With
providers, people sign in with Google or GitHub and become hosted users that dabadata keeps. Withauth.issuers, the provider keeps the accounts and dabadata only checks its tokens. - Both at once is fine:
auth: { users, issuers }. For example, staff sign in with your company's Google and customers with hosted accounts. Rules see a role either way; a rule never looks at the issuer. - The issuer name
hostedis reserved for hosted users, so no entry ofauth.issuersmay use it. One older name is reserved too (see the note on the product's earlier name). - A hosted user's role is stored with the account (set by
defaultRole, changed by you); a provider user's role comes from a claim in their token.