Skip to content

Commit 911fc60

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-ypvuqe
2 parents 9da0cd8 + 3b8c724 commit 911fc60

14 files changed

Lines changed: 888 additions & 94 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(objectql, metadata-protocol, runtime): a package's export carries the `objectExtensions` it was installed with, and the extended object's owner's export does not
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
A package installed through `POST /api/v1/marketplace/install-local` (what `os package install` sends, and how every entry of a whole-deployment export is restored) that extends another package's object exported without its `objectExtensions`: `GET /api/v1/packages/<id>/export` answered the package's other items and no extension, while the extension's fields were folded into the owner's export instead, as if the owner had shipped them. So a deployment restored from an export lost those fields on its next export, with no error, and a `.custom` pair installed from its export no longer exported its extension.
13+
14+
- **The extending package's export** (and its entry in `GET /api/v1/packages/export`) now carries every extension it contributes as `objectExtensions`, however it was installed: an extension published through `/api/v1/meta/object_extension` (as before) or a manifest's `objectExtensions` entry (install-local, or a code package). A published extension is exported as it is stored; a manifest entry as it was registered, with `priority` written only when it is not the default `200`.
15+
- **The extended object's owner's export** now carries the object as the owner declares it, without the fields, field patches or labels of ANY other package's extension. Until now only its own `.custom` pair's extension was left out; an extension from any other installed package was folded in.
16+
- **Every served read is unchanged**: `GET /api/v1/meta/object/<name>`, the object list, the console and the data API keep serving the object with every extension folded in.
17+
18+
`SchemaRegistry` gains `getObjectExtensionsOf(packageId)`, which reads a package's `extend` contributions back as the `objectExtensions` entries they were registered from (the inverse of `registerObjectExtension`). `listItems`, `getAllObjects` and `foldObjectExtendersOnto` take an `onlyExtensionsOf` option beside `withoutExtensionsOf`: each object is resolved with only that package's `extend` contributions folded in. The metadata protocol's per-package layer (the package export) now passes `onlyExtensionsOf`, naming the listed package, and no longer passes `withoutExtensionsOf`, which keeps its meaning for any other caller.
19+
20+
Supersedes, in the same release: the `.custom` pair note for #29903 says an extension contributed by any other installed package is still folded into the base's object in the base's export. After this change it is not: the base's export leaves out every other package's extension, and that package's own export carries it.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a delegated administrator's refused assignment write answers the same for a row it cannot read as for a missing one
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A delegated administrator — a caller holding an `adminScope`, an organization administrator who holds one included — updates or removes an assignment row (`sys_user_position` or `sys_user_permission_set`) by id. The delegated-administration gate (ADR-0090 D12) now asks first whether the caller itself reads that row, in its own context.
11+
12+
- **A row the caller cannot read** is refused with exactly the answer an id that names no row gets: `403 PERMISSION_DENIED`, "the assignment row it addresses could not be read", with the same details. Both are answered by the gate; an id that names no row is no longer handed on, so a removal of one answers that refusal instead of `404 RECORD_NOT_FOUND`.
13+
- **A row the caller reads** keeps the verdict and the guidance it had: the gate judges the stored row, as before.
14+
- **A caller the read door refuses outright** (no read grant on the table) is answered as before: the read refusal, for every id the gate refuses, and the writes it was admitted are still admitted.
15+
- **Unchanged.** Inserts, tenant-level administrators, and organization administrators who hold no scope are judged as before.

