Description
Both bulk actions on the Device Groups page post to POST /device-groups/bulk, which does not exist. Select groups, choose a script or a policy, confirm — the request 404s.
- Run script across selected groups —
handleBulkScript, DeviceGroupsPage.tsx:583, body {action:"run-script", scriptId, groupIds}
- Apply policy across selected groups —
handleBulkPolicy, DeviceGroupsPage.tsx:614, body {action:"apply-policy", policyId, groupIds}
Live proof
POST /api/v1/device-groups/bulk -> 404 {"error":"Not Found","path":"/api/v1/device-groups/bulk"}
Framework-level not-found with the path echoed back — no handler. grep for '/bulk' in apps/api/src/routes/groups.ts returns nothing; the router registers only get, get, post, patch, delete, get.
Why this needs a design call, not just a route
Unlike #3428 (where the handler exists under a different verb), there is no server-side implementation at all — the client invented a contract that was never built. Decisions needed before it can be implemented:
- Fan-out semantics. "Run a script across N groups" expands to a device set that can span multiple organizations, since group selection is not org-constrained in the UI. Per the partner-wide fan-out rule, worker-created child rows (script executions) must take the device's org, not the caller's or the group's. Getting this wrong is the
eq(table.orgId, device.orgId) class of silent no-op.
- Partial failure. If 3 of 10 groups resolve and one script dispatch fails, what does the response look like, and what does the UI show? This is exactly the aggregate/partial-success shape that
runActionAllowlist.ts carves out from the standard runAction toast contract.
- Whether it should be one endpoint with an
action discriminator at all. The client's {action: "run-script" | "apply-policy"} union is a mild smell; two explicit endpoints would type better and keep the authorization checks separate — running a script and assigning a policy are different permissions.
- Deduplication. A device in two selected groups should not get the script twice.
Interim consideration
Since there is no backend, the two bulk controls are pure dead UI. If implementing this isn't near-term, hiding or disabling the bulk toolbar is better than leaving controls that always fail — same reasoning as #2789 (built-but-unrendered deployment UI) and #2606 (features with no page). That's a product call.
Affected files
apps/web/src/components/devices/DeviceGroupsPage.tsx:579-600 (handleBulkScript), :606-630 (handleBulkPolicy)
apps/api/src/routes/groups.ts (no /bulk route)
Context
Found while verifying #3159. Companion to #3428 (edit/drag-drop 404) and #3425 (create 400s for multi-org partners). Not a v0.105.0 regression — pre-existing.
Reported By: internal Playwright UI QA sweep 2026-08-11, evidence in docs/testing/FEATURE_TEST_LOG.md.
Description
Both bulk actions on the Device Groups page post to
POST /device-groups/bulk, which does not exist. Select groups, choose a script or a policy, confirm — the request 404s.handleBulkScript,DeviceGroupsPage.tsx:583, body{action:"run-script", scriptId, groupIds}handleBulkPolicy,DeviceGroupsPage.tsx:614, body{action:"apply-policy", policyId, groupIds}Live proof
Framework-level not-found with the path echoed back — no handler.
grepfor'/bulk'inapps/api/src/routes/groups.tsreturns nothing; the router registers onlyget,get,post,patch,delete,get.Why this needs a design call, not just a route
Unlike #3428 (where the handler exists under a different verb), there is no server-side implementation at all — the client invented a contract that was never built. Decisions needed before it can be implemented:
eq(table.orgId, device.orgId)class of silent no-op.runActionAllowlist.tscarves out from the standardrunActiontoast contract.actiondiscriminator at all. The client's{action: "run-script" | "apply-policy"}union is a mild smell; two explicit endpoints would type better and keep the authorization checks separate — running a script and assigning a policy are different permissions.Interim consideration
Since there is no backend, the two bulk controls are pure dead UI. If implementing this isn't near-term, hiding or disabling the bulk toolbar is better than leaving controls that always fail — same reasoning as #2789 (built-but-unrendered deployment UI) and #2606 (features with no page). That's a product call.
Affected files
apps/web/src/components/devices/DeviceGroupsPage.tsx:579-600(handleBulkScript),:606-630(handleBulkPolicy)apps/api/src/routes/groups.ts(no/bulkroute)Context
Found while verifying #3159. Companion to #3428 (edit/drag-drop 404) and #3425 (create 400s for multi-org partners). Not a v0.105.0 regression — pre-existing.
Reported By: internal Playwright UI QA sweep 2026-08-11, evidence in
docs/testing/FEATURE_TEST_LOG.md.