Skip to content

Commit 373927c

Browse files
committed
Merge origin/main into the spellings-mirror ratchet branch
Resolution: TAKE MAIN as the base everywhere. The only conflict was scripts/pm/check-skill-line-ratchet.mjs, where this branch's obsolete `['AGENTS.md', 961] -> 969` edit collided with main's re-pinned `['AGENTS.md', 1150]`. Main's map wins wholesale, which dissolves commit d41ef56 -- that raise was measured against the pre-reflow layout and its 961 baseline no longer exists. AGENTS.md, lint.yml and check-cross-package-test-inputs.mjs auto-merged: the 120-byte reflow left the spellings section untouched (its first change after line 32 lands at 128), and the lint.yml rewrite left the gate step beside its sibling, "Hand-written declaration mirrors". The re-measured ceiling lands in the next commit.
2 parents d41ef56 + 9abe4e4 commit 373927c

527 files changed

Lines changed: 41416 additions & 3638 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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): enforce the documented `newTabUrl` / `opensInNewTab` co-constraint on `ActionSchema` (#11842)
6+
7+
**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under
8+
the repo's launch-window convention for breaking changes.
9+
10+
`newTabUrl`'s doc has always said "Only valid together with `opensInNewTab`",
11+
and every renderer read point agrees: objectui's pre-opened-tab wrapper reads
12+
the key only behind `action.opensInNewTab && newTabUrl`, and no other path
13+
reads it at all. Nothing on the refine chain enforced the pairing, so an
14+
action declaring `newTabUrl` without `opensInNewTab: true` parsed clean and
15+
the key was silently inert — the ADR-0078 declared-but-unenforced shape,
16+
arriving through a documented co-constraint rather than a missing key.
17+
18+
`ActionSchema` now **rejects at parse time** an action declaring `newTabUrl`
19+
whose `opensInNewTab` is not `true`, with guidance naming the pre-opened-tab
20+
contract and both remedies (declare the flag if a pre-opened tab is intended;
21+
otherwise delete the inert key — behavior is unchanged either way it was
22+
already behaving, because the lone key was never read). An explicit
23+
`opensInNewTab: false` beside `newTabUrl` is refused too, deliberately:
24+
unlike the #11519 doubled-channel rule, `newTabUrl` has no meaning outside
25+
the pre-opened-tab flow, so a declared-off channel leaves the key exactly as
26+
dead as an undeclared one.
27+
28+
The legal pairing is untouched and pinned byte-identically: `opensInNewTab:
29+
true` + `newTabUrl`, `opensInNewTab` alone, and `opensInNewTab: false` alone
30+
all parse exactly as before. The corpus was measured at zero lone-`newTabUrl`
31+
producers (this repo's examples and platform metadata, objectui's fixtures
32+
and renderer read points, and the cloud SSO producers, which declare the pair
33+
correctly — re-measured at claim per the triage requirement), so no shipped
34+
metadata is affected.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over a pair of existing keys: no key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The refusal is the channel that reaches an affected author, at the parse site, carrying both remedies; whether a lone `newTabUrl` meant "add the flag" or "delete the leftover" is authoring intent no migration entry can decide on an upgrader's behalf — and the measured population of affected sources is zero in every corpus. Mirrors the disposition of the adjacent #11519 narrowing on the same schema. -->
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): authorize before the break-glass guard on `POST /api/v1/auth/admin/remove-user` (#11477)
6+
7+
The break-glass last-local-credential guard is registered as a global better-auth
8+
`hooks.before`, which runs ahead of an endpoint's own middleware. On
9+
`/admin/remove-user` — served directly by better-auth's router, whose
10+
`adminMiddleware` establishes only a session, with the role decision landing
11+
later inside the vendor's handler — that ordering let the guard's lookup and its
12+
distinctive refusal be reached by any **authenticated** caller before either
13+
authorization layer had run. Because that refusal is target-dependent, the
14+
refusal itself carried a per-record fact about a user the caller was not
15+
entitled to ask about.
16+
17+
`/admin/ban-user` already ran the same guard **after** authorization: #9652
18+
shades that path with an ObjectStack raw mount whose platform-admin gate fires
19+
first. One guard, two routes, opposite orders, and nothing asserting either.
20+
21+
`/admin/remove-user` now carries the same shading, converging the whole
22+
`/admin/*` family on **authorization before the guard**. The mount reuses the
23+
landed #9652 / #9653 pattern and introduces no new mechanism.
24+
25+
What changes is **when** the guard decides, never **what** it decides:
26+
27+
- an anonymous caller still gets `401 UNAUTHENTICATED`;
28+
- an authenticated non-admin now gets `403 PERMISSION_DENIED` for every target,
29+
so the guard is unreachable before authorization and its answer no longer
30+
varies with the named user;
31+
- a platform admin is unaffected in every respect — the mount **delegates** into
32+
better-auth rather than re-implementing removal, so the path-keyed hook still
33+
fires and the guard still refuses the removal of the last local password
34+
login, and admission remains the vendor's own decision (#9969).
35+
36+
An ordering pin ships with the fix so the sequence is mechanically checkable
37+
rather than re-argued: it asserts that one authenticated non-admin naming two
38+
different targets receives **indistinguishable** responses, and — so the pin
39+
cannot be satisfied by deleting the guard — that an admitted platform admin
40+
still hits the guard's refusal, and still succeeds on an ordinary user.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
`ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter.
6+
7+
Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf.
8+
9+
Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': minor
4+
'@objectstack/verify': patch
5+
---
6+
7+
feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only`
8+
9+
**BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end.
10+
11+
- New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced.
12+
- **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`.
13+
- `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted.
14+
- Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture.
15+
- `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger.
16+
- The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too.
17+
- `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`).
18+
19+
<!-- adr-0087: registered audience-posture-default-invite-only -->
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852)
6+
7+
`PluginHealthMonitor.performHealthCheck` reaches its failure handling by two
8+
disjoint routes, and only one of them could ever restart the plugin.
9+
10+
A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`)
11+
incremented `failureCounters`, cleared `successCounters`, and — once
12+
`failureThreshold` consecutive failures accumulated — consulted `autoRestart`
13+
and restarted the plugin. A check that **threw** took a separate `catch` block
14+
that incremented `failureCounters` and stopped there: it never cleared
15+
`successCounters` and never read `autoRestart`. Because `raceCheckTimeout`
16+
rejects rather than resolving, every `timeout` overrun lands in that `catch`,
17+
so a plugin that hung was marked `failed` and never restarted no matter how
18+
many rounds passed or what `autoRestart` said. The severer of the two failure
19+
modes was the one that could not trigger recovery.
20+
21+
Both routes now funnel into one `recordFailedRound` step that owns the
22+
counters, the `failureThreshold` comparison and the `autoRestart` decision, so
23+
a thrown or timed-out check is restart-eligible on exactly the same terms as a
24+
returned failure.
25+
26+
The per-route *status* label is deliberately unchanged: a throw is still the
27+
separate `failed` status applied immediately with no threshold, as
28+
`content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and
29+
the restart decision are shared — those are what `failureThreshold` and
30+
`autoRestart` declare, and neither names a route.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
Boolean aggregands now answer the ruled #11249 contract on every SQL dialect. On Postgres, `sum`/`avg`/`min`/`max` over a declared `boolean` field are lowered with a cast (`avg(cast("flag" as int))`) instead of reaching the server as `avg("flag")` — which PostgreSQL refuses with SQLSTATE `42883`, so those aggregations previously failed with `DATABASE_ERROR`/500. On every dialect, `min`/`max` results over a declared boolean are now presented as JSON booleans (`false`/`true`) at the driver boundary — previously MySQL (`tinyint(1)` storage) answered `0`/`1`. `sum`/`avg` answer arithmetic (`3` / `0.5` over a 3-true/3-false column); `count`/`count_distinct` are unchanged, and `min`/`max` over an empty window still answer `null`.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
Carry the capability-provider (#3366) and package-docs (ADR-0046) warnings in
6+
the `os build --json` payload, so its `warnings` list matches
7+
`os validate --json` on the same tree
8+
9+
`os build --json` reported a strictly smaller `warnings` list than
10+
`os validate --json` did for the same stack. #11643 closed the gap for the
11+
undeclared-authoring-key findings; two lists were still behind it — the #3366
12+
installable-provider hints (an unknown capability token, or a provider that is
13+
absent but addable with `pnpm add`) and the ADR-0046 package-docs advisories.
14+
A CI job gating on `os build --json` therefore read an empty advisory list for
15+
a stack that names a typo'd capability and ships a doc whose frontmatter tags
16+
were silently dropped, while the identical job gating on `os validate --json`
17+
read both.
18+
19+
Measured over one temp project at `origin/main` `589758d22`, both commands
20+
exiting 0:
21+
22+
```
23+
os build ⚠ requires: "zzz_unknown_capability_token" is not a known platform capability — check for a typo.
24+
⚠ src/docs/advparity_guide.md: Frontmatter `tags:` … is not a list this reader understands …
25+
os validate --json warnings: [ {doc record}, {token,message}, "No apps or plugins defined …" ]
26+
os build --json warnings: [] ← both lists dropped
27+
```
28+
29+
`compile.ts` computed both and then rendered them **inside** the
30+
`if (… && !flags.json)` print blocks, which put them structurally out of reach
31+
of the payload: computed, then discarded, for the one audience `--json` exists
32+
to serve. This is the fourth measured instance of that shape in these two files
33+
(#10953, #11174, #11643), and it takes the established fix — hoist the
34+
formatting to the computation site so one list feeds both faces and they cannot
35+
report different sets.
36+
37+
**Order and shape are mirrored from `os validate --json`, not chosen here.**
38+
That payload reads `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings,
39+
...capProviderWarnings, ...structuralWarnings]`; `os build --json` now emits
40+
that list minus its last member. Doc advisories ride as the issue records
41+
`collectAndLintDocs` returns and capability hints as `{ token, message }`,
42+
which is what validate ships for each, so a consumer reads one shape per class
43+
from either command rather than learning two.
44+
45+
**No new key.** Both lists land in the `warnings` key the payload already
46+
declared — "the whole registry's advisory set, in the shape `os validate --json`
47+
reports", as its own comment has always said. The payload's top-level key set is
48+
unchanged and pinned as unchanged.
49+
50+
**`structuralWarnings` is deliberately not included.** `os validate` derives
51+
four structural advisories ("No objects defined", "No apps or plugins
52+
defined", and two manifest ones) from `collectMetadataStats`; `os compile`
53+
calls that same helper but computes none of them, in any face. That makes it a
54+
missing computation rather than a dropped list, and whether a command that
55+
writes an artifact should raise them is a judgment rather than a mechanical
56+
port. It is split out as #11896 and pinned as the only remaining residue between
57+
the two payloads, so the question stays visible and a fifth genuinely dropped
58+
list cannot hide in the gap.
59+
60+
Text output is unchanged.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): `os build --json` carries the computed advisory lists on every failure exit, not the success payload alone (#11772)
6+
7+
**Machine-contract widening on the `--json` failure payloads.** A consumer that
8+
today branches on `warnings` being ABSENT from an `os build --json` failure
9+
payload sees a different shape after this change.
10+
11+
## What was wrong
12+
13+
The text face prints its advisory blocks before the gates that can stop the
14+
run — the #11529 author-time advisories at step 3b, the #3786 undeclared
15+
authoring-key findings at 3d — and both end in `— re-run with --json for the
16+
full list`. But `warnings` lived on the TERMINAL SUCCESS payload only (plus,
17+
for `ruleAdvisories` alone, the author-time-rules failure). On a tree with 60
18+
undeclared authoring keys *and* a package-docs error:
19+
20+
```
21+
os build Undeclared authoring keys (60) … 50 rows …
22+
… and 10 more … — re-run with --json for the full list
23+
os build --json {"success":false,"error":"docs validation failed","issues":[…]}
24+
^ the 60 keys nowhere
25+
```
26+
27+
The remedy the notice named returned a payload that did not contain the list,
28+
and the author could not reach the withheld entries by any route until an
29+
unrelated later failure was fixed — the "the remedy named is unreachable"
30+
shape of #11643 and #11391.
31+
32+
## Which exits gain the field
33+
34+
All nine failure exits of `os build --json`. Six already had a payload of their
35+
own; three more were found while enumerating (the filing card's table listed
36+
six). `warnings` is now present on every one, alongside each exit's existing
37+
keys, which are unchanged:
38+
39+
| exit (step) | existing keys | `warnings` before | after |
40+
| --- | --- | --- | --- |
41+
| `strict-body: missing body` (2b) | `issues` | absent | `[]` |
42+
| protocol parse failure (3) | `errors` | absent | `[]` |
43+
| `author-time rules failed` (3b) | `issues` | `ruleAdvisories` | unchanged |
44+
| `capability provider preflight failed` (3c) | `issues` | absent | rule + capability |
45+
| `access matrix drift` (3e) | `changes` | absent | rule + key + capability |
46+
| `docs validation failed` (3f) | `issues` | absent | all four lists |
47+
| `--no-runtime-bundle` refusal (4b) | `error` | absent | all four lists |
48+
| `runtime bundle failed` (4b) | `error` | absent | all four lists |
49+
| thrown / caught (bottom) | `error` | absent | what the run had computed |
50+
51+
The success payload is unchanged in content: its
52+
`[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings]`
53+
spread — `os validate --json`'s order minus its trailing `structuralWarnings`
54+
— moved to a single `warningsSoFar()` site that every exit now reads, so the
55+
member order cannot drift between exits.
56+
57+
## What a consumer keying off its absence should do instead
58+
59+
`warnings` is no longer a signal of which exit produced the payload. Read
60+
`success` (and `error` / `errors`) for that; a consumer that inferred "this is
61+
a failure payload" from a missing `warnings` must switch to `success === false`.
62+
63+
`warnings: []` on a failure payload does NOT mean "this tree raises no
64+
advisories". It means **this run stopped before those advisories were
65+
computed** — the two early exits above (`strict-body`, protocol parse) run
66+
before any advisory step, so their list is empty by construction. A consumer
67+
that needs the full advisory set for a tree must read it from a run that
68+
reaches at least the gate that computes it, or from `os validate --json`.
69+
70+
`warnings` is always an array on every `os build --json` payload, success or
71+
failure, so it can be read unconditionally — that shape constancy is the point
72+
of the change (maintainer ruling 2026-08-25, option 1 of three; option 2,
73+
"carry them only where the text face printed them", was rejected as the hardest
74+
contract to declare).
75+
76+
Advisories stay CARRIED, never recomputed: each list is still computed at
77+
exactly the step that owns it, so an exit upstream of a step legitimately
78+
reports that list empty and no failure path pays for a computation it did not
79+
already do.

0 commit comments

Comments
 (0)