@unbrained/pm-web 2026.7.11 → 2026.7.13

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 (63) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +26 -0
  3. package/dist/app.d.ts +1 -0
  4. package/dist/app.js +38 -2
  5. package/dist/app.js.map +1 -1
  6. package/dist/auth.d.ts +2 -1
  7. package/dist/auth.js +10 -0
  8. package/dist/auth.js.map +1 -1
  9. package/dist/db.js +17 -1
  10. package/dist/db.js.map +1 -1
  11. package/dist/ical.js.map +1 -1
  12. package/dist/index.d.ts +1 -22
  13. package/dist/index.js +2 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/oidc.d.ts +89 -0
  16. package/dist/oidc.js +305 -0
  17. package/dist/oidc.js.map +1 -0
  18. package/dist/routes/auth.js +3 -15
  19. package/dist/routes/auth.js.map +1 -1
  20. package/dist/routes/github.js +3 -3
  21. package/dist/routes/github.js.map +1 -1
  22. package/dist/routes/oidc.d.ts +4 -0
  23. package/dist/routes/oidc.js +147 -0
  24. package/dist/routes/oidc.js.map +1 -0
  25. package/dist/routes/pm.js +121 -107
  26. package/dist/routes/pm.js.map +1 -1
  27. package/dist/routes/projects.js +9 -2
  28. package/dist/routes/projects.js.map +1 -1
  29. package/dist/server.js +7 -2
  30. package/dist/server.js.map +1 -1
  31. package/dist/services/pm-runner.d.ts +11 -3
  32. package/dist/services/pm-runner.js +167 -54
  33. package/dist/services/pm-runner.js.map +1 -1
  34. package/dist/services/realtime-bus.d.ts +1 -0
  35. package/dist/services/realtime-bus.js +148 -0
  36. package/dist/services/realtime-bus.js.map +1 -0
  37. package/dist/services/sse.d.ts +3 -1
  38. package/dist/services/sse.js +83 -48
  39. package/dist/services/sse.js.map +1 -1
  40. package/manifest.json +1 -1
  41. package/package.json +11 -11
  42. package/public/cookie-consent.js +54 -39
  43. package/public/cookie-settings.html +7 -7
  44. package/public/index.html +2 -0
  45. package/public/legal-notice.html +10 -12
  46. package/public/privacy-policy.html +14 -24
  47. package/public/src/app.js +4 -4
  48. package/public/src/app.js.map +1 -1
  49. package/public/src/app.ts +4 -4
  50. package/public/src/components/toast.js.map +1 -1
  51. package/public/src/cookie-consent.ts +68 -0
  52. package/public/src/sw.ts +443 -0
  53. package/public/src/views/auth.js +22 -0
  54. package/public/src/views/auth.js.map +1 -1
  55. package/public/src/views/auth.ts +20 -0
  56. package/public/src/views/graph.js.map +1 -1
  57. package/public/styles.css +2 -0
  58. package/public/sw.js +320 -256
  59. package/public/terms.html +7 -9
  60. package/public/tsconfig.json +1 -1
  61. package/public/tsconfig.scripts.json +18 -0
  62. package/public/tsconfig.sw.json +18 -0
  63. package/sql/schema.sql +16 -0
package/dist/routes/pm.js CHANGED
@@ -4,10 +4,11 @@ import { ensureGraphExtension, runPm } from "../services/pm-runner.js";
4
4
  import { boardColumns, filterItemsByQuery } from "../board.js";
5
5
  import { buildIcsCalendar } from "../ical.js";
6
6
  import { verifyProjectAccess } from "./projects.js";
7
- import { addSSEClient, broadcastProjectEvent, setupSSEHeaders, broadcastPresence, updateClientView, getProjectPresence } from "../services/sse.js";
7
+ import { addSSEClient, broadcastProjectEvent, setupSSEHeaders, updateClientView, getProjectPresence } from "../services/sse.js";
8
8
  import { v4 as uuidv4 } from "uuid";
9
9
  import neo4j from "neo4j-driver";
10
10
  import { routeParam } from "./route-params.js";
11
+ import { pool } from "../db.js";
11
12
  // Singleton Neo4j driver — reused across sync calls to avoid per-call connection overhead.
12
13
  let _neo4jDriver = null;
13
14
  let _neo4jDriverKey = "";
@@ -29,7 +30,7 @@ const router = Router({ mergeParams: true });
29
30
  router.use(requireAuth);
30
31
  const pendingGraphSyncs = new Map();
