Skip to content
Back to skills

Push Notifications

ASecurity

Builds remote push on expo-notifications: in-context permission, token sync, server contract, channels, tap routing. Use when adding push, push tokens, FCM/APNs setup or notification taps. Not for local reminders (local-notifications).

  • 3 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 18, 2026
developmentrustgoreactnodetestingapibackend

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 19 files and shows the line behind each finding

Scanned October 18, 2026

npx -y skills add zakariaf/ReactNative-Skills --skill push-notifications --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Push Notifications?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Push Notifications
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-push-notifications/badge)](https://www.skillsdirectory.com/skills/zakariaf-push-notifications)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: push-notifications
description: "Builds remote push on expo-notifications: in-context permission, token sync, server contract, channels, tap routing. Use when adding push, push tokens, FCM/APNs setup or notification taps. Not for local reminders (local-notifications)."
---

# Push notifications

Makes remote push correct on iOS and Android: the permission is asked only when the person sees why,
the server always holds this device's current token (and drops it at sign-out), Android channels and
data kinds match a written contract with the server, a tap opens exactly the screen it is about and
nothing a payload names, and one foreground policy decides what shows while the app is open. A
zero-dependency checker proves the structure; Jest tests over a fake port prove the behaviour.

## Rules that must hold

1. **Ask in context, never at launch; channels first.** The prompt comes from a person's action
   that the notification extends (`optInToPush` from an `onPress`, after the explanation), never from
   the root layout, the composition root or an effect. Android channels exist before the prompt;
   `blocked` offers the system settings. Why: iOS shows its prompt once in an install's life, Android
   issues no token before a channel exists (and an app targeting 12L or lower gets its prompt only
   after the first channel), and a launch prompt is refused more often than any other.
   (`permission-in-context`, `channel-setup`)
2. **One adapter owns expo-notifications' push APIs.** Token, permission, handler and response calls
   live only in `expo-notifications-push-adapter.ts` behind `PushPort`; everything else, tests
   included, uses the port or `createFakePush()`. Why: the SDK's quirks (deprecated `*Async`, the iOS
   `body` key, provisional status, error codes) stay in one tested file, and no test loads native code.
   (`push-adapter-only`)
3. **The token carries an explicit EAS project id and follows every change.** `getExpoPushTokenAsync`
   gets `{ projectId }` from `readEasProjectId()` and the app config sets `extra.eas.projectId`;
   `addPushTokenListener` re-registers each rotation with the token it replaces. Why: without the id a
   non-EAS build gets no token, and a rotated token the server never learned is a silent dead end.
   (`expo-project-id`, `token-rotation`)
4. **One registration, serialized, idempotent, triggered, remembered.** `createPushRegistration`
   syncs at launch, on every return to the foreground (after the channel sync), on rotation and on
   account change, one sync at a time, through a `PushRegistrar` over the signed-in ApiClient, never
   the plain `services.api` while registrations need an account (`PUT /push/registrations/{token}`;
   the server takes the account from the credentials only). The last
   accepted registration is saved (`PushRecordPort`, kv-store), so the first sync after notifications
   were switched off in Settings unregisters it, in the same process or a new one; a failure waits for
   the next trigger (no retry timer); the server drops what is never seen again (`not-seen-270-days`).
   Why: two concurrent registrations arrive in any order; an unsigned call is answered 401 and the
   device never registers; Android ends the process when the permission is revoked; and iOS silently
   drops pushes to a device that turned them off while the server counts them as sent.
   (`push-wiring`, registration tests)
5. **`contracts/push.json` is the agreement with the server.** It names the provider, whether
   registrations need an account, every data kind and every channel; `PUSH_KINDS` and `PUSH_CHANNELS`
   equal it. Why: the server and the app ship separately; a kind or channel only one side knows is a
   notification that never shows or never opens. (`push-contract`, `push-token`, `kinds-contract`,
   `channel-contract`)
6. **The payload is untrusted input.** `parsePushData` (valibot) reads `{ kind, <id> }` with opaque
   ids; every value a string, the whole message at most 4096 bytes, no secret or personal content.
   Why: anyone holding a token can send any JSON, a direct FCM message turns values into strings, and
   the text passes through Apple, Google and Expo onto a locked screen. (`payload-parse`, `kinds-contract`)
