bopodev-api 0.1.28 → 0.1.29

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.
Files changed (42) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -69
  3. package/src/lib/run-artifact-paths.ts +8 -0
  4. package/src/middleware/cors-config.ts +36 -0
  5. package/src/middleware/request-actor.ts +10 -16
  6. package/src/middleware/request-id.ts +9 -0
  7. package/src/middleware/request-logging.ts +24 -0
  8. package/src/routes/agents.ts +3 -9
  9. package/src/routes/companies.ts +18 -1
  10. package/src/routes/goals.ts +7 -13
  11. package/src/routes/governance.ts +2 -5
  12. package/src/routes/heartbeats.ts +7 -25
  13. package/src/routes/issues.ts +62 -120
  14. package/src/routes/observability.ts +6 -1
  15. package/src/routes/plugins.ts +5 -17
  16. package/src/routes/projects.ts +7 -25
  17. package/src/routes/templates.ts +6 -21
  18. package/src/scripts/onboard-seed.ts +5 -7
  19. package/src/server.ts +33 -292
  20. package/src/services/company-export-service.ts +63 -0
  21. package/src/services/governance-service.ts +4 -1
  22. package/src/services/heartbeat-service/active-runs.ts +15 -0
  23. package/src/services/heartbeat-service/budget-override.ts +46 -0
  24. package/src/services/heartbeat-service/claims.ts +61 -0
  25. package/src/services/heartbeat-service/cron.ts +58 -0
  26. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  27. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  28. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +183 -633
  29. package/src/services/heartbeat-service/index.ts +5 -0
  30. package/src/services/heartbeat-service/stop.ts +90 -0
  31. package/src/services/heartbeat-service/sweep.ts +145 -0
  32. package/src/services/heartbeat-service/types.ts +65 -0
  33. package/src/services/memory-file-service.ts +10 -2
  34. package/src/shutdown/graceful-shutdown.ts +77 -0
  35. package/src/startup/database.ts +41 -0
  36. package/src/startup/deployment-validation.ts +37 -0
  37. package/src/startup/env.ts +17 -0
  38. package/src/startup/runtime-health.ts +128 -0
  39. package/src/startup/scheduler-config.ts +39 -0
  40. package/src/types/express.d.ts +13 -0
  41. package/src/types/request-actor.ts +6 -0
  42. package/src/validation/issue-routes.ts +79 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,9 +17,9 @@
17
17
  "nanoid": "^5.1.5",
18
18
  "ws": "^8.19.0",
19
19
  "zod": "^4.1.5",
20
- "bopodev-agent-sdk": "0.1.28",
21
- "bopodev-db": "0.1.28",
22
- "bopodev-contracts": "0.1.28"
20
+ "bopodev-contracts": "0.1.29",
21
+ "bopodev-agent-sdk": "0.1.29",
22
+ "bopodev-db": "0.1.29"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
package/src/app.ts CHANGED
@@ -1,8 +1,6 @@
1
- import cors from "cors";
2
1
  import express from "express";
3
2
  import type { NextFunction, Request, Response } from "express";
4
- import { RepositoryValidationError, sql } from "bopodev-db";
5
- import { nanoid } from "nanoid";
3
+ import { pingDatabase, RepositoryValidationError } from "bopodev-db";
6
4
  import type { AppContext } from "./context";
7
5
  import { createAgentsRouter } from "./routes/agents";
8
6
  import { createAuthRouter } from "./routes/auth";
@@ -17,6 +15,9 @@ import { createProjectsRouter } from "./routes/projects";
17
15
  import { createPluginsRouter } from "./routes/plugins";
18
16
  import { createTemplatesRouter } from "./routes/templates";
19
17
  import { sendError } from "./http";
18
+ import { createCorsMiddleware } from "./middleware/cors-config";
19
+ import { attachCrudRequestLogging } from "./middleware/request-logging";
20
+ import { attachRequestId } from "./middleware/request-id";
20
21
  import { attachRequestActor } from "./middleware/request-actor";
21
22
  import { resolveAllowedOrigins, resolveDeploymentMode } from "./security/deployment-mode";
