Skip to content

Commit 4902bc7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15041-adr-0104-addendum-file-column
2 parents 58f9899 + 2e35765 commit 4902bc7

64 files changed

Lines changed: 8674 additions & 474 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
`os lint --strict` makes warning-severity findings fail the run, so an app can rely on the platform's warning-level rules as its gate instead of re-implementing them locally (#15935)
6+
7+
Only an `error` failed `os lint` before. `packages/lint` ships ≈250 authoring rules, 119 of them at `warning`, and a run with any number of warnings and no errors exited 0 — so an app that wanted one of those rules to gate its CI had to re-implement it locally at error level, or bolt a script onto the JSON output to promote a family by hand.
8+
9+
New public flag: **`os lint --strict`**. With it, a run with one or more `warning`-severity findings exits 1 exactly as an `error` does, and the console says why, naming the count and the flag:
10+
11+
```
12+
✗ 1 warning(s) fail this run under --strict (a warning is advisory without the flag)
13+
```
14+
15+
`suggestion`s stay advisory under both. ⛔ The default is unchanged: without the flag the same stack still exits 0, and no existing `os lint` expectation moves.
16+
17+
The `--json` face carries the verdict so a gate can read it without re-deriving it from the counts. Two keys, unconditionally present on every project-lint payload, flag or no flag:
18+
19+
```json
20+
{ "passed": false, "errors": 0, "warnings": 1, "suggestions": 0, "strict": true, "failing": 1 }
21+
```
22+
23+
`strict` says whether the flag was in effect; `failing` is the count the exit code was read from — `errors`, or `errors + warnings` under `--strict`; and `passed` is `failing === 0`, the same statement the exit code makes — so `--strict --json` on a warning-only stack reads `passed: false` beside exit 1, never `passed: true` next to a failing exit.
24+
25+
Not in this change: per-rule severity configuration, any change to a rule's severity, and `--eval` mode, which keeps its own pass bar (`--eval-min`).
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
The datasource admin routes derive the tenancy posture before resolving the caller
6+
7+
`requireDatasourceAdmin` resolved the request with `resolveAuthzContext({ ql, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran on this family, and an API key stamped with an organization its owner had left was admitted; the routes then gated it on `authz.systemPermissions` alone. Because this family gates on system capabilities rather than on organization-scoped rows, the consequence was an admitted principal rather than a cross-organization row read.
8+
9+
The posture is now read off the kernel's `tenancy` service and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Patch rather than minor: no accept set widens, and a declared guard returns to enforced.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
`POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy.
7+
8+
Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why.
9+
10+
`copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.*
11+
12+
- **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use.
13+
- **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead.
14+
15+
⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing.
16+
17+
Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question.

.changeset/lucky-poems-invite.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-hono-server": minor
3+
---
4+
5+
fix(plugin-hono-server): the current-user faces assemble their `ExecutionContext` through the shared assembler (#15747)
6+
7+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level).
8+
9+
`makeExecutionContextResolver` is exported from this package's index. Its declared return moves **from** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise<any | undefined>` — in practice `any`, since the exported function carried no return annotation at all and the envelope it built was a hand-rolled object literal cast `as any`**to** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise<ExecutionContext | undefined>`. `any` is assignable to everything and admits every property read, so a consumer's code really can stop compiling.
10+
11+
What this asks of a consumer holding the resolver directly (the serverless host path that composes it, cloud#924): narrow the `undefined` arm before reading the envelope — under `strictNullChecks` the resolver has always been able to answer `undefined` for a request with no session, and no caller was ever asked to handle it; and stop reading members `ExecutionContext` does not declare, since the receiver is no longer `any`. A consumer that only calls `registerCurrentUserEndpoints` sees no change.
12+
13+
The envelope itself is now assembled by `assembleExecutionContext` (`@objectstack/core`) — the fail-closed entry every other HTTP transport already uses — instead of the hand-rolled literal, which omitted six fields of the closed entry set: `principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`. `principalKind` is `'human'` on these faces, the value the shared assembler derives for a session-backed principal; the other five are withheld on the record. A field added to `ExecutionContext` from now on fails to compile here until this face decides it.
14+
15+
No runtime behaviour changes: `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` answer byte-identical bodies, pinned as goldens.
16+
17+
<!-- adr-0087: not-required (type-surface-only packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#makeExecutionContextResolver) A published return type moves off an erased `any` onto the kernel's own `ExecutionContext`: no metadata key is removed, renamed or re-shaped, this diff touches no `packages/spec/**` path and no ADR-0087 shape surface (no `*.zod.ts`, no `packages/spec/src/contracts/**` entry, no object definition), and nothing exists for `objectstack migrate meta` to rewrite. The affected party is a TypeScript consumer and the delivery channel is the compiler at their own call site, which reaches every one of them rather than the subset who read release notes. -->
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`objectstack init` and `objectstack create` now read one emission policy instead of each restating it.
6+
7+
Both commands write a `tsconfig.json` and a set of third-party dependency ranges into a new project. Each had written those in its own words, and the words had come apart. Measured on the tree: the TypeScript range — the value that decides whether a scaffolded project type-checks at all — was written in six places across three scaffolders and had split into three values (`^5.3.0`, `^5.8.0`, `^6.0.0`); the vitest range into two. Dated off `git log -G` as of 2026-09-05: the two CLI values were written in the same commit and stayed apart for 210 days, and the third value is 53 days old — the bundled template landed at `^5.3.0` like the others and was moved to `^6.0.0` later, in a commit that records no reasoning about TypeScript.
8+
9+
The control for that reading was already in the same file: `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are imported by the second scaffolder rather than restated, and across the same five emissions, the same window and the same authors, they had not drifted at all. So the policy moved to where those already live — `renderScaffoldTsconfig()` and one `SCAFFOLD_*_RANGE` constant per dependency, in `init.ts`, imported by `create.ts`.
10+
11+
Two emitted values had to survive the merge, and both are argued rather than picked:
12+
13+
- **TypeScript `^5.3.0`.** `TypeScript 5.3+` is already this project's published floor — `content/docs/getting-started/index.mdx` says so, and `content/docs/deployment/troubleshooting.mdx` repeats it. `^5.8.0` matched no statement anywhere, and `^5.3.0` was already what three of the five emissions carried. Measured rather than assumed: TypeScript 5.3.3 type-checks every shape these two commands emit with results identical to 6.0.3.
14+
- **vitest `^4.0.0`.** Neither value was a recorded decision and both were written in the same commit; `^4.0.18` claimed a patch-level floor nothing justifies and was strictly the narrower of the two.
15+
16+
**Nothing a scaffolded project installs changes.** `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, and `^4.0.0` and `^4.0.18` both to vitest 4.1.11 — what moves is the floor each project declares, which is a support promise, so the surviving one is the promise the docs already make. Driving all five emissions and hashing the trees before and after: every `tsconfig.json` is byte-identical, `os init -t app` and `os init -t empty` are byte-identical in full, and exactly three `package.json` files change by exactly the one line each.
17+
18+
`npx create-objectstack` is deliberately untouched. It cannot import from `@objectstack/cli` — the dependency edge runs the other way — and its `^6.0.0` is a different question: unifying it would change which major of TypeScript a scaffolded project installs.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136)
7+
8+
<!-- adr-0087: registered session-payload-positions-security-axis -->
9+
10+
**BREAKING** meaning change on a published payload — `user.positions` in
11+
`GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's
12+
launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on
13+
#15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one
14+
name, one meaning.
15+
16+
`customSession` built the array from the better-auth `sys_user.role` scalar
17+
split on commas, plus the active membership mapped to `org_*`, plus
18+
`platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057
19+
D4 table that is the source of truth for custom positions. The Console binds
20+
that array straight through as the CEL root `current_user`, so an
21+
`action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed
22+
by a business position answered FALSE for **everyone**, including the user who
23+
genuinely held it.
24+
25+
⭐ It failed **silently and in the invisible direction**: the root was bound and
26+
the key was present, so `has(current_user.positions)` was true, CEL raised
27+
nothing, and the predicate simply returned FALSE. A predicate that *faults*
28+
fails OPEN in the shell and would have shown the button; a successful FALSE
29+
shows nothing and reports nothing. The documented example
30+
(`'org_admin' in current_user.positions`) kept working throughout, because
31+
`org_admin` is the one name that sits on **both** axes.
32+
33+
This was a **declared** contract being violated, not an ambiguous name:
34+
`EvalUserSchema` already specified `positions` as "built-in identity names +
35+
position names", exposed to "every predicate surface (server formula, server
36+
RLS, client UI gates) ... with an identical shape" so that a predicate
37+
"evaluates identically wherever it is written". `/auth/me/permissions` and
38+
every server-side evaluator (`ExecutionContext.positions`) already resolved the
39+
security axis; only the session payload did not.
40+
41+
**What changes**
42+
43+
- `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**,
44+
not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE
45+
authority (`core/security/resolve-authz-context.ts`, whose header forbids
46+
every entry point from re-reading the `sys_*` grant tables itself), scoped to
47+
the session's active organization. The payload therefore carries the
48+
`sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and
49+
agrees with `/auth/me/permissions` set for set. Same move
50+
`isPlatformAdminUserId` made at #10348.
51+
- `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as
52+
an alias of `'platform_admin' in positions`), so one authority answers both.
53+
- `packages/spec``EvalUserSchema` states which axis `positions` is, and
54+
states that the better-auth role scalar is not it.
55+
56+
**No key is renamed, and none is added.** The ruling anticipated a renamed
57+
auth-role array; measured against the tree, it has no content to carry and no
58+
consumer. Everything the old union contributed beyond the security axis was the
59+
`sys_user.role` scalar's own tokens — and that scalar is **already published,
60+
unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban
61+
carves out, for third-party schema this platform does not own). Minting a
62+
`roles` array would revive that banned word to publish information the payload
63+
already carries. (Precisely: `check:role-word` ratchets the reserved word in
64+
`content/docs` and `skills/` PROSE, while the identifier ban over authored
65+
metadata lives in `packages/lint`; a TypeScript payload key trips neither
66+
mechanically until it is documented. The ADR-level prohibition is what rules
67+
here, not a gate that would have caught it.) A consumer that wants the
68+
better-auth role reads `user.role`.
69+
70+
**What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2);
71+
`platform_admin` still derives from the unscoped `admin_full_access` grant with
72+
its ADR-0091 validity window and ADR-0049 active flag intact —
73+
`platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over
74+
those shapes.
75+
76+
⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.**
77+
`positions.includes('platform_admin')` is the form
78+
`resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position`
79+
row may spell that very name — and this card is what made that reachable, by
80+
moving `positions` onto an axis a tenant admin can write. Reading the name would
81+
have let a tenant mint platform standing and pass the `/admin/*` mount gate.
82+
`platform-admin-gate.ts` drops its positions leg for the same reason.
83+
`session-platform-admin-rung-agreement.test.ts` requires the payload alias, that
84+
gate and `hasPlatformAdminStanding` to agree, driven with such a row present and
85+
a genuine grant as the control.
86+
87+
**Upgrade.** If you gate on the better-auth role scalar, read `user.role`
88+
instead of looking for its tokens in `user.positions`. Predicates written
89+
against real position names, built-in identity names, or `everyone` need no
90+
change — they start working. Deployments that stored business role names in
91+
`sys_user.role` rather than assigning positions should assign them through
92+
`sys_user_position` (the governed ADR-0090 D12 channel).
93+
94+
A name in `sys_member.role` is still projected, **with one carve-out**: for a
95+
session carrying NO active organization, membership names are now *added*, from
96+
**every** membership the user holds — the resolver projects them all when no
97+
tenant scopes it, where the old derivation contributed none. Measured on the
98+
real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`):
99+
`[]` before, `[org_admin, everyone]` after, pinned by
100+
`session-positions-security-axis.test.ts`. With an active organization the
101+
projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so
102+
membership-derived names there are unchanged.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
Fix: the settings REST doors now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply here — and the tenant this seam hands onward is a vetted one.
6+
7+
Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran at this door, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. This gate does not merely admit the principal: it returns that tenant onward as the resolved settings tenant, so an unvetted claim became the verdict the read/write path acted on. A browser session whose stored active organization is no longer backed by a membership now has that claim dropped here too, rather than passed through.
8+
9+
The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage rather than a quiet admission.

0 commit comments

Comments
 (0)