@checkstack/automation-frontend 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,198 @@
1
1
  # @checkstack/automation-frontend
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [43e4484]
8
+ - Updated dependencies [43e4484]
9
+ - Updated dependencies [43e4484]
10
+ - Updated dependencies [43e4484]
11
+ - Updated dependencies [43e4484]
12
+ - Updated dependencies [43e4484]
13
+ - @checkstack/catalog-common@2.7.0
14
+ - @checkstack/ui@1.26.0
15
+ - @checkstack/frontend-api@0.14.1
16
+ - @checkstack/auth-frontend@0.13.1
17
+ - @checkstack/gitops-frontend@0.7.2
18
+ - @checkstack/script-packages-frontend@0.4.11
19
+ - @checkstack/secrets-frontend@0.3.10
20
+
21
+ ## 0.12.0
22
+
23
+ ### Minor Changes
24
+
25
+ - f93ee7a: Derive frontend authorization gates from the RPC contract instead of hand-picking
26
+ a hook per call site. The backend contract already declares, per procedure, both
27
+ the access rule (`access`) and how it is instance-scoped (`instanceAccess`); the
28
+ frontend gate was a hand re-encoding of that, which is how the "global-only
29
+ team-grant" drift shipped (nothing enforced that the hook a page chose matched
30
+ the mode the contract declared).
31
+
32
+ New `resolveProcedureGate` (`@checkstack/common`) reads a contract procedure's
33
+ metadata and returns the single gate the backend will enforce - classifying
34
+ `global` / `idParam` / `create` / `typeScoped` / post-filtered `open`, deriving
35
+ the object type from the rule and resolving the resource id from the input via
36
+ the contract's declared path. `parentScope` is normalized into an `idParam`/`open`
37
+ gate on a reconstructed parent rule + the parent type (the parent grant string the
38
+ backend checks is exactly `${resourceType}.${action}`, so no contract change was
39
+ needed). New `accessApi.useProcedureAccess(procedure, input)`
40
+ (`@checkstack/frontend-api` / `@checkstack/auth-frontend`) dispatches on the
41
+ derived gate; a call site can no longer gate on the wrong thing.
42
+
43
+ Fix a latent `create.parent` gap: the create gate's global-RBAC path only checked
44
+ the procedure's own manage rule, so a user with GLOBAL manage on the PARENT type
45
+ (e.g. a global system manager creating an incident/maintenance/SLO "for" a system,
46
+ which the backend authorizes via the parent gate) was not offered the create
47
+ affordance. The derived create gate now also ORs global manage on the parent type.
48
+
49
+ Migrate every `useCanCreate` create-button gate (catalog systems, health checks,
50
+ incidents, maintenance, SLOs, automations, status pages) to `useProcedureAccess`
51
+ on the owning create procedure, which also delivers the `create.parent` fix to
52
+ each, then remove `useCanCreate` from the `AccessApi`.
53
+
54
+ BREAKING CHANGES: `accessApi.useCanCreate(...)` is removed from
55
+ `@checkstack/frontend-api`. Replace it with
56
+ `accessApi.useProcedureAccess(SomeApi.contract.createX)` - the create procedure's
57
+ `instanceAccess.create` supplies the object type and parent gate, so no more
58
+ hand-passed `objectType` / `parentType`. The remaining hooks (`useAccess`,
59
+ `useCanAccessType`, `useResourceAccess`, `useRouteAccess`, `useIsAuthenticated`)
60
+ are unchanged: they gate surfaces/rows/routes that are not tied to a single
61
+ procedure. No gate became more restrictive; the create fix makes global
62
+ parent-managers correctly see create controls they were wrongly denied.
63
+
64
+ Patch-level adaptations to the `AccessApi` interface change (no behavior change of
65
+ their own): the host app's fallback `AccessApi` stubs (`@checkstack/frontend`) and
66
+ Storybook's mock (`@checkstack/ui`) drop `useCanCreate` and add the new
67
+ `useProcedureAccess` / `useSurfaceAccess` members so they match the interface, and
68
+ a `@checkstack/catalog-common` doc comment now names `useProcedureAccess` instead
69
+ of the removed hook.
70
+
71
+ - f93ee7a: Fuse authorization into the RPC call so a frontend gate can't drift from - or be
72
+ forgotten alongside - the procedure it guards. This is the structural endpoint of
73
+ the contract-derived gating work: instead of pairing `client.X.useMutation()` with
74
+ a separate `useProcedureAccess(X)`, the gate is welded to the call.
75
+
76
+ - `useGatedMutation` / `useGatedQuery` (`@checkstack/frontend-api`): the plugin
77
+ client's mutation/query hooks now have gate-fused variants that derive the
78
+ authorization verdict from the SAME contract procedure and input the call uses
79
+ and return it as `{ allowed, accessLoading }` on the result. A control cannot
80
+ obtain `mutate` without the verdict, and a gated query stays disabled until the
81
+ caller is authorized (no guaranteed-403 fetch). The id a mutation gates on is
82
+ passed as `gateInput` (e.g. `{ id }`), the same id `mutate` will send.
83
+ - `accessApi.useSurfaceAccess(procedure)` (`@checkstack/auth-frontend`): the
84
+ coarse "can the user reach this management surface" gate, DERIVED from a
85
+ representative procedure of the page (its access rule + object/parent type from
86
+ the contract) instead of hand-passed `objectType`/`parentType` that can drift.
87
+ Generalizes the hand-authored `useCanAccessType` surface gate.
88
+ - Runtime gating-drift detector (`@checkstack/backend-api`): the auth middleware
89
+ logs, in dev/e2e only (no-op in production), when a real user is denied a
90
+ global-only gate - a candidate for the "shown-but-denied" drift class. A
91
+ belt-and-suspenders net for hand-rolled/dynamic call paths the fused hooks
92
+ don't cover.
93
+
94
+ The automation editor is the reference surface: its create/update gates are fused
95
+ directly into the create/update mutations, so there is no separate gate hook to
96
+ keep in sync, and its surface gate uses `useSurfaceAccess`. The run-detail page's
97
+ "Cancel run" control is also fused onto
98
+ `cancelRun` - a real drift fix: it previously gated on a bare
99
+ `useAccess(automation.manage)` (the GLOBAL rule), so a team-scoped manager with a
100
+ grant on the automation but no global rule saw no Cancel button even though the
101
+ `parentScope`d backend would authorize them; the fused gate derives the verdict
102
+ from the page's `automationId`, so they now see it. A
103
+ `checkstack/prefer-gated-mutation` lint rule (dev tooling, scoped, `warn`) nudges
104
+ raw `.useMutation()` toward the fused variant so fusion is the default and raw
105
+ mutations become the deliberate, greppable exception (the remaining raw automation
106
+ mutations - per-row toggle/delete gated via `useResourceAccess`, and the
107
+ stateless `renderTemplate` utility - carry a documented suppression).
108
+
109
+ No behavior change for existing call sites: `useMutation` / `useQuery` /
110
+ `useCanAccessType` are unchanged and remain for per-row arrays, non-procedure
111
+ gates, and compound controls.
112
+
113
+ - f93ee7a: Fix a class of 403s where team-scoped managers were blocked from endpoints they
114
+ needed. A repo-wide audit of every `instanceAccess: { global: true }` procedure
115
+ found more instances of the same bug behind the health-check editor fix: an
116
+ endpoint on a team-scopable resource type, gated so only the GLOBAL access rule
117
+ (never a team grant) authorizes it.
118
+
119
+ Automation: the editor utilities and catalogs (`validateDefinition`,
120
+ `listTriggers`, `listActions`, `listArtifactTypes`, `listAutomationGroups`,
121
+ `listAutomationTemplates`, `renderTemplate`, `testScript`) now use `typeScoped`
122
+ so a team-scoped automation manager can author without the global rule. The run
123
+ endpoints (`listRuns`, `getRun`, `cancelRun`, `getRunScopeForReplay`) are scoped
124
+ to their parent automation via `parentScope` on `automationId`; `getRun`,
125
+ `cancelRun`, and `getRunScopeForReplay` now take the owning `automationId`
126
+ (always available in the run URL/editor) and the handler filters the run fetch by
127
+ it, so a run id cannot be paired with a foreign automation the caller happens to
128
+ hold a grant on. The two migration-admin endpoints stay `global: true` (genuine
129
+ platform-admin actions).
130
+
131
+ Health check: `validateConfiguration` (editor deep-validate) and
132
+ `getPlatformNotificationDefaults` (fetched on every assignment-editor mount) move
133
+ to `typeScoped`. The paired WRITE `setPlatformNotificationDefaults` stays
134
+ `global: true` on purpose - it rewrites instance-wide defaults for every team, so
135
+ a single team grant must not authorize it. Because that write stays global-only,
136
+ the assignment editor's "Notification defaults" button is now gated on the global
137
+ `configuration.manage` rule (`healthcheck-frontend`), so a team-scoped manager no
138
+ longer sees an editor whose Save always 403'd.
139
+
140
+ Anomaly: the anomaly settings panels embedded in the health-check editor
141
+ (`updateAnomalyConfig` / `getAnomalyConfig` and `updateAnomalyAssignmentConfig` /
142
+ `getAnomalyAssignmentConfig`) were authorized against the non-team-scopable
143
+ `anomaly_feed` type (via `global: true` or an `idParam` that could never match a
144
+ team grant), so a team-scoped manager who owns the check/system saw "Save
145
+ Defaults" / "Save Exceptions" buttons whose Save always 403'd. They now
146
+ `parentScope` on the owning health-check configuration (`healthcheck.healthcheck`)
147
+ and catalog system (`catalog.system`) respectively, so managing the check/system
148
+ authorizes reading and editing its anomaly settings. The frontend needed no
149
+ change: those buttons were already disabled for non-managers, and the panels are
150
+ only reachable inside the manager-gated editor. Also, the automation "New
151
+ automation" template picker (`automation-frontend`) gated its page on the bare
152
+ global manage rule; it now uses the create capability, so a team-scoped creator
153
+ (whom the route already reveals the page to) is no longer shown a blocked page.
154
+
155
+ Incident & maintenance: `removeLink` was `global: true` because its input carried
156
+ only the link id. It now takes the owning `incidentId` / `maintenanceId`
157
+ (mirroring `addLink`), authorizes per-instance via `idParam`, and the service
158
+ scopes the delete by that parent id so a link cannot be removed by pairing its id
159
+ with a different incident/maintenance the caller manages. The AI `removeLink`
160
+ tools carry the parent id too.
161
+
162
+ BREAKING CHANGES: `automation.getRun`, `automation.cancelRun`,
163
+ `automation.getRunScopeForReplay`, `incident.removeLink`, and
164
+ `maintenance.removeLink` now require a parent id (`automationId` /
165
+ `incidentId` / `maintenanceId`) in their input. Endpoints previously gated by a
166
+ global rule alone now also accept the owning team's grant; no endpoint became
167
+ more permissive for a user who lacks both the global rule and a relevant team
168
+ grant.
169
+
170
+ Not team-scopable, so intentionally left `global: true` (verified by the audit):
171
+ catalog environments, anomaly config, SLO list/streak/milestone reads and
172
+ health-check history/stats (their read rules are public/default), and every
173
+ hand-rolled HTTP route (global admin/infra or already team-aware).
174
+
175
+ ### Patch Changes
176
+
177
+ - Updated dependencies [f93ee7a]
178
+ - Updated dependencies [f93ee7a]
179
+ - Updated dependencies [f93ee7a]
180
+ - Updated dependencies [f93ee7a]
181
+ - @checkstack/common@0.22.0
182
+ - @checkstack/frontend-api@0.14.0
183
+ - @checkstack/auth-frontend@0.13.0
184
+ - @checkstack/ui@1.25.1
185
+ - @checkstack/catalog-common@2.6.3
186
+ - @checkstack/automation-common@0.10.0
187
+ - @checkstack/auth-common@0.13.0
188
+ - @checkstack/ai-common@0.6.6
189
+ - @checkstack/gitops-frontend@0.7.1
190
+ - @checkstack/integration-common@0.9.8
191
+ - @checkstack/script-packages-frontend@0.4.10
192
+ - @checkstack/secrets-frontend@0.3.9
193
+ - @checkstack/template-engine@0.4.11
194
+ - @checkstack/signal-frontend@0.3.5
195
+
3
196
  ## 0.11.0
