Skip to content

Commit 1427993

Browse files
committed
Merge origin/main into claude/issue-20749-spec-strings-stage6
Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
2 parents 2f30de8 + 901e7cf commit 1427993

9 files changed

Lines changed: 1081 additions & 78 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Public forms on a walled tenancy posture: a form whose object is walled by an organization column is no longer offered to anonymous visitors. An anonymous submission carries no organization, and on a walled posture an insert into such an object without one is refused, so the form used to render and then answer `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on every submit. Both anonymous form endpoints (`GET /forms/:slug` and `POST /forms/:slug/submit`) now answer it exactly as they answer a withdrawn form (`404 FORM_NOT_FOUND`), so an anonymous caller learns nothing about the deployment's tenancy. The administrator's read of the form (`GET /meta/view/:name`) states why in `_diagnostics.warnings`, located at the form's `sharing`, with the remedy: if the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it. Forms bound to tenancy-disabled objects, and single-posture deployments, are unchanged.
6+
7+
Clause-②: no
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
An install-local uninstall (`DELETE /api/v1/marketplace/install-local/:manifestId`) now withdraws the package from the running kernel
6+
7+
Clause-②: no
8+
9+
- The DELETE used to remove the ledger entry and run the uninstall cleanups, but it left the package registered in the running kernel until the next restart. So another package's hot install re-ran the declared-permission seeding over the uninstalled package too. Its permission set came back as a package-managed row, and that row survived the restart as an orphan that an administrator could grant.
10+
- After the ledger entry is removed, the door now calls `SchemaRegistry.uninstallPackage`, the same verb the protocol's own uninstall uses, on the same registry. It does this before the cleanups run. The package's objects answer 404 straight away, not only after a restart, and no reader of the registered packages counts it again. A reinstall of the same package in the same process registers it again.
11+
- If the registry refuses the withdrawal, for example because another package extends an object this package owns, the uninstall still succeeds and the cleanups still run. The refusal is reported as a failed `registry.uninstallPackage` entry in `cleanups`. The operator log carries the cause and the remedy.
12+
- The response `note` no longer says the kernel cannot unregister a package in place. The request and response keys are unchanged.

‎packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts‎

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@
2525
* ## What each `it` reads
2626
*
2727
* One fixture, two orders of events, each probed through the data route a user
28-
* and an admin use: the set by name, the user grant of it by set id, and — after
29-
* the restart — the package's object. The grant is made through the data door
30-
* before the uninstall, so "no binding" is read off a row that existed, not off
31-
* an empty table.
28+
* and an admin use: the set by name, the user grant of it by set id, and the
29+
* package's object — right after the DELETE and after the restart. The grant is
30+
* made through the data door before the uninstall, so "no binding" is read off a
31+
* row that existed, not off an empty table.
3232
*
3333
* A third order of events measures the re-seed window — DELETE, then a hot
34-
* install of ANOTHER package, then restart — and records, as `it.fails`, the
35-
* defect it found there; the block above that `describe` says what it is.
34+
* install of ANOTHER package, then restart. #21576: the DELETE now withdraws the
35+
* package from the running kernel (`SchemaRegistry.uninstallPackage`, the verb
36+
* the protocol's own uninstall uses), so nothing re-projects its set there; the
37+
* block above that `describe` says what used to happen.
38+
*
39+
* A fourth order is the withdrawal's control: a hot reinstall of the SAME
40+
* package in the same process, right after its DELETE, registers it again —
41+
* whatever the withdrawal took, the install path puts back.
3642
*
3743
* ## Spawn shape
3844
*
@@ -261,6 +267,10 @@ interface Run {
261267
uninstall?: Answer;
262268
/** Same process, right after the DELETE answered. */
263269
after?: Grants;
270+
/** The package's object right before the DELETE — it answered, so a later 404 is the DELETE's. */
271+
objectBefore?: Answer;
272+
/** #21576: the package's object in the same process, right after the DELETE answered. */
273+
objectAfter?: Answer;
264274
/** A restart on the same home. */
265275
restarted?: Grants;
266276
/** The package's object after the restart — the uninstall's own effect, as the control. */
@@ -269,6 +279,10 @@ interface Run {
269279
otherInstall?: { exit: number | null; output: string };
270280
/** Re-seed order only: same process, right after that second install. */
271281
afterOtherInstall?: Grants;
282+
/** Reinstall order only: the same package installed again, in the same process, after its DELETE. */
283+
reinstall?: { exit: number | null; output: string };
284+
/** Reinstall order only: the object and the set right after that reinstall. */
285+
afterReinstall?: { object: Answer; sets: Answer };
272286
}
273287

274288
/**
@@ -288,8 +302,10 @@ async function grantThenUninstall(live: LiveStart, session: Session, run: Run):
288302
permission_set_id: setId,
289303
});
290304
run.before = await readGrants(live, session.token, setId);
305+
run.objectBefore = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token);
291306
run.uninstall = await http(live, 'DELETE', UNINSTALL, session.token);
292307
run.after = await readGrants(live, session.token, setId);
308+
run.objectAfter = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token);
293309
}
294310

295311
async function readAfterRestart(live: LiveStart, run: Run): Promise<void> {
@@ -298,7 +314,7 @@ async function readAfterRestart(live: LiveStart, run: Run): Promise<void> {
298314
run.object = await http(live, 'GET', `/api/v1/data/${TASK}`, token);
299315
}
300316

301-
const runs: Record<'hot' | 'restarted' | 'reseed', Run> = { hot: {}, restarted: {}, reseed: {} };
317+
const runs: Record<'hot' | 'restarted' | 'reseed' | 'reinstall', Run> = { hot: {}, restarted: {}, reseed: {}, reinstall: {} };
302318

303319
beforeAll(async () => {
304320
const root = mkdtempSync(join(tmpdir(), 'install-local-uninstall-'));
@@ -342,8 +358,9 @@ beforeAll(async () => {
342358
await stopGroup(e.child);
343359

344360
// ── order 3: hot install → DELETE → hot install of ANOTHER package → restart ──
345-
// The DELETE does not withdraw the package from the running kernel, so it is
346-
// still registered when the second install announces `metadata:reloaded`.
361+
// The second install announces `metadata:reloaded` into the process the
362+
// DELETE ran in, so whatever that process still counts as registered is
363+
// re-seeded — the withdrawal is what keeps the uninstalled package out of it.
347364
const reseedDir = join(root, 'reseed');
348365
mkdirSync(reseedDir, { recursive: true });
349366
const reseedHome = join(reseedDir, 'home');
@@ -357,7 +374,23 @@ beforeAll(async () => {
357374
const g = await bootStart(reseedDir, reseedHome, port);
358375
await readAfterRestart(g, runs.reseed);
359376
await stopGroup(g.child);
360-
}, 8 * BOOT_TIMEOUT_MS);
377+
378+
// ── order 4: hot install → DELETE → hot reinstall of the SAME package ──────
379+
// The withdrawal's control: whatever `uninstallPackage` took from the running
380+
// kernel, the install path registers again, in the same process.
381+
const reinstallDir = join(root, 'reinstall');
382+
mkdirSync(reinstallDir, { recursive: true });
383+
const h = await bootStart(reinstallDir, join(reinstallDir, 'home'), port);
384+
const hSession = await authenticate(h);
385+
runs.reinstall.install = await packageInstall(appDir, h);
386+
await grantThenUninstall(h, hSession, runs.reinstall);
387+
runs.reinstall.reinstall = await packageInstall(appDir, h);
388+
runs.reinstall.afterReinstall = {
389+
object: await http(h, 'GET', `/api/v1/data/${TASK}`, hSession.token),
390+
sets: await http(h, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, hSession.token),
391+
};
392+
await stopGroup(h.child);
393+
}, 9 * BOOT_TIMEOUT_MS);
361394

362395
afterAll(async () => {
363396
for (const child of groups) await stopGroup(child);
@@ -392,6 +425,16 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean
392425
expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]);
393426
});
394427

428+
// #21576: the DELETE withdraws the package from the running kernel, so
429+
// its object answers at once what it used to answer only after a
430+
// restart — the same status and the same code.
431+
it('right after the DELETE: the package object answers what it answers after a restart — no restart needed', () => {
432+
const run = runs[name];
433+
expect(run.objectBefore?.status, JSON.stringify(run.objectBefore?.body)).toBe(200);
434+
expect(run.objectAfter?.status, JSON.stringify(run.objectAfter?.body)).toBe(404);
435+
expect(run.objectAfter?.body?.error?.code, JSON.stringify(run.objectAfter?.body)).toBe(run.object?.body?.error?.code);
436+
});
437+
395438
it('after a restart: still no set and no grant, and the package object is gone', () => {
396439
const run = runs[name];
397440
expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404);
@@ -403,19 +446,19 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean
403446
});
404447
}
405448

406-
// ── The re-seed window: MEASURED RED, reported for filing, not fixed here ──
449+
// ── The re-seed window (#21576) ─────────────────────────────────────────
407450
//
408-
// This DELETE leaves the package registered in the running kernel until the
409-
// next restart (the response's own note says so), and plugin-security's
410-
// `metadata:reloaded` subscriber re-runs the declared-permission seeding over
411-
// every package the kernel holds. So another package's hot install before
412-
// that restart re-projects the uninstalled package's set as a fresh
413-
// `managed_by: package` row, and the restart leaves it orphaned: the package
414-
// is gone, its set is not. The grant does NOT come back — the cleanup deleted
415-
// the binding and the seeding writes none — and that half is pinned plainly.
451+
// This DELETE used to leave the package registered in the running kernel
452+
// until the next restart, and plugin-security's `metadata:reloaded`
453+
// subscriber re-runs the declared-permission seeding over every package the
454+
// kernel holds. So another package's hot install before that restart
455+
// re-projected the uninstalled package's set as a fresh `managed_by: package`
456+
// row, and the restart left it orphaned: the package gone, its set not. The
457+
// grant never came back — the cleanup deleted the binding and the seeding
458+
// writes none.
416459
//
417-
// The two set readings are `it.fails`: each turns red the day its half is
418-
// fixed, which is the cue to promote it to a plain assertion.
460+
// The DELETE now withdraws the package from the running kernel, so no reader
461+
// of the registered packages — that seeding included — counts it again.
419462
describe('hot install → DELETE → hot install of another package → restart (the re-seed window)', () => {
420463
it('precondition: both installs landed, and the DELETE revoked the set and its grant', () => {
421464
const run = runs.reseed;
@@ -439,14 +482,40 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean
439482
expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404);
440483
});
441484

442-
it.fails('KNOWN-BROKEN: the other package\'s hot install re-projects the uninstalled package\'s set (promote to a plain assertion once fixed)', () => {
485+
it('the other package\'s hot install does not re-project the uninstalled package\'s set', () => {
443486
const run = runs.reseed;
444487
expect(rowsOf(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toEqual([]);
445488
});
446489

447-
it.fails('KNOWN-BROKEN: that re-projected set survives the restart as an orphan row (promote to a plain assertion once fixed)', () => {
490+
it('after the restart there is no package-managed set for the uninstalled package — no orphan row', () => {
448491
const run = runs.reseed;
449492
expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]);
450493
});
451494
});
495+
496+
// ── The withdrawal's control (#21576) ───────────────────────────────────
497+
//
498+
// `uninstallPackage` takes the package's objects, namespace, metadata items
499+
// and record out of the running kernel. A hot reinstall of the same package
500+
// in the same process puts every one of them back: the object answers again
501+
// and the set is projected again — once, as the package's own.
502+
describe('hot install → DELETE → hot reinstall of the same package (the withdrawal\'s control)', () => {
503+
it('precondition: the install landed, and the DELETE took the object out of the running kernel', () => {
504+
const run = runs.reinstall;
505+
expect(run.install?.exit, run.install?.output).toBe(0);
506+
expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200);
507+
expect(run.objectBefore?.status, JSON.stringify(run.objectBefore?.body)).toBe(200);
508+
expect(run.objectAfter?.status, JSON.stringify(run.objectAfter?.body)).toBe(404);
509+
expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]);
510+
});
511+
512+
it('the reinstall registers the package again: its object answers, and its set is projected once', () => {
513+
const run = runs.reinstall;
514+
expect(run.reinstall?.exit, run.reinstall?.output).toBe(0);
515+
expect(run.afterReinstall!.object.status, JSON.stringify(run.afterReinstall!.object.body)).toBe(200);
516+
expect(run.afterReinstall!.sets.status).toBe(200);
517+
expect(rowsOf(run.afterReinstall!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id]), JSON.stringify(run.afterReinstall!.sets.body))
518+
.toEqual([[PERMISSION_SET, 'package', APP_ID]]);
519+
});
520+
});
452521
});

0 commit comments

Comments
 (0)