codepiper 0.1.0

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 (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Notification API route handlers
3
+ */
4
+
5
+ import type { ListSessionNotificationsOptions, UpsertPushSubscriptionParams } from "../db/db";
6
+ import type { PushRuntimeStatus } from "../notifications/pushNotifier";
7
+ import type { RouteContext } from "./routes";
8
+ import { jsonError, parseJsonBody } from "./routeUtils";
9
+
10
+ const MAX_NOTIFICATION_LIMIT = 200;
11
+ const MAX_PUSH_ENDPOINT_LENGTH = 2048;
12
+ const MAX_PUSH_KEY_LENGTH = 512;
13
+ const MAX_PUSH_TEST_TITLE_LENGTH = 120;
14
+ const MAX_PUSH_TEST_BODY_LENGTH = 500;
15
+ const MAX_PUSH_TEST_SESSION_ID_LENGTH = 120;
16
+ const MAX_NOTIFICATION_READ_SOURCE_LENGTH = 64;
17
+
18
+ const LOOPBACK_PUSH_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
19
+
20
+ function parseBooleanQuery(
21
+ raw: string | null,
22
+ fieldName: string
23
+ ): { ok: true; value: boolean | undefined } | { ok: false; response: Response } {
24
+ if (raw === null) {
25
+ return { ok: true, value: undefined };
26
+ }
27
+ if (raw === "true") {
28
+ return { ok: true, value: true };
29
+ }
30
+ if (raw === "false") {
31
+ return { ok: true, value: false };
32
+ }
33
+ return { ok: false, response: jsonError(400, `${fieldName} must be "true" or "false"`) };
34
+ }
35
+
36
+ function parsePositiveIntegerQuery(
37
+ raw: string | null,
38
+ fieldName: string,
39
+ max?: number
40
+ ): { ok: true; value: number | undefined } | { ok: false; response: Response } {
41
+ if (raw === null) {
42
+ return { ok: true, value: undefined };
43
+ }
44
+
45
+ const parsed = Number(raw);
46
+ if (!Number.isInteger(parsed) || parsed <= 0) {
47
+ return { ok: false, response: jsonError(400, `${fieldName} must be a positive integer`) };
48
+ }
49
+ if (max !== undefined && parsed > max) {
50
+ return { ok: false, response: jsonError(400, `${fieldName} must be <= ${max}`) };
51
+ }
52
+
53
+ return { ok: true, value: parsed };
54
+ }
55
+
56
+ function validateSessionExists(ctx: RouteContext, sessionId: string): Response | null {
57
+ const session = ctx.db.getSession(sessionId);
58
+ if (!session) {
59
+ return jsonError(404, `Session not found: ${sessionId}`);
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
66
+ }
67
+
68
+ function normalizePushEndpoint(
69
+ endpoint: string
70
+ ): { ok: true; value: string } | { ok: false; response: Response } {
71
+ let parsed: URL;
72
+ try {
73
+ parsed = new URL(endpoint);
74
+ } catch {
75
+ return { ok: false, response: jsonError(400, "endpoint must be a valid URL") };
76
+ }
77
+
78
+ if (parsed.username || parsed.password) {
79
+ return {
80
+ ok: false,
81
+ response: jsonError(400, "endpoint must not include username or password"),
82
+ };
83
+ }
84
+
85
+ const protocol = parsed.protocol.toLowerCase();
86
+ const hostname = parsed.hostname.toLowerCase();
87
+ const isLoopbackHttp = protocol === "http:" && LOOPBACK_PUSH_HOSTS.has(hostname);
88
+ if (!(protocol === "https:" || isLoopbackHttp)) {
89
+ return {
90
+ ok: false,
91
+ response: jsonError(400, "endpoint must use https (http is allowed only for localhost)"),
92
+ };
93
+ }
94
+
95
+ return { ok: true, value: parsed.toString() };
96
+ }
97
+
98
+ function getFallbackPushStatus(): PushRuntimeStatus {
99
+ return {
100
+ enabled: false,
101
+ configured: false,
102
+ reasons: ["not_available"],
103
+ publicKey: null,
104
+ };
105
+ }
106
+
107
+ function parsePushSubscriptionInput(
108
+ value: unknown
109
+ ): { ok: true; value: UpsertPushSubscriptionParams } | { ok: false; response: Response } {
110
+ if (!isRecord(value)) {
111
+ return { ok: false, response: jsonError(400, "Push subscription payload must be an object") };
112
+ }
113
+
114
+ const endpointRaw = value.endpoint;
115
+ if (typeof endpointRaw !== "string" || endpointRaw.trim() === "") {
116
+ return { ok: false, response: jsonError(400, "endpoint must be a non-empty string") };
117
+ }
118
+ const endpoint = endpointRaw.trim();
119
+ if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) {
120
+ return {
121
+ ok: false,
122
+ response: jsonError(400, `endpoint must be at most ${MAX_PUSH_ENDPOINT_LENGTH} characters`),
123
+ };
124
+ }
125
+ const normalizedEndpoint = normalizePushEndpoint(endpoint);
126
+ if (!normalizedEndpoint.ok) {
127
+ return normalizedEndpoint;
128
+ }
129
+
130
+ const keysRaw = value.keys;
131
+ if (!isRecord(keysRaw)) {
132
+ return { ok: false, response: jsonError(400, "keys must be an object") };
133
+ }
134
+
135
+ const p256dhRaw = keysRaw.p256dh;
136
+ const authRaw = keysRaw.auth;
137
+ if (typeof p256dhRaw !== "string" || p256dhRaw.trim() === "") {
138
+ return { ok: false, response: jsonError(400, "keys.p256dh must be a non-empty string") };
139
+ }
140
+ if (typeof authRaw !== "string" || authRaw.trim() === "") {
141
+ return { ok: false, response: jsonError(400, "keys.auth must be a non-empty string") };
142
+ }
143
+ const p256dh = p256dhRaw.trim();
144
+ const auth = authRaw.trim();
145
+ if (p256dh.length > MAX_PUSH_KEY_LENGTH || auth.length > MAX_PUSH_KEY_LENGTH) {
146
+ return {
147
+ ok: false,
148
+ response: jsonError(400, `push keys must be at most ${MAX_PUSH_KEY_LENGTH} characters`),
149
+ };
150
+ }
151
+
152
+ let expirationTime: number | null = null;
153
+ if (value.expirationTime !== undefined && value.expirationTime !== null) {
154
+ if (!(typeof value.expirationTime === "number" && Number.isFinite(value.expirationTime))) {
155
+ return { ok: false, response: jsonError(400, "expirationTime must be a number or null") };
156
+ }
157
+ if (value.expirationTime < 0) {
158
+ return {
159
+ ok: false,
160
+ response: jsonError(400, "expirationTime must be greater than or equal to 0"),
161
+ };
162
+ }
163
+ expirationTime = Math.floor(value.expirationTime);
164
+ }
165
+
166
+ return {
167
+ ok: true,
168
+ value: {
169
+ endpoint: normalizedEndpoint.value,
170
+ keys: { p256dh, auth },
171
+ expirationTime,
172
+ },
173
+ };
174
+ }
175
+
176
+ /**
177
+ * GET /notifications - List notifications
178
+ */
179
+ export async function handleListNotifications(req: Request, ctx: RouteContext): Promise<Response> {
180
+ const url = new URL(req.url);
181
+ const sessionId = url.searchParams.get("sessionId");
182
+ const eventType = url.searchParams.get("eventType");
183
+
184
+ const unreadOnlyResult = parseBooleanQuery(url.searchParams.get("unreadOnly"), "unreadOnly");
185
+ if (!unreadOnlyResult.ok) {
186
+ return unreadOnlyResult.response;
187
+ }
188
+
189
+ const beforeResult = parsePositiveIntegerQuery(url.searchParams.get("before"), "before");
190
+ if (!beforeResult.ok) {
191
+ return beforeResult.response;
192
+ }
193
+
194
+ const limitResult = parsePositiveIntegerQuery(
195
+ url.searchParams.get("limit"),
196
+ "limit",
197
+ MAX_NOTIFICATION_LIMIT
198
+ );
199
+ if (!limitResult.ok) {
200
+ return limitResult.response;
201
+ }
202
+
203
+ if (sessionId) {
204
+ const sessionValidation = validateSessionExists(ctx, sessionId);
205
+ if (sessionValidation) {
206
+ return sessionValidation;
207
+ }
208
+ }
209
+
210
+ const options: ListSessionNotificationsOptions = {
211
+ sessionId: sessionId ?? undefined,
212
+ eventType: eventType ?? undefined,
213
+ unreadOnly: unreadOnlyResult.value,
214
+ before: beforeResult.value,
215
+ limit: limitResult.value,
216
+ };
217
+
218
+ const notifications = ctx.db.listSessionNotifications(options);
219
+ return Response.json({ notifications });
220
+ }
221
+
222
+ /**
223
+ * GET /notifications/counts - Global + per-session unread counts
224
+ */
225
+ export async function handleGetNotificationCounts(
226
+ _req: Request,
227
+ ctx: RouteContext
228
+ ): Promise<Response> {
229
+ const counts = ctx.db.getSessionNotificationCounts();
230
+ return Response.json({ counts });
231
+ }
232
+
233
+ /**
234
+ * GET /notifications/push/status - Daemon push runtime status
235
+ */
236
+ export async function handleGetPushStatus(_req: Request, ctx: RouteContext): Promise<Response> {
237
+ const status = ctx.pushNotifier?.getStatus() ?? getFallbackPushStatus();
238
+ return Response.json({ status });
239
+ }
240
+
241
+ /**
242
+ * POST /notifications/push/test - trigger a test push notification
243
+ */
244
+ export async function handleSendTestPushNotification(
245
+ req: Request,
246
+ ctx: RouteContext
247
+ ): Promise<Response> {
248
+ if (!ctx.pushNotifier) {
249
+ return jsonError(503, "Push notifier is unavailable");
250
+ }
251
+
252
+ let body: { title?: unknown; body?: unknown; sessionId?: unknown } = {};
253
+ if (req.headers.get("content-length") || req.headers.get("content-type")) {
254
+ const parsed = await parseJsonBody<{ title?: unknown; body?: unknown; sessionId?: unknown }>(
255
+ req
256
+ );
257
+ if (!parsed.ok) {
258
+ return parsed.response;
259
+ }
260
+ body = parsed.body;
261
+ }
262
+
263
+ let title: string | undefined;
264
+ if (body.title !== undefined) {
265
+ if (typeof body.title !== "string") {
266
+ return jsonError(400, "title must be a string");
267
+ }
268
+ const normalized = body.title.trim();
269
+ if (normalized.length > MAX_PUSH_TEST_TITLE_LENGTH) {
270
+ return jsonError(400, `title must be <= ${MAX_PUSH_TEST_TITLE_LENGTH} characters`);
271
+ }
272
+ if (normalized.length > 0) {
273
+ title = normalized;
274
+ }
275
+ }
276
+
277
+ let pushBody: string | undefined;
278
+ if (body.body !== undefined) {
279
+ if (typeof body.body !== "string") {
280
+ return jsonError(400, "body must be a string");
281
+ }
282
+ const normalized = body.body.trim();
283
+ if (normalized.length > MAX_PUSH_TEST_BODY_LENGTH) {
284
+ return jsonError(400, `body must be <= ${MAX_PUSH_TEST_BODY_LENGTH} characters`);
285
+ }
286
+ if (normalized.length > 0) {
287
+ pushBody = normalized;
288
+ }
289
+ }
290
+
291
+ let sessionId: string | undefined;
292
+ if (body.sessionId !== undefined) {
293
+ if (typeof body.sessionId !== "string") {
294
+ return jsonError(400, "sessionId must be a string");
295
+ }
296
+ const normalized = body.sessionId.trim();
297
+ if (normalized.length > MAX_PUSH_TEST_SESSION_ID_LENGTH) {
298
+ return jsonError(400, `sessionId must be <= ${MAX_PUSH_TEST_SESSION_ID_LENGTH} characters`);
299
+ }
300
+ if (normalized.length > 0) {
301
+ sessionId = normalized;
302
+ }
303
+ }
304
+
305
+ const result = await ctx.pushNotifier.sendTestNotification({ title, body: pushBody, sessionId });
306
+ return Response.json({ result });
307
+ }
308
+
309
+ /**
310
+ * POST /notifications/:id/read - Mark one notification as read
311
+ */
312
+ export async function handleMarkNotificationRead(
313
+ req: Request,
314
+ ctx: RouteContext,
315
+ notificationIdRaw: string
316
+ ): Promise<Response> {
317
+ const notificationId = Number(notificationIdRaw);
318
+ if (!Number.isInteger(notificationId) || notificationId <= 0) {
319
+ return jsonError(400, "notificationId must be a positive integer");
320
+ }
321
+
322
+ let readSource: string | undefined;
323
+ if (req.headers.get("content-length") || req.headers.get("content-type")) {
324
+ const parsed = await parseJsonBody<{ readSource?: unknown }>(req);
325
+ if (!parsed.ok) {
326
+ return parsed.response;
327
+ }
328
+
329
+ if (parsed.body.readSource !== undefined) {
330
+ if (typeof parsed.body.readSource !== "string" || parsed.body.readSource.trim() === "") {
331
+ return jsonError(400, "readSource must be a non-empty string");
332
+ }
333
+ const normalized = parsed.body.readSource.trim();
334
+ if (normalized.length > MAX_NOTIFICATION_READ_SOURCE_LENGTH) {
335
+ return jsonError(
336
+ 400,
337
+ `readSource must be <= ${MAX_NOTIFICATION_READ_SOURCE_LENGTH} characters`
338
+ );
339
+ }
340
+ readSource = normalized;
341
+ }
342
+ }
343
+
344
+ const readAt = new Date();
345
+ const normalizedReadSource = readSource ?? "click";
346
+ const changed = ctx.db.markSessionNotificationRead(notificationId, normalizedReadSource, readAt);
347
+ if (changed) {
348
+ ctx.eventBus.emit("notification:read", {
349
+ id: notificationId,
350
+ readAt,
351
+ readSource: normalizedReadSource,
352
+ });
353
+ ctx.eventBus.emit("notification:counts_updated", ctx.db.getSessionNotificationCounts());
354
+ }
355
+
356
+ return Response.json({ success: true, changed });
357
+ }
358
+
359
+ /**
360
+ * POST /notifications/read - Mark notifications as read in bulk
361
+ */
362
+ export async function handleMarkNotificationsRead(
363
+ req: Request,
364
+ ctx: RouteContext
365
+ ): Promise<Response> {
366
+ let body: { sessionId?: unknown; readSource?: unknown } = {};
367
+ if (req.headers.get("content-length") || req.headers.get("content-type")) {
368
+ const parsed = await parseJsonBody<{ sessionId?: unknown; readSource?: unknown }>(req);
369
+ if (!parsed.ok) {
370
+ return parsed.response;
371
+ }
372
+ body = parsed.body;
373
+ }
374
+
375
+ let sessionId: string | undefined;
376
+ if (body.sessionId !== undefined) {
377
+ if (typeof body.sessionId !== "string" || body.sessionId.trim() === "") {
378
+ return jsonError(400, "sessionId must be a non-empty string");
379
+ }
380
+ sessionId = body.sessionId.trim();
381
+ const sessionValidation = validateSessionExists(ctx, sessionId);
382
+ if (sessionValidation) {
383
+ return sessionValidation;
384
+ }
385
+ }
386
+
387
+ let readSource: string | undefined;
388
+ if (body.readSource !== undefined) {
389
+ if (typeof body.readSource !== "string" || body.readSource.trim() === "") {
390
+ return jsonError(400, "readSource must be a non-empty string");
391
+ }
392
+ const normalized = body.readSource.trim();
393
+ if (normalized.length > MAX_NOTIFICATION_READ_SOURCE_LENGTH) {
394
+ return jsonError(
395
+ 400,
396
+ `readSource must be <= ${MAX_NOTIFICATION_READ_SOURCE_LENGTH} characters`
397
+ );
398
+ }
399
+ readSource = normalized;
400
+ }
401
+
402
+ const readAt = new Date();
403
+ const normalizedReadSource = readSource ?? "bulk";
404
+ const updated = ctx.db.markSessionNotificationsRead({
405
+ sessionId,
406
+ readSource: normalizedReadSource,
407
+ readAt,
408
+ });
409
+ if (updated > 0) {
410
+ ctx.eventBus.emit("notification:read", {
411
+ id: null,
412
+ readAt,
413
+ readSource: normalizedReadSource,
414
+ sessionId: sessionId ?? null,
415
+ bulk: true,
416
+ updated,
417
+ });
418
+ ctx.eventBus.emit("notification:counts_updated", ctx.db.getSessionNotificationCounts());
419
+ }
420
+
421
+ return Response.json({ success: true, updated });
422
+ }
423
+
424
+ /**
425
+ * GET /sessions/:id/notifications/prefs - Get session notification preference
426
+ */
427
+ export async function handleGetSessionNotificationPrefs(
428
+ _req: Request,
429
+ ctx: RouteContext,
430
+ sessionId: string
431
+ ): Promise<Response> {
432
+ const sessionValidation = validateSessionExists(ctx, sessionId);
433
+ if (sessionValidation) {
434
+ return sessionValidation;
435
+ }
436
+
437
+ const prefs = ctx.db.getSessionNotificationPrefs(sessionId);
438
+ return Response.json({ prefs });
439
+ }
440
+
441
+ /**
442
+ * PUT /sessions/:id/notifications/prefs - Upsert session notification preference
443
+ */
444
+ export async function handleUpdateSessionNotificationPrefs(
445
+ req: Request,
446
+ ctx: RouteContext,
447
+ sessionId: string
448
+ ): Promise<Response> {
449
+ const sessionValidation = validateSessionExists(ctx, sessionId);
450
+ if (sessionValidation) {
451
+ return sessionValidation;
452
+ }
453
+
454
+ const parsed = await parseJsonBody<{ enabled?: unknown }>(req);
455
+ if (!parsed.ok) {
456
+ return parsed.response;
457
+ }
458
+
459
+ if (parsed.body.enabled === undefined) {
460
+ return jsonError(400, "Missing required field: enabled");
461
+ }
462
+ if (!(parsed.body.enabled === null || typeof parsed.body.enabled === "boolean")) {
463
+ return jsonError(400, "enabled must be a boolean or null");
464
+ }
465
+
466
+ const prefs = ctx.db.setSessionNotificationPrefs(sessionId, parsed.body.enabled);
467
+ return Response.json({ prefs });
468
+ }
469
+
470
+ /**
471
+ * GET /notifications/push/subscriptions - list stored push subscriptions
472
+ */
473
+ export async function handleListPushSubscriptions(
474
+ _req: Request,
475
+ ctx: RouteContext
476
+ ): Promise<Response> {
477
+ const subscriptions = ctx.db.listPushSubscriptions().map((subscription) => ({
478
+ endpoint: subscription.endpoint,
479
+ expirationTime: subscription.expirationTime,
480
+ createdAt: subscription.createdAt,
481
+ updatedAt: subscription.updatedAt,
482
+ }));
483
+ return Response.json({ subscriptions });
484
+ }
485
+
486
+ /**
487
+ * PUT /notifications/push/subscriptions - upsert push subscription
488
+ */
489
+ export async function handleUpsertPushSubscription(
490
+ req: Request,
491
+ ctx: RouteContext
492
+ ): Promise<Response> {
493
+ const parsed = await parseJsonBody<Record<string, unknown>>(req);
494
+ if (!parsed.ok) {
495
+ return parsed.response;
496
+ }
497
+
498
+ const subscription = parsePushSubscriptionInput(parsed.body);
499
+ if (!subscription.ok) {
500
+ return subscription.response;
501
+ }
502
+
503
+ const stored = ctx.db.upsertPushSubscription(subscription.value);
504
+ return Response.json({
505
+ subscription: {
506
+ endpoint: stored.endpoint,
507
+ expirationTime: stored.expirationTime,
508
+ createdAt: stored.createdAt,
509
+ updatedAt: stored.updatedAt,
510
+ },
511
+ });
512
+ }
513
+
514
+ /**
515
+ * DELETE /notifications/push/subscriptions - delete push subscription by endpoint
516
+ */
517
+ export async function handleDeletePushSubscription(
518
+ req: Request,
519
+ ctx: RouteContext
520
+ ): Promise<Response> {
521
+ const parsed = await parseJsonBody<{ endpoint?: unknown }>(req);
522
+ if (!parsed.ok) {
523
+ return parsed.response;
524
+ }
525
+
526
+ if (typeof parsed.body.endpoint !== "string" || parsed.body.endpoint.trim() === "") {
527
+ return jsonError(400, "endpoint must be a non-empty string");
528
+ }
529
+
530
+ const deleted = ctx.db.deletePushSubscription(parsed.body.endpoint.trim());
531
+ return Response.json({ success: true, deleted });
532
+ }