@protobase/cli 0.67.5 → 0.67.7

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.
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.67.5";
2
+ var version = "0.67.7";
3
3
  //#endregion
4
4
  export { version };
@@ -2,13 +2,14 @@
2
2
  /** Whether the app keeps tables its users add: the one reading of `userTables`, after `checkUserTables`. */
3
3
  const userTablesOn = (config) => config.userTables === true;
4
4
  /**
5
- * Refuses a `userTables` that is no boolean, and user tables together with organizations, whose definitions and records
6
- * are not kept per organization. Organizations are on for any truthy `auth.organizations`, as sign-in reads it.
5
+ * Refuses a `userTables` that is no boolean, and user tables in an API that trusts the organization tokens of an auth
6
+ * server elsewhere (`authenticate.organizations` without `auth.organizations`): it cannot read who owns or manages an
7
+ * organization, which decides who sees its tables. With the app's own organizations, each keeps its own tables.
7
8
  */
8
9
  const checkUserTables = (config) => {
9
10
  const value = config.userTables;
10
11
  if (value !== void 0 && typeof value !== "boolean") throw new Error(`userTables is ${JSON.stringify(value)}; it takes true or false`);
11
- if (userTablesOn(config) && config.auth && config.auth.organizations) throw new Error("userTables and auth.organizations are both on; tables users add are not kept per organization yet, so turn one of them off");
12
+ if (userTablesOn(config) && config.authenticate?.organizations && !(config.auth && config.auth.organizations)) throw new Error("userTables and authenticate.organizations are both on without auth.organizations; tables users add need the app's own organizations, so turn one of them off");
12
13
  return config;
13
14
  };
14
15
  //#endregion
@@ -7,7 +7,8 @@ import { userTableResources, userTablesSchema } from "@protobase/server/config-e
7
7
  const notServed = (name, problems) => problems.find((problem) => problem.startsWith(`The user table "${name}" `)) ?? `No table users added is named "${name}"`;
8
8
  /**
9
9
  * Each named table users added as the server serves it, with the constraints and indexes of its table, read from the
10
- * database at `url` in read-only sessions. A name the server serves no table of stops it, with why.
10
+ * database at `url` in read-only sessions. A name the server serves no table of, or an organization's table, stops it,
11
+ * with why.
11
12
  */
