@maravilla-labs/platform 0.1.41 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/push.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/push.ts
2
- var DEFAULT_BASE_PATH = "/_platform/push";
3
- var DEFAULT_SW_PATH = "/_platform/push/sw.js";
2
+ var DEFAULT_BASE_PATH = "/_rt/push";
3
+ var DEFAULT_SW_PATH = "/_rt/push/sw.js";
4
4
  var VISITOR_STORAGE_KEY = "maravilla.push.visitorId";
5
+ var REGISTER_TIMEOUT_MS = 1e4;
5
6
  function assertPushSupported() {
6
7
  if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
7
8
  throw new Error("Web Push is not supported: serviceWorker is unavailable");
@@ -87,37 +88,45 @@ async function registerPush(opts = {}) {
87
88
  const topics = opts.topics ?? [];
88
89
  const userId = opts.userId ?? null;
89
90
  const visitorId = resolveVisitorId(userId, opts.visitorId);
90
- const publicKey = await fetchVapidPublicKey(basePath);
91
- const registration = await navigator.serviceWorker.register(swPath);
92
- await navigator.serviceWorker.ready;
93
- const existing = await registration.pushManager.getSubscription();
94
- const subscription = existing ?? await registration.pushManager.subscribe({
95
- userVisibleOnly: true,
96
- applicationServerKey: base64UrlToArrayBuffer(publicKey)
97
- });
98
- const { p256dh, auth } = extractKeys(subscription);
99
- const res = await fetch(`${basePath}/subscribe`, {
100
- method: "POST",
101
- credentials: "same-origin",
102
- headers: { "Content-Type": "application/json", Accept: "application/json" },
103
- body: JSON.stringify({
104
- provider: "web-push",
105
- endpoint: subscription.endpoint,
106
- p256dh,
107
- auth,
108
- userId,
109
- visitorId,
110
- topics
111
- })
112
- });
113
- if (!res.ok) {
114
- throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
115
- }
116
- const saved = await res.json();
117
- if (!saved || typeof saved.id !== "string" || saved.id.length === 0) {
118
- throw new Error("Subscribe response is missing `id`");
119
- }
120
- return { subscription, subscriptionId: saved.id };
91
+ const timeout = new Promise(
92
+ (_, reject) => setTimeout(
93
+ () => reject(new Error(`registerPush timed out after ${REGISTER_TIMEOUT_MS}ms`)),
94
+ REGISTER_TIMEOUT_MS
95
+ )
96
+ );
97
+ const flow = (async () => {
98
+ const publicKey = await fetchVapidPublicKey(basePath);
99
+ const registration = await navigator.serviceWorker.register(swPath);
100
+ const existing = await registration.pushManager.getSubscription();
101
+ const subscription = existing ?? await registration.pushManager.subscribe({
102
+ userVisibleOnly: true,
103
+ applicationServerKey: base64UrlToArrayBuffer(publicKey)
104
+ });
105
+ const { p256dh, auth } = extractKeys(subscription);
106
+ const res = await fetch(`${basePath}/subscribe`, {
107
+ method: "POST",
108
+ credentials: "same-origin",
109
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
110
+ body: JSON.stringify({
111
+ provider: "web-push",
112
+ endpoint: subscription.endpoint,
113
+ p256dh,
114
+ auth,
115
+ userId,
116
+ visitorId,
117
+ topics
118
+ })
119
+ });
120
+ if (!res.ok) {
121
+ throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
122
+ }
123
+ const saved = await res.json();
124
+ if (!saved || typeof saved.id !== "string" || saved.id.length === 0) {
125
+ throw new Error("Subscribe response is missing `id`");
126
+ }
127
+ return { subscription, subscriptionId: saved.id };
128
+ })();
129
+ return Promise.race([flow, timeout]);
121
130
  }
122
131
  async function unregisterPush(subscriptionId, opts = {}) {
123
132
  if (!subscriptionId) {
@@ -134,7 +143,30 @@ async function unregisterPush(subscriptionId, opts = {}) {
134
143
  throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);
135
144
  }
136
145
  }
146
+ function offsetBefore(anchor, offset) {
147
+ const anchorDate = anchor instanceof Date ? anchor : new Date(anchor);
148
+ if (Number.isNaN(anchorDate.getTime())) {
149
+ throw new Error(`offsetBefore: invalid anchor "${String(anchor)}"`);
150
+ }
151
+ const match = /^(\d+)\s*(s|m|h|d|w)$/i.exec(offset.trim());
152
+ if (!match) {
153
+ throw new Error(
154
+ `offsetBefore: invalid offset "${offset}" \u2014 expected something like "30m", "1h", "2d", "1w"`
155
+ );
156
+ }
157
+ const amount = Number(match[1]);
158
+ const unit = match[2].toLowerCase();
159
+ const UNIT_MS = {
160
+ s: 1e3,
161
+ m: 6e4,
162
+ h: 36e5,
163
+ d: 864e5,
164
+ w: 6048e5
165
+ };
166
+ return new Date(anchorDate.getTime() - amount * UNIT_MS[unit]);
167
+ }
137
168
  export {
169
+ offsetBefore,
138
170
  registerPush,
139
171
  unregisterPush
140
172
  };
package/dist/push.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/push.ts"],"sourcesContent":["/**\n * @fileoverview Web Push client SDK for Maravilla tenants.\n *\n * Talks to the tenant-origin `/_platform/push` endpoints served by delivery:\n * - GET /vapid-public-key — fetch the VAPID public key for subscribe\n * - POST /subscribe — create a subscription\n * - POST /unsubscribe — delete a subscription by id (or endpoint)\n *\n * The matching service worker is served from `/_platform/push/sw.js` on the\n * same tenant origin (browsers require same-origin SW registration).\n */\n\nconst DEFAULT_BASE_PATH = '/_platform/push';\nconst DEFAULT_SW_PATH = '/_platform/push/sw.js';\nconst VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';\n\nexport interface RegisterPushOptions {\n /** Free-form topic strings to tag this subscription with. */\n topics?: string[];\n /** Authenticated user id, if any. Takes precedence over visitorId. */\n userId?: string | null;\n /** Anonymous visitor id. If omitted and userId is also omitted, one\n * is generated and persisted to localStorage under `maravilla.push.visitorId`. */\n visitorId?: string | null;\n /** Override the sw.js path (defaults to `/_platform/push/sw.js`). */\n swPath?: string;\n /** Override the API base path (defaults to `/_platform/push`). */\n basePath?: string;\n}\n\nexport interface RegisterPushResult {\n /** The browser PushSubscription (see Web Push spec). */\n subscription: PushSubscription;\n /** Server-issued subscription id — pass this back to unregisterPush. */\n subscriptionId: string;\n}\n\ninterface VapidPublicKeyResponse {\n publicKey: string;\n contactEmail?: string;\n}\n\ninterface ServerSubscription {\n id: string;\n [key: string]: unknown;\n}\n\nfunction assertPushSupported(): void {\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n throw new Error('Web Push is not supported: serviceWorker is unavailable');\n }\n if (typeof window === 'undefined' || !('PushManager' in window)) {\n throw new Error('Web Push is not supported: PushManager is unavailable');\n }\n}\n\nfunction base64UrlToArrayBuffer(input: string): ArrayBuffer {\n const padding = '='.repeat((4 - (input.length % 4)) % 4);\n const base64 = (input + padding).replace(/-/g, '+').replace(/_/g, '/');\n const raw = atob(base64);\n const buffer = new ArrayBuffer(raw.length);\n const view = new Uint8Array(buffer);\n for (let i = 0; i < raw.length; i++) {\n view[i] = raw.charCodeAt(i);\n }\n return buffer;\n}\n\nfunction arrayBufferToBase64Url(buffer: ArrayBuffer | null): string | undefined {\n if (!buffer) return undefined;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\nfunction randomUuid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined;\n if (c && typeof c.randomUUID === 'function') {\n return c.randomUUID();\n }\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') {\n c.getRandomValues(bytes);\n } else {\n for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\nfunction resolveVisitorId(\n userId: string | null | undefined,\n visitorId: string | null | undefined,\n): string | null {\n if (visitorId) return visitorId;\n if (userId) return null;\n try {\n const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);\n if (stored) return stored;\n const fresh = randomUuid();\n window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);\n return fresh;\n } catch {\n return randomUuid();\n }\n}\n\nasync function fetchVapidPublicKey(basePath: string): Promise<string> {\n const res = await fetch(`${basePath}/vapid-public-key`, {\n method: 'GET',\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n if (!res.ok) {\n throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);\n }\n const body = (await res.json()) as VapidPublicKeyResponse;\n if (!body || typeof body.publicKey !== 'string' || body.publicKey.length === 0) {\n throw new Error('VAPID public key response is missing `publicKey`');\n }\n return body.publicKey;\n}\n\nfunction extractKeys(sub: PushSubscription): { p256dh?: string; auth?: string } {\n return {\n p256dh: arrayBufferToBase64Url(sub.getKey('p256dh')),\n auth: arrayBufferToBase64Url(sub.getKey('auth')),\n };\n}\n\nexport async function registerPush(\n opts: RegisterPushOptions = {},\n): Promise<RegisterPushResult> {\n assertPushSupported();\n\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n const swPath = opts.swPath ?? DEFAULT_SW_PATH;\n const topics = opts.topics ?? [];\n const userId = opts.userId ?? null;\n const visitorId = resolveVisitorId(userId, opts.visitorId);\n\n const publicKey = await fetchVapidPublicKey(basePath);\n const registration = await navigator.serviceWorker.register(swPath);\n await navigator.serviceWorker.ready;\n\n const existing = await registration.pushManager.getSubscription();\n const subscription =\n existing ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: base64UrlToArrayBuffer(publicKey),\n }));\n\n const { p256dh, auth } = extractKeys(subscription);\n\n const res = await fetch(`${basePath}/subscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n provider: 'web-push',\n endpoint: subscription.endpoint,\n p256dh,\n auth,\n userId,\n visitorId,\n topics,\n }),\n });\n\n if (!res.ok) {\n throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);\n }\n\n const saved = (await res.json()) as ServerSubscription;\n if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {\n throw new Error('Subscribe response is missing `id`');\n }\n\n return { subscription, subscriptionId: saved.id };\n}\n\nexport async function unregisterPush(\n subscriptionId: string,\n opts: { basePath?: string } = {},\n): Promise<void> {\n if (!subscriptionId) {\n throw new Error('subscriptionId is required');\n }\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n\n const res = await fetch(`${basePath}/unsubscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ subscriptionId }),\n });\n\n if (!res.ok && res.status !== 404) {\n throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);\n }\n}\n"],"mappings":";AAYA,IAAM,oBAAoB;AAC1B,IAAM,kBAAkB;AACxB,IAAM,sBAAsB;AAiC5B,SAAS,sBAA4B;AACnC,MAAI,OAAO,cAAc,eAAe,EAAE,mBAAmB,YAAY;AACvE,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AACA,MAAI,OAAO,WAAW,eAAe,EAAE,iBAAiB,SAAS;AAC/D,UAAM,IAAI,MAAM,uDAAuD;AAAA,EACzE;AACF;AAEA,SAAS,uBAAuB,OAA4B;AAC1D,QAAM,UAAU,IAAI,QAAQ,IAAK,MAAM,SAAS,KAAM,CAAC;AACvD,QAAM,UAAU,QAAQ,SAAS,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AACrE,QAAM,MAAM,KAAK,MAAM;AACvB,QAAM,SAAS,IAAI,YAAY,IAAI,MAAM;AACzC,QAAM,OAAO,IAAI,WAAW,MAAM;AAClC,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK;AACnC,SAAK,CAAC,IAAI,IAAI,WAAW,CAAC;AAAA,EAC5B;AACA,SAAO;AACT;AAEA,SAAS,uBAAuB,QAAgD;AAC9E,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,QAAQ,IAAI,WAAW,MAAM;AACnC,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,cAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAAA,EACxC;AACA,SAAO,KAAK,MAAM,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AAC/E;AAEA,SAAS,aAAqB;AAC5B,QAAM,IAAI,OAAO,WAAW,cAAc,SAAS;AACnD,MAAI,KAAK,OAAO,EAAE,eAAe,YAAY;AAC3C,WAAO,EAAE,WAAW;AAAA,EACtB;AACA,QAAM,QAAQ,IAAI,WAAW,EAAE;AAC/B,MAAI,KAAK,OAAO,EAAE,oBAAoB,YAAY;AAChD,MAAE,gBAAgB,KAAK;AAAA,EACzB,OAAO;AACL,aAAS,IAAI,GAAG,IAAI,IAAI,IAAK,OAAM,CAAC,IAAI,KAAK,MAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EACxE;AACA,QAAM,CAAC,IAAK,MAAM,CAAC,IAAI,KAAQ;AAC/B,QAAM,CAAC,IAAK,MAAM,CAAC,IAAI,KAAQ;AAC/B,QAAM,MAAM,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AAC7E,SAAO,GAAG,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,IAAI,MAAM,GAAG,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,EAAE,CAAC;AAC1G;AAEA,SAAS,iBACP,QACA,WACe;AACf,MAAI,UAAW,QAAO;AACtB,MAAI,OAAQ,QAAO;AACnB,MAAI;AACF,UAAM,SAAS,OAAO,aAAa,QAAQ,mBAAmB;AAC9D,QAAI,OAAQ,QAAO;AACnB,UAAM,QAAQ,WAAW;AACzB,WAAO,aAAa,QAAQ,qBAAqB,KAAK;AACtD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO,WAAW;AAAA,EACpB;AACF;AAEA,eAAe,oBAAoB,UAAmC;AACpE,QAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,qBAAqB;AAAA,IACtD,QAAQ;AAAA,IACR,aAAa;AAAA,IACb,SAAS,EAAE,QAAQ,mBAAmB;AAAA,EACxC,CAAC;AACD,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,MAAM,qCAAqC,IAAI,MAAM,IAAI,IAAI,UAAU,EAAE;AAAA,EACrF;AACA,QAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,MAAI,CAAC,QAAQ,OAAO,KAAK,cAAc,YAAY,KAAK,UAAU,WAAW,GAAG;AAC9E,UAAM,IAAI,MAAM,kDAAkD;AAAA,EACpE;AACA,SAAO,KAAK;AACd;AAEA,SAAS,YAAY,KAA2D;AAC9E,SAAO;AAAA,IACL,QAAQ,uBAAuB,IAAI,OAAO,QAAQ,CAAC;AAAA,IACnD,MAAM,uBAAuB,IAAI,OAAO,MAAM,CAAC;AAAA,EACjD;AACF;AAEA,eAAsB,aACpB,OAA4B,CAAC,GACA;AAC7B,sBAAoB;AAEpB,QAAM,WAAW,KAAK,YAAY;AAClC,QAAM,SAAS,KAAK,UAAU;AAC9B,QAAM,SAAS,KAAK,UAAU,CAAC;AAC/B,QAAM,SAAS,KAAK,UAAU;AAC9B,QAAM,YAAY,iBAAiB,QAAQ,KAAK,SAAS;AAEzD,QAAM,YAAY,MAAM,oBAAoB,QAAQ;AACpD,QAAM,eAAe,MAAM,UAAU,cAAc,SAAS,MAAM;AAClE,QAAM,UAAU,cAAc;AAE9B,QAAM,WAAW,MAAM,aAAa,YAAY,gBAAgB;AAChE,QAAM,eACJ,YACC,MAAM,aAAa,YAAY,UAAU;AAAA,IACxC,iBAAiB;AAAA,IACjB,sBAAsB,uBAAuB,SAAS;AAAA,EACxD,CAAC;AAEH,QAAM,EAAE,QAAQ,KAAK,IAAI,YAAY,YAAY;AAEjD,QAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,cAAc;AAAA,IAC/C,QAAQ;AAAA,IACR,aAAa;AAAA,IACb,SAAS,EAAE,gBAAgB,oBAAoB,QAAQ,mBAAmB;AAAA,IAC1E,MAAM,KAAK,UAAU;AAAA,MACnB,UAAU;AAAA,MACV,UAAU,aAAa;AAAA,MACvB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,MAAM,qBAAqB,IAAI,MAAM,IAAI,IAAI,UAAU,EAAE;AAAA,EACrE;AAEA,QAAM,QAAS,MAAM,IAAI,KAAK;AAC9B,MAAI,CAAC,SAAS,OAAO,MAAM,OAAO,YAAY,MAAM,GAAG,WAAW,GAAG;AACnE,UAAM,IAAI,MAAM,oCAAoC;AAAA,EACtD;AAEA,SAAO,EAAE,cAAc,gBAAgB,MAAM,GAAG;AAClD;AAEA,eAAsB,eACpB,gBACA,OAA8B,CAAC,GAChB;AACf,MAAI,CAAC,gBAAgB;AACnB,UAAM,IAAI,MAAM,4BAA4B;AAAA,EAC9C;AACA,QAAM,WAAW,KAAK,YAAY;AAElC,QAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,gBAAgB;AAAA,IACjD,QAAQ;AAAA,IACR,aAAa;AAAA,IACb,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,IAC9C,MAAM,KAAK,UAAU,EAAE,eAAe,CAAC;AAAA,EACzC,CAAC;AAED,MAAI,CAAC,IAAI,MAAM,IAAI,WAAW,KAAK;AACjC,UAAM,IAAI,MAAM,uBAAuB,IAAI,MAAM,IAAI,IAAI,UAAU,EAAE;AAAA,EACvE;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/push.ts"],"sourcesContent":["/**\n * @fileoverview Web Push client SDK for Maravilla tenants.\n *\n * Talks to the tenant-origin `/_rt/push` endpoints served by delivery:\n * - GET /vapid-public-key — fetch the VAPID public key for subscribe\n * - POST /subscribe — create a subscription\n * - POST /unsubscribe — delete a subscription by id (or endpoint)\n *\n * The matching service worker is served from `/_rt/push/sw.js` on the\n * same tenant origin (browsers require same-origin SW registration).\n */\n\nconst DEFAULT_BASE_PATH = '/_rt/push';\nconst DEFAULT_SW_PATH = '/_rt/push/sw.js';\nconst VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';\nconst REGISTER_TIMEOUT_MS = 10_000;\n\nexport interface RegisterPushOptions {\n /** Free-form topic strings to tag this subscription with. */\n topics?: string[];\n /** Authenticated user id, if any. Takes precedence over visitorId. */\n userId?: string | null;\n /** Anonymous visitor id. If omitted and userId is also omitted, one\n * is generated and persisted to localStorage under `maravilla.push.visitorId`. */\n visitorId?: string | null;\n /** Override the sw.js path (defaults to `/_platform/push/sw.js`). */\n swPath?: string;\n /** Override the API base path (defaults to `/_platform/push`). */\n basePath?: string;\n}\n\nexport interface RegisterPushResult {\n /** The browser PushSubscription (see Web Push spec). */\n subscription: PushSubscription;\n /** Server-issued subscription id — pass this back to unregisterPush. */\n subscriptionId: string;\n}\n\ninterface VapidPublicKeyResponse {\n publicKey: string;\n contactEmail?: string;\n}\n\ninterface ServerSubscription {\n id: string;\n [key: string]: unknown;\n}\n\nfunction assertPushSupported(): void {\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n throw new Error('Web Push is not supported: serviceWorker is unavailable');\n }\n if (typeof window === 'undefined' || !('PushManager' in window)) {\n throw new Error('Web Push is not supported: PushManager is unavailable');\n }\n}\n\nfunction base64UrlToArrayBuffer(input: string): ArrayBuffer {\n const padding = '='.repeat((4 - (input.length % 4)) % 4);\n const base64 = (input + padding).replace(/-/g, '+').replace(/_/g, '/');\n const raw = atob(base64);\n const buffer = new ArrayBuffer(raw.length);\n const view = new Uint8Array(buffer);\n for (let i = 0; i < raw.length; i++) {\n view[i] = raw.charCodeAt(i);\n }\n return buffer;\n}\n\nfunction arrayBufferToBase64Url(buffer: ArrayBuffer | null): string | undefined {\n if (!buffer) return undefined;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\nfunction randomUuid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined;\n if (c && typeof c.randomUUID === 'function') {\n return c.randomUUID();\n }\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') {\n c.getRandomValues(bytes);\n } else {\n for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\nfunction resolveVisitorId(\n userId: string | null | undefined,\n visitorId: string | null | undefined,\n): string | null {\n if (visitorId) return visitorId;\n if (userId) return null;\n try {\n const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);\n if (stored) return stored;\n const fresh = randomUuid();\n window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);\n return fresh;\n } catch {\n return randomUuid();\n }\n}\n\nasync function fetchVapidPublicKey(basePath: string): Promise<string> {\n const res = await fetch(`${basePath}/vapid-public-key`, {\n method: 'GET',\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n if (!res.ok) {\n throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);\n }\n const body = (await res.json()) as VapidPublicKeyResponse;\n if (!body || typeof body.publicKey !== 'string' || body.publicKey.length === 0) {\n throw new Error('VAPID public key response is missing `publicKey`');\n }\n return body.publicKey;\n}\n\nfunction extractKeys(sub: PushSubscription): { p256dh?: string; auth?: string } {\n return {\n p256dh: arrayBufferToBase64Url(sub.getKey('p256dh')),\n auth: arrayBufferToBase64Url(sub.getKey('auth')),\n };\n}\n\nexport async function registerPush(\n opts: RegisterPushOptions = {},\n): Promise<RegisterPushResult> {\n assertPushSupported();\n\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n const swPath = opts.swPath ?? DEFAULT_SW_PATH;\n const topics = opts.topics ?? [];\n const userId = opts.userId ?? null;\n const visitorId = resolveVisitorId(userId, opts.visitorId);\n\n // Whole-flow timeout. Without this, a stuck pushManager.subscribe() or a\n // misbehaving push service can leave the UI hanging forever.\n const timeout = new Promise<never>((_, reject) =>\n setTimeout(\n () => reject(new Error(`registerPush timed out after ${REGISTER_TIMEOUT_MS}ms`)),\n REGISTER_TIMEOUT_MS,\n ),\n );\n\n const flow = (async (): Promise<RegisterPushResult> => {\n const publicKey = await fetchVapidPublicKey(basePath);\n // Register the SW. We deliberately do NOT `await navigator.serviceWorker.ready`\n // here — that promise only resolves when an active SW's scope covers the\n // current page, but our SW is scoped to `/_rt/push/` and the page is at\n // `/`. `pushManager.subscribe()` works against the registration returned\n // by `register()` without needing the SW to control the page.\n const registration = await navigator.serviceWorker.register(swPath);\n\n const existing = await registration.pushManager.getSubscription();\n const subscription =\n existing ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: base64UrlToArrayBuffer(publicKey),\n }));\n\n const { p256dh, auth } = extractKeys(subscription);\n\n const res = await fetch(`${basePath}/subscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n provider: 'web-push',\n endpoint: subscription.endpoint,\n p256dh,\n auth,\n userId,\n visitorId,\n topics,\n }),\n });\n\n if (!res.ok) {\n throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);\n }\n\n const saved = (await res.json()) as ServerSubscription;\n if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {\n throw new Error('Subscribe response is missing `id`');\n }\n\n return { subscription, subscriptionId: saved.id };\n })();\n\n return Promise.race([flow, timeout]);\n}\n\nexport async function unregisterPush(\n subscriptionId: string,\n opts: { basePath?: string } = {},\n): Promise<void> {\n if (!subscriptionId) {\n throw new Error('subscriptionId is required');\n }\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n\n const res = await fetch(`${basePath}/unsubscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ subscriptionId }),\n });\n\n if (!res.ok && res.status !== 404) {\n throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);\n }\n}\n\n/**\n * Compute a `Date` that is `offset` before `anchor` — handy for\n * \"X minutes/hours/days before the event\" scheduling.\n *\n * `offset` is a short duration string:\n * - `\"30s\"` — 30 seconds\n * - `\"15m\"` — 15 minutes\n * - `\"1h\"` — 1 hour\n * - `\"2d\"` — 2 days\n * - `\"1w\"` — 1 week\n *\n * Pure function — safe to call on the client. Works with `platform.push.schedule`:\n *\n * @example\n * ```typescript\n * import { offsetBefore } from '@maravilla-labs/platform';\n *\n * await platform.push.schedule(\n * { topic: `invite:${invite.id}` },\n * { title: invite.title, body: 'Your event is in one hour' },\n * {\n * at: offsetBefore(invite.event_date, '1h'),\n * key: `invite:${invite.id}:reminder-1h`,\n * }\n * );\n * ```\n *\n * @throws if `anchor` is an invalid date string or `offset` isn't a\n * recognised duration.\n */\nexport function offsetBefore(anchor: Date | string, offset: string): Date {\n const anchorDate = anchor instanceof Date ? anchor : new Date(anchor);\n if (Number.isNaN(anchorDate.getTime())) {\n throw new Error(`offsetBefore: invalid anchor \"${String(anchor)}\"`);\n }\n\n const match = /^(\\d+)\\s*(s|m|h|d|w)$/i.exec(offset.trim());\n if (!match) {\n throw new Error(\n `offsetBefore: invalid offset \"${offset}\" — expected something like \"30m\", \"1h\", \"2d\", \"1w\"`,\n );\n }\n\n const amount = Number(match[1]);\n const unit = match[2].toLowerCase();\n const UNIT_MS: Record<string, number> = {\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n };\n return new Date(anchorDate.getTime() - amount * UNIT_MS[unit]);\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maravilla-labs/platform",
3
- "version": "0.1.41",
3
+ "version": "0.1.44",
4
4
  "description": "Universal platform client for Maravilla runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/push.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * @fileoverview Web Push client SDK for Maravilla tenants.
3
3
  *
4
- * Talks to the tenant-origin `/_platform/push` endpoints served by delivery:
4
+ * Talks to the tenant-origin `/_rt/push` endpoints served by delivery:
5
5
  * - GET /vapid-public-key — fetch the VAPID public key for subscribe
6
6
  * - POST /subscribe — create a subscription
7
7
  * - POST /unsubscribe — delete a subscription by id (or endpoint)
8
8
  *
9
- * The matching service worker is served from `/_platform/push/sw.js` on the
9
+ * The matching service worker is served from `/_rt/push/sw.js` on the
10
10
  * same tenant origin (browsers require same-origin SW registration).
11
11
  */
12
12
 
13
- const DEFAULT_BASE_PATH = '/_platform/push';
14
- const DEFAULT_SW_PATH = '/_platform/push/sw.js';
13
+ const DEFAULT_BASE_PATH = '/_rt/push';
14
+ const DEFAULT_SW_PATH = '/_rt/push/sw.js';
15
15
  const VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';
16
+ const REGISTER_TIMEOUT_MS = 10_000;
16
17
 
17
18
  export interface RegisterPushOptions {
18
19
  /** Free-form topic strings to tag this subscription with. */
@@ -144,45 +145,62 @@ export async function registerPush(
144
145
  const userId = opts.userId ?? null;
145
146
  const visitorId = resolveVisitorId(userId, opts.visitorId);
146
147
 
147
- const publicKey = await fetchVapidPublicKey(basePath);
148
- const registration = await navigator.serviceWorker.register(swPath);
149
- await navigator.serviceWorker.ready;
148
+ // Whole-flow timeout. Without this, a stuck pushManager.subscribe() or a
149
+ // misbehaving push service can leave the UI hanging forever.
150
+ const timeout = new Promise<never>((_, reject) =>
151
+ setTimeout(
152
+ () => reject(new Error(`registerPush timed out after ${REGISTER_TIMEOUT_MS}ms`)),
153
+ REGISTER_TIMEOUT_MS,
154
+ ),
155
+ );
150
156
 
151
- const existing = await registration.pushManager.getSubscription();
152
- const subscription =
153
- existing ??
154
- (await registration.pushManager.subscribe({
155
- userVisibleOnly: true,
156
- applicationServerKey: base64UrlToArrayBuffer(publicKey),
157
- }));
157
+ const flow = (async (): Promise<RegisterPushResult> => {
158
+ const publicKey = await fetchVapidPublicKey(basePath);
159
+ // Register the SW. We deliberately do NOT `await navigator.serviceWorker.ready`
160
+ // here — that promise only resolves when an active SW's scope covers the
161
+ // current page, but our SW is scoped to `/_rt/push/` and the page is at
162
+ // `/`. `pushManager.subscribe()` works against the registration returned
163
+ // by `register()` without needing the SW to control the page.
164
+ const registration = await navigator.serviceWorker.register(swPath);
158
165
 
159
- const { p256dh, auth } = extractKeys(subscription);
166
+ const existing = await registration.pushManager.getSubscription();
167
+ const subscription =
168
+ existing ??
169
+ (await registration.pushManager.subscribe({
170
+ userVisibleOnly: true,
171
+ applicationServerKey: base64UrlToArrayBuffer(publicKey),
172
+ }));
160
173
 
161
- const res = await fetch(`${basePath}/subscribe`, {
162
- method: 'POST',
163
- credentials: 'same-origin',
164
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
165
- body: JSON.stringify({
166
- provider: 'web-push',
167
- endpoint: subscription.endpoint,
168
- p256dh,
169
- auth,
170
- userId,
171
- visitorId,
172
- topics,
173
- }),
174
- });
174
+ const { p256dh, auth } = extractKeys(subscription);
175
175
 
176
- if (!res.ok) {
177
- throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
178
- }
176
+ const res = await fetch(`${basePath}/subscribe`, {
177
+ method: 'POST',
178
+ credentials: 'same-origin',
179
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
180
+ body: JSON.stringify({
181
+ provider: 'web-push',
182
+ endpoint: subscription.endpoint,
183
+ p256dh,
184
+ auth,
185
+ userId,
186
+ visitorId,
187
+ topics,
188
+ }),
189
+ });
179
190
 
180
- const saved = (await res.json()) as ServerSubscription;
181
- if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {
182
- throw new Error('Subscribe response is missing `id`');
183
- }
191
+ if (!res.ok) {
192
+ throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
193
+ }
184
194
 
185
- return { subscription, subscriptionId: saved.id };
195
+ const saved = (await res.json()) as ServerSubscription;
196
+ if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {
197
+ throw new Error('Subscribe response is missing `id`');
198
+ }
199
+
200
+ return { subscription, subscriptionId: saved.id };
201
+ })();
202
+
203
+ return Promise.race([flow, timeout]);
186
204
  }
187
205
 
188
206
  export async function unregisterPush(
@@ -205,3 +223,58 @@ export async function unregisterPush(
205
223
  throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);
206
224
  }
207
225
  }
226
+
227
+ /**
228
+ * Compute a `Date` that is `offset` before `anchor` — handy for
229
+ * "X minutes/hours/days before the event" scheduling.
230
+ *
231
+ * `offset` is a short duration string:
232
+ * - `"30s"` — 30 seconds
233
+ * - `"15m"` — 15 minutes
234
+ * - `"1h"` — 1 hour
235
+ * - `"2d"` — 2 days
236
+ * - `"1w"` — 1 week
237
+ *
238
+ * Pure function — safe to call on the client. Works with `platform.push.schedule`:
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * import { offsetBefore } from '@maravilla-labs/platform';
243
+ *
244
+ * await platform.push.schedule(
245
+ * { topic: `invite:${invite.id}` },
246
+ * { title: invite.title, body: 'Your event is in one hour' },
247
+ * {
248
+ * at: offsetBefore(invite.event_date, '1h'),
249
+ * key: `invite:${invite.id}:reminder-1h`,
250
+ * }
251
+ * );
252
+ * ```
253
+ *
254
+ * @throws if `anchor` is an invalid date string or `offset` isn't a
255
+ * recognised duration.
256
+ */
257
+ export function offsetBefore(anchor: Date | string, offset: string): Date {
258
+ const anchorDate = anchor instanceof Date ? anchor : new Date(anchor);
259
+ if (Number.isNaN(anchorDate.getTime())) {
260
+ throw new Error(`offsetBefore: invalid anchor "${String(anchor)}"`);
261
+ }
262
+
263
+ const match = /^(\d+)\s*(s|m|h|d|w)$/i.exec(offset.trim());
264
+ if (!match) {
265
+ throw new Error(
266
+ `offsetBefore: invalid offset "${offset}" — expected something like "30m", "1h", "2d", "1w"`,
267
+ );
268
+ }
269
+
270
+ const amount = Number(match[1]);
271
+ const unit = match[2].toLowerCase();
272
+ const UNIT_MS: Record<string, number> = {
273
+ s: 1000,
274
+ m: 60_000,
275
+ h: 3_600_000,
276
+ d: 86_400_000,
277
+ w: 604_800_000,
278
+ };
279
+ return new Date(anchorDate.getTime() - amount * UNIT_MS[unit]);
280
+ }
package/src/types.ts CHANGED
@@ -931,6 +931,233 @@ export interface PolicyService {
931
931
  isEnabled(): boolean;
932
932
  }
933
933
 
934
+ /**
935
+ * Target selector for Web Push sends. Combine fields to narrow — all
936
+ * specified conditions must match for a subscription to receive the push.
937
+ *
938
+ * @example
939
+ * ```typescript
940
+ * // Every subscription tagged with "waitlist"
941
+ * await platform.push.send({ topic: 'waitlist' }, notification);
942
+ *
943
+ * // Every device belonging to one authenticated user
944
+ * await platform.push.send({ userId: 'u_42' }, notification);
945
+ *
946
+ * // "This specific user's subscription for this specific invite"
947
+ * await platform.push.send({ userId: 'u_42', topic: 'invite:abc:rsvp' }, notification);
948
+ * ```
949
+ */
950
+ export interface PushTarget {
951
+ userId?: string;
952
+ visitorId?: string;
953
+ topic?: string;
954
+ userIds?: string[];
955
+ topics?: string[];
956
+ onlyActive?: boolean;
957
+ }
958
+
959
+ /** Shape of a Web Push notification payload. */
960
+ export interface NotificationPayload {
961
+ /** Required — the notification headline shown on lock screens and the notification shade. */
962
+ title: string;
963
+ body?: string;
964
+ icon?: string;
965
+ badge?: string;
966
+ image?: string;
967
+ /** Browsers dedupe notifications sharing a tag. */
968
+ tag?: string;
969
+ /** Where to navigate when the notification is clicked. */
970
+ url?: string;
971
+ /** Arbitrary JSON delivered to the service worker alongside the notification. */
972
+ data?: Record<string, unknown>;
973
+ /** Seconds the push service holds the message if the device is offline. */
974
+ ttl?: number;
975
+ urgency?: 'very-low' | 'low' | 'normal' | 'high';
976
+ }
977
+
978
+ /**
979
+ * Options for `platform.push.schedule(...)`.
980
+ *
981
+ * @example
982
+ * ```typescript
983
+ * // Remind every RSVP'd guest one hour before the event.
984
+ * await platform.push.schedule(
985
+ * { topic: `invite:${invite.id}` },
986
+ * { title: invite.title, body: 'Your event is in one hour' },
987
+ * {
988
+ * at: offsetBefore(invite.event_date, '1h'),
989
+ * key: `invite:${invite.id}:reminder-1h`,
990
+ * }
991
+ * );
992
+ * ```
993
+ */
994
+ export interface ScheduleOptions {
995
+ /**
996
+ * When to send. Absolute `Date` or ISO-8601 string; the server treats
997
+ * bare (no-offset) strings as UTC.
998
+ */
999
+ at: Date | string;
1000
+ /**
1001
+ * Idempotency key scoped to your project. Re-calling `schedule` with the
1002
+ * same key atomically replaces the prior pending job — safe to call on
1003
+ * every save of an invite whose event date may change.
1004
+ */
1005
+ key: string;
1006
+ /** Maximum delivery attempts before the job is marked failed. Defaults to 3. */
1007
+ maxAttempts?: number;
1008
+ /**
1009
+ * If set, the job re-queues after every successful send and fires again
1010
+ * this many seconds later. Use for daily digests (`86400`), weekly
1011
+ * updates (`604800`), or any fixed-interval loop. `cancelScheduled(key)`
1012
+ * stops the loop.
1013
+ */
1014
+ everySeconds?: number;
1015
+ }
1016
+
1017
+ /** A single scheduled push job as returned by `listScheduled` / `getScheduled`. */
1018
+ export interface ScheduledJob {
1019
+ jobId: string;
1020
+ key?: string;
1021
+ /** Next fire time — unix seconds. Convert with `new Date(scheduledFor * 1000)`. */
1022
+ scheduledFor: number;
1023
+ status: 'pending' | 'running' | 'succeeded' | 'failed';
1024
+ attempts: number;
1025
+ maxAttempts: number;
1026
+ lastError?: string;
1027
+ createdAt: number;
1028
+ updatedAt: number;
1029
+ /** Populated once the job has fired at least once. */
1030
+ sentAt?: number;
1031
+ /** Set when the job recurs — `schedule()` was called with `everySeconds`. */
1032
+ recurringIntervalSecs?: number;
1033
+ }
1034
+
1035
+ /** Filter passed to `listScheduled`. */
1036
+ export interface ListScheduledFilter {
1037
+ status?: 'pending' | 'running' | 'succeeded' | 'failed';
1038
+ limit?: number;
1039
+ offset?: number;
1040
+ }
1041
+
1042
+ /** Counts by status, as returned by `queueStats`. */
1043
+ export interface QueueStats {
1044
+ pending: number;
1045
+ running: number;
1046
+ succeeded: number;
1047
+ failed: number;
1048
+ }
1049
+
1050
+ /** Outcome of a single `platform.push.send(...)` fan-out. */
1051
+ export interface SendReport {
1052
+ attempted: number;
1053
+ succeeded: number;
1054
+ gone: number;
1055
+ failed: number;
1056
+ errors?: Array<{ subscriptionId: string; message: string }>;
1057
+ }
1058
+
1059
+ /** Shape of a stored Web Push subscription. */
1060
+ export interface StoredPushSubscription {
1061
+ id: string;
1062
+ provider: 'web-push' | 'apns' | 'fcm';
1063
+ endpoint: string;
1064
+ p256dh?: string | null;
1065
+ auth?: string | null;
1066
+ userId?: string | null;
1067
+ visitorId?: string | null;
1068
+ userAgent?: string | null;
1069
+ topics: string[];
1070
+ createdAt: number;
1071
+ lastSeenAt?: number | null;
1072
+ expiresAt?: number | null;
1073
+ isActive: boolean;
1074
+ }
1075
+
1076
+ /** Aggregate counts across every subscription in the project. */
1077
+ export interface SubscriptionCounts {
1078
+ total: number;
1079
+ byTopic: Array<[string, number]>;
1080
+ byProvider: Array<[string, number]>;
1081
+ }
1082
+
1083
+ /** Public VAPID config for the current project. */
1084
+ export interface PublicPushConfig {
1085
+ vapidPublic: string;
1086
+ contactEmail: string;
1087
+ updatedAt: number;
1088
+ }
1089
+
1090
+ /**
1091
+ * Server-side Web Push service. Access via `platform.push` inside your
1092
+ * runtime code.
1093
+ */
1094
+ export interface PushService {
1095
+ /**
1096
+ * Fan out a notification to every active subscription matching `target`.
1097
+ * Blocks until every device has been tried.
1098
+ */
1099
+ send(target: PushTarget, notification: NotificationPayload): Promise<SendReport>;
1100
+
1101
+ /**
1102
+ * Fire-and-forget variant. The request handler can return immediately;
1103
+ * the dispatch continues in the background. Best-effort — a delivery
1104
+ * restart mid-dispatch loses in-flight sends.
1105
+ */
1106
+ sendBackground(target: PushTarget, notification: NotificationPayload): Promise<void>;
1107
+
1108
+ /**
1109
+ * Queue a notification for a future time. Idempotent by `key` — repeated
1110
+ * calls with the same key replace the prior pending job. Set `everySeconds`
1111
+ * for recurring digests.
1112
+ */
1113
+ schedule(
1114
+ target: PushTarget,
1115
+ notification: NotificationPayload,
1116
+ opts: ScheduleOptions,
1117
+ ): Promise<{ jobId: string }>;
1118
+
1119
+ /** Cancel the pending scheduled job with this idempotency key. */
1120
+ cancelScheduled(key: string): Promise<{ canceled: number }>;
1121
+
1122
+ /** List scheduled jobs for this project, optionally filtered by status. */
1123
+ listScheduled(filter?: ListScheduledFilter): Promise<ScheduledJob[]>;
1124
+
1125
+ /** Look up a single scheduled job by idempotency key. */
1126
+ getScheduled(key: string): Promise<ScheduledJob | null>;
1127
+
1128
+ /** Per-status counts for the scheduled queue. */
1129
+ queueStats(): Promise<QueueStats>;
1130
+
1131
+ /** List subscriptions — useful for admin UIs and debugging. */
1132
+ list(filter?: {
1133
+ topic?: string;
1134
+ userId?: string;
1135
+ visitorId?: string;
1136
+ onlyActive?: boolean;
1137
+ limit?: number;
1138
+ offset?: number;
1139
+ }): Promise<StoredPushSubscription[]>;
1140
+
1141
+ /** Aggregate counts grouped by topic and provider. */
1142
+ counts(): Promise<SubscriptionCounts>;
1143
+
1144
+ /** Remove a subscription by id. */
1145
+ unsubscribe(subscriptionId: string): Promise<void>;
1146
+
1147
+ /** Remove a subscription by its push service endpoint URL. */
1148
+ unsubscribeByEndpoint(endpoint: string): Promise<void>;
1149
+
1150
+ /** Fetch the project's current VAPID public key + contact email. */
1151
+ getVapidConfig(): Promise<PublicPushConfig>;
1152
+
1153
+ /**
1154
+ * Rotate the project's VAPID keypair. **Every existing subscription
1155
+ * silently stops working** — browsers bind subscriptions to the key they
1156
+ * saw at subscribe time. Confirm with your users before calling.
1157
+ */
1158
+ rotateVapidKeys(): Promise<PublicPushConfig>;
1159
+ }
1160
+
934
1161
  export interface Platform {
935
1162
  /** Environment containing all available platform services */
936
1163
  env: PlatformEnv;
@@ -942,4 +1169,11 @@ export interface Platform {
942
1169
  auth: AuthService;
943
1170
  /** Per-request Layer 2 policy toggle (Layer 1 isolation always applies) */
944
1171
  policy: PolicyService;
1172
+ /**
1173
+ * Web Push — send, schedule, or query browser push notifications for
1174
+ * logged-in users and anonymous visitors. Available when Push is enabled
1175
+ * in project settings; `undefined` when running against the dev-server
1176
+ * fallback that doesn't proxy to delivery.
1177
+ */
1178
+ push?: PushService;
945
1179
  }