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.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- 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
|
+
}
|