@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,229 +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 the user id in the two-factor collection (`user-two-factors`), applied by the host app. Lowdefy creates only the organization and membership indexes itself ([Organizations](/organizations)); this one is the deployment's. For MongoDB, see the `modules-mongodb` `user-account` index reference for how that module declares its auth indexes, or apply it directly — the adapter stores fields in snake_case, so the key is `user_id`:
97
-
98
- ```js
99
- db['user-two-factors'].createIndex({ user_id: 1 }, { unique: true });
100
- ```
101
-
102
- 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.
103
-
104
- ## Requiring two-factor enrolment
105
-
106
- `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.
107
-
108
- ```yaml
109
- auth:
110
- twoFactor:
111
- enabled: true
112
- required: true
113
- authPages:
114
- twoFactor: /two-factor-challenge
115
- twoFactorEnrol: /two-factor-enrol
116
- ```
117
-
118
- 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.
119
-
120
- 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".
121
-
122
- 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.
123
-
124
- `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.
125
-
126
- 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.
127
-
128
- ## What `required` guarantees — and what it does not
129
-
130
- > `required` guarantees that every member has enrolled a factor. It does NOT guarantee that every session presented one.
131
-
132
- 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.
133
-
134
- This is an accepted cost, for three reasons:
135
-
136
- - 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.
137
- - 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.
138
- - 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.
139
-
140
- ## Two-factor with a trusted OAuth provider
141
-
142
- 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.
143
-
144
- The cost, stated plainly: a deployment whose **only** sign-in path is a trusted provider gets enrolment with no challenge ever attached to it.
145
-
146
- 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.
147
-
148
- ## Two-factor for passwordless users
149
-
150
- 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.
151
-
152
- 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.
153
-
154
- ## The enrolment page
155
-
156
- `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.
157
-
158
- 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.
159
-
160
- 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.
161
-
162
- ## Recovering a user who has lost their factor
163
-
164
- When a member loses their authenticator or has a security key stolen, an administrator restores their access with two org-scoped steps:
165
-
166
- | Step | Properties | Permission |
167
- | ----------------------------------------------- | --------------------- | ----------------------------- |
168
- | `ResetUserTwoFactor` | `userId` | `user: ['reset-two-factor']` |
169
- | `RevokeUserPasskeys` | `userId`, `passkeyId?`| `user: ['revoke-passkeys']` |
170
-
171
- 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.
172
-
173
- Three things this routine must not miss:
174
-
175
- **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:
176
-
177
- ```yaml
178
- id: admin-recover-user-two-factor
179
- type: Api
180
- routine:
181
- - id: reset_two_factor
182
- type: ResetUserTwoFactor
183
- properties:
184
- userId:
185
- _payload: userId
186
- - id: revoke_passkeys
187
- type: RevokeUserPasskeys
188
- properties:
189
- userId:
190
- _payload: userId
191
- - id: revoke_sessions
192
- type: RevokeUserSessions
193
- properties:
194
- userId:
195
- _payload: userId
196
- - ':return':
197
- _step: revoke_sessions
198
- ```
199
-
200
- **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.
201
-
202
- **`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.
203
-
204
- 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.
205
-
206
- ## Before you turn `required` on
207
-
208
- **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.
209
-
210
- **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.
211
-
212
- **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:
213
-
214
- 1. Delete the target's row from the `user-two-factors` collection.
215
- 2. Set `twoFactorEnabled` to `false` on their row in the `users` collection.
216
-
217
- That administrator can then sign in and recover the others through the app.
218
-
219
- ## Required index
220
-
221
- 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:
222
-
223
- ```js
224
- db['user-passkeys'].createIndex({ user_id: 1 });
225
- ```
226
-
227
- 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 Lowdefy creates only the organization and membership indexes itself — the deployment applies this one, exactly as it applies the `user-two-factors` index above. (The auth adapter stores fields in snake_case, so the key is `{ user_id: 1 }`, not the logical `{ userId: 1 }`.)
228
-
229
- 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.
@@ -1,58 +0,0 @@
1
- # User Object
2
-
3
- The `user` object holds data about the currently signed-in **caller**, read with the [`_user`](/_user) operator on both the server and the client. It is not the provider's profile — it is a caller Lowdefy resolves from the user's row *and their membership in the active organization*, so the same person carries a different `_user` in two organizations they belong to.
4
-
5
- Its shape is **fixed**. Unlike the old provider-claim mapping, there is no `userFields` config and no set of OpenID Connect claims copied onto the session — the provider subject and extra claims are not on the user object at all. If you need a provider claim at signup, persist it with an [auth hook](/auth-upgrade#7-rewrite-callbacks-and-events-as-hooks) and read it from where you stored it.
6
-
7
- ## Fields
8
-
9
- | Field | On which callers | Meaning |
10
- | ----- | ---------------- | ------- |
11
- | `id` | all | The internal user id. **This replaces the old `sub`** — it is a *different value* (the provider subject lives on the `user-accounts` collection as `accountId`). |
12
- | `email` | all | The user's email. |
13
- | `email_verified` | session | Whether the email is verified. |
14
- | `name`, `image` | session | Display name and avatar — the **per-organization** copies from the active membership, falling back to the deployment-global user record. |
15
- | `roles` | session, strategy | The app's own role strings, from the active membership's `appRoles`. **The only thing `auth.pages.roles` / `auth.api.roles` match.** |
16
- | `org_roles` | session | The organization tier from `member.role`: `owner`, `admin` or `member`. An administrative fact — **no gate reads it**. |
17
- | `attributes` | session | One merged bag: global `user.attributes` under the active `member.attributes`, per key, member wins. |
18
- | `organization_id` | session, mcp | The organization the caller acts in — the value the [tenant wall](/organizations#the-tenant-wall) stamps and filters on. |
19
- | `active_organization_id` | session | The active organization id (equal to `organization_id`). |
20
- | `two_factor_enrolled` | session | Whether the caller holds a factor satisfying `auth.twoFactor.required`. |
21
- | `auth_method` | strategy, mcp | How the caller authenticated — the strategy id, or `'mcp'` for an [assistant](/mcp-oauth). Absent on browser sessions. |
22
- | `profile` | session | The user record's opaque display/app-data bag. Absent (never `{}`) when the user has written no profile. |
23
- | `contact_id` | session | Link to the app's canonical record for this person, when one exists. |
24
-
25
- > **`_user.role` (singular) carries nothing.** Lowdefy never writes it; it sits one character from `roles`, so gating on `_user.role` is gating on a constant. Read `roles`.
26
-
27
- A caller resolved from an [API strategy](/auth-configuration) (`apiKey`, `jwt`) sits outside the membership boundary: it carries `id`, `roles`, `attributes` and `auth_method`, but no organization, `name`, `image`, `profile` or `two_factor_enrolled`. A signed-in user who holds only a pending invitation (before they accept, under `tenant`) is an *awaiting-organization* caller — known to the always-public accept page, refused everywhere else.
28
-
29
- ## Examples
30
-
31
- ###### Use the user's avatar in an Avatar block:
32
- ```yaml
33
- id: avatar
34
- type: Avatar
35
- properties:
36
- src:
37
- _user: image
38
- ```
39
-
40
- ###### Stamp the creator when inserting a document:
41
- ```yaml
42
- id: insert_data
43
- type: MongoDBInsertOne
44
- properties:
45
- doc:
46
- field:
47
- _state: field
48
- created_by:
49
- name:
50
- _user: name
51
- id:
52
- _user: id
53
- ```
54
-
55
- ###### Read a value out of the merged attributes bag:
56
- ```yaml
57
- _user: attributes.plan
58
- ```
@@ -1,108 +0,0 @@
1
- # Publish and Actions
2
-
3
- A `Channel` websocket relays messages between clients: any subscriber can publish, and the server broadcasts to everyone subscribed — including the sender. This powers chat, presence, and collaborative features with no extra services.
4
-
5
- ## A Minimal Chat
6
-
7
- ```yaml
8
- lowdefy: 6.0.0
9
-
10
- websockets:
11
- - id: team_chat
12
- type: Channel
13
- properties:
14
- publish: true
15
-
16
- pages:
17
- - id: chat
18
- type: PageHeaderMenu
19
- subscriptions:
20
- - websocketId: team_chat
21
- client:
22
- maxMessages: 200
23
- blocks:
24
- - id: chat_input
25
- type: TextInput
26
- properties:
27
- placeholder: Say something...
28
- - id: send
29
- type: Button
30
- properties:
31
- title: Send
32
- events:
33
- onClick:
34
- - id: publish
35
- type: Publish
36
- params:
37
- websocketId: team_chat
38
- payload:
39
- text:
40
- _state: chat_input
41
- - id: clear
42
- type: SetState
43
- params:
44
- chat_input: ''
45
- - id: messages
46
- type: Html
47
- properties:
48
- html:
49
- _array.join:
50
- - _array.map:
51
- on:
52
- _websocket: team_chat.messages
53
- callback:
54
- _function:
55
- __string.concat:
56
- - '<p>'
57
- - __args: 0.text
58
- - '</p>'
59
- - ''
60
- ```
61
-
62
- Publishing requires both the channel type to support it and `properties.publish: true` on the definition — server-push channels reject publishes by default.
63
-
64
- ## Actions
65
-
66
- ### `Publish`
67
-
68
- Sends a payload to a channel. The action resolves when the server acknowledges the publish, and rejects if the channel does not allow publishing or the connection cannot be established:
69
-
70
- ```yaml
71
- - id: publish
72
- type: Publish
73
- params:
74
- websocketId: team_chat
75
- payload:
76
- text:
77
- _state: chat_input
78
- ```
79
-
80
- Payload values keep their types across the wire — dates arrive as dates.
81
-
82
- ### `Subscribe` and `Unsubscribe`
83
-
84
- Page `subscriptions` cover most cases, but the actions give dynamic control — subscribe after an interaction, or re-subscribe with a fresh payload:
85
-
86
- ```yaml
87
- - id: change_filter
88
- type: Unsubscribe
89
- params: activity_feed
90
- - id: resubscribe
91
- type: Subscribe
92
- params: activity_feed # payload re-evaluates from current state
93
- ```
94
-
95
- `Subscribe` on an already-subscribed channel does nothing. Channels subscribed with the action are still cleaned up when the page unmounts.
96
-
97
- ## Deployment
98
-
99
- ### Vercel
100
-
101
- Websockets use Vercel's native WebSocket support for Node.js functions on Fluid compute — deploy the same project, no configuration. Two platform behaviors to know:
102
-
103
- - Connections close when a function reaches its `maxDuration`. The Lowdefy client reconnects and resubscribes automatically, so this is invisible to users; raise `maxDuration` to reduce reconnect frequency on websocket-heavy apps.
104
- - Under load, Vercel may run multiple instances of your app. Server-driven sources run per instance, so every subscriber receives every source message regardless of which instance they land on. Client publishes, however, reach subscribers connected to the same instance. For chat-style apps at multi-instance scale, a cross-instance pubsub adapter is on the roadmap.
105
-
106
- ### Self-Hosted
107
-
108
- The Node.js server handles websocket upgrades directly — nothing to configure. A single server process delivers publishes to all subscribers, so `Channel` behavior is complete on one instance. Behind a reverse proxy, forward the upgrade headers and the original `Host` header (`proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host;` for nginx). The server refuses a websocket upgrade from a page whose origin does not match the `Host` it receives.
@@ -1,100 +0,0 @@
1
- # Page Subscriptions
2
-
3
- Pages subscribe to websocket channels with the `subscriptions` key. The engine subscribes when the page mounts and unsubscribes when the user navigates away — no cleanup wiring needed.
4
-
5
- ## Subscription Definition
6
-
7
- - `websocketId: string`: __Required__ - The id of a websocket defined in the top-level `websockets` array.
8
- - `payload: object`: Client-side operators (`_state`, `_url_query`, `_global`) evaluated at subscribe time. The server reads these values with `_payload` in the websocket `properties`.
9
- - `events: object`: Actions for `onMessage`, `onSubscribe` and `onError`.
10
- - `client.maxMessages: number`: How many messages to retain in `messages` (default 100, oldest dropped first).
11
- - `client.throttleRender: number`: Minimum milliseconds between renders while messages stream in (default 250, minimum 100).
12
-
13
- ## Reacting to Messages
14
-
15
- The most common realtime pattern is prompting a data refresh — a source signals that something changed, and the page refetches its request:
16
-
17
- ```yaml
18
- pages:
19
- - id: orders
20
- type: PageHeaderMenu
21
- requests:
22
- - id: get_orders
23
- type: MongoDBFind
24
- connectionId: mongodb
25
- properties:
26
- query: {}
27
- subscriptions:
28
- - websocketId: order_updates
29
- events:
30
- onMessage:
31
- - id: refetch
32
- type: Request
33
- params: get_orders
34
- blocks:
35
- - id: orders_table
36
- type: AgGridAlpine
37
- properties:
38
- rowData:
39
- _request: get_orders
40
- ```
41
-
42
- `onMessage` fires once per render batch, not once per message — on a busy channel the batch holds every message since the last flush. The batch is available on the event:
43
-
44
- ```yaml
45
- events:
46
- onMessage:
47
- - id: use_batch
48
- type: SetState
49
- params:
50
- latest_batch:
51
- _event: messages # array of message payloads in this batch
52
- ```
53
-
54
- `onSubscribe` fires when the server acknowledges the subscription (including after an automatic reconnect). `onError` fires with `_event: message` when the channel errors.
55
-
56
- ## Reading Channel State with `_websocket`
57
-
58
- The `_websocket` operator reads the channel's client state anywhere on the page:
59
-
60
- ```yaml
61
- _websocket: ticker.connected # boolean — subscription is live
62
- _websocket: ticker.messages # array — retained payloads, newest last
63
- _websocket: ticker.lastMessage # the most recent message payload
64
- _websocket: ticker.lastMessage.tick # dot paths into the payload
65
- _websocket: ticker.messageCount # total received this page visit (not capped)
66
- _websocket: ticker.error # last error, null when healthy
67
- ```
68
-
69
- Unlike `_request`, there is no invocation history — a subscription is continuous. State resets when the page unmounts.
70
-
71
- ## Subscription Payloads
72
-
73
- The subscription `payload` parameterizes the channel per subscriber. Client operators are evaluated when the subscription starts:
74
-
75
- ```yaml
76
- pages:
77
- - id: activity
78
- type: PageHeaderMenu
79
- subscriptions:
80
- - websocketId: activity_feed
81
- payload:
82
- project_id:
83
- _url_query: project_id
84
- ```
85
-
86
- ```yaml
87
- websockets:
88
- - id: activity_feed
89
- type: MongoDBChangeStream
90
- connectionId: mongodb
91
- properties:
92
- pipeline:
93
- - $match:
94
- fullDocument.project_id:
95
- _payload: project_id
96
- fullDocument.owner_id:
97
- _user: id
98
- ```
99
-
100
- The payload is evaluated once, at subscribe time. To subscribe with new values — say a changed filter — run [`Unsubscribe` then `Subscribe`](/websocket-publish).
@@ -1,97 +0,0 @@
1
- # WebSockets
2
-
3
- Lowdefy websockets add realtime updates to your app — live dashboards, notifications, and chat — without polling and without running a separate socket service. Channels are defined at the root of your Lowdefy configuration, pages subscribe to them, and the same Lowdefy server that serves your pages pushes messages to the browser over a single WebSocket connection.
4
-
5
- Websockets work identically on a self-hosted Node.js server and on Vercel, where Fluid compute now accepts WebSocket connections natively — one deployment, no extra infrastructure, no token wiring. Authentication uses your app's existing session: the connection is authorized with the same user roles as [API endpoints](/api-endpoints).
6
-
7
- ## Quick Start
8
-
9
- A channel and a page subscription — this page shows a live tick counter:
10
-
11
- ```yaml
12
- lowdefy: 6.0.0
13
-
14
- websockets:
15
- - id: ticker
16
- type: Interval
17
- properties:
18
- ms: 1000
19
-
20
- pages:
21
- - id: dashboard
22
- type: PageHeaderMenu
23
- subscriptions:
24
- - websocketId: ticker
25
- blocks:
26
- - id: ticks
27
- type: Html
28
- properties:
29
- html:
30
- _string.concat:
31
- - 'Ticks: '
32
- - _websocket: ticker.lastMessage.tick
33
- ```
34
-
35
- When the page loads, the client opens a websocket connection, subscribes to `ticker`, and the block re-renders as messages arrive. When the user navigates away, the subscription ends and the channel's server source stops.
36
-
37
- ## How WebSockets Work
38
-
39
- 1. A page with `subscriptions` mounts. The client opens one shared WebSocket connection to the Lowdefy server (lazily — apps without websockets never connect).
40
- 2. For each subscription, the client evaluates the subscription `payload` (client-side operators like `_state`) and sends a subscribe frame.
41
- 3. The server authorizes the subscription against the channel's `auth` config, then evaluates the websocket `properties` server-side — `_payload` reads the subscription payload and `_user` reads the subscriber's session, so a channel can be user-specific.
42
- 4. Subscribers whose evaluated properties are identical share one running source on the server. The first subscriber starts it; the last one to leave stops it.
43
- 5. Messages from the source are pushed to all subscribers, update the channel state read by the [`_websocket`](/websocket-subscriptions) operator, and fire the subscription's `onMessage` event.
44
-
45
- If the connection drops — a deploy, a network blip, or a serverless function reaching its time limit — the client reconnects with backoff and resubscribes automatically. Channel state on the page is preserved across reconnects.
46
-
47
- ## Websocket Definition
48
-
49
- Websockets are defined in the top-level `websockets` array:
50
-
51
- - `id: string`: __Required__ - A unique identifier for the channel.
52
- - `type: string`: __Required__ - The websocket type. `Channel` and `Interval` ship with Lowdefy; connection plugins provide types like [`MongoDBChangeStream`](/MongoDB).
53
- - `connectionId: string`: The `id` of a [connection](/connections) for connection-backed types.
54
- - `properties: object`: Settings for the type. __Operators are evaluated server-side per subscription__ — `_secret`, `_user`, and `_payload` are available.
55
-
56
- ###### A user-specific channel:
57
- ```yaml
58
- websockets:
59
- - id: my_feed
60
- type: MongoDBChangeStream
61
- connectionId: mongodb
62
- properties:
63
- pipeline:
64
- - $match:
65
- fullDocument.owner_id:
66
- _user: id
67
- fullDocument.category:
68
- _payload: category
69
- ```
70
-
71
- Because `properties` evaluate per subscription, two users subscribing to `my_feed` get two independent server sources — but a thousand subscribers to a channel with static properties share just one.
72
-
73
- ## Core Types
74
-
75
- - `Channel` — a pure pub/sub relay. Clients publish messages with the [`Publish`](/websocket-publish) action and the server broadcasts them to all subscribers. Set `properties.publish: true` to allow publishing.
76
- - `Interval` — emits `{ tick, at }` every `properties.ms` milliseconds (default 1000, minimum 100). Useful for examples and testing.
77
-
78
- ## Securing Channels
79
-
80
- Channel access follows the same pattern as [protected API endpoints](/api-endpoints). Configure `auth.websockets` with `public`, `protected`, and `roles` lists:
81
-
82
- ```yaml
83
- auth:
84
- websockets:
85
- roles:
86
- admin:
87
- - admin_feed # only users with the admin role may subscribe
88
- public:
89
- - ticker # everyone else requires a session
90
- ```
91
-
92
- Unauthorized subscriptions receive the same response as subscriptions to channels that do not exist.
93
-
94
- ## What's Next
95
-
96
- - [Page Subscriptions](/websocket-subscriptions) — Subscribe pages to channels, handle messages with events, and read channel state with `_websocket`.
97
- - [Publish and Actions](/websocket-publish) — Client publishing, the `Publish`, `Subscribe` and `Unsubscribe` actions, and deployment notes.
package/hazards.json DELETED
@@ -1,44 +0,0 @@
1
- [
2
- {
3
- "id": "state-in-request-properties",
4
- "appliesTo": { "kinds": ["requests"] },
5
- "message": "_state in request properties is always undefined — a request is evaluated on the server with an empty state. Pass the value in the payload and read it with _payload.",
6
- "see": "concepts/connections-and-requests"
7
- },
8
- {
9
- "id": "visible-false-prunes-state",
10
- "appliesTo": { "kinds": ["blocks"] },
11
- "message": "visible: false removes the block and its state. Style it hidden (for example style: { display: none }) to keep the value.",
12
- "see": "concepts/lowdefy-schema"
13
- },
14
- {
15
- "id": "link-same-page-no-onmount",
16
- "appliesTo": { "types": ["Link"] },
17
- "message": "A Link to the page already open does not remount it, so onMount does not fire again. Trigger the reload chain from the same action list.",
18
- "see": "actions/link"
19
- },
20
- {
21
- "id": "ref-njk-renders-at-build",
22
- "appliesTo": { "types": ["_ref"] },
23
- "message": "A _ref to a .njk file renders at build, so _state, _request and _user are undefined inside it. Use _nunjucks over a .txt file for a template that needs runtime values.",
24
- "see": "operators/_ref"
25
- },
26
- {
27
- "id": "ref-js-imported-as-module",
28
- "appliesTo": { "types": ["_ref"] },
29
- "message": "A _ref to a .js file imports it as a module rather than reading it as text. Name the file .js.txt to read its source.",
30
- "see": "operators/_ref"
31
- },
32
- {
33
- "id": "tenant-wall-lookup",
34
- "appliesTo": { "kinds": ["requests"], "when": "connectionTenantNotNone" },
35
- "message": "This request runs over a tenant-walled connection. The wall injects { $match: { <tenant field> } } into the top-level pipeline and into every $lookup / $unionWith sub-pipeline, so a lookup into a collection with no tenant field returns [] with no error. Run the request over the shared connection and pass tenant facts in the payload instead.",
36
- "see": "concepts/connections-and-requests"
37
- },
38
- {
39
- "id": "reject-not-caught",
40
- "appliesTo": { "types": [":reject"] },
41
- "message": ":reject is the routine's reply, not an exception. A :catch never runs for it, :finally does, and :throw is the caught path.",
42
- "see": "controls/reject"
43
- }
44
- ]