diff --git a/.env.example b/.env.example
index 8ebb743..edc0245 100644
--- a/.env.example
+++ b/.env.example
@@ -166,8 +166,12 @@ SMTP_URL=
EMAIL_FROM=
# Local development only: capture password-reset email and print its link to the API console.
EMAIL_EMULATOR=
+# Optional web push keys (set both). Unset: generated once and stored encrypted in the database.
+# Browsers only allow push on https (or localhost).
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
+# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https.
+VAPID_SUBJECT=
# Structured logs to stdout (json in production, pretty in development unless overridden).
LOG_LEVEL=info
LOG_FORMAT=
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index c8b5057..b2b0faf 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
-import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
@@ -35,13 +34,14 @@ import {
destroyBot,
EmailEmulator,
EncryptedSecretStore,
- ExpoPushProvider,
+ ExpoPushSender,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
InMemoryRealtimeFanout,
InstalledConnectorProvider,
IntegrationProviderSettings,
+ importLegacyPushTokens,
isComposioEnabled,
isMessagingSurfaceEnabled,
isPipedreamEnabled,
@@ -54,9 +54,9 @@ import {
PiOAuthLogins,
PipedreamConnector,
PostgresRealtimeFanout,
+ PushNotificationProvider,
pipedreamConfigFromEnv,
piSessionsRoot,
- pushTokenPath,
reconcileCloudAgents,
reconcileComputerUpdates,
reconcileDelegatedTasks,
@@ -67,6 +67,7 @@ import {
toTeamChatInbound,
VapidKeys,
vapidEnvFromProcess,
+ WebPushSender,
} from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core";
@@ -311,8 +312,14 @@ export async function createApp(
: new PiAgentRuntime({
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
});
- const notifications = new ExpoPushProvider(env.dataDir);
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
+ const notifications = new PushNotificationProvider(prisma, {
+ webpush: new WebPushSender(vapid),
+ expo: new ExpoPushSender(),
+ });
+ void importLegacyPushTokens(prisma, env.dataDir).catch((error) =>
+ getLogger().error("legacy push token import failed", error),
+ );
const auth = createAuth(prisma, {
secret: env.authSecret,
baseURL: env.authUrl,
@@ -368,7 +375,6 @@ export async function createApp(
),
);
await removePiUserSessions(env.dataDir, userId);
- await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
},
});
// One provider instance so emulator launches and polls share the same Map.
diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts
index d95e1fc..b5606a4 100644
--- a/apps/api/src/router.ts
+++ b/apps/api/src/router.ts
@@ -45,7 +45,6 @@ import {
computerSupportsUpdate,
computerUpdateView,
createVoiceProvider,
- deletePushToken,
deploymentAutoReviewDefault,
destroyBot,
displayBotWorkspacePath,
@@ -77,7 +76,6 @@ import {
resolveAutoReviewChecker,
resolveBotWorkspacePath,
sanitizeComposioError,
- savePushToken,
scheduleComputerControlExpiry,
scheduleComputerSleep,
screenLeaseIdForRun,
@@ -140,6 +138,7 @@ import {
projectWorkspaceDto,
releaseSpaceDeletionClaim,
removePushSubscription,
+ removePushSubscriptionsOfKind,
renewSpaceDeletionClaim,
requireMembership,
SPACE_DELETION_CLAIM_TIMEOUT_MS,
@@ -4798,11 +4797,16 @@ export function createRouter(deps: RouterDeps) {
},
notifications: {
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
- await savePushToken(deps.dataDir, context.actor.userId, input.token);
+ await savePushSubscription(deps.prisma, context.actor.userId, {
+ kind: "expo",
+ endpoint: input.token,
+ userAgent: context.userAgent,
+ });
return { ok: true as const };
}),
+ // The mobile app signs out without naming its token, so all of its tokens go.
unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => {
- await deletePushToken(deps.dataDir, context.actor.userId);
+ await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo");
return { ok: true as const };
}),
settings: {
diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts
index 84d7794..32b77a3 100644
--- a/apps/web/e2e/helpers.ts
+++ b/apps/web/e2e/helpers.ts
@@ -242,6 +242,7 @@ export async function openUserSettings(
| "memory"
| "profile"
| "voice"
+ | "notifications"
| "usage"
| "computer"
| "updates",
diff --git a/apps/web/e2e/notification-settings.spec.ts b/apps/web/e2e/notification-settings.spec.ts
new file mode 100644
index 0000000..27e1571
--- /dev/null
+++ b/apps/web/e2e/notification-settings.spec.ts
@@ -0,0 +1,129 @@
+/**
+ * E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless
+ * Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a
+ * reload.
+ */
+import { expect, type Page, test } from "@playwright/test";
+import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
+
+const ENDPOINT = "https://push.example.test/e2e-device";
+
+/**
+ * A fake Notification permission and service worker registration, kept in sessionStorage so they
+ * survive a reload. Headless Chromium reports notifications as denied even when granted.
+ */
+async function stubPushApi(page: Page, endpoint: string) {
+ await page.addInitScript((pushEndpoint) => {
+ const permissionKey = "e2e:notification-permission";
+ const notification = Object.defineProperties(function Notification() {}, {
+ permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" },
+ requestPermission: {
+ value: async () => {
+ sessionStorage.setItem(permissionKey, "granted");
+ return "granted";
+ },
+ },
+ });
+ Object.defineProperty(window, "Notification", {
+ configurable: true,
+ writable: true,
+ value: notification,
+ });
+ const storageKey = "e2e:push-subscription";
+ const current = () => {
+ const stored = sessionStorage.getItem(storageKey);
+ if (!stored) return null;
+ const saved = JSON.parse(stored) as { serverKey: number[] };
+ return {
+ endpoint: pushEndpoint,
+ options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer },
+ toJSON: () => ({
+ endpoint: pushEndpoint,
+ keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) },
+ }),
+ unsubscribe: async () => {
+ sessionStorage.removeItem(storageKey);
+ return true;
+ },
+ };
+ };
+ const registration = {
+ scope: `${location.origin}/`,
+ pushManager: {
+ getSubscription: async () => current(),
+ subscribe: async (options: { applicationServerKey: Uint8Array }) => {
+ sessionStorage.setItem(
+ storageKey,
+ JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }),
+ );
+ return current();
+ },
+ },
+ };
+ Object.defineProperty(navigator, "serviceWorker", {
+ configurable: true,
+ value: {
+ register: async () => registration,
+ getRegistration: async () => registration,
+ ready: Promise.resolve(registration),
+ },
+ });
+ if (!("PushManager" in window)) {
+ Object.defineProperty(window, "PushManager", { configurable: true, value: class {} });
+ }
+ }, endpoint);
+}
+
+test("notification settings: push on this device, quiet hours and nudges", async ({
+ page,
+}, testInfo) => {
+ await stubPushApi(page, ENDPOINT);
+ await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
+ await completeOnboarding(page);
+
+ let settings = await openUserSettings(page, "notifications");
+ const panel = settings.getByTestId("notification-settings");
+ const push = panel.getByTestId("notification-push-switch");
+ await expect(push).not.toBeChecked();
+
+ const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe");
+ await push.click();
+ expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
+ await expect(push).toBeChecked();
+
+ const quiet = panel.getByTestId("notification-quiet-hours-switch");
+ await expect(quiet).not.toBeChecked();
+ await quiet.click();
+ await expect(quiet).toBeChecked();
+ const start = panel.getByTestId("notification-quiet-hours-start");
+ await expect(start).toHaveValue("22:00");
+ await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00");
+ const saved = page.waitForResponse(
+ (response) =>
+ response.url().includes("/rpc/notifications/settings/update") &&
+ response.request().postData()?.includes("23:30") === true,
+ );
+ await start.fill("23:30");
+ await saved;
+
+ const nudges = panel.getByTestId("notification-nudges-switch");
+ await expect(nudges).toBeChecked();
+ await nudges.click();
+ await expect(nudges).not.toBeChecked();
+ await expect
+ .poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null))
+ .toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } });
+
+ await captureScreenshot(page, testInfo, "notification-settings");
+
+ await page.reload();
+ settings = await openUserSettings(page, "notifications");
+ await expect(settings.getByTestId("notification-push-switch")).toBeChecked();
+ await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30");
+ await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked();
+
+ const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe");
+ await settings.getByTestId("notification-push-switch").click();
+ expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
+ await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked();
+});
diff --git a/apps/web/src/components/NotificationSettingsPanel.tsx b/apps/web/src/components/NotificationSettingsPanel.tsx
new file mode 100644
index 0000000..de71979
--- /dev/null
+++ b/apps/web/src/components/NotificationSettingsPanel.tsx
@@ -0,0 +1,197 @@
+import { Trans, useLingui } from "@lingui/react/macro";
+import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts";
+import { Input, Switch } from "@rakazo/ui-web";
+import type { ReactNode } from "react";
+import { useEffect, useId, useRef, useState } from "react";
+import { rpc } from "../lib/rpc";
+import { disableWebPush, enableWebPush, webPushEnabled, webPushSupported } from "../lib/web-push";
+
+const DEFAULT_QUIET_HOURS = { start: "22:00", end: "07:00" };
+const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
+
+type PushState = "loading" | "on" | "off" | "blocked";
+
+function SettingRow({
+ label,
+ testId,
+ checked,
+ disabled,
+ onCheckedChange,
+ children,
+}: {
+ label: ReactNode;
+ testId: string;
+ checked: boolean;
+ disabled?: boolean;
+ onCheckedChange: (checked: boolean) => void;
+ children?: ReactNode;
+}) {
+ const labelId = useId();
+ return (
+
+
+
+ {label}
+
+
+
+ {children}
+
+ );
+}
+
+/** Push on this browser, quiet hours and nudges. Quiet hours and nudges apply to every device. */
+export function NotificationSettingsPanel() {
+ const { t } = useLingui();
+ const pushSupported = webPushSupported();
+ const [settings, setSettings] = useState(null);
+ const [push, setPush] = useState("loading");
+ const [pushBusy, setPushBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [quietDraft, setQuietDraft] = useState(DEFAULT_QUIET_HOURS);
+ const saves = useRef(Promise.resolve());
+
+ useEffect(() => {
+ let cancelled = false;
+ rpc.notifications.settings
+ .get()
+ .then((next) => {
+ if (cancelled) return;
+ setSettings(next);
+ if (next.quietHours) setQuietDraft(next.quietHours);
+ })
+ .catch(() => {
+ if (!cancelled) setError(t`Couldn't load`);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [t]);
+
+ useEffect(() => {
+ if (!pushSupported) return;
+ let cancelled = false;
+ void webPushEnabled().then((enabled) => {
+ if (cancelled) return;
+ setPush(enabled ? "on" : Notification.permission === "denied" ? "blocked" : "off");
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [pushSupported]);
+
+ /** Saves in order, so the last change the user made is the one that sticks. */
+ function save(patch: NotificationSettingsPatch) {
+ setError(null);
+ setSettings((current) => (current ? { ...current, ...patch } : current));
+ saves.current = saves.current.then(async () => {
+ try {
+ setSettings(await rpc.notifications.settings.update(patch));
+ } catch {
+ setError(t`Couldn't save`);
+ setSettings(await rpc.notifications.settings.get().catch(() => null));
+ }
+ });
+ }
+
+ async function changePush(next: boolean) {
+ setPushBusy(true);
+ setError(null);
+ try {
+ if (!next) {
+ await disableWebPush();
+ setPush("off");
+ return;
+ }
+ const result = await enableWebPush();
+ if (result === "enabled") setPush("on");
+ else if (result === "denied") setPush("blocked");
+ else setError(t`Couldn't save`);
+ } catch {
+ setError(t`Couldn't save`);
+ } finally {
+ setPushBusy(false);
+ }
+ }
+
+ function changeQuietTime(field: "start" | "end", value: string) {
+ const next = { ...quietDraft, [field]: value };
+ setQuietDraft(next);
+ if (CLOCK_TIME.test(next.start) && CLOCK_TIME.test(next.end) && next.start !== next.end) {
+ save({ quietHours: next });
+ }
+ }
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {settings ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/pages/SettingsOverlay.tsx b/apps/web/src/pages/SettingsOverlay.tsx
index d71d40a..9f1b8f8 100644
--- a/apps/web/src/pages/SettingsOverlay.tsx
+++ b/apps/web/src/pages/SettingsOverlay.tsx
@@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import {
+ Bell,
Brain,
CloudDownload,
Cpu,
@@ -16,6 +17,7 @@ import {
} from "lucide-react";
import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
+import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel";
import { UserProfileSettings } from "../components/UserProfileSettings";
import type { TradingBotUiState } from "../lib/trading-bot-status";
import {
@@ -37,6 +39,7 @@ export type SettingsSection =
| "memory"
| "profile"
| "voice"
+ | "notifications"
| "usage"
| "computer"
| "updates"
@@ -111,6 +114,7 @@ export function SettingsOverlay({
{ id: "memory", label: t`Memory`, icon: Brain },
{ id: "profile", label: t`About you`, icon: UserRound },
{ id: "voice", label: t`Voice`, icon: Volume2 },
+ { id: "notifications", label: t`Notifications`, icon: Bell },
{ id: "usage", label: t`Usage`, icon: Gauge },
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
{ id: "updates", label: t`Updates`, icon: CloudDownload },
@@ -250,6 +254,7 @@ export function SettingsOverlay({
/>
) : null}
{section === "profile" ? : null}
+ {section === "notifications" ? : null}
{section === "usage" ? (
) : null}
diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx
index a3b1e3a..4656244 100644
--- a/apps/web/src/pages/Shell.tsx
+++ b/apps/web/src/pages/Shell.tsx
@@ -247,6 +247,7 @@ import {
transcriptMovedDown,
} from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
+import { disableWebPush } from "../lib/web-push";
import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu";
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
@@ -4296,10 +4297,14 @@ export function ShellPage() {
variant="ghost"
className="w-full justify-start font-normal"
onClick={() =>
- void authClient.signOut().then(() => {
- clearSpaceSelection();
- navigate("/");
- })
+ // This browser stops receiving the account's pushes once it signs out.
+ void disableWebPush()
+ .catch(() => undefined)
+ .then(() => authClient.signOut())
+ .then(() => {
+ clearSpaceSelection();
+ navigate("/");
+ })
}
>
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index 804281c..39fba39 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -19,7 +19,7 @@ import {
createWebProvider,
databaseCapacityBackoffMs,
EncryptedSecretStore,
- ExpoPushProvider,
+ ExpoPushSender,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
@@ -36,6 +36,7 @@ import {
PiAgentRuntime,
PipedreamConnector,
PostgresRealtimeFanout,
+ PushNotificationProvider,
pipedreamConfigFromEnv,
reconcileCloudAgents,
reconcileComputerUpdates,
@@ -45,6 +46,9 @@ import {
resolveSandboxProvider,
ScriptedAgentRuntime,
SpaceMemoryProviderResolver,
+ VapidKeys,
+ vapidEnvFromProcess,
+ WebPushSender,
} from "@rakazo/adapters";
import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core";
import {
@@ -193,7 +197,10 @@ async function main() {
secretStore: secrets,
deploymentModelKey,
dataDir,
- notifications: new ExpoPushProvider(dataDir),
+ notifications: new PushNotificationProvider(prisma, {
+ webpush: new WebPushSender(new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env))),
+ expo: new ExpoPushSender(),
+ }),
jobs,
events,
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts
index 8ca259f..f47f460 100644
--- a/packages/adapter-kit/src/types.ts
+++ b/packages/adapter-kit/src/types.ts
@@ -555,7 +555,7 @@ export interface ArtifactPut {
}
export interface NotificationMessage {
- kind: "completion" | "failure" | "help" | "takeover";
+ kind: "completion" | "failure" | "help" | "takeover" | "nudge";
title: string;
body: string;
botId: string;
diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts
index 8502640..560893f 100644
--- a/packages/adapters/src/builtin-tools.ts
+++ b/packages/adapters/src/builtin-tools.ts
@@ -8,6 +8,7 @@ import {
USER_FACT_CATEGORIES,
} from "@rakazo/contracts";
import { z } from "zod";
+import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js";
export const SUBAGENT_MODEL_TIER_DESCRIPTION =
"Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set.";
@@ -428,6 +429,27 @@ export const builtinAgentTools: ConnectorTool[] = [
},
},
},
+ {
+ name: "notify_user",
+ description:
+ "Send the user a short push notification that opens the main conversation. Use it sparingly, for a check-in or reminder that matters now, and say the same in the conversation. The user's quiet hours, their nudge setting and a daily limit apply; the result says whether it was sent.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ body: {
+ type: "string",
+ maxLength: NOTIFY_USER_BODY_MAX_LENGTH,
+ description: "One or two short sentences.",
+ },
+ title: {
+ type: "string",
+ maxLength: NOTIFY_USER_TITLE_MAX_LENGTH,
+ description: "Optional; defaults to your name.",
+ },
+ },
+ required: ["body"],
+ },
+ },
{
name: "web_search",
description:
diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts
index d987728..bc2d541 100644
--- a/packages/adapters/src/executor-tools.test.ts
+++ b/packages/adapters/src/executor-tools.test.ts
@@ -64,7 +64,11 @@ describe("builtin tools per bot", () => {
});
it("lists main-only tools for the main bot alone", () => {
- expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual(["delegate_task", "update_user_profile"]);
+ expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([
+ "delegate_task",
+ "notify_user",
+ "update_user_profile",
+ ]);
const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false });
const main = builtinToolNamesForBot({ kind: "main", chatLinked: false });
for (const name of MAIN_ONLY_BUILTIN_TOOLS) {
diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts
index 2e819b8..671ad46 100644
--- a/packages/adapters/src/executor-tools.ts
+++ b/packages/adapters/src/executor-tools.ts
@@ -19,6 +19,7 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([
/** Builtin tools only the user's main bot may use; every other bot's runs never see them. */
export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set([
"delegate_task",
+ "notify_user",
"update_user_profile",
]);
@@ -105,7 +106,10 @@ export function selectBuiltinToolsForRun(options: {
Boolean(options.cloudAgentEnabled),
).filter(
(tool) =>
- !(options.foreignAgentRun && tool.name === "update_user_profile") &&
+ !(
+ options.foreignAgentRun &&
+ (tool.name === "update_user_profile" || tool.name === "notify_user")
+ ) &&
(!options.messagingChannelRun ||
(![
"remember",
@@ -113,6 +117,7 @@ export function selectBuiltinToolsForRun(options: {
"recall_memory",
"forget_memory",
"update_user_profile",
+ "notify_user",
].includes(tool.name) &&
!tool.name.startsWith("scratchpad_"))),
);
diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts
index 7181880..994507a 100644
--- a/packages/adapters/src/executor.ts
+++ b/packages/adapters/src/executor.ts
@@ -298,6 +298,7 @@ import {
modelAcceptsImageInput,
modelIdSupportsImages,
} from "./model-vision.js";
+import { notifyUserFromTool } from "./notify-user-tool.js";
import { toOAuthCredential } from "./pi-credentials.js";
import {
parseModelSecret,
@@ -2571,6 +2572,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
),
);
}
+ if (name === "notify_user") {
+ return finish(
+ await notifyUserFromTool(
+ deps,
+ { userId: run.userId, spaceId: run.spaceId, runId, bot, signal: context.signal },
+ args,
+ ),
+ );
+ }
if (name === "web_search") {
return finish(await webSearchFromTool(web, context, args));
}
diff --git a/packages/adapters/src/expo-push.test.ts b/packages/adapters/src/expo-push.test.ts
index c8a7f4a..9052444 100644
--- a/packages/adapters/src/expo-push.test.ts
+++ b/packages/adapters/src/expo-push.test.ts
@@ -1,15 +1,16 @@
-import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
+import type { PrismaClient } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
- deletePushToken,
- ExpoPushProvider,
+ ExpoPushSender,
expoPushErrorMessage,
- loadPushToken,
+ importLegacyPushTokens,
+ legacyPushTokenDir,
MAX_EXPO_PUSH_RESPONSE_BYTES,
- savePushToken,
} from "./expo-push.js";
+import type { PushPayload } from "./push-notifications.js";
const dirs: string[] = [];
@@ -18,13 +19,19 @@ afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
-const notifyContext = {
- operationId: "n",
- traceId: "n",
- spaceId: "w",
- userId: "user-1",
- signal: new AbortController().signal,
-};
+const device = { endpoint: "ExponentPushToken[test]", p256dh: null, auth: null };
+
+function payload(overrides: Partial = {}): PushPayload {
+ return {
+ kind: "completion",
+ title: "done",
+ body: "ok",
+ botId: "b",
+ threadId: "t",
+ url: "/app/b",
+ ...overrides,
+ };
+}
function jsonResponse(body: unknown, status = 200) {
return Response.json(body, { status });
@@ -49,75 +56,24 @@ describe("expo push tickets", () => {
});
});
-describe("expo push", () => {
- it("keeps refreshed push tokens owner-only", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- const tokenFile = path.join(dataDir, "push-tokens", "user-1.txt");
- await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
- await chmod(tokenFile, 0o644);
-
- await savePushToken(dataDir, "user-1", "ExponentPushToken[new]");
-
- expect((await stat(tokenFile)).mode & 0o777).toBe(0o600);
- });
-
- it("does not follow a token-file symlink for reads or writes", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- const tokenDir = path.join(dataDir, "push-tokens");
- const tokenFile = path.join(tokenDir, "user-1.txt");
- const target = path.join(dataDir, "outside.txt");
- await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
- await writeFile(target, "not-a-push-token");
- await rm(tokenFile);
- await symlink(target, tokenFile);
-
- await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined();
- await expect(savePushToken(dataDir, "user-1", "ExponentPushToken[new]")).rejects.toThrow();
- await expect(readFile(target, "utf8")).resolves.toBe("not-a-push-token");
- });
-
- it("removes a registered token", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
- await deletePushToken(dataDir, "user-1");
- await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined();
- });
-
- it("does not call Expo when the user has no token", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
- const push = new ExpoPushProvider(dataDir);
- await push.send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- {
- operationId: "n",
- traceId: "n",
- spaceId: "w",
- userId: "missing",
- signal: new AbortController().signal,
- },
- );
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it("posts to Expo when a token is registered", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
+describe("expo push sender", () => {
+ it("posts the notification to Expo for the device token", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } }));
vi.stubGlobal("fetch", fetchMock);
- const push = new ExpoPushProvider(dataDir);
- await push.send(
- { kind: "takeover", title: "Need you", body: "on screen", botId: "bot-1", threadId: "th-1" },
- notifyContext,
- );
+ await expect(
+ new ExpoPushSender().send(
+ device,
+ payload({
+ kind: "takeover",
+ title: "Need you",
+ body: "on screen",
+ botId: "bot-1",
+ threadId: "th-1",
+ }),
+ ),
+ ).resolves.toBe("sent");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://exp.host/--/api/v2/push/send");
@@ -126,55 +82,51 @@ describe("expo push", () => {
title: string;
collapseId: string;
tag: string;
- data: { kind: string };
+ data: { kind: string; botId: string; threadId: string };
};
- expect(body.to).toBe("ExponentPushToken[test]");
- expect(body.title).toBe("Need you");
- expect(body.collapseId).toBe("th-1");
- expect(body.tag).toBe("th-1");
- expect(body.data.kind).toBe("takeover");
+ expect(body).toMatchObject({
+ to: "ExponentPushToken[test]",
+ title: "Need you",
+ collapseId: "th-1",
+ tag: "th-1",
+ data: { kind: "takeover", botId: "bot-1", threadId: "th-1" },
+ });
+ });
+
+ it("reports a device Expo no longer knows as gone", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse({ data: { status: "error", details: { error: "DeviceNotRegistered" } } }),
+ ),
+ );
+ await expect(new ExpoPushSender().send(device, payload())).resolves.toBe("gone");
});
it("throws when Expo rejects the request", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)),
);
- const push = new ExpoPushProvider(dataDir);
- await expect(
- push.send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- notifyContext,
- ),
- ).rejects.toThrow("boom");
+ await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("boom");
});
it("rejects and cancels a declared oversized response", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
const response = new Response("oversized", {
headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) },
});
const cancel = vi.spyOn(response.body!, "cancel");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
- await expect(
- new ExpoPushProvider(dataDir).send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- notifyContext,
- ),
- ).rejects.toThrow("Expo push response is too large.");
+ await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
+ "Expo push response is too large.",
+ );
expect(cancel).toHaveBeenCalledOnce();
});
it("does not wait past cancellation when an oversized body cancel hangs", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
let cancelStarted = false;
const hangingBody = new ReadableStream({
cancel() {
@@ -194,51 +146,31 @@ describe("expo push", () => {
);
const started = Date.now();
- await expect(
- new ExpoPushProvider(dataDir).send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- { ...notifyContext, signal: abort.signal },
- ),
- ).rejects.toThrow("Expo push response is too large.");
+ await expect(new ExpoPushSender().send(device, payload(), abort.signal)).rejects.toThrow(
+ "Expo push response is too large.",
+ );
expect(cancelStarted).toBe(true);
expect(Date.now() - started).toBeLessThan(500);
});
it("caps a streamed response without a content length", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))),
);
-
- await expect(
- new ExpoPushProvider(dataDir).send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- notifyContext,
- ),
- ).rejects.toThrow("Expo push response is too large.");
+ await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
+ "Expo push response is too large.",
+ );
});
it("rejects malformed successful responses", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not json")));
-
- await expect(
- new ExpoPushProvider(dataDir).send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- notifyContext,
- ),
- ).rejects.toThrow("Expo push returned an invalid response.");
+ await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
+ "Expo push returned an invalid response.",
+ );
});
it("passes caller cancellation to the Expo request", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
const controller = new AbortController();
controller.abort(new Error("notification cancelled"));
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
@@ -247,49 +179,69 @@ describe("expo push", () => {
});
vi.stubGlobal("fetch", fetchMock);
- await expect(
- new ExpoPushProvider(dataDir).send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- { ...notifyContext, signal: controller.signal },
- ),
- ).rejects.toThrow("notification cancelled");
+ await expect(new ExpoPushSender().send(device, payload(), controller.signal)).rejects.toThrow(
+ "notification cancelled",
+ );
expect(fetchMock).toHaveBeenCalledOnce();
});
it("throws when the Expo request never reaches the network", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
- const push = new ExpoPushProvider(dataDir);
- await expect(
- push.send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- notifyContext,
- ),
- ).rejects.toThrow("offline");
- });
-
- it("reports DeviceNotRegistered without deleting a stored or replacement token", async () => {
- const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
- dirs.push(dataDir);
- await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => {
- await savePushToken(dataDir, "user-1", "ExponentPushToken[new]");
- return jsonResponse({
- data: { status: "error", details: { error: "DeviceNotRegistered" } },
- });
- }),
- );
- const push = new ExpoPushProvider(dataDir);
- await expect(
- push.send(
- { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
- notifyContext,
- ),
- ).rejects.toThrow("DeviceNotRegistered");
- await expect(loadPushToken(dataDir, "user-1")).resolves.toBe("ExponentPushToken[new]");
+ await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("offline");
+ });
+});
+
+describe("legacy push token import", () => {
+ function fakePrisma(existingUsers: string[]) {
+ const upsert = vi.fn().mockResolvedValue({});
+ const prisma = {
+ user: {
+ findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
+ existingUsers.includes(where.id) ? { id: where.id } : null,
+ ),
+ },
+ pushSubscription: {
+ upsert,
+ findMany: vi.fn().mockResolvedValue([]),
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ },
+ } as unknown as PrismaClient;
+ return { prisma, upsert };
+ }
+
+ it("imports each token once, skips unknown users and removes the files", async () => {
+ const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
+ dirs.push(dataDir);
+ const dir = legacyPushTokenDir(dataDir);
+ await mkdir(dir, { recursive: true });
+ await writeFile(path.join(dir, "user-1.txt"), "ExponentPushToken[one]\n");
+ await writeFile(path.join(dir, "gone-user.txt"), "ExponentPushToken[two]");
+ await writeFile(path.join(dir, "user-2.txt"), "not a token");
+ const { prisma, upsert } = fakePrisma(["user-1", "user-2"]);
+
+ await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(1);
+
+ expect(upsert).toHaveBeenCalledOnce();
+ expect(upsert.mock.calls[0]![0]).toMatchObject({
+ where: { endpoint: "ExponentPushToken[one]" },
+ create: { endpoint: "ExponentPushToken[one]", userId: "user-1", kind: "expo" },
+ });
+ await expect(readdir(dataDir)).resolves.toEqual([]);
+ await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0);
+ });
+
+ it("does not follow a token-file symlink", async () => {
+ const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
+ dirs.push(dataDir);
+ const dir = legacyPushTokenDir(dataDir);
+ await mkdir(dir, { recursive: true });
+ const outside = path.join(dataDir, "outside.txt");
+ await writeFile(outside, "ExponentPushToken[outside]");
+ await symlink(outside, path.join(dir, "user-1.txt"));
+ const { prisma, upsert } = fakePrisma(["user-1"]);
+
+ await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0);
+ expect(upsert).not.toHaveBeenCalled();
+ await expect(readdir(dataDir)).resolves.toEqual(["outside.txt"]);
});
});
diff --git a/packages/adapters/src/expo-push.ts b/packages/adapters/src/expo-push.ts
index 3ff5dd9..20192f4 100644
--- a/packages/adapters/src/expo-push.ts
+++ b/packages/adapters/src/expo-push.ts
@@ -1,29 +1,29 @@
import { constants } from "node:fs";
-import { mkdir, open, unlink } from "node:fs/promises";
+import { open, readdir, rmdir, unlink } from "node:fs/promises";
import path from "node:path";
-import type {
- AdapterContext,
- NotificationMessage,
- NotificationProvider,
-} from "@rakazo/adapter-kit";
-import { getLogger } from "@rakazo/logging";
+import { ExpoPushTokenSchema } from "@rakazo/contracts";
+import type { PrismaClient } from "@rakazo/db";
+import { savePushSubscription } from "@rakazo/db";
import { combineSignals } from "./connector-safety.js";
+import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js";
import { readBodyCapped, withAbort } from "./web-ssrf.js";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
const EXPO_PUSH_TIMEOUT_MS = 15_000;
export const MAX_EXPO_PUSH_RESPONSE_BYTES = 64 * 1024;
+const LEGACY_TOKEN_FILE = /^([A-Za-z0-9_-]{1,128})\.txt$/;
-export function pushTokenPath(dataDir: string, userId: string) {
- return path.join(dataDir, "push-tokens", `${userId}.txt`);
+/** Where the API kept the single Expo token per user before push_subscriptions existed. */
+export function legacyPushTokenDir(dataDir: string) {
+ return path.join(dataDir, "push-tokens");
}
-export async function loadPushToken(dataDir: string, userId: string): Promise {
+async function readLegacyToken(file: string): Promise {
try {
- const handle = await open(pushTokenPath(dataDir, userId), constants.O_RDONLY | O_NOFOLLOW);
+ const handle = await open(file, constants.O_RDONLY | O_NOFOLLOW);
try {
- const token = (await handle.readFile("utf8")).trim();
- return token || undefined;
+ const token = ExpoPushTokenSchema.safeParse(await handle.readFile("utf8"));
+ return token.success ? token.data : undefined;
} finally {
await handle.close();
}
@@ -32,26 +32,39 @@ export async function loadPushToken(dataDir: string, userId: string): Promise {
- const file = pushTokenPath(dataDir, userId);
- await mkdir(path.dirname(file), { recursive: true });
- const handle = await open(
- file,
- constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW,
- 0o600,
- );
+/**
+ * Moves tokens from the legacy files into push_subscriptions once, then deletes each file. A file
+ * whose user no longer exists, or that holds no usable token, is deleted without import; a
+ * database failure keeps the file for the next start.
+ */
+export async function importLegacyPushTokens(
+ prisma: PrismaClient,
+ dataDir: string,
+): Promise {
+ const dir = legacyPushTokenDir(dataDir);
+ let names: string[];
try {
- await handle.chmod(0o600);
- await handle.writeFile(token.trim(), "utf8");
- } finally {
- await handle.close();
+ names = await readdir(dir);
+ } catch {
+ return 0;
}
-}
-
-export async function deletePushToken(dataDir: string, userId: string): Promise {
- await unlink(pushTokenPath(dataDir, userId)).catch((error: NodeJS.ErrnoException) => {
- if (error.code !== "ENOENT") throw error;
- });
+ let imported = 0;
+ for (const name of names) {
+ const userId = LEGACY_TOKEN_FILE.exec(name)?.[1];
+ if (!userId) continue;
+ const file = path.join(dir, name);
+ const token = await readLegacyToken(file);
+ if (token) {
+ const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
+ if (user) {
+ await savePushSubscription(prisma, userId, { kind: "expo", endpoint: token });
+ imported += 1;
+ }
+ }
+ await unlink(file).catch(() => undefined);
+ }
+ await rmdir(dir).catch(() => undefined);
+ return imported;
}
export type ExpoPushTicket = {
@@ -87,49 +100,33 @@ export function expoPushErrorMessage(body: unknown, status: number): string | un
return undefined;
}
-export class ExpoPushProvider implements NotificationProvider {
- constructor(private readonly dataDir: string) {}
-
- describe() {
- return {
- id: "expo-push",
- contractVersion: "1",
- adapterVersion: "0.1.0",
- capabilities: { push: true, email: false },
- };
- }
-
- async send(message: NotificationMessage, context: AdapterContext): Promise {
- const token = await loadPushToken(this.dataDir, context.userId);
- if (!token) return;
- const signal = combineSignals(context.signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS));
- let response: Response;
- try {
- response = await fetch("https://exp.host/--/api/v2/push/send", {
- method: "POST",
- headers: { "content-type": "application/json", accept: "application/json" },
- body: JSON.stringify({
- to: token,
- title: message.title,
- body: message.body,
- collapseId: message.threadId,
- tag: message.threadId,
- data: { kind: message.kind, botId: message.botId, threadId: message.threadId },
- }),
- signal,
- });
- } catch (error) {
- getLogger().error("expo push request failed", error);
- throw error;
- }
- const body = await readExpoPushBody(response, signal);
+/** Sends to one Expo push token; "gone" when Expo reports the device is no longer registered. */
+export class ExpoPushSender {
+ async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise {
+ const deadline = combineSignals(signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS));
+ const response = await fetch("https://exp.host/--/api/v2/push/send", {
+ method: "POST",
+ headers: { "content-type": "application/json", accept: "application/json" },
+ body: JSON.stringify({
+ to: target.endpoint,
+ title: payload.title,
+ body: payload.body,
+ collapseId: payload.threadId,
+ tag: payload.threadId,
+ data: { kind: payload.kind, botId: payload.botId, threadId: payload.threadId },
+ }),
+ signal: deadline,
+ });
+ const body = await readExpoPushBody(response, deadline);
if (response.ok && body === undefined) {
throw new Error("Expo push returned an invalid response.");
}
+ if (expoPushTickets(body).some((ticket) => ticket.details?.error === "DeviceNotRegistered")) {
+ return "gone";
+ }
const failure = expoPushErrorMessage(body, response.status);
- if (!failure) return;
- getLogger().error(failure);
- throw new Error(failure);
+ if (failure) throw new Error(failure);
+ return "sent";
}
}
diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts
index 2a841cc..7312219 100644
--- a/packages/adapters/src/index.ts
+++ b/packages/adapters/src/index.ts
@@ -101,6 +101,7 @@ export * from "./pipedream-connector.js";
export * from "./profile-consolidation.js";
export * from "./project-workspace-files.js";
export * from "./project-workspace-path.js";
+export * from "./push-notifications.js";
export * from "./realtime.js";
export * from "./release-watch.js";
export * from "./remote-mcp.js";
diff --git a/packages/adapters/src/notify-user-tool.ts b/packages/adapters/src/notify-user-tool.ts
new file mode 100644
index 0000000..6b10256
--- /dev/null
+++ b/packages/adapters/src/notify-user-tool.ts
@@ -0,0 +1,85 @@
+import type { NotificationProvider } from "@rakazo/adapter-kit";
+import { NUDGES_PER_DAY } from "@rakazo/contracts";
+import type { PrismaClient } from "@rakazo/db";
+import {
+ claimDailyNudge,
+ countPushSubscriptions,
+ ensureMainConversation,
+ loadNotificationPolicy,
+} from "@rakazo/db";
+import { z } from "zod";
+
+export const NOTIFY_USER_BODY_MAX_LENGTH = 200;
+export const NOTIFY_USER_TITLE_MAX_LENGTH = 60;
+
+const NotifyUserArgs = z.object({
+ body: z.string().trim().min(1).max(NOTIFY_USER_BODY_MAX_LENGTH),
+ title: z.string().trim().max(NOTIFY_USER_TITLE_MAX_LENGTH).optional(),
+});
+
+export interface NotifyUserToolContext {
+ userId: string;
+ spaceId: string;
+ runId: string;
+ bot: { id: string; name: string; kind: string };
+ signal: AbortSignal;
+}
+
+export type NotifyUserResult = { error: string } | { sent: true } | { sent: false; reason: string };
+
+/**
+ * A nudge from the main bot: a push that opens the main conversation. The user's nudge switch,
+ * quiet hours and the daily cap all apply; a held-back nudge is reported, not an error.
+ */
+export async function notifyUserFromTool(
+ deps: { prisma: PrismaClient; notifications?: NotificationProvider },
+ context: NotifyUserToolContext,
+ rawArgs: Record,
+): Promise {
+ if (context.bot.kind !== "main") {
+ return { error: "Only the user's main bot can notify the user." };
+ }
+ const parsed = NotifyUserArgs.safeParse(rawArgs);
+ if (!parsed.success) {
+ return {
+ error: `Invalid notification: body is required, up to ${NOTIFY_USER_BODY_MAX_LENGTH} characters; title up to ${NOTIFY_USER_TITLE_MAX_LENGTH}.`,
+ };
+ }
+ const { prisma, notifications } = deps;
+ if (!notifications) {
+ return { sent: false, reason: "Push notifications are not available on this server." };
+ }
+ const policy = await loadNotificationPolicy(prisma, context.userId);
+ if (!policy.nudges) return { sent: false, reason: "The user turned nudges off." };
+ if (policy.quiet) return { sent: false, reason: "It is the user's quiet hours." };
+ if ((await countPushSubscriptions(prisma, context.userId)) === 0) {
+ return { sent: false, reason: "The user has no device with notifications turned on." };
+ }
+ if (!(await claimDailyNudge(prisma, context.userId, policy.localDate))) {
+ return { sent: false, reason: `The daily limit of ${NUDGES_PER_DAY} nudges is reached.` };
+ }
+ const conversation = await ensureMainConversation(prisma, context.userId);
+ try {
+ await notifications.send(
+ {
+ kind: "nudge",
+ title: parsed.data.title || context.bot.name,
+ body: parsed.data.body,
+ botId: context.bot.id,
+ threadId: conversation.threadId,
+ },
+ {
+ operationId: `notify_user:${context.runId}`,
+ traceId: context.runId,
+ spaceId: context.spaceId,
+ userId: context.userId,
+ botId: context.bot.id,
+ runId: context.runId,
+ signal: context.signal,
+ },
+ );
+ } catch {
+ return { sent: false, reason: "No device accepted the notification." };
+ }
+ return { sent: true };
+}
diff --git a/packages/adapters/src/notify-user.postgres.test.ts b/packages/adapters/src/notify-user.postgres.test.ts
new file mode 100644
index 0000000..e5e63d2
--- /dev/null
+++ b/packages/adapters/src/notify-user.postgres.test.ts
@@ -0,0 +1,219 @@
+import { randomUUID } from "node:crypto";
+import type { PrismaClient } from "@rakazo/db";
+import {
+ bootstrapUserSpace,
+ createDb,
+ ensureMainConversation,
+ savePushSubscription,
+ updateNotificationSettings,
+} from "@rakazo/db";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { notifyUserFromTool } from "./notify-user-tool.js";
+import {
+ PushNotificationProvider,
+ type PushPayload,
+ type PushSender,
+} from "./push-notifications.js";
+
+const databaseUrl = process.env.DATABASE_URL;
+const describePostgres =
+ process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
+
+const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
+const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) };
+
+describePostgres("push delivery and notify_user (PostgreSQL)", () => {
+ let prisma: PrismaClient;
+ let close: () => Promise;
+ const userIds: string[] = [];
+
+ async function newUser() {
+ const id = `nu${randomUUID().replaceAll("-", "")}`;
+ userIds.push(id);
+ await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
+ await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
+ const conversation = await ensureMainConversation(prisma, id);
+ const main = await prisma.bot.findUniqueOrThrow({
+ where: { id: conversation.leadBotId },
+ select: { id: true, name: true, kind: true },
+ });
+ return { userId: id, conversation, main };
+ }
+
+ function recordingSender(outcome: "sent" | "gone" = "sent") {
+ const sent: PushPayload[] = [];
+ const sender: PushSender = {
+ send: vi.fn(async (_target, payload: PushPayload) => {
+ sent.push(payload);
+ return outcome;
+ }),
+ };
+ return { sender, sent };
+ }
+
+ function toolContext(user: Awaited>, bot = user.main) {
+ return {
+ userId: user.userId,
+ spaceId: user.conversation.spaceId,
+ runId: `run-${randomUUID()}`,
+ bot,
+ signal: new AbortController().signal,
+ };
+ }
+
+ beforeAll(() => {
+ const db = createDb(databaseUrl!);
+ prisma = db.prisma;
+ close = async () => {
+ await db.prisma.$disconnect();
+ await db.pool.end();
+ };
+ });
+
+ afterAll(async () => {
+ if (!prisma) return;
+ const memberships = await prisma.member.findMany({
+ where: { userId: { in: userIds } },
+ select: { organizationId: true },
+ });
+ await prisma.organization.deleteMany({
+ where: { id: { in: memberships.map((member) => member.organizationId) } },
+ });
+ await prisma.user.deleteMany({ where: { id: { in: userIds } } });
+ await close();
+ });
+
+ it("delivers to each device, links the conversation and drops gone devices", async () => {
+ const user = await newUser();
+ const browser = `https://push.example.test/${randomUUID()}`;
+ await savePushSubscription(prisma, user.userId, {
+ kind: "webpush",
+ endpoint: browser,
+ ...keys,
+ });
+ await savePushSubscription(prisma, user.userId, {
+ kind: "expo",
+ endpoint: `ExponentPushToken[${user.userId}]`,
+ });
+ const web = recordingSender("sent");
+ const expo = recordingSender("gone");
+ await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send(
+ {
+ kind: "completion",
+ title: "Chief finished",
+ body: "Done",
+ botId: user.main.id,
+ threadId: user.conversation.threadId,
+ },
+ {
+ operationId: "notify",
+ traceId: "notify",
+ spaceId: user.conversation.spaceId,
+ userId: user.userId,
+ signal: new AbortController().signal,
+ },
+ );
+ expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`);
+ const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } });
+ expect(devices).toHaveLength(1);
+ expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) });
+ });
+
+ it("lets only the main bot nudge, within the user's settings and the daily cap", async () => {
+ const user = await newUser();
+ const web = recordingSender();
+ const deps = {
+ prisma,
+ notifications: new PushNotificationProvider(prisma, { webpush: web.sender }),
+ };
+
+ await expect(
+ notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), {
+ body: "Hi",
+ }),
+ ).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
+ await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({
+ error: expect.stringContaining("body is required"),
+ });
+ await expect(
+ notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }),
+ ).resolves.toMatchObject({ sent: false });
+ await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
+ sent: false,
+ reason: "The user has no device with notifications turned on.",
+ });
+
+ await savePushSubscription(prisma, user.userId, {
+ kind: "webpush",
+ endpoint: `https://push.example.test/${randomUUID()}`,
+ ...keys,
+ });
+ await updateNotificationSettings(prisma, user.userId, { nudges: false });
+ await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
+ sent: false,
+ reason: "The user turned nudges off.",
+ });
+
+ // Quiet hours around the current time.
+ await updateNotificationSettings(prisma, user.userId, {
+ nudges: true,
+ quietHours: quietAroundNow(),
+ });
+ await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
+ sent: false,
+ reason: "It is the user's quiet hours.",
+ });
+ expect(web.sent).toHaveLength(0);
+
+ await updateNotificationSettings(prisma, user.userId, { quietHours: null });
+ for (let index = 0; index < 3; index += 1) {
+ await expect(
+ notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }),
+ ).resolves.toEqual({ sent: true });
+ }
+ await expect(
+ notifyUserFromTool(deps, toolContext(user), { body: "One too many" }),
+ ).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." });
+
+ expect(web.sent).toHaveLength(3);
+ expect(web.sent[0]).toMatchObject({
+ kind: "nudge",
+ title: user.main.name,
+ body: "Check-in 0",
+ threadId: user.conversation.threadId,
+ url: `/app/g/${user.conversation.groupId}`,
+ });
+ });
+
+ it("reports a nudge no device accepted", async () => {
+ const user = await newUser();
+ await savePushSubscription(prisma, user.userId, {
+ kind: "webpush",
+ endpoint: `https://push.example.test/${randomUUID()}`,
+ ...keys,
+ });
+ const failing: PushSender = {
+ send: async () => {
+ throw new Error("Web push failed (500)");
+ },
+ };
+ await expect(
+ notifyUserFromTool(
+ { prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) },
+ toolContext(user),
+ { body: "Hi", title: "Evening check-in" },
+ ),
+ ).resolves.toEqual({ sent: false, reason: "No device accepted the notification." });
+ });
+});
+
+/** A UTC range that covers the current minute (users without a time zone use UTC). */
+function quietAroundNow() {
+ const now = new Date();
+ const minute = now.getUTCHours() * 60 + now.getUTCMinutes();
+ const clock = (value: number) => {
+ const wrapped = (value + 1440) % 1440;
+ return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`;
+ };
+ return { start: clock(minute - 60), end: clock(minute + 60) };
+}
diff --git a/packages/adapters/src/push-notifications.test.ts b/packages/adapters/src/push-notifications.test.ts
new file mode 100644
index 0000000..e457023
--- /dev/null
+++ b/packages/adapters/src/push-notifications.test.ts
@@ -0,0 +1,183 @@
+import type { NotificationMessage } from "@rakazo/adapter-kit";
+import type { PrismaClient } from "@rakazo/db";
+import { describe, expect, it, vi } from "vitest";
+import {
+ PushNotificationProvider,
+ type PushOutcome,
+ type PushPayload,
+ type PushSender,
+} from "./push-notifications.js";
+
+const context = {
+ operationId: "notify",
+ traceId: "t",
+ spaceId: "space-1",
+ userId: "user-1",
+ signal: new AbortController().signal,
+};
+const message: NotificationMessage = {
+ kind: "completion",
+ title: "Chief finished",
+ body: "Booked the table for Friday.",
+ botId: "bot-1",
+ threadId: "thread-1",
+};
+
+function fakeDb(options: {
+ devices: Array<{ id: string; kind: "webpush" | "expo" }>;
+ quietHours?: { start: number; end: number };
+ timeZone?: string;
+ groupId?: string | null;
+}) {
+ const forgotten: string[] = [];
+ const used: string[] = [];
+ const prisma = {
+ userNotificationSettings: {
+ findUnique: async () =>
+ options.quietHours
+ ? {
+ quietHoursStart: options.quietHours.start,
+ quietHoursEnd: options.quietHours.end,
+ nudges: true,
+ }
+ : null,
+ },
+ userProfile: { findUnique: async () => ({ timeZone: options.timeZone ?? null }) },
+ pushSubscription: {
+ findMany: async () =>
+ options.devices.map((device) => ({
+ ...device,
+ endpoint: `endpoint-${device.id}`,
+ p256dh: null,
+ auth: null,
+ })),
+ deleteMany: async ({ where }: { where: { id: string } }) => {
+ forgotten.push(where.id);
+ return { count: 1 };
+ },
+ updateMany: async ({ where }: { where: { id: string } }) => {
+ used.push(where.id);
+ return { count: 1 };
+ },
+ },
+ thread: { findUnique: async () => ({ groupId: options.groupId ?? null }) },
+ } as unknown as PrismaClient;
+ return { prisma, forgotten, used };
+}
+
+function fakeSender(outcome: PushOutcome | Error) {
+ const sent: PushPayload[] = [];
+ const sender: PushSender = {
+ send: vi.fn(async (_target, payload) => {
+ sent.push(payload);
+ if (outcome instanceof Error) throw outcome;
+ return outcome;
+ }),
+ };
+ return { sender, sent };
+}
+
+// 23:30 in Vienna (UTC+2 in summer).
+const lateEvening = () => new Date("2026-07-01T21:30:00Z");
+
+describe("push notification fan-out", () => {
+ it("sends to every device with a link to the conversation", async () => {
+ const { prisma, used } = fakeDb({
+ devices: [
+ { id: "browser", kind: "webpush" },
+ { id: "phone", kind: "expo" },
+ ],
+ groupId: "group-1",
+ });
+ const web = fakeSender("sent");
+ const expo = fakeSender("sent");
+ await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send(
+ message,
+ context,
+ );
+ expect(web.sent).toEqual([{ ...message, url: "/app/g/group-1" }]);
+ expect(expo.sent).toHaveLength(1);
+ expect(used.sort()).toEqual(["browser", "phone"]);
+ });
+
+ it("links a direct thread to its bot and shortens long bodies", async () => {
+ const { prisma } = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] });
+ const web = fakeSender("sent");
+ await new PushNotificationProvider(prisma, { webpush: web.sender }).send(
+ { ...message, body: "x".repeat(1_000) },
+ context,
+ );
+ expect(web.sent[0]?.url).toBe("/app/bot-1");
+ expect(web.sent[0]?.body.length).toBeLessThanOrEqual(240);
+ });
+
+ it("holds every push back during quiet hours in the user's time zone", async () => {
+ const quiet = fakeDb({
+ devices: [{ id: "browser", kind: "webpush" }],
+ quietHours: { start: 22 * 60, end: 7 * 60 },
+ timeZone: "Europe/Vienna",
+ });
+ const web = fakeSender("sent");
+ await new PushNotificationProvider(quiet.prisma, { webpush: web.sender }, lateEvening).send(
+ message,
+ context,
+ );
+ expect(web.sent).toHaveLength(0);
+
+ // The same moment is mid-afternoon in Los Angeles.
+ const awake = fakeDb({
+ devices: [{ id: "browser", kind: "webpush" }],
+ quietHours: { start: 22 * 60, end: 7 * 60 },
+ timeZone: "America/Los_Angeles",
+ });
+ await new PushNotificationProvider(awake.prisma, { webpush: web.sender }, lateEvening).send(
+ message,
+ context,
+ );
+ expect(web.sent).toHaveLength(1);
+ });
+
+ it("drops devices the push service no longer knows", async () => {
+ const { prisma, forgotten, used } = fakeDb({
+ devices: [
+ { id: "old-browser", kind: "webpush" },
+ { id: "phone", kind: "expo" },
+ ],
+ });
+ await new PushNotificationProvider(prisma, {
+ webpush: fakeSender("gone").sender,
+ expo: fakeSender("sent").sender,
+ }).send(message, context);
+ expect(forgotten).toEqual(["old-browser"]);
+ expect(used).toEqual(["phone"]);
+ });
+
+ it("succeeds when one device works and fails only when all of them fail", async () => {
+ const mixed = fakeDb({
+ devices: [
+ { id: "browser", kind: "webpush" },
+ { id: "phone", kind: "expo" },
+ ],
+ });
+ await expect(
+ new PushNotificationProvider(mixed.prisma, {
+ webpush: fakeSender(new Error("Web push failed (500)")).sender,
+ expo: fakeSender("sent").sender,
+ }).send(message, context),
+ ).resolves.toBeUndefined();
+
+ const broken = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] });
+ await expect(
+ new PushNotificationProvider(broken.prisma, {
+ webpush: fakeSender(new Error("Web push failed (500)")).sender,
+ }).send(message, context),
+ ).rejects.toThrow("Push delivery failed");
+ });
+
+ it("does nothing without devices or senders", async () => {
+ const { prisma } = fakeDb({ devices: [{ id: "phone", kind: "expo" }] });
+ const web = fakeSender("sent");
+ await new PushNotificationProvider(prisma, { webpush: web.sender }).send(message, context);
+ expect(web.sent).toHaveLength(0);
+ });
+});
diff --git a/packages/adapters/src/push-notifications.ts b/packages/adapters/src/push-notifications.ts
new file mode 100644
index 0000000..820a132
--- /dev/null
+++ b/packages/adapters/src/push-notifications.ts
@@ -0,0 +1,102 @@
+import type {
+ AdapterContext,
+ NotificationMessage,
+ NotificationProvider,
+} from "@rakazo/adapter-kit";
+import type { PrismaClient, PushSubscriptionKind } from "@rakazo/db";
+import {
+ forgetPushSubscription,
+ listPushSubscriptions,
+ loadNotificationPolicy,
+ markPushSubscriptionUsed,
+} from "@rakazo/db";
+import { getLogger } from "@rakazo/logging";
+
+/** Lock screens show a line or two; the rest lives in the conversation. */
+const PUSH_BODY_MAX_LENGTH = 240;
+
+/** What a device shows: the notification plus where a tap leads (a path in the web app). */
+export interface PushPayload extends NotificationMessage {
+ url: string;
+}
+
+/** "gone": the push service no longer knows this device, so its registration can be dropped. */
+export type PushOutcome = "sent" | "gone";
+
+export interface PushTarget {
+ endpoint: string;
+ p256dh: string | null;
+ auth: string | null;
+}
+
+export interface PushSender {
+ send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise;
+}
+
+function clip(text: string): string {
+ const trimmed = text.trim();
+ return trimmed.length > PUSH_BODY_MAX_LENGTH
+ ? `${trimmed.slice(0, PUSH_BODY_MAX_LENGTH - 1).trimEnd()}…`
+ : trimmed;
+}
+
+/**
+ * Delivers one notification to every device the user registered (browsers and the mobile app),
+ * except during their quiet hours. Devices the push service reports gone are dropped. Throws only
+ * when every device failed.
+ */
+export class PushNotificationProvider implements NotificationProvider {
+ constructor(
+ private readonly prisma: PrismaClient,
+ private readonly senders: Partial>,
+ private readonly now: () => Date = () => new Date(),
+ ) {}
+
+ describe() {
+ return {
+ id: "push",
+ contractVersion: "1",
+ adapterVersion: "0.1.0",
+ capabilities: { push: true, email: false },
+ };
+ }
+
+ async send(message: NotificationMessage, context: AdapterContext): Promise {
+ const policy = await loadNotificationPolicy(this.prisma, context.userId, this.now());
+ if (policy.quiet) return;
+ const devices = (await listPushSubscriptions(this.prisma, context.userId)).filter(
+ (device) => this.senders[device.kind],
+ );
+ if (devices.length === 0) return;
+ const payload: PushPayload = {
+ ...message,
+ title: message.title.trim().slice(0, 120),
+ body: clip(message.body),
+ url: await this.conversationPath(message),
+ };
+ const results = await Promise.allSettled(
+ devices.map(async (device) => {
+ const outcome = await this.senders[device.kind]!.send(device, payload, context.signal);
+ if (outcome === "gone") await forgetPushSubscription(this.prisma, device.id);
+ else await markPushSubscriptionUsed(this.prisma, device.id, this.now());
+ }),
+ );
+ const failures = results.filter((result) => result.status === "rejected");
+ for (const failure of failures) {
+ // Errors carry a status or a reason, never the endpoint or token.
+ getLogger().error("push delivery failed", failure.reason);
+ }
+ if (failures.length === devices.length) throw new Error("Push delivery failed");
+ }
+
+ /** The web app path of the conversation the notification is about. */
+ private async conversationPath(message: NotificationMessage): Promise {
+ const thread = await this.prisma.thread.findUnique({
+ where: { id: message.threadId },
+ select: { groupId: true },
+ });
+ return thread?.groupId
+ ? `/app/g/${encodeURIComponent(thread.groupId)}`
+ : `/app/${encodeURIComponent(message.botId)}`;
+ }
+}
diff --git a/packages/adapters/src/web-push.test.ts b/packages/adapters/src/web-push.test.ts
new file mode 100644
index 0000000..df381de
--- /dev/null
+++ b/packages/adapters/src/web-push.test.ts
@@ -0,0 +1,156 @@
+import { createECDH, randomBytes } from "node:crypto";
+import type { PrismaClient } from "@rakazo/db";
+import { describe, expect, it, vi } from "vitest";
+import webPush from "web-push";
+import type { PushPayload } from "./push-notifications.js";
+import { EncryptedSecretStore } from "./secrets.js";
+import { VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js";
+
+const browserKey = createECDH("prime256v1");
+browserKey.generateKeys();
+const device = {
+ endpoint: "https://push.example.test/device-1",
+ p256dh: browserKey.getPublicKey().toString("base64url"),
+ auth: randomBytes(16).toString("base64url"),
+};
+const publicHost = async () => [{ address: "93.184.216.34", family: 4 }];
+const payload: PushPayload = {
+ kind: "completion",
+ title: "Chief finished",
+ body: "Done",
+ botId: "bot-1",
+ threadId: "thread-1",
+ url: "/app/g/group-1",
+};
+
+function settingsTable() {
+ const rows = new Map();
+ const prisma = {
+ integrationProviderConfig: {
+ findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
+ rows.has(where.id) ? { ciphertext: rows.get(where.id)! } : null,
+ ),
+ createMany: vi.fn(async ({ data }: { data: Array<{ id: string; ciphertext: string }> }) => {
+ for (const row of data) if (!rows.has(row.id)) rows.set(row.id, row.ciphertext);
+ return { count: data.length };
+ }),
+ },
+ } as unknown as PrismaClient;
+ return { prisma, rows };
+}
+
+const fixedKeys = { subject: "mailto:ops@example.test", ...webPush.generateVAPIDKeys() };
+
+describe("VAPID keys", () => {
+ it("uses the configured key pair", async () => {
+ const { prisma, rows } = settingsTable();
+ const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), fixedKeys);
+ await expect(keys.details()).resolves.toEqual(fixedKeys);
+ expect(rows.size).toBe(0);
+ });
+
+ it("generates one key pair, stores it encrypted and shares it across processes", async () => {
+ const { prisma, rows } = settingsTable();
+ const secrets = new EncryptedSecretStore("test-key");
+ const api = new VapidKeys(prisma, secrets, { publicKey: fixedKeys.publicKey });
+ const worker = new VapidKeys(prisma, secrets);
+ const [fromApi, fromWorker] = await Promise.all([api.details(), worker.details()]);
+ expect(fromWorker).toEqual(fromApi);
+ expect(fromApi.publicKey).not.toBe(fixedKeys.publicKey);
+ expect(fromApi.subject).toBe("mailto:admin@localhost");
+ expect(rows.size).toBe(1);
+ const [ciphertext] = [...rows.values()];
+ expect(ciphertext).not.toContain(fromApi.privateKey);
+ await expect(new VapidKeys(prisma, secrets).publicKey()).resolves.toBe(fromApi.publicKey);
+ });
+
+ it("reports no public key when the configured keys are invalid", async () => {
+ const { prisma } = settingsTable();
+ const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), {
+ ...fixedKeys,
+ privateKey: "not-a-key",
+ });
+ await expect(keys.publicKey()).resolves.toBeNull();
+ });
+
+ it("reads the environment and uses an https web origin as the contact", () => {
+ expect(
+ vapidEnvFromProcess({
+ VAPID_PUBLIC_KEY: " pub ",
+ VAPID_PRIVATE_KEY: "priv",
+ WEB_ORIGIN: "https://app.example.test",
+ }),
+ ).toEqual({ publicKey: "pub", privateKey: "priv", subject: "https://app.example.test" });
+ expect(vapidEnvFromProcess({ WEB_ORIGIN: "http://127.0.0.1:5173" }).subject).toBeUndefined();
+ expect(
+ vapidEnvFromProcess({ VAPID_SUBJECT: "mailto:a@example.test", WEB_ORIGIN: "https://x.test" })
+ .subject,
+ ).toBe("mailto:a@example.test");
+ });
+});
+
+describe("web push sender", () => {
+ const vapid = { details: async () => fixedKeys };
+
+ it("posts an encrypted, VAPID-signed message to the endpoint", async () => {
+ const fetchMock = vi.fn(async () => new Response(null, { status: 201 }));
+ const sender = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost });
+ await expect(sender.send(device, payload)).resolves.toBe("sent");
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toBe(device.endpoint);
+ expect(init.method).toBe("POST");
+ expect(init.redirect).toBe("manual");
+ const headers = new Headers(init.headers);
+ expect(headers.get("content-encoding")).toBe("aes128gcm");
+ expect(headers.get("ttl")).toBe("86400");
+ expect(headers.get("authorization")).toMatch(/^vapid t=.+, k=/);
+ expect(headers.has("content-length")).toBe(false);
+ // The body is ciphertext: the notification text never travels in the clear.
+ expect(Buffer.from(init.body as Uint8Array).toString("utf8")).not.toContain("Chief finished");
+ });
+
+ it("reports an expired subscription as gone", async () => {
+ for (const status of [404, 410]) {
+ const sender = new WebPushSender(vapid, {
+ fetch: async () => new Response(null, { status }),
+ resolveHostname: publicHost,
+ });
+ await expect(sender.send(device, payload)).resolves.toBe("gone");
+ }
+ });
+
+ it("throws on other failures without naming the endpoint", async () => {
+ const sender = new WebPushSender(vapid, {
+ fetch: async () => new Response("quota exceeded", { status: 429 }),
+ resolveHostname: publicHost,
+ });
+ const error = await sender.send(device, payload).catch((caught: Error) => caught);
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toBe("Web push failed (429)");
+ expect((error as Error).message).not.toContain("push.example.test");
+ });
+
+ it("never connects to a private or non-https endpoint", async () => {
+ const fetchMock = vi.fn();
+ const privateHost = new WebPushSender(vapid, {
+ fetch: fetchMock,
+ resolveHostname: async () => [{ address: "10.0.0.5", family: 4 }],
+ });
+ await expect(privateHost.send(device, payload)).rejects.toThrow("private address");
+ const plainHttp = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost });
+ await expect(
+ plainHttp.send({ ...device, endpoint: "http://push.example.test/device-1" }, payload),
+ ).rejects.toThrow("https");
+ const loopback = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost });
+ await expect(
+ loopback.send({ ...device, endpoint: "https://127.0.0.1/device-1" }, payload),
+ ).rejects.toThrow("private or internal host");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("treats a subscription without keys as gone", async () => {
+ const sender = new WebPushSender(vapid, { fetch: vi.fn(), resolveHostname: publicHost });
+ await expect(sender.send({ ...device, p256dh: null }, payload)).resolves.toBe("gone");
+ });
+});
diff --git a/packages/adapters/src/web-push.ts b/packages/adapters/src/web-push.ts
index 3abb071..e851d9e 100644
--- a/packages/adapters/src/web-push.ts
+++ b/packages/adapters/src/web-push.ts
@@ -1,11 +1,11 @@
import { lookup } from "node:dns/promises";
-import type { NotificationMessage } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { Agent } from "undici";
import webPush from "web-push";
import { combineSignals } from "./connector-safety.js";
import { createAddressCheckedLookup, type ResolveHostname } from "./network-address.js";
+import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js";
import type { EncryptedSecretStore } from "./secrets.js";
import { dispatcherFetch } from "./undici-fetch.js";
import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.js";
@@ -137,20 +137,6 @@ export class VapidKeys {
}
}
-/** What a device shows: the notification plus where a tap leads (a path in the web app). */
-export interface PushPayload extends NotificationMessage {
- url: string;
-}
-
-/** "gone": the push service no longer knows this device, so its registration can be dropped. */
-export type PushOutcome = "sent" | "gone";
-
-export interface PushTarget {
- endpoint: string;
- p256dh: string | null;
- auth: string | null;
-}
-
export interface WebPushSenderOptions {
fetch?: typeof globalThis.fetch;
resolveHostname?: ResolveHostname;
diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts
index 5b09a13..bcb7cc3 100644
--- a/packages/contracts/src/notifications.ts
+++ b/packages/contracts/src/notifications.ts
@@ -41,6 +41,13 @@ export const WebPushEndpointSchema = z
}
}, "Endpoint must be an https URL");
+/** The mobile app's Expo push token, e.g. "ExponentPushToken[xxxxxxxx]". */
+export const ExpoPushTokenSchema = z
+ .string()
+ .trim()
+ .max(512)
+ .regex(/^Expo(nent)?PushToken\[[^\]\s]+\]$/, "Invalid Expo push token");
+
/** The JSON form of a browser PushSubscription (RFC 8291 keys, base64url). */
export const WebPushSubscriptionInput = z.object({
endpoint: WebPushEndpointSchema,
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 96a8a3c..249f518 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -84,6 +84,7 @@ import {
} from "./integration-settings.js";
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
import {
+ ExpoPushTokenSchema,
NotificationSettingsSchema,
UpdateNotificationSettingsInput,
WebPushConfigSchema,
@@ -811,7 +812,7 @@ export const appContract = {
},
notifications: {
registerPush: oc
- .input(z.object({ token: z.string().min(8).max(512) }))
+ .input(z.object({ token: ExpoPushTokenSchema }))
.output(z.object({ ok: z.literal(true) })),
unregisterPush: oc.output(z.object({ ok: z.literal(true) })),
settings: {
diff --git a/packages/core/src/quiet-hours.test.ts b/packages/core/src/quiet-hours.test.ts
new file mode 100644
index 0000000..183ee65
--- /dev/null
+++ b/packages/core/src/quiet-hours.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { isQuietMinute, isQuietTime, localDateAndMinute } from "./quiet-hours.js";
+
+const minute = (clock: string) => {
+ const [hour, min] = clock.split(":").map(Number);
+ return hour! * 60 + min!;
+};
+
+describe("quiet hours", () => {
+ it("covers a same-day range with an inclusive start and an exclusive end", () => {
+ const lunch = { start: minute("12:00"), end: minute("13:30") };
+ expect(isQuietMinute(lunch, minute("11:59"))).toBe(false);
+ expect(isQuietMinute(lunch, minute("12:00"))).toBe(true);
+ expect(isQuietMinute(lunch, minute("13:29"))).toBe(true);
+ expect(isQuietMinute(lunch, minute("13:30"))).toBe(false);
+ });
+
+ it("covers a range that crosses midnight", () => {
+ const night = { start: minute("22:00"), end: minute("07:00") };
+ expect(isQuietMinute(night, minute("21:59"))).toBe(false);
+ expect(isQuietMinute(night, minute("22:00"))).toBe(true);
+ expect(isQuietMinute(night, minute("23:59"))).toBe(true);
+ expect(isQuietMinute(night, minute("00:00"))).toBe(true);
+ expect(isQuietMinute(night, minute("06:59"))).toBe(true);
+ expect(isQuietMinute(night, minute("07:00"))).toBe(false);
+ expect(isQuietMinute(night, minute("12:00"))).toBe(false);
+ });
+
+ it("is never quiet without a range or with an empty one", () => {
+ expect(isQuietMinute(null, minute("03:00"))).toBe(false);
+ expect(isQuietMinute({ start: 300, end: 300 }, 300)).toBe(false);
+ });
+
+ it("reads the wall clock in the user's time zone", () => {
+ // 21:30 UTC is 23:30 in Vienna (CEST, UTC+2) and 14:30 in Los Angeles (PDT, UTC-7).
+ const now = new Date("2026-07-01T21:30:00Z");
+ const night = { start: minute("22:00"), end: minute("07:00") };
+ expect(isQuietTime(night, "Europe/Vienna", now)).toBe(true);
+ expect(isQuietTime(night, "America/Los_Angeles", now)).toBe(false);
+ expect(isQuietTime(night, null, now)).toBe(false);
+ });
+
+ it("falls back to UTC for an unknown zone and reports the local date", () => {
+ const now = new Date("2026-01-31T23:15:00Z");
+ expect(localDateAndMinute(now, "Not/AZone")).toEqual({
+ date: "2026-01-31",
+ minute: minute("23:15"),
+ });
+ // Already the next day in Tokyo (UTC+9).
+ expect(localDateAndMinute(now, "Asia/Tokyo")).toEqual({
+ date: "2026-02-01",
+ minute: minute("08:15"),
+ });
+ });
+
+ it("follows daylight saving changes", () => {
+ const night = { start: minute("22:00"), end: minute("07:00") };
+ // 05:30 UTC is 06:30 in Vienna in winter (CET) and 07:30 in summer (CEST).
+ expect(isQuietTime(night, "Europe/Vienna", new Date("2026-01-15T05:30:00Z"))).toBe(true);
+ expect(isQuietTime(night, "Europe/Vienna", new Date("2026-07-15T05:30:00Z"))).toBe(false);
+ });
+});
diff --git a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql
index c684498..cd83c49 100644
--- a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql
+++ b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql
@@ -44,7 +44,9 @@ ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_webpush_keys
ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK (
("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL)
OR (
- "quietHoursStart" BETWEEN 0 AND 1439
+ "quietHoursStart" IS NOT NULL
+ AND "quietHoursEnd" IS NOT NULL
+ AND "quietHoursStart" BETWEEN 0 AND 1439
AND "quietHoursEnd" BETWEEN 0 AND 1439
AND "quietHoursStart" <> "quietHoursEnd"
)
diff --git a/packages/db/src/notification-settings.postgres.test.ts b/packages/db/src/notification-settings.postgres.test.ts
new file mode 100644
index 0000000..624f9dc
--- /dev/null
+++ b/packages/db/src/notification-settings.postgres.test.ts
@@ -0,0 +1,184 @@
+import { randomUUID } from "node:crypto";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import type { PrismaClient } from "./client.js";
+import { createDb } from "./client.js";
+import {
+ claimDailyNudge,
+ getNotificationSettings,
+ loadNotificationPolicy,
+ updateNotificationSettings,
+} from "./notification-settings.js";
+import {
+ listPushSubscriptions,
+ PUSH_SUBSCRIPTION_LIMIT,
+ removePushSubscription,
+ removePushSubscriptionsOfKind,
+ savePushSubscription,
+} from "./push-subscriptions.js";
+
+const databaseUrl = process.env.DATABASE_URL;
+const describePostgres =
+ process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
+
+const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) };
+
+describePostgres("notification settings and push subscriptions (PostgreSQL)", () => {
+ let prisma: PrismaClient;
+ let close: () => Promise;
+ const userIds: string[] = [];
+
+ async function newUser() {
+ const id = `ns${randomUUID().replaceAll("-", "")}`;
+ userIds.push(id);
+ await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
+ return id;
+ }
+
+ beforeAll(() => {
+ const db = createDb(databaseUrl!);
+ prisma = db.prisma;
+ close = async () => {
+ await db.prisma.$disconnect();
+ await db.pool.end();
+ };
+ });
+
+ afterAll(async () => {
+ if (!prisma) return;
+ await prisma.user.deleteMany({ where: { id: { in: userIds } } });
+ await close();
+ });
+
+ it("starts with no quiet hours and nudges on, and applies partial updates", async () => {
+ const userId = await newUser();
+ await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({
+ quietHours: null,
+ nudges: true,
+ });
+
+ await expect(
+ updateNotificationSettings(prisma, userId, { quietHours: { start: "22:30", end: "07:00" } }),
+ ).resolves.toEqual({ quietHours: { start: "22:30", end: "07:00" }, nudges: true });
+ const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } });
+ expect([row.quietHoursStart, row.quietHoursEnd]).toEqual([22 * 60 + 30, 7 * 60]);
+
+ await expect(updateNotificationSettings(prisma, userId, { nudges: false })).resolves.toEqual({
+ quietHours: { start: "22:30", end: "07:00" },
+ nudges: false,
+ });
+ await expect(updateNotificationSettings(prisma, userId, { quietHours: null })).resolves.toEqual(
+ { quietHours: null, nudges: false },
+ );
+ });
+
+ it("rejects an empty quiet-hours range in the repository and in the database", async () => {
+ const userId = await newUser();
+ await expect(
+ updateNotificationSettings(prisma, userId, { quietHours: { start: "08:00", end: "08:00" } }),
+ ).rejects.toThrow();
+ await expect(
+ prisma.userNotificationSettings.create({
+ data: { userId, quietHoursStart: 60, quietHoursEnd: null },
+ }),
+ ).rejects.toThrow();
+ await expect(
+ prisma.userNotificationSettings.create({
+ data: { userId, quietHoursStart: 1440, quietHoursEnd: 60 },
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("judges quiet hours in the profile time zone", async () => {
+ const userId = await newUser();
+ await updateNotificationSettings(prisma, userId, {
+ quietHours: { start: "22:00", end: "07:00" },
+ });
+ const now = new Date("2026-07-01T21:30:00Z");
+ await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toMatchObject({
+ quiet: false,
+ localDate: "2026-07-01",
+ });
+ await prisma.userProfile.create({ data: { userId, timeZone: "Europe/Vienna" } });
+ await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toEqual({
+ quiet: true,
+ nudges: true,
+ localDate: "2026-07-01",
+ });
+ });
+
+ it("caps nudges per local day, also under concurrency", async () => {
+ const userId = await newUser();
+ const claims = await Promise.all(
+ Array.from({ length: 6 }, () => claimDailyNudge(prisma, userId, "2026-07-01")),
+ );
+ expect(claims.filter(Boolean)).toHaveLength(3);
+ await expect(claimDailyNudge(prisma, userId, "2026-07-01")).resolves.toBe(false);
+ await expect(claimDailyNudge(prisma, userId, "2026-07-02")).resolves.toBe(true);
+ const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } });
+ expect(row.nudgeCount).toBe(1);
+ // Claiming never changes the user's settings.
+ await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({
+ quietHours: null,
+ nudges: true,
+ });
+ });
+
+ it("keeps devices per user, moves a re-registered endpoint and caps the list", async () => {
+ const owner = await newUser();
+ const other = await newUser();
+ const endpoint = `https://push.example.test/${randomUUID()}`;
+ await savePushSubscription(prisma, owner, { kind: "webpush", endpoint, ...keys });
+ await savePushSubscription(prisma, owner, { kind: "expo", endpoint: "ExponentPushToken[a]" });
+ expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual([
+ "webpush",
+ "expo",
+ ]);
+
+ // Another user cannot remove the owner's device by naming its endpoint.
+ await removePushSubscription(prisma, other, endpoint);
+ expect(await listPushSubscriptions(prisma, owner)).toHaveLength(2);
+
+ // The same browser signed into another account now delivers to that account.
+ await savePushSubscription(prisma, other, { kind: "webpush", endpoint, ...keys });
+ expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual(["expo"]);
+ expect((await listPushSubscriptions(prisma, other)).map((row) => row.endpoint)).toEqual([
+ endpoint,
+ ]);
+
+ await removePushSubscriptionsOfKind(prisma, owner, "expo");
+ expect(await listPushSubscriptions(prisma, owner)).toEqual([]);
+
+ for (let index = 0; index <= PUSH_SUBSCRIPTION_LIMIT; index += 1) {
+ await savePushSubscription(prisma, owner, {
+ kind: "expo",
+ endpoint: `ExponentPushToken[${owner}-${index}]`,
+ });
+ }
+ const kept = await listPushSubscriptions(prisma, owner);
+ expect(kept).toHaveLength(PUSH_SUBSCRIPTION_LIMIT);
+ expect(kept.map((row) => row.endpoint)).not.toContain(`ExponentPushToken[${owner}-0]`);
+ });
+
+ it("requires keys for web push and removes everything with the account", async () => {
+ const userId = await newUser();
+ await expect(
+ prisma.pushSubscription.create({
+ data: { userId, kind: "webpush", endpoint: `https://push.example.test/${userId}` },
+ }),
+ ).rejects.toThrow();
+ await expect(
+ prisma.pushSubscription.create({
+ data: { userId, kind: "sms", endpoint: `sms-${userId}` },
+ }),
+ ).rejects.toThrow();
+ await savePushSubscription(prisma, userId, {
+ kind: "expo",
+ endpoint: `ExponentPushToken[${userId}]`,
+ });
+ await updateNotificationSettings(prisma, userId, { nudges: false });
+
+ await prisma.user.delete({ where: { id: userId } });
+ expect(await prisma.pushSubscription.count({ where: { userId } })).toBe(0);
+ expect(await prisma.userNotificationSettings.count({ where: { userId } })).toBe(0);
+ });
+});
diff --git a/packages/db/src/push-subscriptions.ts b/packages/db/src/push-subscriptions.ts
index 885f337..879f705 100644
--- a/packages/db/src/push-subscriptions.ts
+++ b/packages/db/src/push-subscriptions.ts
@@ -86,3 +86,24 @@ export async function listPushSubscriptions(
});
return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind }));
}
+
+export async function countPushSubscriptions(
+ prisma: PrismaClient,
+ userId: string,
+): Promise {
+ return prisma.pushSubscription.count({ where: { userId } });
+}
+
+/** Records a successful delivery to a device. */
+export async function markPushSubscriptionUsed(
+ prisma: PrismaClient,
+ id: string,
+ at: Date,
+): Promise {
+ await prisma.pushSubscription.updateMany({ where: { id }, data: { lastUsedAt: at } });
+}
+
+/** Drops a device its push service no longer knows. */
+export async function forgetPushSubscription(prisma: PrismaClient, id: string): Promise {
+ await prisma.pushSubscription.deleteMany({ where: { id } });
+}
diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts
index 5305a76..38487bc 100644
--- a/packages/testkit/src/cli/harness.ts
+++ b/packages/testkit/src/cli/harness.ts
@@ -109,9 +109,11 @@ async function main() {
"packages/testkit/src/delegation.postgres.test.ts",
"packages/testkit/src/user-profile.postgres.test.ts",
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
+ "packages/testkit/src/notifications.postgres.test.ts",
"packages/db/src/space-membership.postgres.test.ts",
"packages/db/src/main-agent.postgres.test.ts",
"packages/db/src/user-profile.postgres.test.ts",
+ "packages/db/src/notification-settings.postgres.test.ts",
"packages/db/src/messaging.postgres.test.ts",
"packages/memory/src/commit.postgres.test.ts",
"packages/adapters/src/wakeup.postgres.test.ts",
@@ -120,6 +122,7 @@ async function main() {
"packages/adapters/src/cloud-agent.postgres.test.ts",
"packages/adapters/src/builtin-memory-provider.postgres.test.ts",
"packages/adapters/src/profile-consolidation.postgres.test.ts",
+ "packages/adapters/src/notify-user.postgres.test.ts",
];
// Each app reconciles all durable work in its database, including intentionally
// unfinished fixture runs. Clone the pristine migrated schema so one suite
diff --git a/packages/testkit/src/notifications.postgres.test.ts b/packages/testkit/src/notifications.postgres.test.ts
new file mode 100644
index 0000000..9668046
--- /dev/null
+++ b/packages/testkit/src/notifications.postgres.test.ts
@@ -0,0 +1,198 @@
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { ComposioEmulator } from "@rakazo/adapters";
+import type { NotificationSettings, WebPushConfig } from "@rakazo/contracts";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import type { createApp } from "../../../apps/api/src/app.ts";
+import { sessionCookieHeader } from "./index.js";
+
+type App = { request: (input: string, init?: RequestInit) => Response | Promise };
+type AppHandles = Awaited>;
+
+process.env.WAKEUP_DRIVER = "memory";
+process.env.SANDBOX_PROVIDER = "fake";
+process.env.AGENT_RUNTIME = "scripted";
+
+const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
+const describeWithDatabase = hasDb ? describe : describe.skip;
+
+const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) };
+
+describeWithDatabase("notifications through the API", () => {
+ let handles: AppHandles;
+ let app: App;
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-notifications-"));
+
+ beforeAll(async () => {
+ const { createApp } = await import("../../../apps/api/src/app.ts");
+ handles = await createApp({
+ databaseUrl: process.env.DATABASE_URL!,
+ dataDir,
+ sandboxProvider: "fake",
+ agentRuntime: "scripted",
+ wakeupDriver: "memory",
+ signupsEnabled: "true",
+ composio: new ComposioEmulator(),
+ });
+ app = handles.app;
+ });
+
+ afterAll(async () => {
+ await handles?.stop();
+ rmSync(dataDir, { recursive: true, force: true });
+ });
+
+ async function devices(cookie: string) {
+ const me = await rpc<{ userId: string }>(app, cookie, "me");
+ return handles.prisma.pushSubscription.findMany({
+ where: { userId: me.userId },
+ orderBy: { createdAt: "asc" },
+ select: { kind: true, endpoint: true, userAgent: true },
+ });
+ }
+
+ it("reads and updates the caller's own settings", async () => {
+ const owner = await signup(app, `settings-owner-${stamp}@rakazo.test`, "Settings Owner");
+ const other = await signup(app, `settings-other-${stamp}@rakazo.test`, "Settings Other");
+
+ await expect(
+ rpc(app, owner, "notifications/settings/get"),
+ ).resolves.toEqual({ quietHours: null, nudges: true });
+ await expect(
+ rpc(app, owner, "notifications/settings/update", {
+ quietHours: { start: "21:00", end: "06:30" },
+ nudges: false,
+ }),
+ ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false });
+ await expect(
+ rpc(app, other, "notifications/settings/get"),
+ ).resolves.toEqual({ quietHours: null, nudges: true });
+
+ for (const invalid of [
+ { quietHours: { start: "9:00", end: "17:00" } },
+ { quietHours: { start: "24:00", end: "06:00" } },
+ { quietHours: { start: "08:00", end: "08:00" } },
+ ]) {
+ const response = await raw(app, owner, "notifications/settings/update", invalid);
+ expect(response.status, JSON.stringify(invalid)).toBe(400);
+ }
+ await expect(
+ rpc(app, owner, "notifications/settings/get"),
+ ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false });
+ });
+
+ it("hands browsers one stable public key", async () => {
+ const cookie = await signup(app, `push-config-${stamp}@rakazo.test`, "Push Config");
+ const first = await rpc(app, cookie, "notifications/webPush/config");
+ expect(first.available).toBe(true);
+ expect(first.publicKey).toMatch(/^[A-Za-z0-9_-]{87}$/);
+ await expect(rpc(app, cookie, "notifications/webPush/config")).resolves.toEqual(
+ first,
+ );
+ const stored = await handles.prisma.integrationProviderConfig.findUniqueOrThrow({
+ where: { id: "web-push-vapid" },
+ });
+ expect(stored.ciphertext).not.toContain(first.publicKey!);
+ });
+
+ it("registers browsers and phones per user, and only the owner removes them", async () => {
+ const owner = await signup(app, `push-owner-${stamp}@rakazo.test`, "Push Owner");
+ const intruder = await signup(app, `push-intruder-${stamp}@rakazo.test`, "Push Intruder");
+ const endpoint = `https://push.example.test/${stamp}`;
+
+ await rpc(app, owner, "notifications/webPush/subscribe", { endpoint, keys });
+ await rpc(app, owner, "notifications/registerPush", { token: `ExponentPushToken[${stamp}]` });
+ expect(await devices(owner)).toEqual([
+ { kind: "webpush", endpoint, userAgent: "" },
+ { kind: "expo", endpoint: `ExponentPushToken[${stamp}]`, userAgent: "" },
+ ]);
+
+ await rpc(app, intruder, "notifications/webPush/unsubscribe", { endpoint });
+ await rpc(app, intruder, "notifications/unregisterPush");
+ expect(await devices(owner)).toHaveLength(2);
+
+ await rpc(app, owner, "notifications/unregisterPush");
+ expect(await devices(owner)).toEqual([{ kind: "webpush", endpoint, userAgent: "" }]);
+ await rpc(app, owner, "notifications/webPush/unsubscribe", { endpoint });
+ expect(await devices(owner)).toEqual([]);
+ });
+
+ it("rejects endpoints that are not public https push services", async () => {
+ const cookie = await signup(app, `push-invalid-${stamp}@rakazo.test`, "Push Invalid");
+ for (const endpoint of [
+ "http://push.example.test/device",
+ "https://127.0.0.1/device",
+ "https://localhost/device",
+ "https://10.0.0.8/device",
+ "https://user:secret@push.example.test/device",
+ "not a url",
+ ]) {
+ const response = await raw(app, cookie, "notifications/webPush/subscribe", {
+ endpoint,
+ keys,
+ });
+ expect(response.status, endpoint).toBe(400);
+ }
+ const badKeys = await raw(app, cookie, "notifications/webPush/subscribe", {
+ endpoint: `https://push.example.test/${stamp}-keys`,
+ keys: { p256dh: "short", auth: keys.auth },
+ });
+ expect(badKeys.status).toBe(400);
+ const badToken = await raw(app, cookie, "notifications/registerPush", {
+ token: "https://push.example.test/not-an-expo-token",
+ });
+ expect(badToken.status).toBe(400);
+ expect(await devices(cookie)).toEqual([]);
+ });
+
+ it("refuses every notification procedure without a session", async () => {
+ for (const [procedure, body] of [
+ ["notifications/settings/get", {}],
+ ["notifications/settings/update", { nudges: false }],
+ ["notifications/webPush/config", {}],
+ ["notifications/webPush/subscribe", { endpoint: `https://push.example.test/${stamp}`, keys }],
+ ["notifications/webPush/unsubscribe", { endpoint: `https://push.example.test/${stamp}` }],
+ ["notifications/registerPush", { token: "ExponentPushToken[anon]" }],
+ ["notifications/unregisterPush", {}],
+ ] as const) {
+ const response = await raw(app, "", procedure, body);
+ expect(response.status, procedure).toBe(401);
+ }
+ });
+});
+
+async function signup(app: App, email: string, name: string) {
+ const response = await app.request("/api/auth/sign-up/email", {
+ method: "POST",
+ headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
+ body: JSON.stringify({ email, password: "password12", name }),
+ });
+ if (response.status >= 400) {
+ throw new Error(`signup failed ${response.status}: ${await response.text()}`);
+ }
+ return sessionCookieHeader(response);
+}
+
+async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
+ return app.request(`/rpc/${procedure}`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...(cookie ? { cookie } : {}),
+ origin: "http://127.0.0.1:5173",
+ },
+ body: JSON.stringify({ json: body }),
+ });
+}
+
+async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) {
+ const response = await raw(app, cookie, procedure, body);
+ const text = await response.text();
+ const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
+ if (response.status >= 400 || payload.error) {
+ throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
+ }
+ return payload.json as T;
+}