Skip to content

Add direct-APNs sender for WidgetKit push subscriptions#337

Open
hariharanjagan wants to merge 7 commits into
home-assistant:mainfrom
hariharanjagan:main
Open

Add direct-APNs sender for WidgetKit push subscriptions#337
hariharanjagan wants to merge 7 commits into
home-assistant:mainfrom
hariharanjagan:main

Conversation

@hariharanjagan

@hariharanjagan hariharanjagan commented Jul 5, 2026

Copy link
Copy Markdown

When Core reports a tracked-entity change for a widget subscription, it POSTs a
{ push_subscription, push_token, registration_info } payload to the relay. That
push_token is an iOS 26 WidgetKit widget push token — a raw APNs token that
FCM can't route (messaging.send targets FCM registration tokens, and Firebase
has no widget-token field like apns.liveActivityToken).

This adds widget-push.js, which sends the silent WidgetKit refresh straight to
APNs over HTTP/2, bypassing the messaging SDK for this one path:

apns-push-type: widgets, topic .push-type.widgets, body
{"aps":{"content-changed":true}}
authenticated with a .p8-signed provider JWT (ES256; the private key never
leaves the function — only the signature goes to Apple)
tries the production host and falls back to sandbox on BadDeviceToken, so it
works for both Xcode and store builds
sendPushNotification now routes requests carrying push_subscription to this
handler. Everything else still goes through FCM unchanged, and Core is untouched.

Why not FCM
The widget push token is an Apple-only token FCM has never seen and has no field
to target (the apns.liveActivityToken trick has no widget equivalent as of
firebase-admin 13.5.0). So the only way to deliver it is straight to APNs.

Deployment note
The APNs key is provided as Functions secrets (Secret Manager) — the same APNs
auth key already used for FCM, just a copy the function can read to sign the JWT:

firebase functions:secrets:set APNS_KEY_P8 # the .p8 contents
firebase functions:secrets:set APNS_KEY_ID # 10-char Key ID
firebase functions:secrets:set APNS_TEAM_ID # 10-char Team ID
Only the function's runtime service account and project admins can read them
(IAM-gated); never the repo or logs.

Testing
test/widget-push.test.js covers the success response, headers/topic, the
sandbox fallback, and the missing-token / missing-app-id / missing-credentials /
rejection paths.
Full suite: 158 passing. Prettier + ESLint clean.
Verified end-to-end against real APNs on an iOS 26.5 device — the widget
reloaded with fresh data.
Context
The iOS and Core sides of this feature are already implemented; this is the relay
piece discussed in home-assistant/iOS#4782.

Related Pull Requests

Core - home-assistant/core#174943
iOS - home-assistant/iOS#4939

When Core reports a tracked entity change for a widget, it POSTs a
{ push_subscription, push_token, registration_info } payload to the relay. The
push_token is an iOS 26 WidgetKit widget push token — a raw APNs token that FCM
can't route (messaging.send targets FCM registration tokens), so this path can't
go through the messaging SDK.

Add widget-push.js, which sends a silent WidgetKit refresh straight to APNs over
HTTP/2 (apns-push-type: widgets, topic <bundle>.push-type.widgets,
{"aps":{"content-changed":true}}), authenticated with a .p8-signed provider JWT
(ES256). It tries the production host and falls back to sandbox on
BadDeviceToken so it works for both Xcode and store builds.

sendPushNotification now routes requests carrying push_subscription to this
handler; the APNs key, key id and team id are provided as Functions secrets
(Secret Manager). Everything else still goes through FCM unchanged.

Add tests covering the success response, headers/topic, sandbox fallback, and
the missing-token / missing-app-id / missing-credentials / rejection paths.
Add direct-APNs sender for WidgetKit push subscriptions
Copilot AI review requested due to automatic review settings July 5, 2026 13:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a direct-to-APNs delivery path for iOS 26 WidgetKit “widget push” subscriptions, since these widget tokens are raw APNs device tokens and cannot be targeted through FCM/Firebase Admin’s messaging.send().

Changes:

  • Added functions/widget-push.js to sign an APNs provider JWT and POST a silent WidgetKit refresh (apns-push-type: widgets) directly to APNs, with production→sandbox fallback on BadDeviceToken.
  • Updated functions/index.js to route requests containing push_subscription to the new APNs sender, while keeping existing notifications on the FCM path.
  • Added Jest coverage for success, required headers/topic, sandbox fallback, and failure cases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
functions/widget-push.js Implements APNs HTTP/2 sender with provider-JWT auth and sandbox fallback for WidgetKit push tokens.
functions/test/widget-push.test.js Adds unit tests for the new APNs widget push handler behavior and error paths.
functions/index.js Routes widget subscription payloads to the APNs sender and configures secrets for that function.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread functions/widget-push.js
Comment thread functions/widget-push.js
Comment thread functions/index.js
@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.31250% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.03%. Comparing base (e6cb774) to head (74d08fd).

Files with missing lines Patch % Lines
functions/widget-push.js 95.31% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #337      +/-   ##
==========================================
+ Coverage   79.62%   81.03%   +1.40%     
==========================================
  Files          10       11       +1     
  Lines         648      712      +64     
  Branches      212      221       +9     
==========================================
+ Hits          516      577      +61     
- Misses        113      116       +3     
  Partials       19       19              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The widget path short-circuited to sendWidgetPush before handleRequest's rate
limiter, leaving an unbounded path an attacker (or a runaway caller) could use to
hammer APNs — raising cost and risking APNs throttling and device battery drain.

Route widget pushes through the same per-token rate limiter: record the attempt,
return 429 when the token exceeds the daily max, and record success/error after
the send (best-effort). Export the shared limiter from handlers.js and add tests
for the rate-limited and outcome-recording paths.
Follow-up to the widget rate-limiting: widget pushes now use a separate limiter
instance with their own daily max (MAX_WIDGET_PUSHES_PER_DAY, default 100) rather
than sharing the notification cap. Apple's WidgetKit push budget is small, so a
lower cap fits and keeps widget traffic from consuming a device's normal
notification allowance. Widget tokens are a distinct key namespace, so their
counters never collide with FCM-token counters.
…rors

Addresses two Copilot review findings on the widget-push relay:
- Reject non-hex push tokens and non-bundle-id app ids before they reach the
  APNs :path and apns-topic headers, avoiding malformed/injected requests.
- Close the HTTP/2 client when the session errors so the socket isn't leaked
  until GC, matching the stream error handler.
Adds tests for the two remaining uncovered lines flagged by Codecov: the
stream-level request error handler (client close + reject) and the 500 returned
when the rate-limit check itself throws. widget-push.js is now at 100% line
coverage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants