feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA

Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT (Docker
    volume) with /tmp fallback for Replit dev, else ephemeral in-memory.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes so over-sized notes don't kill delivery.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (account switch on shared
    device) so the previous user's notifications can't leak.
- Three new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe` (auth-gated, Zod-validated).
- Push hooked into the 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick handlers only
  (no asset caching). Focuses an existing tab or opens a new one at the
  payload URL.
- SW registration in `main.tsx` scoped to `BASE_URL`.
- `use-push-subscription` hook (enable/disable/refresh + status:
  unsupported / denied / default / subscribed).
- New `PushToggleRow` in `NotificationSettingsContent` with ar/en strings
  (notifSettings.push.*). iPad copy explains that the user must add to
  Home Screen first for iOS to allow Web Push.

Plumbing
- OpenAPI: 3 new operations under `notifications` tag; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the generated
  key; subscribe endpoint 401s without auth as expected.
- Architect review surfaced two HIGH issues (account-switch leak,
  payload size); both fixed before completion.
This commit is contained in:
Riyadh
2026-05-16 15:26:18 +00:00
parent 6b6b3873a6
commit cff2f454da
21 changed files with 1266 additions and 0 deletions
+2
View File
@@ -36,6 +36,7 @@
"playwright-core": "^1.59.1",
"sanitize-html": "^2.17.3",
"socket.io": "^4.8.3",
"web-push": "^3.6.7",
"zod": "catalog:"
},
"devDependencies": {
@@ -49,6 +50,7 @@
"@types/nodemailer": "^8.0.0",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pg": "^8.20.0",
@@ -13,6 +13,7 @@ import {
usersTable,
} from "@workspace/db";
import { logger } from "./logger";
import { sendPushToUser } from "./push";
/**
* Canonical list of notification event types the Executive Meetings
@@ -211,6 +212,14 @@ export async function broadcastExecutiveMeetingNotifications(
};
for (const uid of recipientUserIds) {
io.to(`user:${uid}`).emit("notification_created", payload);
void sendPushToUser(uid, {
title: "تنبيه اجتماع",
body: "لديك تنبيه اجتماع تنفيذي",
type: "executive_meeting",
relatedId: meetingId,
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
url: "/meetings",
});
}
io.emit("executive_meeting_notifications_changed", {
notificationType,
+292
View File
@@ -0,0 +1,292 @@
import { promises as fs } from "fs";
import path from "path";
import { eq, and } from "drizzle-orm";
import webpush from "web-push";
import { db } from "@workspace/db";
import {
pushSubscriptionsTable,
usersTable,
} from "@workspace/db";
import { logger } from "./logger";
let initPromise: Promise<{
publicKey: string;
privateKey: string;
subject: string;
} | null> | null = null;
function keysFilePath(): string {
// LOCAL_STORAGE_ROOT is set in Docker prod (mounted volume at
// /app/storage). In dev on Replit it's unset, and PRIVATE_OBJECT_DIR
// points to an object-store path that isn't a real local filesystem,
// so we fall back to /tmp.
const root = process.env.LOCAL_STORAGE_ROOT || "/tmp";
return path.join(root, "vapid-keys.json");
}
/**
* Resolve VAPID keys. Priority:
* 1. env (VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY)
* 2. cached on-disk file (auto-generated on first boot)
* 3. generate, persist, return
*
* Returns null only if write + generate both fail, in which case
* Web Push is disabled but the rest of the app keeps running.
*/
async function loadVapid(): Promise<{
publicKey: string;
privateKey: string;
subject: string;
} | null> {
const subject =
process.env.VAPID_SUBJECT ??
`mailto:admin@${process.env.LOCAL_DOMAIN ?? "tx.local"}`;
const envPub = process.env.VAPID_PUBLIC_KEY;
const envPriv = process.env.VAPID_PRIVATE_KEY;
if (envPub && envPriv) {
try {
webpush.setVapidDetails(subject, envPub, envPriv);
return { publicKey: envPub, privateKey: envPriv, subject };
} catch (err) {
logger.error({ err }, "Invalid VAPID env keys — falling back to disk");
}
}
const file = keysFilePath();
try {
const raw = await fs.readFile(file, "utf8");
const parsed = JSON.parse(raw) as {
publicKey?: string;
privateKey?: string;
};
if (parsed.publicKey && parsed.privateKey) {
webpush.setVapidDetails(subject, parsed.publicKey, parsed.privateKey);
return {
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
subject,
};
}
} catch {
/* missing — fall through to generate */
}
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
// Attempt to persist so the key survives restarts. If the target
// directory isn't writable (e.g. Replit dev where LOCAL_STORAGE_ROOT
// is unset), fall back to /tmp. If even that fails, run with an
// ephemeral in-memory key — Web Push still works for this process,
// existing subscriptions will just be invalidated on next restart.
const candidates = [file, path.join("/tmp", "vapid-keys.json")];
let persistedAt: string | null = null;
for (const target of candidates) {
try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(
target,
JSON.stringify({ publicKey, privateKey }, null, 2),
{ mode: 0o600 },
);
persistedAt = target;
break;
} catch {
/* try next candidate */
}
}
webpush.setVapidDetails(subject, publicKey, privateKey);
if (persistedAt) {
logger.warn(
{ file: persistedAt },
"Generated VAPID keys (no VAPID_PUBLIC_KEY/PRIVATE_KEY env set). " +
"Persisted to disk so they survive restarts. Copy them into env to " +
"share across hosts.",
);
} else {
logger.error(
"Generated ephemeral VAPID keys (could not write to disk). " +
"Push subscriptions will be invalidated on next restart.",
);
}
return { publicKey, privateKey, subject };
}
export function getVapid() {
if (!initPromise) initPromise = loadVapid();
return initPromise;
}
export type PushPayload = {
title: string;
body: string;
tag?: string;
url?: string;
type?: string;
relatedId?: number | null;
};
type ChannelType = "order" | "executive_meeting" | "note" | "info";
/**
* Check the user's per-channel + global mute preferences. Mirrors the
* client-side gating in `useNotificationsSocket` so a server-pushed
* notification stays silent when the user has muted that channel.
*/
async function userAllowsChannel(
userId: number,
channel: ChannelType,
): Promise<boolean> {
const [u] = await db
.select({
muted: usersTable.notificationsMuted,
orders: usersTable.notifyOrdersEnabled,
meetings: usersTable.notifyMeetingsEnabled,
notes: usersTable.notifyNotesEnabled,
})
.from(usersTable)
.where(eq(usersTable.id, userId))
.limit(1);
if (!u) return false;
if (u.muted) return false;
if (channel === "order") return u.orders;
if (channel === "executive_meeting") return u.meetings;
if (channel === "note") return u.notes;
return true;
}
/**
* Send a Web Push notification to every active subscription a user
* owns. Drops 404/410 subscriptions on the fly. Honours the user's
* mute + per-channel preferences. Safe to fire-and-forget.
*/
export async function sendPushToUser(
userId: number,
payload: PushPayload,
): Promise<void> {
if (!Number.isInteger(userId) || userId <= 0) return;
const channel = (payload.type as ChannelType) ?? "info";
const allowed = await userAllowsChannel(userId, channel);
if (!allowed) return;
const vapid = await getVapid();
if (!vapid) return;
const subs = await db
.select()
.from(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.userId, userId));
if (subs.length === 0) return;
// Web Push payloads are capped at 4096 bytes after encryption padding.
// We aim for ~3500 to leave headroom and truncate the (likely-Arabic,
// multi-byte) body if needed so a long note doesn't blow up delivery
// for every device the user owns.
const MAX_BYTES = 3500;
let safePayload = payload;
let json = JSON.stringify(safePayload);
if (Buffer.byteLength(json, "utf8") > MAX_BYTES) {
const overhead = Buffer.byteLength(
JSON.stringify({ ...safePayload, body: "" }),
"utf8",
);
const budget = Math.max(0, MAX_BYTES - overhead - 1);
let truncated = safePayload.body;
while (Buffer.byteLength(truncated, "utf8") > budget && truncated.length > 0) {
truncated = truncated.slice(0, -1);
}
safePayload = { ...safePayload, body: truncated + "…" };
json = JSON.stringify(safePayload);
}
await Promise.all(
subs.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
json,
{ TTL: 60 },
);
await db
.update(pushSubscriptionsTable)
.set({ lastUsedAt: new Date() })
.where(eq(pushSubscriptionsTable.id, sub.id));
} catch (err) {
const status = (err as { statusCode?: number })?.statusCode;
if (status === 404 || status === 410) {
await db
.delete(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.id, sub.id));
logger.info(
{ userId, endpoint: sub.endpoint, status },
"Pruned stale push subscription",
);
} else {
logger.warn({ err, userId, status }, "Web Push send failed");
}
}
}),
);
}
export async function upsertSubscription(
userId: number,
input: {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string | null;
},
): Promise<void> {
const ua = input.userAgent ? input.userAgent.slice(0, 400) : null;
// If the same browser endpoint already exists under a different user
// (shared device, account switch), delete the stale row first so the
// previous user's notifications can't leak onto this device. The
// unique constraint on `endpoint` would otherwise quietly hand the
// subscription over via UPDATE.
const [existing] = await db
.select({ id: pushSubscriptionsTable.id, userId: pushSubscriptionsTable.userId })
.from(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.endpoint, input.endpoint))
.limit(1);
if (existing && existing.userId !== userId) {
await db
.delete(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.id, existing.id));
}
await db
.insert(pushSubscriptionsTable)
.values({
userId,
endpoint: input.endpoint,
p256dh: input.p256dh,
auth: input.auth,
userAgent: ua,
})
.onConflictDoUpdate({
target: pushSubscriptionsTable.endpoint,
set: {
userId,
p256dh: input.p256dh,
auth: input.auth,
userAgent: ua,
lastUsedAt: new Date(),
},
});
}
export async function removeSubscription(
userId: number,
endpoint: string,
): Promise<void> {
await db
.delete(pushSubscriptionsTable)
.where(
and(
eq(pushSubscriptionsTable.userId, userId),
eq(pushSubscriptionsTable.endpoint, endpoint),
),
);
}
+2
View File
@@ -16,6 +16,7 @@ import groupsRouter from "./groups";
import rolesRouter from "./roles";
import auditRouter from "./audit";
import executiveMeetingsRouter from "./executive-meetings";
import pushRouter from "./push";
const router: IRouter = Router();
@@ -36,5 +37,6 @@ router.use(groupsRouter);
router.use(rolesRouter);
router.use(auditRouter);
router.use(executiveMeetingsRouter);
router.use(pushRouter);
export default router;
+17
View File
@@ -13,6 +13,7 @@ import {
notificationsTable,
} from "@workspace/db";
import { requireAuth, getUserRoles } from "../middlewares/auth";
import { sendPushToUser } from "../lib/push";
const router: IRouter = Router();
@@ -1132,6 +1133,14 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
})
.returning();
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
void sendPushToUser(r.recipientUserId, {
title: "ملاحظة جديدة",
body: `${senderNameAr} أرسل لك ملاحظة`,
type: "note",
relatedId: note.id,
tag: `note-${note.id}`,
url: "/notes",
});
await emitToUser(r.recipientUserId, "note_received", {
noteId: note.id,
recipientRowId: r.id,
@@ -1478,6 +1487,14 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
})
.returning();
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
void sendPushToUser(otherPartyUserId, {
title: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
body: `${replierNameAr} رد على ملاحظة`,
type: "note",
relatedId: id.id,
tag: `note-reply-${reply.id}`,
url: "/notes",
});
// Enrich the realtime payload so the recipient's client can render the
// floating reply card without an extra round-trip. We truncate the body
// to keep the socket frame small; the full reply is still in /notes.
+65
View File
@@ -0,0 +1,65 @@
import { Router, type IRouter } from "express";
import { z } from "zod";
import { requireAuth } from "../middlewares/auth";
import {
getVapid,
upsertSubscription,
removeSubscription,
} from "../lib/push";
const router: IRouter = Router();
const SubscribeBody = z.object({
endpoint: z.string().url().max(2048),
keys: z.object({
p256dh: z.string().min(1).max(512),
auth: z.string().min(1).max(512),
}),
});
const UnsubscribeBody = z.object({
endpoint: z.string().url().max(2048),
});
router.get("/push/vapid-public-key", async (_req, res): Promise<void> => {
const vapid = await getVapid();
if (!vapid) {
res.status(503).json({ error: "Web Push not configured" });
return;
}
res.json({ publicKey: vapid.publicKey });
});
router.post("/push/subscribe", requireAuth, async (req, res): Promise<void> => {
const parsed = SubscribeBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
const ua = (req.headers["user-agent"] as string | undefined) ?? null;
await upsertSubscription(userId, {
endpoint: parsed.data.endpoint,
p256dh: parsed.data.keys.p256dh,
auth: parsed.data.keys.auth,
userAgent: ua,
});
res.json({ success: true });
});
router.post(
"/push/unsubscribe",
requireAuth,
async (req, res): Promise<void> => {
const parsed = UnsubscribeBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
await removeSubscription(userId, parsed.data.endpoint);
res.json({ success: true });
},
);
export default router;
@@ -12,6 +12,7 @@ import {
permissionsTable,
} from "@workspace/db";
import { requireAuth, requirePermission } from "../middlewares/auth";
import { sendPushToUser } from "../lib/push";
import {
CreateServiceOrderBody,
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
@@ -133,6 +134,14 @@ async function notifyUser(
})
.returning();
await emitToUser(userId, "notification_created", notification);
void sendPushToUser(userId, {
title: titleAr,
body: bodyAr,
type: "order",
relatedId: orderId,
tag: `order-${orderId}`,
url: "/my-orders",
});
}
async function broadcastIncomingChanged(receiverIds: number[]) {
+82
View File
@@ -0,0 +1,82 @@
/* Tx OS service worker — Web Push only.
*
* Scope: served from the SPA's base path (e.g. `/sw.js`). The SPA
* registers it with `scope: BASE_URL` so the SW controls the full
* Tx OS surface but doesn't intercept anything outside the artifact.
*
* We deliberately do NOT cache app assets here — Tx OS is self-hosted
* on a single machine, so the network is fast and reliable. Adding
* cache layers would risk shipping stale React bundles after an
* upgrade.
*
* Events:
* - install / activate: claim clients immediately so the first push
* after registration lands without a reload.
* - push: render a system notification (lock-screen + banner on iPad
* PWA, system tray on macOS, etc.).
* - notificationclick: focus an existing Tx OS tab (or open one) and
* navigate to the payload's URL.
*/
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (event) => {
/** @type {{title?: string, body?: string, tag?: string, url?: string, type?: string}} */
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = { title: event.data ? event.data.text() : "Tx OS" };
}
const title = payload.title || "Tx OS";
const options = {
body: payload.body || "",
tag: payload.tag || payload.type || "tx-os",
renotify: true,
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
data: {
url: payload.url || "/",
type: payload.type || null,
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetPath = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
(async () => {
const allClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
// Prefer an already-open Tx OS tab; navigate it to targetPath
// and focus.
for (const client of allClients) {
try {
await client.focus();
if ("navigate" in client) {
await client.navigate(targetPath);
}
return;
} catch {
/* try next */
}
}
if (self.clients.openWindow) {
await self.clients.openWindow(targetPath);
}
})(),
);
});
@@ -8,6 +8,7 @@ import {
type NotificationSoundId,
} from "@workspace/api-client-react";
import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-react";
import { usePushSubscription } from "@/hooks/use-push-subscription";
import { useToast } from "@/hooks/use-toast";
import {
Popover,
@@ -315,6 +316,10 @@ export function NotificationSettingsContent() {
<div className="h-px bg-foreground/10 mx-1 my-1" />
<PushToggleRow />
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={user[cfg.soundKey]}
onPick={(id) =>
@@ -325,6 +330,69 @@ export function NotificationSettingsContent() {
);
}
function PushToggleRow() {
const { t } = useTranslation();
const { toast } = useToast();
const { status, busy, enable, disable } = usePushSubscription();
const subscribed = status === "subscribed";
const disabled = busy || status === "unsupported" || status === "denied";
const subtitle =
status === "unsupported"
? t("notifSettings.push.unsupported")
: status === "denied"
? t("notifSettings.push.denied")
: subscribed
? t("notifSettings.push.enabled")
: t("notifSettings.push.desc");
return (
<div className="px-2 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-foreground truncate">
{t("notifSettings.push.title")}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
{subtitle}
</div>
</div>
<button
type="button"
disabled={disabled}
data-testid="notif-push-toggle"
onClick={async () => {
if (subscribed) {
await disable();
} else {
const ok = await enable();
if (!ok) {
toast({ title: t("notifSettings.push.enableFailed") });
}
}
}}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
subscribed ? "bg-primary" : "bg-foreground/15"
} ${disabled ? "opacity-50" : ""}`}
aria-pressed={subscribed}
aria-label={
subscribed
? t("notifSettings.push.disable")
: t("notifSettings.push.enable")
}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
subscribed
? "translate-x-[18px] rtl:translate-x-0.5"
: "translate-x-0.5 rtl:translate-x-[18px]"
}`}
/>
</button>
</div>
</div>
);
}
export function NotificationSettingsPicker() {
const { t } = useTranslation();
const { user } = useAuth();
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useState } from "react";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
type PushStatus =
| "unsupported"
| "denied"
| "default"
| "subscribed";
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function isSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
if (!("serviceWorker" in navigator)) return null;
// Wait for the SPA's registration in main.tsx to settle. `ready`
// resolves once an active SW controls the page.
return navigator.serviceWorker.ready;
}
/**
* Hook that exposes the user's current Web Push subscription state and
* `enable()` / `disable()` actions. Talks to the backend at
* `${BASE}/api/push/*`.
*
* Web Push on iOS Safari only works when the page is installed as a
* PWA (Add to Home Screen). The hook surfaces "unsupported" in that
* case so the UI can prompt the user to install first.
*/
export function usePushSubscription() {
const [status, setStatus] = useState<PushStatus>(() =>
!isSupported()
? "unsupported"
: Notification.permission === "denied"
? "denied"
: "default",
);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
if (!isSupported()) {
setStatus("unsupported");
return;
}
if (Notification.permission === "denied") {
setStatus("denied");
return;
}
const reg = await getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (sub) setStatus("subscribed");
else setStatus(Notification.permission === "granted" ? "default" : "default");
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const enable = useCallback(async (): Promise<boolean> => {
if (!isSupported()) return false;
setBusy(true);
try {
const perm = await Notification.requestPermission();
if (perm !== "granted") {
setStatus(perm === "denied" ? "denied" : "default");
return false;
}
const reg = await getRegistration();
if (!reg) return false;
const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, {
credentials: "include",
});
if (!keyRes.ok) return false;
const { publicKey } = (await keyRes.json()) as { publicKey: string };
// If a stale subscription exists from a previous VAPID key,
// unsubscribe locally first so applicationServerKey isn't
// mismatched.
const existing = await reg.pushManager.getSubscription();
if (existing) {
try {
await existing.unsubscribe();
} catch {
/* ignore */
}
}
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
const json = sub.toJSON();
const subRes = await fetch(`${BASE}/api/push/subscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
}),
});
if (!subRes.ok) {
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
return false;
}
setStatus("subscribed");
return true;
} catch {
return false;
} finally {
setBusy(false);
}
}, []);
const disable = useCallback(async (): Promise<void> => {
if (!isSupported()) return;
setBusy(true);
try {
const reg = await getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (sub) {
const endpoint = sub.endpoint;
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
await fetch(`${BASE}/api/push/unsubscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
}).catch(() => {});
}
setStatus(Notification.permission === "denied" ? "denied" : "default");
} finally {
setBusy(false);
}
}, []);
return { status, busy, enable, disable, refresh };
}
+10
View File
@@ -240,6 +240,16 @@
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
"autoplayHintIos": "اضغط في أي مكان لتفعيل الأصوات. إذا ما زلت لا تسمعها، تأكد إن مستوى صوت الوسائط مرفوع (نفس الصوت اللي يشغّل اليوتيوب).",
"push": {
"title": "إشعارات شاشة القفل",
"desc": "استقبل التنبيهات على الشاشة الرئيسية أو شاشة القفل حتى لو كان Tx مغلقاً.",
"enable": "تفعيل",
"disable": "إيقاف",
"enabled": "مفعّل",
"denied": "محظور — فعّله من إعدادات الجهاز",
"unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.",
"enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى."
},
"slot": {
"order": "الطلبات",
"meeting": "الاجتماعات",
+10
View File
@@ -246,6 +246,16 @@
"autoplayHintTitle": "Sounds need a click",
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
"autoplayHintIos": "Tap anywhere to enable sounds. If you still don't hear them, raise the *media* volume (the same one that controls YouTube).",
"push": {
"title": "Lock-screen notifications",
"desc": "Get alerts on the home/lock screen when Tx OS is closed.",
"enable": "Enable",
"disable": "Disable",
"enabled": "Enabled",
"denied": "Blocked — enable from device settings",
"unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.",
"enableFailed": "Could not enable notifications. Try again."
},
"slot": {
"order": "Orders",
"meeting": "Meetings",
+21
View File
@@ -17,4 +17,25 @@ if (typeof document !== "undefined" && document.fonts?.load) {
void document.fonts.load('700 1em "DIN Next LT Arabic"');
}
// Register the Web Push service worker scoped to the SPA's base path.
// The SW only handles `push` + `notificationclick` — no asset caching —
// so it's safe to register unconditionally as soon as the page boots.
// Without registration, iOS PWAs cannot receive lock-screen notifications.
if (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
window.isSecureContext
) {
const base = import.meta.env.BASE_URL.endsWith("/")
? import.meta.env.BASE_URL
: `${import.meta.env.BASE_URL}/`;
window.addEventListener("load", () => {
navigator.serviceWorker
.register(`${base}sw.js`, { scope: base })
.catch(() => {
/* SW registration is best-effort — push will just stay disabled */
});
});
}
createRoot(document.getElementById("root")!).render(<App />);
+3
View File
@@ -74,6 +74,9 @@ services:
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
SMTP_SECURE: ${SMTP_SECURE:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
volumes:
- app_storage:/app/storage
@@ -1303,6 +1303,24 @@ export type DeleteServiceParams = {
force?: boolean;
};
export type GetPushVapidPublicKey200 = {
publicKey: string;
};
export type SubscribePushBodyKeys = {
p256dh: string;
auth: string;
};
export type SubscribePushBody = {
endpoint: string;
keys: SubscribePushBodyKeys;
};
export type UnsubscribePushBody = {
endpoint: string;
};
export type ListUsersParams = {
/**
* Free-text search across username, email, and display names
+250
View File
@@ -64,6 +64,7 @@ import type {
GetAdminUserDependentOrdersParams,
GetAppPermissionAuditParams,
GetGroupPermissionAuditParams,
GetPushVapidPublicKey200,
GetRolePermissionAuditParams,
GetUserPermissionAuditParams,
Group,
@@ -106,7 +107,9 @@ import type {
ServiceDeletionConflict,
ServiceDependentOrdersPage,
ServiceOrder,
SubscribePushBody,
SuccessResponse,
UnsubscribePushBody,
UpdateAppBody,
UpdateAppSettingsBody,
UpdateClockHour12Body,
@@ -3573,6 +3576,253 @@ export const useMarkAllNotificationsRead = <
return useMutation(getMarkAllNotificationsReadMutationOptions(options));
};
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export const getGetPushVapidPublicKeyUrl = () => {
return `/api/push/vapid-public-key`;
};
export const getPushVapidPublicKey = async (
options?: RequestInit,
): Promise<GetPushVapidPublicKey200> => {
return customFetch<GetPushVapidPublicKey200>(getGetPushVapidPublicKeyUrl(), {
...options,
method: "GET",
});
};
export const getGetPushVapidPublicKeyQueryKey = () => {
return [`/api/push/vapid-public-key`] as const;
};
export const getGetPushVapidPublicKeyQueryOptions = <
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetPushVapidPublicKeyQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPushVapidPublicKey>>
> = ({ signal }) => getPushVapidPublicKey({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPushVapidPublicKeyQueryResult = NonNullable<
Awaited<ReturnType<typeof getPushVapidPublicKey>>
>;
export type GetPushVapidPublicKeyQueryError = ErrorType<ErrorResponse>;
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export function useGetPushVapidPublicKey<
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPushVapidPublicKeyQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Register a Web Push subscription for the current user
*/
export const getSubscribePushUrl = () => {
return `/api/push/subscribe`;
};
export const subscribePush = async (
subscribePushBody: SubscribePushBody,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getSubscribePushUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(subscribePushBody),
});
};
export const getSubscribePushMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
> => {
const mutationKey = ["subscribePush"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof subscribePush>>,
{ data: BodyType<SubscribePushBody> }
> = (props) => {
const { data } = props ?? {};
return subscribePush(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SubscribePushMutationResult = NonNullable<
Awaited<ReturnType<typeof subscribePush>>
>;
export type SubscribePushMutationBody = BodyType<SubscribePushBody>;
export type SubscribePushMutationError = ErrorType<unknown>;
/**
* @summary Register a Web Push subscription for the current user
*/
export const useSubscribePush = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
> => {
return useMutation(getSubscribePushMutationOptions(options));
};
/**
* @summary Remove a Web Push subscription for the current user
*/
export const getUnsubscribePushUrl = () => {
return `/api/push/unsubscribe`;
};
export const unsubscribePush = async (
unsubscribePushBody: UnsubscribePushBody,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getUnsubscribePushUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(unsubscribePushBody),
});
};
export const getUnsubscribePushMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
> => {
const mutationKey = ["unsubscribePush"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unsubscribePush>>,
{ data: BodyType<UnsubscribePushBody> }
> = (props) => {
const { data } = props ?? {};
return unsubscribePush(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UnsubscribePushMutationResult = NonNullable<
Awaited<ReturnType<typeof unsubscribePush>>
>;
export type UnsubscribePushMutationBody = BodyType<UnsubscribePushBody>;
export type UnsubscribePushMutationError = ErrorType<unknown>;
/**
* @summary Remove a Web Push subscription for the current user
*/
export const useUnsubscribePush = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
> => {
return useMutation(getUnsubscribePushMutationOptions(options));
};
/**
* @summary List all users (admin)
*/
+77
View File
@@ -972,6 +972,83 @@ paths:
schema:
$ref: "#/components/schemas/SuccessResponse"
/push/vapid-public-key:
get:
operationId: getPushVapidPublicKey
tags: [notifications]
summary: Get the server's VAPID public key for Web Push subscriptions
responses:
"200":
description: VAPID public key
content:
application/json:
schema:
type: object
properties:
publicKey:
type: string
required: [publicKey]
"503":
description: Web Push not configured on this server
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/push/subscribe:
post:
operationId: subscribePush
tags: [notifications]
summary: Register a Web Push subscription for the current user
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
keys:
type: object
properties:
p256dh:
type: string
auth:
type: string
required: [p256dh, auth]
required: [endpoint, keys]
responses:
"200":
description: Subscribed
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
/push/unsubscribe:
post:
operationId: unsubscribePush
tags: [notifications]
summary: Remove a Web Push subscription for the current user
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
required: [endpoint]
responses:
"200":
description: Unsubscribed
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
# Users (admin)
/users:
get:
+33
View File
@@ -1458,6 +1458,39 @@ export const MarkAllNotificationsReadResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export const GetPushVapidPublicKeyResponse = zod.object({
publicKey: zod.string(),
});
/**
* @summary Register a Web Push subscription for the current user
*/
export const SubscribePushBody = zod.object({
endpoint: zod.string(),
keys: zod.object({
p256dh: zod.string(),
auth: zod.string(),
}),
});
export const SubscribePushResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary Remove a Web Push subscription for the current user
*/
export const UnsubscribePushBody = zod.object({
endpoint: zod.string(),
});
export const UnsubscribePushResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary List all users (admin)
*/
+1
View File
@@ -14,3 +14,4 @@ export * from "./audit-logs";
export * from "./role-audit";
export * from "./permission-audit";
export * from "./executive-meetings";
export * from "./push-subscriptions";
+35
View File
@@ -0,0 +1,35 @@
import {
pgTable,
serial,
integer,
text,
varchar,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { usersTable } from "./users";
export const pushSubscriptionsTable = pgTable(
"push_subscriptions",
{
id: serial("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
endpoint: text("endpoint").notNull().unique(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
userAgent: varchar("user_agent", { length: 400 }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
userIdx: index("push_subscriptions_user_idx").on(t.userId),
}),
);
export type PushSubscriptionRow = typeof pushSubscriptionsTable.$inferSelect;
+100
View File
@@ -138,6 +138,9 @@ importers:
socket.io:
specifier: ^4.8.3
version: 4.8.3
web-push:
specifier: ^3.6.7
version: 3.6.7
zod:
specifier: 'catalog:'
version: 3.25.76
@@ -172,6 +175,9 @@ importers:
'@types/sanitize-html':
specifier: ^2.16.1
version: 2.16.1
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
esbuild:
specifier: ^0.27.3
version: 0.27.3
@@ -3002,6 +3008,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -3093,6 +3102,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -3125,6 +3138,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -3155,6 +3171,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'}
@@ -3180,6 +3199,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==}
@@ -3508,6 +3530,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==}
@@ -3812,6 +3837,14 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
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'}
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
@@ -3933,6 +3966,12 @@ packages:
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
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==}
leven@4.1.0:
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4091,6 +4130,9 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4942,6 +4984,11 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -7506,6 +7553,10 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/web-push@3.6.4':
dependencies:
'@types/node': 25.3.5
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.3.5
@@ -7615,6 +7666,8 @@ snapshots:
acorn@8.16.0: {}
agent-base@7.1.4: {}
ajv-draft-04@1.0.0(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
@@ -7642,6 +7695,13 @@ snapshots:
dependencies:
tslib: 2.8.1
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
atomic-sleep@1.0.0: {}
balanced-match@1.0.2: {}
@@ -7660,6 +7720,8 @@ snapshots:
dependencies:
require-from-string: 2.0.2
bn.js@4.12.3: {}
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -7700,6 +7762,8 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
bytes@3.1.2: {}
@@ -7912,6 +7976,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.307: {}
@@ -8344,6 +8412,15 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
human-signals@8.0.1: {}
i18next@26.0.6(typescript@5.9.3):
@@ -8421,6 +8498,17 @@ snapshots:
jsonpointer@5.0.1: {}
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
leven@4.1.0: {}
lightningcss-android-arm64@1.31.1:
@@ -8547,6 +8635,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
minimalistic-assert@1.0.1: {}
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
@@ -9440,6 +9530,16 @@ snapshots:
w3c-keyname@2.2.8: {}
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
which@2.0.2:
dependencies:
isexe: 2.0.0