A component's own roles.yaml cannot grant an operation that the same component's resources.js registers, because the roles plugin runs before jsResource in the same worker. Static trace against main @ ddb2716; not reproduced against a live instance, so please correct me if I've misread the load order.
This is adjacent to but distinct from the cross-thread grantability gap being fixed separately (companion PR linked below) — that one was main-thread validateOperations never learning about worker registrations at all. This one is a same-thread ordering problem and survives that fix.
The mechanism
server.registerOperation({ requiresSuperUser }) calls registerOperationPermission, which marks the operation grantable in the registering thread's module-local set (utility/operationPermissions.ts → dynamicallyRegisteredOps). A component's roles.yaml is applied by resources/roles.ts → handleApplication, which calls addRole/alterRole (security/role.ts) → addRoleValidation → validateOperations.
Both run in the same worker (componentLoader.ts:669 gates handleApplication on resources.isWorker), so grantability is visible in principle. The problem is ordering:
components/DEFAULT_CONFIG.ts declares plugins in the order rest, graphqlSchema, roles, jsResource, fastifyRoutes, static.
componentLoader.ts:542 iterates with for (const componentName in config) — insertion order.
So roles is processed before jsResource. When roles.yaml is validated, the registerOperation calls in resources.js have not run yet, the name is not in dynamicallyRegisteredOps, and the role is rejected with INVALID_OPERATIONS_OP:
Invalid operations value '<op>'. Must be a valid operation name or group (e.g. 'read_only').
Why it matters
Declaring an operation and the role that may call it in the same component is the natural shape — it's the whole point of shipping roles.yaml alongside resources.js. Today an author has to either grant the operation out-of-band via the add_role API after deploy, or split the operation and its role across two components ordered so the registering one loads first.
It fails closed (a rejected role, never a widened one), so this is a DX/correctness bug, not a security hole.
Repro sketch
A single component with:
// resources.js
server.registerOperation({
name: 'my_component_op',
requiresSuperUser: true,
execute: async function myComponentOp() { return { ok: true }; },
});
# roles.yaml
my_op_role:
operations:
- my_component_op
Expected: the role is created with the grant. Actual (expected from the trace): component load reports the role as invalid.
Possible directions
- Order
jsResource before roles in DEFAULT_CONFIG — smallest change, but relies on object key order as a load-order contract, and only helps the default config (an explicit component config with its own key order would still be able to get this wrong).
- Defer
roles.yaml application until after the component's other plugins have loaded (e.g. apply on the existing deploy:end/ready hook rather than inline at handleEntry) — resources/roles.ts already has a deploy:end reconcile path, so this may be mostly a matter of which pass performs the first application.
- Make load order explicit rather than incidental, so a plugin can declare that it must run after
jsResource.
I'd lean toward the second — the reconcile pass already exists and ordering-by-key-insertion is a fragile contract to lean on.
Tests worth adding
An integration test with a single fixture component that registers a grantable operation in resources.js and grants it in its own roles.yaml, asserting the role exists with the grant after load. integrationTests/components/registered-operation.test.ts now covers the API-side (add_role) path across the worker/main boundary but nothing exercises roles.yaml.
🤖 Filed by Claude on behalf of @dawsontoth
A component's own
roles.yamlcannot grant an operation that the same component'sresources.jsregisters, because therolesplugin runs beforejsResourcein the same worker. Static trace againstmain@ ddb2716; not reproduced against a live instance, so please correct me if I've misread the load order.This is adjacent to but distinct from the cross-thread grantability gap being fixed separately (companion PR linked below) — that one was main-thread
validateOperationsnever learning about worker registrations at all. This one is a same-thread ordering problem and survives that fix.The mechanism
server.registerOperation({ requiresSuperUser })callsregisterOperationPermission, which marks the operation grantable in the registering thread's module-local set (utility/operationPermissions.ts→dynamicallyRegisteredOps). A component'sroles.yamlis applied byresources/roles.ts → handleApplication, which callsaddRole/alterRole(security/role.ts) →addRoleValidation→validateOperations.Both run in the same worker (
componentLoader.ts:669gateshandleApplicationonresources.isWorker), so grantability is visible in principle. The problem is ordering:components/DEFAULT_CONFIG.tsdeclares plugins in the orderrest,graphqlSchema,roles,jsResource,fastifyRoutes,static.componentLoader.ts:542iterates withfor (const componentName in config)— insertion order.So
rolesis processed beforejsResource. Whenroles.yamlis validated, theregisterOperationcalls inresources.jshave not run yet, the name is not indynamicallyRegisteredOps, and the role is rejected withINVALID_OPERATIONS_OP:Why it matters
Declaring an operation and the role that may call it in the same component is the natural shape — it's the whole point of shipping
roles.yamlalongsideresources.js. Today an author has to either grant the operation out-of-band via theadd_roleAPI after deploy, or split the operation and its role across two components ordered so the registering one loads first.It fails closed (a rejected role, never a widened one), so this is a DX/correctness bug, not a security hole.
Repro sketch
A single component with:
Expected: the role is created with the grant. Actual (expected from the trace): component load reports the role as invalid.
Possible directions
jsResourcebeforerolesinDEFAULT_CONFIG— smallest change, but relies on object key order as a load-order contract, and only helps the default config (an explicit component config with its own key order would still be able to get this wrong).roles.yamlapplication until after the component's other plugins have loaded (e.g. apply on the existingdeploy:end/ready hook rather than inline athandleEntry) —resources/roles.tsalready has adeploy:endreconcile path, so this may be mostly a matter of which pass performs the first application.jsResource.I'd lean toward the second — the reconcile pass already exists and ordering-by-key-insertion is a fragile contract to lean on.
Tests worth adding
An integration test with a single fixture component that registers a grantable operation in
resources.jsand grants it in its ownroles.yaml, asserting the role exists with the grant after load.integrationTests/components/registered-operation.test.tsnow covers the API-side (add_role) path across the worker/main boundary but nothing exercisesroles.yaml.🤖 Filed by Claude on behalf of @dawsontoth