diff --git a/packages/db/src/migrations/0055_create_push_subscriptions.sql b/packages/db/src/migrations/0055_create_push_subscriptions.sql new file mode 100644 index 00000000..1a7c3129 --- /dev/null +++ b/packages/db/src/migrations/0055_create_push_subscriptions.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "push_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "user_id" uuid, + "company_id" uuid, + "device_label" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "push_sub_endpoint_idx" ON "push_subscriptions" ("endpoint"); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index e27ef5f7..80abbab5 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -61,3 +61,4 @@ export { chatMessages } from "./chat_messages.js"; export { chatMessageBookmarks } from "./chat_message_bookmarks.js"; export { chatFiles } from "./chat_files.js"; export { chatFileReferences } from "./chat_file_references.js"; +export { pushSubscriptions } from "./push_subscriptions.js"; diff --git a/packages/db/src/schema/push_subscriptions.ts b/packages/db/src/schema/push_subscriptions.ts new file mode 100644 index 00000000..0c65eb62 --- /dev/null +++ b/packages/db/src/schema/push_subscriptions.ts @@ -0,0 +1,18 @@ +import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; + +export const pushSubscriptions = pgTable( + "push_subscriptions", + { + id: uuid("id").primaryKey().defaultRandom(), + endpoint: text("endpoint").notNull(), + p256dh: text("p256dh").notNull(), + auth: text("auth").notNull(), + userId: uuid("user_id"), + companyId: uuid("company_id"), + deviceLabel: text("device_label"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + endpointIdx: index("push_sub_endpoint_idx").on(table.endpoint), + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4c93158..0dbd1de3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,6 +485,9 @@ importers: '@paperclipai/shared': specifier: workspace:* version: link:../packages/shared + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 ajv: specifier: ^8.18.0 version: 8.18.0 @@ -539,6 +542,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + web-push: + specifier: ^3.6.7 + version: 3.6.7 ws: specifier: ^8.19.0 version: 8.19.0 @@ -3599,6 +3605,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -3700,6 +3709,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3798,6 +3810,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3810,6 +3825,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4383,6 +4401,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -4665,6 +4686,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4800,6 +4825,12 @@ packages: engines: {node: '>=6'} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.37: resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==} hasBin: true @@ -4835,6 +4866,7 @@ packages: libsql@0.5.29: resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lightningcss-android-arm64@1.30.2: @@ -5168,6 +5200,9 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -6116,6 +6151,11 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -9645,6 +9685,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-push@3.6.4': + dependencies: + '@types/node': 25.2.3 + '@types/ws@8.18.1': dependencies: '@types/node': 25.2.3 @@ -9759,6 +9803,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} async-exit-hook@2.0.1: {} @@ -9808,6 +9859,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + bn.js@4.12.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -9832,6 +9885,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@6.0.3: @@ -10328,6 +10383,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.286: {} @@ -10741,6 +10800,8 @@ snapshots: transitivePeerDependencies: - supports-color + http_ece@1.2.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -10858,6 +10919,17 @@ snapshots: json5@2.2.3: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + katex@0.16.37: dependencies: commander: 8.3.0 @@ -11517,6 +11589,8 @@ snapshots: mime@2.6.0: {} + minimalistic-assert@1.0.1: {} + minimist@1.2.8: {} mkdirp@0.5.6: @@ -12675,6 +12749,16 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} diff --git a/server/package.json b/server/package.json index d0c1adba..b9c7d9bd 100644 --- a/server/package.json +++ b/server/package.json @@ -57,6 +57,7 @@ "@paperclipai/db": "workspace:*", "@paperclipai/plugin-sdk": "workspace:*", "@paperclipai/shared": "workspace:*", + "@types/web-push": "^3.6.4", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "better-auth": "1.4.18", @@ -75,6 +76,7 @@ "pino-http": "^10.4.0", "pino-pretty": "^13.1.3", "sharp": "^0.34.5", + "web-push": "^3.6.7", "ws": "^8.19.0", "zod": "^3.24.2" }, diff --git a/server/src/app.ts b/server/src/app.ts index d2b6e465..a9f155c7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -31,6 +31,8 @@ import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { llmRoutes } from "./routes/llms.js"; import { assetRoutes } from "./routes/assets.js"; import { chatFileRoutes } from "./routes/chat-files.js"; +import { pushRoutes } from "./routes/push.js"; +import { initVapid } from "./services/pushService.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; @@ -150,6 +152,7 @@ export async function createApp( api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(chatFileRoutes(db, opts.storageService)); + api.use("/push", pushRoutes(db)); api.use(projectRoutes(db)); api.use(issueRoutes(db, opts.storageService)); api.use(routineRoutes(db)); @@ -294,6 +297,13 @@ export async function createApp( app.use(errorHandler); + // Initialize VAPID for push notifications (graceful skip if keys not set) + try { + initVapid(); + } catch (err) { + logger.warn({ err }, "VAPID init skipped — push notifications unavailable"); + } + jobCoordinator.start(); scheduler.start(); void toolDispatcher.initialize().catch((err) => { diff --git a/server/src/routes/push.ts b/server/src/routes/push.ts new file mode 100644 index 00000000..3d10434f --- /dev/null +++ b/server/src/routes/push.ts @@ -0,0 +1,59 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { getVapidPublicKey, saveSubscription, removeSubscription } from "../services/pushService.js"; + +export function pushRoutes(db: Db): Router { + const router = Router(); + + // GET /api/push/vapid-public-key + router.get("/vapid-public-key", (_req, res) => { + const publicKey = getVapidPublicKey(); + if (!publicKey) { + res.status(404).json({ error: "VAPID not configured" }); + return; + } + res.json({ publicKey }); + }); + + // POST /api/push/subscribe + router.post("/subscribe", async (req, res) => { + const { endpoint, keys, userId, companyId, deviceLabel } = req.body as { + endpoint: string; + keys: { p256dh: string; auth: string }; + userId?: string; + companyId?: string; + deviceLabel?: string; + }; + + if (!endpoint || !keys?.p256dh || !keys?.auth) { + res.status(400).json({ error: "Missing required subscription fields" }); + return; + } + + const id = await saveSubscription(db, { + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + userId, + companyId, + deviceLabel, + }); + + res.status(201).json({ id }); + }); + + // DELETE /api/push/subscribe + router.delete("/subscribe", async (req, res) => { + const { endpoint } = req.body as { endpoint: string }; + + if (!endpoint) { + res.status(400).json({ error: "Missing endpoint" }); + return; + } + + await removeSubscription(db, endpoint); + res.status(204).end(); + }); + + return router; +} diff --git a/server/src/services/pushService.ts b/server/src/services/pushService.ts new file mode 100644 index 00000000..15b01206 --- /dev/null +++ b/server/src/services/pushService.ts @@ -0,0 +1,91 @@ +import webPush from "web-push"; +import { eq, and, isNotNull } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { pushSubscriptions } from "@paperclipai/db"; +import { logger } from "../middleware/logger.js"; + +export function initVapid(): void { + const publicKey = process.env.VAPID_PUBLIC_KEY; + const privateKey = process.env.VAPID_PRIVATE_KEY; + if (!publicKey || !privateKey) { + logger.warn("VAPID keys not configured — push notifications disabled"); + return; + } + try { + webPush.setVapidDetails( + process.env.VAPID_SUBJECT ?? "mailto:admin@nexus.local", + publicKey, + privateKey, + ); + } catch (err) { + logger.warn({ err }, "Failed to initialize VAPID — push notifications disabled"); + } +} + +export function getVapidPublicKey(): string | null { + return process.env.VAPID_PUBLIC_KEY ?? null; +} + +export async function saveSubscription( + db: Db, + params: { + endpoint: string; + p256dh: string; + auth: string; + userId?: string; + companyId?: string; + deviceLabel?: string; + }, +): Promise { + const id = crypto.randomUUID(); + await db.insert(pushSubscriptions).values({ + id, + endpoint: params.endpoint, + p256dh: params.p256dh, + auth: params.auth, + userId: params.userId ?? null, + companyId: params.companyId ?? null, + deviceLabel: params.deviceLabel ?? null, + }).onConflictDoNothing(); + return id; +} + +export async function removeSubscription(db: Db, endpoint: string): Promise { + await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)); +} + +export async function sendPushToAll( + db: Db, + companyId: string | null, + payload: { title: string; body: string; icon?: string; data?: Record }, +): Promise { + const rows = await db + .select() + .from(pushSubscriptions) + .where( + companyId + ? and(eq(pushSubscriptions.companyId, companyId), isNotNull(pushSubscriptions.endpoint)) + : isNotNull(pushSubscriptions.endpoint), + ); + + const payloadStr = JSON.stringify(payload); + + await Promise.allSettled( + rows.map(async (row) => { + try { + await webPush.sendNotification( + { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } }, + payloadStr, + ); + } catch (err: unknown) { + const status = (err as { statusCode?: number }).statusCode; + if (status === 410 || status === 404) { + // Stale subscription — remove it + await removeSubscription(db, row.endpoint).catch(() => {}); + } else { + logger.warn({ err, endpoint: row.endpoint }, "Push notification send failed"); + } + } + }), + ); +}