Skip to content

Add FCM push notification support#251

Draft
dalinaum wants to merge 9 commits intohackers-pub:mainfrom
dalinaum:add-fcm-push-notifications
Draft

Add FCM push notification support#251
dalinaum wants to merge 9 commits intohackers-pub:mainfrom
dalinaum:add-fcm-push-notifications

Conversation

@dalinaum
Copy link
Copy Markdown

@dalinaum dalinaum commented Apr 17, 2026

Summary

  • Add FCM device token storage and GraphQL mutations for registering and unregistering FCM tokens
  • Send FCM push notifications alongside APNS for persisted notifications
  • Add the Drizzle migration, snapshot, and env wiring needed for Firebase service account configuration

Environment Setup

FCM requires a Firebase service account key to send push notifications. Set the FCM_SERVICE_ACCOUNT_KEY environment variable with the entire service account JSON, serialized to a single line and wrapped in single quotes (same pattern used by INSTANCE_ACTOR_KEY in this repo).

  1. Go to Firebase Console → Project hackerspub-android → Project Settings → Service Accounts
  2. Click "Generate new private key" to download the JSON file
  3. Append it to .env in one shot:
printf "FCM_SERVICE_ACCOUNT_KEY='%s'\n" "$(jq -c . path/to/serviceAccountKey.json)" >> .env

Why single quotes + single line:

  • jq -c preserves the \n escapes inside private_key as two literal characters (\ + n), which JSON.parse later restores to real newlines — this is the shape the PEM importer needs.
  • Double quotes in .env would re-interpret \n to actual newlines mid-parse and break JSON.parse.
  • No quotes at all would get truncated at the first whitespace/newline.

If FCM_SERVICE_ACCOUNT_KEY is not set, FCM integration is silently disabled (APNS continues to work independently).

Changes since initial review

