
Research
/Security News
OpenAPI React Query Codegen Compromised in Mini Shai-Hulud npm Supply Chain Attack
Ten malicious OpenAPI React Query Codegen versions were published to npm in the Mini Shai-Hulud attack, all with valid provenance.
@notiflyio/web-push
Advanced tools
App-side Notifly wiring for PWAs and their native shells: shell push token lifecycle, session-authed relay/inbox-session route cores, device-token validators, and the useNotiflySession React hook
App-side Notifly wiring for PWAs and their native shells — the ~350 lines every fleet app used to hand-copy, as a zero-dependency package with three entries:
@notiflyio/web-push (browser-safe): createShellPushClient — shell
detection, push token lifecycle (rotation-aware register, provider-tagged
sign-out drop, preference-gated permission ask, iOS permission-state
round-trip), configurable foreground-push handling, deep links, the
device-token validators, pushPayloadLink.@notiflyio/web-push/client: the same browser surface WITHOUT the
device-token format validators — so the APNs regex literal that client-bundle
ban gates grep for cannot reach a browser chunk through this subpath.@notiflyio/web-push/server (Node): createNotiflyServer (ensure
subscriber / per-device token endpoints / workflow trigger with the FCM data
override), buildInboxSession + computeSubscriberHash (HMAC), and
handleTokenRelay — the framework-agnostic core of the token relay route
with the fleet's incident rules baked in (token-less DELETE = logged no-op,
loud 502s, ensure-subscriber retry-once).@notiflyio/web-push/react: useNotiflySession (fetches your
inbox-session route, returns <NovuProvider> props incl. self-hosted
socketUrl) and <NotiflyShellPushMount> (arms the shell push plumbing).Works with @notiflyio/react ≥ 3.17.1 (the first version that honors a
self-hosted socketUrl verbatim and maps ws payload→data). The native
shells speak the matching contract via @notiflyio/capacitor-push (Android)
and the NotiflyShellPush Swift package (iOS/macOS).
npm install @notiflyio/web-push @notiflyio/react
Env (server-only, never NEXT_PUBLIC): NOTIFLY_SECRET_KEY, NOTIFLY_APP_ID,
optional NOTIFLY_API_URL / NOTIFLY_WS_URL (default https://api.notifly.io
/ https://ws.notifly.io — keep the https:// scheme on the ws URL; socket.io
does its own wss upgrade and a literal wss:// breaks the polling handshake).
1. Inbox-session route — app/api/user/inbox-session/route.ts:
import { buildInboxSession } from "@notiflyio/web-push/server";
export async function POST() {
const session = await getSession(); // your auth
if (!session?.user) return Response.json({ error: "unauthorized" }, { status: 401 });
const inbox = buildInboxSession({
secretKey: process.env.NOTIFLY_SECRET_KEY,
applicationIdentifier: process.env.NOTIFLY_APP_ID,
apiUrl: process.env.NOTIFLY_API_URL ?? "https://api.notifly.io",
wsUrl: process.env.NOTIFLY_WS_URL ?? "https://ws.notifly.io",
subscriberId: session.user.id, // from the session, NEVER the body
});
if (!inbox) return Response.json({ error: "not configured" }, { status: 503 });
return Response.json(inbox);
}
2. Token relay route — app/api/user/notifly-push-token/route.ts:
import { createNotiflyServer, handleTokenRelay } from "@notiflyio/web-push/server";
const notifly = process.env.NOTIFLY_SECRET_KEY
? createNotiflyServer({ secretKey: process.env.NOTIFLY_SECRET_KEY, apiUrl: process.env.NOTIFLY_API_URL })
: null;
async function relay(method: "POST" | "DELETE", request: Request) {
const session = await getSession();
if (!session?.user) return Response.json({ error: "unauthorized" }, { status: 401 });
const result = await handleTokenRelay(notifly, {
method,
body: await request.json().catch(() => null),
subscriberId: session.user.id,
profile: { email: session.user.email, name: session.user.name },
});
if (result.error) reportError(result.error); // your reporter
// `result.code` is the contract ('registered' | 'removed' | 'removed_noop' |
// 'invalid_body' | 'invalid_token' | 'not_configured' | 'register_failed' |
// 'remove_failed'). Map it into YOUR envelope; status/body are only defaults.
return Response.json(result.body, { status: result.status });
}
export const POST = (request: Request) => relay("POST", request);
export const DELETE = (request: Request) => relay("DELETE", request);
3. Signed-in layout — arm the shells + boot the bell:
import { NovuProvider } from "@notiflyio/react";
import { useNotiflySession, NotiflyShellPushMount } from "@notiflyio/web-push/react";
function NotificationRoot({ children }) {
const { status, providerProps } = useNotiflySession();
return (
<>
<NotiflyShellPushMount appPrefix="myapp" jsGlobal="MyappPush" syncUrl="/api/user/notifly-sync" />
{status === "ready" ? <NovuProvider {...providerProps}>{children}</NovuProvider> : children}
</>
);
}
4. Sign-out — while the session cookie is still alive:
import { createShellPushClient } from "@notiflyio/web-push";
await createShellPushClient({ appPrefix: "myapp" }).dropStoredPushToken();
That's the whole app-side integration. The bell UI itself stays yours (headless
@notiflyio/react hooks); triggers go through createNotiflyServer(...) .triggerWorkflow(...) or @notiflyio/api.
Event names, endpoint shapes, and validator vocabulary are the fleet-locked
pwa-notifications contracts (proven live 2026-07-23, SDK-ported 2026-07-31):
push-apns-token/push-apns-error/push-notification window CustomEvents from
the shells; per-device token endpoints
POST|DELETE /v1/subscribers/:id/credentials/:provider/token (token in BODY,
both 200); subscriberHash = HMAC-SHA256(secretKey, subscriberId) hex.
push-apns-token is the ONE token event on every platform (contracts §6a) and
carries { token, provider } — an Android token arrives on that apns-named
event with provider: 'fcm'. Since 0.1.1 arm() reads that object shape as
well as the bare token string older shells send (where the event name is the
only provider signal), and relays a given provider+token once even when a shell
emits both the primary event and its deprecated push-fcm-token alias. A detail
it cannot read — no token, or a provider outside apns/fcm — goes to
onError rather than being relayed under a guessed provider. readShellTokenDetail
is exported for apps that write their own listener.
0.1.x was greenfield-only by construction: four wired fleet apps — including uNotes, the implementation this package was mirrored from — independently graded it non-adoptable, because adopting it meant changing a live wire shape, a live storage layout, or a proven client behavior. 0.2 is the design round that closes that. Breaking changes are listed below; each one has a bridge.
1. Storage layout — the one that corrupts data silently. If your app already
stores a device token, look at its VALUE, not just its key name. 0.1's key
<appPrefix>_notifly_device_token NAME-matched uNotes' live key while storing a
bare token where the app stored JSON.stringify({token, provider}). Adopting
stranded every installed device's rotation anchor (the read guarded on a
separate _device_provider key that live devices did not have, so previousToken
was omitted and the old token never deregistered) and then broke the app's own
reader in the other direction. Both silent. Name your layout:
import { createShellPushClient, jsonBlobDeviceCodec } from "@notiflyio/web-push";
createShellPushClient({
appPrefix: "unotes",
storage: {
codec: jsonBlobDeviceCodec, // one key holding {token, provider}
tokenKey: "unotes_notifly_device_token", // your live key name
prefKey: "notifly_push_enabled", // unprefixed legacy keys stay unprefixed
promptedKey: "notifly_push_prompted_at",
legacyKeys: ["notifly_device_token"], // read once, adopt, remove
},
});
The default is bareTokenDeviceCodec — 0.1's exact two-key layout, unchanged,
including its "no provider tag means read null" guard. A greenfield app changes
nothing.
2. Server posture. handleTokenRelay takes any NotiflyServerAdapter, not
only createNotiflyServer(...). An app standardized on @notiflyio/api writes a
thin adapter and keeps its own client, error type and lazy per-request env reads:
import type { NotiflyServerAdapter } from "@notiflyio/web-push/server";
const adapter: NotiflyServerAdapter = {
ensureSubscriber: (subscriberId, profile) => api().subscribers.create({ subscriberId, ...profile }),
registerDeviceToken: ({ subscriberId, provider, ...rest }) =>
api().subscribers.credentials.registerToken({
subscriberId, providerId: provider, registerSubscriberDeviceTokenRequestDto: rest,
}),
removeDeviceToken: ({ subscriberId, provider, token, integrationIdentifier }) =>
api().subscribers.credentials.removeToken({
subscriberId, providerId: provider,
removeSubscriberDeviceTokenRequestDto: { token, integrationIdentifier },
}),
triggerWorkflow: (name, subscriberId, payload) =>
api().trigger({ name, to: { subscriberId }, payload }),
};
3. Your error envelope. TokenRelayResult and handleInboxSession carry a
machine-readable code. Map it into your own vocabulary and ignore status/body
entirely — 0.1 forced apps with a locked envelope to either change their public
wire shape or string-match English prose.
| Change | Bridge |
|---|---|
InboxSession fields are applicationIdentifier / backendUrl? / socketUrl (were appId / apiUrl / wsUrl) — the locked fleet contract every wired app already returned | useNotiflySession is a TOLERANT READER accepting both generations, so route and client deploy in either order. readInboxSession(raw) is exported for hand-written clients. |
buildInboxSession config takes applicationIdentifier | rename one key |
NotiflyProviderProps.apiUrl is optional, absent when the session has no backendUrl | spread as before |
onError(provider, failure) receives { reason, detail } | read failure.detail for the old value |
registerShellPushToken rejects on a transport failure | it still returns false for a non-2xx; arm() routes both to onError |
ensureSubscriber PATCHes on 409 only, no longer on 400 | a 400 now throws — it was always a genuine create failure being converted into silent success |
withNotiflyRetry does NOT retry errors it cannot classify | pass statusOf to teach it your adapter's error type |
handleTokenRelay's third argument is an options object | a bare log function is still accepted |
useNotiflySession's argument may be an options object | a bare route-url string is still accepted |
withNotiflyRetry's third argument may be an options object | a bare maxAttempts number is still accepted |
storage (key names, value codec, legacyKeys migration) with the
bareTokenDeviceCodec / jsonBlobDeviceCodec built-ins.readPushPref() / writePushPref(). arm()'s
prompt gates on pref === null, so a user who explicitly turned push OFF is
never re-prompted — in 0.1, "off" and "never asked" were the same state.push-permission-state round-trip (stale-token recovery) and
push-permission-request result recording.onForegroundPush: 'navigate' (default, 0.1 behavior), 'notify', or your
own presenter receiving {title, body, link}.dropStoredPushToken(providerOverride?).integrationIdentifier + maxDeviceTokens on register, integrationIdentifier
on remove — settable per-route (which overrides the client) or passed through
from the relay body, where they are bounds-checked.triggerWorkflow's notificationId is OPTIONAL, and it takes an
idempotencyKey sent as the platform's Idempotency-Key header.handleInboxSession(config) — the code-first result, distinguishing
unauthenticated (401) from not_configured (503).@notiflyio/web-push/client subpath and "sideEffects": false.computeSubscriberHash here is SYNCHRONOUS (node:crypto); @notiflyio/api
exports a function of the same name that is ASYNC (Web Crypto, so it also runs
on edge runtimes). Output is byte-identical; they are not drop-in substitutes.
Nothing is re-exported from @notiflyio/api here, because importing both under
one name is how a sync/async mix-up becomes a hash of [object Promise].
FAQs
App-side Notifly wiring for PWAs and their native shells: shell push token lifecycle, session-authed relay/inbox-session route cores, device-token validators, and the useNotiflySession React hook
The npm package @notiflyio/web-push receives a total of 97 weekly downloads. As such, @notiflyio/web-push popularity was classified as not popular.
We found that @notiflyio/web-push demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Research
/Security News
Ten malicious OpenAPI React Query Codegen versions were published to npm in the Mini Shai-Hulud attack, all with valid provenance.

Security News
Socket joins more than 100 technology, cybersecurity, and financial organizations calling for a global surge in cyber defense.

Product
Enterprise security teams can now detect malware, credential theft, suspicious network activity, and risky updates across Microsoft Edge extensions.