@lowdefy/docs-content 0.0.0-experimental-20260909201552 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +0 -1
  2. package/content/actions/login.md +5 -23
  3. package/content/actions/logout.md +5 -5
  4. package/content/agents/agentchat.md +1 -13
  5. package/content/concepts/ai-agent-docs.md +4 -90
  6. package/content/concepts/custom-styling.md +5 -50
  7. package/content/concepts/events-and-actions.md +2 -64
  8. package/content/concepts/i18n.md +1 -1
  9. package/content/connections/mongodb.md +3 -109
  10. package/content/container-blocks/modal.md +1 -1
  11. package/content/container-blocks/pagesidebarlayout.md +0 -1
  12. package/content/controls/reject.md +1 -3
  13. package/content/controls/throw.md +0 -1
  14. package/content/controls/try.md +2 -3
  15. package/content/display-blocks/difflist.md +1 -1
  16. package/content/display-blocks/difftimeline.md +1 -1
  17. package/content/display-blocks/s3download.md +1 -1
  18. package/content/display-blocks/search.md +0 -1
  19. package/content/input-blocks/checkboxselector.md +4 -80
  20. package/content/input-blocks/radioselector.md +3 -65
  21. package/content/input-blocks/s3uploadbutton.md +1 -1
  22. package/content/input-blocks/s3uploaddragger.md +1 -1
  23. package/content/input-blocks/s3uploadphoto.md +1 -1
  24. package/content/input-blocks/tiptapmentioninput.md +1 -1
  25. package/content/list-blocks/masonrylist.md +1 -1
  26. package/content/operators/_type.md +3 -14
  27. package/content/operators/_user.md +3 -3
  28. package/content/user-authentication/login-and-logout.md +8 -158
  29. package/content/user-authentication/users-introduction.md +0 -8
  30. package/index.json +1 -99
  31. package/package.json +1 -3
  32. package/content/actions/emailotpsend.md +0 -58
  33. package/content/actions/emailotpverify.md +0 -77
  34. package/content/actions/magiclinkverify.md +0 -113
  35. package/content/controls/while.md +0 -46
  36. package/content/input-blocks/captcha.md +0 -40
  37. package/content/input-blocks/tagmultipleselector.md +0 -101
  38. package/content/input-blocks/tagselector.md +0 -101
  39. package/content/migration/auth-upgrade.md +0 -315
  40. package/content/other/mongodbadapter.md +0 -3
  41. package/content/user-authentication/auth-steps.md +0 -111
  42. package/content/user-authentication/mcp-oauth.md +0 -289
  43. package/content/user-authentication/organizations.md +0 -134
  44. package/content/user-authentication/two-factor.md +0 -225
  45. package/content/user-authentication/user-object.md +0 -58
  46. package/hazards.json +0 -44
