Skip to content

Commit 2cb8dfe

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17052-insert-failure-log-level
2 parents 998d987 + 5a95b0e commit 2cb8dfe

122 files changed

Lines changed: 10939 additions & 374 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the ADR-0112 error envelope gains a producer-side `refusal` declaration, so a deliberate 5xx refusal can keep its caller-authored `message` (#16335)
6+
7+
`ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, **`refusal: true`** — the producer's declaration that the 5xx it named is a deliberate REFUSAL whose `message` is authored for the caller, so the boundary keeps that message verbatim instead of withholding it. Director ruling, decision batch #58 (2026-09-06, option C): the refusal/fault distinction is a producer-side declaration on the published envelope — not a status heuristic and not a second allow-list.
8+
9+
The three cases are now documented side by side on the envelope's TSDoc:
10+
11+
- **undeclared 5xx** (no `status` on the throw) — unchanged: the leak heuristic decides per message.
12+
- **declared fault** (`status >= 500` + `code`, nothing declared here) — unchanged, and still the DEFAULT: `message` is withheld from the body and logged for the operator.
13+
- **declared refusal** (`status >= 500` + `code` + `refusal: true`) — new: `message` is kept verbatim, bounded exactly as a 4xx message is.
14+
15+
Purely additive: a producer that says nothing here gets exactly the previous behaviour. `true` is the only value — `refusal: false` fails parse instead of becoming a third state consumers would have to interpret. `userMessage` is orthogonal (end-user text; it never replaces `message`) and may ride the same envelope; the TSDoc reconciles this flag with the recorded reason `userMessage` is a text-carrying field rather than "a boolean beside `message`".
16+
17+
This is the spec half. The relay half — the three withhold arms reading the declaration (two in `@objectstack/rest`: `declaredServerFaultAnswer`, and `resolveErrorResponse`'s own 5xx passthrough arm, which the `/references` door reaches; one at `@objectstack/runtime`'s dispatcher exit, `errorResponseBase`, which `objectstack serve` mounts and which never consults the first), plus retiring the route-local patch from PR #16143 on `/meta/:type/:name/references` — is #16146 for the REST pair and its sub-issue #17153 for the runtime exit; until they land, a declared refusal is still withheld at the wire.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes.
6+
7+
`EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound).
8+
9+
Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence.
10+
11+
Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one.
12+
13+
New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`.
14+
15+
This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760)
6+
7+
Both methods annotate their return as `SessionResponse` — ObjectStack's REST
8+
`{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth
9+
owns those bytes and answers **bare**. Measured against a real `AuthManager`
10+
(better-auth 1.7.2, organization plugin) over a real driver:
11+
12+
```
13+
GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}}
14+
GET /api/v1/auth/get-session (anonymous) -> 200 null
15+
```
16+
17+
So `(await client.auth.me()).data.user` type-checked and was `undefined` at
18+
runtime, while `.user` — the real payload — did not type-check. The annotation
19+
pointed every caller at the wrong key.
20+
21+
## What changed
22+
23+
- The bare answer is now lifted into the declared envelope, the same lift
24+
`auth.login` has always carried for `/sign-in/email`. `SessionResponse` is
25+
**unchanged** and so is each method's published return annotation: the fix is
26+
in what the methods produce, not in what they promise.
27+
- The lift fills `success` as well as `data`. `SessionResponseSchema` is
28+
`BaseResponseSchema.extend(…)` and that base declares `success` as a required
29+
boolean, so a body carrying `data` alone still would not parse as the declared
30+
type.
31+
- The raw `.user` / `.session` keys are **kept** alongside `data`. They are what
32+
callers were pushed onto while the declared shape was unreachable; dropping
33+
them would trade one silent breakage for another.
34+
- `auth.refreshToken` now reads `data.session.token`. It used to read
35+
`data.data?.token` — a field this route does not produce at any nesting, so
36+
the method returned successfully having captured nothing. A bearer-mode client
37+
calling it to refresh kept whatever credential it already had, silently.
38+
39+
## The read was not a consequence of the envelope
40+
41+
Worth stating because the reverse is the natural assumption: enveloping the body
42+
does **not** put a token at `data.token`, because the route serves no top-level
43+
`token` to lift. The only credential in the body is `session.token`, and that is
44+
now the read. Fixing the shape alone would have left `refreshToken` exactly as
45+
inert as it was.
46+
47+
## FROM → TO
48+
49+
| you wrote | write instead |
50+
|:--|:--|
51+
| `(await client.auth.me()).user` | still works — kept deliberately |
52+
| `(await client.auth.me()).data.user` | now populated (was `undefined`) |
53+
| `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` |
54+
55+
`refreshToken` stores the **unsigned** session token, which is the spelling
56+
`/get-session` serves; `bearer()` accepts it and the signed
57+
`token.signature` form interchangeably, so a client that held the signed form
58+
stays signed in across the call.
59+
60+
Two answers stay outside the declared type and are **not** addressed here: the
61+
anonymous `null`, which would need the published return annotation to widen, and
62+
`SessionUser.image`, declared `z.string().optional()` against a route that
63+
serves `null` (#17235). The sibling `auth.login` / `auth.register`, which
64+
normalize into `data` but set no `success`, are #17234.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": patch
4+
"@objectstack/platform-objects": patch
5+
---
6+
7+
feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms.<type>.fields.<path>.label` onto a derived JSON Schema (#16458)
8+
9+
## What was wrong
10+
11+
The Studio property panel renders `dashboard.header.actions[]` as a table whose
12+
column headers read `items.properties[k].title ?? k` from the JSON Schema
13+
derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields
14+
(`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback
15+
arm ran for every locale, English included, and the maker saw machine keys.
16+
Nothing could localise them either: the only channel, `resolveMetadataFormLabels`,
17+
decorates the `FormFieldSpec` tree, which the table never reads. And the platform
18+
catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared
19+
no children under the composite, so `os i18n extract` emitted no
20+
`header.showTitle` / `header.showDescription` / `header.actions` key and the
21+
console shipped a private overlay for exactly those three.
22+
23+
## What changed
24+
25+
- **`@objectstack/spec`**`DashboardHeaderActionSchema`'s four fields author
26+
`.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the
27+
derived JSON Schema names each column. New export
28+
`resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in
29+
`@objectstack/spec/system`: every `metadataForms.<type>.fields.<path>.label`
30+
at any locale of the chain becomes the `title` of the node the path addresses,
31+
stepping through an array's `items` so a repeater ROW property is addressed
32+
as `<repeater>.<property>` (`header.actions.label`) — the same path the
33+
extractor emits. Pure; returns the input object itself when nothing applies.
34+
`dashboardForm` enumerates the `header` composite's children
35+
(`showTitle`, `showDescription`, `actions` with its four row properties) with
36+
labels equal to the schema titles, pinned equal in `dashboard.test.ts`.
37+
The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx`
38+
→ "Metadata authoring forms".
39+
- **`@objectstack/rest`**`GET /api/v1/meta` localises each entry's derived
40+
`schema` beside its `form`, through that overlay.
41+
- **`@objectstack/platform-objects`** — the four generated `metadata-forms`
42+
catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`,
43+
`ja-JP` and `es-ES`.
44+
45+
Additive: no key removed, no accept set changed, no parsed output moved.
46+
47+
`DashboardSchema.columns` deliberately still declares no `.default(12)`, and
48+
the reason is stronger than the one #16458 assumed. The card reasoned that the
49+
renderer already falls back to 12, which would make `.default(12)`
50+
behaviour-preserving. Measured at objectui `origin/main`
51+
(`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a
52+
`columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4`
53+
yields 12 and everything else yields **4** — and the next line switches the
54+
whole layout on that value (`hasExplicitColumns = schema.columns != null ||
55+
inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the
56+
default would therefore both retire the inference and flip every auto-flow
57+
dashboard into the positioned grid. A default that silently materialises a key
58+
is expensive to take back, so the round stopped at the declared condition and
59+
left the key alone; see #16458.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317)
6+
7+
## What was wrong
8+
9+
Both migration formats emitted the table and none of the object's declared
10+
uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all
11+
three producers into three schemas, `pg_indexes` read back per schema:
12+
13+
```ts
14+
{ name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } }
15+
```
16+
17+
| producer | before | after |
18+
|:--|:--|:--|
19+
| `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged |
20+
| `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** |
21+
| `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** |
22+
23+
Two rows with the same `keyed_unique` value were refused by the platform's table
24+
(`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted
25+
by both generated ones, with nothing reporting it: a scaffold that creates the
26+
table for an object silently dropped a uniqueness guarantee the object declares.
27+
After the change the duplicate is refused by all three, each naming the same
28+
constraint.
29+
30+
The key set was not missing — it was already computed here to size the keyed
31+
text family's columns; only the index it implies was never emitted.
32+
33+
## What it does now
34+
35+
- **`--format sql`** emits an inline `CONSTRAINT "<name>" UNIQUE (<columns>)`.
36+
That is what knex's `table.unique(columns, { indexName })` — the driver's own
37+
call — compiles to on PostgreSQL, so a generated table and a platform-created
38+
one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside
39+
the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD
40+
CONSTRAINT` has no spelling for.
41+
- **`--format ts`** emits that knex call itself, `indexName` included — which is
42+
what makes the driver recognise the constraint as already present on its first
43+
boot against a generated table, instead of adding a second one under its own
44+
name and then reporting the generated one as an orphan to drop.
45+
- Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned
46+
against the driver's own export (a CLI production module may not statically
47+
value-import a driver package).
48+
49+
## What it deliberately still does not emit — and now says so
50+
51+
Both formats print a `NOT EMITTED:` line naming the index, its key parts and the
52+
reason, instead of dropping it silently:
53+
54+
- the **organization-scoped composite** (`unique: true` / `'organization'` on an
55+
object with an organization column), whose key part is
56+
`COALESCE(<organization column>, '__global__')`. Emitting the bare composite
57+
instead would be worse than emitting nothing: under SQL's NULL-distinct
58+
`UNIQUE` it constrains no row that has no organization, which on a
59+
single-tenant deployment is every row.
60+
- an index over a column no field materialises (a virtual `formula` field) —
61+
the same skip the driver performs, where the driver logs a warning.
62+
63+
Object-level `indexes[]` remains unemitted by both formats; it is normalized by
64+
a different driver-side rule and is not covered by this change.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294)
6+
7+
## What was wrong
8+
9+
Neither `os generate migration` format read a field's `defaultValue`, so a table
10+
created from a generated migration had no column DEFAULT where the platform's
11+
own table has one. A row inserted out of band — by a database client, a seed
12+
script, anything that does not go through the engine — got NULL where the
13+
declared value belonged.
14+
15+
Driven on live PostgreSQL 16.13: one object, three schemas, one producer each
16+
(`driver-sql` through `initObjects`, `--format sql` through `db.raw`,
17+
`--format ts` by importing the emitted module and calling `up(db)`), with
18+
`information_schema.columns` read back per schema.
19+
20+
```
21+
field driver sqlgen verdict
22+
f_default null=YES default='hello'::text null=YES default=- DIVERGED
23+
f_default_required null=YES default='hello'::text null=YES default=- DIVERGED
24+
```
25+
26+
After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one
27+
covering every `defaultValue` shape.
28+
29+
## What changed
30+
31+
Both formats now render one shared verdict, taken from
32+
`SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue`
33+
becomes DDL on the platform side:
34+
35+
- a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not
36+
`DEFAULT 42` — PostgreSQL keeps those two textually apart forever in
37+
`column_default`, and the driver's column carries the quoted form);
38+
- **`'NOW()'`** becomes the driver's own translation, which is type-branched:
39+
`CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on
40+
`date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the
41+
server's timezone;
42+
- **any other runtime token** (`current_user`), an **Expression envelope** and
43+
an **option-level `default: true`** emit nothing, each because the driver
44+
emits nothing — the engine owns those, and a column DEFAULT would override a
45+
decision it makes deliberately;
46+
- a **`multiple: true`** field gets neither, because `createColumn` returns
47+
before both questions.
48+
49+
No authorable key, export or accepted-input set changes: `defaultValue` was
50+
already declared, already parsed and already honoured by the driver. The
51+
generators simply now read it.

0 commit comments

Comments
 (0)