Skip to content

Commit e1c377e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-g5rn8k
2 parents b76480e + dbf53ef commit e1c377e

32 files changed

Lines changed: 1070 additions & 160 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': minor
4+
---
5+
6+
feat(spec): an action param can name the widget that draws it (`widget`, same key as on a field), so a param can ask for the permission-set picker (ADR-0131 D4)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
- **`@objectstack/spec`: `ActionParamSchema.widget`**, an optional string with the same key, vocabulary and meaning as `FieldSchema.widget`. It names a registered field component, looked up as `field:` plus the name, to draw the param in the action dialog instead of the `type` default. `type` stays the value's type. The case it exists for is `widget: 'permission-ref'` on a `text` param: it lists the permission sets of the security catalog and submits the chosen machine name, the picker the grant column `sys_user_permission_set.permission_set` already uses. Like the field key it is an open string: an unregistered name is kept, and the dialog draws the `type` renderer instead. A non-string is refused. A param that does not declare the key parses exactly as before. Until now a param naming `widget` was refused as an unknown key.
12+
- **Not read yet.** The console's param resolver does not carry the key to the dialog's widget until objectstack#30260 lands. Until then a param that declares it is drawn by its `type` renderer, and its value is submitted as before. The key's description says so, and the liveness ledger grades it `planned` with an author warning: `os validate` and `os lint` warn an author who writes it.
13+
- **`@objectstack/platform-objects`: the action form's Params repeater offers the key** as a `Widget` column, labelled in en, zh-CN, ja-JP and es-ES.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): a permission set's field key on a field that an `objectExtensions` entry adds, a sibling package's in the same artifact included, is no longer refused as `security-fls-unknown-field` (#30303)
6+
7+
Clause-②: yes (widening: the FLS-key check accepts extension-added fields)
8+
Metadata-protocol: none
9+
10+
- **Before.** The FLS-key check resolved `object.field` against the object's declared `fields` only. On a multi-package artifact, an app package's permission set naming `crm_contract.o2c_probe_note`, a field its module package adds to `crm_contract` through `objectExtensions`, failed `os build`, `os validate` and `os lint` with "crm_contract declares no field o2c_probe_note", although the runtime serves the field and the key masks it. A single-package stack's own extension fields were refused the same way.
11+
- **Now.** The key resolves against the object as the registry serves it: its declared fields plus every field an `objectExtensions` entry of any body of the artifact adds to it, folded as `@objectstack/objectql` folds them (fields by name; one entry per package per object, a later entry replacing an earlier one; `extend` naming the object verbatim). A field that no body declares or adds keeps the same refusal, with the same rule id, severity and message, and so does a field an entry only patches through `fieldPatches`, which adds no field. The hint's nearest name reads the served fields too.
12+
- **One fold.** The controlled-by-parent merge (#23210), the membership-key column check and the FLS-key check now read extensions through one shared fold in `object-graph.ts`.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): an IPv6 CIDR in the allowed IP ranges admits the IPv6 clients inside it, at the sign-in gate and at the settings save check
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`auth.allowed_ip_ranges` (ADR-0069 D5) promises CIDR ranges, but its matcher read only IPv4 CIDR and exact addresses: an IPv6 entry such as `2001:db8::/32` was compared as text, so it never matched a real client. The sign-in gate then refused every IPv6 client the list named, and the settings save check refused the save for an administrator connected over IPv6 from inside it. `ipMatchesRange` now reads an IPv6 CIDR as the first `n` of 128 bits, and both doors use it, so they keep judging the same address the same way.
11+
12+
- An exact IPv6 entry matches the address it denotes however it is spelled (`2001:DB8:0:0::1` matches `2001:db8::1`).
13+
- An IPv4-mapped address, dotted (`::ffff:203.0.113.5`) or hex (`::ffff:cb00:7105`), is still judged as the IPv4 address it maps. A zone identifier on an address (`fe80::1%eth0`) is dropped before matching.
14+
- Families never cross: an IPv4 range never admits a pure IPv6 address, and an IPv6 range never admits an IPv4 one.
15+
- An IPv6 prefix must be a whole number from 0 to 128. An entry with any other prefix matches only its base address, as it did before. IPv4 entries are read exactly as before.

‎content/docs/references/ui/action.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
141141
| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) |
142142
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
143143
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | |
144+
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. |
144145
| **required** | `boolean` | optional (default: `false`) | |
145146
| **options** | `{ label: string \| Record<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
146147
| **placeholder** | `string` | optional | |
@@ -234,6 +235,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
234235
| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) |
235236
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
236237
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | |
238+
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. |
237239
| **required** | `boolean` | optional (default: `false`) | |
238240
| **options** | `{ label: string \| Record<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
239241
| **placeholder** | `string` | optional | |
@@ -370,6 +372,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
370372
| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) |
371373
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
372374
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | |
375+
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. |
373376
| **required** | `boolean` | optional (default: `false`) | |
374377
| **options** | `{ label: string \| Record<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
375378
| **placeholder** | `string` | optional | |

‎packages/lint/src/lint-liveness-properties.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,34 @@ describe('lintLivenessProperties', () => {
187187
}).filter((f) => f.message.includes('objectName'))).toEqual([]);
188188
});
189189

190+
// #30259 — `action.params[].widget` is declared before the console's dialog
191+
// reads it (#30260 adds the pass-through), so the drilled row is `planned` +
192+
// `authorWarn`. Pinned through the SHIPPED ledger and an array of params, so
193+
// the drill (`params.widget`) and the array fan-out both have to hold.
194+
it('warns on an action param that names a widget — planned, nothing reads it yet', () => {
195+
const findings = lintLivenessProperties({
196+
actions: [{
197+
name: 'create_user',
198+
params: [
199+
{ name: 'email', type: 'email', required: true },
200+
{ name: 'permissionSet', type: 'text', widget: 'permission-ref' },
201+
],
202+
}],
203+
});
204+
const warned = findings.filter((f) => f.message.includes('sets `params.widget`'));
205+
expect(warned).toHaveLength(1);
206+
expect(warned[0].rule).toBe('liveness-planned-property');
207+
expect(warned[0].hint).toContain('nothing reads it yet');
208+
// The live siblings the drill graded stay silent.
209+
for (const key of ['name', 'type', 'required', 'options', 'helpText', 'carryOver']) {
210+
expect(ruleOf(findings, `params.${key}`), key).toBeUndefined();
211+
}
212+
// And an action whose params name no widget draws nothing about it.
213+
expect(lintLivenessProperties({
214+
actions: [{ name: 'a2', params: [{ name: 'note', type: 'text' }] }],
215+
}).filter((f) => f.message.includes('widget'))).toEqual([]);
216+
});
217+
190218
it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => {
191219
// `experimental` warns implicitly — shouldWarn() treats a declared-but-
192220
// unenforced guarantee like an opted-in dead prop. Repointed from

‎packages/lint/src/object-graph.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
nearestName,
1616
listNames,
1717
packagesOf,
18+
objectExtensionsByTarget,
19+
objectsAsServed,
1820
RELATIONSHIP_FIELD_TYPES,
1921
} from './object-graph.js';
2022
import { walkFilterFieldKeys, type FilterFieldKey } from './filter-walk.js';
@@ -345,6 +347,93 @@ describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)'
345347
});
346348
});
347349

350+
/**
351+
* [#30303] The shared extension fold on junk input, beside the
352+
* `indexObjectGraph` and `packagesOf` cases above. Neither export refuses
353+
* anything: a malformed `packages` is {@link packagesOf}'s refusal, met by a
354+
* caller that reads the bodies through it (`validateSecurityPosture` does,
355+
* before either fold runs). Each shape below reads as "nothing to fold" for
356+
* the part that is junk, and never throws.
357+
*/
358+
describe('[#30303] object-graph — objectExtensionsByTarget / objectsAsServed on junk input', () => {
359+
const OBJ = { name: 'crm_contract', fields: { contract_no: { type: 'text' } } };
360+
const EXT = { extend: 'crm_contract', fields: { note: { type: 'text' } } };
361+
const served = (stack: unknown) =>
362+
objectsAsServed(stack).objects.map((o) => [o.name, (o.fields as Array<{ name: string }> | Record<string, unknown>)]);
363+
364+
it('a null or non-record stack: no extensions, no objects', () => {
365+
for (const stack of [null, undefined, 'junk', 42, []]) {
366+
expect(objectExtensionsByTarget(stack).size, String(stack)).toBe(0);
367+
expect(objectsAsServed(stack), String(stack)).toEqual({ objects: [], tied: new Map() });
368+
}
369+
});
370+
371+
it('packages: [null] and other non-record entries or bodies contribute nothing; a record body beside them still does', () => {
372+
expect(objectExtensionsByTarget({ packages: [null] }).size).toBe(0);
373+
const stack = {
374+
objects: [OBJ],
375+
packages: [null, 'junk', 42, [], { manifest: null }, { manifest: 'junk' }, { manifest: { objectExtensions: [EXT] } }],
376+
};
377+
expect([...objectExtensionsByTarget(stack).entries()].map(([t, list]) => [t, list.map((e) => [e.carrier, e.path])])).toEqual([
378+
['crm_contract', [[6, 'packages[6].manifest.objectExtensions[0]']]],
379+
]);
380+
expect(objectsAsServed(stack).objects[0].fields).toEqual([
381+
{ name: 'contract_no', type: 'text' },
382+
{ name: 'note', type: 'text' },
383+
]);
384+
});
385+
386+
it('a non-array objectExtensions folds nothing', () => {
387+
for (const objectExtensions of [null, 'junk', 42, { crm_contract: EXT }]) {
388+
const stack = { objects: [OBJ], objectExtensions };
389+
expect(objectExtensionsByTarget(stack).size, JSON.stringify(objectExtensions)).toBe(0);
390+
expect(objectsAsServed(stack).objects, JSON.stringify(objectExtensions)).toEqual([OBJ]);
391+
}
392+
});
393+
394+
it('a non-record entry, or one whose extend is not a string, is skipped; the record entry beside it is read', () => {
395+
const stack = {
396+
objects: [OBJ],
397+
objectExtensions: [null, 'junk', 42, [], { extend: 7, fields: { x: { type: 'text' } } }, { fields: { y: {} } }, EXT],
398+
};
399+
expect([...objectExtensionsByTarget(stack).keys()]).toEqual(['crm_contract']);
400+
expect(objectExtensionsByTarget(stack).get('crm_contract')!.map((e) => e.path)).toEqual(['objectExtensions[6]']);
401+
expect(served(stack)).toEqual([['crm_contract', [{ name: 'contract_no', type: 'text' }, { name: 'note', type: 'text' }]]]);
402+
});
403+
404+
it('junk fields add nothing: a non-collection, non-record members, a member with no name', () => {
405+
for (const fields of [null, 'junk', 42, [null, 'x', 3, { type: 'text' }, { name: '', type: 'text' }]]) {
406+
const stack = { objects: [OBJ], objectExtensions: [{ extend: 'crm_contract', fields }] };
407+
expect(() => objectsAsServed(stack), JSON.stringify(fields)).not.toThrow();
408+
expect(objectsAsServed(stack).objects[0].fields, JSON.stringify(fields)).toEqual([{ name: 'contract_no', type: 'text' }]);
409+
}
410+
// The array form is read by name, as the map form is.
411+
const arrayForm = { objects: [OBJ], objectExtensions: [{ extend: 'crm_contract', fields: [{ name: 'note', type: 'text' }] }] };
412+
expect(served(arrayForm)).toEqual([['crm_contract', [{ name: 'contract_no', type: 'text' }, { name: 'note', type: 'text' }]]]);
413+
});
414+
415+
// `ObjectExtensionSchema.priority` is a number, so a parsed stack never
416+
// carries this shape; the fold reads a non-number as absent (the default 200,
417+
// as `SchemaRegistry.registerObjectExtension` does) rather than guessing.
418+
it('a string priority reads as the default 200 and never throws', () => {
419+
const stack = {
420+
objects: [OBJ],
421+
packages: [
422+
{ manifest: { objectExtensions: [{ extend: 'crm_contract', priority: '100', fields: { note: { type: 'text', label: 'A' } } }] } },
423+
{ manifest: { objectExtensions: [{ extend: 'crm_contract', priority: 150, fields: { note: { type: 'text', label: 'B' } } }] } },
424+
],
425+
};
426+
expect(objectExtensionsByTarget(stack).get('crm_contract')!.map((e) => [e.carrier, e.priority])).toEqual([
427+
[1, 150],
428+
[0, 200],
429+
]);
430+
// So the string-priority entry folds LAST and its definition wins, untied.
431+
const { objects, tied } = objectsAsServed(stack);
432+
expect((objects[0].fields as Array<Record<string, unknown>>).find((f) => f.name === 'note')?.label).toBe('A');
433+
expect(tied.size).toBe(0);
434+
});
435+
});
436+
348437
/**
349438
* [#19289] A `{ type: 'user' }` field with no `reference` is TRAVERSABLE — the
350439
* third defect found by the implicit-target census, and the widest-reaching of

0 commit comments

Comments
 (0)