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:
parent
7185dcdd3c
commit
2d95db70f2
@ -8,9 +8,26 @@ import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup }
|
|||||||
|
|
||||||
const ENDPOINT = "https://push.example.test/e2e-device";
|
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) {
|
async function stubPushApi(page: Page, endpoint: string) {
|
||||||
await page.addInitScript((pushEndpoint) => {
|
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 storageKey = "e2e:push-subscription";
|
||||||
const current = () => {
|
const current = () => {
|
||||||
const stored = sessionStorage.getItem(storageKey);
|
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 ({
|
test("notification settings: push on this device, quiet hours and nudges", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await page.context().grantPermissions(["notifications"]);
|
|
||||||
await stubPushApi(page, ENDPOINT);
|
await stubPushApi(page, ENDPOINT);
|
||||||
await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
|
await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|||||||
@ -167,7 +167,7 @@ export function NotificationSettingsPanel() {
|
|||||||
aria-label={t`Quiet hours start`}
|
aria-label={t`Quiet hours start`}
|
||||||
value={quietDraft.start}
|
value={quietDraft.start}
|
||||||
onChange={(event) => changeQuietTime("start", event.target.value)}
|
onChange={(event) => changeQuietTime("start", event.target.value)}
|
||||||
className="w-32"
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
<span aria-hidden="true" className="text-muted-foreground">
|
<span aria-hidden="true" className="text-muted-foreground">
|
||||||
–
|
–
|
||||||
@ -178,7 +178,7 @@ export function NotificationSettingsPanel() {
|
|||||||
aria-label={t`Quiet hours end`}
|
aria-label={t`Quiet hours end`}
|
||||||
value={quietDraft.end}
|
value={quietDraft.end}
|
||||||
onChange={(event) => changeQuietTime("end", event.target.value)}
|
onChange={(event) => changeQuietTime("end", event.target.value)}
|
||||||
className="w-32"
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
legacyPushTokenDir,
|
legacyPushTokenDir,
|
||||||
MAX_EXPO_PUSH_RESPONSE_BYTES,
|
MAX_EXPO_PUSH_RESPONSE_BYTES,
|
||||||
} from "./expo-push.js";
|
} from "./expo-push.js";
|
||||||
import type { PushPayload } from "./web-push.js";
|
import type { PushPayload } from "./push-notifications.js";
|
||||||
|
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { ExpoPushTokenSchema } from "@rakazo/contracts";
|
|||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { savePushSubscription } from "@rakazo/db";
|
import { savePushSubscription } from "@rakazo/db";
|
||||||
import { combineSignals } from "./connector-safety.js";
|
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";
|
import { readBodyCapped, withAbort } from "./web-ssrf.js";
|
||||||
|
|
||||||
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
||||||
|
|||||||
@ -9,8 +9,11 @@ import {
|
|||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { notifyUserFromTool } from "./notify-user-tool.js";
|
import { notifyUserFromTool } from "./notify-user-tool.js";
|
||||||
import { PushNotificationProvider, type PushSender } from "./push-notifications.js";
|
import {
|
||||||
import type { PushPayload } from "./web-push.js";
|
PushNotificationProvider,
|
||||||
|
type PushPayload,
|
||||||
|
type PushSender,
|
||||||
|
} from "./push-notifications.js";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
const describePostgres =
|
const describePostgres =
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import type { NotificationMessage } from "@rakazo/adapter-kit";
|
import type { NotificationMessage } from "@rakazo/adapter-kit";
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { PushNotificationProvider, type PushSender } from "./push-notifications.js";
|
import {
|
||||||
import type { PushOutcome, PushPayload } from "./web-push.js";
|
PushNotificationProvider,
|
||||||
|
type PushOutcome,
|
||||||
|
type PushPayload,
|
||||||
|
type PushSender,
|
||||||
|
} from "./push-notifications.js";
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
operationId: "notify",
|
operationId: "notify",
|
||||||
|
|||||||
@ -11,11 +11,24 @@ import {
|
|||||||
markPushSubscriptionUsed,
|
markPushSubscriptionUsed,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
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. */
|
/** Lock screens show a line or two; the rest lives in the conversation. */
|
||||||
const PUSH_BODY_MAX_LENGTH = 240;
|
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 {
|
export interface PushSender {
|
||||||
send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise<PushOutcome>;
|
send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise<PushOutcome>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { createECDH, randomBytes } from "node:crypto";
|
|||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import webPush from "web-push";
|
import webPush from "web-push";
|
||||||
|
import type { PushPayload } from "./push-notifications.js";
|
||||||
import { EncryptedSecretStore } from "./secrets.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");
|
const browserKey = createECDH("prime256v1");
|
||||||
browserKey.generateKeys();
|
browserKey.generateKeys();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user