Skip to content

Commit f1415cd

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16320-retire-cron-typed-positions
2 parents 27a660b + d61139f commit f1415cd

61 files changed

Lines changed: 5549 additions & 492 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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534)
6+
7+
Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting:
8+
9+
| route | where the new credential is |
10+
| --- | --- |
11+
| `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) |
12+
| `auth.changePassword({ revokeOtherSessions: true })` | body — `token` |
13+
| `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` |
14+
15+
A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps.
16+
17+
The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to.
18+
19+
**No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers.
20+
21+
## What does NOT change, deliberately
22+
23+
The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `<token>.<sig>` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned.
24+
25+
A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing.
26+
27+
The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547)
6+
7+
Every `--json` face in this CLI declares that it answers an error path with a
8+
payload. `resolveConfigPath()` was the one path that bypassed that declaration:
9+
it wrote its refusal and then called `process.exit(1)` **directly**, so nothing
10+
was thrown and the catch-all each command already carries — all of which sit
11+
downstream of a throw — never ran. Ten published faces answered a missing config
12+
file with an empty stdout.
13+
14+
Measured before this change on the published entry `packages/cli/bin/run.js`,
15+
`NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces
16+
(`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` ·
17+
`migrate meta` · `validate` · `verify`) across both branches of the helper, 19
18+
runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B
19+
(auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the
20+
same 19 runs answer **exit 1 with a parseable document on stdout**, stderr
21+
unchanged byte for byte.
22+
23+
The refusals now throw `ConfigRefusalError`. That is not a new contract — it is
24+
this path being pulled back onto the one its callers had already published, so
25+
it adds **zero** accept-set members and **zero** error codes.
26+
27+
Three properties hold it in place:
28+
29+
- **No face becomes a crash dump.** `os verify` had no `try` at all — measured,
30+
a throw through it produced an oclif error line and no payload where every
31+
sibling emitted an envelope — so it gains the catch-all its nine siblings
32+
already had, in this same change rather than after it.
33+
- **The text face does not narrow.** The refusal and both hint lines are still
34+
written by the helper, to stderr, byte-identical: all 19 non-`--json` runs
35+
compare equal before and after on stdout, on stderr and on exit status. The
36+
catch-alls skip re-rendering the sentence a second time on stdout.
37+
- **No error code is minted.** The thrown error carries neither `code` nor
38+
`httpStatus`, so `errorCodeFields()` contributes nothing and each face emits
39+
its own bare `{ error }`. Whether that shape is right is **#15549**'s open
40+
question, and this change deliberately does not answer it.
41+
42+
The `--json` stdout-purity instrument is widened with the fix rather than after
43+
it: the pre-boot family's discovery moves into a shared module, the pin that
44+
drives it now demands a document (empty stdout no longer passes) and compares
45+
the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts`
46+
— whose own discovery is `bootSchemaStack`-based and cannot see a command that
47+
fails above the kernel — reconciles against that population so neither half can
48+
be lost silently.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
**The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains
6+
one new optional key, `translateSelectOptions`** — the surface the level is graded against,
7+
per the same "a new key on a published exported type is the mechanical floor for clause ②"
8+
rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key,
9+
no wire-shape change), so `minor` rather than `major`.
10+
11+
A dataset's `select`-field dimension now renders its option label in the request's locale on
12+
a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list
13+
grid) already renders for the identical field.
14+
15+
`dimension-labels.ts` resolved a select dimension's category label straight out of field
16+
metadata's authored `options[].label` — always the author's own-language text, since
17+
`SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted
18+
cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field
19+
name never matches a key in the BASE object's own field map, so `resolveDimensionLabels`
20+
skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on
21+
that path, and a regression test now pins that it is never even consulted.
22+
23+
`DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the
24+
plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`)
25+
— the SAME translator the object-metadata REST endpoint already uses — against the
26+
deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec
27+
key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before,
28+
and a kernel with no i18n service configured (or nothing for the requested locale) falls back
29+
to exactly today's authored-label text.
30+
31+
A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup /
32+
master_detail labels resolve through the separate `fetchRecordLabels` capability (a related
33+
RECORD's display name, not a field's authored `options[]`), which this change does not touch.
34+
It does lower the cost of adding translated lookup-record labels later, though — the i18n
35+
service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already
36+
wired into this package and is a `ctx.getService('i18n')` away from reuse.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/cli': patch
4+
---
5+
6+
`object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]`
7+
8+
`FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value.
9+
10+
The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically:
11+
12+
1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok;
13+
2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok;
14+
3. unresolved and not platform-prefixed → **`error`**`os validate` / `os build` / `os lint` exit 1;
15+
4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory.
16+
17+
Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry).
18+
19+
## Migration
20+
21+
**A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one.
22+
23+
**A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one.
24+
25+
**A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
The first-boot `already_have_admin` short-circuit now FINDS an existing platform admin instead of sampling for one, so a tenant's organization-admin count can no longer decide whether a second unscoped `admin_full_access` grant is minted.
6+
7+
Before this change the holders read was `sys_user_permission_set` with **no `orderBy` and a cap of 50**, and the predicate that actually decides — `!organization_id` — was applied **client-side to whatever 50 rows the driver returned first**. `admin_full_access` is not only the platform-admin set: every *organization-scoped* grant of it writes a row carrying the same `permission_set_id`, so this population grows with the number of **org** admins, not platform admins. A tenant with fifty-odd of them filled the window with rows that all fail the filter, the short-circuit did not fire, a **second** unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a genuinely fresh install. Measured on the real better-sqlite3 driver: with 60 organization-scoped grants plus one unscoped human grant, the unordered 50-row window contained 50 organization-scoped rows and not the one that decides.
8+
9+
That is the guarantee #14348 case D pins — 「Moving an already-granted platform admin is reserved to the maintainer.」 — failing open by row count.
10+
11+
- **The read asks the driver the narrow question first.** `{ permission_set_id, organization_id: null }`, ordered and bounded. Because it is narrowed server-side, no number of organization-scoped grants can crowd the answer out of a window.
12+
- **A second, ordered and bounded leg still applies the exact predicate.** It runs only when the narrow leg found nobody. This is deliberate rather than redundant: `organization_id: ''` is storable and reads back as `''` on both SQL families, which `!organization_id` counts as **unscoped** and `where: { organization_id: null }` does **not** return — so replacing the client-side predicate with the narrowed read alone would have made this guard fire *less* often and mint the very grant this fixes. Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less.
13+
- **The bound is never silent.** The scan pages 200 rows at a time up to a 5000-row ceiling, and reaching that ceiling without finding an unscoped human holder now WARNS — naming the ceiling, the number of rows examined, and the consequence (promoting from here would mint a second unscoped grant and re-own the seeded records).
14+
- **The answer says how many rows it examined.** `bootstrapPlatformAdmin`'s returned report gains an optional `adminGrantRowsExamined`, counted by row identity across both legs, on every return the guard reaches. A guard that had seen the whole population and one that had seen a truncated slice of it previously returned byte-identical payloads.
15+
- **The ordering is stated to the driver, and it is measured, not assumed.** `tryFind` answers `[]` when a query is refused, and on this guard `[]` reads as "no platform admin exists yet" — which promotes. An order this object could not serve would therefore be a silent relaxation, so `id` ascending was measured honoured through ObjectQL on both SQL driver families against the real declarations.
16+
17+
Unchanged: an unscoped grant held by the seed identity `usr_system` still never counts, so a database where it was wrongly promoted stays self-healing on restart; the walled postures still mint no grant row and still point a legacy unscoped holder at the config path; and a genuinely fresh install still promotes exactly as before.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840).
6+
7+
`reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again.
8+
9+
`normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds.
10+
11+
⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed.
12+
13+
No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
Draft-preview analytics: `count` over a declared field counts its non-null values, matching every live face
6+
7+
A dataset measure `{ aggregate: 'count', field: 'payer' }` compiles to the cube
8+
metric `{ type: 'count', sql: 'payer' }`, and the draft-preview evaluator carried
9+
that field in and never read it — it answered the ROW count, nulls included,
10+
while every SQL face lowers the same measure to `COUNT("payer")`, defined over
11+
non-null values. A drafted chart therefore showed a different number than the
12+
published one, silently, and the number it showed was the one `count(*)` gives:
13+
the author's choice to count a specific column had no effect on the preview path.
14+
15+
Measured on one dataset, one row set, two `AnalyticsService` instances differing
16+
only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated
17+
SQL on a real SQLite): rows `{meals, 'bob'}` and `{meals, null}` answered
18+
`payer_count` 1 live and 2 on preview. Both now answer 1.
19+
20+
Unchanged, and pinned by the same differential: `count` with no field and `count`
21+
with `field: '*'` still answer the row count (the compiler writes
22+
`sql: m.field ?? '*'`, so the star is the "no field declared" spelling), and
23+
`count_distinct` still answers a cardinality. A group in which no row carries a
24+
value counts `0`, never null — `emptyGroupValueFor` rules counting nothing the
25+
identity `0`.
26+
27+
The live path is unchanged.
28+
29+
Bumped `patch` rather than `minor`: the package's published surface is
30+
byte-unchanged — `src/index.ts` is not in this diff, `aggregate()` is
31+
module-private and `evaluateAnalyticsQueryOverRows` is not on the barrel — and
32+
the only user-visible effect is a drafted chart's number moving to the number
33+
the published chart already showed, which is a correction toward the live
34+
standard rather than the backwards-compatible feature addition `minor` denotes.

0 commit comments

Comments
 (0)