@protobase/cli 0.66.7 → 0.66.9

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 (45) hide show
  1. package/dist/cli/package.js +1 -1
  2. package/dist/dev/api-forward.js +3 -2
  3. package/dist/dev/app-files.js +11 -0
  4. package/dist/dev/check-remote-api.js +18 -0
  5. package/dist/dev/entry.js +1 -1
  6. package/dist/dev/remote-api.js +58 -0
  7. package/dist/dev/run-ui.js +34 -0
  8. package/dist/dev/run.js +5 -8
  9. package/dist/main.js +22 -10
  10. package/package.json +7 -7
  11. package/skills/protobase/references/features/ai.md +10 -7
  12. package/skills/protobase/references/features/build-and-run.md +2 -0
  13. package/skills/protobase/references/features/compatibility.md +6 -6
  14. package/skills/protobase/references/features/data-model.md +1 -1
  15. package/skills/protobase/references/features/email-and-public-forms.md +2 -0
  16. package/skills/protobase/references/features/events-webhooks-and-jobs.md +2 -0
  17. package/skills/protobase/references/features/files-and-images.md +1 -0
  18. package/skills/protobase/references/features/look-and-language.md +1 -0
  19. package/skills/protobase/references/features/navigation.md +5 -4
  20. package/skills/protobase/references/features/sign-in-and-accounts.md +6 -0
  21. package/skills/protobase/references/features/your-own-code.md +4 -2
  22. package/skills/protobase/references/getting-started.md +8 -2
  23. package/skills/protobase/references/guides/api-functions.md +2 -2
  24. package/skills/protobase/references/reference/api.md +1 -1
  25. package/skills/protobase/references/reference/app-identity.md +1 -1
  26. package/skills/protobase/references/reference/assistant.md +22 -2
  27. package/skills/protobase/references/reference/auth.md +33 -6
  28. package/skills/protobase/references/reference/cli.md +19 -3
  29. package/skills/protobase/references/reference/client.md +1 -1
  30. package/skills/protobase/references/reference/comments.md +1 -1
  31. package/skills/protobase/references/reference/custom-components.md +7 -1
  32. package/skills/protobase/references/reference/data-config.md +1 -1
  33. package/skills/protobase/references/reference/email.md +1 -1
  34. package/skills/protobase/references/reference/files.md +4 -0
  35. package/skills/protobase/references/reference/jobs.md +3 -1
  36. package/skills/protobase/references/reference/languages.md +2 -2
  37. package/skills/protobase/references/reference/mcp.md +21 -1
  38. package/skills/protobase/references/reference/plugins.md +6 -4
  39. package/skills/protobase/references/reference/sidebar.md +6 -0
  40. package/skills/protobase/references/reference/supabase-realtime.md +5 -1
  41. package/skills/protobase/references/reference/supabase-rest.md +5 -1
  42. package/skills/protobase/references/reference/supabase-storage.md +5 -1
  43. package/skills/protobase/references/reference/theme.md +1 -1
  44. package/skills/protobase/references/reference/ui-config.md +6 -2
  45. package/skills/protobase/references/reference/versioning.md +2 -0
@@ -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
 
@@ -288,7 +288,7 @@ auth: { accountDeletion: false }
288
288
 
289
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.
290
290
 
291
- - 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`.
292
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.
293
293
  - A [staff session](#staff-sign-in) cannot delete the account (`STAFF_CANNOT_CHANGE_SIGN_IN`).
294
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.
@@ -325,6 +325,8 @@ With mail configured, the account page's Profile tab offers "Change email": the
325
325
 
326
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 }`.
327
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
+
328
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"`.
329
331
 
330
332
  ```ts
@@ -355,6 +357,7 @@ The sign-in page then offers "Create an account", and so do [sign-in prompts](ui
355
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.
356
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.
357
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.
358
361
 
359
362
  ## Passkeys
360
363
 
@@ -366,7 +369,7 @@ People add passkeys (Face ID, Touch ID, Windows Hello, a phone or a security key
366
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).
367
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.
368
371
  - A passkey sign-in asks for no second step: the passkey is something you have and something you are or know.
369
- - 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.
370
373
  - Passkeys can be renamed and removed on the account page; removing one here leaves it on the device.
371
374
 
372
375
  ## Two-factor authentication
@@ -386,7 +389,27 @@ Some values ask for a sign-in that proved a passkey or a second step not long ag
386
389
 
387
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.
388
391
 
389
- 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.
390
413
 
391
414
  ## Sign-in policy
392
415
 
@@ -473,7 +496,7 @@ auth: { operator: false }
473
496
 
474
497
  ## Sign-in with GitHub
475
498
 
476
- 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`:
477
500
 
