/** * @module sdkPush * * SDKPushClient — typed wrapper for Web Push subscribe/unsubscribe flow. * * Spec: docs/superpowers/plans/2026-05-13-w3-push-notifications.md T9 * Server API: POST/DELETE /api/sdk/push/{subscribe,unsubscribe} * GET /api/sdk/push/vapid-public-key * * Auth: SDK JWT with scope push:write:* in Authorization: Bearer header. * Key encoding: browser ArrayBuffer keys (p256dh, auth) → base64url before POST. * Server VAPID public key (base64url) → Uint8Array for PushManager. * * VAPID public key endpoint is intentionally unauthenticated — the public key * is non-secret per RFC 8292 §3. Only subscribe/unsubscribe require Bearer JWT. */ export type SDKPushErrorCode = 'unsupported' | 'invalid_args' | 'permission_denied' | 'permission_required' | 'no_vapid_key' | 'network' | 'server_4xx' | 'server_5xx' | 'subscription_invalid'; export declare class SDKPushError extends Error { readonly code: SDKPushErrorCode; readonly cause: Error | Response | unknown; constructor(code: SDKPushErrorCode, message: string, cause: Error | Response | unknown); } export interface SubscribeResult { endpoint: string; /** Server-returned device UUID. */ deviceId: string; } export interface SubscriptionChangeListenerOpts { /** Called when SW rotated the subscription to a new endpoint. */ onResubscribed: (newEndpoint: string) => void | Promise; /** Called when the new subscription is null (permission revoked / VAPID changed). */ onLost: (oldEndpoint: string | null) => void | Promise; /** Called when onResubscribed or onLost rejects. Defaults to console.error. */ onError?: (err: unknown) => void; } export declare class SDKPushClient { private readonly jwt; private readonly baseUrl; private vapidCache; /** * @param args.jwt Raw SDK JWT with scope push:write:* — do NOT include "Bearer " prefix. * The wrapper adds the prefix. Passing a pre-prefixed token throws. * @param args.baseUrl Optional URL prefix; default ''. * * @throws {SDKPushError} code='invalid_args' if jwt starts with "Bearer ". */ constructor(args: { jwt: string; baseUrl?: string; }); /** * Returns true when Notification API + ServiceWorker + PushManager are all present. * Firefox<99 has serviceWorker without PushManager — both checks required. * * **Caveat:** iOS Safari < 16.4 (and PWA-in-WKWebView on older iOS) returns `true` here * but `subscribe()` will reject — Apple did not implement Web Push until iOS 16.4 * (March 2023). Callers integrating PWAs on older iOS should also gate UX on a * `userAgent` sniff if this matters. */ static isSupported(): boolean; /** Returns current Notification.permission. */ static permission(): NotificationPermission; /** * Requests notification permission from the user. * * Handles Safari<16 which uses the legacy callback-form of * Notification.requestPermission() (returns undefined instead of Promise). * * Implementation note: we probe by calling without a callback first. * If the return value is a Promise (modern path), we return it directly — * this ensures requestPermission() is called exactly once. * If it returns undefined (Safari<16 callback path), we re-call with a * callback. The browser only shows the permission prompt once per user * gesture, so the no-arg probe on Safari<16 is a no-op (returns undefined * without prompting); the callback-form call is the one that actually * prompts. * * @returns Promise that resolves to 'granted' | 'denied' | 'default'. * * @throws {SDKPushError} code='unsupported' if Notification API is absent. * * @example * const perm = await SDKPushClient.requestPermission(); * if (perm !== 'granted') { // show UI explaining why push is needed } */ static requestPermission(): Promise; /** * Fetches VAPID public key from server. * Result is cached per client instance — only one network request per instance. * * The /api/sdk/push/vapid-public-key endpoint is intentionally unauthenticated: * the VAPID public key is non-secret per RFC 8292 §3. subscribe/unsubscribe * carry the Bearer JWT; this endpoint does not. * * @throws {SDKPushError} code='network' | 'server_4xx' | 'server_5xx' | 'no_vapid_key' * * @example * const key = await client.getVapidPublicKey(); */ getVapidPublicKey(): Promise; /** * Subscribes to Web Push notifications. * * Caller MUST call `requestPermission()` and await `'granted'` before `subscribe()`. * If permission is not yet granted this method throws with code='permission_required'. * * Flow: * 1. Permission check — throws permission_denied if denied, permission_required if * not yet granted. * 2. Fetch VAPID key (cached). * 3. navigator.serviceWorker.ready → pushManager.getSubscription() (reuse if present). * 4. If no existing subscription: pushManager.subscribe({userVisibleOnly, applicationServerKey}). * 5. POST /api/sdk/push/subscribe with base64url-encoded keys. * 6. Return {endpoint, deviceId}. * * @throws {SDKPushError} code='permission_denied' | 'permission_required' | 'unsupported' * | 'network' | 'server_4xx' | 'server_5xx' | 'subscription_invalid' * * Note on AbortError: on iOS, `AbortError` from `pushManager.subscribe()` maps to * `code='permission_required'` (not `'network'`) — the prompt closed without the * user explicitly granting or denying. Callers should prompt the user to try again. * * @example * const perm = await SDKPushClient.requestPermission(); * if (perm === 'granted') { * const { endpoint, deviceId } = await client.subscribe(); * } */ subscribe(opts?: { userAgent?: string; }): Promise; /** * Unsubscribes from Web Push. * * Server DELETE is called first to handle 410-style stale cleanup. * For non-410 server errors (401/429/5xx) we still call PushSubscription.unsubscribe() * browser-side before rethrowing — a browser subscription leak is worse than server * inconsistency: the server can be reconciled via admin tooling, but a leaked browser * subscription causes perpetual delivery attempts. * * @throws {SDKPushError} code='unsupported' | 'network' | 'server_4xx' | 'server_5xx' * * @example * await client.unsubscribe(); */ unsubscribe(): Promise; /** * Returns the current PushSubscription or null if not subscribed. * * @throws {SDKPushError} code='unsupported' if ServiceWorker is not ready. * * @example * const sub = await client.currentSubscription(); * if (sub) console.log(sub.endpoint); */ currentSubscription(): Promise; /** * Wires the SW `pushsubscriptionchange` consumer. * * The SW posts `{type:'push_subscription_changed', oldEndpoint, newEndpoint}` * to all clients (web/static/sw.js). When `newEndpoint !== null`, calls * `onResubscribed`; when `newEndpoint === null` (permission revoked / VAPID * changed), calls `onLost`. * * Returns a teardown function that removes the message listener. * * Async rejections from `onResubscribed` / `onLost` are forwarded to * `opts.onError` (defaults to `console.error`) so they are never silently lost. * * @throws Never (errors in callbacks are forwarded to opts.onError). * * @example * const teardown = client.attachSubscriptionChangeListener({ * onResubscribed: async (ep) => { await reRegister(ep); }, * onLost: (old) => showResubscribeBanner(), * onError: (err) => reportError(err), * }); * // later: * teardown(); */ attachSubscriptionChangeListener(opts: SubscriptionChangeListenerOpts): () => void; } //# sourceMappingURL=push.d.ts.map