The original feature commit lives at 8b737ba; everything below was added in response to reviewer feedback.

  • 5f9ff3e — Revert 766ec3f (@dahlia)
    The earlier "harden JWT encoding" commit UTF-8-encoded text before btoa, but the same helper was reused on the raw RSA signature bytes, corrupting any byte > 0x7F and producing an invalid JWT — so any deployment with FCM_SERVICE_ACCOUNT_KEY configured would have failed to obtain an access token. The original btoa(str) path was already safe here (JSON payload is always ASCII; signature bytes fit Latin1), so reverting restored correct behavior.

  • 9246670 — Apply deno fmt to models/fcm.ts (@dahlia)
    Fixes the CI format check that was failing on line 70.

  • 11d6144 — Parallelize per-token FCM dispatch with Promise.allSettled (@dahlia, @gemini-code-assist)
    Because createNotification() awaits sendPushNotificationsBestEffort(), the previous sequential per-token await fetch bound follow/share/reply/react write-path latency to the sum of Google round-trips (up to 20 sequential requests per account). Dispatching concurrently with Promise.allSettled(tokens.map(...)) caps fan-out latency at the slowest single request; stale-token collection and per-iteration failure logging are preserved. OAuth/error response bodies were also properly typed so deno check no longer treats them as unknown. Added a unit test (models/fcm.more.test.ts) that stubs globalThis.fetch + generates a throwaway RSA key, tracks in-flight concurrency, and verifies maxInFlight > 1.

  • c028e5b — Scope FCM stale-token pruning to errorCode only
    Removed the resp.status === 404 standalone trigger: FCM also returns 404 for project misconfiguration (wrong project_id, FCM API unavailable), and deleting tokens in that case would wipe every valid device registration on a bad deployment. Pruning is now strictly driven by the structured errorCode.

  • bd09c30 — Further restrict pruning to UNREGISTERED only
    Removed INVALID_ARGUMENT from the pruning condition. FCM returns INVALID_ARGUMENT for message-payload problems as well as token-format problems; since every token in a fan-out shares the same payload, a payload bug or an API-side validation change would trigger INVALID_ARGUMENT for every token and mass-delete the entire account's Android registrations. This aligns FCM with the existing APNS behavior in models/apns.ts, which only marks tokens stale on strictly token-specific errors (BadDeviceToken, DeviceTokenNotForTopic, Unregistered). If orphan-token log noise becomes measurable in production, a follow-up could inspect google.rpc.BadRequest.fieldViolations[].field == "message.token" for a more precise signal, but YAGNI until then. The test now also exercises an INVALID_ARGUMENT response and asserts the token is retained.

  • ea47aca — Store FCM device tokens as text rather than varchar(256)
    FCM registration tokens are opaque, and Google explicitly warns not to validate them against any pattern because the format can change (observed length already rose from 152 to 163 characters in past iterations; informal upper bound is ~4 KB). A 256-char cap risks silently rejecting valid future tokens. Switched the schema column to text, removed the mirrored length caps in registerFcmDeviceToken() and the GraphQL register mutation, and regenerated the original 0090 migration in place (the feature hasn't shipped yet) so main gets a single clean migration instead of a varchar → text ALTER churn.

  • 38b8f92 — Make FCM_SERVICE_ACCOUNT_KEY discoverable in .env.sample
    The previous sample only described the variable in a prose comment, so anyone bootstrapping from .env.sample got no placeholder to fill in and would leave FCM silently disabled. Switched to the same pattern used by INSTANCE_ACTOR_KEY: a real placeholder line, plus a note that the value must be single-line JSON wrapped in single quotes, with jq -c as the recommended way to produce it.

Testing

  • Unit test for the parallel dispatch + stale-token pruning path: deno test --env-file=.env.test --allow-all models/fcm.more.test.ts (passes).
  • Full models/ suite: 134/134 passing locally with a fresh Postgres 17 test DB.
  • deno fmt --check and deno lint: clean for touched files.

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5675cac4-78f7-405b-afcb-cba457683952

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements Firebase Cloud Messaging (FCM) support for push notifications. It introduces a new fcm_device_token table, GraphQL mutations for token registration and unregistration, and the backend logic for generating OAuth2 access tokens and sending notifications via the FCM v1 API. Feedback includes improving Unicode handling during JWT encoding, parallelizing notification dispatches to reduce latency, adding a notification block to the FCM payload for better background delivery, and limiting the size of error logs when token requests fail.

Comment thread models/fcm.ts
Comment thread models/fcm.ts Outdated
Comment thread models/fcm.ts Outdated
Comment thread models/fcm.ts
dalinaum added a commit to dalinaum/hackerspub-android that referenced this pull request Apr 17, 2026
- Add firebase-messaging dependency
- Implement HackersPubMessagingService for receiving FCM data messages
- Implement FcmTokenManager for token registration/unregistration
  via registerFcmDeviceToken/unregisterFcmDeviceToken GraphQL mutations
- Register FCM token on login, unregister on logout
- Request notification permission on login (not just app startup)
- Remove foreground polling (FCM replaces it; 15-min WorkManager
  polling remains as fallback)
- Handle GraphQL union error types in mutation responses
- Add FCM mutation types to local GraphQL schema

Requires server-side FCM support: hackers-pub/hackerspub#251

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@dahlia dahlia left a comment

Choose a reason for hiding this comment

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

Thanks for your first contribution! Could you format the code using deno fmt?

Comment thread models/fcm.ts
Comment thread models/fcm.ts Outdated
dalinaum and others added 7 commits April 17, 2026 23:41
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously sendFcmNotification awaited each per-token fetch sequentially.
Since createNotification awaits sendPushNotificationsBestEffort before
returning, an account with many FCM tokens paid one full Google
round-trip per device on the notification write path. Dispatch the
per-token sends concurrently so the fan-out latency is bounded by the
slowest single request. Stale-token collection and per-iteration
failure logging are preserved.

Also tighten FCM OAuth token response typing and FCM error body typing
so `deno check` no longer flags them as `unknown`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FCM messages:send returns 404 not only for UNREGISTERED tokens but also
for misconfiguration such as a wrong project_id or an unavailable FCM
API for the project. Using resp.status === 404 as a standalone trigger
meant one bad deployment could wipe valid device registrations and
force users to re-register push notifications. Drop the status-only
branch and only mark tokens stale when the errorCode explicitly says
UNREGISTERED or INVALID_ARGUMENT.

The test now also exercises a 404 response without the UNREGISTERED
errorCode and asserts the token is retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FCM returns INVALID_ARGUMENT not only for malformed registration tokens
but also for malformed message payloads. Because every token in a fan-out
shares the same payload, a payload-level validation failure would make
every INVALID_ARGUMENT response delete an otherwise valid device, silently
wiping out Android registrations en masse.

Align FCM with the APNS path, which only marks tokens stale on strictly
token-specific errors (BadDeviceToken, DeviceTokenNotForTopic, Unregistered).
For FCM that means UNREGISTERED alone. Payload-caused INVALID_ARGUMENT
now logs a warning and leaves the registration intact.

The test now also exercises an INVALID_ARGUMENT response and asserts the
token is retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FCM registration tokens are opaque and Google explicitly warns not to
validate them against any pattern because the format can change; the
currently observed length already rose from 152 to 163 characters in
past iterations, and the informal upper bound is the cookie size limit
(~4 KB). The previous varchar(256) column therefore risks silently
rejecting valid future tokens, which the model and GraphQL layers
mirrored as an arbitrary length cap.

Change the column to text, drop the corresponding length checks in
registerFcmDeviceToken() and the GraphQL register mutation. Since the
feature has not been deployed yet, regenerate the original 0090
migration in place so main gets a single clean migration instead of a
varchar → text ALTER churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the sample only described the variable in a prose comment, so
users bootstrapping from .env.sample got no placeholder to fill in and
could leave FCM silently disabled. The prose also suggested pasting the
raw multi-line JSON, which breaks under dotenv single-line parsing and
under `\n` re-interpretation in double-quoted values.

Switch to the same pattern already used for INSTANCE_ACTOR_KEY: a real
placeholder entry, plus a note that the value must be single-line JSON
wrapped in single quotes, with `jq -c` as the recommended way to produce
it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dalinaum
Copy link
Copy Markdown
Author

Hi @dahlia — thanks again for the review. Just pushed a series of follow-up commits (5f9ff3e…38b8f92) addressing everything in your feedback. The full walkthrough is in the updated PR description, but a quick summary so you don't have to chase individual hashes:

Your two review comments

  • JWT signature corruption: reverted in 5f9ff3e. You were right — I'd reused the UTF-8–wrapping helper on the raw RSA signature bytes, which corrupted any byte > 0x7F and would have broken every access-token request. The original btoa(str) path was already safe for this code (JSON payload is always ASCII; signature bytes fit Latin1), so the revert is the fix.
  • Sequential per-token dispatch: parallelized in 11d6144 with Promise.allSettled(tokens.map(...)). My earlier "keep sequential for straightforward observability" was the wrong trade-off given createNotification() awaits the dispatch. Stale-token collection and per-iteration logging are preserved.

deno fmt: applied in 9246670; CI's format check now passes on this branch.

Additional cleanup based on second-pass review

  • models/fcm.ts:331-334 stale-token logic turned out to have two more issues I tightened in c028e5b and bd09c30: (1) resp.status === 404 alone is too aggressive because FCM also returns 404 on project misconfiguration, and (2) INVALID_ARGUMENT is too aggressive because FCM uses it for both token-format and payload problems — since every token in a fan-out shares the same payload, a payload bug could mass-delete all of an account's registrations. Pruning is now scoped to errorCode === "UNREGISTERED", mirroring the APNS policy in models/apns.ts (only token-specific reasons trigger deletion). Happy to revisit as a pair if you'd prefer a different policy across both providers.
  • models/schema.ts FCM device_token column: switched from varchar(256) to text in ea47aca. Google explicitly warns not to validate token format (observed length already rose 152 → 163 in the past), and our 256 cap would silently reject valid future tokens. Since the feature hasn't shipped, I regenerated the 0090 migration in place rather than stacking a varchar → text ALTER.
  • .env.sample entry: turned into a real placeholder with a jq -c hint in 38b8f92, mirroring how INSTANCE_ACTOR_KEY is documented in .env.ci / .env.test.sample.

Test coverage: added models/fcm.more.test.ts that stubs globalThis.fetch, generates a throwaway RSA key, and exercises the parallel dispatch + stale-token pruning path end-to-end (asserts maxInFlight > 1, verifies UNREGISTERED is pruned while non-token 404 and INVALID_ARGUMENT are retained).

Let me know if any of the policy choices (especially the UNREGISTERED-only pruning to match APNS) don't match what you had in mind and I'll adjust.

@dahlia
Copy link
Copy Markdown
Member

dahlia commented Apr 17, 2026

@dalinaum This seems reasonable to me. UNREGISTERED is the one case here that clearly points to a dead token, while 404 and INVALID_ARGUMENT can still reflect project or payload issues. In that situation, pruning would risk deleting valid registrations because of a server-side problem, which seems like the worse failure mode.

I also like that this keeps the FCM policy aligned with APNS: only prune on token-specific errors. If stale-token noise becomes measurable later, we can always make the FCM heuristic more specific.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants