refactor(notifications): keep the push sender contract next to the fan-out

The payload, target and outcome types are shared by the Expo and web push senders, so they now
live with PushSender in push-notifications.ts instead of in the web push adapter. The settings
E2E also stubs the Notification permission (headless Chromium reports it as denied even when
granted), and the quiet-hours inputs are wide enough for 12-hour times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:49:49 +00:00
parent 7185dcdd3c
commit 2d95db70f2
No known key found for this signature in database
9 changed files with 50 additions and 27 deletions

View File

@ -8,9 +8,26 @@ import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup }
const ENDPOINT = "https://push.example.test/e2e-device";
/** A fake service worker registration whose push subscription lives in sessionStorage. */
/**
* 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";
Object.defineProperty(window, "Notification", {
configurable: true,
writable: true,
value: class {
static get permission() {
return sessionStorage.getItem(permissionKey) ?? "default";
}
static async requestPermission() {
sessionStorage.setItem(permissionKey, "granted");
return "granted";
}
},
});
const storageKey = "e2e:push-subscription";
const current = () => {
const stored = sessionStorage.getItem(storageKey);
@ -59,7 +76,6 @@ async function stubPushApi(page: Page, endpoint: string) {
test("notification settings: push on this device, quiet hours and nudges", async ({
page,
}, testInfo) => {
await page.context().grantPermissions(["notifications"]);
await stubPushApi(page, ENDPOINT);
await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
await completeOnboarding(page);

View File

@ -167,7 +167,7 @@ export function NotificationSettingsPanel() {
aria-label={t`Quiet hours start`}
value={quietDraft.start}
onChange={(event) => changeQuietTime("start", event.target.value)}
className="w-32"
className="w-36"
/>
<span aria-hidden="true" className="text-muted-foreground">
–
@ -178,7 +178,7 @@ export function NotificationSettingsPanel() {
aria-label={t`Quiet hours end`}
value={quietDraft.end}
onChange={(event) => changeQuietTime("end", event.target.value)}
className="w-32"
className="w-36"
/>
</div>
) : null}

View File

@ -10,7 +10,7 @@ import {
legacyPushTokenDir,
MAX_EXPO_PUSH_RESPONSE_BYTES,
} from "./expo-push.js";
import type { PushPayload } from "./web-push.js";
import type { PushPayload } from "./push-notifications.js";
const dirs: string[] = [];

View File

@ -5,7 +5,7 @@ import { ExpoPushTokenSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { savePushSubscription } from "@rakazo/db";
import { combineSignals } from "./connector-safety.js";
import type { PushOutcome, PushPayload, PushTarget } from "./web-push.js";
import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js";
import { readBodyCapped, withAbort } from "./web-ssrf.js";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;

View File

@ -9,8 +9,11 @@ import {
} from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { notifyUserFromTool } from "./notify-user-tool.js";
import { PushNotificationProvider, type PushSender } from "./push-notifications.js";
import type { PushPayload } from "./web-push.js";
import {
PushNotificationProvider,
type PushPayload,
type PushSender,
} from "./push-notifications.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =

View File

@ -1,8 +1,12 @@
import type { NotificationMessage } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { PushNotificationProvider, type PushSender } from "./push-notifications.js";
import type { PushOutcome, PushPayload } from "./web-push.js";
import {
PushNotificationProvider,
type PushOutcome,
type PushPayload,
type PushSender,
} from "./push-notifications.js";
const context = {
operationId: "notify",

View File

@ -11,11 +11,24 @@ import {
markPushSubscriptionUsed,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { PushOutcome, PushPayload, PushTarget } from "./web-push.js";
/** 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>;
}

View File

@ -2,8 +2,9 @@ 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 { type PushPayload, VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js";
import { VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js";
const browserKey = createECDH("prime256v1");
browserKey.generateKeys();

View File

@ -1,11 +1,11 @@
import { lookup } from "node:dns/promises";
import type { NotificationMessage } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { Agent } from "undici";
import webPush from "web-push";
import { combineSignals } from "./connector-safety.js";
import { createAddressCheckedLookup, type ResolveHostname } from "./network-address.js";
import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js";
import type { EncryptedSecretStore } from "./secrets.js";
import { dispatcherFetch } from "./undici-fetch.js";
import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.js";
@ -137,20 +137,6 @@ export class VapidKeys {
}
}
/** What a device shows: the notification plus where a tap leads (a path in the web app). */
export interface PushPayload extends NotificationMessage {
url: string;
}
/** "gone": the push service no longer knows this device, so its registration can be dropped. */
export type PushOutcome = "sent" | "gone";
export interface PushTarget {
endpoint: string;
p256dh: string | null;
auth: string | null;
}
export interface WebPushSenderOptions {
fetch?: typeof globalThis.fetch;
resolveHostname?: ResolveHostname;