@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.
- package/README.md +0 -1
- package/content/actions/link.md +0 -17
- package/content/actions/login.md +5 -23
- package/content/actions/logout.md +5 -5
- package/content/actions/pdfmake.md +2 -2
- package/content/actions/setdarkmode.md +2 -2
- package/content/agents/agent-properties.md +2 -4
- package/content/agents/agentchat.md +6 -21
- package/content/agents/agentconversations.md +2 -2
- package/content/concepts/ai-agent-docs.md +101 -313
- package/content/concepts/cli.md +3 -34
- package/content/concepts/connections-and-requests.md +0 -402
- package/content/concepts/custom-html.md +0 -40
- package/content/concepts/custom-styling.md +5 -50
- package/content/concepts/events-and-actions.md +2 -64
- package/content/concepts/i18n.md +2 -7
- package/content/concepts/keyboard-shortcuts.md +2 -2
- package/content/concepts/layout-overview.md +1 -1
- package/content/concepts/logger.md +0 -81
- package/content/concepts/lowdefy-schema.md +1 -4
- package/content/concepts/menus.md +7 -7
- package/content/concepts/module-authoring.md +2 -2
- package/content/connections/anthropic.md +2 -162
- package/content/connections/awss3.md +0 -54
- package/content/connections/google.md +2 -162
- package/content/connections/mongodb.md +3 -121
- package/content/connections/openai-connection.md +2 -162
- package/content/container-blocks/affix.md +7 -47
- package/content/container-blocks/alert.md +24 -76
- package/content/container-blocks/badge.md +24 -100
- package/content/container-blocks/box.md +9 -9
- package/content/container-blocks/card.md +25 -80
- package/content/container-blocks/carousel.md +7 -60
- package/content/container-blocks/collapse.md +9 -87
- package/content/container-blocks/configprovider.md +13 -35
- package/content/container-blocks/confirmmodal.md +23 -69
- package/content/container-blocks/content.md +98 -62
- package/content/container-blocks/descriptions.md +4 -45
- package/content/container-blocks/drawer.md +28 -92
- package/content/container-blocks/dropdownmenu.md +31 -119
- package/content/container-blocks/flex.md +62 -40
- package/content/container-blocks/footer.md +11 -15
- package/content/container-blocks/googlemapsheatmap.md +1 -1
- package/content/container-blocks/googlemapsscript.md +2 -2
- package/content/container-blocks/header.md +36 -71
- package/content/container-blocks/label.md +7 -34
- package/content/container-blocks/layout.md +34 -52
- package/content/container-blocks/masonry.md +71 -17
- package/content/container-blocks/mobilemenu.md +88 -131
- package/content/container-blocks/modal.md +26 -114
- package/content/container-blocks/pageheadermenu.md +31 -81
- package/content/container-blocks/pagesidebarlayout.md +26 -169
- package/content/container-blocks/pagesidermenu.md +42 -95
- package/content/container-blocks/popover.md +31 -34
- package/content/container-blocks/result.md +21 -32
- package/content/container-blocks/sider.md +122 -85
- package/content/container-blocks/span.md +7 -7
- package/content/container-blocks/spinner.md +3 -3
- package/content/container-blocks/splitter.md +68 -150
- package/content/container-blocks/tabs.md +12 -64
- package/content/container-blocks/tooltip.md +46 -22
- package/content/container-blocks/watermark.md +4 -22
- package/content/controls/reject.md +2 -4
- package/content/controls/throw.md +0 -1
- package/content/controls/try.md +2 -3
- package/content/deployment/deployment-environments.md +6 -8
- package/content/deployment/docker.md +7 -22
- package/content/deployment/node-server.md +0 -4
- package/content/deployment/vercel.md +6 -13
- package/content/display-blocks/aggrid.md +11 -26
- package/content/display-blocks/anchor.md +15 -15
- package/content/display-blocks/avatar.md +37 -57
- package/content/display-blocks/breadcrumb.md +21 -70
- package/content/display-blocks/button.md +18 -92
- package/content/display-blocks/calendar.md +0 -12
- package/content/display-blocks/dangeroushtml.md +1 -1
- package/content/display-blocks/difflist.md +1 -1
- package/content/display-blocks/difftimeline.md +1 -1
- package/content/display-blocks/divider.md +3 -64
- package/content/display-blocks/dropdownbutton.md +28 -79
- package/content/display-blocks/echart.md +1 -35
- package/content/display-blocks/floatbutton.md +42 -129
- package/content/display-blocks/html.md +1 -61
- package/content/display-blocks/icon.md +67 -250
- package/content/display-blocks/menu.md +73 -145
- package/content/display-blocks/message.md +124 -38
- package/content/display-blocks/notification.md +222 -94
- package/content/display-blocks/paragraph.md +9 -59
- package/content/display-blocks/progress.md +17 -96
- package/content/display-blocks/qrcode.md +4 -19
- package/content/display-blocks/s3download.md +1 -1
- package/content/display-blocks/search.md +7 -17
- package/content/display-blocks/skeletonbutton.md +0 -1
- package/content/display-blocks/skeletoninput.md +0 -1
- package/content/display-blocks/statistic.md +17 -75
- package/content/display-blocks/steps.md +25 -58
- package/content/display-blocks/tag.md +17 -102
- package/content/display-blocks/title.md +11 -50
- package/content/display-blocks/tour.md +23 -33
- package/content/input-blocks/autocomplete.md +9 -81
- package/content/input-blocks/buttonselector.md +8 -45
- package/content/input-blocks/checkboxselector.md +7 -97
- package/content/input-blocks/checkboxswitch.md +3 -33
- package/content/input-blocks/colorselector.md +14 -63
- package/content/input-blocks/daterangeselector.md +45 -165
- package/content/input-blocks/dateselector.md +41 -139
- package/content/input-blocks/datetimeselector.md +49 -158
- package/content/input-blocks/inputgallery.md +3 -3
- package/content/input-blocks/monthselector.md +52 -120
- package/content/input-blocks/multipleselector.md +39 -150
- package/content/input-blocks/numberinput.md +12 -98
- package/content/input-blocks/pagination.md +14 -70
- package/content/input-blocks/paragraphinput.md +37 -51
- package/content/input-blocks/passwordinput.md +6 -60
- package/content/input-blocks/phonenumberinput.md +26 -59
- package/content/input-blocks/radioselector.md +7 -83
- package/content/input-blocks/ratingslider.md +26 -57
- package/content/input-blocks/s3uploadbutton.md +24 -24
- package/content/input-blocks/s3uploaddragger.md +3 -3
- package/content/input-blocks/s3uploadphoto.md +5 -5
- package/content/input-blocks/segmentedselector.md +32 -72
- package/content/input-blocks/selector.md +21 -109
- package/content/input-blocks/switch.md +17 -65
- package/content/input-blocks/textarea.md +3 -32
- package/content/input-blocks/textinput.md +28 -69
- package/content/input-blocks/tiptapmentioninput.md +1 -1
- package/content/input-blocks/titleinput.md +12 -39
- package/content/input-blocks/treeinput.md +25 -49
- package/content/input-blocks/treemultipleselector.md +10 -127
- package/content/input-blocks/treeselector.md +9 -97
- package/content/input-blocks/upload.md +15 -64
- package/content/input-blocks/uploaddragger.md +1 -17
- package/content/input-blocks/uploadphoto.md +1 -18
- package/content/input-blocks/weekselector.md +55 -120
- package/content/list-blocks/controlledlist.md +15 -24
- package/content/list-blocks/list.md +11 -11
- package/content/list-blocks/masonrylist.md +34 -7
- package/content/migration/upgrade-guide.md +0 -1
- package/content/operators/_app.md +1 -2
- package/content/operators/_error.md +1 -23
- package/content/operators/_get.md +6 -6
- package/content/operators/_operator.md +3 -17
- package/content/operators/_ref.md +0 -258
- package/content/operators/_type.md +3 -14
- package/content/operators/_user.md +3 -3
- package/content/other/introduction.md +1 -1
- package/content/plugins/plugins-dev.md +0 -66
- package/content/plugins/plugins-introduction.md +1 -27
- package/content/testing/e2e-configuration.md +1 -8
- package/content/testing/e2e-getting-started.md +1 -1
- package/content/testing/e2e-introduction.md +1 -1
- package/content/tutorial/tutorial-actions-operators.md +2 -2
- package/content/tutorial/tutorial-add-blocks.md +2 -2
- package/content/tutorial/tutorial-create-page.md +2 -48
- package/content/tutorial/tutorial-display-data-page.md +3 -3
- package/content/tutorial/tutorial-requests-sql.md +0 -153
- package/content/tutorial/tutorial-start.md +1 -66
- package/content/user-authentication/login-and-logout.md +8 -158
- package/content/user-authentication/users-introduction.md +0 -8
- package/index.json +2 -6065
- package/package.json +1 -7
- package/scripts/extractAgentDocs.js +4 -36
- package/content/actions/emailotpsend.md +0 -58
- package/content/actions/emailotpverify.md +0 -77
- package/content/actions/getlocalstorage.md +0 -41
- package/content/actions/magiclinkverify.md +0 -113
- package/content/actions/posthogalias.md +0 -29
- package/content/actions/posthogcapture.md +0 -49
- package/content/actions/posthogcapturepageleave.md +0 -38
- package/content/actions/posthogfeatureflag.md +0 -50
- package/content/actions/posthoggroup.md +0 -37
- package/content/actions/posthogidentify.md +0 -54
- package/content/actions/posthoginit.md +0 -66
- package/content/actions/posthogoptin.md +0 -29
- package/content/actions/posthogoptout.md +0 -29
- package/content/actions/posthogpageview.md +0 -37
- package/content/actions/posthogreloadfeatureflags.md +0 -47
- package/content/actions/posthogreset.md +0 -28
- package/content/actions/posthogsetpersonproperties.md +0 -36
- package/content/actions/removelocalstorage.md +0 -26
- package/content/actions/setlocalstorage.md +0 -32
- package/content/adapter-reference/mongodbauthadapter.md +0 -46
- package/content/agents/agents-introduction.md +0 -107
- package/content/callback-reference/auth0logoutcallback.md +0 -54
- package/content/concepts/blocks.md +0 -150
- package/content/concepts/dynamic-page-content.md +0 -304
- package/content/concepts/html-attributes.md +0 -285
- package/content/concepts/lowdefy-api.md +0 -1110
- package/content/concepts/notifications.md +0 -155
- package/content/concepts/page-and-app-state.md +0 -244
- package/content/concepts/theming.md +0 -582
- package/content/connections/aigateway.md +0 -368
- package/content/connections/axioshttp.md +0 -181
- package/content/controls/while.md +0 -46
- package/content/display-blocks/clickablehtml.md +0 -239
- package/content/display-blocks/eventlog.md +0 -363
- package/content/display-blocks/pollingtimer.md +0 -263
- package/content/input-blocks/captcha.md +0 -40
- package/content/input-blocks/placesautocomplete.md +0 -319
- package/content/input-blocks/tagmultipleselector.md +0 -114
- package/content/input-blocks/tagselector.md +0 -114
- package/content/migration/auth-upgrade.md +0 -327
- package/content/migration/v3-to-v4.md +0 -417
- package/content/migration/v6-to-v7.md +0 -222
- package/content/operators/_cron.md +0 -292
- package/content/other/mongodbadapter.md +0 -3
- package/content/plugins/plugins-icon-sets.md +0 -267
- package/content/plugins/posthog.md +0 -275
- package/content/provider-reference/auth-js-providers.md +0 -107
- package/content/provider-reference/openidconnectprovider.md +0 -44
- package/content/testing/config-tests.md +0 -278
- package/content/tutorial/tutorial-requests-api.md +0 -155
- package/content/user-authentication/auth-configuration.md +0 -266
- package/content/user-authentication/auth-steps.md +0 -138
- package/content/user-authentication/mcp-oauth.md +0 -292
- package/content/user-authentication/organizations.md +0 -221
- package/content/user-authentication/protected-pages-apis.md +0 -97
- package/content/user-authentication/roles.md +0 -49
- package/content/user-authentication/two-factor.md +0 -229
- package/content/user-authentication/user-object.md +0 -58
- package/content/websockets/websocket-publish.md +0 -108
- package/content/websockets/websocket-subscriptions.md +0 -100
- package/content/websockets/websockets-introduction.md +0 -97
- package/hazards.json +0 -44
- package/scripts/hashDocSources.js +0 -38
- 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
|
-
]
|