@protobase/cli 0.66.6 → 0.66.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/build/config-bundle.js +11 -1
  2. package/dist/build/first-party-code.js +22 -0
  3. package/dist/build/plugin-build.js +3 -1
  4. package/dist/build/shell.js +3 -1
  5. package/dist/cli/package.js +1 -1
  6. package/dist/dev/entry.js +1 -1
  7. package/dist/dev/run.js +2 -1
  8. package/dist/dev/server-package.js +8 -6
  9. package/dist/doctor/built-in-models.js +26 -0
  10. package/dist/doctor/run.js +11 -4
  11. package/dist/main.js +3 -2
  12. package/dist/mobile/android-signing.js +8 -1
  13. package/dist/mobile/load-project.js +4 -1
  14. package/dist/project/cli-modules.js +30 -0
  15. package/dist/project/import-project-file.js +9 -9
  16. package/package.json +8 -7
  17. package/skills/protobase/references/features/ai.md +10 -7
  18. package/skills/protobase/references/features/build-and-run.md +1 -0
  19. package/skills/protobase/references/features/compatibility.md +6 -6
  20. package/skills/protobase/references/features/data-model.md +1 -1
  21. package/skills/protobase/references/features/email-and-public-forms.md +2 -0
  22. package/skills/protobase/references/features/events-webhooks-and-jobs.md +2 -0
  23. package/skills/protobase/references/features/files-and-images.md +1 -0
  24. package/skills/protobase/references/features/history-comments-and-live-updates.md +1 -0
  25. package/skills/protobase/references/features/look-and-language.md +1 -0
  26. package/skills/protobase/references/features/navigation.md +5 -4
  27. package/skills/protobase/references/features/sign-in-and-accounts.md +7 -0
  28. package/skills/protobase/references/features/start-a-project.md +2 -1
  29. package/skills/protobase/references/features/your-own-code.md +4 -2
  30. package/skills/protobase/references/getting-started.md +10 -2
  31. package/skills/protobase/references/guides/api-functions.md +2 -2
  32. package/skills/protobase/references/guides/mobile-app.md +2 -2
  33. package/skills/protobase/references/reference/api.md +2 -2
  34. package/skills/protobase/references/reference/app-identity.md +1 -1
  35. package/skills/protobase/references/reference/assistant.md +22 -2
  36. package/skills/protobase/references/reference/auth.md +42 -11
  37. package/skills/protobase/references/reference/cli.md +9 -5
  38. package/skills/protobase/references/reference/client.md +1 -1
  39. package/skills/protobase/references/reference/comments.md +2 -2
  40. package/skills/protobase/references/reference/custom-components.md +7 -1
  41. package/skills/protobase/references/reference/data-config.md +1 -1
  42. package/skills/protobase/references/reference/email.md +1 -1
  43. package/skills/protobase/references/reference/files.md +4 -0
  44. package/skills/protobase/references/reference/history.md +1 -1
  45. package/skills/protobase/references/reference/jobs.md +3 -1
  46. package/skills/protobase/references/reference/languages.md +2 -2
  47. package/skills/protobase/references/reference/live.md +4 -0
  48. package/skills/protobase/references/reference/mcp.md +22 -2
  49. package/skills/protobase/references/reference/plugins.md +6 -4
  50. package/skills/protobase/references/reference/sidebar.md +6 -0
  51. package/skills/protobase/references/reference/supabase-realtime.md +5 -1
  52. package/skills/protobase/references/reference/supabase-rest.md +5 -1
  53. package/skills/protobase/references/reference/supabase-storage.md +5 -1
  54. package/skills/protobase/references/reference/theme.md +1 -1
  55. package/skills/protobase/references/reference/ui-config.md +6 -2
  56. package/skills/protobase/references/reference/versioning.md +5 -2
  57. package/skills/protobase/references/reference/webhooks.md +2 -0
