Skip to content

Commit 8ce628a

Browse files
hotlongclaude
andauthored
fix(objectql,runtime,rest): store a serializable manifest projection in the package registry (#14499)
* fix(objectql): store a serializable manifest projection in the package registry Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(objectql,runtime,rest): pin the serializable package record and the door projections Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(permissions): re-anchor the system-context census rows past the packages-domain projection Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(docs): regenerate the system-context census on the merged tree Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 93d2d67 commit 8ce628a

8 files changed

Lines changed: 916 additions & 9 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
fix(objectql,runtime,rest): store a serializable manifest in the package registry so `/packages` stops answering 500 (#14309)
8+
9+
On a stock showcase boot, signed in as the seeded admin, every read door that
10+
serialises a package answered `500 INTERNAL_ERROR`:
11+
12+
```
13+
GET /api/v1/packages -> 500
14+
GET /api/v1/packages/com.example.showcase -> 500
15+
GET /api/v1/meta/package/com.example.showcase -> 500
16+
GET /api/v1/meta/package/com.objectstack.setup -> 200
17+
```
18+
19+
with `Converting circular structure to JSON · _ObjectQL -> actionActivation ->
20+
store -> engine`. Studio asks for the list three times on every open.
21+
22+
**Cause.** `SchemaRegistry.installPackage(manifest)` kept the caller's object
23+
verbatim as `pkg.manifest`. For a code-defined stack that object is the live
24+
`defineStack()` one, and its `plugins: [new ConnectorRestPlugin(), …]` entries
25+
hold the engine once they initialise — a cycle since the engine grew
26+
`actionActivation -> store -> engine`. Measured on that boot: of the 26
27+
installed packages exactly ONE manifest key was unserializable (`plugins`, on
28+
`com.example.showcase`), and only after plugin init — during boot the same
29+
manifest serialised cleanly, which is why a package with no plugin instances
30+
(`com.objectstack.setup`) kept answering 200.
31+
32+
**Fix, at the producer.** `installPackage` now stores a serializable projection:
33+
the registry item is a record, not the runtime. The projection drops by shape
34+
rather than by key name — functions, class instances, `Map`/`Set` and reference
35+
cycles are dropped; primitives, plain objects, arrays and `Date` survive — so a
36+
future live member cannot re-open the same hole. The kernel keeps the live
37+
object (`ObjectQL.manifests`), and the one reader of `manifest.plugins[]` reads
38+
its own parameter, never the record, so nothing downstream loses a member it was
39+
using. The caller's manifest is copied, never stripped in place.
40+
41+
**Defence at the read doors.** `GET /packages` and `GET /packages/:id` project a
42+
registry entry onto its declared record fields instead of spreading it whole, so
43+
an undeclared member appearing on the *item* degrades to a field the response
44+
never mentions instead of failing the whole list for every caller. Applied at
45+
both twins — `packages/runtime/src/domains/packages.ts` (the handler that
46+
actually answered the 500; the 404 wording identifies it) and the
47+
`packages/rest` routes. The database half of the REST merge is deliberately not
48+
projected: its shape belongs to `PackageService`.
49+
50+
No response field is added or renamed. Responses that already served fine are
51+
byte-identical; what disappears is a member that could never be serialised.

content/docs/permissions/system-context.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
135135
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
137137
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
138-
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
138+
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
139139
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
140140
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
141141

@@ -160,10 +160,10 @@ The largest single consumer — **20 of the 109 sites**.
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161161
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4716`, `:6079`, `:6327`, `:6758`, `:6951` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163-
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:276`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
163+
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:326`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
165165
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
166-
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:95`, `:128` |
166+
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
167167
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:139`, `:190` |
168168
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` |
169169
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `installPackage` stores a RECORD, never the caller's live object.
5+
*
6+
* ## What was wrong
7+
*
8+
* `installPackage(manifest)` kept the argument verbatim as `pkg.manifest`. For a
9+
* code-defined stack that argument is the live `defineStack()` object, and its
10+
* `plugins: [new ConnectorRestPlugin(), …]` entries hold the engine once they
11+
* initialise. Since the engine grew `actionActivation -> store -> engine` that
12+
* reference closes a CYCLE, so `JSON.stringify` of the registry item threw and
13+
* every read door that serialises a package answered `500 INTERNAL_ERROR` on a
14+
* stock showcase boot — `GET /packages`, `GET /packages/:id`,
15+
* `GET /meta/package/:id`, while `GET /meta/package/<a plugin-less package>`
16+
* stayed 200.
17+
*
18+
* ## Why the assertions are shaped this way
19+
*
20+
* ⚠️ "the manifest still round-trips" passes on the old code for every package
21+
* that has no plugins, which is 25 of the 26 a showcase boot installs. So the
22+
* cases below pin the MECHANISM: a live instance reaching the record, a plain
23+
* reference cycle, and a member that is not data at all — each asserted on
24+
* `JSON.stringify(registry.getPackage(id))`, the exact expression the doors run.
25+
*
26+
* ⚠️ Timing is part of the defect and is pinned too. Measured on the failing
27+
* boot: the same showcase manifest serialised CLEANLY during boot and only
28+
* became cyclic after plugin init, so a check that ran at install time would
29+
* have called the record healthy. The `becomes cyclic only after init` case
30+
* below reproduces that ordering — the projection must not depend on when it is
31+
* asked.
32+
*/
33+
34+
import { describe, it, expect, beforeEach } from 'vitest';
35+
import { SchemaRegistry } from './registry';
36+
37+
/**
38+
* A plugin instance in the shape that broke: a class instance the host
39+
* constructs in `objectstack.config.ts` and hands to `defineStack({ plugins })`,
40+
* which takes the engine when it initialises.
41+
*/
42+
class FakeConnectorPlugin {
43+
name = 'connector-rest';
44+
engine: unknown;
45+
init(engine: unknown) {
46+
this.engine = engine;
47+
}
48+
}
49+
50+
/** The engine's own `actionActivation -> store -> engine` cycle, reproduced. */
51+
function makeCyclicEngine(): Record<string, unknown> {
52+
const engine: Record<string, unknown> = { name: '_ObjectQL' };
53+
const store: Record<string, unknown> = { name: 'ObjectStoreActionActivationStore', engine };
54+
engine.actionActivation = { name: 'ActionActivationProjection', store };
55+
return engine;
56+
}
57+
58+
function baseManifest(overrides: Record<string, unknown> = {}): any {
59+
return {
60+
id: 'com.example.showcase',
61+
name: 'Showcase',
62+
namespace: 'showcase',
63+
version: '1.2.3',
64+
type: 'app',
65+
scope: 'user',
66+
description: 'Kitchen-sink example',
67+
dependencies: ['com.objectstack.plugin-auth'],
68+
objects: [{ name: 'invoice', fields: { total: { type: 'currency' } } }],
69+
apps: [{ name: 'showcase', label: 'Showcase' }],
70+
...overrides,
71+
};
72+
}
73+
74+
describe('SchemaRegistry.installPackage — the record is serializable', () => {
75+
let registry: SchemaRegistry;
76+
77+
beforeEach(() => {
78+
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
79+
registry.logLevel = 'silent';
80+
});
81+
82+
it('survives a plugin instance that closes a cycle through the engine', () => {
83+
const plugin = new FakeConnectorPlugin();
84+
plugin.init(makeCyclicEngine());
85+
registry.installPackage(baseManifest({ plugins: [plugin] }));
86+
87+
// The expression every read door runs.
88+
expect(() => JSON.stringify(registry.getPackage('com.example.showcase'))).not.toThrow();
89+
// The instance itself is gone from the record — this is a projection, not a
90+
// replacement value that still points at the engine.
91+
expect(JSON.stringify(registry.getPackage('com.example.showcase'))).not.toContain('_ObjectQL');
92+
});
93+
94+
it('survives a manifest that becomes cyclic only AFTER install', () => {
95+
// The measured ordering: at install the plugin holds no engine and the
96+
// manifest serialises fine; the cycle appears when the plugin initialises.
97+
// A projection taken at install must still hold, because it copied out of
98+
// the live object rather than aliasing it.
99+
const plugin = new FakeConnectorPlugin();
100+
const manifest = baseManifest({ plugins: [plugin] });
101+
expect(() => JSON.stringify(manifest)).not.toThrow();
102+
103+
registry.installPackage(manifest);
104+
plugin.init(makeCyclicEngine());
105+
106+
expect(() => JSON.stringify(registry.getPackage('com.example.showcase'))).not.toThrow();
107+
});
108+
109+
it('survives a reference cycle among PLAIN data in the manifest', () => {
110+
const cyclic: Record<string, unknown> = { name: 'self' };
111+
cyclic.self = cyclic;
112+
registry.installPackage(baseManifest({ data: { node: cyclic } }));
113+
114+
const record = registry.getPackage('com.example.showcase')!;
115+
expect(() => JSON.stringify(record)).not.toThrow();
116+
// The back-edge is dropped; everything ahead of it survives.
117+
expect((record.manifest as any).data.node.name).toBe('self');
118+
expect((record.manifest as any).data.node.self).toBeUndefined();
119+
});
120+
121+
it('keeps the declarative half of the manifest byte-for-byte', () => {
122+
const manifest = baseManifest({ plugins: [new FakeConnectorPlugin()] });
123+
registry.installPackage(manifest);
124+
const stored = registry.getPackage('com.example.showcase')!.manifest as any;
125+
126+
for (const key of [
127+
'id', 'name', 'namespace', 'version', 'type', 'scope', 'description',
128+
'dependencies', 'objects', 'apps',
129+
]) {
130+
expect(stored[key]).toEqual((manifest as any)[key]);
131+
}
132+
});
133+
134+
it('drops members that are not data, and keeps a Date as data', () => {
135+
const publishedAt = new Date('2026-09-02T00:00:00.000Z');
136+
registry.installPackage(baseManifest({
137+
onEnable: () => undefined,
138+
registryHandle: new Map([['a', 1]]),
139+
publishedAt,
140+
}));
141+
const stored = registry.getPackage('com.example.showcase')!.manifest as any;
142+
143+
expect(stored.onEnable).toBeUndefined();
144+
expect(stored.registryHandle).toBeUndefined();
145+
expect(new Date(stored.publishedAt).toISOString()).toBe(publishedAt.toISOString());
146+
});
147+
148+
it('does not mutate the caller’s manifest — the kernel keeps the live object', () => {
149+
// `ObjectQL.registerApp` reads `manifest.plugins[]` from ITS OWN parameter
150+
// to register nested plugins, and hands the same object to `installPackage`.
151+
// Projecting must therefore copy, never strip in place.
152+
const plugin = new FakeConnectorPlugin();
153+
const manifest = baseManifest({ plugins: [plugin] });
154+
registry.installPackage(manifest);
155+
156+
expect(manifest.plugins).toHaveLength(1);
157+
expect(manifest.plugins[0]).toBe(plugin);
158+
expect(registry.getPackage('com.example.showcase')!.manifest).not.toBe(manifest);
159+
});
160+
161+
it('projects on REINSTALL too (rebuild / HMR overwrite)', () => {
162+
registry.installPackage(baseManifest());
163+
const plugin = new FakeConnectorPlugin();
164+
plugin.init(makeCyclicEngine());
165+
registry.installPackage(baseManifest({ plugins: [plugin] }));
166+
167+
expect(() => JSON.stringify(registry.getPackage('com.example.showcase'))).not.toThrow();
168+
});
169+
170+
it('keeps the whole LIST serializable when one package carries the cycle', () => {
171+
// The list door's failure mode: one unserializable item took out every
172+
// caller's whole listing, not just the offending package.
173+
const plugin = new FakeConnectorPlugin();
174+
plugin.init(makeCyclicEngine());
175+
registry.installPackage(baseManifest({ plugins: [plugin] }));
176+
registry.installPackage({
177+
id: 'com.objectstack.setup', name: 'Setup', namespace: 'setup', version: '9.3.0',
178+
} as any);
179+
180+
expect(() => JSON.stringify(registry.getAllPackages())).not.toThrow();
181+
expect(registry.getAllPackages()).toHaveLength(2);
182+
});
183+
184+
it('still records the namespace and the lifecycle state it always did', () => {
185+
// The projection must not cost the record its non-manifest half.
186+
registry.installPackage(baseManifest({ plugins: [new FakeConnectorPlugin()] }));
187+
const record = registry.getPackage('com.example.showcase')!;
188+
189+
expect(record.status).toBe('installed');
190+
expect(record.enabled).toBe(true);
191+
expect(typeof record.installedAt).toBe('string');
192+
expect(registry.getNamespaceOwners('showcase')).toEqual(['com.example.showcase']);
193+
});
194+
});

0 commit comments

Comments
 (0)