478
501
  ```ts
479
502
  export default defineConfig(({ env }) => ({
@@ -622,7 +645,7 @@ It is Better Auth's organization plugin, with its tables named in snake_case (`o
622
645
 
623
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.
624
647
 
625
- **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.
626
649
 
627
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:
628
651
 
@@ -701,6 +724,10 @@ Their tokens are verified against that app's keys at `<issuer>/api/auth/jwks`; a
701
724
 
702
725
  ## supabase-js and @supabase/ssr
703
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
+
704
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.
705
732
 
706
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).
@@ -751,7 +778,7 @@ auth: { mcp: { path: '/api/mcp', connectPage: '/-/connect' } }
751
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.
752
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.
753
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.
754
- - 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)).
755
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.
756
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.
757
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.
@@ -133,7 +133,8 @@ 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
+ protobase dev --api <url> [--port 5173] [--cache-dir <dir>] [--allowed-hosts <hosts>] [--extend <dir>] # the UI alone, on the API at <url>
137
138
  ```
138
139
 
139
140
  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 +147,7 @@ One port, two processes: Vite serves the admin app from `@protobase/ui`'s `src/a
146
147
  - **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
148
  - **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
149
  - **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.
150
+ - **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
151
  - **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
152
  - **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
153
  - **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.
@@ -154,6 +155,17 @@ One port, two processes: Vite serves the admin app from `@protobase/ui`'s `src/a
154
155
  - **Extensions:** `--extend <dir>` merges an extension in, its UI built into the dev page beside the project's, to try one out; its files reload like the project's.
155
156
  - **Hot reload:** server code, data configs, `ui.ts` views, layouts and the config are loaded through Vite's SSR module graph, and the config is read again with each rebuild. A change inside the project (or, in this repository, in `packages/server`, `packages/query`, `packages/schema`) rebuilds the API on the next request without a restart; `/meta` and `X-Meta-Version` change, so open tabs refetch. The database pool survives reloads. Newly added `ui.ts` files appear after the next change inside the project.
156
157
 
158
+ ### UI only
159
+
160
+ :::caution[Beta]
161
+ Sign-in with a passkey or an outside provider, such as Google, belongs to the instance's own address and does not return to the dev server; sign in with an emailed code or a password. Safari keeps no cookie of an `https` instance on `http://localhost`; use Chrome or Firefox.
162
+ :::
163
+
164
+ `--api <url>` serves the admin app with the project's UI, theme, icons and `--extend` folders, hot reloaded as above, and hands every request the API process would answer (`/api`, `/.well-known/webauthn`, the OAuth discovery documents and `/protobase.sw.js`), its WebSockets included, to the Protobase app already running at `<url>`, such as `https://staging.example.com` or another `protobase dev`. Nothing reads a database or the project's config: no `DATABASE_URL`, no `PROTOBASE_SECRET`, no migration checks and no jobs, and resources, roles and data are the instance's. Change the UI here and the server code where the instance runs.
165
+
166
+ - **Checked at start:** the instance must answer `/api/meta` with an `X-Meta-Version` header, as every Protobase app does; otherwise the command names the URL and what came back. `<url>` is an origin, without a path.
167
+ - **Sign-in:** sign in as a user of the instance. Requests from the dev server's own pages reach it with its own Host, Origin and Referer, so its origin checks pass; a request from any other site keeps its own origin, which the instance refuses. Its cookies come back without their `Domain`, for `localhost` to keep, and a redirect to its own address becomes a path on the dev server.
168
+
157
169
  ### Extending a config
158
170
 
