Skip to content

Commit 258f4e6

Browse files
fix(runtime)!: REST's /actions door refuses an action on an object whose owning package is disabled 404 OBJECT_PACKAGE_DISABLED, before any handler runs (#25305)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuXquM9v2r8ymXj6TzynfjMF)_ Fixes #25296 Clause-②: no (narrowing) Metadata-protocol: none REST's `POST /api/v1/actions/OBJECT/NAME` now asks, before it reads the action's declaration, whether the object it names is served. An object whose owning package is disabled is refused `404 OBJECT_PACKAGE_DISABLED`, with the error the protocol raises for it on the data plane and on `GET /api/v1/meta/object/NAME`, before any gate, record load or handler. The question is asked through the by-name object read the data verbs' exposure gate already makes (`readExposureDeclaration`), so neither the package predicate nor the refusal is copied. ## The defect, measured on base `5801a4f2c9` The `/actions` door (`packages/runtime/src/domains/actions.ts`, the one owner of the route) resolved the action's declaration through `resolveRouteActionDeclaration`, which reads `ql.getSchema(OBJECT)` and the registry's `action` items. A disabled package's objects stay registered there, so the declaration resolved and the door dispatched, while every other door treats the package as gone. Real kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin` over a better-sqlite3 `SqlDriver`), in the two shapes the #25120 rig pins (`env_local` with `createStandaloneStack`'s options, and no `environmentId`), driven through the runtime dispatcher's `dispatch()`. The fixture package, registered through the `manifest` service during the boot and then disabled (`registry.disablePackage`): one object carrying a script action `ping` (its handler registered), a declarative update `mark_done`, a script action `admin_ping` gated by `requiredPermissions: ['manage_metadata']`, and a top-level declarative update `bound_done` bound to the object. | request, as a signed-in caller without `manage_metadata` | base, both shapes | this PR, both shapes | | :-- | :-- | :-- | | `GET /data/OBJECT` (the data plane, unchanged) | `404 OBJECT_PACKAGE_DISABLED` | same | | `POST /actions/OBJECT/ping` | **`200 { pong: true }`, the handler ran** | `404 OBJECT_PACKAGE_DISABLED` | | `POST /actions/OBJECT/ping/r_any` | **`200`, the handler ran** | `404 OBJECT_PACKAGE_DISABLED` | | `POST /actions/OBJECT/mark_done` with a `recordId` | refused only at the record load | `404 OBJECT_PACKAGE_DISABLED` | | `POST /actions/OBJECT/bound_done` with a `recordId` | refused only at the record load | `404 OBJECT_PACKAGE_DISABLED` | | `POST /actions/OBJECT/admin_ping` | `403` (the D4 gate read the declaration) | `404 OBJECT_PACKAGE_DISABLED` | | `POST /actions/OBJECT/qa_nowhere` | the "no declaration" `404` (no code) | `404 OBJECT_PACKAGE_DISABLED` | | the same `ping`, anonymous | `401` | `401` (the anonymous baseline stays first) | | the same `ping`, after `registry.enablePackage` | `200` | `200` | The base column is the pins' main leg below. ## What changed - **`packages/runtime/src/action-execution.ts`.** `refuseDisabledPackageObject(deps, requestContext, object, scopeId?)`: it asks `readExposureDeclaration` (the protocol's by-name object read on its execution face, which `refuseUnexposedDataAction` already asks) and rethrows only the protocol's `OBJECT_PACKAGE_DISABLED` refusal (`isObjectPackageDisabledRefusal`). Any other answer is not its to give: an absent object is the declaration resolution's to answer, and so is an unreadable metadata plane (ADR-0110 D3). Not published by `index.ts`. - **`packages/runtime/src/domains/actions.ts`.** `handleActionsRequest` asks it once the engine is resolved and the activation write door has had its turn, before the declaration resolution; a refusal is answered through `deps.errorFromThrown(err, 404)`, the exit every other domain catch uses, so the envelope carries the protocol error's own `code` and `status`. An object-less invocation (`POST /actions/global/NAME`, `POST /actions//NAME`) names no object and is not asked about. - **`.changeset/25296-rest-actions-disabled-package.md`.** A `patch` for `@objectstack/runtime`, with the BREAKING banner and the ADR-0087 disposition `not-required (no-migration-prescription)`; both declaration lines are in the body. ## Contract Every input whose answer changes, old to new, on `POST /api/v1/actions/OBJECT/NAME[/RECORD]` where OBJECT's owning package is disabled, on every kernel shape: 1. **A script action with a registered handler.** Old: `200`, the handler's result (its body ran, `isSystem`-elevated as a script body always is). New: `404 OBJECT_PACKAGE_DISABLED`; the handler is not called. 2. **A declarative update (embedded, or a top-level action bound to the object).** Old: refused at the record load. New: `404 OBJECT_PACKAGE_DISABLED` before the record load. 3. **An action gated by `requiredPermissions` the caller lacks.** Old: `403`. New: `404 OBJECT_PACKAGE_DISABLED`. The data plane also answers the package refusal ahead of any permission check, and the refusal names the object, which the data plane already discloses to the same caller. 4. **An action switched off in the activation ledger.** Old: `409 ACTION_DISABLED` (after D4). New: `404 OBJECT_PACKAGE_DISABLED`. Not pinned separately: the gate sits ahead of the ledger consult by construction, as it sits ahead of D4 in case 3. 5. **A name nothing declares.** Old: the "no declaration" `404` with its prescription. New: `404 OBJECT_PACKAGE_DISABLED`. 6. **A `flow` action.** Old: dispatched through the automation service (or `503` without one). New: `404 OBJECT_PACKAGE_DISABLED`. Not pinned separately: the rig has no automation service, and the gate runs before the type dispatch for every type. Unchanged: the anonymous baseline (`401`) and the `405` stay first; an enabled package's actions run as before behind every gate (pinned as the control on both shapes); re-enabling the package restores its actions at once, because nothing records the refusal (pinned); an object-less action; the activation door (`POST /actions/_activation/OBJECT/NAME`); a host whose protocol has no by-name read (no refusal, as before). The cost is one by-name object read per invocation on an object route, the read the data verbs' gate already makes per call. ## Pins - **`packages/runtime/src/domains/actions-disabled-package.test.ts`, new, 24 cases on real kernels.** Per shape: the ENABLED control (`ping` answers `200 { pong: true }` and its handler runs once; `mark_done` and `bound_done` update a record seeded through `POST /data/OBJECT`, `200`; `admin_ping` answers `403`; an undeclared name answers `404` with no package code), and the DISABLED package (precondition: `GET /data/OBJECT` answers `404 OBJECT_PACKAGE_DISABLED`; each of the six invocations of the table answers `404 OBJECT_PACKAGE_DISABLED` with the handler never called and the protocol's `getData` / `updateData` and the engine's `executeAction` never reached; the anonymous caller still answers `401`; after `enablePackage`, `ping` runs again). A new file, not the #25120 rig file, which the #25297 card holds. **Red before, green after.** The directions were predicted before each run. Both mutated legs ran the pin file under the verify lock on `99d375e8` (this PR's head); the test imports the runtime's source, so no `dist/` is involved. | leg | mutation | result | which failed | | :-- | :-- | :-- | :-- | | main | the base tree's `action-execution.ts` and `domains/actions.ts` (`git restore --source=5801a4f2`, worktree only), restored by `git checkout HEAD`, `git diff HEAD` empty | 12 failed, 12 passed | the six refusal cases on both shapes | | helper | `refuseDisabledPackageObject` swallows the refusal too (`scripts/ablation-replace.mjs`: anchor 1 to 0, blob `9e202cd4e8a7` to `0711946f42aa`; restored blob equals HEAD, `git diff HEAD` empty) | 12 failed, 12 passed | the same twelve | | fix | none | 24 passed | none | ## Local verification On `99d375e84c`, over `origin/main` `5801a4f2c9`. Every heavy run went through the verify lock; each line cites its `VERDICT`. - `pnpm exec turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/driver-turso --concurrency=3`: `VERDICT command-exit 0`, 66 of 66 tasks (the runtime's closure included; `driver-turso` because the runtime's test typecheck reads it). - The pin file (`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/actions-disabled-package.test.ts`): `VERDICT command-exit 0`, 24 passed. - `@objectstack/runtime` unit suite (`pnpm --filter @objectstack/runtime test`): `VERDICT command-exit 0`; Test Files 437 passed (437); Tests 6952 passed, 19 skipped (6971). - `pnpm --filter @objectstack/runtime typecheck`: `VERDICT command-exit 0` (`tsc --noEmit`, and `check:test-typecheck` OK). A first run before `driver-turso` was built answered one TS2307 in `organization-ownership-boot-guard.turso-remote.kernel.test.ts` (a missing dist, not this diff). - `eslint --no-inline-config` on the three changed source and test files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 68 families derived, each run, every one `exit 0`. Two first answered `exit 3` (prerequisites, not verdicts): `check-plugin-teardown-shape --self-test` until its pinned fixture commit was fetched, and `check:dual-build-cjs-loads` until the eight dists it named were built. `check:type-check-debt` ran under the lock. - `--ran` reconciliation: `68 derived, 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)`. - The full dogfood battery is owed once on the final head (the `!` and the `(narrowing)` arm); its counts follow as a PR comment. ## Acceptance notes - **Measured, not changed here: a disabled package's object-less action still runs through REST.** A top-level action with no `objectName`, shipped by the package (`type: 'script'`, its handler registered under `global`), answered `200` with its handler's result on `POST /actions/global/NAME` and `POST /actions//NAME` after `registry.disablePackage`, on both shapes, while `GET /api/v1/meta/action/NAME` answered `404 RESOURCE_NOT_FOUND`. Scratch probe over this PR's rig, not committed. The card's shape is the object route and its `OBJECT_PACKAGE_DISABLED` answer; an object-less action has no object to refuse, and which answer it owes (the "no declaration" `404`, as the by-name `/meta` read answers it, is the likely reading) is the next card's to settle. The #25297 card moves MCP's object-less actions onto the merged action listing, which omits them. - **`refuseAmbiguousActionActivation` was not needed.** The card asked whether this fix needs the activation-ambiguity check to read the served listing. It does not: the fix reads no collection, and the check serves the activation door, which this PR leaves alone. Whether that check sees a package object's actions on an `environmentId` kernel is unchanged and unmeasured here. - **The activation door is untouched.** Flipping a disabled package's action in the activation ledger still works; it writes a ledger row and runs nothing. - **Branch name.** This PR's branch is `claude/project-thread-vlqmr4`, the name the session was given, not `claude/issue-25296-…`; the Duplicate Fix Guard may say so. The claim (comment 6352124313) names it. --- _Generated by [Claude Code](https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 882372d commit 258f4e6

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime)!: REST's `POST /api/v1/actions/OBJECT/NAME` refuses an action on an object whose owning package is disabled `404 OBJECT_PACKAGE_DISABLED`, the data plane's answer for that object, before any handler runs
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is one answer of the `/actions` door: an invocation on an object whose owning package is disabled is refused with the protocol's own `404 OBJECT_PACKAGE_DISABLED`, read through the by-name object read the data verbs' exposure gate already makes. The one function added (`refuseDisabledPackageObject` in `action-execution.ts`) is not published by `index.ts`. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (the `/actions` door stops running a disabled package's object actions), shipped as `patch`: it changes what one door answers, not what a metadata author can write.
13+
14+
Disabling a package takes its objects off every door: `/data` refuses them `404 OBJECT_PACKAGE_DISABLED`, `GET /api/v1/meta/object` omits them, and the MCP `run_action` tool answers "not found" for their actions. REST's `/actions` door did not ask. It resolved the action's declaration from the engine's schema registry, where a disabled package's objects stay registered, and dispatched.
15+
16+
- **Before.** On every kernel shape, `POST /api/v1/actions/OBJECT/NAME` on an object of a disabled package ran a script action's handler and answered `200` with its result; a declarative update was refused only at the record load; an action gated by `requiredPermissions` answered a caller without them `403`; and a name nothing declares answered the "no declaration" `404`.
17+
- **Now.** Each of those is refused `404 OBJECT_PACKAGE_DISABLED`, with the error the data plane raises for the object, before any gate reads the action's declaration and before any record load or dispatch. The anonymous baseline still answers first.
18+
- **Unchanged.** Actions on objects of an enabled package run as before, as does every gate after this one. Re-enabling the package restores the actions at once: nothing records the refusal. An object-less action (`POST /api/v1/actions/global/NAME`) names no object and is not affected. The activation door (`POST /api/v1/actions/_activation/OBJECT/NAME`) is untouched.
19+
- **What to do.** Nothing. A caller that ran a disabled package's action through REST was running code of a package the rest of the platform no longer serves: re-enable the package to restore it.

‎packages/runtime/src/action-execution.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,33 @@ function isObjectPackageDisabledRefusal(err: unknown): boolean {
382382
return (err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED';
383383
}
384384

385+
/**
386+
* [#25296] Throw the protocol's own `404 OBJECT_PACKAGE_DISABLED` when
387+
* `object`'s owning package is disabled, and return otherwise. The question
388+
* the REST `/actions` door asks before it reads an action's declaration: an
389+
* object the data plane refuses has no action that may run, so neither its
390+
* declaration gates nor its handler are reached.
391+
*
392+
* The read is {@link readExposureDeclaration}'s, the by-name object read the
393+
* data verbs' exposure gate makes, and the refusal is the one constructor the
394+
* protocol raises from it and from its data plane: ⛔ never a second copy of
395+
* the package predicate or of the refusal. Any other answer of that read is
396+
* not this gate's to give: an absent object is the declaration resolution's
397+
* to answer, and so is an unreadable metadata plane (ADR-0110 D3), so they
398+
* pass here unanswered.
399+
*/
400+
export async function refuseDisabledPackageObject(deps: ActionExecutionDeps,
401+
requestContext: HttpProtocolContext,
402+
object: string,
403+
scopeId?: string,
404+
): Promise<void> {
405+
try {
406+
await readExposureDeclaration(deps, requestContext, object, scopeId);
407+
} catch (err) {
408+
if (isObjectPackageDisabledRefusal(err)) throw err;
409+
}
410+
}
411+
385412
/**
386413
* ── Object-level API exposure gate (ADR-0049, #1889) ─────
387414
* Honour the object's `apiEnabled` / `apiMethods` declarations for external
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25296] `POST /api/v1/actions/:object/:action` refuses an action on an
5+
* object whose owning package is disabled `404 OBJECT_PACKAGE_DISABLED`, the
6+
* data plane's answer for that object, before any gate reads the action's
7+
* declaration and before any handler runs.
8+
*
9+
* ## The defect
10+
*
11+
* The `/actions` door resolved the declaration (`resolveRouteActionDeclaration`,
12+
* which reads the engine's schema registry, where a disabled package's objects
13+
* stay registered) and dispatched without asking whether the object is served.
14+
* Measured on this rig at the base tree (`5801a4f2c9`), with the package below
15+
* registered during the boot and then disabled, on both kernel shapes: the
16+
* script action `ping` answered `200 { pong: true }` (its handler ran), the
17+
* declarative update was refused only at the record load, and an action gated
18+
* by `requiredPermissions` answered the caller `403`, while the data plane
19+
* refused the object `404 OBJECT_PACKAGE_DISABLED`, `GET /meta/object` omitted
20+
* it and the MCP `run_action` tool answered "not found".
21+
*
22+
* ## The fix, and where these pins sit
23+
*
24+
* The door asks the protocol's by-name object read (`refuseDisabledPackageObject`
25+
* in `../action-execution.ts`, over the read the data verbs' exposure gate
26+
* makes), and that read throws the protocol's one refusal for such an object.
27+
* ⛔ Not a second copy of the package predicate or of the refusal. The pins
28+
* drive the real runtime dispatcher (`dispatch()`), the one owner of the route,
29+
* over real kernels in both shapes, with an ENABLED package as the control and
30+
* the same package re-enabled as the proof that nothing recorded the verdict.
31+
*
32+
* The negative control (red with the fix ablated) is a one-time ablation,
33+
* cited in the PR, not a permanent case.
34+
*/
35+
36+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
37+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
38+
import { tmpdir } from 'node:os';
39+
import { join } from 'node:path';
40+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
41+
import { MetadataPlugin } from '@objectstack/metadata';
42+
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
43+
import { SqlDriver } from '@objectstack/driver-sql';
44+
import { resolveThrownHttpError } from '@objectstack/types';
45+
import { HttpDispatcher } from '../http-dispatcher.js';
46+
47+
const CALLER = { userId: 'u_actions', tenantId: 'org_qa', isSystem: false, positions: [], permissions: [], systemPermissions: [] };
48+
49+
/** The refusal the data plane answers an object of a disabled package (`@objectstack/metadata-protocol`). */
50+
const PACKAGE_DISABLED = { status: 404, code: 'OBJECT_PACKAGE_DISABLED' } as const;
51+
52+
/** The `manifest` service door a package registers through (`ObjectQLPlugin`'s `register`). */
53+
interface ManifestDoor {
54+
register(artifact: unknown, options?: { source?: 'code' | 'marketplace' }): Promise<void> | void;
55+
}
56+
57+
/**
58+
* A package of one object carrying a script action, a declarative update, a
59+
* script action gated by `requiredPermissions`, and a top-level action bound
60+
* to the object.
61+
*/
62+
function actionPackageOf(prefix: string) {
63+
const object = `${prefix}_obj`;
64+
return {
65+
id: `com.qa.${prefix}`,
66+
object,
67+
artifact: () => ({
68+
id: `com.qa.${prefix}`,
69+
name: `${prefix} package`,
70+
version: '1.0.0',
71+
type: 'app',
72+
objects: [{
73+
name: object,
74+
label: 'Object',
75+
fields: { name: { name: 'name', type: 'text', label: 'Name' } },
76+
actions: [
77+
{ name: 'ping', label: 'Ping', type: 'script', target: 'qa_ping' },
78+
{ name: 'mark_done', label: 'Mark done', type: 'script', operation: 'update', patch: { name: 'done' } },
79+
{ name: 'admin_ping', label: 'Admin ping', type: 'script', target: 'qa_ping', requiredPermissions: ['manage_metadata'] },
80+
],
81+
}],
82+
actions: [
83+
{ name: 'bound_done', label: 'Bound done', type: 'script', operation: 'update', patch: { name: 'done' }, objectName: object },
84+
],
85+
}),
86+
};
87+
}
88+
89+
interface Booted {
90+
kernel: ObjectKernel;
91+
engine: ObjectQL;
92+
root: string;
93+
/** The `qa_ping` handler's invocations. */
94+
pings: ReturnType<typeof vi.fn>;
95+
}
96+
97+
/**
98+
* One kernel in the shape under test (the `environmentId` one with its options
99+
* verbatim from `createStandaloneStack`), with `pkg` registered through the
100+
* `manifest` service during the boot and `qa_ping`'s handler registered after it.
101+
*/
102+
async function bootKernel(environmentId: string | undefined, pkg: ReturnType<typeof actionPackageOf>): Promise<Booted> {
103+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
104+
const root = mkdtempSync(join(tmpdir(), 'os-actions-disabled-package-'));
105+
const engine = new ObjectQL();
106+
engine.registerDriver(new SqlDriver({
107+
client: 'better-sqlite3',
108+
connection: { filename: ':memory:' },
109+
useNullAsDefault: true,
110+
}) as any, true);
111+
if (environmentId === undefined) {
112+
await kernel.use(new MetadataPlugin({ watch: false, rootDir: root }));
113+
await kernel.use(new ObjectQLPlugin({ ql: engine }));
114+
} else {
115+
const artifact = join(root, 'objectstack.json');
116+
writeFileSync(artifact, JSON.stringify({
117+
manifest: { id: 'com.qa.empty', name: 'empty', version: '0.0.0', type: 'app' },
118+
objects: [], views: [], apps: [], flows: [], requires: [],
119+
}));
120+
await kernel.use(new MetadataPlugin({
121+
watch: false, artifactWatch: false, environmentId,
122+
artifactSource: { mode: 'local-file', path: artifact }, rootDir: root,
123+
} as any));
124+
await kernel.use(new ObjectQLPlugin({ ql: engine, environmentId, hydrateMetadataFromDb: true }));
125+
}
126+
const host: Plugin = {
127+
name: 'qa.package-host',
128+
dependencies: ['com.objectstack.engine.objectql'],
129+
init: async (ctx: PluginContext) => {
130+
await ctx.getService<ManifestDoor>('manifest').register(pkg.artifact());
131+
},
132+
};
133+
await kernel.use(host);
134+
await kernel.bootstrap();
135+
await engine.syncSchemas();
136+
const pings = vi.fn(async () => ({ pong: true }));
137+
engine.registerAction(pkg.object, 'qa_ping', pings);
138+
return { kernel, engine, root, pings };
139+
}
140+
141+
async function shutdown(booted: Booted | undefined): Promise<void> {
142+
if (!booted) return;
143+
if (booted.kernel.getState() === 'running') await booted.kernel.shutdown();
144+
rmSync(booted.root, { recursive: true, force: true });
145+
}
146+
147+
/** The refusal's `code` in the dispatcher's envelope: nested under `error`, or beside a string `error`. */
148+
const codeOf = (body: any): unknown => (body?.error && typeof body.error === 'object' ? body.error.code : body?.code);
149+
150+
/** What the runtime dispatcher answers `caller` on `method path`, through `dispatch()` and the host's exit for a throw. */
151+
async function dispatchAs(kernel: ObjectKernel, method: string, path: string, body: Record<string, unknown>, caller: Record<string, unknown> = CALLER) {
152+
const dispatcher = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false });
153+
(dispatcher as any).timedResolveExecutionContext = async () => ({ ...caller });
154+
const raw = new Request(`http://localhost/api/v1${path}`, {
155+
method,
156+
headers: { 'content-type': 'application/json' },
157+
...(method === 'GET' ? {} : { body: JSON.stringify(body) }),
158+
});
159+
try {
160+
const out: any = await dispatcher.dispatch(method, path, body, {}, { request: raw } as any, '/api/v1');
161+
return { status: out.response?.status, code: codeOf(out.response?.body), data: out.response?.body?.data };
162+
} catch (e) {
163+
// A throw out of `dispatch()` is rendered by the host's exit, through this one rule.
164+
const thrown = resolveThrownHttpError(e);
165+
return { status: thrown.status, code: thrown.code, data: undefined };
166+
}
167+
}
168+
169+
/** Every protocol / engine call an action invocation could reach the record or a handler through. */
170+
function dispatchSpies(kernel: ObjectKernel, engine: ObjectQL) {
171+
const protocol: any = kernel.getService('protocol');
172+
return [
173+
...(['getData', 'updateData'] as const).map((m) => vi.spyOn(protocol, m)),
174+
vi.spyOn(engine as any, 'executeAction'),
175+
];
176+
}
177+
178+
/** The invocations the door serves, each on the package's object. */
179+
const INVOCATIONS: ReadonlyArray<{ label: string; path: (object: string) => string; body: Record<string, unknown> }> = [
180+
{ label: 'the script action', path: (o) => `/actions/${o}/ping`, body: {} },
181+
{ label: 'the script action, its record id in the path', path: (o) => `/actions/${o}/ping/r_any`, body: {} },
182+
{ label: 'the declarative update', path: (o) => `/actions/${o}/mark_done`, body: { recordId: 'r_any' } },
183+
{ label: 'the top-level action bound to the object', path: (o) => `/actions/${o}/bound_done`, body: { recordId: 'r_any' } },
184+
{ label: 'the action gated by requiredPermissions the caller lacks', path: (o) => `/actions/${o}/admin_ping`, body: {} },
185+
{ label: 'a name the package does not declare', path: (o) => `/actions/${o}/qa_nowhere`, body: {} },
186+
];
187+
188+
const SHAPES = [
189+
{ shape: 'environmentId env_local (createStandaloneStack)', environmentId: 'env_local' },
190+
{ shape: 'no environmentId', environmentId: undefined },
191+
] as const;
192+
193+
for (const { shape, environmentId } of SHAPES) {
194+
describe(`[#25296] REST /actions and an ENABLED package's actions — ${shape} (the control)`, () => {
195+
const pkg = actionPackageOf('qa_acton');
196+
let booted: Booted | undefined;
197+
198+
beforeAll(async () => {
199+
booted = await bootKernel(environmentId, pkg);
200+
}, 60_000);
201+
afterAll(async () => {
202+
await shutdown(booted);
203+
}, 60_000);
204+
205+
it('runs the script action through its handler', async () => {
206+
const before = booted!.pings.mock.calls.length;
207+
expect(await dispatchAs(booted!.kernel, 'POST', `/actions/${pkg.object}/ping`, {}))
208+
.toEqual({ status: 200, code: undefined, data: { pong: true } });
209+
expect(booted!.pings.mock.calls.length).toBe(before + 1);
210+
});
211+
212+
it('runs the declarative update and the bound top-level action on a record of the object', async () => {
213+
for (const name of ['mark_done', 'bound_done']) {
214+
const created = await dispatchAs(booted!.kernel, 'POST', `/data/${pkg.object}`, { name: 'seed' });
215+
const id = created.data?.id ?? created.data?.record?.id;
216+
expect(typeof id, 'precondition: a record of the package object was created').toBe('string');
217+
const ran = await dispatchAs(booted!.kernel, 'POST', `/actions/${pkg.object}/${name}`, { recordId: id });
218+
expect({ name, status: ran.status, record: ran.data?.record?.name }).toEqual({ name, status: 200, record: 'done' });
219+
}
220+
});
221+
222+
it('answers the action gated by requiredPermissions 403, and an undeclared name 404 with no code of a disabled package', async () => {
223+
expect((await dispatchAs(booted!.kernel, 'POST', `/actions/${pkg.object}/admin_ping`, {})).status).toBe(403);
224+
const nowhere = await dispatchAs(booted!.kernel, 'POST', `/actions/${pkg.object}/qa_nowhere`, {});
225+
expect(nowhere.status).toBe(404);
226+
expect(nowhere.code).not.toBe(PACKAGE_DISABLED.code);
227+
});
228+
});
229+
230+
describe(`[#25296] REST /actions and a DISABLED package's actions — ${shape}: refused as the data plane refuses its object, before anything dispatches`, () => {
231+
const pkg = actionPackageOf('qa_actoff');
232+
let booted: Booted | undefined;
233+
234+
beforeAll(async () => {
235+
booted = await bootKernel(environmentId, pkg);
236+
expect((booted.engine as any).registry.disablePackage(pkg.id), 'precondition: disabled').toBeTruthy();
237+
}, 60_000);
238+
afterAll(async () => {
239+
vi.restoreAllMocks();
240+
await shutdown(booted);
241+
}, 60_000);
242+
243+
it('precondition: the data plane refuses the object 404 OBJECT_PACKAGE_DISABLED', async () => {
244+
const read = await dispatchAs(booted!.kernel, 'GET', `/data/${pkg.object}`, {});
245+
expect({ status: read.status, code: read.code }).toEqual(PACKAGE_DISABLED);
246+
});
247+
248+
for (const { label, path, body } of INVOCATIONS) {
249+
it(`refuses ${label} 404 OBJECT_PACKAGE_DISABLED, reaching no handler and no record`, async () => {
250+
const spies = dispatchSpies(booted!.kernel, booted!.engine);
251+
const before = booted!.pings.mock.calls.length;
252+
try {
253+
const answer = await dispatchAs(booted!.kernel, 'POST', path(pkg.object), body);
254+
expect({ status: answer.status, code: answer.code }).toEqual(PACKAGE_DISABLED);
255+
expect(booted!.pings.mock.calls.length, 'the handler never ran').toBe(before);
256+
for (const spy of spies) expect(spy, 'nothing dispatched').not.toHaveBeenCalled();
257+
} finally {
258+
for (const spy of spies) spy.mockRestore();
259+
}
260+
});
261+
}
262+
263+
it('still answers an anonymous caller the anonymous baseline first', async () => {
264+
const answer = await dispatchAs(booted!.kernel, 'POST', `/actions/${pkg.object}/ping`, {}, { isSystem: false });
265+
expect(answer.status).toBe(401);
266+
expect(answer.code).not.toBe(PACKAGE_DISABLED.code);
267+
});
268+
269+
it('runs the script action again once the package is re-enabled: nothing recorded the refusal', async () => {
270+
expect((booted!.engine as any).registry.enablePackage(pkg.id), 'precondition: re-enabled').toBeTruthy();
271+
const before = booted!.pings.mock.calls.length;
272+
expect(await dispatchAs(booted!.kernel, 'POST', `/actions/${pkg.object}/ping`, {}))
273+
.toEqual({ status: 200, code: undefined, data: { pong: true } });
274+
expect(booted!.pings.mock.calls.length).toBe(before + 1);
275+
});
276+
});
277+
}

0 commit comments

Comments
 (0)