7. **A tap opens a destination from the table, never a route or URL from the payload.**
   `destinationFor` maps a parsed kind to a `PushDestination`; the app maps it to a typed href. Why: a
   forged or stale push could otherwise open debug routes, guarded screens or any website.
   (`payload-navigation`)
8. **Taps are handled at cold start and while running, once each, below the navigator.** The launch
   tap is read once and cleared, the listener handles the rest, each notification id once; only
   taps whose data parses as a push message are handled or cleared (a reminder's launch tap stays for
   local-notifications); `usePushTaps` runs in the push root kit's effects, beside the root Stack. Why: at a cold start the same tap arrives twice,
   and Expo Router refuses to navigate before the root layout mounts. (`tap-handling`, `push-wiring`)
9. **Exactly one foreground handler decides for remote and local notifications.** The adapter installs
   it from `foregroundPresentation`, answering at once with `shouldShowBanner`/`shouldShowList` (never
   the deprecated `shouldShowAlert`). Why: with no handler (or one slower than 3 s) a foreground
   notification is not shown, and a second `setNotificationHandler` silently replaces the first.
   (`foreground-handler`, `deprecated-api`)
10. **Android channels are stable, translated and contracted.** Each id means one behaviour forever
    (importance is fixed once created), names come from the catalogs, the plugin's `defaultChannel`
    equals the contract's. Why: a push naming a channel the device lacks is dropped, and a reused id
    keeps its old importance on every installed phone. (`channel-contract`, `plugin-config`)
11. **Native config per variant; credentials by a person, never in the repo.** The plugin sets `mode`
    (the APNs environment each variant states: `development` for the dev client, `production` for
    builds that ship), `icon` and `defaultChannel`; `android.googleServicesFile` lists every variant's
    package; the APNs key and the FCM V1 service account key go to EAS through `eas credentials`.
    Why: distribution export signs with the profile's production entitlement (Expo's docs), so a
    store build gets pushes only when its bundle id has the Push capability and an APNs key in EAS;
    Android issues no token without FCM; a committed key lets anyone push as the app.
    (`plugin-config`, `fcm-config`, `push-credentials`)
12. **Sign-out removes the registration while the session is still valid.** `pushRegistrationStep`
    is a server sign-out step named `push-registration` in `contracts/auth.json`; its `DELETE` carries
    the step's own access token and removes the saved token first (the one the server holds), then a
    newer one; the server also drops registrations of revoked or expired sessions and on
    `DeviceNotRegistered`, and deletes only the caller's own registrations. Why: otherwise the next
    person on the phone receives this person's notifications. (`sign-out-unregister`)
13. **The app never sends pushes.** No Expo, FCM or APNs send endpoint, no `expo-server-sdk`, no push
    access token in `EXPO_PUBLIC_*`. Why: sending needs a credential, and anything in the app is
    public. (`client-send`, `push-credentials`)

## Workflow

1. Read `references/architecture-and-contract.md` (the pieces, the registration, the contract, the
   payload, taps, the foreground policy). Stop and ask the developer: the provider (`expo` unless the
   server already talks to APNs and FCM), whether registrations need an account, the data kinds and
   what each opens, and the channels a person may want to mute separately.
2. Install and configure (`references/setup-and-credentials.md` sections 1-3):
   `npx expo install expo-notifications expo-constants`, then the plugin, `googleServicesFile` and
   `extra.eas.projectId` in `app.config.ts` (`examples/app.config.ts`). The credential steps
   (section 4) create keys in outside accounts: hand them to the developer; never create, read or
   commit a key.
