@lowdefy/docs-content 0.0.0-experimental-20260928120643 → 6.1.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 (226) hide show
  1. package/README.md +0 -1
  2. package/content/actions/link.md +0 -17
  3. package/content/actions/login.md +5 -23
  4. package/content/actions/logout.md +5 -5
  5. package/content/actions/pdfmake.md +2 -2
  6. package/content/actions/setdarkmode.md +2 -2
  7. package/content/agents/agent-properties.md +2 -4
  8. package/content/agents/agentchat.md +6 -21
  9. package/content/agents/agentconversations.md +2 -2
  10. package/content/concepts/ai-agent-docs.md +101 -313
  11. package/content/concepts/cli.md +3 -34
  12. package/content/concepts/connections-and-requests.md +0 -402
  13. package/content/concepts/custom-html.md +0 -40
  14. package/content/concepts/custom-styling.md +5 -50
  15. package/content/concepts/events-and-actions.md +2 -64
  16. package/content/concepts/i18n.md +2 -7
  17. package/content/concepts/keyboard-shortcuts.md +2 -2
  18. package/content/concepts/layout-overview.md +1 -1
  19. package/content/concepts/logger.md +0 -81
  20. package/content/concepts/lowdefy-schema.md +1 -4
  21. package/content/concepts/menus.md +7 -7
  22. package/content/concepts/module-authoring.md +2 -2
  23. package/content/connections/anthropic.md +2 -162
  24. package/content/connections/awss3.md +0 -54
  25. package/content/connections/google.md +2 -162
  26. package/content/connections/mongodb.md +3 -121
  27. package/content/connections/openai-connection.md +2 -162
  28. package/content/container-blocks/affix.md +7 -47
  29. package/content/container-blocks/alert.md +24 -76
  30. package/content/container-blocks/badge.md +24 -100
  31. package/content/container-blocks/box.md +9 -9
  32. package/content/container-blocks/card.md +25 -80
  33. package/content/container-blocks/carousel.md +7 -60
  34. package/content/container-blocks/collapse.md +9 -87
  35. package/content/container-blocks/configprovider.md +13 -35
  36. package/content/container-blocks/confirmmodal.md +23 -69
  37. package/content/container-blocks/content.md +98 -62
  38. package/content/container-blocks/descriptions.md +4 -45
  39. package/content/container-blocks/drawer.md +28 -92
  40. package/content/container-blocks/dropdownmenu.md +31 -119
  41. package/content/container-blocks/flex.md +62 -40
  42. package/content/container-blocks/footer.md +11 -15
  43. package/content/container-blocks/googlemapsheatmap.md +1 -1
  44. package/content/container-blocks/googlemapsscript.md +2 -2
  45. package/content/container-blocks/header.md +36 -71
  46. package/content/container-blocks/label.md +7 -34
  47. package/content/container-blocks/layout.md +34 -52
  48. package/content/container-blocks/masonry.md +71 -17
  49. package/content/container-blocks/mobilemenu.md +88 -131
  50. package/content/container-blocks/modal.md +26 -114
  51. package/content/container-blocks/pageheadermenu.md +31 -81
  52. package/content/container-blocks/pagesidebarlayout.md +26 -169
  53. package/content/container-blocks/pagesidermenu.md +42 -95
  54. package/content/container-blocks/popover.md +31 -34
  55. package/content/container-blocks/result.md +21 -32
  56. package/content/container-blocks/sider.md +122 -85
  57. package/content/container-blocks/span.md +7 -7
  58. package/content/container-blocks/spinner.md +3 -3
  59. package/content/container-blocks/splitter.md +68 -150
  60. package/content/container-blocks/tabs.md +12 -64
  61. package/content/container-blocks/tooltip.md +46 -22
  62. package/content/container-blocks/watermark.md +4 -22
  63. package/content/controls/reject.md +2 -4
  64. package/content/controls/throw.md +0 -1
  65. package/content/controls/try.md +2 -3
  66. package/content/deployment/deployment-environments.md +6 -8
  67. package/content/deployment/docker.md +7 -22
  68. package/content/deployment/node-server.md +0 -4
  69. package/content/deployment/vercel.md +6 -13
  70. package/content/display-blocks/aggrid.md +11 -26
  71. package/content/display-blocks/anchor.md +15 -15
  72. package/content/display-blocks/avatar.md +37 -57
  73. package/content/display-blocks/breadcrumb.md +21 -70
  74. package/content/display-blocks/button.md +18 -92
  75. package/content/display-blocks/calendar.md +0 -12
  76. package/content/display-blocks/dangeroushtml.md +1 -1
  77. package/content/display-blocks/difflist.md +1 -1
  78. package/content/display-blocks/difftimeline.md +1 -1
  79. package/content/display-blocks/divider.md +3 -64
  80. package/content/display-blocks/dropdownbutton.md +28 -79
  81. package/content/display-blocks/echart.md +1 -35
  82. package/content/display-blocks/floatbutton.md +42 -129
  83. package/content/display-blocks/html.md +1 -61
  84. package/content/display-blocks/icon.md +67 -250
  85. package/content/display-blocks/menu.md +73 -145
  86. package/content/display-blocks/message.md +124 -38
  87. package/content/display-blocks/notification.md +222 -94
  88. package/content/display-blocks/paragraph.md +9 -59
  89. package/content/display-blocks/progress.md +17 -96
  90. package/content/display-blocks/qrcode.md +4 -19
  91. package/content/display-blocks/s3download.md +1 -1
  92. package/content/display-blocks/search.md +7 -17
  93. package/content/display-blocks/skeletonbutton.md +0 -1
  94. package/content/display-blocks/skeletoninput.md +0 -1
  95. package/content/display-blocks/statistic.md +17 -75
  96. package/content/display-blocks/steps.md +25 -58
  97. package/content/display-blocks/tag.md +17 -102
  98. package/content/display-blocks/title.md +11 -50
  99. package/content/display-blocks/tour.md +23 -33
  100. package/content/input-blocks/autocomplete.md +9 -81
  101. package/content/input-blocks/buttonselector.md +8 -45
  102. package/content/input-blocks/checkboxselector.md +7 -97
  103. package/content/input-blocks/checkboxswitch.md +3 -33
  104. package/content/input-blocks/colorselector.md +14 -63
  105. package/content/input-blocks/daterangeselector.md +45 -165
  106. package/content/input-blocks/dateselector.md +41 -139
  107. package/content/input-blocks/datetimeselector.md +49 -158
  108. package/content/input-blocks/inputgallery.md +3 -3
  109. package/content/input-blocks/monthselector.md +52 -120
  110. package/content/input-blocks/multipleselector.md +39 -150
  111. package/content/input-blocks/numberinput.md +12 -98
  112. package/content/input-blocks/pagination.md +14 -70
  113. package/content/input-blocks/paragraphinput.md +37 -51
  114. package/content/input-blocks/passwordinput.md +6 -60
  115. package/content/input-blocks/phonenumberinput.md +26 -59
  116. package/content/input-blocks/radioselector.md +7 -83
  117. package/content/input-blocks/ratingslider.md +26 -57
  118. package/content/input-blocks/s3uploadbutton.md +24 -24
  119. package/content/input-blocks/s3uploaddragger.md +3 -3
  120. package/content/input-blocks/s3uploadphoto.md +5 -5
  121. package/content/input-blocks/segmentedselector.md +32 -72
  122. package/content/input-blocks/selector.md +21 -109
  123. package/content/input-blocks/switch.md +17 -65
  124. package/content/input-blocks/textarea.md +3 -32
  125. package/content/input-blocks/textinput.md +28 -69
  126. package/content/input-blocks/tiptapmentioninput.md +1 -1
  127. package/content/input-blocks/titleinput.md +12 -39
  128. package/content/input-blocks/treeinput.md +25 -49
  129. package/content/input-blocks/treemultipleselector.md +10 -127
  130. package/content/input-blocks/treeselector.md +9 -97
  131. package/content/input-blocks/upload.md +15 -64
  132. package/content/input-blocks/uploaddragger.md +1 -17
  133. package/content/input-blocks/uploadphoto.md +1 -18
  134. package/content/input-blocks/weekselector.md +55 -120
  135. package/content/list-blocks/controlledlist.md +15 -24
  136. package/content/list-blocks/list.md +11 -11
  137. package/content/list-blocks/masonrylist.md +34 -7
  138. package/content/migration/upgrade-guide.md +0 -1
  139. package/content/operators/_app.md +1 -2
  140. package/content/operators/_error.md +1 -23
  141. package/content/operators/_get.md +6 -6
  142. package/content/operators/_operator.md +3 -17
  143. package/content/operators/_ref.md +0 -258
  144. package/content/operators/_type.md +3 -14
  145. package/content/operators/_user.md +3 -3
  146. package/content/other/introduction.md +1 -1
  147. package/content/plugins/plugins-dev.md +0 -66
  148. package/content/plugins/plugins-introduction.md +1 -27
  149. package/content/testing/e2e-configuration.md +1 -8
  150. package/content/testing/e2e-getting-started.md +1 -1
  151. package/content/testing/e2e-introduction.md +1 -1
  152. package/content/tutorial/tutorial-actions-operators.md +2 -2
  153. package/content/tutorial/tutorial-add-blocks.md +2 -2
  154. package/content/tutorial/tutorial-create-page.md +2 -48
  155. package/content/tutorial/tutorial-display-data-page.md +3 -3
  156. package/content/tutorial/tutorial-requests-sql.md +0 -153
  157. package/content/tutorial/tutorial-start.md +1 -66
  158. package/content/user-authentication/login-and-logout.md +8 -158
  159. package/content/user-authentication/users-introduction.md +0 -8
  160. package/index.json +2 -6065
  161. package/package.json +1 -7
  162. package/scripts/extractAgentDocs.js +4 -36
  163. package/content/actions/emailotpsend.md +0 -58
  164. package/content/actions/emailotpverify.md +0 -77
  165. package/content/actions/getlocalstorage.md +0 -41
  166. package/content/actions/magiclinkverify.md +0 -113
  167. package/content/actions/posthogalias.md +0 -29
  168. package/content/actions/posthogcapture.md +0 -49
  169. package/content/actions/posthogcapturepageleave.md +0 -38
  170. package/content/actions/posthogfeatureflag.md +0 -50
  171. package/content/actions/posthoggroup.md +0 -37
  172. package/content/actions/posthogidentify.md +0 -54
  173. package/content/actions/posthoginit.md +0 -66
  174. package/content/actions/posthogoptin.md +0 -29
  175. package/content/actions/posthogoptout.md +0 -29
  176. package/content/actions/posthogpageview.md +0 -37
  177. package/content/actions/posthogreloadfeatureflags.md +0 -47
  178. package/content/actions/posthogreset.md +0 -28
  179. package/content/actions/posthogsetpersonproperties.md +0 -36
  180. package/content/actions/removelocalstorage.md +0 -26
  181. package/content/actions/setlocalstorage.md +0 -32
  182. package/content/adapter-reference/mongodbauthadapter.md +0 -46
  183. package/content/agents/agents-introduction.md +0 -107
  184. package/content/callback-reference/auth0logoutcallback.md +0 -54
  185. package/content/concepts/blocks.md +0 -150
  186. package/content/concepts/dynamic-page-content.md +0 -304
  187. package/content/concepts/html-attributes.md +0 -285
  188. package/content/concepts/lowdefy-api.md +0 -1110
  189. package/content/concepts/notifications.md +0 -155
  190. package/content/concepts/page-and-app-state.md +0 -244
  191. package/content/concepts/theming.md +0 -582
  192. package/content/connections/aigateway.md +0 -368
  193. package/content/connections/axioshttp.md +0 -181
  194. package/content/controls/while.md +0 -46
  195. package/content/display-blocks/clickablehtml.md +0 -239
  196. package/content/display-blocks/eventlog.md +0 -363
  197. package/content/display-blocks/pollingtimer.md +0 -263
  198. package/content/input-blocks/captcha.md +0 -40
  199. package/content/input-blocks/placesautocomplete.md +0 -319
  200. package/content/input-blocks/tagmultipleselector.md +0 -114
  201. package/content/input-blocks/tagselector.md +0 -114
  202. package/content/migration/auth-upgrade.md +0 -327
  203. package/content/migration/v3-to-v4.md +0 -417
  204. package/content/migration/v6-to-v7.md +0 -222
  205. package/content/operators/_cron.md +0 -292
  206. package/content/other/mongodbadapter.md +0 -3
  207. package/content/plugins/plugins-icon-sets.md +0 -267
  208. package/content/plugins/posthog.md +0 -275
  209. package/content/provider-reference/auth-js-providers.md +0 -107
  210. package/content/provider-reference/openidconnectprovider.md +0 -44
  211. package/content/testing/config-tests.md +0 -278
  212. package/content/tutorial/tutorial-requests-api.md +0 -155
  213. package/content/user-authentication/auth-configuration.md +0 -266
  214. package/content/user-authentication/auth-steps.md +0 -138
  215. package/content/user-authentication/mcp-oauth.md +0 -292
  216. package/content/user-authentication/organizations.md +0 -221
  217. package/content/user-authentication/protected-pages-apis.md +0 -97
  218. package/content/user-authentication/roles.md +0 -49
  219. package/content/user-authentication/two-factor.md +0 -229
  220. package/content/user-authentication/user-object.md +0 -58
  221. package/content/websockets/websocket-publish.md +0 -108
  222. package/content/websockets/websocket-subscriptions.md +0 -100
  223. package/content/websockets/websockets-introduction.md +0 -97
  224. package/hazards.json +0 -44
  225. package/scripts/hashDocSources.js +0 -38
  226. package/scripts/recordSourceHashes.js +0 -88