@@ -1,289 +0,0 @@
1
- # MCP Server & OAuth
2
-
3
- A Lowdefy app can expose its API endpoints as tools to an MCP client — Claude, ChatGPT, an IDE assistant — over the Model Context Protocol. When those tools touch anything but public data, the client has to prove who is calling, and Lowdefy handles that by turning the app itself into an **OAuth 2.1 authorization server**: the assistant runs a normal browser authorization, the user signs in, chooses the organization to work in, and grants consent, and the app mints an access token the assistant sends on every tool call.
4
-
5
- This page is about the *authenticated* MCP surface. Exposing tools to an app's own agents, and the `mcp` array on an agent (the app acting as an MCP *client*), are covered in [MCP tools for agents](/agent-mcp-tools) and the [`Mcp` connection](/Mcp). Here the app is the *server*, and the caller is an outside assistant.
6
-
7
- ## One resource, one address
8
-
9
- There is a single MCP resource for the whole deployment, served at `/api/mcp`. The organization a call acts in is **not** part of the address — it is chosen once during authorization and carried inside the access token as a claim. Every assistant connects to the same URL; two users, or one user in two organizations, are told apart by their tokens, never by the path.
10
-
11
- This is the Linear model — one link, the workspace is a property of the authorization rather than the address — and it is deliberate. An earlier design keyed the resource per organization (`/api/mcp/{organization_id}`); that put the organization in the URL, the RFC 8707 `resource` parameter, and the token audience, and needed a per-organization resource row kept in sync. The single resource deletes all of that: one address to paste, one audience, and the organization rides the token.
12
-
13
- | Route | Purpose |
14
- | ----- | ------- |
15
- | `POST /api/mcp` | The MCP resource — streamable HTTP transport. Tools are listed and called here, per request, authorized against the bearer token. |
16
- | `GET /.well-known/oauth-protected-resource/api/mcp` | RFC 9728 protected-resource metadata: the resource URI, the authorization server, and the scopes it supports. Public and constant per deployment. |
17
- | `GET /.well-known/oauth-authorization-server/api/auth` | RFC 8414 authorization-server metadata, re-exposed at the path a client derives from the issuer. |
18
- | `/api/auth/*` | The authorization server itself — authorize, token, registration, and the OAuth endpoints the consent and picker pages call. |
19
-
20
- The `/.well-known` documents derive their URIs from the pinned `BETTER_AUTH_URL`, never from a request `Host` header, so they are the same for every caller. They mount **only when `auth.oauthProvider` is configured** — an app that is not an authorization server has nothing to discover, and its MCP tools are all public (see the rule below).
21
-
22
- ## Turning the app into an authorization server
23
-
24
- Add `auth.oauthProvider`. It names the two pages the browser authorization flow hands control to, and whether unknown clients may register themselves:
25
-
26
- ```yaml
27
- auth:
28
- oauthProvider:
29
- consentPage: /oauth-consent
30
- postLoginPage: /oauth-select-organization
31
- dynamicClientRegistration: false
32
- ```
33
-
34
- - **`consentPage`** *(required)* — the Lowdefy page id of your consent screen. The authorization flow redirects the signed-in user here to approve or deny the client. The engine builds the redirect as `${origin}${basePath}${consentPage}`, joined with no separator, so **the leading slash is required**.
35
- - **`postLoginPage`** — the page where a signed-in user chooses which organization the authorization acts in. It is shown after login and before consent. It is **required under `organizations.policy: tenant`** (the build fails without it) and is skipped under `pinned`, where there is only ever one organization to act in.
36
- - **`dynamicClientRegistration`** — allow unregistered MCP clients to self-register per RFC 7591. Off by default; pre-registered clients are the primary path. Turn it on for public assistants you cannot pre-register.
37
-
38
- Both page ids are validated at build time the same way: the page must exist, and it must not be public-listed in a way that would let it render without a session — the flow only ever reaches them for a signed-in user.
39
-
40
- ## Exposing tools, and their scopes
41
-
42
- The app-level `mcp` block declares the server's identity and which API endpoints are tools:
43
-
44
- ```yaml
45
- mcp:
46
- name: acme
47
- version: '1.0.0'
48
- title: Acme
49
- websiteUrl: https://acme.example.com
50
- icons:
51
- - src: https://acme.example.com/icon-512.png
52
- mimeType: image/png
53
- sizes: ['512x512']
54
- endpoints:
55
- - id: search-customers
56
- scope: mcp:read
57
- - id: create-invoice
58
- scope: mcp:write
59
- ```
60
-
61
- `name`, `version`, `title`, `websiteUrl` and `icons` are the server branding a client shows the user in the `initialize` handshake. Each `endpoints` entry needs an `id` and a `scope`.
62
-
63
- **`scope` is a closed vocabulary — `mcp:read` or `mcp:write`, and nothing else.** Apps cannot mint their own scopes. `mcp:write` implies `mcp:read` at runtime, so a token granted write can call both. Tag a tool that only reads with `mcp:read` and one that mutates with `mcp:write`; the consent screen then lets a user grant an assistant read-only access to the whole surface if they choose.
64
-
65
- Three rules the build enforces on every tool:
66
-
67
- - **Only `Api` endpoints can be tools.** An `InternalApi` endpoint is not reachable as one — it has no external caller by design.
68
- - **Every tool needs a `description` and a `payloadSchema`.** The description is what the assistant reads to decide when to call the tool; the `payloadSchema` becomes the tool's input schema. A tool with neither is not a usable tool, so the build refuses it.
69
- - **The `mcp.agents` key is gone.** MCP agent tools are not supported; remove it if you are upgrading.
70
-
71
- ## A tool is gated twice
72
-
73
- When a call arrives at `/api/mcp`, a tool is listed and callable only when **both** hold:
74
-
75
- 1. the caller's **role outcome** allows the endpoint — the same `auth.api` / `auth.api.roles` gate every API call passes; and
76
- 2. the **token's granted scope** covers the tool's tag.
77
-
78
- The 401 challenge is decided once, at the route boundary, before the MCP transport runs. Past that boundary the transport answers over HTTP 200, so a role or scope shortfall never surfaces as a `403` or an `insufficient_scope` error to the assistant — the tool is simply not offered. This is intentional: the tool surface does not disclose the existence of tools the caller may not use.
79
-
80
- ## The rule that catches everyone: keep MCP endpoints out of `auth.api.public`
81
-
82
- > **Never list an MCP endpoint id in `auth.api.public`.**
83
-
84
- One public tool suppresses the 401 challenge for the *whole* route. The build sets `mcp.json`'s `hasPublicTool` flag when any exposed endpoint is public, and the `/api/mcp` route then serves an anonymous caller a `200` with the public tools instead of a `401` with the `WWW-Authenticate` challenge. A freshly added assistant reports "connected, 1 tool" and **never runs the OAuth flow** — only a *stale* token would take the challenge branch. Every protected tool then sits behind a door the client was never told to knock on.
85
-
86
- So a protected app keeps its `auth.api` gates and leaves every `mcp.endpoints` id out of the public list:
87
-
88
- ```yaml
89
- auth:
90
- api:
91
- protected: true
92
- # Keep MCP endpoint ids OUT of this list. A single public tool
93
- # flips mcp.json hasPublicTool, and /api/mcp then serves anonymous
94
- # callers a 200 with that tool instead of the 401 challenge.
95
- public:
96
- - webhooks/stripe # a webhook receiver, not an MCP tool
97
- ```
98
-
99
- The build has a matching guard from the other direction: a protected or role-gated MCP endpoint **requires** `auth.oauthProvider`. Without the authorization server there is no way to authenticate the caller, so the build fails rather than shipping an unreachable tool — make the endpoint public (accepting the trade above) or configure `oauthProvider`.
100
-
101
- ## The authorization flow, end to end
102
-
103
- 1. The assistant reads `/.well-known/oauth-protected-resource/api/mcp`, finds the authorization server, and (if it is not pre-registered and `dynamicClientRegistration` is on) registers itself.
104
- 2. It opens a browser to the authorization endpoint. The user signs in through your normal sign-in page.
105
- 3. **Under `tenant`,** the flow redirects to `postLoginPage`. The user picks the organization to work in; the page sets it active and continues the authorization. **Under `pinned`,** this step is skipped.
106
- 4. The flow redirects to `consentPage`. The user approves the client and the requested scopes — or, if they have already consented for this client in this organization, consent is skipped.
107
- 5. The app mints an access token whose **`organization_id` claim is the chosen organization** (the consent `referenceId`), and the assistant sends it as a `Bearer` token on every `/api/mcp` call.
108
-
109
- Consent is recorded per **(client, user, organization)**. Re-authorizing into an organization the user has already consented for skips the consent screen; a first authorization into a *different* organization asks for consent again, for that organization.
110
-
111
- ### How a token becomes a caller
112
-
113
- On every `/api/mcp` request the token is verified in-process against the authorization server's own signing keys: the issuer and audience must match the resource, and the token must carry both a `sub` (the user) and an `organization_id` claim. The claim is not trusted on its own — the **consent row for `(client, user, organization)` must still exist**. That liveness read on every call is what makes switching and disconnecting take effect *immediately*: revoke the grant and the next call is refused at once, not at the token's expiry.
114
-
115
- The caller is then resolved as a member of that organization, exactly as a session caller is — same membership wall, same role source, same `_user` shape — with one extra field: **`_user.auth_method` is `'mcp'`**, so a routine can tell an assistant apart from a browser without any app-side plumbing.
116
-
117
- ```yaml
118
- # In an endpoint routine, branch on how the caller arrived:
119
- - id: set_channel
120
- type: SetState
121
- params:
122
- channel:
123
- _if:
124
- test:
125
- _eq:
126
- - _user: auth_method
127
- - mcp
128
- then: mcp
129
- else: ui
130
- ```
131
-
132
- A token that cannot be verified (for example, an opaque token minted because the client omitted the RFC 8707 `resource` parameter), or one whose grant has been revoked, gets a `401` with a `WWW-Authenticate` challenge that tells the client what to do — reconnect and re-run the authorization, including the organization choice.
133
-
134
- ## Building the consent and picker pages
135
-
136
- Both pages are ordinary Lowdefy pages that read the authorization request from the URL query, show the user what is being asked, and finish the flow with client actions. Four actions do the work:
137
-
138
- | Action | What it does |
139
- | ------ | ------------ |
140
- | `ListOrganizations` | Returns the caller's organization memberships — the rows the picker renders. |
141
- | `SetActiveOrganization` | Sets the caller's active organization. The picker calls it for the chosen organization. |
142
- | `OAuthContinue` | Continues the authorization after the organization is chosen (`POST /api/auth/oauth2/continue`). Returns `{ url }` to redirect to. |
143
- | `OAuthConsent` | Approves (`accept: true`) or denies (`accept: false`) the client. Returns `{ url }` to redirect to. |
144
-
145
- ### The organization picker (`postLoginPage`)
146
-
147
- On mount the page calls `ListOrganizations` and stores the rows in state; a `ListSelector` renders them, and its click handler sets the chosen organization active and continues:
148
-
149
- ```yaml
150
- id: select_org_rows
151
- type: ListSelector
152
- properties:
153
- selectable: false
154
- hoverable: true
155
- data:
156
- _state: organizations
157
- events:
158
- onClick:
159
- - id: set_active_organization
160
- type: SetActiveOrganization
161
- skip:
162
- _eq:
163
- - _event: item.id
164
- - _user: organization_id
165
- params:
166
- organizationId:
167
- _event: item.id
168
- - id: continue_authorization
169
- type: OAuthContinue
170
- messages:
171
- error: false
172
- - id: continue_redirect
173
- type: Link
174
- params:
175
- url:
176
- _actions: continue_authorization.response.url
177
- ```
178
-
179
- `SetActiveOrganization` is skipped when the chosen organization is already the active one, so re-picking the current organization is a no-op that still continues the flow.
180
-
181
- ### The consent screen (`consentPage`)
182
-
183
- Reads the request from the query, shows the client and scopes, and finishes on the user's decision:
184
-
185
- ```yaml
186
- id: consent_allow
187
- type: Button
188
- properties:
189
- title: Allow access
190
- events:
191
- onClick:
192
- - id: consent_allow_action
193
- type: OAuthConsent
194
- messages:
195
- error: false
196
- params:
197
- accept: true
198
- - id: consent_allow_redirect
199
- type: Link
200
- params:
201
- url:
202
- _actions: consent_allow_action.response.url
203
- ```
204
-
205
- The deny button is identical with `params: { accept: false }`. Read `_url_query: client_id` and `_url_query: scope` to render what the client is asking for, and `_url_query: resource` to confirm the audience. Fetch the client's own metadata (name, logo) from `/api/auth/oauth2/public-client?client_id=...` to show a human-readable client name rather than an opaque id.
206
-
207
- ## Switching organization from the assistant
208
-
209
- An assistant connected to one organization switches by *disconnecting* — the client then re-runs the authorization and the user picks a different organization. Expose a tool that revokes the calling grant with the `RevokeMcpGrant` step:
210
-
211
- ```yaml
212
- id: switch-organization
213
- type: Api
214
- description: >
215
- Disconnect this assistant from the organization it is connected to so
216
- the user can connect it to another one. Use ONLY when the user asks to
217
- work in a different organization. After it succeeds the connection is
218
- gone: tell the user to reconnect in their assistant — the browser will
219
- ask which organization to connect to.
220
- payloadSchema:
221
- type: object
222
- additionalProperties: false
223
- properties: {}
224
- routine:
225
- - id: revoke_grant
226
- type: RevokeMcpGrant
227
- - ':return':
228
- disconnected: true
229
- organization_id:
230
- _step: revoke_grant.organizationId
231
- message: >-
232
- Disconnected. Ask the user to reconnect in their assistant.
233
- ```
234
-
235
- [`RevokeMcpGrant`](/auth-steps) reads the calling token's `(client, user, organization)` from the request context, deletes that one consent row, and revokes its refresh tokens. It is **caller-scoped**: it touches only the grant the call arrived on, so another assistant the same person connected, or this assistant's grant in another organization, is untouched. It refuses to run for any caller that did not arrive over MCP — there is no grant behind a browser session for it to revoke.
236
-
237
- Expose it as an `mcp:read` tool. The next tool call the assistant makes gets a `401`, the client re-runs OAuth, and the user lands back on the organization picker.
238
-
239
- ## Disconnecting assistants from the app
240
-
241
- The mirror control belongs in your app's UI: a person removing every assistant connected to their active organization. It deletes the caller's consent rows for that organization and revokes the matching refresh tokens — the same two writes `RevokeMcpGrant` makes, scoped to the active organization instead of one client:
242
-
243
- ```yaml
244
- id: disconnect-assistants
245
- type: Api
246
- description: Disconnect every assistant connected to the caller's active organization.
247
- payloadSchema:
248
- type: object
249
- additionalProperties: false
250
- properties: {}
251
- routine:
252
- - id: revoke_refresh_tokens
253
- type: MongoDBUpdateMany
254
- connectionId: oauth-refresh-tokens
255
- properties:
256
- filter:
257
- user_id:
258
- _user: id
259
- reference_id:
260
- _user: organization_id
261
- revoked: null
262
- update:
263
- $set:
264
- revoked:
265
- _date: now
266
- - id: delete_consents
267
- type: MongoDBDeleteMany
268
- connectionId: oauth-consents
269
- properties:
270
- filter:
271
- user_id:
272
- _user: id
273
- reference_id:
274
- _user: organization_id
275
- - ':return':
276
- disconnected:
277
- _step: delete_consents.deletedCount
278
- ```
279
-
280
- Because the `/api/mcp` route reads the live consent row on every call, every affected assistant is refused on its next call, not at token expiry.
281
-
282
- ## Summary
283
-
284
- - The app is an OAuth 2.1 authorization server; enable it with `auth.oauthProvider`.
285
- - There is one MCP resource at `/api/mcp`. The organization is a token claim, not a URL segment.
286
- - Tag each tool `mcp:read` or `mcp:write`; a tool is gated by both role and scope.
287
- - **Never list an MCP endpoint id in `auth.api.public`** — one public tool suppresses the challenge for the whole route.
288
- - Consent is per `(client, user, organization)` and read live on every call, so `RevokeMcpGrant` and disconnecting take effect immediately.
289
- - `_user.auth_method` is `'mcp'` for assistant callers.
@@ -1,134 +0,0 @@
1
- # Organizations & Multi-Tenancy
2
-
3
- Every Lowdefy app has organizations — always, even a single-team internal tool. A signed-in caller is a **member** of an organization, and it is the membership, not the user record, that carries the caller's app roles and per-organization attributes. This is the unit the whole auth system is built on: `_user.roles` is the active membership's roles, and the [tenant wall](#the-tenant-wall) scopes data to the active organization.
4
-
5
- The single knob that decides how organizations behave is `auth.organizations.policy`.
6
-
7
- ## Two policies: `pinned` and `tenant`
8
-
9
- ```yaml
10
- auth:
11
- organizations:
12
- policy: pinned # or "tenant"
13
- ```
14
-
15
- **`pinned`** *(the default)* — one organization for the whole deployment, ensured at startup. This is the shape for an internal app or a single-customer install: there is one workspace, everyone is a member of it, and the active organization is never switched. The organization plugin's per-organization routes (create, switch, self-service org management) are disabled.
16
-
17
- **`tenant`** — organizations are created per user, and a caller can belong to several and switch between them. This is the shape for a multi-tenant SaaS: each customer is an organization, members are invited into it, and `SetActiveOrganization` moves the caller between the workspaces they belong to.
18
-
19
- An app that sets nothing gets `pinned` with one auto-seeded organization, invite-only. The policy is not something you can leave to a default and change later without thought — it changes what `_user.organization_id` means, whether the tenant wall is active, and whether the MCP flow needs an [organization picker](/mcp-oauth). Choose it up front.
20
-
21
- ## The `organizations` keys
22
-
23
- | Key | Type | Applies to | Default | Meaning |
24
- | --- | ---- | ---------- | ------- | ------- |
25
- | `policy` | `pinned` \| `tenant` | both | `pinned` | The organization model, above. |
26
- | `org` | string | **pinned only** | `default` | The slug the deployment pins as the active organization. Under `pinned` the slug **is** the organization's id. |
27
- | `signup` | `invite-only` \| `open` | both | `pinned` → `invite-only`, `tenant` → `open` | Whether uninvited sign-ups are admitted. `invite-only` refuses a sign-up with no invitation; `open` admits everyone. |
28
- | `create` | `auto` \| `operator` | **tenant only** | `tenant` → `auto` | How a tenant organization comes into being: `auto` mints one for a user at first session; `operator` leaves creation to your own config (the `CreateOrganization` step). |
29
- | `invitationExpiresIn` | integer (seconds, min 60) | both | `172800` (48h) | How long an invitation stays acceptable. Re-sending an invitation refreshes its expiry. |
30
-
31
- The build cross-checks these, and the errors are worth knowing before you hit them:
32
-
33
- - **`org` is required under `pinned`** and **rejected under `tenant`** — under `tenant` there is no single organization to pin; organizations are created per user.
34
- - **`create` is rejected under `pinned`** — the active organization is ensured at startup, so there is nothing to create on demand.
35
- - **Renaming `org` strands the existing membership.** The startup ensure is by slug, so changing `org` mints a *fresh* organization rather than renaming the old one, and every existing `member` row still points at the old id. Treat the pinned slug as permanent.
36
-
37
- Defaults line up with intent: `pinned` defaults to a closed, invite-only internal app; `tenant` defaults to self-serve SaaS (`signup: open`, `create: auto`).
38
-
39
- ## The `owner` / `admin` / `member` tier vs app roles
40
-
41
- A membership carries **two** independent role authorities, and keeping them apart is the whole point:
42
-
43
- - **`_user.org_roles`** — the organization tier: `owner`, `admin`, or `member`. This is BetterAuth's administrative fact about the membership, and it is what the [auth-step authority floor](/auth-steps) checks. **No page or API gate reads it.**
44
- - **`_user.roles`** — the app's own role strings, from the membership's `appRoles`. These are what [`auth.pages.roles` and `auth.api.roles`](/roles) match, and the only thing they match.
45
-
46
- So an app that wants a page for organization administrators does **not** gate it on the `admin` tier — it gates on one of its own app roles, and lets the write authority answer the administration question separately, at the step. See [Roles](/roles) for page and API gating, and [Auth Steps](/auth-steps) for the administration model.
47
-
48
- ## Bootstrapping the first administrator
49
-
50
- Nothing grants organization authority implicitly — not even to the first user. On a fresh `pinned` + `invite-only` deployment that is a closed loop: nobody can invite (inviting needs authority nobody holds) and nobody can sign up (the gate admits only members and pending invitees). Breaking the loop is a single invitation document inserted by hand into the `user-invitations` collection, seeded with the `owner` tier. The full recipe — the exact document, why the key must be `_id`, why the role must be `owner`, and how to deliver the accept link — is in the [Auth Upgrade guide](/auth-upgrade#bootstrapping-the-first-administrator).
51
-
52
- ## The tenant wall
53
-
54
- Under `policy: tenant`, data must not leak across organizations. The **tenant wall** enforces that mechanically: a scoping-capable connection is filtered to the caller's active organization on every read and stamped with it on every write, without the request author writing a single filter clause. The wall is declared **on the connection**; requests, steps and websockets only declare *exceptions*.
55
-
56
- ### Declaring scope on a connection
57
-
58
- Under `tenant`, a connection whose type implements the scoping contract (MongoDB does) is **scoped by default** — silence means scoped. You only ever declare the two exceptions:
59
-
60
- ```yaml
61
- connections:
62
- # Scoped by default under tenant policy — nothing to declare.
63
- - id: app_data
64
- type: MongoDBCollection
65
- properties:
66
- databaseUri:
67
- _secret: MONGODB_URI
68
- collection: records
69
-
70
- # Data deliberately shared across organizations (reference data,
71
- # a global catalogue) — opt out of scoping explicitly.
72
- - id: countries
73
- type: MongoDBCollection
74
- tenant: shared
75
- properties:
76
- databaseUri:
77
- _secret: MONGODB_URI
78
- collection: countries
79
-
80
- # Scope on a field other than the default organization_id.
81
- - id: legacy_records
82
- type: MongoDBCollection
83
- tenant:
84
- field: tenant_id
85
- properties:
86
- databaseUri:
87
- _secret: MONGODB_URI
88
- collection: legacy
89
- ```
90
-
91
- `tenant` on a connection is either the string `shared` or `{ field: <name> }` (a non-empty name with no dots). The default scoping field is `organization_id`. There is deliberately **no `tenant: true`** — under `tenant` policy a capable connection is already scoped, so `true` would only restate the default; the build rejects it.
92
-
93
- **Under `tenant` policy every connection type must declare its capability.** A connection whose type does not declare tenant support (`connectionMetas.tenant`) fails the build — no connection is ever *silently* unscoped. Connection plugins declare this in their `types.js`; you do not set it.
94
-
95
- ### Exceptions at the point of use
96
-
97
- The wall scopes mechanically, but a few operations need an explicit opt-out or opt-in, declared on the request, step or websocket — never on the connection:
98
-
99
- | Surface | Allowed values | Meaning |
100
- | ------- | -------------- | ------- |
101
- | Request | `none` | Opt this request out of scoping entirely. |
102
- | Request | `authored` | The request authors its own tenant clause (audited at runtime). |
103
- | Websocket | `none` | Opt out. `authored` is not allowed — change streams are always scoped mechanically. |
104
-
105
- An aggregation stage the wall cannot scope mechanically — `$search`, `$searchMeta`, `$vectorSearch`, `$geoNear`, `$graphLookup` — must declare `tenant: authored` and author the organization clause *inside the stage*. The runtime audits that clause against the caller's active organization; it does not trust the declaration alone.
106
-
107
- ```yaml
108
- # A $search aggregation on a scoped connection: author the org filter
109
- # inside the stage and declare it.
110
- id: search_records
111
- type: MongoDBAggregation
112
- connectionId: app_data
113
- tenant: authored
114
- properties:
115
- pipeline:
116
- - $search:
117
- compound:
118
- filter:
119
- - equals:
120
- path: organization_id
121
- value:
122
- _user: organization_id
123
- must:
124
- - text:
125
- query:
126
- _payload: q
127
- path: title
128
- ```
129
-
130
- ### Why the wall lives on the connection
131
-
132
- Putting scope on the connection, and only exceptions at the point of use, means the default is safe: a developer who forgets to think about tenancy gets a scoped read and a stamped write, not a leak. The dangerous states — sharing across organizations, opting a request out, authoring a raw clause — are the ones that have to be typed out, reviewed in a diff, and (for `authored`) audited at runtime. This inverts the usual footgun where the safe path is the verbose one.
133
-
134
- Under `policy: pinned` the wall is inert — there is one organization, so scoping to it is a no-op — but declaring `tenant` on connections does no harm, which lets one config serve both a pinned install and a tenant deployment.
@@ -1,225 +0,0 @@
1
- # Two-Factor Authentication
2
-
3
- Enabling `auth.twoFactor` gives your app a second factor for sign-in: time-based one-time password (TOTP) codes from an authenticator app, plus a set of single-use backup codes for when the authenticator is unavailable. Backup codes are consumed one per use. The challenge is enforced by the engine on every sign-in method that can meet it — not left to your app config — so an enrolled user cannot walk past their second factor by choosing a different way in.
4
-
5
- ## Enabling it
6
-
7
- ```yaml
8
- auth:
9
- twoFactor:
10
- enabled: true
11
- authPages:
12
- twoFactor: /two-factor-challenge
13
- ```
14
-
15
- `authPages.twoFactor` is required when `twoFactor.enabled` is true — the build fails without it. This is not a convenience default the build could invent: the engine navigates the user to that page itself, so a missing page would leave an enrolled user unable to sign in, with nowhere to show an error.
16
-
17
- The challenge page is public automatically, the same way the password-reset page is. By the time a user lands on it, the engine has already deleted the session the sign-in created and set a signed cookie holding the pending challenge, so there is no session to authenticate the page against — the protection lives in the cookie, not in a page role.
18
-
19
- The page should catch the `INVALID_TWO_FACTOR_COOKIE` error and redirect to sign-in. Visited directly, without the cookie, the page renders fine and the verify call throws. That is harmless, but it leaves the user looking at a code box that can never succeed.
20
-
21
- ## Which sign-in methods are challenged
22
-
23
- | Sign-in method | Challenged |
24
- | ------------------------------ | ----------------------------------- |
25
- | Email + password | Yes |
26
- | Phone number + password | Yes |
27
- | Magic link | Yes — always, no toggle |
28
- | SMS code (`PhoneNumberVerify`) | Yes — always, no toggle |
29
- | OAuth provider | Yes, unless the provider is trusted |
30
- | Passkey (`PasskeySignIn`) | No — deliberately |
31
-
32
- Magic link and SMS code have no toggle. A magic link is possession-of-inbox — the factor most likely to be compromised in exactly the incident two-factor authentication exists to survive. An SMS code is possession-of-SIM, and SIM-swap is the best-documented account-takeover attack of any factor on this list. Neither is a factor worth accepting on its own for an enrolled user.
33
-
34
- Passkey sign-in is not challenged, and that is correct rather than missing. A passkey is possession of the authenticator plus user verification in a single phishing-resistant ceremony, and the major identity providers accept one as satisfying multi-factor authentication outright.
35
-
36
- ### The engine routes the challenge
37
-
38
- [`Login`](/Login) and `PhoneNumberVerify` navigate to `authPages.twoFactor` themselves, and they **end the event chain** when they do. A step placed after `Login` or `PhoneNumberVerify` does not run on the challenge path, so your app needs no `skip` guard for it. Your app does not route the challenge. The action response still carries `twoFactorRedirect` and `twoFactorMethods` if you want to read them.
39
-
40
- The trade this makes: an inline two-factor challenge rendered on the login page itself is no longer possible. The challenge is always a page.
41
-
42
- ### A challenge page routes nothing
43
-
44
- The engine owns both ends of the challenge hop. It navigates to `authPages.twoFactor` on every sign-in path, carrying the destination the caller asked for as `?callbackUrl=`, and `TwoFactorVerify` navigates away again after a successful verify — reading that same parameter through the same ladder as the rest of sign-in. So an enrolled user who deep-linked to `/invoices/123`, was bounced to sign in and then challenged, lands back on `/invoices/123`, and your challenge page needs no routing config for it.
45
-
46
- Only app-relative, same-origin destinations are carried; anything else falls through to the home page. `basePath` needs no handling: the carried path already includes it.
47
-
48
- `TwoFactorVerify` takes the same `callbackUrl` param as [`Login`](/Login): an explicit target wins over the carried one, and `callbackUrl: false` stays put, for a challenge page that renders its own post-verify state instead.
49
-
50
- ### Trusting a device
51
-
52
- `TwoFactorVerify`'s `trustDevice` param sets a cookie that short-circuits the challenge on later sign-ins from the same browser. It applies on every one of the challenged paths above, not just the one it was set from.
53
-
54
- ## Trusting an OAuth provider
55
-
56
- An enterprise identity provider often enforces its own multi-factor policy. `twoFactorTrusted` on a provider entry tells the engine not to challenge sign-ins that came through it:
57
-
58
- ```yaml
59
- auth:
60
- providers:
61
- - id: corp-idp
62
- type: GenericOAuth
63
- twoFactorTrusted: true
64
- properties: ...
65
- ```
66
-
67
- What it means: skip the engine's challenge for sign-ins through this provider, because you are asserting the IdP enforced multi-factor authentication itself.
68
-
69
- **The trust is declared, not verified.** The engine cannot confirm what the IdP actually enforced, and it checks nothing. Set this only where you control or otherwise trust the IdP's own MFA policy.
70
-
71
- `twoFactorTrusted` is **unrelated to `auth.account.accountLinking.trustedProviders`**, which trusts a provider's _email claim_ enough to link a sign-in to an existing account automatically. Trusting Google's email claim is routine; it is not a claim that every Google sign-in cleared MFA. The two lists are independent, and setting one has no effect on the other. This is the mistake the separate key exists to prevent.
72
-
73
- The escape hatch exists because without it the only advice would be "don't enable two-factor alongside an enterprise IdP", which is a functionality loss dressed up as operational advice. Entra has federated-IdP-MFA trust settings and Okta has the IdP factor: nobody double-challenges by default, and nobody silently accepts either.
74
-
75
- ## Known limitation: TOTP codes are replayable within their window
76
-
77
- A TOTP code that has already been used successfully is accepted again inside its validity window — roughly 30 to 60 seconds. BetterAuth's verifier keeps no ledger of consumed codes and no last-used counter, so nothing distinguishes a second presentation of a code from the first.
78
-
79
- This violates the standards:
80
-
81
- - RFC 6238 §5.2: "The verifier MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP."
82
- - OWASP ASVS 5.0 §6.5.1 (L2), which requires the same single-use property.
83
-
84
- **Severity is narrow.** Exploiting this needs the user's password _and_ a code inside its window — and an attacker holding both can simply sign in, with no replay involved. The real gain is the case where the legitimate user spent the code first: an attacker who has the password and observes that code can mint their own session inside the window without racing anyone for it.
85
-
86
- It is not a bypass. The second factor works; it is replayable within its own validity. Replay against the _same_ sign-in challenge already fails, because that challenge's verification record is consumed and its cookie expired. What remains is a _fresh_ challenge accepting the same code while the window is still open.
87
-
88
- Tracked upstream at [#10387](https://github.com/better-auth/better-auth/issues/10387). Lowdefy has not mitigated it locally: a `before` hook on the sign-in path has no session to work from, so recovering the user would mean reaching into the two-factor plugin's private cookie and verification internals — a bad trade for a sixty-second window that already sits behind a password.
89
-
90
- If you are enabling multi-factor authentication to satisfy an auditor, know about this before the auditor tells you.
91
-
92
- ## Concurrent enrolment and the unique index
93
-
94
- Two `TwoFactorEnable` calls that run concurrently for the same user can write two rows to the two-factor collection. Sign-in may then read the unverified row, find no methods to offer, and leave the user unable to complete a challenge — a silent lockout. Tracked upstream at [#10561](https://github.com/better-auth/better-auth/issues/10561).
95
-
96
- The mitigation is a unique index on `userId` in the two-factor collection (`user-two-factors`), applied by the host app — no layer in Lowdefy provisions indexes. For MongoDB, see the `modules-mongodb` `user-account` index reference for how that module declares its auth indexes, rather than writing the command by hand.
97
-
98
- Be clear about what the index buys: it converts an unrecoverable silent lockout into a visible, retryable duplicate-key error. It does not fix the race.
99
-
100
- ## Requiring two-factor enrolment
101
-
102
- `twoFactor.enabled` lets a member enrol a factor. `twoFactor.required` makes enrolment a floor: a member who has not enrolled cannot use the app until they do.
103
-
104
- ```yaml
105
- auth:
106
- twoFactor:
107
- enabled: true
108
- required: true
109
- authPages:
110
- twoFactor: /two-factor-challenge
111
- twoFactorEnrol: /two-factor-enrol
112
- ```
113
-
114
- A caller satisfies `required` by holding **either** a TOTP enrolment **or** a registered passkey. A passkey counts on its own: it is a phishing-resistant possession factor bound to user verification, which is exactly why Entra and Okta accept one outright. Demanding TOTP on top of a passkey is theatre — it adds no assurance and pushes the user toward the weaker of the two factors.
115
-
116
- An unenrolled caller is redirected to `authPages.twoFactorEnrol` on every page, and refused at every request, endpoint and websocket with a `TwoFactorEnrolmentRequiredError` — a **403, not a 401**. A 401 reads to the client as a dead session and bounces the user to sign-in, which is the loop the floor exists to avoid; a 403 says "you are signed in, but you are missing something".
117
-
118
- The enrolment check runs **after** the role check. Probing a page you lack the roles for still returns the opaque 404 it always did, so turning `required` on never reveals a page's existence to someone who was not authorised for it in the first place.
119
-
120
- `authPages.twoFactorEnrol` is **required when `required: true`** — the build fails without it. Every unenrolled user is redirected there, so a deployment that required enrolment without naming the page would redirect them to nowhere.
121
-
122
- Callers that carry no session pass untouched: the API strategy and the injected callers used in dev and e2e are not session-resolved members, so the enrolment floor does not apply to them.
123
-
124
- ## What `required` guarantees — and what it does not
125
-
126
- > `required` guarantees that every member has enrolled a factor. It does NOT guarantee that every session presented one.
127
-
128
- Read the second sentence before you rely on the first. Here is the concrete gap. Take a user who registered a passkey and also holds a password. They sign in with the password. BetterAuth's sign-in hook opens with `if (!data?.user.twoFactorEnabled) return;`, so a passkey-only enrolment fires no challenge. The enrolment floor then computes `false || passkeyCount > 0` and reads the caller as satisfied. Nothing on this path compares the factor **presented this sign-in** against the factors the user **holds** — so a password-only sign-in clears a floor a passkey was meant to enforce.
129
-
130
- This is an accepted cost, for three reasons:
131
-
132
- - The fix is session-scoped satisfaction — comparing presented against held on every request. That is a larger feature than a per-user flag, and shipping it half-built would be worse than naming the limitation. It has a known correct answer; it is not in this release.
133
- - Dropping the passkey disjunct would trade a soft hole for a hard lockout: a passwordless user who enrolled only a passkey would satisfy nothing and be locked out of an app they have a perfectly good factor for.
134
- - The affected population **voluntarily registered a strong, phishing-resistant credential.** They are not the threat `required` defends against. `required` exists to catch members with **no second factor at all** — and every one of those is caught, because an unenrolled caller has neither a TOTP row nor a passkey and fails the floor outright.
135
-
136
- ## Two-factor with a trusted OAuth provider
137
-
138
- A `twoFactorTrusted` provider's users are not challenged on that sign-in path (see **Trusting an OAuth provider**, above). Under `required`, a user who signed in that way and has not separately enrolled is unenrolled, so they are routed to `authPages.twoFactorEnrol` to enrol a TOTP their sign-in will never present.
139
-
140
- The cost, stated plainly: a deployment whose **only** sign-in path is a trusted provider gets enrolment with no challenge ever attached to it.
141
-
142
- The enrolled factor is not inert, though. It is what a password or magic-link sign-in **would** challenge, and it is what an admin reset restores. It earns its keep the moment a second sign-in path exists.
143
-
144
- ## Two-factor for passwordless users
145
-
146
- The four BetterAuth two-factor endpoints that mutate a factor are password-gated: they call `shouldRequirePassword`, which demands the caller's current password unless `allowPasswordless` is set. Lowdefy sets `allowPasswordless: true`, so an OAuth or magic-link user who never set a password can still enrol TOTP.
147
-
148
- The waiver is **per user**, not per app. A caller who *does* hold a password still faces the password gate on all four endpoints — the waiver only lifts it for the users who have no password to give. There is no route difference to configure: the same enrol flow serves both.
149
-
150
- ## The enrolment page
151
-
152
- `authPages.twoFactorEnrol` is the one auth page that is **not public**. On the sign-in and challenge pages the user has no session yet; here they hold a valid session and are merely missing a factor — an authorisation gap, not an identity one — so the page stays behind a session.
153
-
154
- The constraint that follows: **the enrolment page cannot call any Lowdefy request or endpoint.** An unenrolled caller is refused at every one of them with a 403, and the person on this page is unenrolled by definition. It must be self-sufficient on the client auth actions — `TwoFactorEnable`, `TwoFactorVerify` and `PasskeyRegister` — which hit `/api/auth/*` directly and are not behind the enrolment floor.
155
-
156
- Reached with no session at all, it behaves like any protected page: the user is sent to sign in with a `callbackUrl` back to the enrolment page.
157
-
158
- ## Recovering a user who has lost their factor
159
-
160
- When a member loses their authenticator or has a security key stolen, an administrator restores their access with two org-scoped steps:
161
-
162
- | Step | Properties | Permission |
163
- | ----------------------------------------------- | --------------------- | ----------------------------- |
164
- | `ResetUserTwoFactor` | `userId` | `user: ['reset-two-factor']` |
165
- | `RevokeUserPasskeys` | `userId`, `passkeyId?`| `user: ['revoke-passkeys']` |
166
-
167
- Both are `org`-scoped and bounded by target membership: an administrator can only reach a user who holds a member row in an organization they administer. Both permissions ship granted to `owner` and `admin`; a narrower custom role can be given member management without either of them.
168
-
169
- Three things this routine must not miss:
170
-
171
- **Pair the reset with `RevokeUserSessions`.** Clearing a factor does not end a session — an attacker who already holds one keeps it, which is precisely the incident you are recovering from. A reset without a session revocation has not recovered the account. Run all three together:
172
-
173
- ```yaml
174
- id: admin-recover-user-two-factor
175
- type: Api
176
- routine:
177
- - id: reset_two_factor
178
- type: ResetUserTwoFactor
179
- properties:
180
- userId:
181
- _payload: userId
182
- - id: revoke_passkeys
183
- type: RevokeUserPasskeys
184
- properties:
185
- userId:
186
- _payload: userId
187
- - id: revoke_sessions
188
- type: RevokeUserSessions
189
- properties:
190
- userId:
191
- _payload: userId
192
- - ':return':
193
- _step: revoke_sessions
194
- ```
195
-
196
- **The reach is suite-wide.** `twoFactorEnabled` lives on the deployment-wide user row, and there is one enrolment per user — never one factor per organization. The membership bound governs **who** an administrator may reset, not **how far** a reset reaches: reset the user in the one organization you administer and their factor is cleared everywhere.
197
-
198
- **`ResetUserTwoFactor` also deletes the user's trust-device records.** This is load-bearing, not tidying. A device the user once ticked "trust this device" on holds a signed cookie that skips the challenge; leave the record in place and, the moment the victim re-enrols, a stolen device walks straight past the factor they just set up.
199
-
200
- One timing note on session revocation: with `session.cookieCache` enabled, a revoked session can still be honoured from its cached copy for up to the cache's `maxAge` (default 300 seconds). Lowdefy defaults `cookieCache` **off**, so a default deployment has no such window — but if you turned it on, size the exposure accordingly.
201
-
202
- ## Before you turn `required` on
203
-
204
- **Enrol your administrators first — more than one of them.** Turning `required` on sends every unenrolled user, administrators included, to the enrolment page at their next request. If the only person who can run `ResetUserTwoFactor` is themselves locked out mid-enrolment, you have no in-app way back.
205
-
206
- **There is no escape hatch, by design.** No exempt identity, no environment override, no grace period. Each of those would be a permanent bypass of the floor, available to anyone who found it, so none exists.
207
-
208
- **Last-admin recovery is a database edit.** If every administrator is locked out, the only way back is to clear one administrator's factor directly in the database — the same two writes `ResetUserTwoFactor` makes:
209
-
210
- 1. Delete the target's row from the `user-two-factors` collection.
211
- 2. Set `twoFactorEnabled` to `false` on their row in the `users` collection.
212
-
213
- That administrator can then sign in and recover the others through the app.
214
-
215
- ## Required index
216
-
217
- Under `required`, the engine counts a caller's passkeys on every request from anyone not yet enrolled, to decide whether they clear the floor. That read needs an index on the passkey collection:
218
-
219
- ```js
220
- db['user-passkeys'].createIndex({ userId: 1 });
221
- ```
222
-
223
- This index is **platform-owned but host-applied.** The engine reads it per request for any unenrolled caller under `required`; without it, that read is a full collection scan on every such request. But no layer in Lowdefy provisions indexes — the deployment applies this one, exactly as it applies the `user-two-factors` index above. (This example follows the `userId` field convention: the auth adapter maps its collections with camelCase field names, so the key is `{ userId: 1 }`, not `{ user_id: 1 }`.)
224
-
225
- An **enrolled** caller never reaches the passkey read: the floor short-circuits on `twoFactorEnabled`, which rides the session, so an enrolled member pays nothing. Once `required` has been on for a while, that is the overwhelming majority of traffic.