Skip to content

Commit 606faa0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24915-approval-node-translation-key
2 parents 00cadf4 + 7169612 commit 606faa0

133 files changed

Lines changed: 3599 additions & 1218 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/runtime': patch
5+
---
6+
7+
feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data change this relies on is the operator's ownership ceremony, `os migrate organization-ownership`, which an upgraded database must have passed before it boots. -->
12+
13+
**BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently.
14+
15+
- **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all.
16+
- **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone.
17+
- **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row.
18+
- **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer".
19+
- **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one.
20+
- **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth.
21+
22+
**What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
---
4+
5+
fix(driver-sql)!: on PostgreSQL, `introspectSchema` reads each table's columns in the table's own schema, and refuses a `search_path` that reaches one table name in two schemas instead of silently dropping one
6+
7+
`introspectSchema` lists the base tables of every schema in `current_schemas(false)` and keys the answer by bare table name. Two things went wrong when a connection's `search_path` named more than one schema (`ALTER DATABASE … SET search_path = public, sales`). First, each table's columns came from knex `columnInfo()` on the bare name, which reads only the first schema on the path, so a table that lives only in a later schema (`sales.orders`) was listed with no columns. `listRemoteTables` answered it `columnCount: 0`, and a draft built from it had no fields. Second, a same-named table in a later schema (`sales.customers` behind `public.customers`) was dropped without a word, because one key cannot hold two tables. The PostgreSQL branch now reads every table's columns in its own schema. When one bare name is reached in more than one schema, it refuses the whole introspection with `RESOURCE_CONFLICT` / `409`, naming each colliding table with its schemas in `search_path` order. A connection that reaches one schema, the default `public` deployment included, answers exactly what it answered before. The SQLite and MySQL branches are unchanged.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the SQL driver's PostgreSQL introspection gives for a connection whose search_path reaches more than one schema: a table in a later schema now carries its own columns, and a table name reached in two schemas is refused rather than answered with one of the two tables. The remedy is connection configuration (one schema per connection) or a rename on the remote database, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (introspection's accept set narrows on PostgreSQL), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** a PostgreSQL connection whose `search_path` reached a table name in two schemas got a successful `introspectSchema()` answer holding one of the two tables (the one in the earliest schema on the path), and so did every caller built on it: `listRemoteTables`, object drafting, catalog refresh and the external datasource's connection test.
16+
- **TO:** the same call throws `RESOURCE_CONFLICT` (`status: 409`), naming each colliding table and its schemas, for example `'customers' ('public', 'sales')`. Those callers surface that error.
17+
- **Also changed (a wrong answer made right, no accept set moved):** a table that lives only in a later schema on the path now reports its own columns where it reported none.
18+
- **Unchanged:** a connection that reaches one schema, the default `public` deployment included. One schema cannot hold two same-named tables, so it is never refused, and its answer is byte-identical to before.
19+
- **Fix:** point the connection at one schema (the datasource's `schema` setting sets its `search_path`), or rename one of the two tables on the remote database.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/metadata-core': patch
5+
---
6+
7+
fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (already-registered view-list-filterable-fields-removed, list-view-filterable-fields-retired) The key's retirement, its D2 conversion and its D3 entry are already registered by the change that tombstoned it, which shipped in 18.0.0. This change adds no key, conversion or stored shape: it makes `os lint` and the react-page props check give the verdict `os validate` and `os build` already give the list-view key, with the tombstone's own prescription. -->
12+
13+
**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
`@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position:
16+
17+
- **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement.
18+
- **The react-page props check** read `<ListView filterableFields={[…]}>` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved.
19+
20+
Both now refuse the key by name, as an `error`, whatever it names:
21+
22+
- a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves;
23+
- `<ListView filterableFields>` on a react page reports `react-prop-retired`.
24+
25+
The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`.
26+
27+
### FROM → TO
28+
29+
| before | after |
30+
| --- | --- |
31+
| `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` |
32+
| `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` |
33+
| `<ListView filterableFields={['status']} />` on a react page: passed every authoring command | `react-prop-retired` |
34+
35+
**The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand.
36+
37+
### The residue readers that go
38+
39+
- **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position.
40+
- **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it.
41+
- **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name.
42+
43+
⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state
6+
7+
Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)
8+
9+
Five row and record-header actions judged only the caller's standing, so Setup offered both halves of each pair on every row. Each now also reads the record's state, in the form `sys_oauth_application`'s Disable / Enable pair already uses:
10+
11+
- `sys_user` (Setup › Users): *Ban User* is offered when `banned` is not true, *Unban User* when it is. A timed ban past `ban_expires` still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
12+
- `sys_user`: *Unlock Account* is offered when `locked_until` is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on `sys_two_factor` and not on the user row, so a two-factor user keeps the button. An expired `locked_until` keeps it too: the failed-attempt counter stays at the threshold until Unlock or a successful sign-in resets it.
13+
- `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one.
14+
15+
The `sys_user` actions keep their platform-admin standing (`current_user.isPlatformAdmin == true`) first. A row that does not carry the field offers neither action of a pair. The console asks for every field an object action's predicate reads, so the default Users list carries `banned` without showing it.
16+
17+
Unchanged: labels, texts, targets and params, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still decided by the server.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
fix(cli): `os migrate apply` on an empty database writes the organization-ownership birth marker, so the fresh install's first `os serve` starts
6+
7+
Clause-②: yes
8+
9+
`os serve` (and so `os dev` and `os start`) serves a database this release created itself, recognised by the birth marker in `sys_migration` (row `adr-0131-organization-ownership`), or one the ownership ceremony certified. `os migrate apply` holds its schema DDL back until the operator confirms, so its boot saw no table created and wrote no marker, and the tables it then created carried none. A deploy script that ran `os migrate apply` before the first `os serve` was refused as a database "17.x or an 18.0 pre-release created", and sent through the upgrade ceremony.
10+
11+
- **Now.** When `os migrate apply` creates the database from empty, it writes the birth marker right after it creates the tables, and the first `os serve` starts. The log line is the one a serving boot prints: `[organization-ownership] a datastore created from empty: birth marker written (shape generation 1, …)`.
12+
- **Never on a database that was not empty.** The decision is the serving boot's own: the database counts as created from empty only when none of the tables this release declares existed before the apply. A database an earlier release created gains no marker from `os migrate apply`, even when the apply creates a table in it, and is still refused until the ceremony runs: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`.
13+
- **A failed write fails the command.** If the tables are created and the marker cannot be written, `os migrate apply` exits `1` and names the ceremony, which certifies such a database.
14+
- **Unchanged.** A database that already carries a marker keeps it as it is. `os migrate plan` writes nothing. On a Turso remote (`libsql://`) primary, `os migrate apply` creates no tables (its schema sync is skipped there), so the first serving boot creates them and writes the marker, as before.

‎content/docs/releases/v18/index.mdx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,47 @@ in one major (its D14) instead of spreading them across 17.x minors. While the
2121
line was open, every other breaking change that landed on `main` shipped on it
2222
too, under the same rules.
2323

24+
## Upgrading a database to 18.0.0
25+
26+
An existing database moves to 18.0.0 through the **organization-ownership
27+
ceremony**, `os migrate organization-ownership` (ADR-0131 D10).
28+
29+
- **A fresh install writes a birth marker.** The first boot that creates a
30+
datastore from empty writes a `sys_migration` row
31+
(`adr-0131-organization-ownership`) naming the ownership shape generation it
32+
was born in. A fresh 18.0.0 install never needs the ceremony, under any
33+
tenancy posture.
34+
- **A database without one is refused at boot.** A database 17.x or an 18.0
35+
pre-release (`18.0.0-next.*`) created carries no birth marker, so an 18.0.0
36+
server refuses it and names the ceremony command. Start 18.0.0 against it
37+
once (the refused boot still creates the tables 18.0.0 adds), take a backup,
38+
run `os migrate organization-ownership --out plan.json`, review the plan, run
39+
`os migrate organization-ownership --apply --plan-file plan.json --backup-taken`,
40+
and start the server again. The ceremony writes the same marker, verified,
41+
when its post-check passes.
42+
- **A development boot passes `--dev`.** On a deployment served with `--dev`
43+
(`os dev`, `os serve --dev`), for example a local one with no
44+
`OS_AUTH_SECRET`, both ceremony commands take `--dev`, as the refusal
45+
`os serve --dev` prints names them. `os dev` runs the ceremony itself for a
46+
local SQLite development database.
47+
- **Walled postures boot.** `OS_TENANCY_POSTURE=group` and `isolated` (and
48+
`OS_MULTI_ORG_ENABLED=true`) boot on a fresh install and on a database the
49+
ceremony upgraded, under the same prerequisites as 17.x: the enterprise
50+
`@objectstack/organizations` runtime and `OS_PLATFORM_OWNER_EMAIL`. The
51+
18.0 pre-releases refused them; 18.0.0 does not.
52+
- **A production `os serve` needs an auth secret.** A production `os serve`
53+
whose stack enables auth and which resolves no `OS_AUTH_SECRET` is refused
54+
before any plugin starts: with no AuthPlugin, no caller can
55+
authenticate and every row the boot wrote would have no owner. Set the
56+
secret, or serve without the data API (`--no-server`). `os dev` and
57+
`os serve --dev` use a development fallback and need none, and `os start`
58+
generates an auth secret on its first run and persists it to
59+
`<home>/auth-secret`.
60+
61+
The step-by-step is [Upgrading to 18.0](/docs/upgrading#upgrading-to-180), and
62+
every flag is in
63+
[`os migrate organization-ownership`](/docs/deployment/cli#os-migrate-organization-ownership).
64+
2465
## How a breaking stage lands on this line
2566

2667
- **Through `next` before `latest`.** Every major is published to the `next`

0 commit comments

Comments
 (0)