Skip to content

Web Push notifications foundation (CIRCLE-38)#107

Merged
HamptonMakes merged 1 commit into
mainfrom
hampton/circle-38-web-push-foundation
May 11, 2026
Merged

Web Push notifications foundation (CIRCLE-38)#107
HamptonMakes merged 1 commit into
mainfrom
hampton/circle-38-web-push-foundation

Conversation

@HamptonMakes
Copy link
Copy Markdown
Collaborator

Foundation for in-browser push notifications. No notifications are delivered yet — that lands in follow-up work hooked into comment-reply / mention events.

Linear: CIRCLE-38

What's here

Per-device subscription storage

  • New coplan_web_push_subscriptions table (FK to coplan_users) and CoPlan::WebPushSubscription model.
  • Stores endpoint, SHA256 endpoint digest (uniquely indexed since FCM endpoints can exceed indexable length), p256dh + auth keys, user agent, last-seen / last-delivered timestamps, and a delivered-counter.
  • upsert_for is idempotent and rescues RecordNotUnique for concurrent inserts; record_delivery! uses an atomic increment! so concurrent deliveries can't lose updates.
  • device_label returns friendly strings like "Chrome on macOS" / "Safari on iOS".

Service worker

  • Served from /coplan_service_worker.js (engine-mounted route), no auth, Cache-Control: no-cache. Rendered inline so reverse proxies that intercept X-Sendfile won't try to reach into the gem on disk.
  • Default scope only — push events fire regardless of scope, so we don't broaden it.
  • Push handler shows the notification; click handler focuses an existing same-origin tab and navigates it (preserving the URL hash for anchor deep links) or opens a new window.

Subscription endpoints

  • POST /web_push/subscription — upserts the browser subscription for current_user.
  • DELETE /web_push/subscription — scoped to current_user so a leaked endpoint can't unsubscribe someone else.
  • Both 503 when VAPID isn't configured.

Browser-side JS

  • coplan/web_push.js ES module with isSupported / permission / isSubscribed / subscribe / unsubscribe. Pinned via importmap.
  • Awaits navigator.serviceWorker.ready after register() so PushManager has an active worker (fixes the "no active Service Worker" failure on first subscribe).
  • VAPID public key and service worker URL discovered via meta tags rendered only when CoPlan.configuration.web_push_configured?.

Settings UI

  • Notifications card on the Settings page: enable/disable on the current device, status text, and a list of all known devices for the user with friendly names + delivery stats.
  • New CSS for the settings rows / device list. Also adds [hidden] { display: none !important; } so hidden actually hides .btn elements.

Configuration

  • CoPlan::Configuration gains vapid_public_key / vapid_private_key / vapid_subject and web_push_configured?.
  • New bundle exec rake coplan:web_push:generate_keys.
  • web-push gem added to engine gemspec.
  • Host config/initializers/coplan.rb ships a checked-in dev VAPID keypair behind ENV overrides so the Settings UI works out-of-the-box locally. Production should override via env / encrypted credentials.

Verification

  • bundle exec rspec → 814 examples, 0 failures.
  • New specs: WebPushSubscription model, subscriptions controller (POST/DELETE), service worker route.
  • Manually exercised the Settings card: enable / disable / re-enable, device row appears with a friendly name.

Out of scope (next PRs)

  • Actual push delivery service (web-push gem + VAPID signing) wired into comment-reply / mention events.
  • Opt-in banner that nudges commenters to enable notifications.
  • Production VAPID key wiring in coplan-square.

Generated with Amp

Sets up the plumbing for browser-based push notifications without yet
delivering any. Subsequent work hooks comment-reply/mention events into
this pipeline.

Engine
- coplan_web_push_subscriptions table + model with per-device records
  (endpoint digest, keys, UA, last_seen_at, last_delivered_at, counter).
- Idempotent upsert_for tolerant of concurrent insert races; atomic
  increment for delivery counters.
