Skip to content

Commit 06b372b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-3653-scim-stable-pin-move
2 parents c87d731 + 15bf9e8 commit 06b372b

52 files changed

Lines changed: 5517 additions & 579 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.

.changeset/builtin-column-collision-warning.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ only when the declaration asks for storage the platform's own column does not de
3838
(a differing `type`, a `maxLength`, `unique`, `defaultValue`, `storage.notNull`, a
3939
`multiple` shape…) and stays silent when it does not: `created_at: { type: 'datetime',
4040
defaultValue: 'NOW()' }` describes precisely what lands, and says nothing.
41-
`id: { type: 'number' }` — an author expecting a numeric key — still fires, as does
42-
`id: { type: 'text' }`. The storage/presentation split is one table
41+
`id: { type: 'number' }` — an author expecting a numeric key — still fires.
42+
`id: { type: 'text' }` does **not**: varchar(255) canonicalizes to the field type
43+
`text`, so that declaration asks for precisely what the column delivers (#12131
44+
the delivery table recorded the knex builder name `'string'` there at first, and
45+
reported all 45 of the platform's own correct `id` declarations as disagreements). The storage/presentation split is one table
4346
(`builtin-column-collision.ts`) pinned against `FieldSchema.shape`, so a field key
4447
added later is classified deliberately instead of defaulting into silence.
4548

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
fix(driver-sql): the builtin-column delivery table speaks the spec's field-type vocabulary, not knex's builder names (#12131)
7+
8+
`BUILTIN_COLUMN_DELIVERY.id.type` recorded `'string'` — the **knex builder name** from
9+
`table.string('id').primary()` — and `undeliveredStorageAttributes` compares that value
10+
with `===` against a declaration's `type`, which is a spec `FieldType`. The two are
11+
different vocabularies, and `'string'` is not a member of the one being compared: it is
12+
absent from `FieldType`'s 49 options, `Field.string` is absent from the builder's keys,
13+
and `FieldSchema` refuses `type: 'string'` outright. So **no declaration could ever
14+
match it**, and the #12015 diagnostic reported every correct declaration on the
15+
platform's own key as a disagreement.
16+
17+
Measured on a stock boot of `@objectstack/platform-objects`: **45 warnings, one per
18+
system object**, each saying `type: 'text' (the column is 'string')` about a
19+
declaration that was right all along. `varchar` canonicalizes to the field type `text`
20+
(`canonicalizeSqlType('varchar(255)') === 'text'`, `suggestFieldTypeForSqlType('varchar(255)') === 'text'`,
21+
`isCompatible('varchar(255)', 'text') === true` — all pinned in `type-compat.test.ts`),
22+
so `id: Field.text(...)` asks for exactly what the platform's column delivers. The
23+
delivery table now records `text`, and the 45 lines go silent because they were false,
24+
not because they were suppressed.
25+
26+
`sys_migration.id`'s `maxLength: 128` was the one **honest** disagreement in that corpus
27+
— the column is varchar(255) — and it is removed rather than widened to 255. It bound
28+
nothing in any seam: the DDL discards a declared width on a builtin column name, and
29+
`validateRecord` skips `id` by name on both the insert and the update path (it is also
30+
`readonly`). Declaring a width that nothing enforces is the shape enforce-or-remove
31+
exists to prevent, and the 44 sibling system objects declare none.
32+
33+
The classification pin now holds **every** entry in the delivery table to
34+
`FieldType.options`, so a builder name written there fails by name instead of surfacing
35+
as a corpus of false warnings. The fixtures in both #12015 pin files were written
36+
against the delivery table rather than against the source — `sys_presence.id` was spelled
37+
`type: 'string'` in the "silent" cases, which is why they passed while the same
38+
declaration as actually written warned. They now use the shapes as declared, and the
39+
firing cases declare a type that genuinely disagrees.
40+
41+
**Grade: `patch` for both, and deliberately.** No door moves and no DDL changes: the
42+
platform still owns `id` / `created_at` / `updated_at`, the emitted column is
43+
byte-identical, every object that booted before still boots, and `BUILTIN_COLUMN_DELIVERY`
44+
is internal to the package (it is not re-exported from the package entry). The
45+
`platform-objects` half removes one metadata key that was measured inert in every seam
46+
that could read it. What changes is what the driver **says**.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): `os serve` says so when a port is read as something other than what the text says (#12674)
6+
7+
`os serve` reads its port with `parseInt`, and `parseInt` is tolerant in a way
8+
that changes the *answer* rather than the spelling. `--port 3e3` binds port
9+
**3**. `--port 0x0BB8` binds 3000. `--port 3000abc` binds 3000. The boot
10+
succeeds, on a port the operator never named, and nothing anywhere says so — an
11+
operator who wrote `PORT=3e3` meaning 3000 gets a server on port 3, and on a
12+
non-root host that surfaces (much later, if at all) as an `EACCES` that still
13+
does not name the coercion.
14+
15+
The value is now announced when it does not read as the port it selected:
16+
17+
```
18+
⚠ PORT="3e3" was read as port 3.
19+
That text is not a plain decimal number, and the reader that accepts it
20+
is tolerant: it honours a leading 0x as hexadecimal and discards
21+
everything from the first character that cannot continue the number.
22+
Nothing downstream reads it again — 3 is the port this server asked
23+
for, whatever the text looks like.
24+
If that is not the port you meant, correct PORT in this process's
25+
environment (for example PORT=3000), or override it with --port 3000.
26+
```
27+
28+
**Nothing is refused, and nothing binds differently.** The accept set is exactly
29+
what it was: every spelling that boots today still boots, on the same port, byte
30+
for byte. Whether `os serve` should take only strict decimal text is a contract
31+
question about a published CLI's accepted input, and it is deliberately left
32+
open. This repairs the silence, which is where the harm actually was.
33+
34+
The notice fires on a *difference*, so what counts as agreement is the whole of
35+
it: leading and trailing whitespace, a leading `+` and leading zeros do not
36+
change what the text says (`" 3000"`, `"+3000"` and `"08080"` are silent — the
37+
first is what production `PORT` values look like, and a notice there would drone
38+
at every ordinary boot). An exponent, a radix prefix, a fraction, a digit
39+
separator or trailing text all do (`"3e3"`, `"1e10"`, `"0x0BB8"`, `"0b111"`,
40+
`"3000.0"`, `"1_000"`, `"3000abc"` all speak).
41+
42+
It names both the text and the port it selected — never a third number, because
43+
`3e3` looks like 3000 to a reader but `3000abc` has no second reading and a
44+
guess would be wrong the first time it met one. Written to **stderr** like every
45+
other `os serve` diagnostic: `stdout` carries JSON-RPC frames whenever the stdio
46+
MCP transport is mounted.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): cache successful `sys_setting` localization reads, invalidated synchronously on write (#11966)
6+
7+
Leg C (ship-first) of the accepted #11633 cross-request caching design
8+
(maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0).
9+
`resolveLocalizationContext` re-read `sys_setting` on **every** authenticated
10+
request to answer the same three keys — `timezone` / `locale` / `currency`
11+
for a workspace whose values change roughly never. That read is now cached.
12+
13+
**Grade: `minor`, not `patch`.** It adds a deployment variable
14+
(`OS_LOCALIZATION_CACHE_TTL_MS`) and changes the query pattern of a shipped code
15+
path. Not `major`: the observable contract callers actually depend on — a
16+
settings write is visible to the very next read — is preserved, and pinned.
17+
18+
Caching this read was tried once before and reverted. #10221's first version
19+
memoized every outcome for 30s and CI went red on
20+
`analytics-timezone.dogfood.test.ts`, which writes a new org timezone and
21+
expects the very next analytics query to bucket under it; the cache was narrowed
22+
to memoize **failures** only. That verdict was on **TTL-only** caching and it
23+
still stands unamended. What changed is that the process now has invalidation
24+
seams it did not have then:
25+
26+
- **Primary — the settings change seam.** `SettingsService.subscribe(ns, handler)`
27+
dispatches synchronously and in-process from the write path, after the row is
28+
persisted. (⚠️ #11633 calls this a "settings change bus"; no such module
29+
exists — `subscribe()` is the seam. No change was needed in
30+
`@objectstack/service-settings`: the seam was already public and already does
31+
exactly this.)
32+
- **Backstop — the engine write epoch** from #11968's substrate. Needed because
33+
this resolver's own fallback reads `sys_setting` *directly*, so a seeder or
34+
any other direct engine write emits no settings event at all. It is read
35+
structurally rather than imported, because `@objectstack/objectql` depends on
36+
`@objectstack/core` and the substrate declared `WriteEpochLike` separately for
37+
exactly this consumer. A peer node's hint arrives as a local bump, so an
38+
attached `authz.invalidated` bridge narrows cross-node convergence for free.
39+
- **TTL** — the residual bound, for what neither seam can see. Default 30s,
40+
`0` disables the cache on a real path rather than a degenerate one.
41+
42+
Two rules carry the change and are pinned rather than merely documented:
43+
44+
1. **A success is cached only when the engine exposes the write epoch.** A `ql`
45+
with no seam is a `ql` whose writes the cache cannot observe, so rather than
46+
degrade to the TTL-only shape that was already reverted here once, the cache
47+
declines. A partial `{ current }` shape is not a seam either — a counter
48+
nothing can bump would read as a live invalidation source and pin the answer
49+
for a whole TTL.
50+
2. **Invalidation retires success entries only.** #10221's failure memo exists
51+
for an environment where `sys_setting` is missing; retiring it on a write
52+
would restart precisely the per-request driver log spam that memo removed,
53+
and no write can create a missing table. It stays TTL-bound and behaves
54+
exactly as #10221/#11877 shipped it.
55+
56+
`analytics-timezone.dogfood.test.ts` is unchanged and unweakened — it is this
57+
leg's acceptance test, and an ablation that reduces the cache to its TTL turns
58+
it red on the same assertion the original revert was recorded against.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
**Security (p0, upstream half):** `GET /api/v1/runtime/config` now serves the Console's client error-reporting **sink** — the DSN itself, plus the closed set of knobs that travel with it — so a self-hosting operator configures telemetry on the server, in one place, with no frontend rebuild (#12681, upstream half of `cloud#1508`).
6+
7+
```json
8+
{
9+
"telemetry": {
10+
"errorReporting": {
11+
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
12+
"sendDefaultPii": false,
13+
"environment": "production",
14+
"tracesSampleRate": 0.1,
15+
"replaysOnErrorSampleRate": 0
16+
}
17+
}
18+
}
19+
```
20+
21+
An air-gapped on-premises EE Console was measured sending **14 Sentry envelopes per session** to `sentry.io`, carrying IP and User-Agent PII, with no way for the customer to turn it off. The first fix (#10805) served a runtime *permission* and left the *source* where it was — a build-time `VITE_SENTRY_DSN` inlined into the published bundle. That closed the leak and opened a different hole, which the maintainer named on 2026-08-27:
22+
23+
> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」
24+
25+
ObjectStack's users consume a **prebuilt** Console. They cannot set a build-time key, so under the two-key gate a self-hosting operator could not enable client error reporting at all: the permission was reachable and the source was not.
26+
27+
**The DSN's presence IS the grant.** There is no second boolean, and this is not shorthand — it removes the failure mode the two-key shape had. With a permission and a source configured in different places, "permission on, no DSN" and "DSN in, permission off" are two silent dead states that look identical from the browser. One knob cannot disagree with itself.
28+
29+
The fail-closed direction survives the collapse for free, and more robustly than the boolean managed: the grant is now "a non-empty DSN reached me", so an older runtime, a third-party host, a 404, a network error, a malformed body and a payload that has not arrived yet all carry no DSN and therefore deny. A boolean needed `=== true` plus a written argument about why `disabled: true` would have been vacuous; absence of a *source* is not a value that can be misread.
30+
31+
**Everything that must travel with the DSN travels with it.** `sendDefaultPii`, `environment`, `tracesSampleRate` and `replaysOnErrorSampleRate` were build-time `VITE_SENTRY_*` variables, which a prebuilt-console consumer could set none of — including the one deciding whether IP and User-Agent leave the network. This is not new surface; it is the same surface moved to the side that can operate it. One knob deliberately did **not** move: a release identifies *which bundle* produced a stack trace and must match that build's uploaded source maps, so `VITE_SENTRY_RELEASE` stays build-time in objectui and is the only `VITE_SENTRY_*` knob that does.
32+
33+
**Malformed is refused at mount, never coerced**, and every refusal lands on the safer value. A DSN that is not an `https://PUBLIC_KEY@HOST/PROJECT_ID` URL is refused and the whole block withheld — there is no safe default for a source. A DSN carrying a **secret** after the public key is refused for a different reason: this payload is read by every browser that loads the Console, so a legacy secret-bearing DSN would publish that secret to every visitor while looking entirely ordinary. A bad sample rate falls back to its documented default instead, because silencing error reporting over a typo in a volume knob would be strictness pointed away from the hazard. Quoted values are key-redacted: boot logs travel further than the configuration they quote.
34+
35+
**A runtime that declared its control plane off serves no sink.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the DSN and says so in the boot log — the copied-hosted-config-onto-an-air-gapped-box shape. That declaration is the repo's one existing network-posture signal and needs no new knob: the EE image's compose file already defaults `OS_CLOUD_URL` to `off`, so the operator this failed is safe with zero configuration.
36+
37+
**Absence is denial, and the reading ships with the contract.** `readClientErrorReporting(payload)` is the canonical fail-closed reader, returning the sink or `null`; a failed fetch is spelled by passing `undefined`, so the error path and the absent path reach the same answer through the same function. It is exported rather than left to consumers because "no DSN means do not send" is a claim about *their* code.
38+
39+
### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled
40+
41+
The #10805 permission boolean is removed in this same change — no dual-spelling window. It was added days ago, is **unreleased** (it appears in no published `CHANGELOG.md`), and no deployment consumes it; its pending changeset is superseded by this one rather than shipping a feature and its removal in the same release notes.
42+
43+
| FROM | TO |
44+
|:--|:--|
45+
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
46+
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
47+
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
48+
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
49+
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |
50+
51+
One-line fix for an operator: replace the `..._ENABLED=true` line with a `..._DSN=` line carrying your DSN. One-line fix for a consumer: `if (readClientErrorReporting(payload)) …` in place of `if (buildTimeDsn && isClientErrorReportingAllowed(payload)) …` — the build-time conjunct is gone, because the server now supplies the source.
52+
53+
**Landing order is safe in both directions.** An old client meeting this server reads an absent `allowClientErrorReporting` and denies; a new client meeting an old server reads an absent DSN and stays off. Neither half can turn reporting on by itself, so the two repos' PRs can land in any order.
54+
55+
<!-- adr-0087: not-required (unpublished) the replaced boolean, its env var, its config option and its reader were added by #10805 and never shipped in a published release — `@objectstack/cloud-connection@17.2.0` carries no mention of them and their changeset was still pending in `.changeset/`, so there is no upgrader to reach. -->

0 commit comments

Comments
 (0)