22
23
 
@@ -24,75 +25,20 @@ export function createApp(ctx: AppContext) {
24
25
  const app = express();
25
26
  const deploymentMode = ctx.deploymentMode ?? resolveDeploymentMode();
26
27
  const allowedOrigins = ctx.allowedOrigins ?? resolveAllowedOrigins(deploymentMode);
27
- app.use(
28
- cors({
29
- origin(origin, callback) {
30
- if (!origin) {
31
- callback(null, true);
32
- return;
33
- }
34
- if (
35
- deploymentMode === "local" &&
36
- (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
37
- ) {
38
- callback(null, true);
39
- return;
40
- }
41
- if (allowedOrigins.includes(origin)) {
42
- callback(null, true);
43
- return;
44
- }
45
- callback(new Error(`CORS origin denied: ${origin}`));
46
- },
47
- credentials: true,
48
- methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
49
- allowedHeaders: [
50
- "content-type",
51
- "x-company-id",
52
- "authorization",
53
- "x-client-trace-id",
54
- "x-bopo-actor-token",
55
- "x-request-id",
56
- "x-bopodev-run-id"
57
- ]
58
- })
59
- );
28
+ app.use(createCorsMiddleware(deploymentMode, allowedOrigins));
60
29
  app.use(express.json());
61
30
  app.use(attachRequestActor);
62
-
63
- app.use((req, res, next) => {
64
- const requestId = req.header("x-request-id")?.trim() || nanoid(14);
65
- req.requestId = requestId;
66
- res.setHeader("x-request-id", requestId);
67
- next();
68
- });
31
+ app.use(attachRequestId);
69
32
  const logApiRequests = process.env.BOPO_LOG_API_REQUESTS !== "0";
70
33
  if (logApiRequests) {
71
- app.use((req, res, next) => {
72
- if (req.path === "/health") {
73
- next();
74
- return;
75
- }
76
- const method = req.method.toUpperCase();
77
- if (!isCrudMethod(method)) {
78
- next();
79
- return;
80
- }
81
- const startedAt = Date.now();
82
- res.on("finish", () => {
83
- const elapsedMs = Date.now() - startedAt;
84
- const timestamp = new Date().toTimeString().slice(0, 8);
85
- process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
86
- });
87
- next();
88
- });
34
+ app.use(attachCrudRequestLogging);
89
35
  }
90
36
 
91
37
  app.get("/health", async (_req, res) => {
92
38
  let dbReady = false;
93
39
  let dbError: string | undefined;
94
40
  try {
95
- await ctx.db.execute(sql`SELECT 1`);
41
+ await pingDatabase(ctx.db);
96
42
  dbReady = true;
97
43
  } catch (error) {
98
44
  dbError = String(error);
@@ -126,18 +72,20 @@ export function createApp(ctx: AppContext) {
126
72
  app.use("/plugins", createPluginsRouter(ctx));
127
73
  app.use("/templates", createTemplatesRouter(ctx));
128
74
 
129
- app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
75
+ app.use((error: unknown, req: Request, res: Response, _next: NextFunction) => {
130
76
  if (error instanceof RepositoryValidationError) {
131
77
  return sendError(res, error.message, 422);
132
78
  }
133
- // eslint-disable-next-line no-console
134
- console.error(error);
79
+ const requestId = req.requestId;
80
+ if (requestId) {
81
+ // eslint-disable-next-line no-console
82
+ console.error(`[request ${requestId}]`, error);
83
+ } else {
84
+ // eslint-disable-next-line no-console
85
+ console.error(error);
86
+ }
135
87
  return sendError(res, "Internal server error", 500);
136
88
  });
137
89
 
138
90
  return app;
139
91
  }
140
-
141
- function isCrudMethod(method: string) {
142
- return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
143
- }
@@ -70,6 +70,14 @@ function normalizeWorkspaceRelativeArtifactPath(value: string) {
70
70
  }
71
71
  const workspaceScopedMatch = normalized.match(/(?:^|\/)workspace\/([^/]+)\/(.+)$/);
72
72
  if (!workspaceScopedMatch) {
73
+ const projectAgentsMatch = normalized.match(/(?:^|\/)projects\/agents\/([^/]+)\/operating\/(.+)$/);
74
+ if (projectAgentsMatch) {
75
+ const [, agentId, suffix] = projectAgentsMatch;
76
+ if (!agentId || !suffix) {
77
+ return "";
78
+ }
79
+ return `agents/${agentId}/operating/${suffix}`;
80
+ }
73
81
  return normalized;
74
82
  }
75
83
  const scopedRelativePath = workspaceScopedMatch[2];
@@ -0,0 +1,36 @@
1
+ import cors from "cors";
2
+ import type { DeploymentMode } from "../security/deployment-mode";
3
+
4
+ export function createCorsMiddleware(deploymentMode: DeploymentMode, allowedOrigins: string[]) {
5
+ return cors({
6
+ origin(origin, callback) {
7
+ if (!origin) {
8
+ callback(null, true);
9
+ return;
10
+ }
11
+ if (
12
+ deploymentMode === "local" &&
13
+ (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
14
+ ) {
15
+ callback(null, true);
16
+ return;
17
+ }
18
+ if (allowedOrigins.includes(origin)) {
19
+ callback(null, true);
20
+ return;
21
+ }
22
+ callback(new Error(`CORS origin denied: ${origin}`));
23
+ },
24
+ credentials: true,
25
+ methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
26
+ allowedHeaders: [
27
+ "content-type",
28
+ "x-company-id",
29
+ "authorization",
30
+ "x-client-trace-id",
31
+ "x-bopo-actor-token",
32
+ "x-request-id",
33
+ "x-bopodev-run-id"
34
+ ]
35
+ });
36
+ }
@@ -3,23 +3,9 @@ import { RequestActorHeadersSchema } from "bopodev-contracts";
3
3
  import { sendError } from "../http";
4
4
  import { verifyActorToken } from "../security/actor-token";
5
5
  import { isAuthenticatedMode, resolveDeploymentMode } from "../security/deployment-mode";
6
+ import type { RequestActor } from "../types/request-actor";
6
7
 
7
- export type RequestActor = {
8
- type: "board" | "member" | "agent";
9
- id: string;
10
- companyIds: string[] | null;
11
- permissions: string[];
12
- };
13
-
14
- declare global {
15
- namespace Express {
16
- interface Request {
17
- actor?: RequestActor;
18
- companyId?: string;
19
- requestId?: string;
20
- }
21
- }
22
- }
8
+ export type { RequestActor };
23
9
 
24
10
  export function attachRequestActor(req: Request, res: Response, next: NextFunction) {
25
11
  const deploymentMode = resolveDeploymentMode();
@@ -136,6 +122,14 @@ export function requirePermission(permission: string) {
136
122
  };
137
123
  }
138
124
 
125
+ export function enforcePermission(req: Request, res: Response, permission: string): boolean {
126
+ let allowed = false;
127
+ requirePermission(permission)(req, res, () => {
128
+ allowed = true;
129
+ });
130
+ return allowed;
131
+ }
132
+
139
133
  export function requireBoardRole(req: Request, res: Response, next: NextFunction) {
140
134
  if (req.actor?.type !== "board") {
141
135
  return sendError(res, "Board role required.", 403);
@@ -0,0 +1,9 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+ import { nanoid } from "nanoid";
3
+
4
+ export function attachRequestId(req: Request, res: Response, next: NextFunction) {
5
+ const requestId = req.header("x-request-id")?.trim() || nanoid(14);
6
+ req.requestId = requestId;
7
+ res.setHeader("x-request-id", requestId);
8
+ next();
9
+ }
@@ -0,0 +1,24 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+
3
+ function isCrudMethod(method: string) {
4
+ return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
5
+ }
6
+
7
+ export function attachCrudRequestLogging(req: Request, res: Response, next: NextFunction) {
8
+ if (req.path === "/health") {
9
+ next();
10
+ return;
11
+ }
12
+ const method = req.method.toUpperCase();
13
+ if (!isCrudMethod(method)) {
14
+ next();
15
+ return;
16
+ }
17
+ const startedAt = Date.now();
18
+ res.on("finish", () => {
19
+ const elapsedMs = Date.now() - startedAt;
20
+ const timestamp = new Date().toTimeString().slice(0, 8);
21
+ process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
22
+ });
23
+ next();
24
+ }
@@ -37,7 +37,7 @@ import { resolveHiringDelegate } from "../lib/hiring-delegate";
37
37
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
38
38
  import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
39
39
  import { requireCompanyScope } from "../middleware/company-scope";
40
- import { requireBoardRole, requirePermission } from "../middleware/request-actor";
40
+ import { enforcePermission, requireBoardRole, requirePermission } from "../middleware/request-actor";
41
41
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
42
42
  import { publishAttentionSnapshot } from "../realtime/attention";
43
43
  import {
@@ -629,10 +629,7 @@ export function createAgentsRouter(ctx: AppContext) {
629
629
  });
630
630
 
631
631
  router.post("/:agentId/pause", async (req, res) => {
632
- requirePermission("agents:lifecycle")(req, res, () => {});
633
- if (res.headersSent) {
634
- return;
635
- }
632
+ if (!enforcePermission(req, res, "agents:lifecycle")) return;
636
633
  const agent = await updateAgent(ctx.db, {
637
634
  companyId: req.companyId!,
638
635
  id: req.params.agentId,
@@ -656,10 +653,7 @@ export function createAgentsRouter(ctx: AppContext) {
656
653
  });
657
654
 
658
655
  router.post("/:agentId/resume", async (req, res) => {
659
- requirePermission("agents:lifecycle")(req, res, () => {});
660
- if (res.headersSent) {
661
- return;
662
- }
656
+ if (!enforcePermission(req, res, "agents:lifecycle")) return;
663
657
  const agent = await updateAgent(ctx.db, {
664
658
  companyId: req.companyId!,
665
659
  id: req.params.agentId,
@@ -10,6 +10,7 @@ import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigTo
10
10
  import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
11
11
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
12
12
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
13
+ import { buildCompanyPortabilityExport } from "../services/company-export-service";
13
14
  import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
14
15
  import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
15
16
  import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
@@ -21,7 +22,7 @@ const createCompanySchema = z.object({
21
22
  name: z.string().min(1),
22
23
  mission: z.string().optional(),
23
24
  providerType: z
24
- .enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "shell"])
25
+ .enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "http", "shell"])
25
26
  .optional(),
26
27
  runtimeModel: z.string().optional()
27
28
  });
@@ -53,6 +54,21 @@ export function createCompaniesRouter(ctx: AppContext) {
53
54
  );
54
55
  });
55
56
 
57
+ router.get("/:companyId/export", async (req, res) => {
58
+ const companyId = readCompanyIdParam(req);
59
+ if (!companyId) {
60
+ return sendError(res, "Missing company id.", 422);
61
+ }
62
+ if (!canAccessCompany(req, companyId)) {
63
+ return sendError(res, "Actor does not have access to this company.", 403);
64
+ }
65
+ const payload = await buildCompanyPortabilityExport(ctx.db, companyId);
66
+ if (!payload) {
67
+ return sendError(res, "Company not found.", 404);
68
+ }
69
+ return sendOk(res, payload);
70
+ });
71
+
56
72
  router.post("/", requireBoardRole, async (req, res) => {
57
73
  const parsed = createCompanySchema.safeParse(req.body);
58
74
  if (!parsed.success) {
@@ -171,6 +187,7 @@ function parseAgentProvider(value: unknown) {
171
187
  value === "opencode" ||
172
188
  value === "openai_api" ||
173
189
  value === "anthropic_api" ||
190
+ value === "http" ||
174
191
  value === "shell"
175
192
  ) {
176
193
  return value;
@@ -5,7 +5,7 @@ import { appendAuditEvent, createApprovalRequest, createGoal, deleteGoal, getApp
5
5
  import type { AppContext } from "../context";
6
6
  import { sendError, sendOk, sendOkValidated } from "../http";
7
7
  import { requireCompanyScope } from "../middleware/company-scope";
8
- import { requirePermission } from "../middleware/request-actor";
8
+ import { enforcePermission } from "../middleware/request-actor";
9
9
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
10
10
  import { publishAttentionSnapshot } from "../realtime/attention";
11
11
  import { isApprovalRequired } from "../services/governance-service";
@@ -13,6 +13,7 @@ import { isApprovalRequired } from "../services/governance-service";
13
13
  const createGoalSchema = z.object({
14
14
  projectId: z.string().optional(),
15
15
  parentGoalId: z.string().optional(),
16
+ ownerAgentId: z.string().optional(),
16
17
  level: z.enum(["company", "project", "agent"]),
17
18
  title: z.string().min(1),
18
19
  description: z.string().optional(),
@@ -23,6 +24,7 @@ const updateGoalSchema = z
23
24
  .object({
24
25
  projectId: z.string().nullable().optional(),
25
26
  parentGoalId: z.string().nullable().optional(),
27
+ ownerAgentId: z.string().nullable().optional(),
26
28
  level: z.enum(["company", "project", "agent"]).optional(),
27
29
  title: z.string().min(1).optional(),
28
30
  description: z.string().nullable().optional(),
@@ -44,10 +46,7 @@ export function createGoalsRouter(ctx: AppContext) {
44
46
  });
45
47
 
46
48
  router.post("/", async (req, res) => {
47
- requirePermission("goals:write")(req, res, () => {});
48
- if (res.headersSent) {
49
- return;
50
- }
49
+ if (!enforcePermission(req, res, "goals:write")) return;
51
50
  const parsed = createGoalSchema.safeParse(req.body);
52
51
  if (!parsed.success) {
53
52
  return sendError(res, parsed.error.message, 422);
@@ -76,6 +75,7 @@ export function createGoalsRouter(ctx: AppContext) {
76
75
  companyId: req.companyId!,
77
76
  projectId: parsed.data.projectId,
78
77
  parentGoalId: parsed.data.parentGoalId,
78
+ ownerAgentId: parsed.data.ownerAgentId,
79
79
  level: parsed.data.level,
80
80
  title: parsed.data.title,
81
81
  description: parsed.data.description
@@ -92,10 +92,7 @@ export function createGoalsRouter(ctx: AppContext) {
92
92
  });
93
93
 
94
94
  router.put("/:goalId", async (req, res) => {
95
- requirePermission("goals:write")(req, res, () => {});
96
- if (res.headersSent) {
97
- return;
98
- }
95
+ if (!enforcePermission(req, res, "goals:write")) return;
99
96
  const parsed = updateGoalSchema.safeParse(req.body);
100
97
  if (!parsed.success) {
101
98
  return sendError(res, parsed.error.message, 422);
@@ -118,10 +115,7 @@ export function createGoalsRouter(ctx: AppContext) {
118
115
  });
119
116
 
120
117
  router.delete("/:goalId", async (req, res) => {
121
- requirePermission("goals:write")(req, res, () => {});
122
- if (res.headersSent) {
123
- return;
124
- }
118
+ if (!enforcePermission(req, res, "goals:write")) return;
125
119
  const deleted = await deleteGoal(ctx.db, req.companyId!, req.params.goalId);
126
120
  if (!deleted) {
127
121
  return sendError(res, "Goal not found.", 404);
@@ -14,7 +14,7 @@ import {
14
14
  import type { AppContext } from "../context";
15
15
  import { sendError, sendOk } from "../http";
16
16
  import { requireCompanyScope } from "../middleware/company-scope";
17
- import { requirePermission } from "../middleware/request-actor";
17
+ import { enforcePermission } from "../middleware/request-actor";
18
18
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
19
19
  import { publishAttentionSnapshot } from "../realtime/attention";
20
20
  import {
@@ -146,10 +146,7 @@ export function createGovernanceRouter(ctx: AppContext) {
146
146
  });
147
147
 
148
148
  router.post("/resolve", async (req, res) => {
149
- requirePermission("governance:resolve")(req, res, () => {});
150
- if (res.headersSent) {
151
- return;
152
- }
149
+ if (!enforcePermission(req, res, "governance:resolve")) return;
153
150
  const parsed = resolveSchema.safeParse(req.body);
154
151
  if (!parsed.success) {
155
152
  return sendError(res, parsed.error.message, 422);
@@ -4,7 +4,7 @@ import { agents, and, eq, heartbeatRuns, listHeartbeatQueueJobs } from "bopodev-
4
4
  import type { AppContext } from "../context";
5
5
  import { sendError, sendOk } from "../http";
6
6
  import { requireCompanyScope } from "../middleware/company-scope";
7
- import { requirePermission } from "../middleware/request-actor";
7
+ import { enforcePermission } from "../middleware/request-actor";
8
8
  import {
9
9
  findPendingProjectBudgetOverrideBlocksForAgent,
10
10
  runHeartbeatSweep,
@@ -30,10 +30,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
30
30
  router.use(requireCompanyScope);
31
31
 
32
32
  router.post("/run-agent", async (req, res) => {
33
- requirePermission("heartbeats:run")(req, res, () => {});
34
- if (res.headersSent) {
35
- return;
36
- }
33
+ if (!enforcePermission(req, res, "heartbeats:run")) return;
37
34
  const parsed = runAgentSchema.safeParse(req.body);
38
35
  if (!parsed.success) {
39
36
  return sendError(res, parsed.error.message, 422);
@@ -84,10 +81,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
84
81
  });
85
82
 
86
83
  router.post("/:runId/stop", async (req, res) => {
87
- requirePermission("heartbeats:run")(req, res, () => {});
88
- if (res.headersSent) {
89
- return;
90
- }
84
+ if (!enforcePermission(req, res, "heartbeats:run")) return;
91
85
  const parsed = runIdParamsSchema.safeParse(req.params);
92
86
  if (!parsed.success) {
93
87
  return sendError(res, parsed.error.message, 422);
@@ -173,10 +167,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
173
167
  }
174
168
 
175
169
  router.post("/:runId/resume", async (req, res) => {
176
- requirePermission("heartbeats:run")(req, res, () => {});
177
- if (res.headersSent) {
178
- return;
179
- }
170
+ if (!enforcePermission(req, res, "heartbeats:run")) return;
180
171
  const parsed = runIdParamsSchema.safeParse(req.params);
181
172
  if (!parsed.success) {
182
173
  return sendError(res, parsed.error.message, 422);
@@ -200,10 +191,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
200
191
  });
201
192
 
202
193
  router.post("/:runId/redo", async (req, res) => {
203
- requirePermission("heartbeats:run")(req, res, () => {});
204
- if (res.headersSent) {
205
- return;
206
- }
194
+ if (!enforcePermission(req, res, "heartbeats:run")) return;
207
195
  const parsed = runIdParamsSchema.safeParse(req.params);
208
196
  if (!parsed.success) {
209
197
  return sendError(res, parsed.error.message, 422);
@@ -227,10 +215,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
227
215
  });
228
216
 
229
217
  router.post("/sweep", async (req, res) => {
230
- requirePermission("heartbeats:sweep")(req, res, () => {});
231
- if (res.headersSent) {
232
- return;
233
- }
218
+ if (!enforcePermission(req, res, "heartbeats:sweep")) return;
234
219
  const runIds = await runHeartbeatSweep(ctx.db, req.companyId!, {
235
220
  requestId: req.requestId,
236
221
  realtimeHub: ctx.realtimeHub
@@ -239,10 +224,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
239
224
  });
240
225
 
241
226
  router.get("/queue", async (req, res) => {
242
- requirePermission("heartbeats:run")(req, res, () => {});
243
- if (res.headersSent) {
244
- return;
245
- }
227
+ if (!enforcePermission(req, res, "heartbeats:run")) return;
246
228
  const parsed = queueQuerySchema.safeParse(req.query);
247
229
  if (!parsed.success) {
248
230
  return sendError(res, parsed.error.message, 422);