@@ -11,11 +11,13 @@ React components, field types, pages and handlers of your own, loaded as plugins
11
11
  - [Custom field types](../reference/plugins.md#field-types): Register how a kind of value is shown and edited, and pick it per field in a view.
12
12
  - [Custom create pages](../reference/custom-components.md#create-pages): Replace a resource's create form with your own page, such as a booking flow.
13
13
  - [Form parts](../reference/custom-components.md#form-parts): Build your own forms from the app's fields, pickers, switches, buttons and dialogs.
14
+ - [Markdown](../reference/custom-components.md#components): Show markdown in your own components, such as a chat message, as the app's markdown fields draw it.
15
+ - [Polling lists](../reference/custom-components.md#record-sections): A list your own component reads can refetch on an interval you set, to stay current.
14
16
  - [Image carousel](../reference/custom-components.md#image-carousel): Step through screenshots or a record's files in your own components by swiping or with arrows, as cards step through photos.
15
17
  - [Assistant parts](../reference/assistant.md#in-the-ui): The assistant's panel, chat messages, composer and action cards for your own components, with a frameless action card for a page that is already framed.
16
18
  - [Chat parts](../reference/custom-components.md#chat-parts): A chat between people from the app's parts: messages with their author, avatar, time and a status line, day and new-message lines, a thread that loads earlier messages, keeps the reader's place, opens on the new-message line, says when the reader has read to the end and jumps to the end when they send, and a composer of several lines with @mentions and slots for an attach button and a tray of picked files, which sends files without text and takes pasted and dropped files.
17
- - [Members' names](../reference/custom-components.md#members-names): The names and photos of the organization's members by user id, for message authors and @mentions in your own components; every member reads them, without email addresses.
18
- - [Signed-in user](../reference/custom-components.md#members-names): The signed-in person's user id and name in your own components, such as for a filter on their own messages.
19
+ - [Members' names](../reference/custom-components.md#members-names) (beta): The names and photos of the organization's members by user id, for message authors and @mentions in your own components; every member reads them, without email addresses.
20
+ - [Signed-in user](../reference/custom-components.md#signed-in-user): The signed-in person's user id and name in your own components, such as for a filter on their own messages.
19
21
  - [Action handlers](../reference/custom-components.md#action-handlers): Your own code for a named action, with the record, the API client and the action's inputs.
20
22
  - [Shell slots](../reference/custom-components.md#shell-slots): Your own components in the top bar, in a panel beside every page, or as the sidebar logo.
21
23
  - [Plugins](../reference/plugins.md#how-a-plugin-stays-working): Your React code is a plugin that the app loads at start and that keeps working across Protobase releases without changes.
@@ -61,8 +61,6 @@ Then it runs that `scaffold` in the project and prints the next steps. Without a
61
61
 
62
62
  It never writes to the database. The first of the next steps does: `bun run db:migrate` adds the `auth` schema that sign-in keeps its users in, and the `migrations` schema, where the script records the migrations it applied in `migrations.applied`. A `public.schema_migrations` another migration tool keeps is left as it is.
63
63
 
64
- On a Supabase database, which it knows by its `auth` and `storage` schemas and its `supabase_admin` role, as [`import supabase`](reference/cli.md#import-supabase) does, the `auth` schema is Supabase Auth's, and the project changes nothing in it. It leaves out `auth`, `storage` and the platform's other schemas, such as `realtime` and `supabase_migrations`, as Supabase's own tables. The project reads Supabase's users through a view over `auth.users` and keeps only its own sign-in data, in a schema of its own, `app_auth`: it points `protobase.config.ts` at the view, with [`users.keyTable`](reference/auth.md#a-view-over-another-systems-users), and writes migrations in place of those of Protobase's own user table: Better Auth's tables in `app_auth`, pointing at `auth.users`, then the view, its side table of the columns only Better Auth has, and each user's account with their Supabase password and social providers. `bun run db:migrate` applies them as the `postgres` role a Supabase connection string signs in as, and Supabase's users sign in with their own passwords, under their own ids. You can create, disable and delete users from either side, and a name changed in Supabase shows at once. <gap>A user who signs up through Supabase Auth later is a user of the project at once but cannot sign in with a password because sharing passwords with Supabase Auth is not implemented.</gap> Supabase's phone-only and anonymous users are not users of the project, since sign-in needs an email. A ban set in the project stays the project's own, while a ban set in Supabase holds in the project too.
65
-
66
64
  ```sh
67
65
  cd shop
68
66
  bun run db:migrate # the first write to the database
@@ -70,6 +68,14 @@ bun run protobase users create you@example.com --generate-password
70
68
  bun run dev # http://localhost:5173
71
69
  ```
72
70
 
71
+ #### On a Supabase database
72
+
73
+ :::caution[Beta]
74
+ A user who signs up through Supabase Auth later cannot sign in to the project with a password yet, as below.
75
+ :::
76
+
77
+ On a Supabase database, which it knows by its `auth` and `storage` schemas and its `supabase_admin` role, as [`import supabase`](reference/cli.md#import-supabase) does, the `auth` schema is Supabase Auth's, and the project changes nothing in it. It leaves out `auth`, `storage` and the platform's other schemas, such as `realtime` and `supabase_migrations`, as Supabase's own tables. The project reads Supabase's users through a view over `auth.users` and keeps only its own sign-in data, in a schema of its own, `app_auth`: it points `protobase.config.ts` at the view, with [`users.keyTable`](reference/auth.md#a-view-over-another-systems-users), and writes migrations in place of those of Protobase's own user table: Better Auth's tables in `app_auth`, pointing at `auth.users`, then the view, its side table of the columns only Better Auth has, and each user's account with their Supabase password and social providers. `bun run db:migrate` applies them as the `postgres` role a Supabase connection string signs in as, and Supabase's users sign in with their own passwords, under their own ids. You can create, disable and delete users from either side, and a name changed in Supabase shows at once. <gap>A user who signs up through Supabase Auth later is a user of the project at once but cannot sign in with a password because sharing passwords with Supabase Auth is not implemented.</gap> Supabase's phone-only and anonymous users are not users of the project, since sign-in needs an email. A ban set in the project stays the project's own, while a ban set in Supabase holds in the project too.
78
+
73
79
  ## Admin over an existing database
74
80
 
75
81
  For a Postgres database you already have, `npm create protobase <database-url>` makes the project, as [above](#on-an-existing-database). [Admin over an existing database](guides/existing-database.md) does it by hand, in a folder of your own: install the packages, scaffold the config from the tables, check it with `doctor`, add sign-in and its tables, create the first admin and run `protobase dev`.
@@ -78,6 +84,8 @@ For a Postgres database you already have, `npm create protobase <database-url>`
78
84
 
79
85
  Every page of these docs is also Markdown at its address plus `.md`: `https://docs.protobase.net/reference/cli/` is also `https://docs.protobase.net/reference/cli.md`. The code a page shows from example files is written out in it, and its live demos are left out, so a coding agent or a chat assistant reads the page whole. In a project, [`protobase skill install`](reference/cli.md#skill) gives coding agents the docs of the installed version.
80
86
 
87
+ A chat assistant starts from [`/llms.txt`](https://docs.protobase.net/llms.txt), which links every page's Markdown and the protobase agent skill of the latest release at [`/skills/protobase/SKILL.md`](https://docs.protobase.net/skills/protobase/SKILL.md), with its references beside it.
88
+
81
89
  The rest of this page works in a clone of this repository, on its example apps in `tests/apps`.
82
90
 
83
91
  ## Setup
@@ -419,9 +419,9 @@ export default defineFunction({ longQueries: true }, async (request, { db }) =>
419
419
 
420
420
  [`protobase import supabase`](../reference/cli.md#import-supabase) converts `supabase/functions` into `functions/`. A converted function keeps Supabase's shape, `defineFunction({ supabase: true }, { fetch })`, and keeps working with `@supabase/server` and supabase-js:
421
421
 
422
- - **At `/api/sb/functions/v1/<name>` too,** so `supabase.functions.invoke` callers do not change. Only a function with `supabase: true` answers there, and any other function answers `404`. There a protected function lets in a request with the app's publishable or secret key in the `apikey` header, and a user token of [`/api/sb/auth/v1`](../reference/auth.md#supabase-js-and-supabasessr) as the bearer, if any, as Supabase's `verify_jwt` does. A public function lets in anyone. The keys are those of [supabase-js's Realtime](../reference/supabase-realtime.md): without them, only public functions answer there. As on Supabase, the request's path starts at the function's name (`/<name>/more/parts`), and a CORS preflight reaches the function, which answers it.
422
+ - **At `/api/sb/functions/v1/<name>` too,** so `supabase.functions.invoke` callers do not change. Only a function with `supabase: true` answers there, and any other function answers `404`. There a protected function lets in a request with the app's publishable or secret key in the `apikey` header, and a user token of [`/api/sb/auth/v1`](../reference/auth.md#supabase-js-and-supabasessr) as the bearer, if any, as Supabase's `verify_jwt` does. A public function lets in anyone. The keys are the app's [API keys](../reference/supabase-rest.md#keys-and-roles): without them, only public functions answer there. As on Supabase, the request's path starts at the function's name (`/<name>/more/parts`), and a CORS preflight reaches the function, which answers it.
423
423
  - **Its context there** is `{ name, db, caller() }`, where `caller()` resolves to who the key and token make the request: `{ role: 'anon' }` for the publishable key alone, `{ role: 'app' }` for the secret key, or `{ role: 'authenticated', claims }`. There is no `session` and no `records`. The publishable key alone lets callers in, so give `supabase: true` only to functions written for Supabase's `verify_jwt`. Together with `roles` or `capability`, which the address cannot check, it stops startup.
424
- - **Supabase's variables:** `protobase dev` sets `SUPABASE_URL` to the app's address with `/api/sb`, where supabase-js finds it, `SUPABASE_ANON_KEY`, `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_PUBLISHABLE_KEYS` to the publishable key, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_SECRET_KEY` and `SUPABASE_SECRET_KEYS` to the secret key, `SUPABASE_JWKS_URL` to the key set of `/api/sb/auth/v1`, and `SUPABASE_DB_URL` to the database. A variable set in `.env` wins. Under `protobase serve`, set them where the host keeps its settings.
424
+ - **Supabase's variables:** `protobase dev` sets `SUPABASE_URL` to the app's address with `/api/sb`, where supabase-js finds it, `SUPABASE_ANON_KEY` and `SUPABASE_PUBLISHABLE_KEY` to the publishable key and `SUPABASE_PUBLISHABLE_KEYS` to `{"default":<publishable key>}`, `SUPABASE_SERVICE_ROLE_KEY` and `SUPABASE_SECRET_KEY` to the secret key and `SUPABASE_SECRET_KEYS` to `{"default":<secret key>}`, each only when the app has that key, `SUPABASE_JWKS_URL` to the key set of `/api/sb/auth/v1` when the app has sign-in, and `SUPABASE_DB_URL` to the database. A variable set in `.env` wins. Under `protobase serve`, set them where the host keeps its settings.
425
425
  - **`waitUntil(promise)`** from `@protobase/server` takes the place of `EdgeRuntime.waitUntil`. The response goes out at once, and the process waits for the work when it shuts down gracefully. Work still running when the host kills the process is lost. On Protobase Cloud's Starter plan an app stops 30 seconds after its last request, with 10 seconds to finish, so move longer work into a [job](../reference/jobs.md).
426
426
 
427
427
  ## What functions are not
@@ -44,7 +44,7 @@ With a mobile section, the API answers the app's origins, `capacitor://localhost
44
44
  Signing in works in the app as on the web, with nothing more to set up:
45
45
 
46
46
  - Emailed codes, passwords and the second step of two-factor authentication work. <gap>Passkeys do not work in the app yet.</gap> <gap>Sign-in with another provider does not work in the app yet.</gap>
47
- - The app calls `/api/auth` through the device's native HTTP, naming its Capacitor origin. With a mobile section, sign-in trusts the Capacitor origin a request names, for that request only, without CORS.
47
+ - The app calls `/api/auth` through the device's native HTTP, naming its Capacitor origin. With a mobile section, sign-in trusts the Capacitor origin a request names, for that request only, without CORS. A POST from a Capacitor origin must send a JSON body: any other is refused with 415, as only a page in the app sends one.
48
48
  - The session lives in the device's cookie store, so it survives reloads, updates and relaunches. On iOS, signing in or out within about a second of killing the app may be lost.
49
49
  - <gap>"Forgot password?" is not offered in the app, as the emailed link cannot lead back into it.</gap>
50
50
  - Page script cannot reach the session cookie, on iOS and Android alike: Capacitor's `CapacitorHttp`, `CapacitorCookies` and `WebView` plugins are refused to it, and so are the app's local file routes. A plugin that reads files for its JavaScript, such as `@capacitor/filesystem`, could still hand the cookie over.
@@ -118,7 +118,7 @@ Use it as the upload key with Play App Signing. Set these variables in your shel
118
118
 
119
119
  Only Gradle gets them, for the length of the build: npm, its install scripts and Capacitor never do. The build needs the Android SDK and JDK 21.
120
120
 
121
- **iOS**, on a Mac with Xcode: `--team` is your Apple Developer team id, shown in your account's membership details. Xcode signs with the certificates and profiles of the account you added in its settings. For manual signing, pass `--certificate <name>` and `--profile <name>` too. If automatic signing finds no profile for a new app, run `protobase mobile open ios`, pick the team in Signing & Capabilities and archive once from Xcode (Product, Archive), which creates the profile; then build again.
121
+ **iOS**, on a Mac with Xcode: `--team` is your Apple Developer team id, shown in your account's membership details. Xcode signs with the certificates and profiles of the account you added in its settings. For manual signing, pass `--certificate <name>` and `--profile <name>` too. <gap>Manual signing cannot use a certificate or profile name that holds `<`, `>` or `&` because writing the escaped export options is not implemented.</gap> If automatic signing finds no profile for a new app, run `protobase mobile open ios`, pick the team in Signing & Capabilities and archive once from Xcode (Product, Archive), which creates the profile; then build again.
122
122
 
123
123
  ## 7. Live updates
124
124
 
@@ -115,7 +115,7 @@ Get responses carry `permissions: { update, delete, fields }`, where `fields` ma
115
115
 
116
116
  A field declared [`.sensitive()`](data-config.md#sensitive-fields) is in `/meta` (marked `sensitive: true`), in record `permissions.fields` and in create and update bodies, but in no list, get, `:search`, `:seek`, write or batch response, and `filter`, `order_by`, `fields` and the aggregates refuse it like an unknown field. `POST /{resource}/{key}:reveal` with `{ "field": "<name>" }` returns one record's value. It needs read access to the record and the field: another tenant's record, a row outside the caller's row filter or a key that does not exist is a `404`, a field the caller cannot read or that is not sensitive a `400`. An [encrypted field](data-config.md#encrypted-fields)'s reveal decrypts its value, and an empty one reveals `null`; a value that does not decrypt is a `500` reported to the operator, without an audit event. An update that changes the key of a record with encrypted fields is a `400` `key-change-unsupported`.
117
117
 
118
- A field with [`.strongSignIn()`](data-config.md#recent-strong-sign-in) is a `403` `strong-sign-in-required`, whose problem carries `minutes`, until the caller's sign-in proved a passkey or a second step that recently. The same request reveals a resource's [computed reveals](data-config.md#computed-reveals) by name, whose value the server makes when asked.
118
+ A field with [`.strongSignIn()`](data-config.md#recent-strong-sign-in) is a `403` `strong-sign-in-required`, whose problem carries `minutes`, until the caller's sign-in proved a passkey or a second step that recently, or, for an account with neither, an [emailed confirmation code](auth.md#emailed-confirmation-code). The same request reveals a resource's [computed reveals](data-config.md#computed-reveals) by name, whose value the server makes when asked.
119
119
 
120
120
  Before answering, the server publishes an audit event to the app's audit queue, and when publishing fails the reveal fails (`500`), so no value leaves without its event. The event never holds the value:
121
121
 
@@ -235,6 +235,6 @@ export default defineConfig({
235
235
  ```
236
236
 
237
237
  - **Server:** every `500` with the request it failed, and every `504 statement-timeout` and `503 connections-busy` with the request it names, under `dev`, `serve` and the runtime, besides the default export of `hooks/error.ts` or the console. Under `serve`, the runtime and `worker`, an uncaught exception or unhandled rejection too, which then still ends the process with exit code 1.
238
- - **Admin app:** uncaught errors and the errors of components that fail to draw. `/api/meta` names the DSN to every signed-in caller, as a browser app always shows its own.
238
+ - **Admin app:** uncaught errors and the errors of components that fail to draw. `/api/meta` names the DSN to every signed-in caller, as a browser app always shows its own. Only the app's own errors are sent: `build`, `build-shell` and `build-plugin` mark every script they write as the app's, and an error none of whose stack frames is in a marked script, such as one raised by a browser extension or a script the browser injects, is dropped. Under `dev`, whose scripts are not built, every error is sent. <gap>An error raised only inside a plugin built by an earlier `build-plugin` cannot be reported because marking a plugin's scripts when the app loads them is not implemented; build the plugin again.</gap>
239
239
  - Errors only, with no performance tracing. Before an event leaves, the values of authorization headers, cookies, passwords, tokens and other credentials, in fields and in URLs, are replaced by `[Filtered]`.
240
240
  - `environment` names the deployment in the tracker; without it the tracker's default, `production`. `errorReporting: false` turns reporting off whatever the environment says. A DSN that is no `http(s)` URL with a key and a project id stops startup.
@@ -30,7 +30,7 @@ export default defineConfig({
30
30
 
31
31
  ## The icons
32
32
 
33
- `protobase.favicon.svg` is the one source of the app's icon: the tab shows it, the [web manifest](installable-app.md#the-manifest) names it, and with it the sidebar's logo draws it beside the app's name in place of Protobase's mark and wordmark. A project's own [`shell.logo`](custom-components.md#shell-slots) still wins.
33
+ `protobase.favicon.svg` is the one source of the app's icon: the tab shows it, the [web manifest](installable-app.md#the-manifest) names it, and with it the sidebar's logo draws it in place of Protobase's mark, beside the app's name. A project's own [`shell.logo`](custom-components.md#shell-slots) still wins.
34
34
 
35
35
  Some platforms take no SVG, so a project may put PNGs of the same icon beside it, each square at its size:
36
36
 
@@ -4,6 +4,10 @@
4
4
 
5
5
  The chat beside the admin, who sees it, and its built-in backend on OpenRouter or another OpenAI-compatible endpoint.
6
6
 
7
+ :::caution[Beta]
8
+ A user cannot keep several conversations yet: each has one, shared by their tabs, as [the app's own tools](#the-apps-own-tools) says.
9
+ :::
10
+
7
11
  The admin app can show an assistant: an **Assistant** button at the end of the top bar opens a chat beside the page. The app only draws what a backend sends and sends back what the user types and clicks; the backend decides everything else. Protobase ships one backend, built in, which answers questions about the app, reads its data and proposes changes the user approves. Another backend can take its place by URL.
8
12
 
9
13
  ## Who sees it
@@ -66,6 +70,10 @@ Making the query role needs `CREATEROLE` once, for the role that applies the mig
66
70
 
67
71
  ### The app's own tools
68
72
 
73
+ :::caution[Beta]
74
+ A user cannot keep several conversations yet: each has one, shared by their tabs, as below.
75
+ :::
76
+
69
77
  `assistant/tools.ts` adds tools of the app's own, which the built-in backend offers after its two: its default export is an array of tools, found by that path when the app starts. A tool is `defineTool({ name, description, parameters, run })` from `@protobase/server`: `parameters` is the JSON Schema of its arguments, and `run(args, context)` returns the text the model reads. A name must be 1 to 64 letters, digits, `_` or `-`, and a name taken twice stops the server at startup. A backend elsewhere has its own tools, so the file does nothing there, nor with `assistant: false`. A tool's `annotations` are MCP's tool hints (`readOnlyHint`, `destructiveHint`, ...), which the dock ignores. The file serves the dock only, not the app's MCP endpoint: tools for AI clients over MCP go in `mcp/tools.ts`, which can export these too (see [The app's own tools](mcp.md#the-apps-own-tools) on MCP).
70
78
 
71
79
  `context` holds the user's `session`, `locale` (the language they read, `'en'` or `'nl'`, for the texts a tool shows them such as its approvals; set over MCP, absent in the dock), `show(part)` to add a part to the answer, `approve(request)` for an approval card, `conversation` (the dock's, absent over MCP), `resources()` (the resources the user may read, restricted to their fields, with their views), and `records`: `list(resource, params)` with the REST API's list parameters, `aggregate(resource, { group_by, filter?, limit?, label? })` with its counts per value as `:aggregate` gives them, `get(resource, key)`, `create(resource, data)`, `update(resource, key, data, { etag? })` (without an ETag it updates any version), `delete(resource, key, { etag? })` (soft on a resource with `.softDelete()`) and `softDeleteField(resource)` (the field a delete sets, or `undefined` when a delete removes the record) on the app's resources **as the user**, through their access rules, tenant scope, validation and write hooks, as in the REST API. `get`, `create`, `update` and `delete` each answer `{ record, etag }`, and `list` a page of records. `batch(operations)` runs the operations of a REST [batch write](api.md#batch-writes) in one transaction and answers its `results`; a refusal or a missing record rejects with an `HttpProblem`, whose `detail` a tool can return to the model as text, such as with `.catch(refused)`: `refused` from `@protobase/server` turns a refusal into `{ "error": <slug>, "message": <detail> }` and throws anything else again.
@@ -108,13 +116,25 @@ export const TaskProposal = ({ taskId }: { taskId: number }) => {
108
116
 
109
117
  The tool and its component live apart, joined by the name, since the server config cannot import `@protobase/ui` and the browser must not get server code; put shared prop types in a file both import with `import type`. Whoever may update the task decides on it, now or next month, from the chat or the task's own page, and a write hook can stamp who did from `event.user`; when two decide at once, the ETag makes the second write fail and the widget shows the first decision. The chat is not told: the model learns of the change by reading the record again, which the description asks it to do.
110
118
 
111
- Each message carries the page the user is on, such as `/orders/42`, which the model reads with it.
119
+ Each message carries the page the user is on, such as `/orders/42`, which the model reads with it. A message is at most 4,000 characters, and a turn takes at most 8 steps of tool calls, after which the answer ends with `Stopped after 8 steps.`
112
120
 
113
121
  <gap>There is one conversation per user, shared by their tabs.</gap> The server keeps it as a file, `<chats>/<user>/chat.jsonl` (see `PROTOBASE_ASSISTANT_CHATS`), with the chat and everything the model read, so a restart keeps it and the app only ever sends the new message. The directory is made on the first message and must be writable: in a container, point it at a volume. With several instances, send each user's requests to the same one.
114
122
 
115
123
  ## Another backend
116
124
 
117
- A backend elsewhere replaces the built-in one through `PROTOBASE_ASSISTANT_URL`. It receives the user's API token as `Authorization: Bearer <token>`, verifies it against the app's key set (`/api/auth/jwks`), and allows the app's origin with CORS. It can be built from the building blocks `@protobase/server` exports (the protocol's routes, the turn loop with tool calling, tools, approvals, the query tools and a conversation store interface to keep chats elsewhere, such as in a database) with its own prompt and tools; the built-in backend's source, `packages/server/src/assistant/built-in-assistant.ts`, is the reference.
125
+ A backend elsewhere replaces the built-in one through `PROTOBASE_ASSISTANT_URL`. It receives the user's API token as `Authorization: Bearer <token>`, verifies it against the app's key set (`/api/auth/jwks`), and allows the app's origin with CORS. It can be built from the building blocks `@protobase/server` exports (the protocol's routes, `assistantProtocolRoutes`, the turn loop with tool calling, `runTurn`, tools, approvals, the query tools, `withCacheBreakpoints`, which marks a chat's messages for prompt caching, and `createConversations` over a conversation store, `fileConversationStore` or one of your own that keeps chats elsewhere, such as in a database) with its own prompt and tools; the built-in backend's source, `packages/server/src/assistant/built-in-assistant.ts`, is the reference.
126
+
127
+ ### The protocol
128
+
129
+ The dock and a backend speak three requests on the backend's URL, each with the user's bearer token:
130
+
131
+ | Request | What it does |
132
+ | --- | --- |
133
+ | `GET <url>/events` | Server-sent events, each a JSON `AssistantEvent`: `state` replaces the whole chat and always comes first on a connection, `message` adds a message or replaces the one with its id, `part` adds a part to a message or replaces the one with its id, and `patch` changes `replying`, `status` or the composer's `placeholder`. |
134
+ | `POST <url>/messages` | `{ text, page? }`: what the user wrote, and the page they are on. The built-in backend answers `202` and replies with events, or `409` while it still answers the last message. |
135
+ | `POST <url>/actions` | `{ partId, actionId }`: the user clicked a button of a card. |
136
+
137
+ The types are exported from `@protobase/schema` (`AssistantEvent`, `AssistantState`, `AssistantMessage`, `AssistantPart` and its text, table, card and widget parts, `AssistantMessageRequest`, `AssistantActionRequest`). `createAssistantClient({ url, token })` from `@protobase/client`, where `token` is a function that gives the user's token, follows the event stream, reconnecting by itself, and sends the two requests; the dock uses it.
118
138
 
119
139
  ## Widget parts
120
140
 
@@ -262,15 +262,19 @@ The signed-in person changes their own name and password, and signs out their ot
262
262
  | Client | Endpoint |
263
263
  | --- | --- |
264
264
  | `updateName(name)` | `POST /api/auth/update-user` with `{ name }` |
265
- | `changePassword(current, next, signOutOthers)` | `POST /api/auth/change-password` with `{ currentPassword, newPassword, revokeOtherSessions }`, for an account that has a password (a wrong one is `INVALID_PASSWORD`) |
265
+ | `changePassword(current, next)` | `POST /api/auth/change-password` with `{ currentPassword, newPassword }`, for an account that has a password (a wrong one is `INVALID_PASSWORD`) |
266
266
  | `setPassword(next)` | `POST /api/auth/account/set-password` with `{ newPassword }`, for an account without a password, such as one that signs in with [emailed codes](#emailed-sign-in-codes) or [GitHub](#sign-in-with-github); one that has a password already is `PASSWORD_ALREADY_SET` |
267
267
  | `sessions()` | `GET /api/auth/list-sessions`: the devices signed in now, each `{ token, current, userAgent?, ipAddress?, createdAt, updatedAt, expiresAt, staff? }`, with `current` for this one and `staff` for a [staff session](#staff-sign-in) |
268
268
  | `revokeSession(token)` | `POST /api/auth/revoke-session` with `{ token }`: signs out that device |
269
269
  | `revokeOtherSessions()` | `POST /api/auth/revoke-other-sessions`: signs out every device but this one |
270
270
 
271
- Signing out a device, or a new password with `signOutOthers`, stops that device's API token at its next request (see [When a token stops working](#when-a-token-stops-working)); this device stays signed in. A first password with `setPassword` signs out no device and disconnects no [MCP client](#oauth-for-mcp-clients); it needs 12 characters like any other, is limited to 5 per minute and client, and is refused while the [sign-in policy](#sign-in-policy) turns passwords off (`403`). A [staff session](#staff-sign-in) cannot change or set the password or sign out the other devices (`STAFF_CANNOT_CHANGE_SIGN_IN`).
271
+ Signing out a device stops that device's API token at its next request (see [When a token stops working](#when-a-token-stops-working)); this device stays signed in. A password change always signs out every other device, whatever `revokeOtherSessions` says, and disconnects the account's [MCP clients](#oauth-for-mcp-clients); this device gets a new session and stays signed in. A first password with `setPassword` signs out no device and disconnects no [MCP client](#oauth-for-mcp-clients); it needs 12 characters like any other, is limited to 5 per minute and client, and is refused while the [sign-in policy](#sign-in-policy) turns passwords off (`403`). A [staff session](#staff-sign-in) cannot change or set the password or sign out the other devices (`STAFF_CANNOT_CHANGE_SIGN_IN`).
272
272
 
273
- On the [account page](#account-page), the `Password` block does both: with a password, it asks for the current one, the new one twice, and whether to sign out the other devices; without one, it sets the first, and nobody is signed out. While the sign-in policy turns passwords off, or in a staff session, it only says so.
273
+ When the app has mail, the account's address is emailed that its password changed, with a link to choose a new one if it was not them: after a change, a first password, a [reset](#password-reset), an admin setting it, and a new password through the [endpoints for supabase-js](#supabase-js-and-supabasessr).
274
+
275
+ Checks of the signed-in account's current password are limited to 5 a minute for each account, on top of the limit for each client: changing the password, deleting the account, turning [two-factor authentication](#two-factor-authentication) on or off, its authenticator link and new backup codes, and `POST /api/auth/verify-password`. Every check counts, a right one too; one over the limit is refused with `429` (`TOO_MANY_PASSWORD_CHECKS`, with `X-Retry-After` in seconds), so a stolen session cannot guess the password from many addresses. <gap>The current-password limit cannot be shared between server instances, each of which counts on its own, because a shared store for it is not implemented.</gap>
276
+
277
+ On the [account page](#account-page), the `Password` block does both: with a password, it asks for the current one and the new one twice, and says the other devices are signed out; without one, it sets the first, and nobody is signed out. While the sign-in policy turns passwords off, or in a staff session, it only says so.
274
278
 
275
279
  The [account page](#account-page)'s Devices tab lists the sessions with this one first and marked, a Sign out on each other one and Sign out of all other devices. It names each by its browser and system from the user agent, and a staff session by the operator alone, such as "Protobase staff" with when it signed in and until when, never the staff member.
276
280
 
@@ -284,7 +288,7 @@ auth: { accountDeletion: false }
284
288
 
285
289
  It is Better Auth's own deletion, `POST /api/auth/delete-user` with `{ password }`, which the client has as `session.deleteAccount(password?)` and `GET /api/auth/status` offers as `"accountDeletion": true`. It deletes the user with their sessions, sign-in methods and linked accounts, as an admin's [removal](#users-page) does, along with what the app keeps of them elsewhere, such as their [devices for push](../guides/push-notifications.md), and their [MCP clients](#oauth-for-mcp-clients)' grants; the app's own records that point at the user stay. This device is signed out, and the address can no longer sign in.
286
290
 
287
- - Without a password, as for someone who signs in with [emailed codes](#emailed-sign-in-codes) or a passkey, the session must have signed in within the last day, else it is `SESSION_EXPIRED`, which the page shows as "sign out and sign in again first". A wrong password is `INVALID_PASSWORD`.
291
+ - Without a password, as for someone who signs in with [emailed codes](#emailed-sign-in-codes) or a passkey, a session that signed in more than a day ago must [confirm it's them](#confirming-an-account-change) first, else it is `403 STRONG_SIGN_IN_REQUIRED`; the dialog asks for it in place. A wrong password is `INVALID_PASSWORD`.
288
292
  - The last active admin cannot delete themselves (`400 LAST_ADMIN`), nor can an account in `PROTOBASE_LOCKED_ACCOUNTS` (`403 ACCOUNT_LOCKED`); nothing is deleted then.
289
293
  - A [staff session](#staff-sign-in) cannot delete the account (`STAFF_CANNOT_CHANGE_SIGN_IN`).
290
294
  - With `accountDeletion: false` the endpoint answers `404` and the page offers nothing. Better Auth's emailed confirmation link (`/delete-user/callback`) is always off.
@@ -321,6 +325,8 @@ With mail configured, the account page's Profile tab offers "Change email": the
321
325
 
322
326
  With mail configured, the sign-in page offers "Email me a sign-in code": the person enters their address, gets a 6-digit code, and signs in with it instead of a password. It is Better Auth's [email OTP](https://www.better-auth.com/docs/plugins/email-otp) plugin: `POST /api/auth/email-otp/send-verification-otp` with `{ email, type: "sign-in" }`, then `POST /api/auth/sign-in/email-otp` with `{ email, otp }`.
323
327
 
328
+ The code goes in a box per digit, which takes a pasted code and the system's code autofill, and is checked as soon as its last digit is in; **Send a new code** waits 30 seconds after each code that went out. The [sign-up](#open-sign-up) code page works the same; a new address's code is checked once full too, and a two-factor code goes in the same boxes.
329
+
324
330
  By default the page leads with the password and offers the code below it. For an app whose people mostly never set a password, `signInFirst: 'emailCode'` leads with the code instead: the page asks for the address and "Email me a sign-in code", and the password field, with "Forgot password?", waits behind "Use a password". It applies while the [sign-in policy](#sign-in-policy) leaves both on; `/api/auth/status` then answers `signInFirst: "emailCode"`.
325
331
 
326
332
  ```ts
@@ -351,6 +357,7 @@ The sign-in page then offers "Create an account", and so do [sign-in prompts](ui
351
357
  - The password follows the [sign-in policy](#sign-in-policy) as for an invitation: none while passwords are off, optional while emailed codes are on, 12 characters or more. What the policy requires, such as two-factor authentication, is set up after the first sign-in, as for every account.
352
358
  - The three requests are limited per minute and client like sign-in (`signInPerMinute`, default 5), and refused (`NO_ADMIN_YET`) while the store has no user.
353
359
  - The `onUserCreated` [hook](#hooks) runs for every account made this way.
360
+ - `createAuthSession` from `@protobase/client` has the three steps as `sendSignUpCode(email)`, `verifySignUpCode(email, code)`, which resolves to the ticket, and `signUp({ ticket, name, password? })`, which resolves to the signed-in user.
354
361
 
355
362
  ## Passkeys
356
363
 
@@ -362,7 +369,7 @@ People add passkeys (Face ID, Touch ID, Windows Hello, a phone or a security key
362
369
  - On an app with several origins, every passkey works on each of them through [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) (Chrome and Edge 128, Safari 18, Firefox 152; at most 5 registrable domains).
363
370
  - A browser asks for the passkeys of one RP ID at a time. When people have passkeys for more than one of the app's hosts, `GET /api/auth/status` lists them as `passkeyDomains`, the passkey domain first, and the sign-in page adds "Use a passkey made for {host}" for each of the others. `GET /api/auth/passkey/generate-authenticate-options?rpId=<host>` asks for one of them.
364
371
  - A passkey sign-in asks for no second step: the passkey is something you have and something you are or know.
365
- - Adding a passkey needs a session started in the last day; after that, the page asks to sign in again first.
372
+ - A session of any age adds a first passkey. Adding another to an account with a passkey or a [second step](#two-factor-authentication), from a session that signed in more than a day ago, asks to [confirm it's them](#confirming-an-account-change) first, and the account page asks for it in place.
366
373
  - Passkeys can be renamed and removed on the account page; removing one here leaves it on the device.
367
374
 
368
375
  ## Two-factor authentication
@@ -382,7 +389,27 @@ Some values ask for a sign-in that proved a passkey or a second step not long ag
382
389
 
383
390
  The app's authenticator gives a token of the person's own session `signIn.strongAt()`, which reads that time when a request needs it, so other requests do no extra lookup. An app with its own authenticator (`auth/authenticate.ts`) sets `signIn: { staff: false, strongAt }` on the sessions it returns, `strongAt` resolving to a `Date` or `undefined`; without it, such values are refused.
384
391
 
385
- Confirming it's them later counts from then: a passkey signs the browser in anew, and a code from the authenticator app, or a backup code, confirms the session it has. `createAuthSession` from `@protobase/client` does both, with `signInWithPasskey()` and `verifyTwoFactor({ method: 'totp', code })`.
392
+ Confirming it's them later counts from then: a passkey signs the browser in anew, and a code from the authenticator app, a backup code, or the emailed code of an account whose second step is email (`/two-factor/send-otp`, then `verify-otp`), confirms the session it has. `createAuthSession` from `@protobase/client` does both, with `signInWithPasskey()` and `verifyTwoFactor({ method: 'totp', code })`.
393
+
394
+ ### Emailed confirmation code
395
+
396
+ An account with neither a passkey nor a second step has nothing stronger to confirm with, so when the app has mail it confirms with a code emailed on request instead, and the record page and the account page offer it in place: "Email me a code", then the code from the email. `POST /api/auth/account/confirmation-code/send` emails a new code to the signed-in person, and `POST /api/auth/account/confirmation-code/verify` with `{ code }` checks it and notes the strong sign-in for that session, as a second step does. The client has them as `session.account.sendConfirmationCode()` and `confirmWithCode(code)`.
397
+
398
+ - It is a code of its own kind: it never signs anyone in, and a sign-in code never confirms.
399
+ - It works only in the session that asked for it, of the user that asked, and only through the session cookie: an [MCP client](#oauth-for-mcp-clients) or an app's token cannot ask for one or use it. A [staff session](#staff-sign-in) cannot either (`STAFF_CANNOT_CHANGE_SIGN_IN`).
400
+ - It works for 10 minutes and once. A new one ends the one before, and three wrong codes end it (`TOO_MANY_ATTEMPTS`); asking and checking are each limited to 5 a minute and client.
401
+ - When the email cannot be sent, the request fails and no code works.
402
+ - An account with a passkey or a second step is refused (`403 CONFIRMATION_CODE_NOT_OFFERED`) and keeps confirming with those.
403
+
404
+ ### Confirming an account change
405
+
406
+ Better Auth's own check of a fresh session, which could only be met by signing out and in again, is off (`freshAge: 0`). In its place, a session that signed in more than a day ago confirms it's them in place, as above, before a change that needs it, else the change is `403 STRONG_SIGN_IN_REQUIRED`; the confirmation counts for 15 minutes:
407
+
408
+ - adding a passkey to an account that has a passkey or a second step (a first passkey needs none);
409
+ - unlinking a sign-in provider (`/unlink-account`);
410
+ - [deleting the account](#deleting-the-account) without a password.
411
+
412
+ Listing the account's devices (`/list-sessions`) needs no confirmation.
386
413
 
387
414
  ## Sign-in policy
388
415
 
@@ -469,7 +496,7 @@ auth: { operator: false }
469
496
 
470
497
  ## Sign-in with GitHub
471
498
 
472
- Projects have no sign-in provider of their own by default (a platform can add [one](#platform-sign-in-provider)). `auth.socialProviders` adds Better Auth's [social providers](https://www.better-auth.com/docs/authentication/github), and the sign-in page shows "Continue with GitHub" when `github` is one:
499
+ Projects have no sign-in provider of their own by default (a platform can add [one](#platform-sign-in-provider)). `auth.socialProviders` adds Better Auth's [social providers](https://www.better-auth.com/docs/authentication/github), and the sign-in page shows "Continue with {name}" for each, such as "Continue with GitHub" with GitHub's mark for `github`:
473
500
 
474
501
  ```ts
475
502
  export default defineConfig(({ env }) => ({
@@ -618,7 +645,7 @@ It is Better Auth's organization plugin, with its tables named in snake_case (`o
618
645
 
619
646
  **Deleting** an organization (Better Auth's `delete`, owners only) is refused with `409 ORGANIZATION_HAS_RECORDS` while a `.tenant` resource has rows in it; `deleteRecords: 'cascade'` leaves it to the project's foreign keys. The `beforeOrganizationDeleted` [hook](#hooks) may refuse too.
620
647
 
621
- **Ids.** The `generateOrganizationId` [hook](#hooks) makes a new organization's id, by default a UUID; `.tenant` columns hold it, so `text` or `uuid`. An app whose tenant columns are integers takes the next value of its own organizations sequence instead, and adds its own row in `onOrganizationCreated`, as the ERP example does (`tests/apps/erp/auth/hooks.ts`). An app that ran as one organization keeps its tenant value: `protobase organizations create "Acme" --owner you@example.com --id 1 --members all` makes that organization, with every user in it: superusers as owners, everyone else as a member, their global roles moved onto the membership.
648
+ **Ids.** The `generateOrganizationId` [hook](#hooks) makes a new organization's id, by default a UUID; `.tenant` columns hold it, so `text` or `uuid`. An app whose tenant columns are integers takes the next value of its own organizations sequence instead, and adds its own row in `onOrganizationCreated`, as the ERP example does (`tests/apps/erp/auth/hooks.ts`). An app that ran as one organization keeps its tenant value: `protobase organizations create "Acme" --owner you@example.com --id 1 --members all` makes that organization, with every user in it: superusers as owners, everyone else as a member, their global roles moved onto the membership. The first user `createUser` makes also becomes the owner of every organization without members, such as those a seed made before anyone could own them.
622
649
 
623
650
  **On the host.** The [`protobase organizations`](cli.md#organizations) commands call these functions of `@protobase/server`, which a script of the project's own, such as a seed, calls with the sign-in of [`loadProjectAuth`](#first-run). The host acts as a superuser, so it may give any role; `organization` is a slug or an id:
624
651
 
@@ -697,6 +724,10 @@ Their tokens are verified against that app's keys at `<issuer>/api/auth/jwks`; a
697
724
 
698
725
  ## supabase-js and @supabase/ssr
699
726
 
727
+ :::caution[Beta]
728
+ MFA, passkeys, SSO, phone and anonymous sign-in, the admin API and changing the address are not offered yet, as the list below says.
729
+ :::
730
+
700
731
  An app moving from Supabase keeps its `supabase.auth` calls and its `@supabase/ssr` session checks: the app serves GoTrue's endpoints at `/api/sb/auth/v1`, over the same Better Auth sign-in, so `createClient(<the app's origin>/api/sb, <publishable key>)` signs in, refreshes, reads the user and signs out as on Supabase.
701
732
 
702
733
  - `signInWithPassword`, `signInWithOtp` then `verifyOtp({ email, token, type: 'email' })`, `signInWithOAuth` with one of the app's [sign-in providers](#sign-in-with-github) (PKCE, as `@supabase/ssr` uses, or the implicit flow), `refreshSession`, `getUser`, `getClaims`, `updateUser({ password, data })` and `signOut` work. Each goes through Better Auth's own endpoint, with its rate limits and the [sign-in policy](#sign-in-policy).
@@ -704,7 +735,7 @@ An app moving from Supabase keeps its `supabase.auth` calls and its `@supabase/s
704
735
  - The sign-in must start at `/api/sb/auth/v1/authorize` in the same browser: `/api/sb/auth/v1/callback` hands a code or a session only at the end of a provider sign-in started there.
705
736
  - The browser keeps no Protobase session afterwards: the app holds its own.
706
737
  - A wildcard pattern in `auth.trustedOrigins` still trusts every host it matches with the sign-in code, so list only hosts you control.
707
- - Addresses are proved with the emailed 6-digit code, never a link. `signUp({ email, password, options: { data } })` needs [open sign-up](#open-sign-up) and mails a sign-up code; `verifyOtp({ email, token, type: 'signup' })` makes the account, with `data` in the [user metadata column](#an-existing-users-table), and signs in. Until then there is no account and `signUp` answers no user. `resetPasswordForEmail` mails a sign-in code; `verifyOtp` with `type: 'recovery'` signs in, and `updateUser({ password })` sets the new password.
738
+ - Addresses are proved with the emailed 6-digit code, never a link. `signUp({ email, password, options: { data } })` needs [open sign-up](#open-sign-up) and mails a sign-up code; `verifyOtp({ email, token, type: 'signup' })` makes the account, with `data` in the [user metadata column](#an-existing-users-table), and signs in. Until then there is no account and `signUp` answers no user. `resetPasswordForEmail` mails a sign-in code; `verifyOtp` with `type: 'recovery'` signs in, and `updateUser({ password })` sets the new password. Every new password through `updateUser` signs out the user's other sessions and [MCP clients](#oauth-for-mcp-clients), and emails the address when the app has mail.
708
739
  - The access token is ES256, valid for an hour, with Supabase's claims: `sub`, `role: authenticated`, `aud: authenticated`, `email`, `app_metadata`, `user_metadata`, `aal`, `session_id`, issued by `<url>/api/sb/auth/v1`. Its keys are at `/api/sb/auth/v1/.well-known/jwks.json`. It is apart from the EdDSA token of [Tokens and roles](#tokens-and-roles): neither is accepted for the other.
709
740
  - The refresh token is the Better Auth session's token. Each refresh gives the session a new one, so a refresh token works once, and signing out, a ban or a revoked session ends it.
710
741
  - `email_confirmed_at` is the time the address was confirmed, in the user table's `email_confirmed_at` column. An account verified before that column existed has none.
@@ -747,9 +778,9 @@ auth: { mcp: { path: '/api/mcp', connectPage: '/-/connect' } }
747
778
  - The scopes are `mcp` and `offline_access`, which is added when a client leaves it out. Access tokens last 15 minutes, carry the MCP resource as their audience and `typ: at+jwt`, and work for MCP only: the app's API refuses them, and the MCP endpoint refuses the app's own tokens. Refresh tokens roll for 30 days. The MCP endpoint verifies them, and checks on every request that the person still allows the client, still exists unbanned and still holds each role the token carries, so a disconnected client, a ban, a lowered role or a deleted user stops its access tokens at the next request rather than at their expiry; a lookup that fails refuses the request. A client that binds its tokens to a key (DPoP) sends a proof with each, checked against the resource's URL.
748
779
  - A grant keeps the role and organization the person worked in when they allowed the client. A refresh is refused once they no longer hold that role or may no longer enter that organization, once the [sign-in policy](#sign-in-policy) requires something they have not set up, and while they are banned; access tokens already issued last out their 15 minutes. A grant made without a role is refused.
749
780
  - Someone who has not set up what the sign-in policy requires is sent to the setup page first. Staff signed in as a person cannot allow a client, nor disconnect one. An account whose address is not verified cannot connect a client until it signs in once with an emailed code: a provider sign-up whose provider did not vouch for the address, or a password account created before Protobase created accounts verified. With password-only sign-in, or without mail, it cannot.
750
- - The connect page shows the client's name as the client's own claim, who published it for a client with an https id, where the code goes (a website by its host, an app on this computer by its port, or an app on the device by its whole address), and the signed-in address. It cannot be used inside another site's frame, and Allow takes a click only once the page has been in front for half a second. `protobase serve` and `protobase dev` send `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` with every page of the UI; a host that serves `public/` itself should send them too.
781
+ - The connect page shows the client's name as the client's own claim, who published it for a client with an https id, where the code goes (a website by its host, an app on this computer by its port, or an app on the device by its whole address), and the signed-in address. It cannot be used inside another site's frame, and Allow takes a click only once the page has been in front for half a second. `protobase serve` and `protobase dev` send `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` with every page of the UI; a host that serves `public/` itself should send them too. A host's own consent screen arms its Allow button the same way with the guard `@protobase/ui` exports (see [the plugin API](plugins.md#api)).
751
782
  - The account page lists the clients a person allowed, under Connected apps, and disconnects any one: its consent is forgotten and its tokens revoked, access tokens already issued included, so it has to ask again. A client published at a URL has one id for everyone, so two copies of it allowed on one account with the same role show as one entry, and disconnecting it disconnects both.
752
- - Signing out everywhere, signing out the other devices, a password change that signs out the other devices, a password reset, an admin ending the person's sessions, banning them, setting their password or marking their address unverified, and `protobase users disable` disconnect every client and forget which were allowed, so each has to ask again; enabling a disabled user brings none back. Through the [endpoints for supabase-js](#supabase-js-and-supabasessr) the same holds for signing out with the `global` or `others` scope and for every new password set with `PUT /user`, since a password reset ends there too. Signing out of one browser, or a session expiring, disconnects nothing.
783
+ - Signing out everywhere, signing out the other devices, a password change, a password reset, an admin ending the person's sessions, banning them, setting their password or marking their address unverified, and `protobase users disable` disconnect every client and forget which were allowed, so each has to ask again; enabling a disabled user brings none back. Through the [endpoints for supabase-js](#supabase-js-and-supabasessr) the same holds for signing out with the `global` or `others` scope and for every new password set with `PUT /user`, since a password reset ends there too. Signing out of one browser, or a session expiring, disconnects nothing.
753
784
  - A refresh token presented again after it was rotated, outside the provider's 30-second window for a retried request, is taken for stolen: that client is disconnected for the person, its access tokens stop at the next request, and it has to ask again. Their other clients keep working.
754
785
 
755
786
  ## Several origins
@@ -107,7 +107,7 @@ protobase doctor [connection string | --env VAR] [--config <dir>]
107
107
 
108
108
  The URL comes as for [`scaffold`](#scaffold): the connection string, the variable `--env` names, or `DATABASE_URL`, each also read from the nearest `.env`.
109
109
 
110
- Imports `config/index.ts` and the views of `config/*/ui.ts`, as the config module has them by convention, and checks every exported resource against the database:
110
+ Imports `config/index.ts` and the views of `config/*/ui.ts`, as the config module has them by convention, and checks every exported resource against the database, with the resources Protobase adds by what `protobase.config.ts` (read with `command: 'tool'`) and the resources enable: the [webhook](webhooks.md#subscriptions-in-the-admin-app) ones of `webhooks`, Failed jobs, [Activity](history.md) and [Comments](comments.md). The tables [`migration`](#migration) writes for those have every index their reads need, so after the project's migrations they pass:
111
111
 
112
112
  - error: missing table or column, field type that does not fit the column (a file field needs a text column), table without a usable key, configured key not backed by a unique index, `.openExisting()` fields that are not the columns of a unique index, relation to a resource the config does not declare
113
113
  - error: a read the [scan guard](api.md#safety) refuses, because no index serves a column it filters, sorts, searches or counts by. Doctor reads each table's indexes from the catalog, as the server does, and judges with the scan guard's own rule every read the API takes and the app makes: a filter on each `.filterable()` field, a sort by each `.sortable()` one, `search()` over the search fields, a role's own records (`.owner()`) and the names a relation shows; each view's list in its order and with the filter it opens with, its filter widgets and their counts, its chart, its map, board, calendar and timeline, its card photos, its order by hand, its related sections, galleries and charts, its named lists, its recent records and a saved search's new records; and each block of a [composed page](layouts.md) that lists records, and each `Chart` block's grouped and split fields. Each error is an index to add, with every request that needs it and where it comes from:
@@ -133,7 +133,7 @@ Exit code is 1 when there are errors. Run it after the project's migrations and
133
133
  ## `dev`
134
134
 
135
135
  ```sh
136
- protobase dev [--port 5173] [--env DATABASE_URL] [--cache-dir <dir>] [--allowed-hosts <hosts>] [--extend <dir>] # run inside a project, e.g. bun run --cwd tests/apps/erp dev
136
+ protobase dev [--port 5173] [--env DATABASE_URL] [--cache-dir <dir>] [--allowed-hosts <hosts>] [--extend <dir>] [--no-jobs] # run inside a project, e.g. bun run --cwd tests/apps/erp dev
137
137
  ```
138
138
 
139
139
  One port, two processes: Vite serves the admin app from `@protobase/ui`'s `src/app/` (`dist/app/` when installed; `index.html` and `main.tsx`, or `main.js`; the command fails with a clear message when they are missing) and hands `/api`, supabase-js's `/api/sb` included, to the Hono app from `createAdmin`, which runs in a process of its own; the CLI's process never loads `@protobase/server`. Open `http://localhost:<port>`; the API reference is at `/api/docs`.
@@ -146,7 +146,7 @@ One port, two processes: Vite serves the admin app from `@protobase/ui`'s `src/a
146
146
  - **Cache:** Vite's dependency cache is `node_modules/.vite`; two dev servers sharing it can disturb each other's optimisation, so pass `--cache-dir` for a second one (the integration test does).
147
147
  - **Login:** real sign-in only, with the config's `auth`, on by default, or its `authenticate` for tokens of an auth server elsewhere (see [protobase.config.ts](config.md) and [Login with Better Auth](auth.md)). A project with sign-in off and no authenticator fails at startup with a message pointing there, as does one with sign-in on and no `PROTOBASE_SECRET`, one whose auth store has not applied every migration of this version, naming them (see [`auth migration`](#auth-migration)), one with a resource over a table of the auth store, and one with a resource whose [events](events.md) or [history](history.md) lack their triggers, or whose [comments](comments.md) lack their table, or whose [rollup and formula fields](data-config.md#rollups-and-formulas) lack their columns or triggers, which it checks again on every reload (see [`migration`](#migration)), and one with an [AI field or action](ai.md#the-model) and no model key; there is no development login. The `login` line of the startup message says how to sign in: while the auth store has no users, it prints the exact command that creates the first admin, such as `bun run protobase users create you@example.com --generate-password` (the package manager that started `dev`, `npx` otherwise). Against a database that has users already, such as an app's hosted one, sign in as one of them or add one with a new email; its own password is set there. A `warning` line follows for each way the auth store's tables differ from what the app's sign-in needs, such as a column dropped by hand: the one place Protobase compares them, since `serve` reads the store's record of [migrations](auth.md#migrations) alone.
148
148
  - **Mobile releases:** see [below](#mobile-releases).
149
- - **Jobs:** with [jobs](jobs.md) in the config it runs them in the API's process, once the jobs schema checks out (see [`migration`](#migration)); an edited job runs its new code from the next job on.
149
+ - **Jobs:** with [jobs](jobs.md) in the config it runs them in the API's process, once the jobs schema checks out (see [`migration`](#migration)); an edited job runs its new code from the next job on. `--no-jobs` runs none of them and delivers none of the app's [events](events.md), webhooks or pushes, for a dev server on the database of a hosted app that already runs them; jobs are still enqueued, for that app to run, and the startup message says `jobs off`.
150
150
  - **Tunnels and other hosts:** Vite only answers `localhost` by default. `--allowed-hosts .trycloudflare.com,app.example.com` adds Host headers (a leading dot matches all subdomains). Hot reload needs no extra setting: the Vite client takes protocol, host and port from the page, so behind an HTTPS tunnel it connects over `wss` on 443 as long as the tunnel forwards WebSockets.
151
151
  - **Project discovery**, by convention: `config/index.ts` exports the resources (and views), and every `config/*/ui.ts` export that is not already exported by the index is added as a view. Each `functions/<name>.ts` (or `functions/<name>/index.ts`) is an [API function](../guides/api-functions.md) at `/api/functions/<name>`. `protobase.config.ts` (or `protobase.config.json`) in the project root describes the rest as data, and the jobs, hooks and other code it names are found by their paths; see [protobase.config.ts](config.md). Without it, the app runs on the defaults.
152
152
  - **Project UI:** the app imports `protobase.ui.tsx` from the project root, with hot reload, when it exists (see [Custom components](custom-components.md)): the same contract as the plugin `protobase build` makes of it, built into the dev page instead. Layout files are checked as they load, as `protobase build` checks them.
@@ -575,6 +575,10 @@ Run inside a project with sign-in on, against its database with the project's mi
575
575
 
576
576
  ## `import supabase`
577
577
 
578
+ :::caution[Beta]
579
+ MFA factors and passkeys do not carry over yet, as the second run says.
580
+ :::
581
+
578
582
  ```sh
579
583
  protobase import supabase [<connection> | --source-env <variable>] [--env DATABASE_URL] [--dir db/migrations] [--config config]
580
584
  [--storage-url <url>] [--storage-key-env SUPABASE_SERVICE_ROLE_KEY] [--yes]
@@ -589,7 +593,7 @@ Turns a Supabase project's database into the project in the current directory, i
589
593
 
590
594
  5. **Storage's files**: with `--storage-url https://<ref>.supabase.co` and the project's secret key in the variable `--storage-key-env` names, `SUPABASE_SERVICE_ROLE_KEY` by default, the second run, or any run after it, copies the bytes of every object in `storage.objects` from the Supabase project's Storage API into the app's [Storage file provider](supabase-storage.md#where-things-are-kept), where [`/api/sb/storage/v1`](supabase-storage.md) serves them, with the type and cache control the rows recorded. It streams each object, checks its byte count against the size Supabase recorded and its MD5 against the ETag where that is one, and deletes a copy that does not match. It first prints each bucket's size and stops when what is left to copy and what the provider holds exceed the provider's `max_bytes`, unless `--yes`. An object that fails does not stop the rest; the run then fails, listing them in `supabase-import/storage.md`, and a rerun copies only what the provider does not hold yet. The key goes only in the requests' headers, never in a file. Plain `http://` is accepted only for a local Supabase on `localhost`, `127.0.0.1` or `[::1]`, such as `supabase start`'s `http://127.0.0.1:54321`, since the key bypasses every Storage policy; a download Supabase answers with a redirect is not followed, so the key goes to no other host, and that object fails.
591
595
 
592
- An agent with the `supabase-move` skill of [`skill install`](#skill) finishes what the report leaves: it rewrites each item, checking the project's database for the extensions an item needs, and runs `doctor` and the typecheck until both pass, leaving the values of Vault's secrets for you to enter.
596
+ An agent with the `supabase-move` skill of [`skill install`](#skill) finishes what the report leaves: it rewrites each item, checking the project's database for the extensions an item needs, and runs `doctor` and the typecheck until both pass, leaving the values of Vault's secrets for you to enter. It keeps `supabase/functions`, the Deno originals the app no longer runs, out of the typecheck through the `exclude` of `tsconfig.json`, and removes the folder only when you say so.
593
597
 
594
598
  Remove `supabase-import/` to import again.
595
599
 
@@ -610,7 +614,7 @@ Builds the project in the current directory as an iOS or Android app with Capaci
610
614
  - `run` runs the app on `--target`, or asks for one. On iOS, `--team` writes the Apple team id into the generated project and builds it once with xcodebuild allowed to create the development profile and register the device, `--target` or the one connected device, before Capacitor runs it there. With `--live` it opens `protobase dev` on `localhost:--port` instead of its own files. `open` opens the native project in Xcode or Android Studio.
611
615
  - `build` makes a signed release, an `.aab` (Android) or `.ipa` (iOS), and copies it to `--out`, which ignores itself in git when the command creates it, as `<appId>-<version>-<build number>`. Android signs through Gradle with the keystore, alias and passwords in `PROTOBASE_ANDROID_KEYSTORE`, `PROTOBASE_ANDROID_KEY_ALIAS`, `PROTOBASE_ANDROID_KEYSTORE_PASSWORD` and `PROTOBASE_ANDROID_KEY_PASSWORD`; iOS runs Capacitor's build with `--team`, and `--certificate` with `--profile` for manual signing.
612
616
  - A release calls an https server that is not this machine, and `--app-version` (one to three numbers) and `--build-number` (1 to 2100000000) are required.
613
- - npm and Capacitor get the CLI's environment without any variable named in the current directory's `.env*` files, nor the four `PROTOBASE_ANDROID_*` signing variables, which only Gradle gets, and with `CI=true`, which keeps Capacitor's telemetry off.
617
+ - npm and Capacitor get the CLI's environment without any variable named in the current directory's `.env*` files, nor the four `PROTOBASE_ANDROID_*` signing variables, which only Gradle gets, and with `CI=true`, which keeps Capacitor's telemetry off. The project's config, its extensions' configs and the web build run inside the CLI with no `PROTOBASE_ANDROID_*` variable left in its environment, even one the project's `.env` sets.
614
618
  - It prints the workspace, the API origin and the native fingerprint, which the app also sends in its user agent as `ProtobaseNative/<fingerprint>`: a hash of the native packages `@protobase/mobile` pins, at their versions, its own native contract, and the section's `plugins`, at the versions they give. A [mobile release](#mobile-releases) runs only on a binary of its own fingerprint. A Protobase release that pins the same native packages and keeps the same native contract keeps it; raise a plugin's version when the app's files start using a newer native feature of it, which changes it.
615
619
  - The app's files carry the live updater's manifest of them, so an update downloads only the files that changed.
616
620
  - With `mobile.publicKey`, the Android and iOS app get the [live updater](#the-live-updater), unless `--live` is given, the app's files name no build time, or the API is neither https nor http to this machine; it prints a note why when it leaves it out.
@@ -17,7 +17,7 @@ const { record, etag } = await api.get(orders, id)
17
17
  await api.update(orders, id, { status: 'shipped' }, etag) // 412 rejects with PreconditionFailedError
18
18
  ```
19
19
 
20
- Pass a resource builder to get its record type ([`InferRecord`](https://docs.protobase.net/api/schema/type-aliases/inferrecord/)), or a name for loose records. Filters are AIP-160 text or [`where`](https://docs.protobase.net/api/schema/variables/where/) results (printed with [`printFilter`](https://docs.protobase.net/api/schema/functions/printfilter/)). Errors are `ApiError` (`status`, `slug`, `errors`, `filterErrors` with spans, `blockedBy` for a delete other records point at), which `isApiError(error)` tells apart. Also: `search`, `create`, `remove`, `facets`, `series`, `histogram`, `map(resource, { latitude, longitude, zoom, filter?, orderBy?, fields? })` (the records in a map's view, or buckets of them, see [`:map`](api.md#maps)), `seek`, `reveal(resource, key, field)` (a [sensitive field](data-config.md#sensitive-fields)'s value), `generate(resource, key, action)` (runs an [AI action](ai.md#ai-actions) and resolves to the record as written), `meta(etag?, { signal? })`, `invoke(name, { body?, method?, path?, query? })` (one of the app's [API functions](../guides/api-functions.md#calling-a-function); its answer as parsed JSON, text, or a `Blob` for any other type), `upload(resource, field, file, { onProgress?, signal? })` (a file for a [file field](files.md#uploading); its `value` goes in the next write, and `onProgress` uses XMLHttpRequest, since fetch reports no upload progress), `live({ onChanges, onResync, onRetry? })` (hears which records of the [live](live.md) resources change until the function it returns is called, connecting again by itself; every caller of one client shares one stream), `channel(resource, key)` (a record's [channel](channels.md#from-a-page), with its presence and broadcasts, every channel of the client on one socket).
20
+ Pass a resource builder to get its record type ([`InferRecord`](https://docs.protobase.net/api/schema/type-aliases/inferrecord/)), or a name for loose records. Filters are AIP-160 text or [`where`](https://docs.protobase.net/api/schema/variables/where/) results (printed with [`printFilter`](https://docs.protobase.net/api/schema/functions/printfilter/)). Errors are `ApiError` (`status`, `slug`, `errors`, `filterErrors` with spans, `blockedBy` for a delete other records point at), which `isApiError(error)` tells apart. Also: `search`, `create`, `remove`, `facets`, `series`, `histogram`, `map(resource, { latitude, longitude, zoom, filter?, orderBy?, fields? })` (the records in a map's view, or buckets of them, see [`:map`](api.md#maps)), `seek`, `reveal(resource, key, field)` (a [sensitive field](data-config.md#sensitive-fields)'s value), `generate(resource, key, action)` (runs an [AI action](ai.md#ai-actions) and resolves to the record as written), `meta(etag?, { signal? })`, `invoke(name, { body?, method?, path?, query? })` (one of the app's [API functions](../guides/api-functions.md#calling-a-function); its answer as parsed JSON, text, or a `Blob` for any other type), `history(resource, key, { pageSize?, pageToken? })` (a page of a record's [changes](history.md), newest first), `comments(resource, key, { pageSize?, pageToken? })`, `comment(resource, key, { body, mentions? })` and `mentionable(resource, key, query)` (a record's [comments](comments.md#the-endpoints), adding one, and the people it may mention), `form(name)`, `formChallenge(name)` and `submitForm(name, values, proof)` (a [public form](forms.md#routes)), `upload(resource, field, file, { onProgress?, signal? })` (a file for a [file field](files.md#uploading); its `value` goes in the next write, and `onProgress` uses XMLHttpRequest, since fetch reports no upload progress), `live({ onChanges, onResync, onRetry? })` (hears which records of the [live](live.md) resources change until the function it returns is called, connecting again by itself; every caller of one client shares one stream), `channel(resource, key)` (a record's [channel](channels.md#from-a-page), with its presence and broadcasts, every channel of the client on one socket).
21
21
 
22
22
  With an absolute `baseUrl`, such as another app's `https://desk.example.com/api/v1`, the `url` of each [file](files.md) in a record the client answers is absolute too, on that API's origin, where the API answers a path on its own origin. `routedClient(own, sourceOf)` is one client over several APIs: each call about a resource goes to the client `sourceOf(resource)` gives, else to `own`, as the admin app does for [remote resources](remote-resources.md), and `sourceMeta(meta, source)` keeps the part of another API's `/meta` an app draws.
23
23
 
@@ -54,7 +54,7 @@ Whoever may read the record may read its comments and add one: the same tenant,
54
54
 
55
55
  ## The endpoints
56
56
 
57
- `GET /api/v1/{resource}/{key}:comments` reads them newest first, `POST` to the same path adds one, and `GET /api/v1/{resource}/{key}:mentionable?q=` lists whom a comment may mention. See [REST API](api.md#record-comments).
57
+ `GET /api/v1/{resource}/{key}:comments` reads them newest first, `POST` to the same path adds one, and `GET /api/v1/{resource}/{key}:mentionable?q=` lists whom a comment may mention. See [REST API](api.md#record-comments). From code, [`@protobase/client`](client.md) has `comments(resource, key, { pageSize?, pageToken? })`, `comment(resource, key, { body, mentions? })`, where `mentions` holds the ids of the users mentioned, and `mentionable(resource, key, query)`.
58
58
 
59
59
  An AI client reads and adds them over the app's [MCP endpoint](mcp.md#comments), with `list_comments` and `add_comment`, under the same rules.
60
60
 
@@ -64,7 +64,7 @@ Once a resource takes comments, admins get **Comments** (the built-in resource `
64
64
 
65
65
  ## The table
66
66
 
67
- [`protobase migration`](cli.md#migration) adds the schema `comments` with the table `comments.comments`; apply it with the project's other migrations. `protobase dev` and `protobase serve` do not start while a resource has comments and the table is missing. A deleted record's comments stay in the table.
67
+ [`protobase migration`](cli.md#migration) adds the schema `comments` with the table `comments.comments`; apply it with the project's other migrations. It has an index for each column the Comments list filters on, and the next migration adds one an earlier version's table lacks. `protobase dev` and `protobase serve` do not start while a resource has comments and the table or one of its indexes is missing. A deleted record's comments stay in the table.
68
68
 
69
69
  | Column | |
70
70
  | --- | --- |
@@ -239,7 +239,7 @@ A component that asks for input looks like the app's own forms when it is built
239
239
  | `RecordPicker` | Records of another resource, picked as the create form picks a relation: type to narrow them by their search fields or title, scroll for more, or use the keyboard. `resource` names the resource, whose model it reads from the app; `value` is the chosen record's key as text and `onChange` gets the new one. With `multiple`, `value` and `onChange` hold several keys, such as the objectives a task serves through a join resource: each shows as a token that can be taken off. |
240
240
  | `Segmented`, `MenuSelect` | A switch between a few short options in a row (`size`, `iconOnly`), and the same options in a menu. |
241
241
  | `Button`, `buttonClass` | A button (`variant` `primary`, `secondary`, `ghost` or `danger`, `size`, `loading`), and its classes for a link that looks like one. |
242
- | `Dialog`, `ResponsiveDialog` | A modal with a `title`, `description`, `actions` and a body, which Escape and a click outside close with `onClose`; the responsive one becomes a sheet on a phone. |
242
+ | `Dialog`, `ResponsiveDialog` | A modal with a `title`, `description`, `actions` and a body, which Escape and a click outside close with `onClose`; the responsive one becomes a sheet from the bottom of a phone, which a swipe down on its header closes too. |
243
243
 
244
244
  ```tsx
245
245
  // components/book-repair.tsx, exported from protobase.ui.tsx
@@ -335,6 +335,10 @@ export const Channel = ({ messages, sent, members, onSend, onLoadEarlier }: Chan
335
335
 
336
336
  ### Members' names
337
337
 
338
+ :::caution[Beta]
339
+ The whole member list loads in the browser, with no paging or search on the server, and former members' names do not show.
340
+ :::
341
+
338
342
  The author is a user id, and `useMembers()` from `@protobase/ui` turns it into a name: the members of the organization the signed-in person works in, `{ id, name, image? }` by name, with `id` the user id that `.author()` stores and that `onSend`'s mentions hold. Every member reads it, owners, admins and members alike; it holds no email addresses. It is a TanStack Query result (`data`, `isLoading`, `error`) with `member(id)`, which finds one and, for an id it does not know, such as a newcomer's first message, reads the list again once it is 10 seconds old; it is also read again when the window regains focus and the list is 30 seconds old. It needs [organizations](auth.md#organizations): without them, `error` says so. In an app with [status](status.md#in-the-app), each member carries their `status` too, read again every 15 seconds.
339
343
 
340
344
  ```tsx
@@ -358,6 +362,8 @@ export const ChannelComposer = ({ onSend }: { onSend: (text: string, mentions: s
358
362
 
359
363
  `searchPeople` filters the list in the browser and shows at most 8, as comments' mentions do. <gap>`useMembers()` cannot page or search on the server because a server-side people resource is not implemented, so the whole list loads in the browser, which suits a team, not thousands of members.</gap> <gap>Old messages of someone who has left the organization cannot show their name because looking up former members is not implemented.</gap> A person who works in an organization without being a member of it, through a global role, gets no list.
360
364
 
365
+ ### Signed-in user
366
+
361
367
  `useSignedInUser()` from `@protobase/ui` gives the signed-in person as `{ id, name? }`, `id` being the user id `.author()` stores and mentions hold, such as for a filter on their own messages (`author_id != "<id>"`) or mentions of them; it is `undefined` while the session loads, for a visitor and when nobody is signed in.
362
368
 
363
369
  ## Components in the chat
@@ -224,7 +224,7 @@ A [webhook subscription](webhooks.md) delivering `as` a role runs that role's ac
224
224
 
225
225
  ### Recent strong sign-in
226
226
 
227
- A field such as a recovery code or an API secret can ask for more than read access: `.strongSignIn()` reveals it only to someone whose sign-in proved a passkey or a second step in the last 15 minutes, or `minutes` you give. It is sensitive too, so all of the above applies, and `/meta` marks it `strongSignIn: { minutes }`.
227
+ A field such as a recovery code or an API secret can ask for more than read access: `.strongSignIn()` reveals it only to someone whose sign-in proved a passkey or a second step in the last 15 minutes, or `minutes` you give; an account with neither confirms with an [emailed code](auth.md#emailed-confirmation-code) instead. It is sensitive too, so all of the above applies, and `/meta` marks it `strongSignIn: { minutes }`.
228
228
 
229
229
  ```ts
230
230
  recoveryCode: f.text().encrypted().strongSignIn({ minutes: 10 }),
@@ -89,7 +89,7 @@ The template that comes back has two methods, both typed by the component's prop
89
89
 
90
90
  React and React Email's renderer are loaded on the first email rendered, not when the app starts.
91
91
 
92
- Mail an app sends in bulk, such as alerts or newsletters, should carry a one-click unsubscribe link, which Gmail and Yahoo expect of bulk senders ([RFC 8058](https://www.rfc-editor.org/rfc/rfc8058)): a `List-Unsubscribe` header with the recipient's own link, and `List-Unsubscribe-Post`. The link must stop the mail on a `POST` without a sign-in, such as an [API function](../guides/api-functions.md) that takes a token of the recipient's in its address:
92
+ Mail an app sends in bulk, such as alerts or newsletters, should carry a one-click unsubscribe link, which large mailbox providers expect of bulk senders ([RFC 8058](https://www.rfc-editor.org/rfc/rfc8058)): a `List-Unsubscribe` header with the recipient's own link, and `List-Unsubscribe-Post`. The link must stop the mail on a `POST` without a sign-in, such as an [API function](../guides/api-functions.md) that takes a token of the recipient's in its address:
93
93
 
94
94
  ```ts
95
95
  headers: ({ unsubscribeUrl }: Digest) => ({ 'List-Unsubscribe': `<${unsubscribeUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' }),
@@ -168,6 +168,10 @@ Uploads go through Protobase's own route, so the type is checked and processors
168
168
 
169
169
  A write takes only a ticket, `null` to clear the field, or the stored `uri` unchanged, and in a field with `.externalUrls()` an https URL. Anything else is refused with `400`, so a row can only point at a file its writer uploaded for that field, or at a URL elsewhere where the field allows one. From the client, `client.upload(resource, field, file, { onProgress })` does step 1; see [Client](client.md).
170
170
 
171
+ ### In forms
172
+
173
+ In the admin app's create and edit forms, a file field takes a dropped or chosen file, limited to the field's `.accept()` types, and uploads it at once with a progress bar; Save then stores it with the record. A stored file shows its thumbnail or an icon, its name linking to the file, its type and size, and the type it was sent as when the server corrected it. Replace picks another file, and Remove, for an optional field, clears it. Tables show the file's name, beside a small thumbnail for an image.
174
+
171
175
  ### How the type is decided
172
176
 
173
177
  An upload carries three hints: what the **content** is (C, from its magic bytes), what the **name's extension** says (E), and what the **client declared** (H, the request's or the file part's `Content-Type`; `application/octet-stream` counts as none).
@@ -71,7 +71,7 @@ Once a resource has history, admins get **Activity** (the built-in resource `rec
71
71
 
72
72
  ## The table
73
73
 
74
- [`protobase migration`](cli.md#migration) adds the table `changes.history` to the `changes` schema of the event capture (renamed with `events: { schema }`), and the capture triggers of each resource with history; apply it with the project's other migrations. `protobase dev` and `protobase serve` do not start while the table or a trigger is missing. A resource that drops `.history()` loses its triggers, unless it has events, at the next migration; its changes stay in the table.
74
+ [`protobase migration`](cli.md#migration) adds the table `changes.history` to the `changes` schema of the event capture (renamed with `events: { schema }`), and the capture triggers of each resource with history; apply it with the project's other migrations. It has an index for each column the Activity list filters on, and the next migration adds one an earlier version's table lacks. `protobase dev` and `protobase serve` do not start while the table, one of its indexes or a trigger is missing. A resource that drops `.history()` loses its triggers, unless it has events, at the next migration; its changes stay in the table.
75
75
 
76
76
  | Column | |
77
77
  | --- | --- |
@@ -152,13 +152,15 @@ Each run is a job of its own, enqueued ahead for its time by the workers: its id
152
152
 
153
153
  Because the next run waits in the database like any delayed job, [`jobs.next_due()`](#hosts-that-sleep) sees it, and a host that wakes the app for due jobs wakes it for its recurring jobs too. That is why Protobase does not use pg-boss's own scheduler, which keeps no next run in the database and works the time out in a running process.
154
154
 
155
+ `isSchedule(schedule, timezone)` from `@protobase/server` tells whether a recurring job takes a schedule in a time zone, for a tool that writes the config, such as an import.
156
+
155
157
  ## Failed jobs page
156
158
 
157
159
  With jobs, the admin app has a **Failed jobs** page, for admins only: each job that used up its attempts or failed for good, with its payload and last error. **Retry** gives a job one more attempt, from now, and **Delete** removes it. It reads the `jobs.failed` view, which a project's SQL can query too.
158
160
 
159
161
  ## Where jobs run
160
162
 
161
- - **`protobase dev`** runs them in its process. An edited job runs its new code from the next job on.
163
+ - **`protobase dev`** runs them in its process. An edited job runs its new code from the next job on. `protobase dev --no-jobs` runs none, for a dev server on a hosted app's database.
162
164
  - **`protobase serve`** runs them in its process, beside the API, unless `PROTOBASE_JOBS=false`; it enqueues them either way. On SIGTERM it stops taking jobs and gives running ones 30 seconds to finish. A job still running then is failed, to be retried.
163
165
  - **[`protobase worker <bundle>`](cli.md#worker)** runs the same jobs from the same bundle in a process of its own, with the same shutdown, for a `serve` with `PROTOBASE_JOBS=false`.
164
166
  - Each worker checks every job name for due jobs every 2 seconds: one indexed query per job name and interval. Once a minute, one of the workers also counts the queues and retries the jobs whose worker died (their `timeout` passed); once a day it deletes completed and failed jobs older than 7 days.
@@ -24,8 +24,8 @@ Numbers, amounts and dates use the conventions of the language: English as writt
24
24
 
25
25
  ## What is translated
26
26
 
27
- Every built-in screen is translated: the shell with its sidebar, top bar, search, notification bell, profile menu and bottom bar; the list page with all its displays (table, cards, board, calendar, timeline and map), filters, search, sort, imports and exports; the record page with its actions, related lists, history, comments, gallery, saving, conflicts and deleting; the create page and composed pages; saved searches and favorites; the sign-in, sign-up and two-factor screens, the account and sign-in policy pages, and the organization, people and invitation pages; public forms; and the messages, toasts and confirmations around them. So are counts, relative times ("5 minutes ago"), yes, no and the other values the app writes itself, and the notices shown while the app loads.
27
+ Every built-in screen is translated: the shell with its sidebar, top bar, search, notification bell, profile menu and bottom bar; the list page with all its displays (table, cards, board, calendar, timeline and map), filters, search, sort, imports and exports; the record page with its actions, related lists, history, comments, gallery, saving, conflicts and deleting; the create page and composed pages; saved searches and favorites; the sign-in, sign-up and two-factor screens, the account and sign-in policy pages, and the organization, people and invitation pages; public forms; the assistant's dock and a record's [lines](ui-config.md#record-lines); the notice naming a plugin that is off and why; and the messages, toasts and confirmations around them. The parts `@protobase/ui` gives your own components, such as the [form parts](custom-components.md#form-parts), the [chat parts](custom-components.md#chat-parts) and the markdown editor, come in the app's language too. So are counts, relative times ("5 minutes ago"), yes, no and the other values the app writes itself, and the notices shown while the app loads.
28
28
 
29
29
  The approvals an AI client asks for over [MCP](mcp.md#writes) are translated too, with their buttons and the refusal for a client that cannot ask: in the app's `locale`, or else in the language the client's request accepts.
30
30
 
31
- The other texts a plugin or the server writes itself, such as an error message the API sends back or why a plugin could not be loaded, show as they are written.
31
+ The other texts a plugin or the server writes itself, such as an error message the API sends back, show as they are written.