@lowdefy/docs-content 0.0.0-experimental-20260909201552 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/content/actions/login.md +5 -23
- package/content/actions/logout.md +5 -5
- package/content/agents/agentchat.md +1 -13
- package/content/concepts/ai-agent-docs.md +4 -90
- package/content/concepts/custom-styling.md +5 -50
- package/content/concepts/events-and-actions.md +2 -64
- package/content/concepts/i18n.md +1 -1
- package/content/connections/mongodb.md +3 -109
- package/content/container-blocks/modal.md +1 -1
- package/content/container-blocks/pagesidebarlayout.md +0 -1
- package/content/controls/reject.md +1 -3
- package/content/controls/throw.md +0 -1
- package/content/controls/try.md +2 -3
- package/content/display-blocks/difflist.md +1 -1
- package/content/display-blocks/difftimeline.md +1 -1
- package/content/display-blocks/s3download.md +1 -1
- package/content/display-blocks/search.md +0 -1
- package/content/input-blocks/checkboxselector.md +4 -80
- package/content/input-blocks/radioselector.md +3 -65
- package/content/input-blocks/s3uploadbutton.md +1 -1
- package/content/input-blocks/s3uploaddragger.md +1 -1
- package/content/input-blocks/s3uploadphoto.md +1 -1
- package/content/input-blocks/tiptapmentioninput.md +1 -1
- package/content/list-blocks/masonrylist.md +1 -1
- package/content/operators/_type.md +3 -14
- package/content/operators/_user.md +3 -3
- package/content/user-authentication/login-and-logout.md +8 -158
- package/content/user-authentication/users-introduction.md +0 -8
- package/index.json +1 -99
- package/package.json +1 -3
- package/content/actions/emailotpsend.md +0 -58
- package/content/actions/emailotpverify.md +0 -77
- package/content/actions/magiclinkverify.md +0 -113
- package/content/controls/while.md +0 -46
- package/content/input-blocks/captcha.md +0 -40
- package/content/input-blocks/tagmultipleselector.md +0 -101
- package/content/input-blocks/tagselector.md +0 -101
- package/content/migration/auth-upgrade.md +0 -315
- package/content/other/mongodbadapter.md +0 -3
- package/content/user-authentication/auth-steps.md +0 -111
- package/content/user-authentication/mcp-oauth.md +0 -289
- package/content/user-authentication/organizations.md +0 -134
- package/content/user-authentication/two-factor.md +0 -225
- package/content/user-authentication/user-object.md +0 -58
- package/hazards.json +0 -44
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# TagSelector
|
|
2
|
-
|
|
3
|
-
Single-select input rendered as a row of toggleable, colored tag pills.
|
|
4
|
-
|
|
5
|
-
```yaml
|
|
6
|
-
- id: basic_strings
|
|
7
|
-
type: TagSelector
|
|
8
|
-
properties:
|
|
9
|
-
title: Domain
|
|
10
|
-
options:
|
|
11
|
-
- Ethics
|
|
12
|
-
- Environment
|
|
13
|
-
- Governance
|
|
14
|
-
- Social
|
|
15
|
-
- Data Privacy
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
```yaml
|
|
19
|
-
- id: label_value
|
|
20
|
-
type: TagSelector
|
|
21
|
-
properties:
|
|
22
|
-
title: Status
|
|
23
|
-
options:
|
|
24
|
-
- label: Draft
|
|
25
|
-
value: draft
|
|
26
|
-
- label: In Review
|
|
27
|
-
value: in_review
|
|
28
|
-
- label: Published
|
|
29
|
-
value: published
|
|
30
|
-
- label: Archived
|
|
31
|
-
value: archived
|
|
32
|
-
disabled: true
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
```yaml
|
|
36
|
-
- id: explicit_colors
|
|
37
|
-
type: TagSelector
|
|
38
|
-
properties:
|
|
39
|
-
title: Priority
|
|
40
|
-
options:
|
|
41
|
-
- label: Low
|
|
42
|
-
value: low
|
|
43
|
-
color: "#59A14F"
|
|
44
|
-
- label: Medium
|
|
45
|
-
value: medium
|
|
46
|
-
color: "#EDC948"
|
|
47
|
-
- label: High
|
|
48
|
-
value: high
|
|
49
|
-
color: "#E15759"
|
|
50
|
-
- id: single_accent
|
|
51
|
-
type: TagSelector
|
|
52
|
-
properties:
|
|
53
|
-
title: Interest
|
|
54
|
-
colored: false
|
|
55
|
-
options:
|
|
56
|
-
- Design
|
|
57
|
-
- Engineering
|
|
58
|
-
- Product
|
|
59
|
-
- Research
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
| Property | Type | Default | Description |
|
|
63
|
-
| --- | --- | --- | --- |
|
|
64
|
-
| `options` | array | - | Options to select from. Primitives, or { label, value, color, disabled } - an explicit color overrides the stable palette color. |
|
|
65
|
-
| `options.$.label` | string | - | Tag label. Defaults to the value. |
|
|
66
|
-
| `options.$.value` | - | - | Option value set as the selection when the tag is selected. |
|
|
67
|
-
| `options.$.color` | string | - | Explicit tag color (hex or CSS color). Overrides the stable palette color. |
|
|
68
|
-
| `options.$.disabled` | boolean | `false` | Disable this tag. |
|
|
69
|
-
| `colored` | boolean | `true` | Give each option a stable color (hash of its value over a fixed palette). false = single primary accent. |
|
|
70
|
-
| `disabled` | boolean | `false` | Disable the block if true. |
|
|
71
|
-
| `size` | string | `"default"` | Size of the block. Enum: `small`, `default`, `large`. |
|
|
72
|
-
| `label` | object | - | Label properties. |
|
|
73
|
-
| `label.align` | string | `"left"` | Align label left or right when inline. Enum: `left`, `right`. |
|
|
74
|
-
| `label.colon` | boolean | `true` | Append label with colon. |
|
|
75
|
-
| `label.extra` | string | - | Extra text to display beneath the content - supports html. |
|
|
76
|
-
| `label.title` | string | - | Label title - supports html. |
|
|
77
|
-
| `label.tooltip` | string \| object | - | Help tooltip shown via an icon beside the label. A string sets the tooltip text (supports html), or an object to also customize the icon and color. Use the block's onTooltipClick event to respond to clicks on the icon. |
|
|
78
|
-
| `label.tooltip.title` | string | - | Tooltip text shown on hover - supports html. |
|
|
79
|
-
| `label.tooltip.icon` | string | `"AiOutlineQuestionCircle"` | Name of the icon to show beside the label. |
|
|
80
|
-
| `label.tooltip.color` | string | - | Color of the tooltip icon. |
|
|
81
|
-
| `label.span` | number | - | Label inline span. |
|
|
82
|
-
| `label.disabled` | boolean | `false` | Hide input label. |
|
|
83
|
-
| `label.hasFeedback` | boolean | `true` | Display feedback extra from validation, this does not disable validation. |
|
|
84
|
-
| `label.inline` | boolean | `false` | Render input and label inline. |
|
|
85
|
-
| `title` | string | - | Title to describe the input component, if no title is specified the block id is displayed - supports html. |
|
|
86
|
-
|
|
87
|
-
| Event | Event Data | Description |
|
|
88
|
-
| --- | --- | --- |
|
|
89
|
-
| `onChange` | `{ value }` | Trigger actions when the selection is changed. |
|
|
90
|
-
| `onTooltipClick` | \- | Trigger actions when the tooltip icon is clicked. |
|
|
91
|
-
|
|
92
|
-
| Key | Target |
|
|
93
|
-
| --- | --- |
|
|
94
|
-
| `/block` | Outer block wrapper (always available). |
|
|
95
|
-
| `/element` | The TagSelector tag row. |
|
|
96
|
-
| `/tag` | Each tag pill. |
|
|
97
|
-
| `/label` | The TagSelector label. |
|
|
98
|
-
| `/extra` | The TagSelector extra content. |
|
|
99
|
-
| `/feedback` | The TagSelector validation feedback. |
|
|
100
|
-
|
|
101
|
-
No slots defined.
|
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
# Auth Upgrade (BetterAuth)
|
|
2
|
-
|
|
3
|
-
This guide covers upgrading an app from the previous NextAuth-shaped `auth:` configuration to the BetterAuth-based auth system. The `auth:` block is restructured, sessions are database-backed, the session user shape changes, and roles move onto organization memberships. Every app with auth is affected.
|
|
4
|
-
|
|
5
|
-
API auth strategies (`auth.strategies`) are purely additive — an app with no strategies configured behaves exactly as before.
|
|
6
|
-
|
|
7
|
-
## Summary of breaking changes
|
|
8
|
-
|
|
9
|
-
| Change | Old | New |
|
|
10
|
-
|---|---|---|
|
|
11
|
-
| Database required | Optional (JWT-only possible) | `auth.database` required for any login method |
|
|
12
|
-
| Provider type names | `GoogleProvider`, `GitHubProvider`, ... | `Google`, `GitHub`, ... (`OpenIDConnectProvider` → `GenericOAuth`) |
|
|
13
|
-
| Callbacks / events | `auth.callbacks`, `auth.events` (JS plugins) | `auth.hooks` — bindings to `InternalApi` endpoints |
|
|
14
|
-
| Session strategy | `session.strategy: jwt` supported | Database sessions only; `maxAge` → `expiresIn` |
|
|
15
|
-
| Collection names | `users`, `accounts`, `sessions` | `user-*` convention, fixed by the adapter |
|
|
16
|
-
| `userFields` | Mapped provider claims onto the session | Removed — each mapped key has a new home (see below) |
|
|
17
|
-
| Secret | `NEXTAUTH_SECRET` env var | `auth.secret` with the `_secret` operator |
|
|
18
|
-
| Auth UI | NextAuth built-in pages, `auth.theme` | Removed — the app provides its own pages |
|
|
19
|
-
| Magic link | `EmailProvider` in `providers` | `auth.magicLink` + `auth.email` |
|
|
20
|
-
| `authPages` | `signIn`, `signOut`, `error`, `verifyRequest`, `newUser` | `signIn`, `signUp`, `error`, `forgotPassword`, `resetPassword`, `verifyEmail` |
|
|
21
|
-
| OIDC profile claims | ~20 claims copied onto the session user | Only `id`, `name`, `email`, `image`, `email_verified` |
|
|
22
|
-
| `sub` | `_user.sub` = provider's OIDC subject | Removed — `_user.id` is the internal id (a different value) |
|
|
23
|
-
| Roles | On the user record via `userFields` | App roles on `member.appRoles` (`_user.roles`); the organization's `owner`/`admin`/`member` tier on `member.role` (`_user.org_roles`) |
|
|
24
|
-
| Attributes | `_user.app_attributes`, `_user.global_attributes` | One merged `_user.attributes` bag |
|
|
25
|
-
| Signed-out request to any URL | `/404` if the page is missing, sign-in if protected | Sign-in for every URL, in apps whose unlisted ids default to protected |
|
|
26
|
-
| Missing request / endpoint id, signed out (auth'd app) | `500` (missing) vs `401` (protected) | `401` for both |
|
|
27
|
-
| 404 page | Had to serve signed-out visitors refused a protected page | Only ever serves signed-in visitors, in a protected app |
|
|
28
|
-
|
|
29
|
-
## Upgrade steps
|
|
30
|
-
|
|
31
|
-
### 1. Restructure the `auth:` block
|
|
32
|
-
|
|
33
|
-
Rename provider types (drop the `Provider` suffix; `OpenIDConnectProvider` becomes `GenericOAuth` with `discoveryUrl` and a `scopes` array). Convert the session block — `strategy` is gone, `maxAge` becomes `expiresIn`:
|
|
34
|
-
|
|
35
|
-
```yaml
|
|
36
|
-
auth:
|
|
37
|
-
session:
|
|
38
|
-
expiresIn: 604800 # was maxAge
|
|
39
|
-
updateAge: 86400
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Remove `theme`, `debug`, `advanced`, and `userFields` — they have no successor keys. `theme` styled NextAuth's built-in pages, which no longer exist. Every `userFields` mapping has a new home — see [the session user shape](#the-session-user-shape) below.
|
|
43
|
-
|
|
44
|
-
### 2. Add `auth.secret`
|
|
45
|
-
|
|
46
|
-
The `NEXTAUTH_SECRET` / `AUTH_SECRET` environment variables are no longer read. Configure the secret explicitly:
|
|
47
|
-
|
|
48
|
-
```yaml
|
|
49
|
-
auth:
|
|
50
|
-
secret:
|
|
51
|
-
_secret: BETTER_AUTH_SECRET
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Set `LOWDEFY_SECRET_BETTER_AUTH_SECRET` to a secure random string (`openssl rand -base64 32`). Reusing your old secret value is fine — sessions are invalidated by the upgrade anyway.
|
|
55
|
-
|
|
56
|
-
### 3. Pin the canonical URL with `BETTER_AUTH_URL`
|
|
57
|
-
|
|
58
|
-
Set the `BETTER_AUTH_URL` environment variable to your app's canonical origin (e.g. `https://app.example.com`). Auth builds password-reset, magic-link and email-verification links — and its CSRF origin allowlist — from this value. When it is pinned, those links and origins are fixed. When it is unset, the host is derived from each incoming request, so a spoofed `Host` / `X-Forwarded-Host` header can steer a reset email to an attacker-controlled link. Pinning it is strongly recommended for any production deployment. This replaces the old `NEXTAUTH_URL` variable.
|
|
59
|
-
|
|
60
|
-
### 4. Add `auth.database`
|
|
61
|
-
|
|
62
|
-
Sessions are database-backed, so every app with a login method needs an adapter — including apps that previously ran JWT-only with no adapter:
|
|
63
|
-
|
|
64
|
-
```yaml
|
|
65
|
-
auth:
|
|
66
|
-
database:
|
|
67
|
-
id: auth_db
|
|
68
|
-
type: MongoDBAuthAdapter
|
|
69
|
-
properties:
|
|
70
|
-
uri:
|
|
71
|
-
_secret: AUTH_DATABASE_URI
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
If you used the old `MongoDBAdapter`: the type is renamed, `databaseUri` becomes `uri`, and `options.databaseName` folds into the URI path. There is no `options.collections` mapping — collection names are fixed by the adapter.
|
|
75
|
-
|
|
76
|
-
### 5. Migrate MongoDB collections
|
|
77
|
-
|
|
78
|
-
The adapter uses the `user-*` naming convention, and the names are fixed — there is no rename escape hatch:
|
|
79
|
-
|
|
80
|
-
- `users` keeps its name.
|
|
81
|
-
- Rename `accounts` to `user-accounts`.
|
|
82
|
-
- Drop `sessions` and `verification_tokens` — the session format changes and users sign in again.
|
|
83
|
-
|
|
84
|
-
If you configured custom collection names via the old `options.collections`, rename those collections to the fixed names instead.
|
|
85
|
-
|
|
86
|
-
### 6. Migrate `EmailProvider` to `magicLink`
|
|
87
|
-
|
|
88
|
-
Magic-link login is a plugin with its own key, not a provider entry. `auth.email` no longer carries an inline SMTP transport — it references an `SMTP` connection by id. That one connection owns `from`, `replyTo`, the transport, and the delivery `filter`, and is shared by every email flow (magic links, verification, password reset, invitations):
|
|
89
|
-
|
|
90
|
-
```yaml
|
|
91
|
-
connections:
|
|
92
|
-
- id: email
|
|
93
|
-
type: SMTP
|
|
94
|
-
properties:
|
|
95
|
-
from: noreply@example.com
|
|
96
|
-
host:
|
|
97
|
-
_secret: SMTP_HOST
|
|
98
|
-
port: 587
|
|
99
|
-
auth:
|
|
100
|
-
user:
|
|
101
|
-
_secret: SMTP_USER
|
|
102
|
-
pass:
|
|
103
|
-
_secret: SMTP_PASS
|
|
104
|
-
auth:
|
|
105
|
-
magicLink:
|
|
106
|
-
enabled: true
|
|
107
|
-
expiresIn: 300
|
|
108
|
-
email:
|
|
109
|
-
connectionId: email # an SMTP connection in connections[]
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
The client signs in with the `Login` action: `params: { magicLink: true, email: ... }`.
|
|
113
|
-
|
|
114
|
-
**Auth emails obey the connection's delivery filter.** Because auth email now sends through the same `SMTP` connection your app uses for notification and routine mail, that connection's `filter` (dev catch-all, sandbox allowlist/regex, none in production) governs auth email too. This closes the old footgun where auth emails bypassed the filter and shipped to real inboxes while everything else was caught in development.
|
|
115
|
-
|
|
116
|
-
**Branded HTML by default.** The four auth emails — verification, password reset, magic link, and invitation — render branded HTML from your `app.email` theme (logo, colour, layout), not the old plain-text link. There is no per-flow theme knob; one `app.email` brand covers them all.
|
|
117
|
-
|
|
118
|
-
**Overriding a flow.** Point any flow at one of your own `notifications:` entries to replace its email with a bespoke notification template (its own copy, subject, and per-notification `theme` merged over `app.email`):
|
|
119
|
-
|
|
120
|
-
```yaml
|
|
121
|
-
auth:
|
|
122
|
-
email:
|
|
123
|
-
connectionId: email
|
|
124
|
-
templates: # all optional; unset → branded stock template
|
|
125
|
-
verifyEmail: verify-email-notification
|
|
126
|
-
resetPassword: reset-password-notification
|
|
127
|
-
magicLink: magic-link-notification
|
|
128
|
-
invitation: invite-notification
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
Each value is a notification id from `notifications:`. These `templates` keys are an email-flow namespace, separate from the `authPages` roles despite the shared `resetPassword` / `verifyEmail` spelling. An override template **must surface the framework `url` behind its call-to-action** — the engine injects `url` (and, for invitations, the accept-page link). A verify email with no verify link is a dead end, so a template that ignores `url` is a mistake.
|
|
132
|
-
|
|
133
|
-
**`invitation.send` is removed.** The invitation email is now symmetric with the other three: branded stock `InvitationEmail` by default, an override notification when configured. The old `invitation.send` auth hook — whose only purpose was branding the invite before overrides existed — is gone. If you bound it, point `auth.email.templates.invitation` at a notification instead.
|
|
134
|
-
|
|
135
|
-
### 7. Rewrite callbacks and events as hooks
|
|
136
|
-
|
|
137
|
-
`auth.callbacks` and `auth.events` are replaced by `auth.hooks`. A hook is not a plugin — it binds an auth lifecycle point to an `InternalApi` endpoint, and the endpoint's routine does the work, reading the hook payload with the `_payload` operator:
|
|
138
|
-
|
|
139
|
-
```yaml
|
|
140
|
-
auth:
|
|
141
|
-
hooks:
|
|
142
|
-
- id: audit-login
|
|
143
|
-
point: session.create.after
|
|
144
|
-
endpointId: audit-login
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Where old slots land:
|
|
148
|
-
|
|
149
|
-
| Old | New point |
|
|
150
|
-
|---|---|
|
|
151
|
-
| `createUser` event | `user.create.after` |
|
|
152
|
-
| `signIn` event | `session.create.after` |
|
|
153
|
-
| `signOut` event | `session.delete.after` |
|
|
154
|
-
| `linkAccount` event | `account.create.after` |
|
|
155
|
-
| `updateUser` event | `user.update.after` |
|
|
156
|
-
| `signIn` callback (allow / deny) | `session.create.before` — `:reject` vetoes the sign-in |
|
|
157
|
-
| `jwt` callback | None — database sessions carry no JWT; persist data via a create hook instead |
|
|
158
|
-
| `redirect` callback | None — post-login navigation is the `Login` action's `callbackUrl` |
|
|
159
|
-
| `session` callback | None — the session shape is fixed; store extra data in attributes or app collections |
|
|
160
|
-
|
|
161
|
-
Custom callback/event plugin logic moves into the endpoint's routine; anything a routine can't express goes in a custom request plugin called from the routine.
|
|
162
|
-
|
|
163
|
-
### 8. Build your auth pages
|
|
164
|
-
|
|
165
|
-
BetterAuth ships no UI. The app owns its sign-in, sign-up, and error pages, and `auth.authPages` points at them:
|
|
166
|
-
|
|
167
|
-
```yaml
|
|
168
|
-
auth:
|
|
169
|
-
authPages:
|
|
170
|
-
signIn: /login
|
|
171
|
-
signUp: /signup
|
|
172
|
-
error: /auth-error
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
The old `signOut`, `verifyRequest`, and `newUser` keys are removed. New optional keys for email flows: `signUp`, `forgotPassword`, `resetPassword`, `verifyEmail`. `twoFactor` is a further key, and unlike those it is **required** when `auth.twoFactor.enabled` is true — the build fails without it, because the engine routes the two-factor challenge to that page itself. See [Two-Factor Authentication](/two-factor).
|
|
176
|
-
|
|
177
|
-
`acceptInvitation` is a further key, naming the page that consumes an `?invitationId=` link. **Write its copy for the unauthenticated case.** Where one app sends another organization's invitations, the invitee accepts on the *sending* app, and `AcceptInvitation` sets the active organization inside its own transaction — so the accept page itself renders unauthenticated the moment the accept succeeds. That page must tell an already-signed-in invitee to sign in again **at the app they are on**, not at the app that sent the invitation: the active-organization policy runs at every session create and re-pins them here, while the production cookie prefix is shared across apps on one host, so signing in at the other app flips the shared session back the other way.
|
|
178
|
-
|
|
179
|
-
Sign-in uses the `Login` action, which dispatches by parameter — `providerId` for OAuth, `magicLink: true`, or `email` + `password`. Email/password sign-up pages use the new `SignUp` action; social and magic-link "sign-up" pages use `Login`, since those methods create the account on first sign-in. Sign-in errors now surface inline (the `Login` action returns `error.code`) rather than as a `?error=` query parameter on the error page.
|
|
180
|
-
|
|
181
|
-
### 9. Run the user-model data migration
|
|
182
|
-
|
|
183
|
-
Roles move off the user record onto organization memberships, and fused user-contact records split into `contact` / `user` / `member` records. If your app is built on the `modules-mongodb` `user_contacts` convention, this migration ships from that module — not from the core upgrade. Apps not using that convention only need the collection renames from step 4.
|
|
184
|
-
|
|
185
|
-
### 10. Sign in again
|
|
186
|
-
|
|
187
|
-
Existing sessions are invalidated — the session format changed. There is nothing to migrate; users log in once after the upgrade.
|
|
188
|
-
|
|
189
|
-
## The session user shape
|
|
190
|
-
|
|
191
|
-
The session user carries `id`, `name`, `email`, `image`, `email_verified`, `active_organization_id`, and the user record's own `profile` bag, plus three fields Lowdefy resolves from the caller's row in the active organization:
|
|
192
|
-
|
|
193
|
-
- **`roles`** — the app's own role names, read from the active membership's `appRoles` array. These are what page and endpoint `auth.roles` gates match, and the only thing they match.
|
|
194
|
-
- **`org_roles`** — the organization tier, from `member.role`: `owner`, `admin` or `member`. This is an administrative fact about the active organization, and it is **not a gate source** — `createAuthorize` reads only `roles`. An app that wants a page for organization administrators gates that page on one of its own app roles, and lets the write authority answer the administration question separately, at the step.
|
|
195
|
-
- **`attributes`** — the one merged bag of authorization inputs: `user.attributes` (global) under the active `member.attributes` (per-organization), merged per key, member wins.
|
|
196
|
-
|
|
197
|
-
**`_user.role` (singular) carries nothing.** It rides the session-user spread off the user record, and Lowdefy never writes it. It sits one character away from `roles`, so an app that gates on `_user.role` is gating on a constant — the gate either always passes or always fails, and never for the reason the author intended. Read `roles`.
|
|
198
|
-
|
|
199
|
-
There is no `impersonatedBy` field on the session user. Impersonation is not part of the platform.
|
|
200
|
-
|
|
201
|
-
Where old `_user` reads land:
|
|
202
|
-
|
|
203
|
-
| Old read | New home |
|
|
204
|
-
|---|---|
|
|
205
|
-
| `_user.sub` | `_user.id` — a **different value**: the internal id, not the provider subject. The provider subject lives on the `user-accounts` collection (`accountId`). |
|
|
206
|
-
| `_user.picture` | `_user.image` |
|
|
207
|
-
| `_user.email_verified` | `_user.email_verified` — unchanged; the pre-upgrade OIDC claim and the resolved caller field already share this name. |
|
|
208
|
-
| `_user.roles` | Unchanged read — but roles now resolve from the active organization membership's `appRoles`, granted via invitations and auth steps, not provider claims. |
|
|
209
|
-
| `_user.orgRoles` | `_user.org_roles` — the organization tier from `member.role` (`owner`, `admin`, `member`). |
|
|
210
|
-
| `_user.twoFactorEnrolled` | `_user.two_factor_enrolled` — whether the caller holds a factor satisfying `auth.twoFactor.required`. |
|
|
211
|
-
| `_user.authMethod` | `_user.auth_method` — the strategy that authenticated the caller, on strategy-resolved callers only. |
|
|
212
|
-
| `_user.app_attributes.*` | `_user.attributes.*` — per-app values live on the membership (`member.attributes`). |
|
|
213
|
-
| `_user.global_attributes.*` | `_user.attributes.*` — global values live on the user (`user.attributes`); merged per key, member wins. |
|
|
214
|
-
| Other OIDC claims (`given_name`, `phone_number`, `address`, ...) | Not in the session. Persist what you need at signup via an endpoint hook — profile data onto your contact collection, authorization inputs into attributes — and read it from there. |
|
|
215
|
-
|
|
216
|
-
## Page and API existence is no longer observable before you sign in
|
|
217
|
-
|
|
218
|
-
This is a behaviour change every app inherits, not a two-factor feature. It removes an enumeration asymmetry — a logged-out caller used to be able to tell a real-but-protected id apart from an absent one by the response they got back. That is an information leak the engine design had accepted as a documented cost of following web convention, not an access-control failure: no protected page or request was ever reachable without a session. Nothing about who can *reach* a surface changes here — only what a signed-out caller can *learn* about which surfaces exist.
|
|
219
|
-
|
|
220
|
-
**The rule.** When the caller is not authenticated, existence is never consulted. A protected app answers the sign-in page for any URL a logged-out visitor asks for — whether that URL is a real protected page or a typo that matches nothing. Existence is only resolved once there is a session to resolve it against.
|
|
221
|
-
|
|
222
|
-
**Which apps change, which don't.** Only apps whose *unlisted* page ids default to protected are affected. The `auth.pages.protected: [...]` shape (an explicit protected list, everything else public) is **untouched** — a logged-out visitor typing a wrong URL still gets `/404`, because unlisted ids there are public. Every other shape treats an unlisted id as protected, so a logged-out typo now lands on the sign-in page, then resolves to `/404` once the visitor authenticates — one extra hop for a wrong URL.
|
|
223
|
-
|
|
224
|
-
| Config | An unlisted page id is |
|
|
225
|
-
|---|---|
|
|
226
|
-
| `auth.pages.public: [...]` | protected |
|
|
227
|
-
| `auth.pages.protected: true` | protected |
|
|
228
|
-
| `auth.pages.protected: [...]` | public |
|
|
229
|
-
| neither | public |
|
|
230
|
-
|
|
231
|
-
**A known imprecision, so nobody files it as a bug.** The default the runtime reads for an unlisted id is a single build-resolved boolean, deliberately coarser than the glob-list config that produced it. In an app configured `auth.pages.public: ['docs/**']` (so unlisted ids default to protected), a signed-out visitor typing `docs/typo` gets the sign-in page rather than `/404`, even though the `docs/**` namespace was declared public. Every id that **is** in the build carries its own resolved decision, so no real page changes behaviour — only a URL matching nothing reads the coarse default. Shipping the pattern lists into the build artifact and re-matching globs per request was considered and rejected: the artifact exists precisely so the runtime reads a resolved decision and never re-derives access from patterns.
|
|
232
|
-
|
|
233
|
-
**What you can delete.** In a protected app the 404 page now answers one question — "does a page with this id exist for this signed-in caller?" — instead of three. The auth logic some apps hand-write onto their 404 page (a session check, a conditional "sign in" link for the logged-out case) is now dead code: a logged-out visitor never reaches the 404 page, so it only ever renders for someone already signed in. Delete it. The build still forces the `404` page id public, unchanged, so the page itself is always reachable.
|
|
234
|
-
|
|
235
|
-
**The API surface.** In an authenticated app a logged-out caller gets `401 Authentication required` for **every** request and endpoint id, present or absent. Two consequences for upgrading apps. A missing id used to surface as a `500`, so any error handling keyed on the status code will now see `401` where it previously saw `500`. And a typo'd `requestId` called from a logged-out browser now reads "Authentication required" rather than a not-found error — expected, given existence is no longer disclosed pre-auth. The **authenticated** case is unchanged: for a signed-in caller a missing id and a role-refused id both still surface as the same opaque "does not exist". A no-auth app is unchanged, and so is a system or routine context — the `401` rewrite is for a real session-less human on a protected surface, not for "no user" in general.
|
|
236
|
-
|
|
237
|
-
**Websockets are unchanged.** The websocket surface was already symmetric — a logged-out caller could never distinguish a present subscription id from an absent one — so it needed no change and got none. It is called out here only so a reader does not conclude the surface was overlooked.
|
|
238
|
-
|
|
239
|
-
## Administering organizations
|
|
240
|
-
|
|
241
|
-
Administering an organization means holding `admin` (or `owner`) in that organization — the `member.role` tier, on the caller's own member row there. There is no app-wide administering role, and no config key that grants administration across organizations: nothing to configure. Every auth step declares the authority it requires, and the step floor authorizes the caller against their member row in the organization the step names, so a caller who administers the team organization holds nothing in the customer organization. The steps that write the deployment-wide `user` row — `UpdateUserProfile`, `UpdateUserAttributes`, `BanUser`, `UnbanUser`, `DeleteUser`, `RevokeUserSessions` — additionally require the **target** to hold a member row in that organization, so an administrator can only reach people who are members of an organization they administer.
|
|
242
|
-
|
|
243
|
-
## Bootstrapping the first administrator
|
|
244
|
-
|
|
245
|
-
Nothing grants organization authority implicitly. On a fresh `policy: pinned` + `signup: invite-only` deployment that is a closed loop: nobody can be invited, because inviting needs `invitation: ['create']` authority that nobody holds, and nobody can sign up, because the admission gate admits only members and pending invitees. Breaking the loop is one document inserted into the `user-invitations` collection, by hand, outside the platform:
|
|
246
|
-
|
|
247
|
-
```js
|
|
248
|
-
{ _id: <uuid>, organizationId: 'team', email: 'admin@example.com',
|
|
249
|
-
role: 'owner', appRoles: [], status: 'pending',
|
|
250
|
-
inviterId: 'bootstrap', expiresAt: <future date>, createdAt: new Date() }
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
**`organizationId` is the configured slug.** Under `pinned` the organization's id *is* `auth.organizations.org`, so there is no id to look up and the same document works in every environment.
|
|
254
|
-
|
|
255
|
-
**The key is `_id`, not `id`.** The adapter maps the logical `id` to the physical `_id` inbound and back outbound, so every invitation the platform writes stores its id at `_id`. A document inserted literally as `{ id: … }` gets a driver-generated `ObjectId` `_id` plus a stray `id` field, after which the accept flow's invitation lookup never finds it — while the admission gate, which queries by email and status, happily admits the person to sign up. The result is a user who can log in, an invitation that cannot be accepted, and no error pointing at either. Use `_id`.
|
|
256
|
-
|
|
257
|
-
**The role is `owner`, not `admin`, and that is not cosmetic.** With no owner in the organization every membership guard is inert: `removeMember`'s creator protection engages only when the target holds the creator role, and `updateMemberRole`'s last-owner count only when the updater is the creator editing themselves. So a sole `admin` could demote themselves to `member`, or remove the last other `admin`, after which nobody anywhere holds `member: ['update']` or `invitation: ['create']` — and under `signup: invite-only` nobody can sign up to fix it. Seeding `owner` makes all three guards live, and costs nothing reachable: `owner`'s only statement beyond `admin`'s is `organization: ['delete']`, whose endpoint is disabled under `pinned` and has no step.
|
|
258
|
-
|
|
259
|
-
**Deliver the accept URL by hand.** The accept URL is built only inside the invitation email sender, which only the `InviteMember` step reaches, so a hand-inserted invitation sends no email. Give the nominated person this link:
|
|
260
|
-
|
|
261
|
-
```
|
|
262
|
-
{baseUrlOrigin}{basePath}{authPages.acceptInvitation}?invitationId=<the _id above>
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
**The pre-accept state looks like a failure and is not.** Under `invite-only`, an invitee holding only a pending invitation gets a session with **no** active organization, and a session with no active organization resolves unauthenticated. So the nominated person signs up successfully and lands logged out everywhere. That is the carve-out working: it admits them just far enough to accept. The link is the only way forward — the front door alone does not finish the bootstrap.
|
|
266
|
-
|
|
267
|
-
**Email verification is required before accepting.** Lowdefy sets a function-form `advanced.database.generateId`, which makes the organization plugin require a verified email for the accept-by-invitation-id action. Normal flow, not a workaround: verify the address, then open the link.
|
|
268
|
-
|
|
269
|
-
**Under `signup: open` this route does not work.** The auto-join hooks mint a `role: 'member'` member row before the accept, so accepting fails as already-a-member. There the single write is the other one: the person signs up normally, and the operator sets `member.role: 'owner'` on the row they already have. That one field is the whole grant — no second write and no denormalized copy to leave stale, because nothing writes `user.role`.
|
|
270
|
-
|
|
271
|
-
**Bootstrap is per-organization, not per-deployment.** An `admin` of the team organization holds no authority in the customer organization, so that organization's first `admin` needs its own inserted invitation. After the first one, each organization is self-sustaining: its owner can invite and promote from inside the app.
|
|
272
|
-
|
|
273
|
-
The field names above are the live camelCase names. If the `snake-case-data-fields` change has landed in your version, they are `organization_id`, `inviter_id`, `expires_at`, `created_at` and `app_roles`.
|
|
274
|
-
|
|
275
|
-
## Retired client actions
|
|
276
|
-
|
|
277
|
-
Seven client actions are gone. An app that authors one fails at build with the unknown-action-type error; each has a named replacement, except the two that do not:
|
|
278
|
-
|
|
279
|
-
| Retired action | Replacement |
|
|
280
|
-
|---|---|
|
|
281
|
-
| `ImpersonateUser` | **None.** The capability is retired. |
|
|
282
|
-
| `StopImpersonating` | **None.** The capability is retired. |
|
|
283
|
-
| `InviteMember` | The `InviteMember` step |
|
|
284
|
-
| `CancelInvitation` | The `CancelInvitation` step |
|
|
285
|
-
| `UpdateMemberRole` | The `UpdateMemberOrgRole` step (the `owner`/`admin`/`member` tier). App roles are the `UpdateMemberRoles` step. |
|
|
286
|
-
| `RemoveMember` | The `RemoveMember` step |
|
|
287
|
-
| `UpdateOrganization` | The `UpdateOrganization` step |
|
|
288
|
-
|
|
289
|
-
The rule that produced the list also tells you which actions will keep existing: **a client action is an operation that mutates the caller's own session, and nothing else.** So `SetActiveOrganization`, `AcceptInvitation` and `LeaveOrganization` remain — each acts on the caller. An app administering **other** people's memberships authors a routine instead, and that is what earns it the per-step authority floor, an explicitly named target organization, and both role tiers (`appRoles` and `orgRole`) rather than one fused `role`.
|
|
290
|
-
|
|
291
|
-
Two consequences. A `pinned` app never had these actions working in the first place — every organization route is disabled under `pinned` — so this reads as a removal only for `tenant` apps. And the step path is what makes cross-organization administration possible at all: a client action forwards no organization id, so it could only ever act on the session's active organization.
|
|
292
|
-
|
|
293
|
-
## Exposing MCP tools to assistants
|
|
294
|
-
|
|
295
|
-
New in this release: a Lowdefy app can be an **OAuth 2.1 authorization server** and expose its API endpoints as authenticated tools to MCP clients (Claude, ChatGPT, IDE assistants). There is one MCP resource at `/api/mcp`; the organization a call acts in is a token claim chosen at authorization, not a URL segment. Enable it with `auth.oauthProvider` and tag endpoints in the `mcp` block. This is entirely additive — an app with no `mcp` endpoints and no `auth.oauthProvider` is unaffected.
|
|
296
|
-
|
|
297
|
-
**One rule to internalise before you ship it: never list an MCP endpoint id in `auth.api.public`.** A single public tool suppresses the OAuth challenge for the whole route, so a newly connected assistant reports "connected" and never authenticates. The full model — the flow, the `organization_id` claim, `mcp:read` / `mcp:write` scopes, the consent and organization-picker pages, and switching or disconnecting with `RevokeMcpGrant` — is on the [MCP Server & OAuth](/mcp-oauth) page.
|
|
298
|
-
|
|
299
|
-
The `mcp.agents` config key is removed; MCP agent tools are not supported.
|
|
300
|
-
|
|
301
|
-
## Where the details now live
|
|
302
|
-
|
|
303
|
-
This guide is the migration path; the current behaviour is documented on the dedicated pages:
|
|
304
|
-
|
|
305
|
-
- [Organizations & Multi-Tenancy](/organizations) — the `pinned` / `tenant` policies and the tenant wall.
|
|
306
|
-
- [Auth Steps](/auth-steps) — the per-step authority model and every step's scope and permission.
|
|
307
|
-
- [MCP Server & OAuth](/mcp-oauth) — the authenticated MCP surface.
|
|
308
|
-
- [User Object](/user-object) — the fixed `_user` shape.
|
|
309
|
-
|
|
310
|
-
## What you don't need to change
|
|
311
|
-
|
|
312
|
-
- **Page and API authorization** — `auth.pages` and `auth.api` (`protected` / `public` / `roles`) keep their shape and semantics.
|
|
313
|
-
- **Page role lists** — existing role names keep working; roles are matched as strings exactly as before.
|
|
314
|
-
- **Provider ids** — OAuth callback URLs are still `/api/auth/callback/<id>`; keep your provider `id` values.
|
|
315
|
-
- **`dev.mockUser`** — still available in the dev server.
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# Auth Steps
|
|
2
|
-
|
|
3
|
-
Auth steps are routine steps that administer users, organizations and memberships — inviting a member, updating an app role, banning a user, creating an organization. They run **inside an `Api` or `InternalApi` endpoint routine**, not as client actions, because administering *other* people's memberships is a server operation that must be authorized against the caller, name its target organization explicitly, and be visible in a diff.
|
|
4
|
-
|
|
5
|
-
> A **client action** mutates the caller's *own* session and nothing else — `SetActiveOrganization`, `AcceptInvitation`, `LeaveOrganization`. Anything that reaches another person's membership is an auth **step** in a routine, which is what earns it the per-step authority floor below. See the [Auth Upgrade guide](/auth-upgrade#retired-client-actions) for the actions that became steps.
|
|
6
|
-
|
|
7
|
-
```yaml
|
|
8
|
-
id: invite-member
|
|
9
|
-
type: Api
|
|
10
|
-
routine:
|
|
11
|
-
- id: invite
|
|
12
|
-
type: InviteMember
|
|
13
|
-
properties:
|
|
14
|
-
organizationId:
|
|
15
|
-
_user: organization_id
|
|
16
|
-
email:
|
|
17
|
-
_payload: email
|
|
18
|
-
appRoles:
|
|
19
|
-
_payload: roles
|
|
20
|
-
- ':return':
|
|
21
|
-
_step: invite
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## The authority model
|
|
25
|
-
|
|
26
|
-
Every auth step declares, in its own code, the authority it requires — and the engine's **authority floor** enforces that declaration mechanically before the step runs. This is not optional decoration: a step with no declared authority is a build/runtime error, so an app can never expose a member mutation by forgetting a check. The reason the floor exists in one place is that most steps go adapter-direct or through BetterAuth's admin plugin, whose check reads a single deployment-wide `user.role` field and **cannot** answer "is the caller an administrator of *this* organization". For those steps, the floor is the only authorization that can express per-organization authority.
|
|
27
|
-
|
|
28
|
-
A step declares one of three **scopes**.
|
|
29
|
-
|
|
30
|
-
### `org` — authorized against the caller's membership in the target organization
|
|
31
|
-
|
|
32
|
-
The common case. The floor:
|
|
33
|
-
|
|
34
|
-
1. **Resolves the target organization** (see below).
|
|
35
|
-
2. **Finds the caller's `member` row** in that organization and checks its role against the step's required **permissions**. The caller's authority is their membership in the *target* organization — an administrator of the team organization holds nothing in the customer organization.
|
|
36
|
-
3. For steps that write the deployment-wide `user` row, additionally requires the **target user** to be a member of that organization (`targetUser`), so org authority alone can never reach a user who is not the caller's business.
|
|
37
|
-
|
|
38
|
-
Permissions map to the organization tier via BetterAuth's organization access control: `owner` and `admin` are granted the member-management permissions out of the box; `member` holds none of them. A refused caller gets a `ConfigError` naming exactly what was required.
|
|
39
|
-
|
|
40
|
-
### `system` — caller-less, trusted runs only
|
|
41
|
-
|
|
42
|
-
A `system`-scoped step (`CreateOrganization`, `ListUsers`) has no organization to be authorized in, so a caller can never satisfy it. It runs **only** where the run itself is trusted — a caller-less system routine. Mark the step `system: true`, or run it from a system context (cron, a verified webhook, an auth hook):
|
|
43
|
-
|
|
44
|
-
```yaml
|
|
45
|
-
- id: create_org
|
|
46
|
-
type: CreateOrganization
|
|
47
|
-
system: true
|
|
48
|
-
properties:
|
|
49
|
-
name:
|
|
50
|
-
_payload: name
|
|
51
|
-
slug:
|
|
52
|
-
_payload: slug
|
|
53
|
-
userId:
|
|
54
|
-
_payload: ownerUserId
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
### `caller` — acts on the caller's own records
|
|
58
|
-
|
|
59
|
-
A `caller`-scoped step (`RevokeMcpGrant`) acts only on rows the caller owns, so it needs no organization authority — but it is meaningless without a caller, and the system has no rows of its own. It requires a real caller and refuses `system: true`.
|
|
60
|
-
|
|
61
|
-
## `system: true` — the two doors to a caller-less run
|
|
62
|
-
|
|
63
|
-
The floor's caller requirement passes on either of two independent doors:
|
|
64
|
-
|
|
65
|
-
- a **run-level** system context (`context.system === true`) — a trusted, caller-less run such as a cron endpoint, an auth hook, or a verified webhook; or
|
|
66
|
-
- a **per-step** `system: true` on a single step in an otherwise user-driven run — a waiver visible in the diff, for the one privileged operation in a member-facing routine.
|
|
67
|
-
|
|
68
|
-
Either door makes the step act caller-less **as the system**, bypassing the org/permission checks. That is an explicit trust decision, not attribution — use it deliberately.
|
|
69
|
-
|
|
70
|
-
## How the target organization is resolved
|
|
71
|
-
|
|
72
|
-
For every `org`-scoped step the floor resolves the target organization once, and the step writes into the *same* one it authorized — they cannot drift. The rule:
|
|
73
|
-
|
|
74
|
-
1. An explicit **`organizationId`** property always wins. This is what a multi-organization admin routine passes, and it is what lets a session pinned to the team organization administer the customer organization.
|
|
75
|
-
2. `ListMembers` also accepts an **`organizationSlug`**, resolved to an id here.
|
|
76
|
-
3. Otherwise the **pinned** organization is the default — **but under `policy: tenant` there is no pinned organization**, so an omitted `organizationId` is a runtime error naming the fix. Under `tenant`, always pass `organizationId` explicitly.
|
|
77
|
-
|
|
78
|
-
## `targetUser` and `selfTargetExempt`
|
|
79
|
-
|
|
80
|
-
Two refinements the floor reads from a step's declaration:
|
|
81
|
-
|
|
82
|
-
- **`targetUser`** names the property holding the id of the user being acted on. Steps that write the shared `user` row declare it, so the floor can require that user to be a member of the target organization. An absent target-user property is the step's own required-property error, not the floor's.
|
|
83
|
-
- **`selfTargetExempt`** lets a caller act on **their own** row without holding org authority — this is what makes `UpdateUserProfile` a self-service "save my profile" step as well as an admin one. When the named property equals the caller's own id, the org authority check is skipped.
|
|
84
|
-
|
|
85
|
-
## Step reference
|
|
86
|
-
|
|
87
|
-
Scope, required permission, and target for every auth step. Permissions are checked against the caller's `member.role` in the target organization; `owner`/`admin` hold them by default.
|
|
88
|
-
|
|
89
|
-
| Step | Scope | Permission | Target | What it does |
|
|
90
|
-
| ---- | ----- | ---------- | ------ | ------------ |
|
|
91
|
-
| `InviteMember` | org | `invitation: [create]` | — | Invite a member by email; carries `appRoles`, `orgRole`, `attributes`, `contactId`, `profile`; `resend` refreshes. |
|
|
92
|
-
| `CancelInvitation` | org | `invitation: [cancel]` | — | Cancel a pending invitation. |
|
|
93
|
-
| `ListMembers` | org | `member: [list]` | — | List members of the organization (accepts `organizationSlug`). |
|
|
94
|
-
| `RemoveMember` | org | `member: [delete]` | — | Remove a member from the organization. |
|
|
95
|
-
| `UpdateMemberRoles` | org | `member: [update]` | — | Set the membership's **app roles** (`appRoles` array; empty clears). |
|
|
96
|
-
| `UpdateMemberOrgRole` | org | `member: [update]` | — | Set the **org tier** (`owner`/`admin`/`member`). |
|
|
97
|
-
| `UpdateMemberAttributes` | org | `member: [update]` | — | Set the membership's per-organization attributes. |
|
|
98
|
-
| `UpdateOrganization` | org | `organization: [update]` | — | Update the organization row (name, slug, metadata). |
|
|
99
|
-
| `UpdateUserProfile` | org | `user: [update]` | `userId` (self-exempt) | Update a member's display name/image (per-organization copy). Self-service save is exempt. |
|
|
100
|
-
| `UpdateUserAttributes` | org | `user: [set-attributes]` | `userId` | Set global user attributes. |
|
|
101
|
-
| `BanUser` | org | `user: [ban]` | `userId` | Ban a user. |
|
|
102
|
-
| `UnbanUser` | org | `user: [ban]` | `userId` | Lift a ban. |
|
|
103
|
-
| `DeleteUser` | org | `user: [delete]` | `userId` | Delete a user. |
|
|
104
|
-
| `RevokeUserSessions` | org | `session: [revoke]` | `userId` | Sign a user out of all sessions. |
|
|
105
|
-
| `ResetUserTwoFactor` | org | `user: [reset-two-factor]` | `userId` | Clear a user's two-factor enrolment and trust-device records. |
|
|
106
|
-
| `RevokeUserPasskeys` | org | `user: [revoke-passkeys]` | `userId` | Remove a user's passkeys (`passkeyId` to target one). |
|
|
107
|
-
| `CreateOrganization` | system | — | — | Create an organization (tenant provisioning). Needs `userId`, or `name`+`slug` for creator-less provisioning. |
|
|
108
|
-
| `ListUsers` | system | — | — | List every user in the deployment. |
|
|
109
|
-
| `RevokeMcpGrant` | caller | — | — | Revoke the calling assistant's own [MCP grant](/mcp-oauth#switching-organization-from-the-assistant). |
|
|
110
|
-
|
|
111
|
-
The two role tiers a member carries — the `owner`/`admin`/`member` org tier and the app's own role strings — are explained in [Organizations & Multi-Tenancy](/organizations#the-owner-admin-member-tier-vs-app-roles). Recovering a user who lost their second factor is a worked routine on the [Two-Factor Authentication](/two-factor#recovering-a-user-who-has-lost-their-factor) page.
|