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:
commit
245efc88b1
@ -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=
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -242,6 +242,7 @@ export async function openUserSettings(
|
|||||||
| "memory"
|
| "memory"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "voice"
|
| "voice"
|
||||||
|
| "notifications"
|
||||||
| "usage"
|
| "usage"
|
||||||
| "computer"
|
| "computer"
|
||||||
| "updates",
|
| "updates",
|
||||||
|
|||||||
129
apps/web/e2e/notification-settings.spec.ts
Normal file
129
apps/web/e2e/notification-settings.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
197
apps/web/src/components/NotificationSettingsPanel.tsx
Normal file
197
apps/web/src/components/NotificationSettingsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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("/");
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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_"))),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
85
packages/adapters/src/notify-user-tool.ts
Normal file
85
packages/adapters/src/notify-user-tool.ts
Normal 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 };
|
||||||
|
}
|
||||||
219
packages/adapters/src/notify-user.postgres.test.ts
Normal file
219
packages/adapters/src/notify-user.postgres.test.ts
Normal 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) };
|
||||||
|
}
|
||||||
183
packages/adapters/src/push-notifications.test.ts
Normal file
183
packages/adapters/src/push-notifications.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
packages/adapters/src/push-notifications.ts
Normal file
102
packages/adapters/src/push-notifications.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
packages/adapters/src/web-push.test.ts
Normal file
156
packages/adapters/src/web-push.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
62
packages/core/src/quiet-hours.test.ts
Normal file
62
packages/core/src/quiet-hours.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
184
packages/db/src/notification-settings.postgres.test.ts
Normal file
184
packages/db/src/notification-settings.postgres.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 } });
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
198
packages/testkit/src/notifications.postgres.test.ts
Normal file
198
packages/testkit/src/notifications.postgres.test.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user