4
197
 
5
198
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/automation-frontend",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -16,20 +16,20 @@
16
16
  "lint:code": "eslint . --max-warnings 0"
17
17
  },
18
18
  "dependencies": {
19
- "@checkstack/auth-common": "0.12.2",
20
- "@checkstack/auth-frontend": "0.12.0",
21
- "@checkstack/automation-common": "0.9.2",
22
- "@checkstack/ai-common": "0.6.5",
23
- "@checkstack/catalog-common": "2.6.2",
24
- "@checkstack/common": "0.21.0",
25
- "@checkstack/frontend-api": "0.13.2",
26
- "@checkstack/gitops-frontend": "0.7.0",
27
- "@checkstack/integration-common": "0.9.7",
28
- "@checkstack/script-packages-frontend": "0.4.9",
29
- "@checkstack/secrets-frontend": "0.3.8",
30
- "@checkstack/signal-frontend": "0.3.4",
31
- "@checkstack/template-engine": "0.4.10",
32
- "@checkstack/ui": "1.25.0",
19
+ "@checkstack/auth-common": "0.13.0",
20
+ "@checkstack/auth-frontend": "0.13.1",
21
+ "@checkstack/automation-common": "0.10.0",
22
+ "@checkstack/ai-common": "0.6.6",
23
+ "@checkstack/catalog-common": "2.7.0",
24
+ "@checkstack/common": "0.22.0",
25
+ "@checkstack/frontend-api": "0.14.1",
26
+ "@checkstack/gitops-frontend": "0.7.2",
27
+ "@checkstack/integration-common": "0.9.8",
28
+ "@checkstack/script-packages-frontend": "0.4.11",
29
+ "@checkstack/secrets-frontend": "0.3.10",
30
+ "@checkstack/signal-frontend": "0.3.5",
31
+ "@checkstack/template-engine": "0.4.11",
32
+ "@checkstack/ui": "1.26.0",
33
33
  "@dnd-kit/core": "^6.3.1",
34
34
  "@dnd-kit/sortable": "^8.0.0",
35
35
  "@dnd-kit/utilities": "^3.2.2",
@@ -43,6 +43,6 @@
43
43
  "typescript": "^5.0.0",
44
44
  "@types/react": "^19.0.0",
45
45
  "@checkstack/tsconfig": "0.0.7",
46
- "@checkstack/scripts": "0.7.2"
46
+ "@checkstack/scripts": "0.7.3"
47
47
  }
