shop-backend-frontend
Write the FRONT END that talks to a merchant's Webmati backend — signing a person in (password or social), where the session lives, calling the data API and their workflow routes, and what each refusal means. Use when building an app, page or component against a backend already declared with shop-backend-builder. NOT for declaring tables or drawing workflows — that is shop-backend-builder.
The backend is already declared: tables, workflows, routes. This is the other half — the app a person actually touches.
⚠️ THE PLATFORM CAN SIGN SOMEONE IN; IT CANNOT STORE THE SESSION IN THEIR BROWSER. Half of every auth flow is yours. Skip it and the flow ends with the person looking at a JSON body on an API URL, which is exactly what happened in the field on 2026-09-08.
0. Where the API is
https://api-<store-slug>.<apex>, or the merchant's own hostname once they have
verified one. It is never the same hostname as their website: /orders must not
be ambiguous between a page and an endpoint.
1. Ask what the sign-in screen should contain
const cfg = await fetch(`${API}/auth/config`).then((r) => r.json());
// { password: true, providers: [ { provider: "google", label: "Google",
// start: "/auth/oauth/google/start" } ] }
Public, unauthenticated, and it says only what is already visible to anyone who presses a button. Render from it. Hard-coding a Google button shows one on a website where Google is switched off, and the person clicks into an error.
2. Password
// Register. The response carries the user, and NOT a verification token —
// that goes to their mailbox. An address the registrant could verify themselves
// is an address nobody verified.
await fetch(`${API}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
// Sign in. THIS one returns a session.
const { token, user } = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}).then((r) => r.json());
Forgot-password answers {"ok": true} whether or not the address has an
account — the same bytes either way, because a different answer is a list of
who has one. Say "if that address has an account, we have sent a code"; never
"no such user", because the platform did not tell you and will not.
3. Social
<!-- A LINK, not fetch(). The consent screen is a page the person must see, and
XHR cannot navigate there. -->
<a href="https://api-<slug>.<apex>/auth/oauth/google/start?return_to=https://yourapp.com/dashboard">
Đăng nhập với Google
</a>
return_to must be inside the provider's redirect_allowlist, which the
merchant configured. Omit it and the FIRST allowed address is used — so the flow
always lands somewhere they chose, never on the API.
// On the page you named, the session is waiting in the URL FRAGMENT.
const hash = new URLSearchParams(location.hash.slice(1));
const token = hash.get("token");
if (token) {
save(token);
// Take it out of the address bar. A token left in the URL goes into history,
// into a screenshot, and into whatever gets pasted next.
history.replaceState(null, "", location.pathname + location.search);
}
⚠️ It is a fragment and not a query parameter for a reason: a fragment is
never sent to a server, so the session stays out of every access log and out of
the Referer the next page sends. That is also why only the front end can read
it — nothing on the server ever sees it.
4. Every later call carries the session
fetch(`${API}/_bb/todos`, { headers: { Authorization: `Bearer ${token}` } });
/_bb/<table> is REST over a declared table, free with the declaration. A
workflow route is whatever path the merchant bound it to.
A route with auth_mode: end_user scopes to the person by itself. Do not
send their id in the body to "filter by user" — the platform fills an
owner-scoped column from the session and appends the filter, so a body that
named someone else would be ignored, and a body that named the right person is
work you did not need to do.
5. Reading a refusal
| status | it means | what the screen should say |
|---|---|---|
| 401 | no session, or it expired | send them back to sign in |
| 402 | the merchant's plan does not cover this | this is the OWNER's problem, not the visitor's — do not offer a retry |
| 422 | the write was refused, per column | show it against the field: the body carries violations: [{column, rule, detail}] |
| 429 | too many requests from this caller | back off; the ceiling is per IP and per route |
| 404 | no such route, or no such record | not the same thing — read error |
⚠️ A 422 is not a validation guess, it is the declaration talking. Render
detail beside the named column rather than one banner: the platform already
knows which field is wrong, and collapsing that into "có lỗi xảy ra" throws the
only useful part away.
6. What NOT to build
- Do not re-implement sign-in as a workflow.
/auth/*is built in; a drawn copy would mint sessions the platform does not know about. - Do not keep a
wmk_key in front-end code. It is scoped to the whole ORGANIZATION and takes its website from a header — in a browser it is every sibling website's data, handed to anyone who opens devtools. The session token from/auth/loginis the visitor's own and is the only credential a front end should hold. - Do not poll a workflow to find out if it finished. A route answers when it answers; if the work is long, the merchant's workflow should write a row and the app should read that row.
