Skip to content

Commit 2d04cd2

Browse files
fix(runtime)!: an action that dispatches to a handler another declared action owns also answers with that owner's permission and activation gates (#30788)
Clause-②: no (narrowing) — the REST `POST /api/v1/actions/:object/:action` door and the MCP `run_action` tool refuse a `script` action whose dispatch reaches a handler that a different declared action's own dispatch reaches, when the caller lacks that action's `requiredPermissions` (403 `PERMISSION_DENIED`) or that action is switched off (409 `ACTION_DISABLED`), and while the metadata store cannot be read they refuse 503 `SERVICE_UNAVAILABLE` a call reaching a handler built from another stored declaration; both doors answered such a call with the handler's result before. Nothing is widened, and no schema or authorable key moves. Metadata-protocol: semantic ## 维护者速读 - 改了什么:动作分发命中的处理器若属于另一个已声明动作,REST `/actions` 与 MCP `run_action` 两个入口都会同时套用那个动作的 `requiredPermissions`(403)与启停开关(409),与被调用动作自身的门取并集,只收窄不放宽。 - 为什么改:加固规则——一个处理器只有一套门;任何声明经由它运行时,都要过拥有它的那个动作的权限与启停开关。 - 风险与回滚:只影响「两个声明共用一个处理器」的调用;仓内发运代码未见此形态,唯一命中的是一个测试夹具(已改为各用各的处理器键)。回滚即还原本 PR 的两个 runtime 源文件。 - 先看:`packages/runtime/src/action-execution.ts` 的 `actionHandlerOwnerRefusal`(归属判定与元数据面不可读分支);`packages/runtime/src/domains/actions.ts` 与 `invokeBusinessAction` 中的两处调用点。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/action-handler-owner-gates.test.ts`,预期 17 条全绿。 - 需要你做的:无,席位落地。 ## What changes The hardening rule: **an action that dispatches to a handler another declared action owns also answers with that owner's permission and activation gates.** - `actionHandlerOwnerRefusal` (`packages/runtime/src/action-execution.ts`) is the one gate, called by both doors right after their own ADR-0066 D4 capability gate and ADR-0126 activation consult, ahead of the param contract, the subject-record load and dispatch. - Ownership is read at the handler **entry** the dispatch actually reaches (`object:key`, one snapshot of `listRegisteredActions()`, in `executeRegisteredAction`'s own rotation). A declared `script` action owns the entry its own dispatch reaches. A declaration is one identity (its owning object key and its name), so the same declaration seen object-embedded and as a standalone copy is not a second owner, and same-named actions on two objects stay independent while each object has its own handler. - Owner permissions are judged before owner switches, the doors' own order. REST answers the refusal on its envelope; MCP throws it with `code` and `status`. - The owners are read from the engine's in-memory copies (registered objects and standalone action items) and from the metadata plane's listings. When the plane cannot be read, a handler a package or code registered is still judged from the engine's copies, and an action's own stored handler still runs; only a handler the authored-action binding built for another stored declaration is refused 503. - `content/docs/ui/actions.mdx` states the rule beside the existing flow-target precedent (a `flow` action inherits its target flow's `requiredPermissions`). - One fixture in `actions-disabled-package.test.ts` declared two actions with one handler key and different gates; the gated one now has its own key, so the file pins what it pinned before. ## Tests `packages/runtime/src/domains/action-handler-owner-gates.test.ts`, organised per door: - REST: 403 when the caller lacks the owner's `requiredPermissions` (target-bound owner, body owner registered under its name, object-less owner under the global key, metadata-plane owner); 409 when the owner is switched off, even for a caller holding its permissions; 503 when the plane is unreadable and the reached handler was built for another plane declaration. Every refusal asserts `status` + `code` and that the owner's handler never ran. - MCP `run_action`: the 403 and 409 legs, asserting the thrown `code` + `status` and that the handler never ran. - Controls: a plain action with its own handler; the owner invoked directly (403 for a member, 200 with the permission); a caller holding the owner's permissions (200, owner's handler runs once); one declaration carried twice; a same-named action on another object; an action's own plane-built handler, and a package handler, with the plane unreadable. ## Acceptance notes - The UI gate is unchanged: a button still reads only its own action's `requiredPermissions`, so a button whose handler belongs to a gated action can be shown to a user the server then refuses. - Fixture authors who give two actions one handler key now get one gate across both; the doc bullet says to give each action its own handler key unless the two should share one gate. ## Verification Head `8f94aaf619f381a499d8755bea6abd821f682a62` (branch merged with `origin/main` at `06cccd32e1`). - Premise: the new test file run against the base sources fails 6 of its 13 legs then present, every one a refusal leg, and passes with the change. - Targeted at head: 5 runtime files (the new file, `action-activation-dispatch`, `actions-disabled-package`, `dispatcher-unique-violation-wire-code`, `sandbox-unreadable-rejection-door`) — 91/91. `pnpm --filter @objectstack/runtime typecheck` exit 0; `pnpm check:type-check-debt` exit 0 (0 raw errors above the ledger). - Full `@objectstack/runtime` suite at `9b2ba60b94` (the head before a type-only follow-up): 545/545 files, 8726 passed, 19 skipped. - Ablation (`scripts/ablation-replace.mjs`, each restored blob-equal, tree clean afterwards): owner-permission refusal → 6 legs red; owner-switch refusal → both 409 legs red; unreadable-plane refusal → the 503 leg red; REST call site → 7 legs red; MCP call site → both MCP refusal legs red. - `node scripts/pm/dispatch-gates.mjs --ran`: 90 derived, 90 run, 0 NOT-MEASURED, all exit 0. - Dogfood battery at head, closure built first, under the verify lock: 421 files passed, 3 skipped (424); 3513 tests passed, 36 skipped (3549); exit 0. No dogfood file is touched by this change. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg <!-- signed: security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55cd927 commit 2d04cd2

6 files changed

Lines changed: 733 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: an action that dispatches to a handler another declared action owns also answers with that owner's permission and activation gates
6+
7+
Clause-②: no (narrowing) — the REST `POST /api/v1/actions/:object/:action` door and the MCP `run_action` tool refuse a `script` action whose dispatch reaches a handler that a different declared action's own dispatch reaches, when the caller lacks that action's `requiredPermissions` (403 `PERMISSION_DENIED`) or that action is switched off (409 `ACTION_DISABLED`), and while the metadata store cannot be read they refuse 503 `SERVICE_UNAVAILABLE` a call reaching a handler built from another stored declaration; both doors answered such a call with the handler's result before. Nothing is widened, and no schema or authorable key moves.
8+
Metadata-protocol: semantic
9+
Skills-docs: none — `skills/**` teaches `target` as a registered function name and `requiredPermissions` as an action's own gate, and neither statement changes; the rule is documented in `content/docs/ui/actions.mdx` in this change.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) no authorable key, value or export is removed or renamed; an action declaration parses and saves exactly as before, and only which gates its dispatch answers to grows -->
12+
13+
**BREAKING** narrowing at the two action-dispatch doors (`@objectstack/runtime`).
14+
15+
- **The rule.** A handler entry belongs to every declared `script` action whose own dispatch reaches it — through its `target`, or through the handler its `body` registered under its name. When a call reaches an entry that a different declaration owns, that declaration's `requiredPermissions` and its packaged-action switch apply on top of the invoked action's own: the caller needs both sets, and the switch of either turns the call off. The handler does not run on a refusal, and nothing about the request is read first.
16+
- **An unreadable metadata store.** While the metadata store cannot be read, a call reaching a handler that was built from another stored action declaration is refused 503 `SERVICE_UNAVAILABLE`, because that declaration's gates cannot be checked; a handler a package or code registered, and an action's own stored handler, keep running.
17+
- **What counts as "different".** A declaration is its owning object and its name, so one declaration carried both inside its object and as a standalone item is one owner, not two. Two objects that each declare a same-named action are independent while each object has its own handler.
18+
- **Not changed.** An action with its own handler key answers exactly as before, and so do `flow`, `api` and declarative-update actions, which dispatch through no handler entry.

‎content/docs/ui/actions.mdx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,16 @@ is a spec proposal for a properly named key, not a values map under this one.
433433
action inherits the flow's list; when both declare one, the caller needs
434434
both. Referenced
435435
capabilities must exist — `os lint` checks that.
436+
- **A `script` action that dispatches to a handler another declared action
437+
owns is gated by that action too.** When the handler a call reaches is the
438+
one a different declaration's own dispatch reaches — its `target` names that
439+
declaration's handler key, or the handler that declaration's body registered —
440+
the server applies that action's `requiredPermissions` (**403**
441+
`PERMISSION_DENIED`) and its packaged-action switch (**409** `ACTION_DISABLED`)
442+
as well as the invoked action's own, on the REST `/actions` endpoint and the
443+
MCP `run_action` tool alike. The two sets add up, so the caller needs both,
444+
and the handler does not run on a refusal. Give each action its own handler
445+
key unless the two should share one gate.
436446
- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression
437447
that throws hides the action silently. Two rules save real debugging time:
438448

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

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4343,6 +4343,16 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
43434343
if (refusal) {
43444344
throw Object.assign(new Error(refusal.message), { code: refusal.code, status: refusal.status });
43454345
}
4346+
4347+
// The handler the script dispatch below reaches may be one a DIFFERENT
4348+
// declared action owns: that owner's gates apply too, at the same
4349+
// point the REST `/actions` door asks the same function.
4350+
const ownerRefusal = await actionHandlerOwnerRefusal(deps, requestContext, {
4351+
ql: activationEngine, action, objectName, candidates: resolveActionHandlerKeys(action), ec, envId, obj,
4352+
});
4353+
if (ownerRefusal) {
4354+
throw Object.assign(new Error(ownerRefusal.message), { code: ownerRefusal.code, status: ownerRefusal.status });
4355+
}
43464356
}
43474357

43484358
// [ADR-0104 D2] Declared param contract — same enforcement as the REST
@@ -4909,6 +4919,274 @@ export function registeredActionHandlerProbe(_deps: ActionExecutionDeps,
49094919
}
49104920

49114921

4922+
/**
4923+
* The handler ENTRY a script dispatch for `(objectName, candidates)` reaches:
4924+
* the first `object:key` pair of {@link executeRegisteredAction}'s rotation
4925+
* that the engine's registry holds, read from `registered` (one snapshot of
4926+
* `listRegisteredActions()` as a `Set` of `object:key`). `undefined` = the
4927+
* dispatch reaches nothing.
4928+
*/
4929+
function reachedHandlerEntry(registered: ReadonlySet<string>, objectName: string, candidates: string[]): string | undefined {
4930+
for (const obj of actionHandlerObjectKeys(objectName)) {
4931+
for (const key of candidates) {
4932+
if (registered.has(`${obj}:${key}`)) return `${obj}:${key}`;
4933+
}
4934+
}
4935+
return undefined;
4936+
}
4937+
4938+
/** True when a declaration is dispatched through the engine's handler registry. */
4939+
function dispatchesThroughHandlerRegistry(action: any): boolean {
4940+
return !isDeclarativeUpdateAction(action) && (action?.type ?? 'script') === 'script';
4941+
}
4942+
4943+
/** The refusal {@link actionHandlerOwnerRefusal} answers — the ADR-0112 envelope's three fields. */
4944+
export interface ActionHandlerOwnerRefusal {
4945+
code: string;
4946+
status: number;
4947+
message: string;
4948+
}
4949+
4950+
/** The one protocol verb {@link collectHandlerOwnerCandidates} reads: the merged listing `GET /api/v1/meta/TYPE` serves. */
4951+
type HandlerOwnerListingRead = Partial<Pick<MetadataProtocol, 'getMetaItems'>>;
4952+
4953+
/**
4954+
* Every declaration that could own an entry under `entryObjectKey`, as
4955+
* `{ action, objectName }` rows: each source a declaration is resolved from
4956+
* on either door, read in full, so a declaration missing from one of them is
4957+
* still found in another. More rows can only add gates.
4958+
*
4959+
* - object-embedded `actions[]` — of the routed object alone when the entry
4960+
* lives under a real object key (only that object's declarations rotate
4961+
* to it, {@link actionHandlerObjectKeys}), of every object when the entry
4962+
* is object-less: the door's resolved definition, the engine's schema, and
4963+
* for an object-less entry the protocol's merged listing (or the metadata
4964+
* service's) and the engine registry's;
4965+
* - standalone `action` items — the protocol's merged listing (or the
4966+
* metadata service's `loadMany`), and the engine registry's, a disabled
4967+
* package's items included.
4968+
*
4969+
* The engine's own copies are in memory and always read. The metadata plane's
4970+
* listings are read beside them, and a plane listing that throws is REPORTED
4971+
* (`planeError`), never read as "no rows": the caller decides whether the
4972+
* entry it judges can have an owner the engine's copies do not hold.
4973+
*/
4974+
async function collectHandlerOwnerCandidates(
4975+
deps: ActionExecutionDeps,
4976+
requestContext: HttpProtocolContext,
4977+
args: { ql: any; entryObjectKey: string; routedObjectName: string; routedObject?: any; envId?: string },
4978+
): Promise<{ rows: Array<{ action: any; objectName: string }>; planeError?: unknown }> {
4979+
const { ql, entryObjectKey, routedObjectName, routedObject, envId } = args;
4980+
const protocol = await deps.resolveService(requestContext, 'protocol', envId) as HandlerOwnerListingRead | undefined;
4981+
const meta = await deps.resolveService(requestContext, 'metadata', envId);
4982+
let planeError: unknown;
4983+
const fromPlane = async (read: () => Promise<any[] | undefined>): Promise<any[]> => {
4984+
try {
4985+
return (await read()) ?? [];
4986+
} catch (err) {
4987+
planeError ??= err;
4988+
return [];
4989+
}
4990+
};
4991+
const mergedListing = async (type: 'object' | 'action'): Promise<any[] | undefined> => {
4992+
if (!protocol || typeof protocol.getMetaItems !== 'function') return undefined;
4993+
const answer: unknown = await protocol.getMetaItems({ type });
4994+
if (Array.isArray(answer)) return answer;
4995+
const items = (answer as { items?: unknown } | null | undefined)?.items;
4996+
return Array.isArray(items) ? items : [];
4997+
};
4998+
const registryListing = (type: 'object' | 'action'): any[] => {
4999+
const registry: any = ql?.registry;
5000+
if (type === 'object') return typeof registry?.getAllObjects === 'function' ? (registry.getAllObjects() ?? []) : [];
5001+
return typeof registry?.listItems === 'function'
5002+
? (registry.listItems('action', undefined, { includeDisabledPackages: true }) ?? [])
5003+
: [];
5004+
};
5005+
5006+
const objects: any[] = [];
5007+
if (routedObject) objects.push(routedObject);
5008+
if (typeof ql?.getSchema === 'function') {
5009+
const schema = ql.getSchema(routedObjectName);
5010+
if (schema) objects.push(schema);
5011+
}
5012+
if (isObjectLessActionKey(entryObjectKey)) {
5013+
objects.push(...registryListing('object'));
5014+
objects.push(...await fromPlane(async () => (await mergedListing('object')) ?? (await meta?.listObjects?.())));
5015+
}
5016+
5017+
const standalone: any[] = [
5018+
...registryListing('action'),
5019+
...await fromPlane(async () => (await mergedListing('action')) ?? (await meta?.loadMany?.('action'))),
5020+
];
5021+
5022+
const rows: Array<{ action: any; objectName: string }> = [];
5023+
for (const obj of objects) {
5024+
const objectName: unknown = obj?.name;
5025+
if (typeof objectName !== 'string' || objectName === '') continue;
5026+
for (const action of Array.isArray(obj?.actions) ? obj.actions : []) {
5027+
if (action && typeof action.name === 'string') rows.push({ action, objectName });
5028+
}
5029+
}
5030+
for (const action of standalone) {
5031+
if (action && typeof action.name === 'string') rows.push({ action, objectName: standaloneActionOwnerKey(action) });
5032+
}
5033+
return planeError === undefined ? { rows } : { rows, planeError };
5034+
}
5035+
5036+
/**
5037+
* The package label the engine plugin's authored-action resync registers a
5038+
* metadata-plane action's handler under (`ObjectQLPlugin`'s
5039+
* `resyncAuthoredActionsNow`, `registerAction(…, 'metadata-service')`). An
5040+
* entry carrying it was built from a plane row, so the declarations that own
5041+
* it are plane rows, which the engine's in-memory copies need not hold.
5042+
*/
5043+
const PLANE_AUTHORED_HANDLER_PACKAGE = 'metadata-service';
5044+
5045+
/**
5046+
* [ADR-0066 D4 · ADR-0126 §8 item 2 · ADR-0110 D2] An action whose dispatch
5047+
* reaches a handler that a DIFFERENT declared action owns also answers with
5048+
* that owner's gates: its `requiredPermissions` (403 `PERMISSION_DENIED`) and
5049+
* its activation-ledger switch (409 `ACTION_DISABLED`), on top of the invoked
5050+
* declaration's own, which the door has already applied. The sets are unioned,
5051+
* so this only ever narrows who may run a handler — the reading
5052+
* `content/docs/ui/actions.mdx` already gives a `flow` action, which inherits
5053+
* its target flow's `requiredPermissions`.
5054+
*
5055+
* ## Owner = the entry the declaration's own dispatch reaches
5056+
*
5057+
* ADR-0110 D2 makes a handler KEY an addressing fact, not an identity — so
5058+
* ownership is read at the handler ENTRY the dispatch actually reaches
5059+
* (`object:key`, one snapshot of `listRegisteredActions()`, walked in
5060+
* {@link executeRegisteredAction}'s own rotation), never at every key a
5061+
* declaration names. A declared `script` action owns the entry ITS OWN
5062+
* dispatch would reach. Consequences, each pinned by
5063+
* `domains/action-handler-owner-gates.test.ts`:
5064+
*
5065+
* - a declaration is one identity — its owning object key and its name — so
5066+
* the same declaration reaching the population twice (object-embedded and
5067+
* as a standalone copy) is the invoked one, not a second owner;
5068+
* - two objects that each declare a same-named action are not coupled while
5069+
* each has its own entry: each declaration's dispatch stops at its own
5070+
* object's key before the object-less one;
5071+
* - only `script` declarations dispatch through the registry, so a `flow`,
5072+
* `api` or declarative-update declaration owns no entry, and an invocation
5073+
* of one is not judged here.
5074+
*
5075+
* ## Where it sits
5076+
*
5077+
* Both doors call it right after their own D4 gate and activation consult,
5078+
* ahead of the param contract, the subject-record load and dispatch: a
5079+
* refused call reads no row and runs nothing. Owner permissions are judged
5080+
* before owner switches, the doors' own order, so the switch is no oracle to
5081+
* a caller the owner would refuse. The REST `/actions` door maps the refusal
5082+
* onto its envelope; the MCP `run_action` bridge throws it with `code` and
5083+
* `status`.
5084+
*
5085+
* ## An unreadable metadata plane
5086+
*
5087+
* The owners are read from the engine's in-memory copies and from the plane's
5088+
* listings ({@link collectHandlerOwnerCandidates}). The plane being down
5089+
* adds no dependency to a call whose handler a package or code registered:
5090+
* its owners are declarations the engine holds. Only an entry the
5091+
* authored-action resync built for ANOTHER plane declaration is refused
5092+
* 503 while the plane cannot be read — the one case where the owner's gates
5093+
* live nowhere else.
5094+
*
5095+
* `candidates` are the handler keys the door hands {@link executeRegisteredAction},
5096+
* so the entry judged here is the one the dispatch reaches. An engine that
5097+
* cannot enumerate its handlers (no `listRegisteredActions`) names no entry,
5098+
* and this answers `undefined`.
5099+
*/
5100+
export async function actionHandlerOwnerRefusal(deps: ActionExecutionDeps,
5101+
requestContext: HttpProtocolContext,
5102+
args: {
5103+
ql: any;
5104+
action: any;
5105+
objectName: string;
5106+
candidates: string[];
5107+
ec: any;
5108+
envId?: string;
5109+
/** The routed object's definition, as the door resolved it. */
5110+
obj?: any;
5111+
},
5112+
): Promise<ActionHandlerOwnerRefusal | undefined> {
5113+
const { ql, action, objectName, candidates, ec, envId, obj } = args;
5114+
if (!dispatchesThroughHandlerRegistry(action)) return undefined;
5115+
if (!ql || typeof ql.listRegisteredActions !== 'function') return undefined;
5116+
5117+
const registered = new Set<string>();
5118+
const packageOf = new Map<string, string | undefined>();
5119+
for (const row of ql.listRegisteredActions() as Array<{ objectName: string; actionName: string; package?: string }>) {
5120+
registered.add(`${row.objectName}:${row.actionName}`);
5121+
packageOf.set(`${row.objectName}:${row.actionName}`, row.package);
5122+
}
5123+
const entry = reachedHandlerEntry(registered, objectName, candidates);
5124+
if (!entry) return undefined;
5125+
5126+
// The invoked declaration's identity: the routed object key when the routed
5127+
// object embeds that name (both doors resolve object-embedded first, so
5128+
// the invoked declaration IS that entry), else its own standalone owner
5129+
// key — an object-less item is routable at any object.
5130+
const embeddedInRouted = Array.isArray(obj?.actions)
5131+
&& obj.actions.some((a: any) => a === action || (a && a.name === action?.name));
5132+
const invokedIdentity = `${embeddedInRouted ? objectName : standaloneActionOwnerKey(action)}:${action?.name}`;
5133+
5134+
const { rows: population, planeError } = await collectHandlerOwnerCandidates(deps, requestContext, {
5135+
ql, entryObjectKey: entry.slice(0, entry.indexOf(':')), routedObjectName: objectName, routedObject: obj, envId,
5136+
});
5137+
// A plane listing that could not be read leaves the engine's own copies,
5138+
// which hold every declaration a package or code registered a handler for.
5139+
// An entry built from a plane row is owned by the plane row it was built
5140+
// for (registered under that row's owner key and name): when that row is
5141+
// not the invoked declaration, its gates cannot be verified with the plane
5142+
// unread, and the call is refused rather than run ungated — ADR-0110 D3's
5143+
// posture for an unreadable plane.
5144+
if (planeError !== undefined && entry !== invokedIdentity
5145+
&& packageOf.get(entry) === PLANE_AUTHORED_HANDLER_PACKAGE) {
5146+
const reason = planeError instanceof Error ? planeError.message : String(planeError);
5147+
return {
5148+
code: standardErrorCodeForHttpStatus(503),
5149+
status: 503,
5150+
message:
5151+
`Cannot verify which declared action owns the handler that action '${action?.name ?? 'unknown'}' ` +
5152+
`dispatches to — the metadata plane is unavailable (${reason}). ` +
5153+
'Refusing rather than running it ungated.',
5154+
};
5155+
}
5156+
5157+
const owners = new Map<string, { action: any; objectName: string }[]>();
5158+
for (const row of population) {
5159+
const identity = `${row.objectName}:${row.action.name}`;
5160+
if (identity === invokedIdentity) continue;
5161+
if (!dispatchesThroughHandlerRegistry(row.action)) continue;
5162+
if (reachedHandlerEntry(registered, row.objectName, resolveActionHandlerKeys(row.action)) !== entry) continue;
5163+
const copies = owners.get(identity) ?? [];
5164+
copies.push(row);
5165+
owners.set(identity, copies);
5166+
}
5167+
if (owners.size === 0) return undefined;
5168+
5169+
const via = `Action '${action?.name ?? 'unknown'}' on '${objectName}' dispatches to a handler`;
5170+
for (const copies of owners.values()) {
5171+
for (const owner of copies) {
5172+
const denied = actionPermissionError(deps, owner.action, ec, owner.objectName);
5173+
if (denied) {
5174+
return {
5175+
code: standardErrorCodeForHttpStatus(403),
5176+
status: 403,
5177+
message: `${via} that action '${owner.action.name}' owns, so it is gated by that action too: ${denied}`,
5178+
};
5179+
}
5180+
}
5181+
}
5182+
for (const copies of owners.values()) {
5183+
const switchedOff = disabledActionRefusal(deps, ql, copies[0].action);
5184+
if (switchedOff) return switchedOff;
5185+
}
5186+
return undefined;
5187+
}
5188+
5189+
49125190
/**
49135191
* Resolve the DECLARATION behind a `<object>/<action>` route pair — the
49145192
* single source the REST `/actions` route reads for the ADR-0066 D4

0 commit comments

Comments
 (0)