3. Copy every file under `templates/` to the path its first line names (JSON files land at their path
   under `templates/`), tests included; the catalog keys merge into the app's catalogs, keys kept
   in sorted order (i18n-strings-and-catalogs' linter fails on unsorted keys).
4. Make the templates the app's: kinds in `push-message.ts`, destinations in `push-destination.ts`,
   the policy in `foreground-presentation.ts`, channels in `push-channels.ts` plus their catalog keys
   in every language, and the same facts in `contracts/push.json`; update each test with them.
5. Wire it as a root kit (`references/architecture-and-contract.md` section 10): the adapter, the
   saved-record adapter and the registration (`examples/create-app-push.ts`; with accounts they exist
   before the session, which takes `pushRegistrationStep` and attaches itself to the signed-in client,
   `"push-registration"` in `contracts/auth.json`, `examples/create-app-session.ts`), the registrar over
   the signed-in client, never the plain `services.api` (`examples/push-registrar.ts`,
   `examples/session-api-client.ts`), and `createPushKit({ push, registration, errorLog, account,
   openSettings, hrefForDestination })` registered in app-startup-and-bootstrap's
   `src/services/create-root-kits.ts` (`examples/create-root-kits.ts`, with the route table
   `examples/push-destination-href.ts`). The kit provides the in-context ask's dependencies
   (`usePushServices()`) and mounts `usePushSync`, the rotation watch (`registration.start()`) and
   `usePushTaps` beside the root Stack; the root files stay unchanged. Add `expo-notifications` to the architecture contract's
   `vendorSdks` (`"LocalNotificationsPort/PushPort"` when local-notifications is adopted too). With
   local-notifications adopted, create its adapter with `installForegroundHandler: false` (this
   adapter's handler decides for both; never edit the copied local adapter).
6. Build the in-context ask where the value is visible (`examples/use-share-list-model.ts`). The
   notification permission is the `notifications` kind of permissions-and-system-prompts'
   `PermissionsPort`: bind `access.notifications` to this port's `permission`/`requestPermission`
   (architecture-and-contract.md section 2), ask through its `usePermissionGate` from the press, and
   call `optInToPush` from `onAllowed` (it then only syncs channels and registers). Add a
   `notifications` entry to `contracts/permissions.json` (no `purposeKey`; explain and settings copy
   keys in every catalog; `askedFrom` = that model, with a sibling test naming blocked and declined),
   or check-permissions fails. Without that skill, `optInToPush` asks itself and `blocked` offers
   `Linking.openSettings()`.
7. Hand the store facts to privacy-and-store-declarations (`references/setup-and-credentials.md`
   section 6) and the server facts (`contracts/push.json`, `examples/server/send-task-assigned.ts`) to
   the backend developer.
8. Test the copied registrar and session client too (testing-push.md section 1, "The registrar":
   401 → rejected, 404 on DELETE → done, retryable → unavailable, the sign-out DELETE's bearer, the
   unattached client → unauthorized). Run `npx jest --ci src/core/push src/services/push src/hooks`, then
   `node ${CLAUDE_SKILL_DIR}/scripts/check-push.mjs .`; fix and rerun until `RESULT: PASS`. Look up a
   rule in `references/checker-rules.md`, a symptom in `references/platform-behaviour.md` section 8.
9. Prove the native path on a named simulator (`xcrun simctl push <udid> ...` with
   `examples/task-assigned.apns`) and an emulator, then hand the device checklist
   (`references/testing-push.md` section 5) to a person for one iPhone and one Android phone.

## Definition of done

- [ ] No prompt at launch; the ask comes from an action, channels first, `blocked` offers Settings.
- [ ] Only the push adapter calls expo-notifications' push APIs; tests use `createFakePush()`.
- [ ] Explicit `projectId`; `extra.eas.projectId` set; rotations re-register with `previousToken`.
- [ ] One registration service, synced at launch, foreground, rotation and account change; its record saved.
- [ ] `contracts/push.json` filled in and handed to the server; kinds and channels equal the code.
- [ ] Payloads parsed with valibot; taps open only `destinationFor` destinations via typed hrefs.
- [ ] Launch tap read once and cleared (push taps only); listener deduped; `usePushTaps` below the navigator.
- [ ] One `setNotificationHandler`, from `foregroundPresentation`; no deprecated API.
- [ ] Plugin with `mode` per variant, `icon`, `defaultChannel`; `googleServicesFile` with every variant.
- [ ] Credentials uploaded to EAS by a person; no key or access token anywhere in the repo.
- [ ] `push-registration` in `serverSignOutSteps` when registrations need an account.
- [ ] The push root kit is registered in `src/services/create-root-kits.ts`; no root file imports push code.
- [ ] The registrar signs every call: `signedInApi` (attached by the session), never `services.api`
  when registrations need an account; the sign-out `DELETE` carries the step's access token.
- [ ] `npx jest --ci` passes, including every template test.
- [ ] Device checklist done on one iPhone and one Android phone before release.
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-push.mjs .` prints `RESULT: PASS` (the only allowed
  SKIP is `SKIP contracts/auth.json [sign-out-unregister] not yet created (auth-and-secure-storage
  creates it)`; `NOT APPLICABLE` only while expo-notifications is absent or nothing requests a token).

## Anti-patterns

- **`requestPermissionsAsync()` in the root layout "so it is done".** Asked before any value is
  shown, refused, and on iOS never asked again. Ask from the action the notification extends.
- **`router.push(response.notification.request.content.data.url)`** (Expo's own sample shape). Any
  sender can open any route or site. Parse, map to a destination, map that to a typed href.
- **Registering only after the prompt.** Tokens rotate and people flip Settings; sync on launch,
  foreground, rotation and account change.
- **A `setNotificationHandler` per feature.** The last one wins for every notification; one policy.
- **`useLastNotificationResponse()` in a screen.** It is `undefined`, then `null`, then the response:
  late, twice, and only on that screen. One router hook below the navigator.
- **Custom keys at the top of a hand-made APNs payload.** iOS hands `content.data` from `"body"`
  only; the tap finds no data.
- **Reusing a channel id with new importance.** Installed phones keep the old one; new id instead.
- **Unregistering after the token wipe at sign-out.** It goes out unauthenticated and fails
  silently; make it a server sign-out step.
- **Data-only pushes as a sync mechanism.** iOS throttles and drops them; refresh on foreground.
- **Sending a push from the app or putting the Expo access token in `EXPO_PUBLIC_*`.** Public the
  moment the app ships.
- **Testing push in Expo Go on Android.** Removed in SDK 53; use a development build.

## Files in this skill

| File | What it is | Read/run when |
|---|---|---|
| `references/architecture-and-contract.md` | The pieces, port, provider choice, registration, contract keys and server duties, payload layout per platform, taps, foreground policy, sign-out, tests, multi-package | Steps 1, 4, 5 |
| `references/platform-behaviour.md` | Permission, tokens, channels, delivery by app state, taps, badges and icons, Expo Go and simulators; pitfalls with fixes | Building; a push misbehaves |
| `references/setup-and-credentials.md` | Packages, plugin options, variants, credential steps a person does, contracts, store facts | Steps 2, 7 |
| `references/testing-push.md` | Jest recipes, simulator and emulator pushes, a real push in development, the device checklist, Maestro | Steps 8, 9 |
| `references/checker-rules.md` | Each check-push rule, when it applies, contract and config keys | Reading a check-push result |
| `templates/src/core/push/` | `push-message.ts` (kinds, schema, `parsePushData`), `push-destination.ts`, `foreground-presentation.ts`, `registration-plan.ts`, `push-registration.ts` (registration and registrar types), tests | Steps 3, 4 |
| `templates/src/services/push/` | `PushPort`, the expo-notifications adapter (+ iOS and Android tests), the fake, channels, registration service and sign-out step, the saved record (`PushRecordPort`, kv-store adapter, fake), opt-in, tap router, project id reader, the root kit `push-root-kit.tsx` and `push-context.tsx` (`usePushServices`), tests | Steps 3-5 |
| `templates/src/hooks/` | `use-push-sync.ts`, `use-push-taps.ts` (+ tests); `use-is-app-active.ts` (+ test, synced, react-components-and-hooks owns it) | Steps 3, 5 |
| `templates/src/i18n/catalogs/` | Channel names and descriptions in `en` and `ar` (merged into the app's catalogs) | Step 4 |
| `templates/contracts/` | `push.json` (the app's push facts, shared with the server) and its schema | Steps 3, 4 |
| `templates/src/core/result.ts` | Canonical Result type the templates import (synced; never edit here) | Only if the app lacks it |
| `templates/src/core/result.test.ts` | Its test (synced) | Only if the app lacks it |
| `templates/src/services/clock/clock-port.ts` | Canonical clock port (synced) | Only if the app lacks it |
| `templates/src/services/error-log/error-log-port.ts` | Canonical error log port (synced) | Only if the app lacks it |
| `templates/src/services/error-log/fake-error-log.ts` | Canonical fake error log (synced) | Only if the app lacks it |
| `templates/src/services/error-log/fake-error-log.test.ts` | Its test (synced) | Only if the app lacks it |
| `templates/test/flush-microtasks.ts` | Canonical test helper (synced) | Only if the app lacks it |
| `examples/create-app-push.ts` | The push parts of an app with accounts: the signed-in client, the adapter, the saved record and the registration, created before the session | Step 5 |
| `examples/create-root-kits.ts` | The root-kit registry with the push kit: the session as the account source, `openSettings`, the route table | Step 5 |
| `examples/push-registrar.ts` | The registrar over the signed-in ApiClient; the sign-out DELETE carries the step's access token (404 as done) | Step 5 |
| `examples/session-api-client.ts` | The signed-in client created before the session and attached by it (breaks the session/registration cycle) | Step 5 |
| `examples/push-destination-href.ts` | Destinations to typed routes | Step 5 |
| `examples/create-app-session.ts` | `pushRegistrationStep` in `serverSignOutSteps`; the session attached to the push parts' `signedInApi` | Step 5 |
| `examples/use-share-list-model.ts` | The in-context ask from a feature model through permissions-and-system-prompts' gate, then `optInToPush` | Step 6 |
| `examples/app.config.ts` | Plugin options, `googleServicesFile`, `extra.eas.projectId` | Step 2 |
| `examples/server/send-task-assigned.ts` | The server half: language, string data, channel, tickets and receipts | Step 7 |
| `examples/task-assigned.apns` | A simulator payload (`xcrun simctl push`), data under `body` | Step 9 |
| `scripts/check-push.mjs` | The checker (20 rules) | Step 8 and the definition of done |
| `scripts/lib/push-rules.mjs` | The checker's rules | Changing the checker |
| `scripts/lib/push-source.mjs` | Source loading, import and call helpers for the rules | Changing the checker |
| `scripts/check-lib.mjs` | Shared checker library (synced; never edit here) | Never by hand |
| `scripts/lib/source-scan.mjs` | Shared source scanner (synced; never edit here) | Never by hand |
| `scripts/selftest.mjs` | Proves the checker on planted bugs | After changing the checker |
| `tests/bases/app/` | The fixture base app, with planted findings in installed skills that must stay silent | The self-test reads it |
| `tests/fixtures/check-push/` | One overlay case per rule (good, bad-*, pass-*, error-*) on the base, templates and examples | The self-test reads them |
| `assets/shared.json` | Which shared files are synced into this skill | Maintenance |

## Related skills

- `local-notifications` - reminders scheduled on the device; they share the one foreground handler.
- `permissions-and-system-prompts` - the explanation before the prompt, denied versus blocked.
- `deep-links-and-universal-links` - links from outside; push taps reuse destinations, not URLs.
- `auth-and-secure-storage` - the server sign-out step that removes the registration.
- `networking-and-server-state` - the ApiClient the registrar uses.
- `architecture-and-boundaries` - ports, adapters, the composition root, `vendorSdks`.
- `app-variants-and-environment` - per-variant ids, `mode` and Firebase apps.
- `expo-prebuild-and-config-plugins` - the plugin and the native snapshot.
- `privacy-and-store-declarations` - the push token in App Privacy and Data safety.
- `i18n-strings-and-catalogs` - channel names in every language.
- `background-tasks` - data-only pushes and headless tasks.
- `unit-and-component-tests` - fakes, render helpers and mocks.
- `e2e-maestro` - flows for the opt-in and the screens a tap opens.
- `eas-build-and-submit` - the builds and credentials push needs.

Files in this skill

  • SKILL.md21 KB
  • assets/shared.json1.2 KB
  • examples/app.config.ts2.5 KB
  • examples/create-app-push.ts3.8 KB
  • examples/create-app-session.ts2 KB
  • examples/create-root-kits.ts2.7 KB
  • examples/push-destination-href.ts774 B
  • examples/push-registrar.ts2.8 KB
  • examples/server/send-task-assigned.ts2 KB
  • examples/session-api-client.ts1.6 KB
  • examples/task-assigned.apns233 B
  • examples/use-share-list-model.ts3.3 KB
  • references/architecture-and-contract.md24.4 KB
  • references/checker-rules.md8.7 KB
  • references/platform-behaviour.md11.7 KB
  • references/setup-and-credentials.md6.7 KB
  • references/testing-push.md9.4 KB
  • scripts/check-push.mjs8.1 KB
  • scripts/lib/push-rules.mjs27.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.