48
48
  }
@@ -45,7 +45,7 @@ export const RunReplayPicker: React.FC<RunReplayPickerProps> = ({
45
45
  );
46
46
 
47
47
  const replayQuery = client.getRunScopeForReplay.useQuery(
48
- { runId: selectedRunId ?? "" },
48
+ { runId: selectedRunId ?? "", automationId },
49
49
  { enabled: Boolean(selectedRunId), gcTime: 0 },
50
50
  );
51
51
 
package/src/index.tsx CHANGED
@@ -51,6 +51,9 @@ export default createFrontendPlugin({
51
51
  })),
52
52
  title: "Automations",
53
53
  accessRule: automationAccess.read,
54
+ // Team-scoped: a team with any automation grant may reach the list; the
55
+ // backend `listAutomations` post-filters it to their own automations.
56
+ manageCapability: { objectType: automationResourceTypes.automation },
54
57
  nav: { group: "Automation", icon: Workflow },
55
58
  },
56
59
  {
@@ -83,6 +86,9 @@ export default createFrontendPlugin({
83
86
  })),
84
87
  title: "Edit automation",
85
88
  accessRule: automationAccess.read,
89
+ // Team-scoped: a team manager of an automation may reach its edit page
90
+ // (the page + backend enforce the per-instance manage grant).
91
+ manageCapability: { objectType: automationResourceTypes.automation },
86
92
  },
