Skip to content

Commit 2c537b7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20390-forward-conversion-window
2 parents a1dc7d3 + 87c37ae commit 2c537b7

24 files changed

Lines changed: 997 additions & 111 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/objectql': minor
4+
---
5+
6+
fix(cli): `os validate` refuses a `views:` container whose own `name` disagrees with the object it binds to, the stack the server refuses at boot (#20331)
7+
8+
Clause-②: yes
9+
10+
A view container is registered under the object it binds to. When its own `name`
11+
is set to something else, for example `{ name: 'order_line', object: 'my_app_order_line', list: { … } }`,
12+
the server refuses the whole stack at boot. `os validate` used to pass that stack
13+
at exit 0, so the first sign of the mistake was a server that would not start.
14+
15+
`os validate` now runs the same check the server runs at boot and prints the same
16+
message. The text form and `--json` both exit `1`. The `--json` failure payload lists
17+
one `errors` entry per refused container, with `path` (for example `views[0]`, or
18+
`packages[1].manifest.views[0]` in a multi-package stack), `code: 'VALIDATION_ERROR'`,
19+
`httpStatus: 400` and `message`. Every other exit, and the success payload, are
20+
unchanged.
21+
22+
**Fix:** remove the container's `name`, or set it to the object name the message names.
23+
24+
**New in `@objectstack/objectql` (the widening):** two new exports on the package's
25+
root entry, `viewContainerNameRefusal(container, sourceLabel, ownerId)` and its
26+
return type `ViewContainerNameRefusal`. The function returns the refusal the boot
27+
registrar throws, or `undefined`. It returns `undefined` for a container whose
28+
derived object key is empty, because the boot registrar skips that entry with a
29+
warning and never refuses it. The boot registrar now calls this function. What it
30+
refuses, its message and its `VALIDATION_ERROR` / `400` envelope are unchanged.
31+
32+
Not changed: `os build` does not run this check, so it still writes an artifact
33+
carrying such a container, and the server refuses that artifact when it loads it.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
The `no_sign_in_account_at_boot` boot report states the email-verification rule each audience posture enforces.
6+
7+
Clause-②: no
8+
9+
The report's recovery advice said an `open` or `email_domain` posture forces email verification on the invited login. Later in the same message it said an `open` deployment can turn verification off. The first sentence stopped being true when posture `open` began honouring a deployment's opt-out. The message now states the rule once, as the auth plugin enforces it, and applies it to the invited login and to a new self-registered address alike:
10+
11+
- `email_domain` always forces email verification on.
12+
- `open` forces it on unless the deployment turns it off, with `emailAndPassword.requireEmailVerification: false` or `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`. A `false` stored only through the settings console is refused.
13+
- `invite_only` follows the deployment's declaration and is off by default.
14+
15+
The advice keeps its order: on a deployment with no mail transport, close the posture back to `invite_only`, with verification left at its default off, before the invited person registers.
16+
17+
Text only: no admission decision, verification default or accepted value changes.

‎packages/cli/src/commands/validate.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ import {
5858
formatPermissionSetNameCollisions,
5959
} from '../utils/permission-set-name-collisions.js';
6060
import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-security';
61+
// [#20331] The boot registrar's divergent view-container `name` refusal, walked
62+
// over the parsed stack the way the load path registers it. The verdict is
63+
// `@objectstack/objectql`'s; see the module header.
64+
import { findViewContainerNameRefusals } from '../utils/view-container-names.js';
6165

6266
export default class Validate extends Command {
6367
static override description =
@@ -328,6 +332,51 @@ export default class Validate extends Command {
328332
this.exit(1);
329333
}
330334

335+
// 2c. [#20331] The boot registrar's divergent view-container `name`
336+
// refusal, judged here by the SAME function
337+
// `ObjectQL.registerMetadataCollections` throws the answer of
338+
// (`viewContainerNameRefusal`, `@objectstack/objectql`). This door
339+
// used to pass `{ name: 'order_line', object: 'my_app_order_line',
340+
// list: {…} }` at exit 0 while `os serve` refused the same stack at
341+
// boot — the silent-validator shape, on the command whose whole job
342+
// is to say what the runtime will accept.
343+
//
344+
// ⛔ One judge, not a second rule: not an `@objectstack/lint`
345+
// registry member and not a re-spelling of the check. The helper
346+
// owns only the WALK (which `views:` entries boot registers, under
347+
// which package id); the verdict and its words are the runtime's,
348+
// so the author reads here exactly what the server would print.
349+
//
350+
// Right after the parse, ahead of the rule table: this is the
351+
// runtime's own accept set, the same class as the schema, and
352+
// nothing below it is worth reading about a stack the server will
353+
// not load. `os build` does not run it (see the ledger row in
354+
// `test/validate-build-gate-parity.test.ts`).
355+
const containerNameRefusals = findViewContainerNameRefusals(result.data as Record<string, unknown>);
356+
if (containerNameRefusals.length > 0) {
357+
if (flags.json) {
358+
await emitJson({
359+
valid: false,
360+
errors: containerNameRefusals,
361+
// [#12047] Every exit carries the lists the run has computed so
362+
// far — here the pre-parse ones only, and the conversion notices
363+
// `normalizeStackInput` filled at step 2.
364+
warnings: warningsSoFar(),
365+
conversions: conversionNotices,
366+
duration: timer.elapsed(),
367+
});
368+
this.exit(1);
369+
}
370+
const n = containerNameRefusals.length;
371+
console.log('');
372+
printError(`The server would refuse this stack at boot (${n} view container${n > 1 ? 's' : ''})`);
373+
printBulletList(
374+
containerNameRefusals.map((r) => r.message),
375+
{ noun: 'view-container refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
376+
);
377+
this.exit(1);
378+
}
379+
331380
// 3. The author-time rule registry (#4409). Every rule the three authoring
332381
// commands share — expressions, view shape, widget/action/filter/name
333382
// references, SDUI styling, page sources, security posture, the CLI's
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20331] `findViewContainerNameRefusals` — the WALK `os validate` runs the
5+
* boot registrar's view-container `name` judgment over.
6+
*
7+
* The verdict is `@objectstack/objectql`'s `viewContainerNameRefusal`, the
8+
* function the boot loop throws the answer of; its own file pins that. What
9+
* this module owns, and what is pinned here, is which `views:` entries it hands
10+
* that judge and under which package id — the load path's answer:
11+
*
12+
* - no `packages[]` → the top-level `views`, owned by `manifest.id`;
13+
* - `packages[]` → each body's own `views`, owned by that package, and the
14+
* top level NOT at all (the load path does not register from it).
15+
*
16+
* Every row's message is asserted EQUAL to the judge's answer for the same
17+
* entry, source label and id — never re-spelled — so this file cannot drift
18+
* into a second copy of the words it exists to repeat.
19+
*/
20+
21+
import { describe, expect, it } from 'vitest';
22+
import { viewContainerNameRefusal } from '@objectstack/objectql';
23+
import { findViewContainerNameRefusals } from './view-container-names.js';
24+
25+
const ID = 'com.example.vcn';
26+
27+
const view = (extra: Record<string, unknown>) => ({
28+
object: 'vcn_order_line',
29+
list: { type: 'grid', columns: [{ field: 'name' }] },
30+
...extra,
31+
});
32+
33+
const divergent = view({ name: 'order_line' });
34+
35+
const manifest = (id: string) => ({ id, name: id, version: '1.0.0', type: 'app' });
36+
37+
describe('#20331 — findViewContainerNameRefusals walks what the load path registers', () => {
38+
it('a one-package stack: the top-level `views`, under the manifest id, in the judge\'s words', () => {
39+
const rows = findViewContainerNameRefusals({ manifest: manifest(ID), views: [divergent] });
40+
expect(rows).toHaveLength(1);
41+
const expected = viewContainerNameRefusal(divergent, 'manifest', ID);
42+
expect(expected).toBeDefined();
43+
expect(rows[0]).toEqual({
44+
path: 'views[0]',
45+
code: 'VALIDATION_ERROR',
46+
httpStatus: 400,
47+
message: expected!.message,
48+
});
49+
expect(rows[0].message).toContain(`from manifest '${ID}'`);
50+
});
51+
52+
it('reports EVERY divergent container, located, and none of the others', () => {
53+
const rows = findViewContainerNameRefusals({
54+
manifest: manifest(ID),
55+
views: [
56+
view({ name: 'vcn_order_line' }),
57+
divergent,
58+
view({}),
59+
view({ name: 'order_line_two' }),
60+
],
61+
});
62+
expect(rows.map((r) => r.path)).toEqual(['views[1]', 'views[3]']);
63+
});
64+
65+
it('CONTROL: a matching `name`, an absent one, and no `views` at all report nothing', () => {
66+
expect(findViewContainerNameRefusals({ manifest: manifest(ID), views: [view({ name: 'vcn_order_line' })] }))
67+
.toEqual([]);
68+
expect(findViewContainerNameRefusals({ manifest: manifest(ID), views: [view({})] })).toEqual([]);
69+
expect(findViewContainerNameRefusals({ manifest: manifest(ID) })).toEqual([]);
70+
});
71+
72+
it('a `packages[]` stack: each body\'s own `views`, under THAT package\'s id', () => {
73+
const rows = findViewContainerNameRefusals({
74+
packages: [
75+
{ manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } },
76+
{ manifest: { ...manifest('com.example.orders'), views: [divergent] } },
77+
],
78+
});
79+
expect(rows).toHaveLength(1);
80+
expect(rows[0].path).toBe('packages[1].manifest.views[0]');
81+
expect(rows[0].message).toBe(viewContainerNameRefusal(divergent, 'manifest', 'com.example.orders')!.message);
82+
});
83+
84+
it('a `packages[]` stack: the top-level `views` is NOT judged, because the load path does not register it', () => {
85+
// `resolveArtifactPackageOrder` returns the package bodies alone once
86+
// `packages` is present, so a top-level copy never reaches the registrar.
87+
// Judging it here would refuse a stack the server loads.
88+
const rows = findViewContainerNameRefusals({
89+
manifest: manifest(ID),
90+
views: [divergent],
91+
packages: [{ manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } }],
92+
});
93+
expect(rows).toEqual([]);
94+
});
95+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os validate`'s author-time half of the boot registrar's divergent
5+
* view-container `name` refusal (#20331).
6+
*
7+
* ## The defect
8+
*
9+
* A `views:` container whose own `name` disagrees with the object key it binds
10+
* to — `{ name: 'order_line', object: 'my_app_order_line', list: {…} }` — is
11+
* refused at boot by `ObjectQL.registerMetadataCollections` (#7378 row 1).
12+
* `os validate` had no counterpart, so it exited 0 on that stack and
13+
* `os serve` then refused it. `os validate` is the author-time judge of what
14+
* the runtime will accept (NORTH-STAR road step ①), and a green validate
15+
* followed by a boot refusal is the silent-validator shape.
16+
*
17+
* ## One judge, not a second rule
18+
*
19+
* {@link viewContainerNameRefusal} is `@objectstack/objectql`'s, the SAME
20+
* function the boot registrar throws the answer of. ⛔ Not an `@objectstack/lint`
21+
* rule and not a re-spelling of the check here: a twin agrees with the
22+
* runtime only until one of the two is edited, which is the drift this card
23+
* exists to close. What this module owns is the WALK — which `views:` entries
24+
* boot judges, and under which package id — and nothing about the verdict.
25+
*
26+
* ## The walk is the boot path's
27+
*
28+
* `AppPlugin` hands the manifest service `{ ...stack.manifest, ...stack }`;
29+
* `resolveArtifactPackageOrder` returns that payload itself when it carries no
30+
* `packages` key and each `packages[i].manifest` body otherwise (ADR-0130 D4);
31+
* `ObjectQL.registerApp(body)` then runs `registerMetadataCollections(body,
32+
* body.id || body.name, 'manifest')`. So:
33+
*
34+
* - no `packages[]` → the top-level `views`, owned by the manifest's id;
35+
* - `packages[]` → each body's own `views`, owned by that package's id,
36+
* and ⛔ NOT the top level, which the load path does not register from.
37+
*
38+
* The package id is read through the owners that already exist for it: the
39+
* runtime's `artifactPackageId` (`@objectstack/core` — "every seam that has to
40+
* name a package reads the id through THIS function") for the one-package
41+
* payload, and this package's `artifactPackages` for `packages[]`, the reader
42+
* both `os build` and `os validate` already walk with.
43+
*
44+
* ⚠️ Bound, stated rather than hidden: a nested `plugins[]` entry's `views` is
45+
* registered by boot too (label `nested plugin`), and is NOT walked here. The
46+
* stack schema types `plugins` as `unknown[]` — in an authored config they are
47+
* runtime plugin instances, not metadata bundles — so this door has no parsed
48+
* shape to walk there.
49+
*
50+
* Reads the PARSED stack — what `defineStack()` hands the boot wrap, and what
51+
* `os build` serializes.
52+
*/
53+
54+
import { artifactPackageId } from '@objectstack/core';
55+
import { viewContainerNameRefusal } from '@objectstack/objectql';
56+
57+
import { artifactPackages } from './artifact-packages.js';
58+
59+
/** One refusal, located. `message` is the boot registrar's, verbatim. */
60+
export interface ViewContainerNameRefusalRow {
61+
/** Where the entry sits in the parsed stack, e.g. `views[0]`. */
62+
path: string;
63+
/** The ADR-0112 code the boot registrar throws with. */
64+
code: string;
65+
/** The HTTP status the boot registrar throws with. */
66+
httpStatus: number;
67+
/** The refusal, in the boot registrar's own words. */
68+
message: string;
69+
}
70+
71+
type AnyRec = Record<string, unknown>;
72+
73+
const asRec = (v: unknown): AnyRec | undefined =>
74+
v && typeof v === 'object' && !Array.isArray(v) ? (v as AnyRec) : undefined;
75+
76+
/**
77+
* Every `views:` container in this stack that the boot registrar would refuse
78+
* for a divergent `name`, in the order boot meets them within each body.
79+
*
80+
* Returns `[]` for a stack the boot registrar accepts on this axis.
81+
*/
82+
export function findViewContainerNameRefusals(parsed: AnyRec): ViewContainerNameRefusalRow[] {
83+
const bodies: Array<{ at: string; ownerId: string | undefined; views: unknown }> = [];
84+
// The resolver's own branch test (`declared === undefined`). On the PARSED
85+
// stack a present `packages` is an array — `ArtifactPackageSchema[]`,
86+
// `.optional()`, so `null` and every non-array were refused at the parse.
87+
if (parsed.packages !== undefined) {
88+
for (const pkg of artifactPackages(parsed)) {
89+
bodies.push({ at: `packages[${pkg.index}].manifest.views`, ownerId: pkg.id, views: pkg.body.views });
90+
}
91+
} else {
92+
const manifest = asRec(parsed.manifest);
93+
const payload = manifest ? { ...manifest, ...parsed } : parsed;
94+
bodies.push({ at: 'views', ownerId: artifactPackageId(payload), views: parsed.views });
95+
}
96+
97+
const rows: ViewContainerNameRefusalRow[] = [];
98+
for (const { at, ownerId, views } of bodies) {
99+
if (!Array.isArray(views)) continue;
100+
views.forEach((entry, index) => {
101+
const refusal = viewContainerNameRefusal(entry, 'manifest', ownerId);
102+
if (!refusal) return;
103+
rows.push({
104+
path: `${at}[${index}]`,
105+
code: refusal.code,
106+
httpStatus: refusal.httpStatus,
107+
message: refusal.message,
108+
});
109+
});
110+
}
111+
return rows;
112+
}

‎packages/cli/test/build-text-face-advisory-count.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,11 @@ const ordersObjects = [{
187187
}];
188188
const ordersViews = [
189189
{
190-
name: 'bc_account_list', label: 'Account List', object: 'bc_account',
190+
name: 'bc_account', label: 'Account List', object: 'bc_account',
191191
list: { label: 'Account List', columns: ['name', 'industry'] },
192192
},
193193
{
194-
name: 'bc_order_list', label: 'Order List', object: 'bc_order',
194+
name: 'bc_order', label: 'Order List', object: 'bc_order',
195195
list: { label: 'Order List', columns: ['name', 'account'] },
196196
},
197197
];

‎packages/cli/test/format-zod-union.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ const TOOLTIP_ALIAS_STACK = {
185185
manifest: { id: 'com.example.union-probe', name: 'Union Probe', namespace: 'union_probe', version: '1.0.0', type: 'app' },
186186
views: [
187187
{
188-
name: 'union_probe_view',
188+
name: 'union_probe_obj',
189189
object: 'union_probe_obj',
190190
list: {
191191
name: 'union_probe_list',

‎packages/cli/test/info-detail-package-fold.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ const OBJECTS = [
9191

9292
const VIEWS = [
9393
{
94-
name: 'ob_order_views',
94+
name: 'ob_order',
9595
object: 'ob_order',
9696
list: {
9797
label: 'Orders',

‎packages/cli/test/lint-handwritten-checks-package-fold.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const definitions = () => ({
103103
],
104104
views: [
105105
{
106-
name: 'probe_view',
106+
name: 'probe_order',
107107
object: 'probe_order',
108108
list: { label: 'order list', columns: ['number'] }, // convention/label-case
109109
},

‎packages/cli/test/lint-label-case-localized.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ const stackWithApp = (label: unknown) => ({
9292

9393
const stackWithListLabel = (label: unknown) => ({
9494
manifest: MANIFEST,
95-
views: [{ name: 'invoice_views', object: 'invoice', list: { label, type: 'grid', columns: ['name'] } }],
95+
views: [{ name: 'invoice', object: 'invoice', list: { label, type: 'grid', columns: ['name'] } }],
9696
});
9797

9898
const stackWithNamedListLabel = (label: unknown) => ({
9999
manifest: MANIFEST,
100100
views: [{
101-
name: 'invoice_views',
101+
name: 'invoice',
102102
object: 'invoice',
103103
listViews: { all: { label, type: 'grid', columns: ['name'] } },
104104
}],

0 commit comments

Comments
 (0)