31
32
  router.use(async (req, res, next) => {
32
- if (["GET", "HEAD", "OPTIONS"].includes(req.method)) {
33
+ if (["GET", "HEAD", "OPTIONS"].includes(req.method) || (req.method === "PATCH" && req.path.startsWith("/presence/"))) {
33
34
  next();
34
35
  return;
35
36
  }
@@ -232,8 +233,8 @@ async function syncGraphToNeo4j(graph, projectKey) {
232
233
  return { syncedNodes: graph.nodes.length, syncedRelationships: graph.relationships.length };
233
234
  }
234
235
  async function syncProjectGraph(project) {
235
- const extensionGraph = pmGraphExtensionGraphForProject(project);
236
- const graph = extensionGraph.graph ?? fallbackGraphForProject(project.ownerUserId, project.slug);
236
+ const extensionGraph = await pmGraphExtensionGraphForProject(project);
237
+ const graph = extensionGraph.graph ?? await fallbackGraphForProject(project.ownerUserId, project.slug);
237
238
  return syncGraphToNeo4j(graph, graphProjectKey(project));
238
239
  }
239
240
  function scheduleGraphSync(projectId, project, reason) {
@@ -281,8 +282,8 @@ function broadcastDependencyEvent(projectId, kind, data) {
281
282
  function itemsFromListAll(parsed) {
282
283
  return ((parsed?.items) ?? []);
283
284
  }
284
- function fallbackGraphForProject(ownerUserId, slug) {
285
- const itemsResult = runPm({
285
+ async function fallbackGraphForProject(ownerUserId, slug) {
286
+ const itemsResult = await runPm({
286
287
  args: ["list-all"],
287
288
  userId: ownerUserId,
288
289
  slug,
@@ -295,12 +296,12 @@ function fallbackGraphForProject(ownerUserId, slug) {
295
296
  // Avoid N+1 subprocess calls by using only the embedded data.
296
297
  return graphFromItems(items, new Map());
297
298
  }
298
- function pmGraphExtensionGraphForProject(project) {
299
- const provision = ensureGraphExtension(project.ownerUserId, project.slug);
299
+ async function pmGraphExtensionGraphForProject(project) {
300
+ const provision = await ensureGraphExtension(project.ownerUserId, project.slug);
300
301
  if (!provision.ok) {
301
302
  return { error: provision.error };
302
303
  }
303
- const extensionResult = runPm({
304
+ const extensionResult = await runPm({
304
305
  args: ["pm-graph", "export", "--json"],
305
306
  userId: project.ownerUserId,
306
307
  slug: project.slug,
@@ -341,7 +342,7 @@ router.get("/schema", async (req, res) => {
341
342
  res.status(404).json({ error: "Project not found" });
342
343
  return;
343
344
  }
344
- const result = runPm({ args: ["contracts", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
345
+ const result = await runPm({ args: ["contracts", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
345
346
  const contracts = result.ok && result.parsed ? result.parsed : null;
346
347
  const rt = contracts?.["runtime_schema"];
347
348
  res.json({
@@ -375,7 +376,7 @@ router.get("/list", async (req, res) => {
375
376
  args.push("--release", release);
376
377
  if (assignee)
377
378
  args.push("--assignee", assignee);
378
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
379
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
379
380
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
380
381
  });
381
382
  // GET /api/projects/:projectId/pm/list-all
@@ -391,7 +392,7 @@ router.get("/list-all", async (req, res) => {
391
392
  args.push("--type", type);
392
393
  if (limit)
393
394
  args.push("--limit", limit);
394
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
395
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
395
396
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
396
397
  });
397
398
  // GET /api/projects/:projectId/pm/board
@@ -403,12 +404,12 @@ router.get("/board", async (req, res) => {
403
404
  res.status(404).json({ error: "Project not found" });
404
405
  return;
405
406
  }
406
- const contracts = runPm({ args: ["contracts", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
407
+ const contracts = await runPm({ args: ["contracts", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
407
408
  const rt = contracts.ok && contracts.parsed
408
409
  ? contracts.parsed["runtime_schema"]
409
410
  : undefined;
410
411
  const statuses = Array.isArray(rt?.["statuses"]) ? rt["statuses"] : [];
411
- const listed = runPm({ args: ["list-all", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
412
+ const listed = await runPm({ args: ["list-all", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
412
413
  if (!listed.ok) {
413
414
  res.json({ error: listed.stderr, columns: [] });
414
415
  return;
@@ -425,7 +426,7 @@ router.get("/search", async (req, res) => {
425
426
  return;
426
427
  }
427
428
  const query = String(req.query["q"] ?? "");
428
- const listed = runPm({ args: ["list-all", "--json", "--include-body"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
429
+ const listed = await runPm({ args: ["list-all", "--json", "--include-body"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
429
430
  if (!listed.ok) {
430
431
  res.json({ error: listed.stderr, items: [] });
431
432
  return;
@@ -508,7 +509,7 @@ router.post("/create", async (req, res) => {
508
509
  args.push("--outcome", outcome);
509
510
  if (definitionOfReady)
510
511
  args.push("--definition-of-ready", definitionOfReady);
511
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
512
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
512
513
  if (!result.ok) {
513
514
  res.status(400).json({ error: result.stderr || "Failed to create item" });
514
515
  return;
@@ -528,7 +529,7 @@ router.get("/get/:itemId", async (req, res) => {
528
529
  res.status(404).json({ error: "Project not found" });
529
530
  return;
530
531
  }
531
- const result = runPm({
532
+ const result = await runPm({
532
533
  args: ["get", routeParam(req, "itemId")],
533
534
  userId: project.ownerUserId,
534
535
  slug: project.slug,
@@ -575,7 +576,7 @@ router.patch("/update/:itemId", async (req, res) => {
575
576
  // Type can be set but must use --type
576
577
  if (body.type)
577
578
  args.push("--type", body.type);
578
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
579
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
579
580
  if (!result.ok) {
580
581
  res.status(400).json({ error: result.stderr || "Failed to update item" });
581
582
  return;
@@ -600,7 +601,7 @@ router.post("/close/:itemId", async (req, res) => {
600
601
  res.status(400).json({ error: "Close reason is required" });
601
602
  return;
602
603
  }
603
- const result = runPm({
604
+ const result = await runPm({
604
605
  args: ["close", routeParam(req, "itemId"), reason.trim()],
605
606
  userId: project.ownerUserId,
606
607
  slug: project.slug,
@@ -624,7 +625,7 @@ router.delete("/delete/:itemId", async (req, res) => {
624
625
  res.status(404).json({ error: "Project not found" });
625
626
  return;
626
627
  }
627
- const result = runPm({
628
+ const result = await runPm({
628
629
  args: ["delete", routeParam(req, "itemId"), "--yes"],
629
630
  userId: project.ownerUserId,
630
631
  slug: project.slug,
@@ -652,7 +653,7 @@ router.post("/comments/:itemId", async (req, res) => {
652
653
  res.status(400).json({ error: "Comment text is required" });
653
654
  return;
654
655
  }
655
- const result = runPm({
656
+ const result = await runPm({
656
657
  args: ["comments", routeParam(req, "itemId"), text.trim()],
657
658
  userId: project.ownerUserId,
658
659
  slug: project.slug,
@@ -671,7 +672,7 @@ router.get("/comments/:itemId", async (req, res) => {
671
672
  res.status(404).json({ error: "Project not found" });
672
673
  return;
673
674
  }
674
- const result = runPm({
675
+ const result = await runPm({
675
676
  args: ["comments", routeParam(req, "itemId")],
676
677
  userId: project.ownerUserId,
677
678
  slug: project.slug,
@@ -686,7 +687,7 @@ router.get("/notes/:itemId", async (req, res) => {
686
687
  res.status(404).json({ error: "Project not found" });
687
688
  return;
688
689
  }
689
- const result = runPm({
690
+ const result = await runPm({
690
691
  args: ["notes", routeParam(req, "itemId")],
691
692
  userId: project.ownerUserId,
692
693
  slug: project.slug,
@@ -706,7 +707,7 @@ router.post("/notes/:itemId", async (req, res) => {
706
707
  res.status(400).json({ error: "Note text is required" });
707
708
  return;
708
709
  }
709
- const result = runPm({
710
+ const result = await runPm({
710
711
  args: ["notes", routeParam(req, "itemId"), text.trim()],
711
712
  userId: project.ownerUserId,
712
713
  slug: project.slug,
@@ -728,7 +729,7 @@ router.get("/context", async (req, res) => {
728
729
  const { depth } = req.query;
729
730
  const validDepths = ["brief", "standard", "deep"];
730
731
  const resolvedDepth = validDepths.includes(depth) ? depth : "standard";
731
- const result = runPm({
732
+ const result = await runPm({
732
733
  args: ["context", "--depth", resolvedDepth],
733
734
  userId: project.ownerUserId,
734
735
  slug: project.slug,
@@ -747,7 +748,7 @@ router.get("/activity", async (req, res) => {
747
748
  const args = ["activity"];
748
749
  if (limit)
749
750
  args.push("--limit", limit);
750
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
751
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
751
752
  res.json(result.ok ? (result.parsed || {}) : { activity: [] });
752
753
  });
753
754
  // GET /api/projects/:projectId/pm/stats
@@ -757,7 +758,7 @@ router.get("/stats", async (req, res) => {
757
758
  res.status(404).json({ error: "Project not found" });
758
759
  return;
759
760
  }
760
- const result = runPm({
761
+ const result = await runPm({
761
762
  args: ["stats"],
762
763
  userId: project.ownerUserId,
763
764
  slug: project.slug,
@@ -772,7 +773,7 @@ router.get("/aggregate", async (req, res) => {
772
773
  res.status(404).json({ error: "Project not found" });
773
774
  return;
774
775
  }
775
- const result = runPm({
776
+ const result = await runPm({
776
777
  args: ["aggregate"],
777
778
  userId: project.ownerUserId,
778
779
  slug: project.slug,
@@ -794,7 +795,7 @@ router.post("/search", async (req, res) => {
794
795
  }
795
796
  const validModes = ["keyword", "semantic", "hybrid"];
796
797
  const safeMode = validModes.includes(mode || "") ? mode : "hybrid";
797
- const result = runPm({
798
+ const result = await runPm({
798
799
  args: ["search", "--mode", safeMode, ...query.trim().split(/\s+/)],
799
800
  userId: project.ownerUserId,
800
801
  slug: project.slug,
@@ -816,7 +817,7 @@ router.get("/calendar", async (req, res) => {
816
817
  res.status(404).json({ error: "Project not found" });
817
818
  return;
818
819
  }
819
- const result = runPm({
820
+ const result = await runPm({
820
821
  args: ["calendar", "--view", "month"],
821
822
  userId: project.ownerUserId,
822
823
  slug: project.slug,
@@ -835,7 +836,7 @@ router.get("/calendar.ics", async (req, res) => {
835
836
  res.status(404).json({ error: "Project not found" });
836
837
  return;
837
838
  }
838
- const listed = runPm({
839
+ const listed = await runPm({
839
840
  args: ["list-all", "--json"],
840
841
  userId: project.ownerUserId,
841
842
  slug: project.slug,
@@ -873,7 +874,7 @@ router.get("/health", async (req, res) => {
873
874
  res.status(404).json({ error: "Project not found" });
874
875
  return;
875
876
  }
876
- const result = runPm({
877
+ const result = await runPm({
877
878
  args: ["health"],
878
879
  userId: project.ownerUserId,
879
880
  slug: project.slug,
@@ -893,7 +894,7 @@ router.post("/append/:itemId", async (req, res) => {
893
894
  res.status(400).json({ error: "Text is required" });
894
895
  return;
895
896
  }
896
- const result = runPm({
897
+ const result = await runPm({
897
898
  args: ["append", routeParam(req, "itemId"), text.trim()],
898
899
  userId: project.ownerUserId,
899
900
  slug: project.slug,
@@ -913,7 +914,7 @@ router.get("/history/:itemId", async (req, res) => {
913
914
  res.status(404).json({ error: "Project not found" });
914
915
  return;
915
916
  }
916
- const result = runPm({
917
+ const result = await runPm({
917
918
  args: ["history", routeParam(req, "itemId")],
918
919
  userId: project.ownerUserId,
919
920
  slug: project.slug,
@@ -928,7 +929,7 @@ router.get("/deps/:itemId", async (req, res) => {
928
929
  res.status(404).json({ error: "Project not found" });
929
930
  return;
930
931
  }
931
- const result = runPm({
932
+ const result = await runPm({
932
933
  args: ["deps", routeParam(req, "itemId")],
933
934
  userId: project.ownerUserId,
934
935
  slug: project.slug,
@@ -949,7 +950,7 @@ router.post("/deps/:itemId", async (req, res) => {
949
950
  return;
950
951
  }
951
952
  const depRel = normalizeDependencyKind(rel);
952
- const result = runPm({
953
+ const result = await runPm({
953
954
  args: ["update", routeParam(req, "itemId"), "--dep", `id=${targetId.trim()},kind=${depRel}`],
954
955
  userId: project.ownerUserId,
955
956
  slug: project.slug,
@@ -982,7 +983,7 @@ router.delete("/deps/:itemId", async (req, res) => {
982
983
  }
983
984
  const depRel = normalizeDependencyKind(rel || "relates_to");
984
985
  const selector = `id=${targetId.trim()},kind=${depRel}`;
985
- const result = runPm({
986
+ const result = await runPm({
986
987
  args: ["update", routeParam(req, "itemId"), "--dep-remove", selector],
987
988
  userId: project.ownerUserId,
988
989
  slug: project.slug,
@@ -1014,7 +1015,7 @@ router.post("/rel", async (req, res) => {
1014
1015
  return;
1015
1016
  }
1016
1017
  const depRel = normalizeDependencyKind(relType || "relates_to");
1017
- const result = runPm({
1018
+ const result = await runPm({
1018
1019
  args: ["update", from.trim(), "--dep", `id=${to.trim()},kind=${depRel}`],
1019
1020
  userId: project.ownerUserId,
1020
1021
  slug: project.slug,
@@ -1047,7 +1048,7 @@ router.delete("/rel", async (req, res) => {
1047
1048
  }
1048
1049
  const depRel = normalizeDependencyKind(relType || "relates_to");
1049
1050
  const selector = `id=${to.trim()},kind=${depRel}`;
1050
- const result = runPm({
1051
+ const result = await runPm({
1051
1052
  args: ["update", from.trim(), "--dep-remove", selector, "--message", `Remove ${depRel} dependency on ${to.trim()}`],
1052
1053
  userId: project.ownerUserId,
1053
1054
  slug: project.slug,
@@ -1073,7 +1074,7 @@ router.get("/graph", async (req, res) => {
1073
1074
  res.status(404).json({ error: "Project not found" });
1074
1075
  return;
1075
1076
  }
1076
- const extensionGraph = pmGraphExtensionGraphForProject(project);
1077
+ const extensionGraph = await pmGraphExtensionGraphForProject(project);
1077
1078
  if (extensionGraph.graph) {
1078
1079
  res.json({
1079
1080
  ok: true,
@@ -1085,7 +1086,7 @@ router.get("/graph", async (req, res) => {
1085
1086
  try {
1086
1087
  res.json({
1087
1088
  ok: true,
1088
- graph: fallbackGraphForProject(project.ownerUserId, project.slug),
1089
+ graph: await fallbackGraphForProject(project.ownerUserId, project.slug),
1089
1090
  extensionAvailable: false,
1090
1091
  extensionError: extensionGraph.error,
1091
1092
  });
@@ -1140,7 +1141,7 @@ router.get("/graph/neighbors/:nodeId", async (req, res) => {
1140
1141
  res.status(400).json({ error: "nodeId is required" });
1141
1142
  return;
1142
1143
  }
1143
- const result = runPm({
1144
+ const result = await runPm({
1144
1145
  args: ["pm-graph", "neighbors", nodeId, "--json"],
1145
1146
  userId: project.ownerUserId,
1146
1147
  slug: project.slug,
@@ -1171,7 +1172,7 @@ router.post("/graph/query", async (req, res) => {
1171
1172
  res.status(400).json({ error: "cypher query is required" });
1172
1173
  return;
1173
1174
  }
1174
- const result = runPm({
1175
+ const result = await runPm({
1175
1176
  args: ["pm-graph", "query", cypher.trim(), "--json"],
1176
1177
  userId: project.ownerUserId,
1177
1178
  slug: project.slug,
@@ -1196,7 +1197,7 @@ router.get("/learnings/:itemId", async (req, res) => {
1196
1197
  res.status(404).json({ error: "Project not found" });
1197
1198
  return;
1198
1199
  }
1199
- const result = runPm({
1200
+ const result = await runPm({
1200
1201
  args: ["learnings", routeParam(req, "itemId")],
1201
1202
  userId: project.ownerUserId,
1202
1203
  slug: project.slug,
@@ -1216,7 +1217,7 @@ router.post("/learnings/:itemId", async (req, res) => {
1216
1217
  res.status(400).json({ error: "Learning text is required" });
1217
1218
  return;
1218
1219
  }
1219
- const result = runPm({
1220
+ const result = await runPm({
1220
1221
  args: ["learnings", routeParam(req, "itemId"), text.trim()],
1221
1222
  userId: project.ownerUserId,
1222
1223
  slug: project.slug,
@@ -1235,7 +1236,7 @@ router.post("/claim/:itemId", async (req, res) => {
1235
1236
  res.status(404).json({ error: "Project not found" });
1236
1237
  return;
1237
1238
  }
1238
- const result = runPm({
1239
+ const result = await runPm({
1239
1240
  args: ["claim", routeParam(req, "itemId")],
1240
1241
  userId: project.ownerUserId,
1241
1242
  slug: project.slug,
@@ -1255,7 +1256,7 @@ router.post("/release/:itemId", async (req, res) => {
1255
1256
  res.status(404).json({ error: "Project not found" });
1256
1257
  return;
1257
1258
  }
1258
- const result = runPm({
1259
+ const result = await runPm({
1259
1260
  args: ["release", routeParam(req, "itemId")],
1260
1261
  userId: project.ownerUserId,
1261
1262
  slug: project.slug,
@@ -1275,7 +1276,7 @@ router.post("/start-task/:itemId", async (req, res) => {
1275
1276
  res.status(404).json({ error: "Project not found" });
1276
1277
  return;
1277
1278
  }
1278
- const result = runPm({
1279
+ const result = await runPm({
1279
1280
  args: ["start-task", routeParam(req, "itemId")],
1280
1281
  userId: project.ownerUserId,
1281
1282
  slug: project.slug,
@@ -1295,7 +1296,7 @@ router.post("/pause-task/:itemId", async (req, res) => {
1295
1296
  res.status(404).json({ error: "Project not found" });
1296
1297
  return;
1297
1298
  }
1298
- const result = runPm({
1299
+ const result = await runPm({
1299
1300
  args: ["pause-task", routeParam(req, "itemId")],
1300
1301
  userId: project.ownerUserId,
1301
1302
  slug: project.slug,
@@ -1315,7 +1316,7 @@ router.get("/tests/:itemId", async (req, res) => {
1315
1316
  res.status(404).json({ error: "Project not found" });
1316
1317
  return;
1317
1318
  }
1318
- const result = runPm({
1319
+ const result = await runPm({
1319
1320
  args: ["test", routeParam(req, "itemId")],
1320
1321
  userId: project.ownerUserId,
1321
1322
  slug: project.slug,
@@ -1338,7 +1339,7 @@ router.post("/tests/:itemId", async (req, res) => {
1338
1339
  const args = ["test", routeParam(req, "itemId"), "--add", "--command", command.trim()];
1339
1340
  if (description)
1340
1341
  args.push("--description", description.trim());
1341
- const result = runPm({
1342
+ const result = await runPm({
1342
1343
  args,
1343
1344
  userId: project.ownerUserId,
1344
1345
  slug: project.slug,
@@ -1357,7 +1358,7 @@ router.get("/dedupe-audit", async (req, res) => {
1357
1358
  res.status(404).json({ error: "Project not found" });
1358
1359
  return;
1359
1360
  }
1360
- const result = runPm({
1361
+ const result = await runPm({
1361
1362
  args: ["dedupe-audit"],
1362
1363
  userId: project.ownerUserId,
1363
1364
  slug: project.slug,
@@ -1372,7 +1373,7 @@ router.get("/validate", async (req, res) => {
1372
1373
  res.status(404).json({ error: "Project not found" });
1373
1374
  return;
1374
1375
  }
1375
- const result = runPm({
1376
+ const result = await runPm({
1376
1377
  args: ["validate"],
1377
1378
  userId: project.ownerUserId,
1378
1379
  slug: project.slug,
@@ -1392,7 +1393,7 @@ router.post("/restore/:itemId", async (req, res) => {
1392
1393
  res.status(400).json({ error: "Restore target (timestamp or version) is required" });
1393
1394
  return;
1394
1395
  }
1395
- const result = runPm({
1396
+ const result = await runPm({
1396
1397
  args: ["restore", routeParam(req, "itemId"), target.trim()],
1397
1398
  userId: project.ownerUserId,
1398
1399
  slug: project.slug,
@@ -1417,7 +1418,7 @@ router.post("/close-task/:itemId", async (req, res) => {
1417
1418
  res.status(400).json({ error: "Close reason is required" });
1418
1419
  return;
1419
1420
  }
1420
- const result = runPm({
1421
+ const result = await runPm({
1421
1422
  args: ["close-task", routeParam(req, "itemId"), reason.trim()],
1422
1423
  userId: project.ownerUserId,
1423
1424
  slug: project.slug,
@@ -1440,7 +1441,7 @@ router.post("/reindex", async (req, res) => {
1440
1441
  const { mode = "keyword" } = req.body;
1441
1442
  const validModes = ["keyword", "semantic", "hybrid"];
1442
1443
  const safeMode = validModes.includes(mode) ? mode : "keyword";
1443
- const result = runPm({
1444
+ const result = await runPm({
1444
1445
  args: ["reindex", "--mode", safeMode],
1445
1446
  userId: project.ownerUserId,
1446
1447
  slug: project.slug,
@@ -1460,7 +1461,7 @@ router.post("/normalize", async (req, res) => {
1460
1461
  res.status(404).json({ error: "Project not found" });
1461
1462
  return;
1462
1463
  }
1463
- const result = runPm({
1464
+ const result = await runPm({
1464
1465
  args: ["normalize"],
1465
1466
  userId: project.ownerUserId,
1466
1467
  slug: project.slug,
@@ -1475,7 +1476,7 @@ router.get("/comments-audit", async (req, res) => {
1475
1476
  res.status(404).json({ error: "Project not found" });
1476
1477
  return;
1477
1478
  }
1478
- const result = runPm({
1479
+ const result = await runPm({
1479
1480
  args: ["comments-audit"],
1480
1481
  userId: project.ownerUserId,
1481
1482
  slug: project.slug,
@@ -1499,7 +1500,7 @@ router.post("/files/:itemId", async (req, res) => {
1499
1500
  if (scope)
1500
1501
  addVal += `,scope=${scope}`;
1501
1502
  const args = ["files", routeParam(req, "itemId"), "--add", addVal];
1502
- const result = runPm({
1503
+ const result = await runPm({
1503
1504
  args,
1504
1505
  userId: project.ownerUserId,
1505
1506
  slug: project.slug,
@@ -1519,7 +1520,7 @@ router.get("/files/:itemId", async (req, res) => {
1519
1520
  res.status(404).json({ error: "Project not found" });
1520
1521
  return;
1521
1522
  }
1522
- const result = runPm({
1523
+ const result = await runPm({
1523
1524
  args: ["files", routeParam(req, "itemId")],
1524
1525
  userId: project.ownerUserId,
1525
1526
  slug: project.slug,
@@ -1534,7 +1535,7 @@ router.get("/guide", async (req, res) => {
1534
1535
  res.status(404).json({ error: "Project not found" });
1535
1536
  return;
1536
1537
  }
1537
- const result = runPm({
1538
+ const result = await runPm({
1538
1539
  args: ["guide"],
1539
1540
  userId: project.ownerUserId,
1540
1541
  slug: project.slug,
@@ -1549,7 +1550,7 @@ router.get("/guide/:topicId", async (req, res) => {
1549
1550
  res.status(404).json({ error: "Project not found" });
1550
1551
  return;
1551
1552
  }
1552
- const result = runPm({
1553
+ const result = await runPm({
1553
1554
  args: ["guide", routeParam(req, "topicId")],
1554
1555
  userId: project.ownerUserId,
1555
1556
  slug: project.slug,
@@ -1575,7 +1576,7 @@ router.get("/export", async (req, res) => {
1575
1576
  }
1576
1577
  const format = req.query["format"] || "json";
1577
1578
  // Use --full --include-body to get the richest available list-level metadata
1578
- const result = runPm({
1579
+ const result = await runPm({
1579
1580
  args: ["list-all", "--limit", "10000", "--full", "--include-body"],
1580
1581
  userId: project.ownerUserId,
1581
1582
  slug: project.slug,
@@ -1715,7 +1716,7 @@ router.post("/import", async (req, res) => {
1715
1716
  args.push("--body", item.body);
1716
1717
  if (item.parent)
1717
1718
  args.push("--parent", item.parent);
1718
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1719
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1719
1720
  if (result.ok && result.parsed) {
1720
1721
  const parsed = result.parsed;
1721
1722
  created.push(parsed.item?.id || `item[${i}]`);
@@ -1772,7 +1773,7 @@ router.post("/update-many", async (req, res) => {
1772
1773
  if (body[key])
1773
1774
  args.push(flag, body[key]);
1774
1775
  }
1775
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1776
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1776
1777
  if (!result.ok) {
1777
1778
  res.status(400).json({ error: result.stderr || "update-many failed" });
1778
1779
  return;
@@ -1814,7 +1815,7 @@ router.post("/close-many", async (req, res) => {
1814
1815
  if (body[key])
1815
1816
  listArgs.push(flag, body[key]);
1816
1817
  }
1817
- const listResult = runPm({ args: listArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1818
+ const listResult = await runPm({ args: listArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1818
1819
  if (!listResult.ok) {
1819
1820
  res.status(400).json({ error: listResult.stderr || "Failed to list items for close-many" });
1820
1821
  return;
@@ -1834,7 +1835,7 @@ router.post("/close-many", async (req, res) => {
1834
1835
  const closeArgs = targetStatus === "canceled"
1835
1836
  ? ["update", itemId, "--status", "canceled"]
1836
1837
  : ["close", itemId, reason];
1837
- const closeResult = runPm({ args: closeArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1838
+ const closeResult = await runPm({ args: closeArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1838
1839
  if (closeResult.ok) {
1839
1840
  rows.push({ id: itemId, status: "ok" });
1840
1841
  closedCount++;
@@ -1866,7 +1867,7 @@ router.get("/docs/:itemId", async (req, res) => {
1866
1867
  res.status(404).json({ error: "Project not found" });
1867
1868
  return;
1868
1869
  }
1869
- const result = runPm({
1870
+ const result = await runPm({
1870
1871
  args: ["docs", routeParam(req, "itemId")],
1871
1872
  userId: project.ownerUserId,
1872
1873
  slug: project.slug,
@@ -1901,7 +1902,7 @@ router.post("/docs/:itemId", async (req, res) => {
1901
1902
  res.status(400).json({ error: "path, remove, or validatePaths is required" });
1902
1903
  return;
1903
1904
  }
1904
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1905
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1905
1906
  if (!result.ok) {
1906
1907
  res.status(400).json({ error: result.stderr || "Failed to update docs" });
1907
1908
  return;
@@ -1924,7 +1925,7 @@ router.post("/test-all", async (req, res) => {
1924
1925
  args.push("--limit", body.limit);
1925
1926
  if (body.timeout)
1926
1927
  args.push("--timeout", body.timeout);
1927
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1928
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1928
1929
  if (!result.ok) {
1929
1930
  res.status(400).json({ error: result.stderr || "test-all failed" });
1930
1931
  return;
@@ -1944,7 +1945,7 @@ router.get("/test-runs", async (req, res) => {
1944
1945
  args.push("--status", status);
1945
1946
  if (limit)
1946
1947
  args.push("--limit", limit);
1947
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1948
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1948
1949
  res.json(result.ok ? (result.parsed || {}) : { runs: [] });
1949
1950
  });
1950
1951
  // POST /api/projects/:projectId/pm/gc
@@ -1954,7 +1955,7 @@ router.post("/gc", async (req, res) => {
1954
1955
  res.status(404).json({ error: "Project not found" });
1955
1956
  return;
1956
1957
  }
1957
- const result = runPm({ args: ["gc"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1958
+ const result = await runPm({ args: ["gc"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1958
1959
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1959
1960
  });
1960
1961
  // GET /api/projects/:projectId/pm/templates
@@ -1964,7 +1965,7 @@ router.get("/templates", async (req, res) => {
1964
1965
  res.status(404).json({ error: "Project not found" });
1965
1966
  return;
1966
1967
  }
1967
- const result = runPm({ args: ["templates", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1968
+ const result = await runPm({ args: ["templates", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1968
1969
  res.json(result.ok ? (result.parsed || {}) : { templates: [] });
1969
1970
  });
1970
1971
  // GET /api/projects/:projectId/pm/templates/:name
@@ -1974,7 +1975,7 @@ router.get("/templates/:name", async (req, res) => {
1974
1975
  res.status(404).json({ error: "Project not found" });
1975
1976
  return;
1976
1977
  }
1977
- const result = runPm({ args: ["templates", "show", routeParam(req, "name")], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1978
+ const result = await runPm({ args: ["templates", "show", routeParam(req, "name")], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1978
1979
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1979
1980
  });
1980
1981
  // GET /api/projects/:projectId/pm/config
@@ -1984,7 +1985,7 @@ router.get("/config", async (req, res) => {
1984
1985
  res.status(404).json({ error: "Project not found" });
1985
1986
  return;
1986
1987
  }
1987
- const result = runPm({ args: ["config", "project", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1988
+ const result = await runPm({ args: ["config", "project", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1988
1989
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1989
1990
  });
1990
1991
  // GET /api/projects/:projectId/pm/config/:key
@@ -1995,7 +1996,7 @@ router.get("/config/:key", async (req, res) => {
1995
1996
  return;
1996
1997
  }
1997
1998
  const key = routeParam(req, "key");
1998
- const result = runPm({ args: ["config", "project", "get", key], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1999
+ const result = await runPm({ args: ["config", "project", "get", key], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1999
2000
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
2000
2001
  });
2001
2002
  // PATCH /api/projects/:projectId/pm/config/:key
@@ -2014,7 +2015,7 @@ router.patch("/config/:key", async (req, res) => {
2014
2015
  args.push("--policy", body.policy);
2015
2016
  if (body.format)
2016
2017
  args.push("--format", body.format);
2017
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2018
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2018
2019
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
2019
2020
  });
2020
2021
  // ─── List status shortcut routes ───
@@ -2044,7 +2045,7 @@ function buildListShortcutRoute(pmCommand) {
2044
2045
  args.push("--sprint", sprint);
2045
2046
  if (release)
2046
2047
  args.push("--release", release);
2047
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2048
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2048
2049
  res.json(result.ok ? (result.parsed || {}) : { items: [] });
2049
2050
  };
2050
2051
  }
@@ -2080,7 +2081,7 @@ router.post("/plan", async (req, res) => {
2080
2081
  args.push("--priority", priority);
2081
2082
  if (body)
2082
2083
  args.push("--body", body);
2083
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2084
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2084
2085
  if (!result.ok) {
2085
2086
  res.status(400).json({ error: result.stderr || "Failed to create plan" });
2086
2087
  return;
@@ -2098,7 +2099,7 @@ router.get("/plan/:planId", async (req, res) => {
2098
2099
  res.status(404).json({ error: "Project not found" });
2099
2100
  return;
2100
2101
  }
2101
- const result = runPm({
2102
+ const result = await runPm({
2102
2103
  args: ["plan", "show", routeParam(req, "planId"), "--depth", "standard"],
2103
2104
  userId: project.ownerUserId,
2104
2105
  slug: project.slug,
@@ -2123,7 +2124,7 @@ router.patch("/plan/:planId", async (req, res) => {
2123
2124
  args.push("--title", title.trim());
2124
2125
  if (description !== undefined)
2125
2126
  args.push("--description", description);
2126
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2127
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2127
2128
  if (!result.ok) {
2128
2129
  res.status(400).json({ error: result.stderr || "Failed to update plan" });
2129
2130
  return;
@@ -2141,7 +2142,7 @@ router.delete("/plan/:planId", async (req, res) => {
2141
2142
  res.status(404).json({ error: "Project not found" });
2142
2143
  return;
2143
2144
  }
2144
- const result = runPm({
2145
+ const result = await runPm({
2145
2146
  args: ["delete", routeParam(req, "planId")],
2146
2147
  userId: project.ownerUserId,
2147
2148
  slug: project.slug,
@@ -2174,7 +2175,7 @@ router.post("/plan/:planId/steps", async (req, res) => {
2174
2175
  args.push("--description", description);
2175
2176
  if (dependsOn)
2176
2177
  args.push("--depends-on", dependsOn);
2177
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2178
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2178
2179
  if (!result.ok) {
2179
2180
  res.status(400).json({ error: result.stderr || "Failed to add step" });
2180
2181
  return;
@@ -2198,7 +2199,7 @@ router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
2198
2199
  args.push("--title", title);
2199
2200
  if (description)
2200
2201
  args.push("--description", description);
2201
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2202
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2202
2203
  if (!result.ok) {
2203
2204
  res.status(400).json({ error: result.stderr || "Failed to update step" });
2204
2205
  return;
@@ -2216,7 +2217,7 @@ router.post("/plan/:planId/steps/:stepRef/complete", async (req, res) => {
2216
2217
  res.status(404).json({ error: "Project not found" });
2217
2218
  return;
2218
2219
  }
2219
- const result = runPm({
2220
+ const result = await runPm({
2220
2221
  args: ["plan", "complete-step", routeParam(req, "planId"), routeParam(req, "stepRef")],
2221
2222
  userId: project.ownerUserId,
2222
2223
  slug: project.slug,
@@ -2244,7 +2245,7 @@ router.post("/plan/:planId/steps/:stepRef/block", async (req, res) => {
2244
2245
  res.status(400).json({ error: "Block reason is required" });
2245
2246
  return;
2246
2247
  }
2247
- const result = runPm({
2248
+ const result = await runPm({
2248
2249
  args: ["plan", "block-step", routeParam(req, "planId"), routeParam(req, "stepRef"), "--step-blocked-reason", reason.trim()],
2249
2250
  userId: project.ownerUserId,
2250
2251
  slug: project.slug,
@@ -2267,7 +2268,7 @@ router.delete("/plan/:planId/steps/:stepRef", async (req, res) => {
2267
2268
  res.status(404).json({ error: "Project not found" });
2268
2269
  return;
2269
2270
  }
2270
- const result = runPm({
2271
+ const result = await runPm({
2271
2272
  args: ["plan", "remove-step", routeParam(req, "planId"), routeParam(req, "stepRef")],
2272
2273
  userId: project.ownerUserId,
2273
2274
  slug: project.slug,
@@ -2290,7 +2291,7 @@ router.post("/plan/:planId/approve", async (req, res) => {
2290
2291
  res.status(404).json({ error: "Project not found" });
2291
2292
  return;
2292
2293
  }
2293
- const result = runPm({
2294
+ const result = await runPm({
2294
2295
  args: ["plan", "approve", routeParam(req, "planId")],
2295
2296
  userId: project.ownerUserId,
2296
2297
  slug: project.slug,
@@ -2321,7 +2322,7 @@ router.post("/plan/:planId/materialize", async (req, res) => {
2321
2322
  args.push("--materialize-parent", materializeParent);
2322
2323
  if (steps)
2323
2324
  args.push("--steps", steps);
2324
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2325
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2325
2326
  if (!result.ok) {
2326
2327
  res.status(400).json({ error: result.stderr || "Failed to materialize plan" });
2327
2328
  return;
@@ -2344,7 +2345,7 @@ router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2344
2345
  res.status(400).json({ error: "reorderTo (new order integer) is required" });
2345
2346
  return;
2346
2347
  }
2347
- const result = runPm({
2348
+ const result = await runPm({
2348
2349
  args: ["plan", "reorder-step", routeParam(req, "planId"), routeParam(req, "stepRef"), String(reorderTo)],
2349
2350
  userId: project.ownerUserId,
2350
2351
  slug: project.slug,
@@ -2379,7 +2380,7 @@ router.post("/plan/:planId/link", async (req, res) => {
2379
2380
  args.push("--link-note", linkNote);
2380
2381
  if (promoteToItemDep === "true")
2381
2382
  args.push("--promote-to-item-dep");
2382
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2383
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2383
2384
  if (!result.ok) {
2384
2385
  res.status(400).json({ error: result.stderr || "Failed to link plan" });
2385
2386
  return;
@@ -2405,7 +2406,7 @@ router.delete("/plan/:planId/link", async (req, res) => {
2405
2406
  const args = ["plan", "unlink", routeParam(req, "planId"), "--link", link.trim()];
2406
2407
  if (linkKind)
2407
2408
  args.push("--link-kind", linkKind);
2408
- const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2409
+ const result = await runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2409
2410
  if (!result.ok) {
2410
2411
  res.status(400).json({ error: result.stderr || "Failed to unlink plan" });
2411
2412
  return;
@@ -2426,7 +2427,7 @@ router.get("/upgrade", async (req, res) => {
2426
2427
  res.status(404).json({ error: "Project not found" });
2427
2428
  return;
2428
2429
  }
2429
- const result = runPm({
2430
+ const result = await runPm({
2430
2431
  args: ["upgrade", "--dry-run", "--packages-only"],
2431
2432
  userId: project.ownerUserId,
2432
2433
  slug: project.slug,
@@ -2441,7 +2442,7 @@ router.post("/upgrade", async (req, res) => {
2441
2442
  return;
2442
2443
  }
2443
2444
  // Only allow package upgrades from the web UI — never self-upgrade the CLI binary.
2444
- const result = runPm({
2445
+ const result = await runPm({
2445
2446
  args: ["upgrade", "--packages-only"],
2446
2447
  userId: project.ownerUserId,
2447
2448
  slug: project.slug,
@@ -2453,18 +2454,25 @@ router.post("/upgrade", async (req, res) => {
2453
2454
  }
2454
2455
  res.json(result.parsed || { ok: true });
2455
2456
  });
2456
- // ─── Presence endpoints ───
2457
- // GET /api/projects/:projectId/pm/presence
2458
- router.get("/presence", async (req, res) => {
2459
- const projectId = routeParam(req, "projectId");
2460
- res.json({ users: getProjectPresence(projectId) });
2461
- });
2462
2457
  // PATCH /api/projects/:projectId/pm/presence/:clientId
2463
2458
  router.patch("/presence/:clientId", async (req, res) => {
2459
+ const projectId = routeParam(req, "projectId");
2460
+ const access = await verifyProjectAccess(req.user.userId, projectId);
2461
+ if (!access) {
2462
+ res.status(404).json({ error: "Project not found" });
2463
+ return;
2464
+ }
2464
2465
  const { clientId } = req.params;
2465
2466
  const { view } = req.body;
2466
- if (view)
2467
- updateClientView(clientId, view);
2467
+ if (!view || !/^[a-z][a-z0-9-]{0,63}$/.test(view)) {
2468
+ res.status(400).json({ error: "Invalid view" });
2469
+ return;
2470
+ }
2471
+ const updated = updateClientView(clientId, req.user.userId, projectId, view);
2472
+ if (!updated) {
2473
+ res.status(404).json({ error: "Presence session not found" });
2474
+ return;
2475
+ }
2468
2476
  res.json({ ok: true });
2469
2477
  });
2470
2478
  // ─── SSE endpoint ───
@@ -2482,13 +2490,21 @@ router.get("/events", async (req, res) => {
2482
2490
  res.status(500).json({ error: "Failed to verify project for real-time sync" });
2483
2491
  return;
2484
2492
  }
2485
- setupSSEHeaders(res);
2486
2493
  const clientId = uuidv4();
2487
2494
  const projectId = routeParam(req, "projectId");
2488
2495
  const userId = req.user.userId;
2489
- // Client sends display name as query param; fall back to email
2490
- const displayName = String(req.query["dn"] ?? req.user.email ?? userId);
2496
+ let displayName;
2497
+ try {
2498
+ const userResult = await pool.query("SELECT display_name FROM pm_users WHERE id = $1", [userId]);
2499
+ displayName = userResult.rows[0]?.display_name?.trim() || "Project member";
2500
+ }
2501
+ catch (error) {
2502
+ console.error("SSE presence lookup failed", error instanceof Error ? error.name : typeof error);
2503
+ res.status(500).json({ error: "Failed to initialize real-time presence" });
2504
+ return;
2505
+ }
2491
2506
  const currentView = String(req.query["view"] ?? "items");
2507
+ setupSSEHeaders(res);
2492
2508
  const unsubscribe = addSSEClient({
2493
2509
  id: clientId,
2494
2510
  projectId,
@@ -2502,8 +2518,6 @@ router.get("/events", async (req, res) => {
2502
2518
  const heartbeat = setInterval(() => {
2503
2519
  try {
2504
2520
  res.write(": heartbeat\n\n");
2505
- // Re-broadcast presence on heartbeat to keep list fresh
2506
- broadcastPresence(projectId);
2507
2521
  }
2508
2522
  catch {
2509
2523
  clearInterval(heartbeat);