@@ -1,292 +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 canonical URL (`BETTER_AUTH_URL`, else the current environment's `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
- instructions: Search for a customer before creating an invoice for them.
51
- icons:
52
- - src: https://acme.example.com/icon-512.png
53
- mimeType: image/png
54
- sizes: ['512x512']
55
- endpoints:
56
- - id: search-customers
57
- scope: mcp:read
58
- - id: create-invoice
59
- scope: mcp:write
60
- ```
61
-
62
- `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`.
63
-
64
- `instructions` is an optional string sent in the `initialize` result. Clients such as Claude Code place it in the model's system prompt, so use it for a short note on how the tools fit together or when to reach for them. Clients may truncate long instructions, so keep it to a few lines. When it is not set, the server sends no instructions.
65
-
66
- **`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.
67
-
68
- Three rules the build enforces on every tool:
69
-
70
- - **Only `Api` endpoints can be tools.** An `InternalApi` endpoint is not reachable as one — it has no external caller by design.
71
- - **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.
72
- - **The `mcp.agents` key is gone.** MCP agent tools are not supported; remove it if you are upgrading.
73
-
74
- ## A tool is gated twice
75
-
76
- When a call arrives at `/api/mcp`, a tool is listed and callable only when **both** hold:
77
-
78
- 1. the caller's **role outcome** allows the endpoint — the same `auth.api` / `auth.api.roles` gate every API call passes; and
79
- 2. the **token's granted scope** covers the tool's tag.
80
-
81
- 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.
82
-
83
- ## The rule that catches everyone: keep MCP endpoints out of `auth.api.public`
84
-
85
- > **Never list an MCP endpoint id in `auth.api.public`.**
86
-
87
- 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.
88
-
89
- So a protected app keeps its `auth.api` gates and leaves every `mcp.endpoints` id out of the public list:
90
-
91
- ```yaml
92
- auth:
93
- api:
94
- protected: true
95
- # Keep MCP endpoint ids OUT of this list. A single public tool
96
- # flips mcp.json hasPublicTool, and /api/mcp then serves anonymous
97
- # callers a 200 with that tool instead of the 401 challenge.
98
- public:
99
- - webhooks/stripe # a webhook receiver, not an MCP tool
100
- ```
101
-
102
- 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`.
103
-
104
- ## The authorization flow, end to end
105
-
106
- 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.
107
- 2. It opens a browser to the authorization endpoint. The user signs in through your normal sign-in page.
108
- 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.
109
- 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.
110
- 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.
111
-
112
- 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.
113
-
114
- ### How a token becomes a caller
115
-
116
- 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.
117
-
118
- 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.
119
-
120
- ```yaml
121
- # In an endpoint routine, branch on how the caller arrived:
122
- - id: set_channel
123
- type: SetState
124
- params:
125
- channel:
126
- _if:
127
- test:
128
- _eq:
129
- - _user: auth_method
130
- - mcp
131
- then: mcp
132
- else: ui
133
- ```
134
-
135
- A token that cannot be verified (for example, an opaque token minted because the client omitted the RFC 8707 `resource` parameter), one whose grant has been revoked, or one whose user is no longer a member of the token's organization (removed, left, or the user deleted), 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. The authorization server also refuses to refresh a grant whose user is no longer a member of its organization (`invalid_grant`), so a client holding a refresh token falls back to the full authorization instead of refreshing into another refused token.
136
-
137
- ## Building the consent and picker pages
138
-
139
- 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:
140
-
141
- | Action | What it does |
142
- | ------ | ------------ |
143
- | `ListOrganizations` | Returns the caller's organization memberships — the rows the picker renders. |
144
- | `SetActiveOrganization` | Sets the caller's active organization. The picker calls it for the chosen organization. |
145
- | `OAuthContinue` | Continues the authorization after the organization is chosen (`POST /api/auth/oauth2/continue`). Returns `{ url }` to redirect to. |
146
- | `OAuthConsent` | Approves (`accept: true`) or denies (`accept: false`) the client. Returns `{ url }` to redirect to. |
147
-
148
- ### The organization picker (`postLoginPage`)
149
-
150
- 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:
151
-
152
- ```yaml
153
- id: select_org_rows
154
- type: ListSelector
155
- properties:
156
- selectable: false
157
- hoverable: true
158
- data:
159
- _state: organizations
160
- events:
161
- onClick:
162
- - id: set_active_organization
163
- type: SetActiveOrganization
164
- skip:
165
- _eq:
166
- - _event: item.id
167
- - _user: organization_id
168
- params:
169
- organizationId:
170
- _event: item.id
171
- - id: continue_authorization
172
- type: OAuthContinue
173
- messages:
174
- error: false
175
- - id: continue_redirect
176
- type: Link
177
- params:
178
- url:
179
- _actions: continue_authorization.response.url
180
- ```
181
-
182
- `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.
183
-
184
- ### The consent screen (`consentPage`)
185
-
186
- Reads the request from the query, shows the client and scopes, and finishes on the user's decision:
187
-
188
- ```yaml
189
- id: consent_allow
190
- type: Button
191
- properties:
192
- title: Allow access
193
- events:
194
- onClick:
195
- - id: consent_allow_action
196
- type: OAuthConsent
197
- messages:
198
- error: false
199
- params:
200
- accept: true
201
- - id: consent_allow_redirect
202
- type: Link
203
- params:
204
- url:
205
- _actions: consent_allow_action.response.url
206
- ```
207
-
208
- 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.
209
-
210
- ## Switching organization from the assistant
211
-
212
- 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:
213
-
214
- ```yaml
215
- id: switch-organization
216
- type: Api
217
- description: >
218
- Disconnect this assistant from the organization it is connected to so
219
- the user can connect it to another one. Use ONLY when the user asks to
220
- work in a different organization. After it succeeds the connection is
221
- gone: tell the user to reconnect in their assistant — the browser will
222
- ask which organization to connect to.
223
- payloadSchema:
224
- type: object
225
- additionalProperties: false
226
- properties: {}
227
- routine:
228
- - id: revoke_grant
229
- type: RevokeMcpGrant
230
- - ':return':
231
- disconnected: true
232
- organization_id:
233
- _step: revoke_grant.organizationId
234
- message: >-
235
- Disconnected. Ask the user to reconnect in their assistant.
236
- ```
237
-
238
- [`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.
239
-
240
- 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.
241
-
242
- ## Disconnecting assistants from the app
243
-
244
- 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:
245
-
246
- ```yaml
247
- id: disconnect-assistants
248
- type: Api
249
- description: Disconnect every assistant connected to the caller's active organization.
250
- payloadSchema:
251
- type: object
252
- additionalProperties: false
253
- properties: {}
254
- routine:
255
- - id: revoke_refresh_tokens
256
- type: MongoDBUpdateMany
257
- connectionId: oauth-refresh-tokens
258
- properties:
259
- filter:
260
- user_id:
261
- _user: id
262
- reference_id:
263
- _user: organization_id
264
- revoked: null
265
- update:
266
- $set:
267
- revoked:
268
- _date: now
269
- - id: delete_consents
270
- type: MongoDBDeleteMany
271
- connectionId: oauth-consents
272
- properties:
273
- filter:
274
- user_id:
275
- _user: id
276
- reference_id:
277
- _user: organization_id
278
- - ':return':
279
- disconnected:
280
- _step: delete_consents.deletedCount
281
- ```
282
-
283
- 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.
284
-
285
- ## Summary
286
-
287
- - The app is an OAuth 2.1 authorization server; enable it with `auth.oauthProvider`.
288
- - There is one MCP resource at `/api/mcp`. The organization is a token claim, not a URL segment.
289
- - Tag each tool `mcp:read` or `mcp:write`; a tool is gated by both role and scope.
290
- - **Never list an MCP endpoint id in `auth.api.public`** — one public tool suppresses the challenge for the whole route.
291
- - Consent is per `(client, user, organization)` and read live on every call, so `RevokeMcpGrant` and disconnecting take effect immediately.
292
- - `_user.auth_method` is `'mcp'` for assistant callers.
@@ -1,221 +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 organization `create: auto` mints
40
-
41
- Under `tenant` with `create: auto`, the first session of a user who has no membership and no pending invitation mints the user's own organization, with the user as its `owner`, slugged `org-<userId>`. A user who later leaves their last organization gets a fresh one at their next session (`org-<userId>-2`, then `-3` and on). An organization the user was ever a member of is never handed back to them, even after every member has left it, so its data and pending invitations stay with it.
42
-
43
- The mint is safe when the same user signs in from two tabs at once or submits twice: exactly one organization and one `owner` membership are written. It relies on two unique indexes in the auth database, which the server creates at startup (and checks again before it mints):
44
-
45
- ```js
46
- db['user-organizations'].createIndex({ slug: 1 }, { unique: true })
47
- db['user-members'].createIndex({ user_id: 1, organization_id: 1 }, { unique: true })
48
- ```
49
-
50
- An equivalent unique index you created yourself, under any name, is accepted. If the auth database user may not create indexes, or the collections already hold duplicate rows, the server logs an error naming the index, and a sign-in that needs a new organization is refused with status 503 and the code `ORGANIZATION_SETUP_UNAVAILABLE` (existing members sign in as usual). The server tries again at most every 30 seconds while such sign-ins arrive, so removing the duplicates or creating the indexes by hand recovers without a restart.
51
-
52
- ### Invitations for existing members
53
-
54
- An invitation is how someone joins an organization, never how a membership changes. Accepting an invitation into an organization you already belong to (you joined another way after it was sent) succeeds and marks the invitation accepted, but leaves your membership exactly as it is: the invitation's role, app roles and attributes are not applied, and no second membership is written. If your session had no active organization, the accepted organization becomes active. To change an existing membership, use the [auth steps](/auth-steps) (`UpdateMemberOrgRole`, `UpdateMemberRoles`, `UpdateMemberAttributes`), which check the caller's authority at the time of the change.
55
-
56
- ## The `owner` / `admin` / `member` tier vs app roles
57
-
58
- A membership carries **two** independent role authorities, and keeping them apart is the whole point:
59
-
60
- - **`_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.**
61
- - **`_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.
62
-
63
- 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.
64
-
65
- ## Bootstrapping the first administrator
66
-
67
- 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).
68
-
69
- ## The tenant wall
70
-
71
- 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*.
72
-
73
- ### Declaring scope on a connection
74
-
75
- 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:
76
-
77
- ```yaml
78
- connections:
79
- # Scoped by default under tenant policy — nothing to declare.
80
- - id: app_data
81
- type: MongoDBCollection
82
- properties:
83
- databaseUri:
84
- _secret: MONGODB_URI
85
- collection: records
86
-
87
- # Data deliberately shared across organizations (reference data,
88
- # a global catalogue) — opt out of scoping explicitly.
89
- - id: countries
90
- type: MongoDBCollection
91
- tenant: shared
92
- properties:
93
- databaseUri:
94
- _secret: MONGODB_URI
95
- collection: countries
96
-
97
- # Scope on a field other than the default organization_id.
98
- - id: legacy_records
99
- type: MongoDBCollection
100
- tenant:
101
- field: tenant_id
102
- properties:
103
- databaseUri:
104
- _secret: MONGODB_URI
105
- collection: legacy
106
- ```
107
-
108
- `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.
109
-
110
- **A shared connection's change log must not write into a walled collection.** A `tenant: shared` connection's writes belong to no organization, so its `changeLog` records carry no `organization_id`. In a collection a scoped connection reads, those records would be invisible to every walled read and would make the tenant preflight refuse to serve the app, so the build rejects a shared connection whose `changeLog.collection` is the collection of a scoped connection in the same database. Give shared connections their own change-log collection.
111
-
112
- **A shared connection over a walled collection has its writes checked.** When a `tenant: shared` connection names the same collection, in the same database, as a scoped connection, the build marks it, and every row it inserts, replaces, upserts or updates must keep a non-empty `organization_id` — the same check a [`tenant: none` write](#writing-with-tenant-none) gets. Its reads stay unscoped, which is what `shared` is for. A collection name resolved at runtime (`_secret`, `_payload`) can not be compared at build time, so such a connection is not marked.
113
-
114
- **A shared connection's aggregation may not `$out` or `$merge` into a walled collection.** The rows an aggregation writes are not checked, so the build rejects a request or step on a `tenant: shared` connection whose literal pipeline writes, with `$out` or `$merge`, into a collection a scoped connection reads in the same database. Write the rows with a checked request instead (see the rollup example below). A target named by an operator, or in another database, is not checked at build time.
115
-
116
- **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.
117
-
118
- ### Exceptions at the point of use
119
-
120
- 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:
121
-
122
- | Surface | Allowed values | Meaning |
123
- | ------- | -------------- | ------- |
124
- | Request | `none` | Opt this request out of scoping entirely. |
125
- | Request | `authored` | The request authors its own tenant clause (audited at runtime). |
126
- | Websocket | `none` | Opt out. `authored` is not allowed — change streams are always scoped mechanically. |
127
-
128
- 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.
129
-
130
- ```yaml
131
- # A $search aggregation on a scoped connection: author the org filter
132
- # inside the stage and declare it.
133
- id: search_records
134
- type: MongoDBAggregation
135
- connectionId: app_data
136
- tenant: authored
137
- properties:
138
- pipeline:
139
- - $search:
140
- compound:
141
- filter:
142
- - equals:
143
- path: organization_id
144
- value:
145
- _user: organization_id
146
- must:
147
- - text:
148
- query:
149
- _payload: q
150
- path: title
151
- ```
152
-
153
- ### Writing with `tenant: none`
154
-
155
- `tenant: none` lifts the filter and the stamp, not the rule that every row in a walled collection belongs to an organization. A row without `organization_id` is invisible to every walled read, and the tenant preflight refuses to serve the app while one exists, so a `tenant: none` write on a scoped connection is checked instead of stamped:
156
-
157
- - Inserted and replacement documents must carry a non-empty `organization_id`, and upserts must author it (`$set` / `$setOnInsert`, or an equality match in the filter).
158
- - Updates may not null, unset, rename or otherwise overwrite it with a value the wall can not verify.
159
- - An aggregation may not contain `$out` or `$merge`: the rows they write can not be checked. Return the documents and write them with `MongoDBInsertMany` or `MongoDBBulkWrite`, which check every row (see the example below).
160
-
161
- **Change logs.** On a connection with a `changeLog`, the change-log record of a `tenant: none` write carries the `organization_id` of the rows it records, so the log collection can itself be read through a scoped connection. A single-document write takes it from the row it inserted, updated (the row as the update left it) or deleted; a write that matched no row writes no record. A multi-document write must be held to one organization before it runs:
162
-
163
- | Request | Allowed on a change-logged connection when |
164
- | ------- | ------------------------------------------ |
165
- | `MongoDBInsertMany`, `MongoDBInsertManyConsecutiveIds` | every document carries the same `organization_id` |
166
- | `MongoDBUpdateMany` | the filter matches `organization_id` by equality (`{ organization_id: <id> }` or `{ $eq: <id> }`) and the update does not write it |
167
- | `MongoDBDeleteMany` | the filter matches `organization_id` by equality |
168
-
169
- Anything else is refused before it writes, with an error naming the fix — usually running the write once per organization. Connections without a `changeLog` are not affected. (A [shared connection](#declaring-scope-on-a-connection) writes no organization into its change-log records; its change log can not point into a walled collection.)
170
-
171
- **Rollups across organizations** — group walled rows by organization and save the totals, typically from a scheduled endpoint — read with `tenant: none` and write the rows with a checked request instead of `$merge`:
172
-
173
- ```yaml
174
- routine:
175
- - id: rollup
176
- type: MongoDBAggregation
177
- connectionId: orders
178
- tenant: none
179
- properties:
180
- pipeline:
181
- - $group:
182
- _id:
183
- organization_id: $organization_id
184
- month:
185
- $dateToString:
186
- format: '%Y-%m'
187
- date: $created_at
188
- total:
189
- $sum: $amount
190
- - $project:
191
- _id: 0
192
- organization_id: $_id.organization_id
193
- month: $_id.month
194
- total: 1
195
- - id: save_rollup
196
- type: MongoDBBulkWrite
197
- connectionId: monthly_totals
198
- tenant: none
199
- properties:
200
- operations:
201
- _array.map:
202
- - _step: rollup
203
- - _function:
204
- replaceOne:
205
- filter:
206
- organization_id:
207
- __args: 0.organization_id
208
- month:
209
- __args: 0.month
210
- replacement:
211
- __args: 0
212
- upsert: true
213
- ```
214
-
215
- Every replacement carries the `organization_id` the group produced, so the bulk write passes the check, and each organization reads its own totals through the scoped `monthly_totals` connection.
216
-
217
- ### Why the wall lives on the connection
218
-
219
- 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.
220
-
221
- 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,97 +0,0 @@
1
- # Protected Pages and APIs
2
-
3
- By default, all pages and APIs in a Lowdefy app are public. Protected pages and APIs are resources that can only be accessed by a logged in user. If a user that is not logged in tries to access a protected page or API, they will receive an authentication error. For pages, the user will be redirected to the 404 page, and pages that a user is not allowed to see will be filtered from the app menus. For APIs, an "API Endpoint does not exist" error will be thrown.
4
-
5
- The config can either be set to protect all pages/APIs, except for a list of public ones, or vice-versa, all are public except for a list of protected ones.
6
-
7
- Protected and public pages and APIs can be configured in the `auth.pages` and `auth.api` sections of the Lowdefy configuration. Here the fields `protected` and `public` can be set to true, or a list of page/API IDs. You cannot set `protected` or `public` to `false`, and both can't be an array or `true` at the same time.
8
-
9
- When protecting all pages in the app, you will need to set at least one page as public to allow users to log in to the app.
10
-
11
- The 404 page (used to indicate that a requested page cannot be found) is always a public page. A default 404 page will be created if you do not specify one, but if you create a page with pageId `'404'` then that page will be the 404 page.
12
-
13
- ## Examples
14
-
15
- ###### List specific public pages and APIs:
16
- ```yaml
17
- lowdefy: 6.0.0
18
- auth:
19
- pages:
20
- protected: true
21
- public:
22
- - '404'
23
- - login
24
- - public-page
25
- api:
26
- protected: true
27
- public:
28
- - health_check
29
- - get_public_content
30
- ```
31
-
32
- ###### List specific protected pages and APIs:
33
- ```yaml
34
- lowdefy: 6.0.0
35
- auth:
36
- pages:
37
- public: true
38
- protected:
39
- - admin
40
- - users
41
- api:
42
- public: true
43
- protected:
44
- - update_user_settings
45
- - admin_dashboard_api
46
- ```
47
-
48
- ###### Mixed configuration:
49
- ```yaml
50
- lowdefy: 6.0.0
51
- auth:
52
- # Protect specific pages while keeping most public
53
- pages:
54
- public: true
55
- protected:
56
- - dashboard
57
- - profile
58
- - settings
59
-
60
- # Protect all API endpoints
61
- api:
62
- protected: true
63
- ```
64
-
65
- ## Wildcard Patterns
66
-
67
- Page and API access rules support glob patterns using `*` and `**` wildcards. This is useful for granting access to all pages from a [module](/modules):
68
-
69
- - `team-users/*` — matches all pages in the `team-users` module
70
- - `team-*/*` — matches all pages in modules with IDs starting with `team-`
71
- - `*/settings` — matches the `settings` page in any module
72
- - `**` — matches all pages
73
-
74
- Exact page IDs still work: `home`, `team-users/users-list`.
75
-
76
- ###### Wildcard pattern example:
77
- ```yaml
78
- lowdefy: 6.0.0
79
- auth:
80
- pages:
81
- protected: true
82
- public:
83
- - home
84
- - login
85
- roles:
86
- admin:
87
- - team-users/*
88
- - admin/*
89
- user:
90
- - notifications/*
91
- ```
92
-
93
- ## Important Notes
94
-
95
- - Pages and APIs are configured separately - protecting pages does not automatically protect APIs
96
- - Protected resources require users to be authenticated before access is granted
97
- - Role-based access control can be layered on top of protection for finer-grained permissions
@@ -1,49 +0,0 @@
1
- # Roles
2
-
3
- Roles can be used to limit user access to certain pages and API endpoints. Only users with a role linked to the page will be able to see that page, and the page will be filtered from menus if the user does not have the role.
4
-
5
- Roles are read from the `roles` field on the [user object](/user-object) — an array of role-name strings. These are the caller's **app roles**, resolved from their membership in the active organization (`member.appRoles`), not from a provider claim. A member is granted app roles through an [invitation](/auth-steps) (`InviteMember`) or an auth step (`UpdateMemberRoles`), so the same person can hold different roles in two organizations they belong to.
6
-
7
- Declare the role names your app uses in `auth.roles` (id, optional label and description) so tooling can list them:
8
-
9
- ```yaml
10
- lowdefy: 6.0.0
11
- auth:
12
- roles:
13
- - id: user-admin
14
- label: User Administrator
15
- - id: sales
16
- - id: reports
17
- ```
18
-
19
- Do **not** gate on `_user.org_roles` (the `owner`/`admin`/`member` organization tier) — no page or API gate reads it. It is an administrative fact used by the [auth-step authority floor](/auth-steps), separate from app roles. See [Organizations & Multi-Tenancy](/organizations#the-owner-admin-member-tier-vs-app-roles).
20
-
21
- The pages that are protected by roles are configured in the `auth.pages.roles` section in the Lowdefy configuration. This should be an object, where the keys are the role names, and the values are an array of pageIds that are protected by that role.
22
-
23
- Similarly, the API endpoints that are protected by roles are configured in the `auth.api.roles` section in the Lowdefy configuration, where the keys are the role names, and the values are an array of endpointsIds that are protected by that role.
24
-
25
- ###### Protect pages and API endpoints using roles:
26
- ```yaml
27
- lowdefy: 6.0.0
28
- auth:
29
- pages:
30
- public: true
31
- roles:
32
- user-admin:
33
- - users
34
- - new-user
35
- - edit-user
36
- sales:
37
- - customers
38
- - new-customer
39
- - edit-customer
40
- reports:
41
- - sales-report
42
- - operations-report
43
- api:
44
- protected: true
45
- roles:
46
- user-admin:
47
- - create-user
48
- - update-user
49
- ````