You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 659029c
Browse filesBrowse the repository at this point in the historyBrowse files
fix(api): put push gateway status behind admin and confirm unsubscribe
GET /health/push showed queue counts and the VAPID subject with no auth.
The route is removed and now answers 404. Admins read the same status on
GET /api/admin/push/gateway. That route always answers 200, so the
dashboard reads the VAPID and queue flags from the body. The dashboard
now calls it through fetchApi, which sends the admin token. The OpenAPI
spec drops /health/push, the PushHealth schema and the empty Push tag.
GET /api/email/health stays public, because it already returns status only.
A GET of the unsubscribe link used to change preferences at once. Mail
link scanners open every link, so a scanner could unsubscribe a reader.
The GET now checks the token and renders a confirm page with an
Unsubscribe button. It writes nothing. The button posts confirm=yes to
the same URL and gets an HTML result page. Any other POST body, such as
the RFC 8058 one-click post from a mail client, keeps the JSON answer.
A new integration test pins that a GET never calls apply_unsubscribe.
Refs #433
Refs #445
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Copy file name to clipboardExpand all lines: apps/hocuspocus.server/API.md
+4-7Lines changed: 4 additions & 7 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -112,10 +112,6 @@ Redis `PING`. `disabled` when Redis is not configured.
112
112
113
113
Supabase reachability. `disabled` when `SUPABASE_URL`/`SUPABASE_ANON_KEY` are unset.
114
114
115
-
### GET /health/push
116
-
117
-
Push gateway status. `200` when VAPID is configured and the queue is connected.
118
-
119
115
## Documents
120
116
121
117
Base path `/api/documents` (`src/api/routers/documents.router.ts`). Success responses use `{ "success": true, "data": ... }`.
@@ -1206,11 +1202,11 @@ Render a template (`notification` or `digest`) with sample data, as HTML. Servic
1206
1202
1207
1203
### GET /api/email/unsubscribe?token=
1208
1204
1209
-
One-click unsubscribe from an email link. No auth (the token is the credential). Verifies the token in `lib/unsubscribeToken.ts`, then applies the change through the `apply_unsubscribe` Supabase RPC, and returns an HTML confirmation page.
1205
+
The email link. No auth (the token is the credential). Verifies the token in `lib/unsubscribeToken.ts` and returns an HTML page with an Unsubscribe button. It changes nothing. Mail link scanners open every link, so a GET must never write.
1210
1206
1211
1207
### POST /api/email/unsubscribe?token=
1212
1208
1213
-
RFC 8058 `List-Unsubscribe-Post` handler for mail clients. Returns JSON (`{ "success": true }` or an `{ "error": ... }` body).
1209
+
The only route that writes. It applies the change through the `apply_unsubscribe` Supabase RPC. The confirm page's button posts the form field `confirm=yes` and gets an HTML result page. Any other body gets JSON (`{ "success": true }` or an `{ "error": ... }` body). That covers the RFC 8058 `List-Unsubscribe=One-Click` post from mail clients.
1214
1210
1215
1211
## Admin
1216
1212
@@ -1223,6 +1219,7 @@ Two audit routes are easy to misread. `/audit/media-storage` and `/audit/media-s
Push has no public HTTP endpoint. Admins read the gateway status on `GET /api/admin/push/gateway`. It always answers `200` with the status, even when push is not set up.
Copy file name to clipboardExpand all lines: apps/hocuspocus.server/CLAUDE.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -193,6 +193,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md). The `### Supab
193
193
### Digest Email Links And Counts
194
194
195
195
-**The app has no `/notifications` and no `/settings` route.**`apps/webapp/src/pages/` holds neither, and `[...slugs].tsx` serves every path, so those URLs returned HTTP 200 and opened a blank pad named after the path. `/settings/notifications` was worse: the second segment became a filter term. Every email link into the app must therefore be a real route plus a hash. `/unsubscribe` stays a real URL on the app's own domain, but the webapp no longer renders it: `next.config.js` proxies it to this server's `/api/email/unsubscribe`, which answers with HTML and needs no JavaScript. Its `routePolicy.ts``UTILITY_PATHS` entry stays as a fall-through guard, so a broken rewrite opens no blank pad.
196
+
**A GET of `/unsubscribe` never writes (#445).** It checks the token and renders a confirm page. Only a POST writes: the page's form (`confirm=yes`, HTML) or a mail client's RFC 8058 one-click (JSON). Mail link scanners open every link, so never make GET write again.
196
197
-**Both parts of a mail must offer the same link.**`resolveFooterLinks` in `packages/email-templates/src/helpers.ts` owns one table of action to link and label. `footerLinks` renders the HTML and `footerLinksText` the plain text, so neither can drift. The plain-text parts used to hardcode a tokenless URL, and the digest HTML aliased `unsubscribe_all` onto the digest link, which labelled a digest-scoped link "Unsubscribe from all". Pass the notification type, never a doctored links object.
197
198
-**Footer links are signed in Node, never in Postgres.**`lib/unsubscribeToken.ts` owns signing and verification, keyed on `EMAIL_UNSUBSCRIBE_SECRET`; Postgres keeps only `apply_unsubscribe(user_id, action)`, the write. The SQL signer was deleted because a hosted project's `postgres` role cannot set `app.unsubscribe_secret` — `ALTER DATABASE` and `ALTER ROLE` both raise `42501` — so the secret was never provisioned, `get_email_footer_links` raised on every send, `sender.ts` swallowed the error, and every footer shipped a tokenless link the route then rejected. Postgres also wraps `encode(…, 'base64')` at 76 characters, which the old signer never stripped, so a long payload carried an embedded newline. **Do not re-propose signing in SQL, and do not reach for Vault: the fix was to delete the requirement, not to store the secret somewhere else.**`apply_unsubscribe` names a user with no proof of identity, so its `service_role`-only grant is load-bearing.
198
199
-**The contributor count is a floor, never a census, and zero never ships.**`withSections` reads `outcome.result.summary.contributors.length`, which compute already resolved, so no new query exists. It sets the field only above 0, `contributorLine` returns an empty string for absent and 0, and both surfaces guard on that string. The number can legitimately be 0 while the document changed: a service-role write sets `triggeredBy` to an explicit null, and `resolveProfiles` returns `[]` on a profile outage. `Documents.contributors` is also per-replica and editor-only. Never word that line as complete.
0 commit comments