87
93
  {
88
94
  route: automationRoutes.routes.runs,
@@ -17,7 +17,6 @@ import {
17
17
  AutomationApi,
18
18
  automationAccess,
19
19
  automationRoutes,
20
- automationResourceTypes,
21
20
  type AutomationDefinition,
22
21
  } from "@checkstack/automation-common";
23
22
  import {
@@ -116,9 +115,14 @@ const AutomationEditContent: React.FC = () => {
116
115
  const toast = useToast();
117
116
  const navigate = useNavigate();
118
117
 
119
- const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
120
- automationAccess.read,
121
- );
118
+ // Surface gate: the caller may reach this create/edit page when they hold the
119
+ // GLOBAL read rule OR any team-derived grant on the automation type
120
+ // (create/manage). A team-scoped creator/manager holds no global rule but must
121
+ // still open the full flow; the mutation controls below stay gated on the
122
+ // per-instance manage grant (`canManageThisAutomation`) and the create
123
+ // capability (`canCreate`), and the backend enforces `create`/`idParam`.
124
+ const { allowed: canAccessSurface, loading: accessLoading } =
125
+ accessApi.useSurfaceAccess(AutomationApi.contract.listAutomations);
122
126
  // allowGlobal: the GLOBAL manage rule means the caller may create a resource
123
127
  // not scoped to any team. This feeds the TeamOwnershipPicker only.
124
128
  const { allowed: hasGlobalManageAccess } = accessApi.useAccess(
@@ -126,21 +130,10 @@ const AutomationEditContent: React.FC = () => {
126
130
  );
127
131
  const allowGlobal = hasGlobalManageAccess;
128
132
 
129
- // Create/page gate (new automations): global manage rule OR team-derived
130
- // create capability.
131
- const { allowed: canCreate } = accessApi.useCanCreate({
132
- accessRule: automationAccess.manage,
133
- objectType: automationResourceTypes.automation,
134
- });
135
- // Per-resource manage gate (existing automations): grants to global-manage
136
- // holders AND to team-scoped users with a per-object grant on THIS id.
137
- const { canAccess } = accessApi.useResourceAccess({
138
- accessRule: automationAccess.manage,
139
- objectType: automationResourceTypes.automation,
140
- resourceIds: !isNew && automationId ? [automationId] : [],
141
- });
142
- const canManageThisAutomation =
143
- !isNew && automationId ? canAccess(automationId) : false;
133
+ // Create + per-instance manage gates are FUSED into the create/update
134
+ // mutations below (`useGatedMutation`), so the controls that call `mutate`
135
+ // read their own authorization from the same object - the gate can't drift
136
+ // from the call. See `createMutation` / `updateMutation`.
144
137
 
145
138
  // GitOps provenance lock: when this automation is declaratively managed,
146
139
  // disable manual edits + show a banner. `entityId` is the automation id
@@ -151,10 +144,8 @@ const AutomationEditContent: React.FC = () => {
151
144
  entityId: isNew ? undefined : automationId,
152
145
  });
153
146
 
154
- // Effective edit permission: RLAC manage (create capability for new, per-
155
- // resource access for existing) AND not GitOps-locked.
156
- const canManage =
157
- (isNew ? canCreate : canManageThisAutomation) && !isLocked;
147
+ // `canManage` is derived from the fused mutation gates below (create for new,
148
+ // update-idParam for existing) AND the GitOps lock - see after the mutations.
158
149
 
159
150
  const loadQuery = client.getAutomation.useQuery(
160
151
  { id: automationId ?? "" },
@@ -285,6 +276,9 @@ const AutomationEditContent: React.FC = () => {
285
276
  setTab(next);
286
277
  };
287
278
 
279
+ // Not gated: a sandboxed validation dry-run (typeScoped), not a resource
280
+ // write - nothing to fuse a per-instance verdict onto.
281
+ // eslint-disable-next-line checkstack/prefer-gated-mutation -- validation dry-run, not a gated write
288
282
  const validateMutation = client.validateDefinition.useMutation({
289
283
  onSuccess: (result) => {
290
284
  setValidationErrors(result.valid ? [] : result.errors);
@@ -296,6 +290,7 @@ const AutomationEditContent: React.FC = () => {
296
290
  // so its constant background runs don't flicker the Save button's
297
291
  // pending state. `mutateAsync` is stable across renders.
298
292
  const { mutateAsync: runLiveValidation } =
293
+ // eslint-disable-next-line checkstack/prefer-gated-mutation -- validation dry-run, not a gated write
299
294
  client.validateDefinition.useMutation();
300
295
 
301
296
  // Re-validate (debounced) on every edit in either tab, so invalid
@@ -335,7 +330,9 @@ const AutomationEditContent: React.FC = () => {
335
330
  return () => clearTimeout(handle);
336
331
  }, [tab, yamlText, definition, runLiveValidation]);
337
332
 
338
- const createMutation = client.createAutomation.useMutation({
333
+ // Gate-fused create: `createMutation.allowed` is the create capability derived
334
+ // from `createAutomation`'s own contract - you cannot get `mutate` without it.
335
+ const createMutation = client.createAutomation.useGatedMutation({
339
336
  onSuccess: (data) => {
340
337
  // Saved: clear the dirty flag, then redirect on the next tick so the
341
338
  // unsaved-changes guard has committed `isDirty = false` and doesn't
@@ -358,7 +355,11 @@ const AutomationEditContent: React.FC = () => {
358
355
  },
359
356
  });
360
357
 
361
- const updateMutation = client.updateAutomation.useMutation({
358
+ // Gate-fused update: `updateMutation.allowed` is the per-instance manage grant
359
+ // on THIS automation, derived from `updateAutomation`'s `idParam` contract and
360
+ // the id passed as `gateInput` - the same id `mutate` will send.
361
+ const updateMutation = client.updateAutomation.useGatedMutation({
362
+ gateInput: !isNew && automationId ? { id: automationId } : undefined,
362
363
  onSuccess: (data) => {
363
364
  setIsDirty(false);
364
365
  toast.success(`Saved ${data.name}`);
@@ -366,6 +367,14 @@ const AutomationEditContent: React.FC = () => {
366
367
  onError: (error) => toastError(toast, "Failed to save automation", error),
367
368
  });
368
369
 
370
+ // Effective edit permission, derived from the FUSED mutation gates (create for
371
+ // new, per-instance update for existing) AND not GitOps-locked.
372
+ const canManage =
373
+ (isNew ? createMutation.allowed : updateMutation.allowed) && !isLocked;
374
+
375
+ // Compound-gated: the "Run now" control is rendered only under `canManage`
376
+ // (itself the fused update gate), so its enablement is already contract-derived.
377
+ // eslint-disable-next-line checkstack/prefer-gated-mutation -- compound-gated by canManage (fused from updateAutomation)
369
378
  const manualRunMutation = client.manualRun.useMutation({
370
379
  onSuccess: (data) => {
371
380
  toast.success(`Manual run queued`);
@@ -539,7 +548,7 @@ const AutomationEditContent: React.FC = () => {
539
548
  subtitle={isNew ? "Wire a trigger to one or more actions" : undefined}
540
549
  icon={Workflow}
541
550
  loading={accessLoading || (!isNew && loadQuery.isLoading)}
542
- allowed={canRead && (isNew ? canManage : true)}
551
+ allowed={canAccessSurface && (isNew ? canManage : true)}
543
552
  actions={
544
553
  <div className="flex items-center gap-2">
545
554
  <Link to={resolveRoute(automationRoutes.routes.list)}>
@@ -78,15 +78,20 @@ const AutomationListContent: React.FC = () => {
78
78
  const toast = useToast();
79
79
  const navigate = useNavigate();
80
80
 
81
- const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
82
- automationAccess.read,
83
- );
81
+ // Surface gate: the GLOBAL read rule OR any team-derived grant on the
82
+ // automation type. A team-scoped creator/manager holds no global rule but must
83
+ // still reach the list (the backend `listAutomations` post-filters to their
84
+ // own automations via `listKey`), so they can find and open their automation.
85
+ const { allowed: canAccessSurface, loading: accessLoading } =
86
+ accessApi.useCanAccessType({
87
+ accessRule: automationAccess.read,
88
+ objectType: automationResourceTypes.automation,
89
+ });
84
90
  // Create/page gate: whether the user may create a new automation at all
85
91
  // (global manage rule OR team-derived create capability).
86
- const { allowed: canCreate } = accessApi.useCanCreate({
87
- accessRule: automationAccess.manage,
88
- objectType: automationResourceTypes.automation,
89
- });
92
+ const { allowed: canCreate } = accessApi.useProcedureAccess(
93
+ AutomationApi.contract.createAutomation,
94
+ );
90
95
 
91
96
  const [statusFilter, setStatusFilter] = React.useState<
92
97
  "all" | "enabled" | "disabled"
@@ -123,6 +128,11 @@ const AutomationListContent: React.FC = () => {
123
128
  // 2. onError rolls back from the snapshot, then surfaces a toast.
124
129
  // 3. onSettled invalidates so server truth settles in either branch.
125
130
  // 4. No success toast — the visible switch flip IS the feedback.
131
+ // Per-ROW mutation: one instance dispatched with each row's id, and every
132
+ // control is gated by `canAccess(automation.id)` below. There is no single id
133
+ // to fuse a gate onto, so this stays raw (the array-gated exception the rule
134
+ // documents).
135
+ // eslint-disable-next-line checkstack/prefer-gated-mutation -- per-row, gated via useResourceAccess().canAccess(id)
126
136
  const toggleMutation = client.toggleAutomation.useMutation<{
127
137
  previous: AutomationsQueryData | undefined;
128
138
  }>({
@@ -153,6 +163,10 @@ const AutomationListContent: React.FC = () => {
153
163
  },
154
164
  });
155
165
 
166
+ // Per-ROW mutation: dispatched with the row's id from the delete dialog, and
167
+ // every delete control is gated by `canAccess(automation.id)` below. No single
168
+ // id to fuse onto (the array-gated exception the rule documents).
169
+ // eslint-disable-next-line checkstack/prefer-gated-mutation -- per-row, gated via useResourceAccess().canAccess(id)
156
170
  const deleteMutation = client.deleteAutomation.useMutation({
157
171
  onSuccess: () => {
158
172
  toast.success("Automation deleted");
@@ -440,7 +454,7 @@ const AutomationListContent: React.FC = () => {
440
454
  subtitle="Trigger-driven workflows that react to platform events"
441
455
  icon={Workflow}
442
456
  loading={accessLoading}
443
- allowed={canRead}
457
+ allowed={canAccessSurface}
444
458
  actions={
445
459
  <div className="flex items-center gap-2">
446
460
  <Link to={resolveRoute(automationRoutes.routes.playground)}>
@@ -9,7 +9,6 @@ import {
9
9
  } from "@checkstack/frontend-api";
10
10
  import {
11
11
  AutomationApi,
12
- automationAccess,
13
12
  automationRoutes,
14
13
  type AutomationTemplate,
15
14
  } from "@checkstack/automation-common";
@@ -35,9 +34,13 @@ const AutomationTemplatePickerContent: React.FC = () => {
35
34
  const accessApi = useApi(accessApiRef);
36
35
  const navigate = useNavigate();
37
36
 
38
- const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
39
- automationAccess.manage,
40
- );
37
+ // The "New automation" picker is a create surface. Gate on the create
38
+ // capability DERIVED from the create procedure's contract (its `create`
39
+ // instanceAccess): global manage rule OR a team-derived create/manage grant,
40
+ // so a team-scoped automation creator - whom the route's `manageCapability`
41
+ // already reveals this page to - is not blocked by a bare global-rule check.
42
+ const { allowed: canCreate, loading: accessLoading } =
43
+ accessApi.useProcedureAccess(AutomationApi.contract.createAutomation);
41
44
 
42
45
  const query = client.listAutomationTemplates.useQuery();
43
46
 
@@ -68,7 +71,7 @@ const AutomationTemplatePickerContent: React.FC = () => {
68
71
  subtitle="Start from a curated example or build your own from scratch"
69
72
  icon={Workflow}
70
73
  loading={accessLoading}
71
- allowed={canManage}
74
+ allowed={canCreate}
72
75
  >
73
76
  <div className="space-y-6">
74
77
  <div className="relative flex flex-col gap-3 overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] sm:flex-row sm:items-center sm:justify-between">
@@ -115,12 +115,11 @@ const RunDetailContent: React.FC = () => {
115
115
  const { allowed, loading: accessLoading } = accessApi.useAccess(
116
116
  automationAccess.read,
117
117
  );
118
- const { allowed: canManage } = accessApi.useAccess(automationAccess.manage);
119
118
 
120
119
  const query = client.getRun.useQuery(
121
- { id: runId ?? "" },
120
+ { id: runId ?? "", automationId: automationId ?? "" },
122
121
  {
123
- enabled: Boolean(runId),
122
+ enabled: Boolean(runId) && Boolean(automationId),
124
123
  // Live runs only: poll every 2s while the run is `running`/`waiting` so a
125
124
  // user watching an execution sees steps progress without a manual reload.
126
125
  // Returning `false` on a terminal status stops the polling entirely.
@@ -137,7 +136,14 @@ const RunDetailContent: React.FC = () => {
137
136
  query.data?.run.status === "running" ||
138
137
  query.data?.run.status === "waiting";
139
138
 
140
- const cancelMutation = client.cancelRun.useMutation();
139
+ // Fuse the cancel gate onto the mutation: `cancelRun` is `parentScope`d on the
140
+ // owning automation (MANAGE), so the verdict derives from THIS page's
141
+ // `automationId` - a team-scoped manager (grant on this automation, no global
142
+ // rule) can cancel their own run, which a bare `useAccess(manage)` on the
143
+ // global rule would wrongly hide.
144
+ const cancelMutation = client.cancelRun.useGatedMutation({
145
+ gateInput: automationId ? { automationId } : undefined,
146
+ });
141
147
 
142
148
  if (!automationId || !runId) {
143
149
  return (
@@ -178,14 +184,16 @@ const RunDetailContent: React.FC = () => {
178
184
  All runs
179
185
  </Button>
180
186
  </Link>
181
- {canManage &&
187
+ {cancelMutation.allowed &&
182
188
  query.data &&
183
189
  (query.data.run.status === "running" ||
184
190
  query.data.run.status === "waiting") && (
185
191
  <Button
186
192
  variant="destructive"
187
193
  size="sm"
188
- onClick={() => cancelMutation.mutate({ id: runId })}
194
+ onClick={() =>
195
+ cancelMutation.mutate({ id: runId, automationId })
196
+ }
189
197
  disabled={cancelMutation.isPending}
190
198
  >
191
199
  Cancel run
@@ -74,6 +74,11 @@ const TemplatePlaygroundContent: React.FC = () => {
74
74
  | null
75
75
  >(null);
76
76
 
77
+ // Stateless `typeScoped` render utility (no automation id in input): the gate
78
+ // is "any automation grant", already enforced at the page surface, and there
79
+ // is no resource instance to fuse a per-call gate onto. Stays raw (the
80
+ // utility/no-instance exception the rule documents).
81
+ // eslint-disable-next-line checkstack/prefer-gated-mutation -- typeScoped utility, no resource id to gate on; surface-gated
77
82
  const renderMutation = client.renderTemplate.useMutation({
78
83
  onSuccess: (data) => {
79
84
  if (data.success) {