Merge web push, quiet hours and nudges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:12:33 +00:00
commit 245efc88b1
No known key found for this signature in database
31 changed files with 1836 additions and 278 deletions

View File

@ -166,8 +166,12 @@ SMTP_URL=
EMAIL_FROM= EMAIL_FROM=
# Local development only: capture password-reset email and print its link to the API console. # Local development only: capture password-reset email and print its link to the API console.
EMAIL_EMULATOR= 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_PUBLIC_KEY=
VAPID_PRIVATE_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). # Structured logs to stdout (json in production, pretty in development unless overridden).
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT= LOG_FORMAT=

View File

@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server"; import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import type { import type {
@ -35,13 +34,14 @@ import {
destroyBot, destroyBot,
EmailEmulator, EmailEmulator,
EncryptedSecretStore, EncryptedSecretStore,
ExpoPushProvider, ExpoPushSender,
GraphileJobPublisher, GraphileJobPublisher,
GraphileJobWorkerHost, GraphileJobWorkerHost,
InMemoryJobQueue, InMemoryJobQueue,
InMemoryRealtimeFanout, InMemoryRealtimeFanout,
InstalledConnectorProvider, InstalledConnectorProvider,
IntegrationProviderSettings, IntegrationProviderSettings,
importLegacyPushTokens,
isComposioEnabled, isComposioEnabled,
isMessagingSurfaceEnabled, isMessagingSurfaceEnabled,
isPipedreamEnabled, isPipedreamEnabled,
@ -54,9 +54,9 @@ import {
PiOAuthLogins, PiOAuthLogins,
PipedreamConnector, PipedreamConnector,
PostgresRealtimeFanout, PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv, pipedreamConfigFromEnv,
piSessionsRoot, piSessionsRoot,
pushTokenPath,
reconcileCloudAgents, reconcileCloudAgents,
reconcileComputerUpdates, reconcileComputerUpdates,
reconcileDelegatedTasks, reconcileDelegatedTasks,
@ -67,6 +67,7 @@ import {
toTeamChatInbound, toTeamChatInbound,
VapidKeys, VapidKeys,
vapidEnvFromProcess, vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core"; import { signupPolicyFromEnv } from "@rakazo/core";
@ -311,8 +312,14 @@ export async function createApp(
: new PiAgentRuntime({ : new PiAgentRuntime({
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
}); });
const notifications = new ExpoPushProvider(env.dataDir);
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env)); 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, { const auth = createAuth(prisma, {
secret: env.authSecret, secret: env.authSecret,
baseURL: env.authUrl, baseURL: env.authUrl,
@ -368,7 +375,6 @@ export async function createApp(
), ),
); );
await removePiUserSessions(env.dataDir, userId); 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. // One provider instance so emulator launches and polls share the same Map.

View File

@ -45,7 +45,6 @@ import {
computerSupportsUpdate, computerSupportsUpdate,
computerUpdateView, computerUpdateView,
createVoiceProvider, createVoiceProvider,
deletePushToken,
deploymentAutoReviewDefault, deploymentAutoReviewDefault,
destroyBot, destroyBot,
displayBotWorkspacePath, displayBotWorkspacePath,
@ -77,7 +76,6 @@ import {
resolveAutoReviewChecker, resolveAutoReviewChecker,
resolveBotWorkspacePath, resolveBotWorkspacePath,
sanitizeComposioError, sanitizeComposioError,
savePushToken,
scheduleComputerControlExpiry, scheduleComputerControlExpiry,
scheduleComputerSleep, scheduleComputerSleep,
screenLeaseIdForRun, screenLeaseIdForRun,
@ -140,6 +138,7 @@ import {
projectWorkspaceDto, projectWorkspaceDto,
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
removePushSubscription, removePushSubscription,
removePushSubscriptionsOfKind,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership, requireMembership,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
@ -4798,11 +4797,16 @@ export function createRouter(deps: RouterDeps) {
}, },
notifications: { notifications: {
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => { 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 }; 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 }) => { 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 }; return { ok: true as const };
}), }),
settings: { settings: {

View File

@ -242,6 +242,7 @@ export async function openUserSettings(
| "memory" | "memory"
| "profile" | "profile"
| "voice" | "voice"
| "notifications"
| "usage" | "usage"
| "computer" | "computer"
| "updates", | "updates",

View File

@ -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();
});

View File

@ -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 (
<li className="py-3 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-4">
<span id={labelId} className="text-ui-body text-foreground">
{label}
</span>
<Switch
data-testid={testId}
aria-labelledby={labelId}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
{children}
</li>
);
}
/** 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<NotificationSettings | null>(null);
const [push, setPush] = useState<PushState>("loading");
const [pushBusy, setPushBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div data-testid="notification-settings" className="space-y-5">
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
{settings ? (
<section className="rounded-xl border border-border px-4 py-4">
<ul className="divide-y divide-border">
{pushSupported ? (
<SettingRow
label={<Trans>Push on this device</Trans>}
testId="notification-push-switch"
checked={push === "on"}
disabled={push === "loading" || push === "blocked" || pushBusy}
onCheckedChange={(next) => void changePush(next)}
>
{push === "blocked" ? (
<p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans>Blocked in your browser settings</Trans>
</p>
) : null}
</SettingRow>
) : null}
<SettingRow
label={<Trans>Quiet hours</Trans>}
testId="notification-quiet-hours-switch"
checked={settings.quietHours !== null}
onCheckedChange={(next) => save({ quietHours: next ? quietDraft : null })}
>
{settings.quietHours ? (
<div className="mt-3 flex items-center gap-2">
<Input
type="time"
data-testid="notification-quiet-hours-start"
aria-label={t`Quiet hours start`}
value={quietDraft.start}
onChange={(event) => changeQuietTime("start", event.target.value)}
className="w-36"
/>
<span aria-hidden="true" className="text-muted-foreground">
–
</span>
<Input
type="time"
data-testid="notification-quiet-hours-end"
aria-label={t`Quiet hours end`}
value={quietDraft.end}
onChange={(event) => changeQuietTime("end", event.target.value)}
className="w-36"
/>
</div>
) : null}
</SettingRow>
<SettingRow
label={<Trans>Nudges from your Oo</Trans>}
testId="notification-nudges-switch"
checked={settings.nudges}
onCheckedChange={(next) => save({ nudges: next })}
/>
</ul>
</section>
) : null}
</div>
);
}

View File

@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts"; import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { import {
Bell,
Brain, Brain,
CloudDownload, CloudDownload,
Cpu, Cpu,
@ -16,6 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { type ComponentType, useEffect, useRef, useState } from "react"; import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel";
import { UserProfileSettings } from "../components/UserProfileSettings"; import { UserProfileSettings } from "../components/UserProfileSettings";
import type { TradingBotUiState } from "../lib/trading-bot-status"; import type { TradingBotUiState } from "../lib/trading-bot-status";
import { import {
@ -37,6 +39,7 @@ export type SettingsSection =
| "memory" | "memory"
| "profile" | "profile"
| "voice" | "voice"
| "notifications"
| "usage" | "usage"
| "computer" | "computer"
| "updates" | "updates"
@ -111,6 +114,7 @@ export function SettingsOverlay({
{ id: "memory", label: t`Memory`, icon: Brain }, { id: "memory", label: t`Memory`, icon: Brain },
{ id: "profile", label: t`About you`, icon: UserRound }, { id: "profile", label: t`About you`, icon: UserRound },
{ id: "voice", label: t`Voice`, icon: Volume2 }, { id: "voice", label: t`Voice`, icon: Volume2 },
{ id: "notifications", label: t`Notifications`, icon: Bell },
{ id: "usage", label: t`Usage`, icon: Gauge }, { id: "usage", label: t`Usage`, icon: Gauge },
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []), ...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
{ id: "updates", label: t`Updates`, icon: CloudDownload }, { id: "updates", label: t`Updates`, icon: CloudDownload },
@ -250,6 +254,7 @@ export function SettingsOverlay({
/> />
) : null} ) : null}
{section === "profile" ? <UserProfileSettings /> : null} {section === "profile" ? <UserProfileSettings /> : null}
{section === "notifications" ? <NotificationSettingsPanel /> : null}
{section === "usage" ? ( {section === "usage" ? (
<UsageSettingsPanel usage={usage} panelRef={usageRef} /> <UsageSettingsPanel usage={usage} panelRef={usageRef} />
) : null} ) : null}

View File

@ -247,6 +247,7 @@ import {
transcriptMovedDown, transcriptMovedDown,
} from "../lib/transcript-scroll"; } from "../lib/transcript-scroll";
import { speaker } from "../lib/tts"; import { speaker } from "../lib/tts";
import { disableWebPush } from "../lib/web-push";
import { ActivityList } from "./ActivityList"; import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu"; import type { ContextMenuPosition } from "./BotContextMenu";
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel"; import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
@ -4296,7 +4297,11 @@ export function ShellPage() {
variant="ghost" variant="ghost"
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
onClick={() => onClick={() =>
void authClient.signOut().then(() => { // This browser stops receiving the account's pushes once it signs out.
void disableWebPush()
.catch(() => undefined)
.then(() => authClient.signOut())
.then(() => {
clearSpaceSelection(); clearSpaceSelection();
navigate("/"); navigate("/");
}) })

View File

@ -19,7 +19,7 @@ import {
createWebProvider, createWebProvider,
databaseCapacityBackoffMs, databaseCapacityBackoffMs,
EncryptedSecretStore, EncryptedSecretStore,
ExpoPushProvider, ExpoPushSender,
GraphileJobPublisher, GraphileJobPublisher,
GraphileJobWorkerHost, GraphileJobWorkerHost,
InMemoryJobQueue, InMemoryJobQueue,
@ -36,6 +36,7 @@ import {
PiAgentRuntime, PiAgentRuntime,
PipedreamConnector, PipedreamConnector,
PostgresRealtimeFanout, PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv, pipedreamConfigFromEnv,
reconcileCloudAgents, reconcileCloudAgents,
reconcileComputerUpdates, reconcileComputerUpdates,
@ -45,6 +46,9 @@ import {
resolveSandboxProvider, resolveSandboxProvider,
ScriptedAgentRuntime, ScriptedAgentRuntime,
SpaceMemoryProviderResolver, SpaceMemoryProviderResolver,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core"; import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core";
import { import {
@ -193,7 +197,10 @@ async function main() {
secretStore: secrets, secretStore: secrets,
deploymentModelKey, deploymentModelKey,
dataDir, dataDir,
notifications: new ExpoPushProvider(dataDir), notifications: new PushNotificationProvider(prisma, {
webpush: new WebPushSender(new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env))),
expo: new ExpoPushSender(),
}),
jobs, jobs,
events, events,
messaging: messaging ? createMessagingContextLoader(prisma) : undefined, messaging: messaging ? createMessagingContextLoader(prisma) : undefined,

View File

@ -555,7 +555,7 @@ export interface ArtifactPut {
} }
export interface NotificationMessage { export interface NotificationMessage {
kind: "completion" | "failure" | "help" | "takeover"; kind: "completion" | "failure" | "help" | "takeover" | "nudge";
title: string; title: string;
body: string; body: string;
botId: string; botId: string;

View File

@ -8,6 +8,7 @@ import {
USER_FACT_CATEGORIES, USER_FACT_CATEGORIES,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { z } from "zod"; 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 = 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."; "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", name: "web_search",
description: description:

View File

@ -64,7 +64,11 @@ describe("builtin tools per bot", () => {
}); });
it("lists main-only tools for the main bot alone", () => { 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 specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false });
const main = builtinToolNamesForBot({ kind: "main", chatLinked: false }); const main = builtinToolNamesForBot({ kind: "main", chatLinked: false });
for (const name of MAIN_ONLY_BUILTIN_TOOLS) { for (const name of MAIN_ONLY_BUILTIN_TOOLS) {

View File

@ -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. */ /** 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<string> = new Set<string>([ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set<string>([
"delegate_task", "delegate_task",
"notify_user",
"update_user_profile", "update_user_profile",
]); ]);
@ -105,7 +106,10 @@ export function selectBuiltinToolsForRun(options: {
Boolean(options.cloudAgentEnabled), Boolean(options.cloudAgentEnabled),
).filter( ).filter(
(tool) => (tool) =>
!(options.foreignAgentRun && tool.name === "update_user_profile") && !(
options.foreignAgentRun &&
(tool.name === "update_user_profile" || tool.name === "notify_user")
) &&
(!options.messagingChannelRun || (!options.messagingChannelRun ||
(![ (![
"remember", "remember",
@ -113,6 +117,7 @@ export function selectBuiltinToolsForRun(options: {
"recall_memory", "recall_memory",
"forget_memory", "forget_memory",
"update_user_profile", "update_user_profile",
"notify_user",
].includes(tool.name) && ].includes(tool.name) &&
!tool.name.startsWith("scratchpad_"))), !tool.name.startsWith("scratchpad_"))),
); );

View File

@ -298,6 +298,7 @@ import {
modelAcceptsImageInput, modelAcceptsImageInput,
modelIdSupportsImages, modelIdSupportsImages,
} from "./model-vision.js"; } from "./model-vision.js";
import { notifyUserFromTool } from "./notify-user-tool.js";
import { toOAuthCredential } from "./pi-credentials.js"; import { toOAuthCredential } from "./pi-credentials.js";
import { import {
parseModelSecret, 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") { if (name === "web_search") {
return finish(await webSearchFromTool(web, context, args)); return finish(await webSearchFromTool(web, context, args));
} }

View File

@ -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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import type { PrismaClient } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
deletePushToken, ExpoPushSender,
ExpoPushProvider,
expoPushErrorMessage, expoPushErrorMessage,
loadPushToken, importLegacyPushTokens,
legacyPushTokenDir,
MAX_EXPO_PUSH_RESPONSE_BYTES, MAX_EXPO_PUSH_RESPONSE_BYTES,
savePushToken,
} from "./expo-push.js"; } from "./expo-push.js";
import type { PushPayload } from "./push-notifications.js";
const dirs: string[] = []; const dirs: string[] = [];
@ -18,13 +19,19 @@ afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
}); });
const notifyContext = { const device = { endpoint: "ExponentPushToken[test]", p256dh: null, auth: null };
operationId: "n",
traceId: "n", function payload(overrides: Partial<PushPayload> = {}): PushPayload {
spaceId: "w", return {
userId: "user-1", kind: "completion",
signal: new AbortController().signal, title: "done",
body: "ok",
botId: "b",
threadId: "t",
url: "/app/b",
...overrides,
}; };
}
function jsonResponse(body: unknown, status = 200) { function jsonResponse(body: unknown, status = 200) {
return Response.json(body, { status }); return Response.json(body, { status });
@ -49,75 +56,24 @@ describe("expo push tickets", () => {
}); });
}); });
describe("expo push", () => { describe("expo push sender", () => {
it("keeps refreshed push tokens owner-only", async () => { it("posts the notification to Expo for the device token", 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]");
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } })); .mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } }));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const push = new ExpoPushProvider(dataDir); await expect(
await push.send( new ExpoPushSender().send(
{ kind: "takeover", title: "Need you", body: "on screen", botId: "bot-1", threadId: "th-1" }, device,
notifyContext, payload({
); kind: "takeover",
title: "Need you",
body: "on screen",
botId: "bot-1",
threadId: "th-1",
}),
),
).resolves.toBe("sent");
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://exp.host/--/api/v2/push/send"); expect(url).toBe("https://exp.host/--/api/v2/push/send");
@ -126,55 +82,51 @@ describe("expo push", () => {
title: string; title: string;
collapseId: string; collapseId: string;
tag: string; tag: string;
data: { kind: string }; data: { kind: string; botId: string; threadId: string };
}; };
expect(body.to).toBe("ExponentPushToken[test]"); expect(body).toMatchObject({
expect(body.title).toBe("Need you"); to: "ExponentPushToken[test]",
expect(body.collapseId).toBe("th-1"); title: "Need you",
expect(body.tag).toBe("th-1"); collapseId: "th-1",
expect(body.data.kind).toBe("takeover"); 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 () => { 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( vi.stubGlobal(
"fetch", "fetch",
vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)), vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)),
); );
const push = new ExpoPushProvider(dataDir); await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("boom");
await expect(
push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("boom");
}); });
it("rejects and cancels a declared oversized response", async () => { 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", { const response = new Response("oversized", {
headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) }, headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) },
}); });
const cancel = vi.spyOn(response.body!, "cancel"); const cancel = vi.spyOn(response.body!, "cancel");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
await expect( await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
new ExpoPushProvider(dataDir).send( "Expo push response is too large.",
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, );
notifyContext,
),
).rejects.toThrow("Expo push response is too large.");
expect(cancel).toHaveBeenCalledOnce(); expect(cancel).toHaveBeenCalledOnce();
}); });
it("does not wait past cancellation when an oversized body cancel hangs", async () => { 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; let cancelStarted = false;
const hangingBody = new ReadableStream<Uint8Array>({ const hangingBody = new ReadableStream<Uint8Array>({
cancel() { cancel() {
@ -194,51 +146,31 @@ describe("expo push", () => {
); );
const started = Date.now(); const started = Date.now();
await expect( await expect(new ExpoPushSender().send(device, payload(), abort.signal)).rejects.toThrow(
new ExpoPushProvider(dataDir).send( "Expo push response is too large.",
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, );
{ ...notifyContext, signal: abort.signal },
),
).rejects.toThrow("Expo push response is too large.");
expect(cancelStarted).toBe(true); expect(cancelStarted).toBe(true);
expect(Date.now() - started).toBeLessThan(500); expect(Date.now() - started).toBeLessThan(500);
}); });
it("caps a streamed response without a content length", async () => { 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( vi.stubGlobal(
"fetch", "fetch",
vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))), vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))),
); );
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
await expect( "Expo push response is too large.",
new ExpoPushProvider(dataDir).send( );
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push response is too large.");
}); });
it("rejects malformed successful responses", async () => { 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"))); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not json")));
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
await expect( "Expo push returned an invalid response.",
new ExpoPushProvider(dataDir).send( );
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push returned an invalid response.");
}); });
it("passes caller cancellation to the Expo request", async () => { 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(); const controller = new AbortController();
controller.abort(new Error("notification cancelled")); controller.abort(new Error("notification cancelled"));
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
@ -247,49 +179,69 @@ describe("expo push", () => {
}); });
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
await expect( await expect(new ExpoPushSender().send(device, payload(), controller.signal)).rejects.toThrow(
new ExpoPushProvider(dataDir).send( "notification cancelled",
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, );
{ ...notifyContext, signal: controller.signal },
),
).rejects.toThrow("notification cancelled");
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
}); });
it("throws when the Expo request never reaches the network", async () => { 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"))); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
const push = new ExpoPushProvider(dataDir); await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("offline");
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 () => { 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-")); const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir); dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[old]"); const dir = legacyPushTokenDir(dataDir);
vi.stubGlobal( await mkdir(dir, { recursive: true });
"fetch", await writeFile(path.join(dir, "user-1.txt"), "ExponentPushToken[one]\n");
vi.fn(async () => { await writeFile(path.join(dir, "gone-user.txt"), "ExponentPushToken[two]");
await savePushToken(dataDir, "user-1", "ExponentPushToken[new]"); await writeFile(path.join(dir, "user-2.txt"), "not a token");
return jsonResponse({ const { prisma, upsert } = fakePrisma(["user-1", "user-2"]);
data: { status: "error", details: { error: "DeviceNotRegistered" } },
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);
const push = new ExpoPushProvider(dataDir); });
await expect(
push.send( it("does not follow a token-file symlink", async () => {
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
notifyContext, dirs.push(dataDir);
), const dir = legacyPushTokenDir(dataDir);
).rejects.toThrow("DeviceNotRegistered"); await mkdir(dir, { recursive: true });
await expect(loadPushToken(dataDir, "user-1")).resolves.toBe("ExponentPushToken[new]"); 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"]);
}); });
}); });

View File

@ -1,29 +1,29 @@
import { constants } from "node:fs"; 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 path from "node:path";
import type { import { ExpoPushTokenSchema } from "@rakazo/contracts";
AdapterContext, import type { PrismaClient } from "@rakazo/db";
NotificationMessage, import { savePushSubscription } from "@rakazo/db";
NotificationProvider,
} from "@rakazo/adapter-kit";
import { getLogger } from "@rakazo/logging";
import { combineSignals } from "./connector-safety.js"; import { combineSignals } from "./connector-safety.js";
import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js";
import { readBodyCapped, withAbort } from "./web-ssrf.js"; import { readBodyCapped, withAbort } from "./web-ssrf.js";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
const EXPO_PUSH_TIMEOUT_MS = 15_000; const EXPO_PUSH_TIMEOUT_MS = 15_000;
export const MAX_EXPO_PUSH_RESPONSE_BYTES = 64 * 1024; 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) { /** Where the API kept the single Expo token per user before push_subscriptions existed. */
return path.join(dataDir, "push-tokens", `${userId}.txt`); export function legacyPushTokenDir(dataDir: string) {
return path.join(dataDir, "push-tokens");
} }
export async function loadPushToken(dataDir: string, userId: string): Promise<string | undefined> { async function readLegacyToken(file: string): Promise<string | undefined> {
try { try {
const handle = await open(pushTokenPath(dataDir, userId), constants.O_RDONLY | O_NOFOLLOW); const handle = await open(file, constants.O_RDONLY | O_NOFOLLOW);
try { try {
const token = (await handle.readFile("utf8")).trim(); const token = ExpoPushTokenSchema.safeParse(await handle.readFile("utf8"));
return token || undefined; return token.success ? token.data : undefined;
} finally { } finally {
await handle.close(); await handle.close();
} }
@ -32,26 +32,39 @@ export async function loadPushToken(dataDir: string, userId: string): Promise<st
} }
} }
export async function savePushToken(dataDir: string, userId: string, token: string): Promise<void> { /**
const file = pushTokenPath(dataDir, userId); * Moves tokens from the legacy files into push_subscriptions once, then deletes each file. A file
await mkdir(path.dirname(file), { recursive: true }); * whose user no longer exists, or that holds no usable token, is deleted without import; a
const handle = await open( * database failure keeps the file for the next start.
file, */
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW, export async function importLegacyPushTokens(
0o600, prisma: PrismaClient,
); dataDir: string,
): Promise<number> {
const dir = legacyPushTokenDir(dataDir);
let names: string[];
try { try {
await handle.chmod(0o600); names = await readdir(dir);
await handle.writeFile(token.trim(), "utf8"); } catch {
} finally { return 0;
await handle.close(); }
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);
export async function deletePushToken(dataDir: string, userId: string): Promise<void> { }
await unlink(pushTokenPath(dataDir, userId)).catch((error: NodeJS.ErrnoException) => { await rmdir(dir).catch(() => undefined);
if (error.code !== "ENOENT") throw error; return imported;
});
} }
export type ExpoPushTicket = { export type ExpoPushTicket = {
@ -87,49 +100,33 @@ export function expoPushErrorMessage(body: unknown, status: number): string | un
return undefined; return undefined;
} }
export class ExpoPushProvider implements NotificationProvider { /** Sends to one Expo push token; "gone" when Expo reports the device is no longer registered. */
constructor(private readonly dataDir: string) {} export class ExpoPushSender {
async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise<PushOutcome> {
describe() { const deadline = combineSignals(signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS));
return { const response = await fetch("https://exp.host/--/api/v2/push/send", {
id: "expo-push",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { push: true, email: false },
};
}
async send(message: NotificationMessage, context: AdapterContext): Promise<void> {
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", method: "POST",
headers: { "content-type": "application/json", accept: "application/json" }, headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ body: JSON.stringify({
to: token, to: target.endpoint,
title: message.title, title: payload.title,
body: message.body, body: payload.body,
collapseId: message.threadId, collapseId: payload.threadId,
tag: message.threadId, tag: payload.threadId,
data: { kind: message.kind, botId: message.botId, threadId: message.threadId }, data: { kind: payload.kind, botId: payload.botId, threadId: payload.threadId },
}), }),
signal, signal: deadline,
}); });
} catch (error) { const body = await readExpoPushBody(response, deadline);
getLogger().error("expo push request failed", error);
throw error;
}
const body = await readExpoPushBody(response, signal);
if (response.ok && body === undefined) { if (response.ok && body === undefined) {
throw new Error("Expo push returned an invalid response."); 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); const failure = expoPushErrorMessage(body, response.status);
if (!failure) return; if (failure) throw new Error(failure);
getLogger().error(failure); return "sent";
throw new Error(failure);
} }
} }