- Service worker served from /coplan_service_worker.js (engine-scoped,
  no auth, no-cache, rendered inline so reverse proxies don't intercept).
- Push handler shows notifications and routes notification clicks to a
  matching same-origin tab (preserves URL hash for anchor deep links).
- POST/DELETE /web_push/subscription endpoints, scoped to current_user.
- Shared coplan/web_push ES module (subscribe/unsubscribe/isSupported).
- Settings card to enable/disable per-device + list of known devices
  with friendly labels (Chrome on macOS, Safari on iOS, etc.).
- VAPID public key + service worker URL exposed via meta tags only when
  configured. Configuration gains vapid_public_key/vapid_private_key/
  vapid_subject and web_push_configured?.
- web-push gem dependency + bundle exec rake coplan:web_push:generate_keys.

Host
- Dev VAPID key pair wired into config/initializers/coplan.rb via ENV
  fallbacks so local Settings UI is testable out-of-the-box.

Generated with Amp

Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435
Co-authored-by: Amp <amp@ampcode.com>
@HamptonMakes HamptonMakes marked this pull request as ready for review May 11, 2026 16:37
@HamptonMakes HamptonMakes merged commit 1b5d39d into main May 11, 2026
5 checks passed
@HamptonMakes HamptonMakes deleted the hampton/circle-38-web-push-foundation branch May 11, 2026 16:43
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7b7447ee9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# or your secrets manager. Generate fresh keys with:
# bundle exec rake coplan:web_push:generate_keys
config.vapid_public_key = ENV["COPLAN_VAPID_PUBLIC_KEY"] || "BPY5NsdGJ4vEmHHNz3SqK2XsmV93j-iR3-kqN-RMbl4JRd9jnKpzunwdXDwFwlzbRlPErn3x379e6Cz7DfdSS6o="
config.vapid_private_key = ENV["COPLAN_VAPID_PRIVATE_KEY"] || "1HoYR1d8QIlf8RYTfugJQFTyLlBat3zd-EFkj5dO9WQ="
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove checked-in fallback VAPID private key

This initializer hardcodes a usable VAPID private key as a default, so any environment that forgets to set COPLAN_VAPID_PRIVATE_KEY will silently run with a publicly known signing key. In that state, anyone who learns a subscription endpoint can forge push messages that appear to come from this app. This should fail closed in non-development environments instead of defaulting to a committed secret.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — fixed in 3f2aa35. The fallback keypair now only applies in Rails.env.development?. In any other env, missing COPLAN_VAPID_* leaves the config nil, which makes web_push_configured? false and disables the meta tags / subscription endpoints / settings card entirely. Also rotated the dev keypair since the prior one is now in commit history.

Comment thread Gemfile.lock
arm-linux-musl
arm64-darwin
x86_64-darwin
arm64-darwin-25
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore Intel macOS platform support in lockfile

The lockfile now lists only arm64-darwin-25 for macOS and drops x86_64-darwin, which makes Bundler treat Intel Macs as unsupported when using a frozen/deployment lockfile. That blocks bundle install for contributors or CI runners on Intel macOS until the platform is re-added to Gemfile.lock.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 3f2aa35 via bundle lock --add-platform x86_64-darwin arm64-darwin. Both are back in the lockfile so frozen bundle install works on Intel macOS again. (The original drop was an artifact of bundle install running on this arm64-darwin-25 machine.)

HamptonMakes added a commit that referenced this pull request May 11, 2026
- Fail closed on missing VAPID keys outside development. The checked-in
  fallback keypair only applies in Rails.env.development?; production
  with unset COPLAN_VAPID_* simply disables web push (per
  web_push_configured?) instead of silently signing with a public key.
  Rotated the dev fallback keys since the previous values are now in
  commit history.
- Restore arm64-darwin and x86_64-darwin in Gemfile.lock so Intel macOS
  contributors and CI can install with a frozen lockfile.

Generated with Amp

Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435

Co-authored-by: Amp <amp@ampcode.com>
HamptonMakes added a commit that referenced this pull request May 11, 2026
Foundation in #107 stops at "subscriptions are stored, SW is wired up,
Settings card works." This commit lights it up — the server actually
sends a push to every device a user has enabled, anchored to the
Notification model so reply / new_comment / mention / agent_response /
status_change all flow through the same path.

Engine
- CoPlan::WebPush::Deliver — wraps web-push gem; signs with VAPID;
  returns :delivered or :expired (404/410); re-raises transient errors
  so SolidQueue can retry.
- CoPlan::WebPush::PayloadForNotification — builds {title,body,url,tag}
  per Notification; reason-aware phrasing; mention chips rewritten to
  @username; markdown emphasis stripped; body truncated to 140 chars
  with an ellipsis; hyphens and # preserved (regression test included)
  so co-worker / URL#fragment survive intact; URL is engine-relative so
  the SW resolves it against self.location.origin.
- CoPlan::WebPushDeliveryJob — per-(notification, subscription) so a
  single bad endpoint doesn't block the user's other devices; destroys
  the subscription on :expired; retry_on PushServiceError /
  TooManyRequests up to 5 times with polynomial backoff; defensive
  against either record being deleted before the job runs.
- Notification#after_commit on :create fans out one job per active
  subscription belonging to the recipient. Quietly no-ops when
  web_push_configured? is false (host hasn't set VAPID).

Specs (+20 examples)
- Deliver: 2xx records delivery, 410/404 returns :expired, 503 re-raises,
  ConfigurationError when VAPID is unset.
- PayloadForNotification: per-reason titles, mention/markdown stripping,
  hyphen/URL preservation, truncation with ellipsis, anonymous fallback.
- WebPushDeliveryJob: calls Deliver with the right args, destroys on
  :expired, no-ops on missing records.
- Notification: fan-out enqueues one job per subscription on create,
  zero when no subscriptions, zero on update, zero when web push isn't
  configured.

Full suite: 834 examples, 0 failures.

Generated with Amp

Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435
Co-authored-by: Amp <amp@ampcode.com>
HamptonMakes added a commit that referenced this pull request May 12, 2026
Foundation in #107 stops at "subscriptions are stored, SW is wired up,
Settings card works." This commit lights it up — the server actually
sends a push to every device a user has enabled, anchored to the
Notification model so reply / new_comment / mention / agent_response /
status_change all flow through the same path.

Engine
- CoPlan::WebPush::Deliver — wraps web-push gem; signs with VAPID;
  returns :delivered or :expired (404/410); re-raises transient errors
  so SolidQueue can retry.
- CoPlan::WebPush::PayloadForNotification — builds {title,body,url,tag}
  per Notification; reason-aware phrasing; mention chips rewritten to
  @username; markdown emphasis stripped; body truncated to 140 chars
  with an ellipsis; hyphens and # preserved (regression test included)
  so co-worker / URL#fragment survive intact; URL is engine-relative so
  the SW resolves it against self.location.origin.
- CoPlan::WebPushDeliveryJob — per-(notification, subscription) so a
  single bad endpoint doesn't block the user's other devices; destroys
  the subscription on :expired; retry_on PushServiceError /
  TooManyRequests up to 5 times with polynomial backoff; defensive
  against either record being deleted before the job runs.
- Notification#after_commit on :create fans out one job per active
  subscription belonging to the recipient. Quietly no-ops when
  web_push_configured? is false (host hasn't set VAPID).

Specs (+20 examples)
- Deliver: 2xx records delivery, 410/404 returns :expired, 503 re-raises,
  ConfigurationError when VAPID is unset.
- PayloadForNotification: per-reason titles, mention/markdown stripping,
  hyphen/URL preservation, truncation with ellipsis, anonymous fallback.
- WebPushDeliveryJob: calls Deliver with the right args, destroys on
  :expired, no-ops on missing records.
- Notification: fan-out enqueues one job per subscription on create,
  zero when no subscriptions, zero on update, zero when web push isn't
  configured.

Full suite: 834 examples, 0 failures.

Generated with Amp

Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435

Co-authored-by: Amp <amp@ampcode.com>
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.

1 participant