159
171
  `--extend <dir>` (repeatable) merges an extension's folder into the app before the project: its config and the code its paths find, the same as the project's. How each part merges, and what a name defined twice does, is in [Extensions](config.md#extensions).
@@ -575,6 +587,10 @@ Run inside a project with sign-in on, against its database with the project's mi
575
587
 
576
588
  ## `import supabase`
577
589
 
590
+ :::caution[Beta]
591
+ MFA factors and passkeys do not carry over yet, as the second run says.
592
+ :::
593
+
578
594
  ```sh
579
595
  protobase import supabase [<connection> | --source-env <variable>] [--env DATABASE_URL] [--dir db/migrations] [--config config]
580
596
  [--storage-url <url>] [--storage-key-env SUPABASE_SERVICE_ROLE_KEY] [--yes]
@@ -589,7 +605,7 @@ Turns a Supabase project's database into the project in the current directory, i
589
605
 
590
606
  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
607
 
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.
608
+ 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
609
 
594
610
  Remove `supabase-import/` to import again.
595
611
 
@@ -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
 
@@ -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).
@@ -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.
@@ -14,7 +14,7 @@ An app can serve an MCP endpoint: a person adds its address to an AI client that
14
14
  export default defineConfig({ auth: { mcp: true } })
15
15
  ```
16
16
 
17
- Every auth store has the OAuth tables, so turning it on needs no migration. The endpoint is `https://<app>/api/mcp`, or the path `auth.mcp.path` names. How clients register, how people sign in and allow them, and how they disconnect them is in [OAuth for MCP clients](auth.md#oauth-for-mcp-clients).
17
+ Every auth store has the OAuth tables, so turning it on needs no migration. The endpoint verifies its tokens with the app's own sign-in, so an app whose authenticator is its own, `auth/authenticate.ts`, does not start with MCP on. The endpoint is `https://<app>/api/mcp`, or the path `auth.mcp.path` names. How clients register, how people sign in and allow them, and how they disconnect them is in [OAuth for MCP clients](auth.md#oauth-for-mcp-clients).
18
18
 
19
19
  ## Connecting a client
20
20
 
@@ -33,10 +33,18 @@ Give the client the endpoint's address. It finds the app's sign-in from there, o
33
33
 
34
34
  ## The app's icon
35
35
 
36
+ :::caution[Beta]
37
+ Clients that draw no SVG get no icon for an app without PNGs of its own, as below.
38
+ :::
39
+
36
40
  The endpoint names the app's [icon](app-identity.md#the-icons) to the client when it connects (`serverInfo.icons`), at absolute addresses on the app's origin: the project's 192 pixel, touch and 512 pixel PNGs it has, then its `protobase.favicon.svg`, or Protobase's without one. A client that draws server icons shows it beside the app's name. <gap>An app without PNGs of its own cannot offer clients that draw no SVG an icon because PNG versions of Protobase's icon are not implemented.</gap>
37
41
 
38
42
  ## The tools
39
43
 
44
+ :::caution[Beta]
45
+ `count_records` cannot count soft-deleted records per value yet, as below.
46
+ :::
47
+
40
48
  Each tool reads as the signed-in person: their role's access rules, row filters, tenant and hidden fields apply as in the [REST API](api.md#access).
41
49
 
42
50
  | Tool | What it does |
@@ -90,6 +98,10 @@ It runs one `SELECT` in a read-only transaction, as the app's database role, **o
90
98
 
91
99
  ### Writes
92
100
 
101
+ :::caution[Beta]
102
+ An approval cannot be answered on another server instance or after a restart yet, as below.
103
+ :::
104
+
93
105
  `mcpEndpoint.writes` also offers four tools that change records, to everyone signed in:
94
106
 
95
107
  ```ts
@@ -129,6 +141,10 @@ A write returns its record small, so changing one field of a large record does n
129
141
 
130
142
  #### Writes without approval
131
143
 
144
+ :::caution[Beta]
145
+ A deleted record cannot be restored over MCP yet, as below.
146
+ :::
147
+
132
148
  An app that runs service accounts over MCP, such as a backend job or an agent with no person present, names their roles in `mcpEndpoint.writes`:
133
149
 
134
150
  ```ts
@@ -162,6 +178,10 @@ A batch is checked before the person is asked: an operation other than a create,
162
178
 
163
179
  ## The app's own tools
164
180
 
181
+ :::caution[Beta]
182
+ Nothing refuses a tool result larger than an AI client takes yet, as below.
183
+ :::
184
+
165
185
  `mcp/tools.ts` adds tools of the app's own, offered after the built-in ones to everyone signed in, whatever `mcpEndpoint.writes` says. Its default export is an array of `defineTool(...)` from `@protobase/server`, the same shape as the [assistant's own tools](assistant.md#the-apps-own-tools): a `name` of 1 to 64 letters, digits, `_` or `-`, a `description` the client's model reads, `parameters`, the JSON Schema of its arguments, which the endpoint checks each call against, `annotations`, MCP's tool hints such as `readOnlyHint`, optionally a `title` and `_meta`, which `tools/list` gives clients as they are, and `run(args, context)`, which returns the text the client reads. The file is found when the app starts, under `protobase dev` and `protobase serve` alike, and only with `auth.mcp` on; with `mcpEndpoint: false` it offers nothing.
166
186
 
167
187
  `context` holds the caller's `session`, the `locale` they read, `approve(request)`, `resources()`, the resources they may read, and `records`, the app's records **as that person**: their role's access rules, tenant, hidden fields, `validate` and write hooks apply, as in the REST API and the built-in tools. Its methods are the assistant's, listed [there](assistant.md#the-apps-own-tools); a refusal rejects with an `HttpProblem`. `show` does nothing over MCP, and there is no `conversation`.
@@ -42,8 +42,10 @@ Each place a plugin can fill is an extension point. The config names it, or a pl
42
42
  | [Action handler](custom-components.md#action-handlers) | `a.action('name')` in a view | `actions: { name }` | `ActionContext` | The button is disabled |
43
43
  | [Shell slot](custom-components.md#shell-slots) | Nothing: drawn on every page | `shell: { actions, rightPanel, logo, bell }` | Nothing; `logo` gets `compact`, `workspace`, `appName` and `appIcon` | Not drawn; Protobase's logo for `logo`, its notification bell for `bell` |
44
44
  | [Before sign-in](#before-sign-in) | `beforeSignIn: true` where the plugin is listed | `beforeSignIn` | `children`, `params`, `removeParams`, `signedIn` | The pages as they are without it |
45
+ | Rules and hooks a view names | A [drop rule](ui-config.md#drop-rules), [board drop](ui-config.md#board-drops), [title rule](ui-config.md#titles-per-reader) or [marks hook](sidebar.md#marks-per-reader) by name in a view | `dropRules`, `boardDrops`, `titles`, `marks` | What its page says | What its page says |
46
+ | [Presence](status.md#in-the-app) | Nothing: read on the members page and the sidebar | `presence` | Nothing; a hook | No one shows as online or away |
45
47
 
46
- Components and cards share one namespace of names, which `component('Name')` joins to the export; field types and action handlers each have their own. Props that name fields go through the same walker as everything else in `/api/meta`, so a user who may not read a field never gets it in a component's props; a field type's `props` are plain data, and a field the user may not read is left out of the view, field type and all.
48
+ Components and cards share one namespace of names, which `component('Name')` joins to the export; field types, action handlers, drop rules, board drops, title rules and marks hooks each have their own. Props that name fields go through the same walker as everything else in `/api/meta`, so a user who may not read a field never gets it in a component's props; a field type's `props` are plain data, and a field the user may not read is left out of the view, field type and all.
47
49
 
48
50
  New extension points are new optional keys of `definePlugin`, so adding one never changes what a plugin built before it does.
49
51
 
@@ -67,7 +69,7 @@ export { DraftOrders } from './components/draft-orders'
67
69
  export default definePlugin({ name: 'sales-tools', actions: { remindCustomer } })
68
70
  ```
69
71
 
70
- It is written as a project's `protobase.ui.tsx` is: each component exported under the PascalCase name the config declares, from a file of its own, and the rest in the default export, `definePlugin({ name, actions, fields, shell, beforeSignIn, extends })` (`defineUi` is the same function). Its components use the hooks and components of `@protobase/ui`, which read the API as the signed-in user, under the same access rules as everything else.
72
+ It is written as a project's `protobase.ui.tsx` is: each component exported under the PascalCase name the config declares, from a file of its own, and the rest in the default export, `definePlugin({ name, actions, fields, shell, beforeSignIn, extends, ... })` (`defineUi` is the same function), with the keys of [Extension points](#extension-points). Its components use the hooks and components of `@protobase/ui`, which read the API as the signed-in user, under the same access rules as everything else.
71
73
 
72
74
  **Imports.** `react`, `react/jsx-runtime` (the JSX), `react-dom` and `@protobase/ui`, by those names, come from the app. Everything else a plugin imports, its own files, icons or a chart library, is built into it. Other modules of those packages, such as `react-dom/client` or a file inside `@protobase/ui`, fail the build: a copy of its own would not work with the app's. Read data with `@protobase/ui`'s hooks (`useList`, `useRecord`, `useClient`), which share the app's cache: a data library of the plugin's own keeps a cache of its own.
73
75
 
@@ -235,7 +237,7 @@ When the user signs in, or before for a plugin that loads [before sign-in](#befo
235
237
  - The files in `assets/`, named by their content, forever (`Cache-Control: public, max-age=31536000, immutable`).
236
238
  - From another origin than the app's, with `Access-Control-Allow-Origin` (`*` is fine for public files), since the browser loads modules and `plugin.json` with CORS.
237
239
 
238
- The plugins are merged in the order listed. Two of them cannot define one name: a plugin that defines a component, action handler or field type an earlier one defines is turned off, and the earlier one keeps it. So the project's own names win over a platform's, and within a bundle the build already fails on such a name.
240
+ The plugins are merged in the order listed. Two of them cannot define one name: a plugin that defines a component, action handler, field type, drop rule, board drop, title rule or marks hook an earlier one defines is turned off, and the earlier one keeps it. So the project's own names win over a platform's, and within a bundle the build already fails on such a name. The logo, the bell and the presence hook come from the last one that sets them, so the project's own replace a plugin's.
239
241
 
240
242
  `protobase dev` loads plugins the same way, its page sharing the same four modules. The project's own `protobase.ui.tsx` is built into the dev page with hot reload instead, merged after the plugins, and keeps its names over theirs.
241
243
 
@@ -326,7 +328,7 @@ From `@protobase/ui`:
326
328
 
327
329
  | | |
328
330
  | --- | --- |
329
- | `definePlugin(config)` | Typed identity for a plugin's default export: `name`, `actions`, `fields`, `shell`, `beforeSignIn`, `extends`, as `defineUi`. |
331
+ | `definePlugin(config)` | Typed identity for a plugin's default export: `name`, `extends` and the keys of [Extension points](#extension-points), as `defineUi`. |
330
332
  | `BeforeSignInProps` | The props of a plugin's [`beforeSignIn`](#before-sign-in) component. |
331
333
  | `usePlugin()` | The plugin the calling component belongs to (see [above](#writing-a-plugin)). |
332
334
  | `useResourceClient(resource)` | The client of the API that serves a resource: the app's own, or a source's for a [remote resource](remote-resources.md). |
@@ -6,6 +6,8 @@ How sidebar entries are grouped and which icon each shows, how an entry lists re
6
6
 
7
7
  The admin's sidebar has one entry per resource view, grouped by `nav.group` (default: the database schema, with no group name for `public`) and sorted by `nav.order`, then by the order `config/index.ts` exports them, in `protobase dev` and in a built bundle alike. The app opens on the first entry. A view's [named lists](ui-config.md#more-than-one-list) are entries of their own, placed by their own `nav`. `nav({ hidden: true })` leaves a resource out, for line and link tables; a resource in the [user menu](user-menu.md) is left out too. [Composed pages](layouts.md#setup) have entries of their own, and groups that hold pages come first. A [remote resource](remote-resources.md) has its entry where its source's view places it; while its source cannot be reached, it sits under the source's label, marked "Cannot reach" with the source's name.
8
8
 
9
+ Below the `md` breakpoint (768px) the sidebar is a drawer, which the top bar's menu button opens, or **More** on a [bottom bar](bottom-bar.md).
10
+
9
11
  ## Sidebar icons
10
12
 
11
13
  Each resource shows the icon its view names, in the sidebar, the [bottom bar](bottom-bar.md) and the [user menu](user-menu.md), and its [named lists](ui-config.md#more-than-one-list) show the same icon:
@@ -49,6 +51,10 @@ export const directMessagesView = view<typeof directMessages>('direct_messages')
49
51
 
50
52
  ## Recent records
51
53
 
54
+ :::caution[Beta]
55
+ A marks hook that throws stops the whole sidebar, not only its own group.
56
+ :::
57
+
52
58
  An entry can list a few of its records under it, each with a status dot, so the ones that need attention are one click away:
53
59
 
54
60
  ```ts
@@ -4,6 +4,10 @@
4
4
 
5
5
  The Realtime-compatible /api/sb/realtime/v1 that supabase-js's channel() joins, for postgres_changes checked under the subscriber's RLS policies, broadcasts and presence, on public and private channels, and broadcasts from the database.
6
6
 
7
+ :::caution[Beta]
8
+ A channel cannot `select` columns, a policy cannot read `request.headers`, and a project that was not imported has no `realtime.send()` yet: see the limits in each section.
9
+ :::
10
+
7
11
  An app with API keys also serves `/api/sb/realtime/v1/websocket`, the socket that supabase-js's `channel()` joins. An app moved from Supabase keeps its `postgres_changes` listeners, broadcasts and presence: point the client at the app's `/api/sb` and give it the publishable key.
8
12
 
9
13
  ```ts
@@ -18,7 +22,7 @@ supabase
18
22
 
19
23
  ## Keys, roles and tokens
20
24
 
21
- The keys are `apiKeys` in `protobase.config.ts`, `{ publishable_key, secret_key }`, or else `PROTOBASE_PUBLISHABLE_KEY` and `PROTOBASE_SECRET_KEY`. The socket sends the key in its URL and is refused with `401` without a key of the app. Each channel joins with the client's access token: a signed-in user's token makes it `authenticated` with the token's claims, the publishable key alone `anon`, the secret key alone the app's own role. A token the client refreshes on an open socket replaces the channel's; a channel whose token expires is closed with a `Token has expired` system message, as on Supabase.
25
+ The keys are `apiKeys` in `protobase.config.ts`, `{ publishable_key, secret_key }`, or else `PROTOBASE_PUBLISHABLE_KEY` and `PROTOBASE_SECRET_KEY`. The socket sends the key in its URL and is refused with `401` without a key of the app. Each channel joins with the client's access token: a user's access token from [`/api/sb/auth/v1`](auth.md#supabase-js-and-supabasessr), which supabase-js sends once `supabase.auth` has signed in, makes it `authenticated` with the token's claims, the publishable key alone `anon`, the secret key alone the app's own role. A token the client refreshes on an open socket replaces the channel's; a channel whose token expires is closed with a `Token has expired` system message, as on Supabase.
22
26
 
23
27
  The server serves the socket when the host gives `createAdmin` its WebSocket upgrade: `protobase serve` does, on Bun and Node, and so does `protobase dev`, beside Vite's hot reload socket. An edit to the project under `protobase dev` closes the open sockets with code `1012`, and supabase-js reconnects to the rebuilt API.
24
28
 
@@ -4,6 +4,10 @@
4
4
 
5
5
  The PostgREST-compatible /api/sb/rest/v1 that supabase-js's from() and rpc() call, under the tables' RLS policies.
6
6
 
7
+ :::caution[Beta]
8
+ Some PostgREST features are not supported yet, such as many-to-many embeds, spreads, CSV, schemas other than `public` and aggregates: see [What it understands](#what-it-understands).
9
+ :::
10
+
7
11
  An app with API keys also serves `/api/sb/rest/v1`, the API that supabase-js's `from()` and `rpc()` call. An app moved from Supabase keeps its data calls: point the client at the app's `/api/sb` and give it the publishable key.
8
12
 
9
13
  ```ts
@@ -20,7 +24,7 @@ The keys are `apiKeys` in `protobase.config.ts`, `{ publishable_key, secret_key
20
24
  | The request sends | It runs as |
21
25
  | --- | --- |
22
26
  | the publishable key | `anon` |
23
- | either key and a signed-in user's token | `authenticated` |
27
+ | either key and a user's access token from [`/api/sb/auth/v1`](auth.md#supabase-js-and-supabasessr) | `authenticated` |
24
28
  | the secret key | the app's own role, which owns the tables |
25
29
 
26
30
  The transaction sets the role with `SET LOCAL ROLE` and the token's claims in `request.jwt.claims` and `request.jwt.claim.sub`, so Supabase's `auth.uid()`, `auth.role()` and `auth.jwt()` work, and the tables' RLS policies decide what the request may read and write. The resources' access rules (`.access(...)`) do not apply here: a table needs its own grants and policies, and one without a grant to `anon` or `authenticated` refuses them. The `role` claim is the database role, as on Supabase. The database needs the `anon` and `authenticated` roles, and the app's role must be allowed to `SET ROLE` to them.
@@ -4,6 +4,10 @@
4
4
 
5
5
  The Storage-compatible /api/sb/storage/v1 that supabase-js's storage calls, over the app's file provider, under the restored storage policies.
6
6
 
7
+ :::caution[Beta]
8
+ Image transforms, resumable uploads, the S3 protocol and object versioning are not supported yet: see [What it serves](#what-it-serves).
9
+ :::
10
+
7
11
  An app with API keys also serves `/api/sb/storage/v1`, the API that supabase-js's `storage` calls. An app moved from Supabase keeps its uploads, downloads, public URLs and signed URLs: point the client at the app's `/api/sb` and give it the publishable key.
8
12
 
9
13
  ```ts
@@ -24,7 +28,7 @@ The keys are `apiKeys` in `protobase.config.ts`, `{ publishable_key, secret_key
24
28
  | The request sends | It runs as |
25
29
  | --- | --- |
26
30
  | the publishable key | `anon` |
27
- | either key and a signed-in user's token | `authenticated` |
31
+ | either key and a user's access token from [`/api/sb/auth/v1`](auth.md#supabase-js-and-supabasessr) | `authenticated` |
28
32
  | the secret key | the app's own role, which owns the tables |
29
33
 
30
34
  The role is set with `SET LOCAL ROLE` and the token's claims in `request.jwt.claims` and `request.jwt.claim.sub`, so Supabase's `auth.uid()` works in the policies. The database needs the `anon` and `authenticated` roles, and the app's role must be allowed to `SET ROLE` to them. Every read and write of `storage.objects` and `storage.buckets` runs as that role, with the token's claims set, so the restored policies decide, as on Supabase:
@@ -123,7 +123,7 @@ The list is `packages/ui/src/tokens.css`, and a test fails when one of its token
123
123
 
124
124
  ## Logo and favicon
125
125
 
126
- The sidebar shows Protobase's logo, its mark alone when the sidebar shows only icons, or, for a project with its own favicon, that icon and the app's name ([App identity](app-identity.md)). A project draws something else with `shell.logo` in `protobase.ui.tsx`, a component drawn in its place ([Shell slots](custom-components.md#shell-slots)); it draws with these tokens like the rest of the app.
126
+ The sidebar's logo is the project's own favicon, or Protobase's mark without one, beside the app's [name](app-identity.md#the-name), with the organization the user works in under it, and the icon alone when the sidebar shows only icons. An app that keeps Protobase's name and has no favicon of its own shows Protobase's wordmark beside the mark. A project draws something else with `shell.logo` in `protobase.ui.tsx`, a component drawn in its place ([Shell slots](custom-components.md#shell-slots)); it draws with these tokens like the rest of the app.
127
127
 
128
128
  The browser tab shows Protobase's mark. A project shows its own with a `protobase.favicon.svg` at its root, beside `protobase.theme.css`: the app's `index.html` links `/protobase.favicon.svg`, which `protobase build` writes to the bundle's `public/`, the project's file or Protobase's without one, and which `protobase dev` serves the same way. An SVG can follow the system's light or dark with a `prefers-color-scheme` media query inside it, as Protobase's does. PNGs beside it serve the platforms that take no SVG, such as an iPhone's home screen ([App identity](app-identity.md#the-icons)).
129
129