View File

@ -101,6 +101,7 @@ export * from "./pipedream-connector.js";
export * from "./profile-consolidation.js"; export * from "./profile-consolidation.js";
export * from "./project-workspace-files.js"; export * from "./project-workspace-files.js";
export * from "./project-workspace-path.js"; export * from "./project-workspace-path.js";
export * from "./push-notifications.js";
export * from "./realtime.js"; export * from "./realtime.js";
export * from "./release-watch.js"; export * from "./release-watch.js";
export * from "./remote-mcp.js"; export * from "./remote-mcp.js";

View File

@ -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<string, unknown>,
): Promise<NotifyUserResult> {
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 };
}

View File

@ -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<void>;
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<ReturnType<typeof newUser>>, 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) };
}

View File

@ -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);
});
});

View File

@ -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<PushOutcome>;
}
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<Record<PushSubscriptionKind, PushSender>>,
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<void> {
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<string> {
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)}`;
}
}

View File

@ -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<string, string>();
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");
});
});

View File

@ -1,11 +1,11 @@
import { lookup } from "node:dns/promises"; import { lookup } from "node:dns/promises";
import type { NotificationMessage } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
import { Agent } from "undici"; import { Agent } from "undici";
import webPush from "web-push"; import webPush from "web-push";
import { combineSignals } from "./connector-safety.js"; import { combineSignals } from "./connector-safety.js";
import { createAddressCheckedLookup, type ResolveHostname } from "./network-address.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 type { EncryptedSecretStore } from "./secrets.js";
import { dispatcherFetch } from "./undici-fetch.js"; import { dispatcherFetch } from "./undici-fetch.js";
import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.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 { export interface WebPushSenderOptions {
fetch?: typeof globalThis.fetch; fetch?: typeof globalThis.fetch;
resolveHostname?: ResolveHostname; resolveHostname?: ResolveHostname;

View File

@ -41,6 +41,13 @@ export const WebPushEndpointSchema = z
} }
}, "Endpoint must be an https URL"); }, "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). */ /** The JSON form of a browser PushSubscription (RFC 8291 keys, base64url). */
export const WebPushSubscriptionInput = z.object({ export const WebPushSubscriptionInput = z.object({
endpoint: WebPushEndpointSchema, endpoint: WebPushEndpointSchema,

View File

@ -84,6 +84,7 @@ import {
} from "./integration-settings.js"; } from "./integration-settings.js";
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
import { import {
ExpoPushTokenSchema,
NotificationSettingsSchema, NotificationSettingsSchema,
UpdateNotificationSettingsInput, UpdateNotificationSettingsInput,
WebPushConfigSchema, WebPushConfigSchema,
@ -811,7 +812,7 @@ export const appContract = {
}, },
notifications: { notifications: {
registerPush: oc registerPush: oc
.input(z.object({ token: z.string().min(8).max(512) })) .input(z.object({ token: ExpoPushTokenSchema }))
.output(z.object({ ok: z.literal(true) })), .output(z.object({ ok: z.literal(true) })),
unregisterPush: oc.output(z.object({ ok: z.literal(true) })), unregisterPush: oc.output(z.object({ ok: z.literal(true) })),
settings: { settings: {

View File

@ -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);
});
});

View File

@ -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 ( ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK (
("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL) ("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL)
OR ( 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 "quietHoursEnd" BETWEEN 0 AND 1439
AND "quietHoursStart" <> "quietHoursEnd" AND "quietHoursStart" <> "quietHoursEnd"
) )

View File

@ -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<void>;
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);
});
});

View File

@ -86,3 +86,24 @@ export async function listPushSubscriptions(
}); });
return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind })); return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind }));
} }
export async function countPushSubscriptions(
prisma: PrismaClient,
userId: string,
): Promise<number> {
return prisma.pushSubscription.count({ where: { userId } });
}
/** Records a successful delivery to a device. */
export async function markPushSubscriptionUsed(
prisma: PrismaClient,
id: string,
at: Date,
): Promise<void> {
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<void> {
await prisma.pushSubscription.deleteMany({ where: { id } });
}

View File

@ -109,9 +109,11 @@ async function main() {
"packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/delegation.postgres.test.ts",
"packages/testkit/src/user-profile.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts",
"packages/testkit/src/onboarding-discovery.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/space-membership.postgres.test.ts",
"packages/db/src/main-agent.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts",
"packages/db/src/user-profile.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/db/src/messaging.postgres.test.ts",
"packages/memory/src/commit.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts",
"packages/adapters/src/wakeup.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/cloud-agent.postgres.test.ts",
"packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts",
"packages/adapters/src/profile-consolidation.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 // Each app reconciles all durable work in its database, including intentionally
// unfinished fixture runs. Clone the pristine migrated schema so one suite // unfinished fixture runs. Clone the pristine migrated schema so one suite

View File

@ -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<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
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<NotificationSettings>(app, owner, "notifications/settings/get"),
).resolves.toEqual({ quietHours: null, nudges: true });
await expect(
rpc<NotificationSettings>(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<NotificationSettings>(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<NotificationSettings>(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<WebPushConfig>(app, cookie, "notifications/webPush/config");
expect(first.available).toBe(true);
expect(first.publicKey).toMatch(/^[A-Za-z0-9_-]{87}$/);
await expect(rpc<WebPushConfig>(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<T>(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;
}