‎packages/metadata-protocol/src/protocol-meta-list-reads.ts‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,19 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
8989
// and exports every package, enabled or not); the effective and the
9090
// execution reads hide them, at the registry and after the merges below.
9191
const keepDisabledPackages = layer === 'per-package';
92-
// [#29903, ADR-0146 D8 item 3] …and it serves a package's objects as the
93-
// package declares them: its `.custom` pair's extension of one is the
94-
// pair's item, exported as the pair's `objectExtensions` entry, never
95-
// folded into the base's object for the base's export (a field and its
96-
// patches would travel twice, the second time as the base's). Every
97-
// other contributor folds as on the effective read, and the effective
98-
// and execution reads keep the pair's extension in the object they serve.
99-
const pairOfLayer = layer === 'per-package' && packageId ? customizationPackageId(packageId) : undefined;
100-
const withoutExtensionsOf = pairOfLayer?.ok ? pairOfLayer.value : undefined;
92+
// [#29903, #29872, ADR-0146 D8 item 3] …and it serves a package's objects
93+
// as the package declares them: another package's extension of one (the
94+
// `.custom` pair's, or any other installed package's) is that package's,
95+
// exported as its own `objectExtensions` entry, never folded into the
96+
// owner's object for the owner's export (a field and its patches would
97+
// travel twice, the second time as the owner's). Only the package's own
98+
// extensions fold, and the effective and execution reads keep every
99+
// extension in the object they serve.
100+
const onlyExtensionsOf = layer === 'per-package' && packageId ? packageId : undefined;
101101
const listRegistry = (type: string): unknown[] => (keepDisabledPackages
102102
? host.engine.registry.listItems(type, packageId, {
103103
includeDisabledPackages: true,
104-
...(withoutExtensionsOf !== undefined ? { withoutExtensionsOf } : {}),
104+
...(onlyExtensionsOf !== undefined ? { onlyExtensionsOf } : {}),
105105
})
106106
: host.engine.registry.listItems(type, packageId));
107107
// ── [ADR-0131 D6] The environment → code read ──────────────────────
@@ -263,10 +263,10 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
263263
// object disagree the moment a row exists. Both routes were
264264
// wrong here together, which is why #7556's byName===listed
265265
// pin stayed green through this defect.
266-
// [#29903] …without the package's pair's extension on the
267-
// per-package layer, as its registry items are listed.
266+
// [#29903, #29872] …with only the package's own extensions on
267+
// the per-package layer, as its registry items are listed.
268268
return host.foldObjectExtendersFromRegistry(
269-
request.type, (data as { name?: unknown } | null)?.name, data, withoutExtensionsOf,
269+
request.type, (data as { name?: unknown } | null)?.name, data, onlyExtensionsOf,
270270
);
271271
}, unseated, { servedOverride });
272272