12
13
  const readEjected = async (url, names) => {
13
14
  const sql = connect(url);
@@ -17,6 +18,7 @@ const readEjected = async (url, names) => {
17
18
  const tables = names.map((name) => {
18
19
  const definition = served.definitions.find((each) => each.name === name);
19
20
  if (!definition) throw new Error(notServed(name, served.problems));
21
+ if (definition.organization_id !== null) throw new Error(`"${name}" belongs to an organization: take it over with \`protobase scaffold\`, which keeps its records in it`);
20
22
  return {
21
23
  table: relations.find((each) => each.schema === userTablesSchema && each.name === name),
22
24
  definition,
@@ -58,5 +58,13 @@ const makeLiveInDataFile = (text) => {
58
58
  chain.chainTop.replaceWithText(`${chain.chainTop.getText()}\n .live()`);
59
59
  return source.getFullText();
60
60
  };
61
+ const addTenantToDataFile = (text, field) => {
62
+ const source = parseSource(text);
63
+ const chain = resourceChain(source);
64
+ if (!chain) throw new Error("data.ts has no resource(...) builder chain to scope to its tenant");
65
+ if (methodCalls(chain.chainTop, "tenant").length > 0) return text;
66
+ chain.chainTop.replaceWithText(`${chain.chainTop.getText()}\n .tenant((r) => r.${field})`);
67
+ return source.getFullText();
68
+ };
61
69
  //#endregion
62
- export { addFieldsToDataFile, makeLiveInDataFile, renderDataFile };
70
+ export { addFieldsToDataFile, addTenantToDataFile, makeLiveInDataFile, renderDataFile };
@@ -0,0 +1,21 @@
1
+ import { definitionsSchema, userTablesSchema } from "@protobase/server/config-exports";
2
+ //#region src/scaffold/organization-tables.ts
3
+ /**
4
+ * The tables users add that belong to an organization, as `schema.table` with the organization's id, from their
5
+ * definitions: none when the database keeps no definitions, or none made with organizations.
6
+ */
7
+ const readOrganizationTables = async (sql) => {
8
+ const [found] = await sql`select exists (select from information_schema.columns
9
+ where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'organization_id') as organizations`;
10
+ if (!found?.organizations) return /* @__PURE__ */ new Map();
11
+ const rows = await sql`select table_name, organization_id
12
+ from ${sql(definitionsSchema)}.tables where organization_id is not null`;
13
+ return new Map(rows.map((row) => [`${userTablesSchema}.${row.table_name}`, row.organization_id]));
14
+ };
15
+ /**
16
+ * What scaffold says when it takes over an organization's table: whose definition it took, that the records keep their
17
+ * organization, and that the access rule the server gave the table does not come along.
18
+ */
19
+ const takenOverLine = (table, organizationId) => `${table}: taken over from the definition of organization ${organizationId}; its records keep their organization through .tenant((r) => r.organization_id). Served from its definition, only that organization's owners and admins reached it; the resource in code follows the project's access rules instead, so add .access(...) to keep it to them`;
20
+ //#endregion
21
+ export { readOrganizationTables, takenOverLine };
@@ -3,8 +3,9 @@ import { keyColumns } from "../db/keys.js";
3
3
  import { protobaseSchemas } from "./protobase-tables.js";
4
4
  import { camelCase } from "../infer/names.js";
5
5
  import { findField } from "./existing-config.js";
6
+ import { takenOverLine } from "./organization-tables.js";
6
7
  import { proposeField } from "../infer/proposal.js";
7
- import { addFieldsToDataFile, makeLiveInDataFile, renderDataFile } from "./data-file.js";
8
+ import { addFieldsToDataFile, addTenantToDataFile, makeLiveInDataFile, renderDataFile } from "./data-file.js";
8
9
  import { detectDrift } from "./drift.js";
9
10
  import { ensureExports } from "./index-file.js";
10
11
  import { assignNames } from "./resource-names.js";
@@ -96,8 +97,11 @@ const planScaffold = async (input) => {
96
97
  const clash = decisions.find((d) => current.fields.some((f) => f.name === d.proposal.name));
97
98
  if (clash) throw new Error(`${key}: field "${clash.proposal.name}" already exists, edit the name`);
98
99
  const file = path.join(configDir, current.dir, "data.ts");
100
+ const organization = input.organizationTables?.get(key);
99
101
  const fielded = addFieldsToDataFile(current.text, decisions);
100
- changes.push(...change(file, current.text, isLive(key) ? makeLiveInDataFile(fielded) : fielded));
102
+ const scoped = organization === void 0 ? fielded : addTenantToDataFile(fielded, "organization_id");
103
+ if (organization !== void 0) warnings.push(takenOverLine(key, organization));
104
+ changes.push(...change(file, current.text, isLive(key) ? makeLiveInDataFile(scoped) : scoped));
101
105
  if (isLive(key)) live.push(key);
102
106
  drift.push(...detectDrift(current, table, context(table)));
103
107
  await writeUi(current.dir, current.name, current.identifier, table, current, decisions);
@@ -1,6 +1,7 @@
1
1
  import { connect } from "../db/connect.js";
2
2
  import { introspect } from "../db/introspect.js";
3
3
  import { readExistingResources } from "./existing-config.js";
4
+ import { readOrganizationTables } from "./organization-tables.js";
4
5
  import { parseTarget } from "./targets.js";
5
6
  import { planScaffold } from "./plan.js";
6
7
  import { acceptAll, interactive } from "./prompt.js";
@@ -31,6 +32,7 @@ const runScaffold = async (options, out) => {
31
32
  target: parseTarget(options.target),
32
33
  excludes: options.excludes,
33
34
  live: options.live,
35
+ organizationTables: await readOrganizationTables(sql),
34
36
  decide: options.decide ?? prompter?.decide ?? acceptAll,
35
37
  ui: options.ui,
36
38
  readText
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.67.5",
3
+ "version": "0.67.7",
4
4
  "description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,12 +43,12 @@
43
43
  "dependencies": {
44
44
  "@better-auth/core": "^1.7.12",
45
45
  "@hono/node-server": "^2.1.3",
46
- "@protobase/config": "0.67.5",
47
- "@protobase/layout": "0.67.5",
48
- "@protobase/query": "0.67.5",
49
- "@protobase/schema": "0.67.5",
50
- "@protobase/server": "0.67.5",
51
- "@protobase/ui": "0.67.5",
46
+ "@protobase/config": "0.67.7",
47
+ "@protobase/layout": "0.67.7",
48
+ "@protobase/query": "0.67.7",
49
+ "@protobase/schema": "0.67.7",
50
+ "@protobase/server": "0.67.7",
51
+ "@protobase/ui": "0.67.7",
52
52
  "@react-email/render": "^2.1.0",
53
53
  "@scalar/hono-api-reference": "^0.12.10",
54
54
  "@sentry/vite-plugin": "^11.10.0",
@@ -7,3 +7,4 @@ Tables an app's users add from the admin UI, by hand or from a file, and how a d
7
7
  - [Tables defined in the database](../reference/user-tables.md#tables-defined-in-the-database) (beta): An app can keep tables its users add in its own database, beside the resources in its code. Each one is served like any other resource, at its own address with its list, record page, search and API. Every server process serves a new table within a second of it being added.
8
8
  - [Adding tables and fields](../reference/user-tables.md#adding-tables-and-fields) (beta): Someone with the right capability creates a table with its fields, or adds a field to it, through the app's API. It is created in the database at once, with an index for every field but JSON so its list can be filtered by it, and sorted by it unless it is long text, and only admins can see it until access is widened.
9
9
  - [Taking a table over as code](../reference/cli.md#eject) (beta): `protobase eject` turns a table someone added in the admin app into ordinary config and a migration in the project, so a developer can customise it like any other resource. Its records stay where they are.
10
+ - [Tables per organization](../reference/user-tables.md#organizations) (beta): In an app with organizations, a table someone adds belongs to their organization. Only its owners and admins, and superusers working in it, see the table and its records.
@@ -636,7 +636,7 @@ auth: {
636
636
 
637
637
  It is Better Auth's organization plugin, with its tables named in snake_case (`organization`, `member`, `invitation`, `session.active_organization_id`); every store has them, and `user.last_organization_id`, with organizations on or off (see [migrations](#migrations)).
638
638
 
639
- **Two kinds of role.** Every membership has an organization role, Better Auth's `owner`, `admin` or `member`, which decides who manages the organization: owners and admins invite, remove and change members and edit its details; only owners hand it over, delete it or make owners. App roles, the project's `roles`, decide what the app shows and allows. A user holds them **globally** (the admin plugin's `role` column, as without organizations), which applies in every organization, or **per membership** (`member.app_roles`), which applies there only. A request's role, `ctx.user.roles`, is the session's [active role](#active-role), of either kind; access rules never see the organization role. `admin` is the superuser: global only, it holds every role. With organizations every role needs a label; `owner` and `member` cannot be app role names, and `user` stands for an account without a global role.
639
+ **Two kinds of role.** Every membership has an organization role, Better Auth's `owner`, `admin` or `member`, which decides who manages the organization: owners and admins invite, remove and change members and edit its details; only owners hand it over, delete it or make owners. App roles, the project's `roles`, decide what the app shows and allows. A user holds them **globally** (the admin plugin's `role` column, as without organizations), which applies in every organization, or **per membership** (`member.app_roles`), which applies there only. A request's role, `ctx.user.roles`, is the session's [active role](#active-role), of either kind; access rules see the organization role as `ctx.organization.role`, read from the membership on each request, absent for someone who is no member there, such as a superuser who entered it. `admin` is the superuser: global only, it holds every role. With organizations every role needs a label; `owner` and `member` cannot be app role names, and `user` stands for an account without a global role.
640
640
 
641
641
  **Who gives which role.** Someone may give or take away a member's app role only if they hold it, in that organization or globally, or hold a role whose `grants` list it (`manager` above gives `accountant` without holding it); grants do not chain, and a superuser may give any. Only a superuser changes global roles. The creator of an organization becomes its owner with every app role (`creatorAppRoles`, default `'all'`), so they can give them on; for a role added later, list it in a role owners hold, or run `protobase organizations add-app-role <role> --to owners`.
642
642
 
@@ -146,7 +146,7 @@ Takes [tables users added](user-tables.md) over as code. The URL comes as for [`
146
146
  - **`ui.ts`** has the definition's singular and plural label, its title field, each field's label, a long text field multiline, and the list `scaffold --ui` writes.
147
147
  - **The migration** makes each table as the catalogue has it: `create schema if not exists`, `create extension if not exists pg_trgm` when an index needs it, `create table if not exists` with the columns and constraints, and `create index if not exists` for each index, by its name. On the database the table is in it changes nothing and moves no row; on an empty one it makes the same table.
148
148
 
149
- Then it runs [`doctor`](#doctor) on the config. When doctor reports an error in an ejected resource, or cannot load the config, every file is put back as it was and the command fails. It refuses a name no definition the server serves has, saying why the server leaves it out, and one a resource in `config/` has, or whose table one reads, already.
149
+ Then it runs [`doctor`](#doctor) on the config. When doctor reports an error in an ejected resource, or cannot load the config, every file is put back as it was and the command fails. It refuses a name no definition the server serves has, saying why the server leaves it out, and one a resource in `config/` has, or whose table one reads, already. It refuses a table that belongs to an [organization](user-tables.md#organizations), which `scaffold` takes over instead.
150
150
 
151
151
  ```sh
152
152
  protobase eject notes # config/notes/data.ts, config/notes/ui.ts, db/migrations/NNN_eject_notes.sql
@@ -178,7 +178,7 @@ export default defineConfig(({ env }) => ({
178
178
  - **Type:** `boolean`
179
179
  - **Default:** `false`
180
180
 
181
- `true` keeps the tables the app's users add in its own database, in the `table_definitions` and `user_tables` schemas that [`protobase migration`](../cli.md#migration) writes, and serves each one like a resource in code. It cannot be on together with organizations. See [User tables](../user-tables.md).
181
+ `true` keeps the tables the app's users add in its own database, in the `table_definitions` and `user_tables` schemas that [`protobase migration`](../cli.md#migration) writes, and serves each one like a resource in code. With [organizations](../auth.md#organizations), each organization keeps its own tables; an API that trusts the organization tokens of an auth server elsewhere (`authenticate.organizations` without `auth.organizations`) cannot turn it on. See [User tables](../user-tables.md).
182
182
 
183
183
  ```ts
184
184
  export default defineConfig({ userTables: true })
@@ -203,7 +203,7 @@ export default defineConfig({ webhooks: { deliveryLog: true } })
203
203
  - **Type:** `boolean`
204
204
  - **Default:** `false`
205
205
 
206
- Lets subscriptions send to private, loopback and link-local addresses, for development and tests. Off, a URL whose host resolves to one is refused, so a subscription cannot reach the app's own network.
206
+ Lets subscriptions send to private, loopback and link-local addresses, for development and tests. Off, a URL whose host resolves to one is refused when the subscription is saved and again at each delivery, which connects only to the address it checked, so a subscription cannot reach the app's own network.
207
207
 
208
208
  ```ts
209
209
  export default defineConfig(({ command }) => ({ webhooks: { allowPrivateAddresses: command === 'dev' } }))
@@ -9,13 +9,11 @@ An app's resources usually come from its code: a resource in `config/` over a ta
9
9
  ## Tables defined in the database
10
10
 
11
11
  :::caution[Beta]
12
- Tables are added through the API; the admin app has no screens for them yet, and an app with organizations cannot use them.
12
+ Tables are added through the API; the admin app has no screens for them yet.
13
13
  :::
14
14
 
15
15
  An app can keep tables its users add in its own database, beside the resources in its code. Each one is served like any other resource, at its own address with its list, record page, search and API. Every server process serves a new table within a second of it being added.
16
16
 
17
- <gap>Tables users add cannot be used in an app with organizations because keeping their definitions and records per organization is not implemented.</gap>
18
-
19
17
  <gap>Users cannot add a table from the admin app yet because its screens for tables and fields are not implemented.</gap>
20
18
 
21
19
  ### The switch
@@ -26,7 +24,7 @@ User tables are off by default. Turn them on in `protobase.config.ts`:
26
24
  export default defineConfig({ userTables: true })
27
25
  ```
28
26
 
29
- `userTables` takes only `true` or `false`. Together with organizations (`auth.organizations`), `dev`, `build` and `serve` stop with an error that names both settings.
27
+ `userTables` takes only `true` or `false`. With [organizations](#organizations), each organization keeps its own tables. An API that trusts the organization tokens of an auth server elsewhere (`authenticate.organizations` without `auth.organizations`) cannot read who manages an organization, so there `dev`, `build` and `serve` stop with an error that names both settings.
30
28
 
31
29
  ### The schemas and the migration
32
30
 
@@ -34,8 +32,8 @@ The tables users add and their definitions live in two schemas of the app's data
34
32
 
35
33
  | Schema | What it holds |
36
34
  | --- | --- |
37
- | `table_definitions` | `tables`, one row per table users add, with its singular and plural label, its title field and a version, 1 when it is added, and `fields`, each table's fields in order with their label, type and whether they are required |
38
- | `user_tables` | the tables themselves, each with an `id bigint generated always as identity` primary key and a column per field |
35
+ | `table_definitions` | `tables`, one row per table users add, with its singular and plural label, its title field, a version, 1 when it is added, its organization with organizations, and its table's name, and `fields`, each table's fields in order with their label, type and whether they are required |
36
+ | `user_tables` | the tables themselves, each with an `id bigint generated always as identity` primary key and a column per field, and with organizations an `organization_id` column |
39
37
 
40
38
  With `userTables: true`, [`protobase migration`](cli.md#migration) writes a migration that adds both schemas, the two definition tables and the `pg_trgm` extension when it is missing, and grants the app's database role CREATE on `user_tables` alone, and writes nothing once the database has them. Apply it with the project's other migrations; the server never creates them itself. At start, `dev` and `serve` stop while one is missing; `PROTOBASE_STARTUP_CHECKS=false` skips that check in `serve`, as it skips the others.
41
39
 
@@ -56,7 +54,7 @@ A table's name and each field's name are lowercase letters, digits and `_`, star
56
54
 
57
55
  When the server starts, and whenever the definitions change, it builds a resource from each definition and serves it at `/<table>` and `/<table>/<key>`, with its list, record page, sidebar entry and REST API, and in `/meta`, as the resources in code are. The labels of the definition are the names the admin app shows.
58
56
 
59
- - **Admins only.** Each table starts readable and writable by admins alone, and is left out of role wildcards such as `*.read`, like [resources that need named grants](access.md#resources-that-need-named-grants). Anyone else does not see it.
57
+ - **Admins only.** Each table starts readable and writable by admins alone, and is left out of role wildcards such as `*.read`, like [resources that need named grants](access.md#resources-that-need-named-grants). Anyone else does not see it. With organizations, [its organization's owners and admins](#organizations).
60
58
  - **Filters, sorts and search follow the indexes.** A field can be filtered or sorted by only where an index of its table serves it, and the table is searched on its title field only where a `pg_trgm` index serves that field, as the [scan guard](config/api.md#scanguard) requires.
61
59
  - **A definition that does not fit is left out.** A definition with a name or type outside the rules above, or whose table or columns do not match it, is not served, and the server prints why at start. The rest of the app serves.
62
60
  - **Code wins.** A resource in code with the name of a user table, or a resource Protobase adds, is served instead of the definition, which is then ignored with a warning. This is how a table users added is taken over as code.
@@ -94,7 +92,7 @@ With `userTables: true`, the API has two resources over the definitions, with th
94
92
 
95
93
  | Resource | A record | Fields |
96
94
  | --- | --- | --- |
97
- | `user_tables` | a table, at `/user_tables/<id>` | `name`, `singular_label`, `plural_label`, `title_field`; `id` and `version` are read-only |
95
+ | `user_tables` | a table, at `/user_tables/<id>` | `name`, `singular_label`, `plural_label`, `title_field`; `id`, `version` and `table_name`, the name of its table and its address, are read-only |
98
96
  | `user_table_fields` | a field of a table, at `/user_table_fields/<table_id>,<name>` | `table_id`, `name`, `label`, `type` (one of the [types](#the-schemas-and-the-migration)), `position` (the order of the fields), `precision` and `scale` (a decimal's alone) |
99
97
 
100
98
  Make a table with its fields in one [batch write](api.md#batch-writes): the table, each field pointing at it, then the title field, which names a field that exists. Nothing is created unless all of it is:
@@ -111,10 +109,10 @@ POST /api/v1:batchWrite
111
109
  }
112
110
  ```
113
111
 
114
- The answer has the table's record, so a client links to the new table at `/notes`. Add a field later with `POST /api/v1/user_table_fields`.
112
+ The answer has the table's record, so a client links to the new table at its `table_name`, `/notes`. Add a field later with `POST /api/v1/user_table_fields`.
115
113
 
116
114
  - **What is created.** A table is `user_tables.<name>` with its `id bigint generated always as identity` key. A field is a column of its type that may be empty and has no default, so the records already there read it as empty, with a btree index, or a `pg_trgm` one for `long_text`, whose values outgrow a btree entry; a `json` field gets none. The title field gets the `pg_trgm` index search needs. The rows, the table or column, its indexes and the version are written in one transaction: anything refused leaves none of them.
117
- - **What is refused.** A name outside the rules above, a word SQL reserves such as `select`, a field named `id` or `etag`, the name of a resource in code, of one Protobase adds or of a table `user_tables` already has, a field marked required, and a title that is not a text field of the table, each as a field error. A table whose name a resource in code has now, which is how a table is taken over as code, takes no new field.
115
+ - **What is refused.** A name outside the rules above, a word SQL reserves such as `select`, a field named `id`, `etag` or `organization_id`, the name of a resource in code, of one Protobase adds or of a table `user_tables` already has, a field marked required, and a title that is not a text field of the table, each as a field error. A table whose name a resource in code has now, which is how a table is taken over as code, takes no new field.
118
116
  - **What changes later.** The labels, the title field and the fields' order. A name, a type, a decimal's precision and scale never change, and no table or field is deleted. <gap>A table or field cannot be renamed, retyped or removed because changing the columns of a table users add is not implemented.</gap>
119
117
  - **Who may.** With the project's [roles](access.md), the capabilities `user_tables.create` and `user_table_fields.create`, which only `*` or a grant that names them holds, never a wildcard such as `*.create`, like [resources that need named grants](access.md#resources-that-need-named-grants); reading and changing the labels need `.read` and `.update` the same way. Without roles, admins. Each new table is its admins' alone, as [each one served](#how-each-one-is-served) is.
120
118
  - **The rest of the app.** The process that made the change serves it before it answers, and every other process [within a second](#how-a-change-reaches-every-process). While a deploy freezes writes, a create answers as any write does then.
@@ -139,3 +137,19 @@ When a table needs more than the admin app gives it, such as a validation rule,
139
137
  Once the code serves, the table takes no new field from the admin app, and a new table cannot take its name. The resource starts as the admins' alone, as it was served; widen its access in `data.ts` as for any resource.
140
138
 
141
139
  <gap>A table cannot be taken over from the admin app or the assistant because an eject that writes to the project's repository is not implemented.</gap>
140
+
141
+ ## Organizations
142
+
143
+ :::caution[Beta]
144
+ A table belongs to one organization and is never shared with another. Only the organization's owners and admins reach it; access cannot be widened to its other members yet.
145
+ :::
146
+
147
+ In an app with organizations, a table someone adds belongs to their organization. Only its owners and admins, and superusers working in it, see the table and its records.
148
+
149
+ - **Who makes and sees them.** The owners and admins of the organization a session works in, as its [organization role](auth.md#organizations) stands on that request, and superusers working in it. They make a table, add its fields and work with its records as [above](#adding-tables-and-fields); the project's roles and their capabilities do not widen it. Anyone else, in another organization or in none, gets not found at its address, and does not see it in `/meta`, the sidebar, search, the API document or the assistant. A member who is demoted or removed loses it on their next request. <gap>Members of an organization other than its owners and admins cannot see its tables because widening their access is not implemented.</gap>
150
+ - **Its records.** Each table is a `.tenant` resource over its `organization_id` column, as [organizations](auth.md#organizations) describe: the server sets it on every record from the session, a request body cannot, and every query reads only the organization's own.
151
+ - **Names.** A name is unique within an organization, so two organizations can each have a `contacts` table. Its table, and its address, is `t<id>_<name>`, such as `/t12_contacts`, with `<id>` the definition's; the labels the app shows are the ones it was given. With organizations a table's name is at most 42 bytes, and may be the name of a resource in code.
152
+ - **Taking one over as code.** `protobase eject` refuses an organization's table, as the resource it writes would not keep its records in the organization. Declare a resource over its table instead, such as `resource('t12_contacts').table('user_tables.t12_contacts')`, and run [`protobase scaffold`](cli.md#scaffold): it adds the fields and `.tenant((r) => r.organization_id)`, so the records stay in their organization, and prints which organization's definition it took. The resource in code follows the project's access rules, not the owners-and-admins rule the definition had, so give it the `.access(...)` it needs.
153
+ - **A table made before organizations.** One added while the app had no organizations belongs to none, and stays its superusers' alone.
154
+
155
+ Every process serves the tables of every organization, so [a rebuild](#how-a-change-reaches-every-process) follows a change in any organization, and the times measured there count the tables of all organizations together.
@@ -113,7 +113,7 @@ export default defineConfig({ webhooks: {} })
113
113
 
114
114
  It adds two pages, over a `webhooks` schema that [`protobase migration`](cli.md#migration) writes (`NNN_webhooks.sql`):
115
115
 
116
- - **Webhook subscriptions** (`webhook_subscriptions`, the table `webhooks.subscriptions`): a name, the events (`orders.created orders.large`, or `orders.*`), the URL, a filter, extra headers, full or thin payload, and whether it is enabled. The server sets the tenant and the owner, the person who made it, and makes the secret, which only a [reveal](api.md#sensitive-fields-and-audit) shows. Saving checks the events and the filter against the resources, and refuses a URL whose host is a private, loopback or link-local address.
116
+ - **Webhook subscriptions** (`webhook_subscriptions`, the table `webhooks.subscriptions`): a name, the events (`orders.created orders.large`, or `orders.*`), the URL, a filter, extra headers, full or thin payload, and whether it is enabled. The server sets the tenant and the owner, the person who made it, and makes the secret, which only a [reveal](api.md#sensitive-fields-and-audit) shows. Saving checks the events and the filter against the resources, and refuses a URL whose host is a private, loopback or link-local address. Each delivery checks the host's addresses again and connects to the very address it checked, so a host that answers differently by then cannot steer it into the app's own network; a redirect is never followed.
117
117
  - **Webhook deliveries** (`webhook_deliveries`, a view over the delivery jobs): the deliveries that wait for their next try or failed for good, with their last error. A delivery leaves the list once it succeeds.
118
118
 
119
119
  The migration gives the subscriptions table an index for each sort, filter and search of its list, and creates the `pg_trgm` extension the search's indexes need (`create extension if not exists pg_trgm`, a trusted extension since Postgres 13, so any role that may create in the database can). A database that has an earlier version's schema gets the indexes from the next `protobase migration`; until it is applied the server still starts, and refuses only the list's reads that need them.