Skip to content

Commit c84fbed

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-7725-accept-invitation-adopt-membership
Resolved packages/plugins/plugin-auth/src/objectql-adapter.ts: the only conflict was the import block, where #7825's session-tombstone helpers and this branch's adoptExistingMembership landed on the same line. Both imports are kept — the two features hook disjoint adapter methods (adoption on `create`, tombstoning on `findOne`/`findMany`/`delete`/`deleteMany`), so neither side may be taken wholesale.
2 parents 530b760 + 3f296bf commit c84fbed

164 files changed

Lines changed: 14989 additions & 1746 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
fix(platform-objects): one decision, one dialog — carry identity confirm questions on `description` (#7309)
6+
7+
The shared console action runner chains confirmation **then** param collection,
8+
both awaited (objectui `packages/core/src/actions/ActionRunner.ts`). An action
9+
declaring `confirmText` **and** `params` therefore opened **two** sequential
10+
dialogs for one click, with nothing sent until the second — while the first
11+
already read as "the action ran".
12+
13+
The maintainer's 2026-08-10 ruling on #7278 (shipped in PR #7592) is to carry the
14+
confirm question in the action's top-level `description` (#7367), which the param
15+
dialog renders under its title, and to drop `confirmText`. #7278 applied it to the
16+
two `plugin-approvals` actions; this change sweeps the **14** remaining in-repo
17+
action sites, all in `identity/`:
18+
19+
| object | actions |
20+
|---|---|
21+
| `sys_user` | `ban_user`, `delete_my_account`, `disable_two_factor`, `generate_backup_codes` |
22+
| `sys_oauth_application` | `enable_oauth_application`, `disable_oauth_application`, `rotate_client_secret`, `delete_oauth_application` |
23+
| `sys_two_factor` | `disable_two_factor`, `regenerate_backup_codes` |
24+
| `sys_account` | `unlink_account` |
25+
| `sys_organization` | `change_slug` |
26+
| `sys_sso_provider` | `delete_sso_provider` |
27+
| `sys_team_member` | `remove_team_member` |
28+
29+
**No warning was reworded and none was dropped** — each question moves verbatim
30+
from `confirmText` to `description`, so the user still reads it before committing,
31+
now in the one dialog that collects the params. `sys_oauth_application.rotate_client_secret`
32+
went from three dialogs to two: one param dialog (question + `client_id`), then the
33+
existing post-run `resultDialog` that reveals the new secret. That reveal is output
34+
shown once *after* the rotation, not a second pre-run decision, so it stays.
35+
36+
**`confirmText` is untouched where it is still correct.** A param-LESS action has
37+
no param dialog to fold the question into, so the confirm *is* its only dialog —
38+
`delete_organization`, `leave_organization` and `impersonate_user` keep theirs.
39+
40+
The `en` / `zh-CN` / `ja-JP` / `es-ES` bundles move the same 14 leaves by hand.
41+
`os i18n extract` treats a renamed key as a new gap and this repo extracts with
42+
`--fill=default`, which would have seeded English over the curated translations
43+
in three of the four shipped locales — invisible to `check:i18n`, whose fresh
44+
extract would agree with the English it just wrote. A carryover test pins each
45+
locale as translated rather than echoing the English source.
46+
47+
Tests pin the user-visible consequence in both directions: that an action
48+
carrying params opens one dialog, **and** that its question is still shown.
49+
Deleting a warning instead of moving it goes red on `ban_user`,
50+
`delete_my_account` and `rotate_client_secret` — the failure a "no `confirmText`
51+
anywhere" grep cannot see.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec-tooling): a hard-wrapped mention no longer reads as a migration label (#7094)
6+
7+
Branch 1 of the ADR-0087 completeness gate decides whether the `FROM`/`TO`
8+
placeholder is being **used** as a label or merely **mentioned** in a sentence, by
9+
looking at the one character to its left. Prose in this repo is hard-wrapped at ~80
10+
columns, so a mention that happens to wrap onto a fresh line has *nothing* to its
11+
left — its governing word is on the line above — and was read as a declaration.
12+
That direction hard-blocks an author: all four dispositions close at once and the
13+
only remaining move is to reword a sentence that was true.
14+
15+
When the placeholder opens its line, the question is now asked of the line above
16+
instead — and it is a **narrower** question there. Within a line, adjacency is the
17+
evidence and any attached word governs. A line break is ambiguous by construction, so
18+
across it only a closed class of determiners, possessives and complement-taking
19+
prepositions counts: `carry their` continues into the next line, `ends the line here`
20+
does not. A structural line above (heading, table row, fence marker) does not wrap at
21+
all, and a blank line above opens a paragraph — both keep their previous reading.
22+
23+
Measured over the whole 1792-changeset stock before and after: 176 hits / 132
24+
declared-breaking → 174 / 132. The two that leave are `changelog-ships-in-tarball.md`
25+
(the reported specimen) and `notification-retirement-evidence-corrected.md`, both
26+
mentions on inspection and **neither declaring a breaking change**. The
27+
`--audit-stock` worklist is byte-identical — residue 97, `!` candidates 52 — so no
28+
declared-breaking changeset changed state. The gate's diff-only posture (#6129) is
29+
untouched, and no ledger entry is added or implied.
30+
31+
The wider version of this fix — reusing the in-line "any letter governs" test across
32+
the wrap — was written first and rejected by its own positive control, which is
33+
pinned as an assertion rather than described: it turned every label wrapped under an
34+
ordinary sentence into a mention. The self-test gains 11 assertions (142 → 153)
35+
covering both directions.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(platform-objects,plugin-auth): let the API-key revoke/restore actions actually run (#7727)
7+
8+
`sys_api_key` contradicted itself. It declared two row actions —
9+
`revoke_api_key` / `restore_api_key` — as `PATCH /api/v1/data/sys_api_key/{id}`
10+
with `bodyExtra: { revoked: true|false }`, while the same object set
11+
`enable.apiMethods = ['get', 'list']`. The declared PATCH was refused at the
12+
ADR-0049 method gate with `405 OBJECT_API_METHOD_NOT_ALLOWED` before any
13+
authorization ran, so **no product route revoked an API key**: the Setup →
14+
API Keys → Revoke button produced an error toast, the row still read
15+
`revoked = false`, and the key kept authenticating. A leaked key could only be
16+
retired by writing the row out of band.
17+
18+
Enforcement of the flag was never the problem — the verifier filters
19+
`revoked: false` and re-checks the row, so a flipped bit takes effect on the
20+
very next `x-api-key` call. The missing piece was purely the write path, and it
21+
had **two** gates, not one:
22+
23+
- **The method gate.** `enable.apiMethods` now carries `update`. `create` and
24+
`delete` stay off: minting is `POST /api/v1/keys` (the only path that ever
25+
returns the raw secret) and keys are retired by revoking, not deleting.
26+
- **The affordance reconciler.** ADR-0103's `reconcileManagedApiMethods` strips
27+
any write verb a `managedBy` object's resolved affordances do not grant —
28+
warning, not failing. So `apiMethods` alone would still have served 405 while
29+
the source read correctly. `userActions: { edit: true }` declares the
30+
affordance, exactly as `sys_user` does under ADR-0092 D4.
31+
32+
**Opening the method does not open the columns.** `sys_api_key` stays
33+
`managedBy: 'better-auth'`, so ADR-0092 D2's identity write guard still
34+
fail-closed rejects user-context writes, and its per-object update whitelist
35+
remains the only opening. `revoked` is registered there and nothing else is:
36+
`key` stays unwritable (a rotated hash would mint a credential nobody holds),
37+
`user_id` stays unwritable (re-owning a key is privilege transfer), and
38+
`expires_at` stays on the mint path. A PATCH carrying only non-whitelisted
39+
columns is refused `403 PERMISSION_DENIED` rather than degrading into a
40+
timestamp touch, and a mixed patch applies `revoked` while stripping the rest.
41+
The guard itself is unchanged — no general weakening, and every other identity
42+
table keeps its default-deny.
43+
44+
Per ADR-0092 D4's form-rendering constraint, the columns outside the whitelist
45+
(`name`, `prefix`, `user_id`, `scopes`, `expires_at`) are now `readonly`, so the
46+
edit form this affordance turns on cannot offer a write the server refuses —
47+
the declared-≠-enforced shape that caused the original defect.
48+
49+
Nothing pinned any of this before: the existing tests exercise key *resolution*
50+
against a pre-revoked row and never call the route the actions declare, which is
51+
how a declared action and a method gate cancelled out unnoticed. The new
52+
`api-key-revoke-lifecycle` dogfood suite drives the real PATCH, asserts `200`,
53+
and then asserts the consequence — the key stops authenticating — because a 200
54+
that leaves the key working is the defect wearing a success code.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
Recompute `unit_and_subordinates` / `business_unit` sharing-rule grants when the business-unit graph changes.
6+
7+
**Security:** read access granted through a business-unit sharing rule is now withdrawn immediately when the business unit is moved out of the shared subtree, deactivated or deleted, or when the membership row is removed — previously it survived until the shared record happened to be written next, which put no bound at all on how long a revoked recipient kept reading the record.
8+
9+
The sharing-rule recompute hooks were registered only on each rule's own object, and nothing was registered on `sys_business_unit` or `sys_business_unit_member`. A rule whose recipient resolves through the business-unit tree therefore never re-materialised its `sys_record_share` grants when the tree or a membership moved. Writes to both tables now drive a recompute: the withdrawal is synchronous and complete before the write returns (scoped by recipient, so it needs no scan of the records the rule matches), and the grant direction — a unit moved *into* a shared subtree — is queued on the existing re-grant queue.
10+
11+
Only recipient kinds that actually read the business-unit graph are recomputed (`business_unit`, `unit_and_subordinates`); `user`, `team`, `position` and `queue` rules are untouched by a business-unit write.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os dev` refuses to serve a console built from a different objectui SHA than the pin (#7752)
6+
7+
`packages/console/dist` is a gitignored local build that only
8+
`scripts/build-console.sh` (`pnpm objectui:build`) refreshes — `turbo run
9+
build` never touches it. Pull a branch that moves `.objectui-sha` and the pin
10+
advances while the dist stays frozen, so the server keeps serving a Console
11+
SPA the repo no longer pins.
12+
13+
`pnpm check:console-sha` already fails on exactly this, but it is wired into
14+
the root `pnpm dev` / `dev:showcase` / `dev:crm` / `dev:todo` scripts only.
15+
Every other way to boot reaches the server without passing it — `objectstack
16+
dev` run inside an example dir, an example's own `dev` script (`objectstack
17+
dev --seed-admin`), a `.claude/launch.json` config driving `pnpm exec
18+
objectstack dev`. A QA sweep booted that way and spent its run measuring a
19+
console two days behind the pin; two of its clauses had to be recorded
20+
`blocked` once the gap was found.
21+
22+
So the guard gets a second seat on the boot path itself. When the dist carries
23+
an objectui stamp that provably differs from the repo's pin, `os dev` now
24+
declines to mount `/_console` and prints the rebuild remediation, instead of
25+
serving the stale bundle under a warning that scrolls past. The API still
26+
boots, so api/cli work is unaffected, and the banner stops advertising a
27+
console URL that would have been a lie.
28+
29+
Deliberately narrow: dev only, and only on drift it can prove. A published
30+
install ships no `.objectui-sha` pin and the sibling-repo dev fallback writes
31+
no stamp — both keep resolving exactly as before, and no production or cloud
32+
deployment can reach the refusal. `OS_ALLOW_CONSOLE_DRIFT=1` boots the stale
33+
bundle anyway when that is what you want.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(plugin-email): a `sendTemplate` with no locale renders the documented en-US default, not an arbitrary row (#7731)
7+
8+
With an i18n bundle in `sys_email_template` — `en-US` and `zh-CN` rows under one
9+
name — a `sendTemplate` call that named no `locale` rendered **zh-CN**, on two
10+
consecutive fresh boots. Three declarations say en-US is the answer there
11+
(`SendTemplateInput.locale`, `EmailTemplateDefinitionSchema.locale`, and
12+
`sys_email_template`'s own object doc); the code asked the driver instead.
13+
14+
Two seams, both now answering from the contract:
15+
16+
- The `sys_email_template` loader moved out of `EmailServicePlugin` into
17+
`createSysEmailTemplateLoader`. Its no-locale branch queries
18+
`(name, 'en-US')` by name rather than `{ name }` unordered with `limit: 1`,
19+
so no driver's row order can change the answer. Every query it issues carries
20+
an `orderBy`, so duplicate rows for one locale resolve the same way on every
21+
boot too.
22+
- `EmailService.sendTemplate`'s ladder asks for `DEFAULT_TEMPLATE_LOCALE` when
23+
the caller named no locale — the en-US fallback used to run only when a
24+
locale *had* been named, so the no-locale path never reached it.
25+
26+
A bundle with no en-US row at all (a single-locale tenant) keeps rendering:
27+
the lowest locale tag in the bundle is used, ordered rather than arbitrary.
28+
Explicit locales are unchanged — exact match, then en-US. Language-only prefix
29+
matching (`zh` → `zh-CN`) is still not performed; no contract declares it.
30+
31+
`SendTemplateInput.locale` (spec, doc-comment only) now spells the whole ladder
32+
out, including that last rung — behaviour a caller can rely on has to be
33+
declared where the contract is, not only where it is implemented.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the external-federation error family declares its HTTP status, so a write refusal stops leaking as a bare 500 (#7739)
6+
7+
A write to a read-only federated external object was refused correctly on the
8+
server — `ExternalWriteForbiddenError` names the datasource, its `schemaMode`,
9+
and both flags that would be required, and nothing is applied (the gate throws
10+
before the driver is reached) — but it reached the client as a bare
11+
**500 INTERNAL_ERROR with no `code`**, indistinguishable from the server
12+
falling over.
13+
14+
The `code` existed the whole way down: `EXTERNAL_WRITE_FORBIDDEN` is registered
15+
in the ADR-0112 error-code ledger. What the error did not carry was an HTTP
16+
**status**, and no HTTP exit can invent one — so the refusal fell past every
17+
structured branch of the REST boundary, matched no message heuristic, and left
18+
through the terminal sanitised 500 that drops the `code` with it.
19+
20+
**The whole family now declares its status, in one table.** New export
21+
`EXTERNAL_ERROR_HTTP_STATUS` (`@objectstack/spec/shared`) maps every
22+
`EXTERNAL_ERROR_CODES` member to the status it is reported with, and each of
23+
the three error classes carries the corresponding value as `status`:
24+
25+
| code | status | why |
26+
| --- | --- | --- |
27+
| `EXTERNAL_WRITE_FORBIDDEN` | **403** | a policy refusal — the identical body succeeds once `datasource.external.allowWrites` and `object.external.writable` are both on, so 400/422 ("fix your request") would be a lie and 409 promises a retry that cannot help |
28+
| `EXTERNAL_SCHEMA_MODE_VIOLATION` | **403** | the same sentence about DDL: `schemaMode !== 'managed'` forbids it, and no rewritten request changes that |
29+
| `EXTERNAL_SCHEMA_MISMATCH` | **503** | not the caller at all — the deployment's metadata and the remote table diverged, only an operator can reconcile them, and it may clear (the reading `ERR_DATASOURCE_UNAVAILABLE` already gets) |
30+
31+
Declared at the **producer** rather than in a REST error map, because every
32+
HTTP exit in the framework already resolves `status` then `statusCode` —
33+
`mapDataError`'s `declaredHttpStatus`, `resolveErrorResponse`,
34+
`HttpDispatcher.errorFromThrown`, `dispatcher-plugin.errorResponseBase`,
35+
`endpoint-executor`, `domains/actions`, `plugin-hono-server`. One table fixes
36+
every door at once; a branch in the REST server would have fixed one and left
37+
the runtime dispatcher, the endpoint executor and the CLI answering 500 for the
38+
same throw. `satisfies Record<ExternalErrorCode, number>` makes a future gate
39+
that adds a code without a status a compile error, so the family cannot
40+
silently re-acquire a member that leaks as a 500.
41+
42+
**What callers see now.** `POST /api/v1/data/<external_object>` against a
43+
read-only federated object answers `403` with `code:
44+
"EXTERNAL_WRITE_FORBIDDEN"` and the refusal detail naming both flags to set,
45+
instead of `500` with no `code`. Behaviour is unchanged — nothing was applied
46+
before and nothing is applied now; only the envelope was wrong.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/service-datasource": patch
4+
---
5+
6+
fix(objectql,service-datasource): bind federated objects to their remote tables whatever the boot order, and report the ones that could not be bound (#7737)
7+
8+
`driver.registerExternalObject(obj)` is the only thing that installs an
9+
ADR-0015 federated object's read metadata — the object -> remote-table mapping
10+
(`external.remoteName` / `remoteSchema`), the `external.columnMap` translation
11+
and the coercion maps. An external object that never gets it resolves to a
12+
table named after the OBJECT rather than the remote table it declares, so every
13+
read against it fails with `no such table`, or answers from the wrong table.
14+
15+
`ObjectQLPlugin`'s boot schema-sync calls it, but that call runs inside the
16+
engine plugin's `start()`, while the declared datasource that owns the remote
17+
database is auto-connected in `AppPlugin.start()` — a later `start()`. So on a
18+
perfectly healthy boot `getDriverForObject()` answers `undefined` for every
19+
federated object at that moment and the call is skipped; whether the object ends
20+
up bound depended on some other component re-driving it afterwards. Two cases
21+
where nothing did:
22+
23+
- an object routed to the datasource by a **`datasourceMapping` rule** (#4462)
24+
rather than an explicit `object.datasource` — `DatasourceConnectionService`
25+
re-drove only the explicitly-bound list;
26+
- any deployment running with **`OS_SKIP_SCHEMA_SYNC`** — that flag is about DDL
27+
managed out of band, and this binding is DDL-free, but it took both
28+
`syncRegisteredSchemas()` calls (and the only in-plugin binding site) with it.
29+
30+
**What changed**
31+
32+
- `ObjectQLPlugin` now runs a federated-binding reconciliation on
33+
`kernel:ready`, after every plugin's `start()` has completed: it re-drives the
34+
binding for every registered external object (idempotent) regardless of which
35+
plugin connected the datasource, in which slot, or whether DDL was skipped.
36+
Boot order no longer decides whether federation works.
37+
- The same pass **reports** what it could not bind, at `error`, naming the
38+
objects, their datasources, the consequence and the fix. Previously the entire
39+
diagnosis of a broken federation was one `debug` line reading
40+
`No driver available for object, skipping schema sync` — invisible at any
41+
normal log level, and emitted on healthy boots too. A boot with nothing to
42+
report stays silent.
43+
- `DatasourceConnectionService.connect()` now re-drives `mappedObjects`
44+
alongside `objects` when a datasource comes up, so a mapping-routed federated
45+
object is also bound by a **runtime** (UI-created) datasource connect, not
46+
only at boot.
47+
48+
No authoring surface changes; a deployment whose federated objects already
49+
worked behaves identically.

0 commit comments

Comments
 (0)