‎packages/metadata-protocol/src/protocol-object-extender-fold.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function foldObjectExtendersFromRegistry(
4646
type: string,
4747
name: unknown,
4848
body: unknown,
49-
withoutExtensionsOf?: string,
49+
onlyExtensionsOf?: string,
5050
): unknown {
5151
const singular = PLURAL_TO_SINGULAR[type] ?? type;
5252
if (singular !== 'object') return body;
@@ -60,11 +60,12 @@ export function foldObjectExtendersFromRegistry(
6060
// cannot fold answers exactly as it did before.
6161
if (!registry || typeof registry.foldObjectExtendersOnto !== 'function') return body;
6262
try {
63-
// [#29903] `withoutExtensionsOf`: that package's `extend` contributions
64-
// are left out of the fold (the per-package layer names a base's pair).
65-
return withoutExtensionsOf === undefined
63+
// [#29903, #29872] `onlyExtensionsOf`: every other package's `extend`
64+
// contributions are left out of the fold (the per-package layer names
65+
// the listed package).
66+
return onlyExtensionsOf === undefined
6667
? registry.foldObjectExtendersOnto(name, body)
67-
: registry.foldObjectExtendersOnto(name, body, { withoutExtensionsOf });
68+
: registry.foldObjectExtendersOnto(name, body, { onlyExtensionsOf });
6869
} catch {
6970
// The fold is a read over in-memory contributors; a failure here
7071
// must not turn a served schema into a 5xx.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3526,13 +3526,13 @@ export class ObjectStackProtocolImplementation implements
35263526
* (see {@link SchemaRegistry.foldObjectExtendersOnto}), so applying it twice
35273527
* would duplicate both.
35283528
*/
3529-
private foldObjectExtendersFromRegistry(type: string, name: unknown, body: unknown, withoutExtensionsOf?: string): unknown {
3529+
private foldObjectExtendersFromRegistry(type: string, name: unknown, body: unknown, onlyExtensionsOf?: string): unknown {
35303530
// [#25467 cut 30] The body moved to `protocol-object-extender-fold.ts` (see `ObjectExtenderFoldHost`).
35313531
const readEngine = () => this.engine;
35323532
const host: ThisParameterType<typeof foldObjectExtendersFromRegistry> = {
35333533
get engine() { return readEngine(); },
35343534
};
3535-
return foldObjectExtendersFromRegistry.call(host, type, name, body, withoutExtensionsOf);
3535+
return foldObjectExtendersFromRegistry.call(host, type, name, body, onlyExtensionsOf);
35363536
}
35373537

35383538
/**

‎packages/objectql/src/registry-extension-field-patches.test.ts‎

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
2121
import { SchemaRegistry } from './registry.js';
2222
import { ObjectQL } from './engine.js';
2323
import { fieldPatchTargetRefusals } from '@objectstack/metadata-core';
24+
import { ObjectExtensionSchema } from '@objectstack/spec/data';
2425

2526
const OWNER_PKG = 'com.vendor.crm';
2627
const EXT_PKG = 'com.acme.crm-display';
@@ -406,7 +407,7 @@ describe('ADR-0146 D10 — the manifest loop carries fieldPatches onto the exten
406407
});
407408
});
408409

409-
describe('#29903 — a read can leave one package\'s extensions out of the fold', () => {
410+
describe('#29903, #29872 — a read can fold only one package\'s own extensions', () => {
410411
const PAIR_PKG = `${OWNER_PKG}.custom`;
411412
/** The vendor's object, a third package's patch, and the pair's field plus its patch. */
412413
function extended(): SchemaRegistry {
@@ -424,31 +425,80 @@ describe('#29903 — a read can leave one package\'s extensions out of the fold'
424425
industry: body?.fields?.industry?.label,
425426
name: body?.fields?.name?.label,
426427
});
427-
const listed = (reg: SchemaRegistry, options?: { withoutExtensionsOf?: string }) =>
428-
(reg.listItems<any>('object', OWNER_PKG, options)).find((o) => o?.name === OBJ);
428+
const listed = (reg: SchemaRegistry, packageId: string, options?: { withoutExtensionsOf?: string; onlyExtensionsOf?: string }) =>
429+
(reg.listItems<any>('object', packageId, options)).find((o) => o?.name === OBJ);
429430

430-
it('the listed object without the pair\'s extension is the object as no pair had extended it', () => {
431+
it('[#29903] `withoutExtensionsOf` leaves that one package\'s extension out, and folds every other', () => {
431432
const reg = extended();
432-
expect(shape(listed(reg, { withoutExtensionsOf: PAIR_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Account name' });
433-
expect(listed(reg, { withoutExtensionsOf: PAIR_PKG })?._packageId).toBe(OWNER_PKG);
434-
// CONTROL: every other read of the object still folds the pair in.
435-
expect(shape(listed(reg))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
436-
expect(shape(reg.getObject(OBJ))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
433+
expect(shape(listed(reg, OWNER_PKG, { withoutExtensionsOf: PAIR_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Account name' });
434+
expect(listed(reg, OWNER_PKG, { withoutExtensionsOf: PAIR_PKG })?._packageId).toBe(OWNER_PKG);
435+
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { withoutExtensionsOf: PAIR_PKG })))
436+
.toEqual({ region: false, industry: 'Industry', name: 'Account name' });
437437
// A package that extends nothing changes nothing: the cached merged object.
438-
expect(listed(reg, { withoutExtensionsOf: 'com.nobody' })).toBe(reg.getObject(OBJ));
438+
expect(listed(reg, OWNER_PKG, { withoutExtensionsOf: 'com.nobody' })).toBe(reg.getObject(OBJ));
439+
});
440+
441+
it('the owner\'s listed object with only its own extensions is the object as no package had extended it', () => {
442+
const reg = extended();
443+
// Neither the pair's extension nor the third package's is folded in.
444+
expect(shape(listed(reg, OWNER_PKG, { onlyExtensionsOf: OWNER_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Name' });
445+
expect(listed(reg, OWNER_PKG, { onlyExtensionsOf: OWNER_PKG })?._packageId).toBe(OWNER_PKG);
446+
// CONTROL: every other read of the object still folds both in.
447+
expect(shape(listed(reg, OWNER_PKG))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
448+
expect(shape(reg.getObject(OBJ))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
449+
});
450+
451+
it('an extender\'s listed object folds that extender\'s own extension only', () => {
452+
expect(shape(listed(extended(), EXT_PKG, { onlyExtensionsOf: EXT_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Account name' });
439453
});
440454

441-
it('leaving the pair out is the answer a registry with no pair gives, byte for byte', () => {
455+
it('an object no other package extends is the cached merged object', () => {
442456
const reg = withVendor();
443-
extend(reg, { name: { label: 'Account name' } });
444-
expect(JSON.stringify(listed(extended(), { withoutExtensionsOf: PAIR_PKG }))).toBe(JSON.stringify(listed(reg)));
457+
expect(listed(reg, OWNER_PKG, { onlyExtensionsOf: OWNER_PKG })).toBe(reg.getObject(OBJ));
458+
});
459+
460+
it('leaving every other package out is the answer a registry with no extender gives, byte for byte', () => {
461+
expect(JSON.stringify(listed(extended(), OWNER_PKG, { onlyExtensionsOf: OWNER_PKG })))
462+
.toBe(JSON.stringify(listed(withVendor(), OWNER_PKG)));
445463
});
446464

447-
it('the fold onto a stored body leaves the pair\'s extension out the same way', () => {
465+
it('the fold onto a stored body leaves the other packages\' extensions out the same way', () => {
448466
const reg = extended();
449-
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { withoutExtensionsOf: PAIR_PKG })))
450-
.toEqual({ region: false, industry: 'Industry', name: 'Account name' });
467+
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { onlyExtensionsOf: OWNER_PKG })))
468+
.toEqual({ region: false, industry: 'Industry', name: 'Name' });
469+
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { onlyExtensionsOf: PAIR_PKG })))
470+
.toEqual({ region: true, industry: 'Sector (customized)', name: 'Name' });
451471
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount())))
452472
.toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
453473
});
454474
});
475+
476+
describe('#29872 — getObjectExtensionsOf reads a package\'s extensions back as the entries it registered from', () => {
477+
const MANIFEST_EXTENSION = { extend: OBJ, fields: { acme_code: { type: 'text', label: 'Billing code' } } };
478+
const STORED_EXTENSION = { extend: OBJ, fieldPatches: { industry: { label: 'Sector' } }, priority: 250 };
479+
480+
it('a manifest `objectExtensions` entry (registerApp, no item) comes back as written, its default priority omitted', () => {
481+
const ql = new ObjectQL();
482+
(ql.registry as SchemaRegistry).logLevel = 'silent';
483+
ql.registerApp({ id: OWNER_PKG, name: 'CRM', version: '1.0.0', objects: [vendorAccount()] });
484+
ql.registerApp({ id: EXT_PKG, name: 'CRM ext', version: '1.0.0', objectExtensions: [{ ...MANIFEST_EXTENSION }] });
485+
486+
const entries = (ql.registry as SchemaRegistry).getObjectExtensionsOf(EXT_PKG);
487+
expect(entries).toEqual([MANIFEST_EXTENSION]);
488+
expect(ObjectExtensionSchema.safeParse(entries[0]).success).toBe(true);
489+
// The owner contributes no extension, and a package that extends nothing reads none.
490+
expect((ql.registry as SchemaRegistry).getObjectExtensionsOf(OWNER_PKG)).toEqual([]);
491+
expect((ql.registry as SchemaRegistry).getObjectExtensionsOf('com.nobody')).toEqual([]);
492+
});
493+
494+
it('a stored item\'s extension (registerObjectExtension) comes back with its own priority, and re-registers to the same object', () => {
495+
const reg = withVendor();
496+
reg.registerObjectExtension({ ...STORED_EXTENSION }, EXT_PKG);
497+
const entries = reg.getObjectExtensionsOf(EXT_PKG);
498+
expect(entries).toEqual([STORED_EXTENSION]);
499+
// The inverse of registerObjectExtension: a fresh registry fed the entries serves the same object.
500+
const fresh = withVendor();
501+
for (const entry of entries) fresh.registerObjectExtension(entry, EXT_PKG);
502+
expect(JSON.stringify(fresh.getObject(OBJ))).toBe(JSON.stringify(reg.getObject(OBJ)));
503+
});
504+
});

0 commit comments

Comments
 (0)