Skip to content

Commit 659029c

Browse files
HMarzbanclaude
andcommitted
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]>
1 parent 9df3600 commit 659029c

19 files changed

Lines changed: 313 additions & 121 deletions

File tree

‎apps/admin-dashboard/src/services/api.ts‎

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -437,22 +437,13 @@ function categorizeError(errorMsg: string): string {
437437
export async function checkPushGatewayHealth(): Promise<PushGatewayHealth> {
438438
const start = Date.now()
439439
try {
440-
const response = await fetch(`${API_URL}/health/push`, {
441-
signal: AbortSignal.timeout(5000)
442-
})
440+
const data = await fetchApi<{
441+
vapid_configured?: boolean
442+
queue_connected?: boolean
443+
pending_jobs?: number
444+
failed_jobs?: number
445+
}>('/api/admin/push/gateway', { signal: AbortSignal.timeout(5000) })
443446
const latency = Date.now() - start
444-
445-
if (!response.ok) {
446-
return {
447-
status: 'degraded',
448-
latency,
449-
vapidConfigured: false,
450-
queueConnected: false,
451-
error: `HTTP ${response.status}`
452-
}
453-
}
454-
455-
const data = await response.json()
456447
return {
457448
status: data.vapid_configured && data.queue_connected ? 'healthy' : 'degraded',
458449
latency,

‎apps/hocuspocus.server/API.md‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,6 @@ Redis `PING`. `disabled` when Redis is not configured.
112112

113113
Supabase reachability. `disabled` when `SUPABASE_URL`/`SUPABASE_ANON_KEY` are unset.
114114

115-
### GET /health/push
116-
117-
Push gateway status. `200` when VAPID is configured and the queue is connected.
118-
119115
## Documents
120116

121117
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
12061202

12071203
### GET /api/email/unsubscribe?token=
12081204

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.
12101206

12111207
### POST /api/email/unsubscribe?token=
12121208

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.
12141210

12151211
## Admin
12161212

@@ -1223,6 +1219,7 @@ Two audit routes are easy to misread. `/audit/media-storage` and `/audit/media-s
12231219
| Method | Path | Purpose |
12241220
| ------ | ------------------------- | ------------------------ |
12251221
| GET | `/stats` | Dashboard overview stats |
1222+
| GET | `/push/gateway` | Push gateway status |
12261223
| GET | `/users/document-counts` | Document count per user |
12271224
| GET | `/users/admins` | All admin user IDs |
12281225
| POST | `/users/:id/toggle-admin` | Grant or revoke admin |
@@ -1331,7 +1328,7 @@ await supabase.rpc('register_push_subscription', {
13311328
await supabase.rpc('unregister_push_subscription', { p_device_id: 'unique-device-id' })
13321329
```
13331330

1334-
`GET /health/push` reports gateway status.
1331+
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.
13351332

13361333
## Rate limiting
13371334

‎apps/hocuspocus.server/CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ This file is the operator and API changelog. The pad product lives in the [root
2929
- **Owner and author profiles drop `status` ([#434](https://github.com/docs-plus/docs.plus/issues/434)).**
3030
The `ownerProfile` on `GET /api/documents/:docName`, the documents list
3131
`owner`, and the `history.list` profiles no longer send it. See Security.
32+
- **`GET /health/push` is removed ([#433](https://github.com/docs-plus/docs.plus/issues/433)).**
33+
It now answers `404`. See Security.
3234

3335
### Migration
3436

@@ -52,6 +54,12 @@ breaks its sign-in and its heartbeat. After the push, check that
5254
`users.status` and `users.online_at`. Also check that `pg_publication_tables`
5355
has no `users` row.
5456

57+
**Push gateway status ([#433](https://github.com/docs-plus/docs.plus/issues/433)).** If you
58+
polled `GET /health/push`, call `GET /api/admin/push/gateway` with an admin
59+
token. That route always answers `200`, so read the VAPID and queue flags in
60+
the body, not the status code. Move or delete any uptime probe of the old
61+
path, because it now answers `404`.
62+
5563
**Media objects ([#408](https://github.com/docs-plus/docs.plus/issues/408)).** New S3 uploads are
5664
private. Objects written before this release stay `public-read` until you
5765
change them. Deploy the server first, then set the existing objects private.
@@ -429,6 +437,12 @@ SMTP_PASS=...
429437

430438
### Fixed
431439

440+
- **The unsubscribe link asks before it changes anything ([#445](https://github.com/docs-plus/docs.plus/issues/445)).**
441+
`GET /api/email/unsubscribe` now checks the token and shows an Unsubscribe
442+
button. It writes nothing, so a mail link scanner can no longer unsubscribe
443+
a reader. The button posts `confirm=yes` to the same URL, and that post
444+
writes. One-click unsubscribe from a mail client is unchanged. Links in
445+
mail already sent still work, and they now open the confirm page.
432446
- **Without Redis, a failed mail is no longer sent twice ([#421](https://github.com/docs-plus/docs.plus/issues/421)).**
433447
The service sent again after an inline failure. Now an inline failure is
434448
final: the row settles `failed`, and the pgmq message is acked. A skipped
@@ -537,6 +551,12 @@ SMTP_PASS=...
537551

538552
### Security
539553

554+
- **Push gateway status needs an admin token ([#433](https://github.com/docs-plus/docs.plus/issues/433)).**
555+
`GET /health/push` is removed and now answers `404`. It showed queue counts
556+
and the VAPID subject with no auth. Admins read the same status on
557+
`GET /api/admin/push/gateway`. That route always answers `200` with the
558+
status, even when push is not set up. `GET /api/email/health` stays public,
559+
because it already returns status only ([#423](https://github.com/docs-plus/docs.plus/issues/423)).
540560
- **Media of a document in Trash stops serving ([#408](https://github.com/docs-plus/docs.plus/issues/408)).**
541561
`GET /api/plugins/hypermultimedia/:documentId/:mediaId` answers `404`
542562
`NOT_FOUND` while the document is in Trash. After Restore, it serves again.

‎apps/hocuspocus.server/CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md). The `### Supab
193193
### Digest Email Links And Counts
194194

195195
- **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.
196197
- **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.
197198
- **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.
198199
- **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.

‎apps/hocuspocus.server/src/api/controllers/admin-stats.controller.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/**
22
* Runs through the service_role client. The browser anon-key is RLS-scoped,
33
* and email/push/email_queue are revoked from `authenticated`, so these
4-
* counts cannot be computed client-side.
4+
* counts cannot be computed client-side. The push gateway status reads process state.
55
*/
66

77
import { adminLogger } from '../../lib/logger'
8+
import { pushGateway } from '../../lib/push'
89
import type { AppContext } from '../../types/hono.types'
910
import { getSupabaseClient } from '../utils/supabase'
1011

@@ -206,6 +207,16 @@ export async function getPushStatsAdmin(c: AppContext) {
206207
}
207208
}
208209

210+
/** Always 200 with the status, so the dashboard reads the VAPID and queue flags from the body. */
211+
export async function getPushGatewayAdmin(c: AppContext) {
212+
try {
213+
return c.json(await pushGateway.getHealth())
214+
} catch (error) {
215+
adminLogger.error({ err: error }, 'Failed to get push gateway status')
216+
return c.json({ error: 'Failed to fetch push gateway status' }, 500)
217+
}
218+
}
219+
209220
export async function getPushPipelineAdmin(c: AppContext) {
210221
const supabase = getSupabaseClient()
211222
if (!supabase) return c.json({ error: 'Supabase not configured' }, 500)

‎apps/hocuspocus.server/src/api/controllers/health.controller.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { pushGateway } from '../../lib/push'
21
import type { HealthCheckResult } from '../../types'
32
import type { AppContext } from '../../types/hono.types'
43
import * as healthService from '../services/health.service'
@@ -45,10 +44,3 @@ export const checkSupabaseHealth = async (c: AppContext): Promise<Response> => {
4544

4645
return c.json(result, statusCode)
4746
}
48-
49-
export const checkPushHealth = async (c: AppContext): Promise<Response> => {
50-
const result = await pushGateway.getHealth()
51-
const statusCode = result.vapid_configured && result.queue_connected ? 200 : 503
52-
53-
return c.json(result, statusCode)
54-
}

‎apps/hocuspocus.server/src/api/email.ts‎

Lines changed: 69 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
renderUnsubscribePage
1313
} from '@docs.plus/email-templates'
1414
import { zValidator } from '@hono/zod-validator'
15-
import { Hono } from 'hono'
15+
import { type Context, Hono } from 'hono'
1616
import { z } from 'zod'
1717

1818
import { config } from '../config/env'
@@ -22,7 +22,7 @@ import { emailGateway } from '../lib/email'
2222
import { recordEmailBounce } from '../lib/email/bounces'
2323
import { emailLogger } from '../lib/logger'
2424
import { getServiceRoleClient } from '../lib/supabase'
25-
import { verifyUnsubscribeToken } from '../lib/unsubscribeToken'
25+
import { type UnsubscribePayload, verifyUnsubscribeToken } from '../lib/unsubscribeToken'
2626
import {
2727
emailBounceSchema,
2828
sendDigestEmailSchema,
@@ -352,6 +352,18 @@ interface UnsubscribeResult {
352352
type UnsubscribeOutcome =
353353
{ status: 'ok'; result: UnsubscribeResult } | { status: 'unconfigured' } | { status: 'failed' }
354354

355+
const INVALID_TOKEN_MESSAGE = 'This unsubscribe link is invalid or has expired.'
356+
357+
/** The one token check. GET and POST both call it, so they cannot disagree on a token. */
358+
function checkUnsubscribeToken(token: string): UnsubscribePayload | 'unconfigured' | null {
359+
const secret = config.email.unsubscribeSecret
360+
if (!secret) {
361+
emailLogger.error('EMAIL_UNSUBSCRIBE_SECRET is not set — cannot verify unsubscribe tokens')
362+
return 'unconfigured'
363+
}
364+
return verifyUnsubscribeToken({ token, secret })
365+
}
366+
355367
/**
356368
* Identity comes from the token, which is verified here rather than in Postgres.
357369
* A missing secret and a missing client are both `unconfigured`: neither is the
@@ -363,21 +375,13 @@ async function processUnsubscribe(token: string): Promise<UnsubscribeOutcome> {
363375
emailLogger.error('Supabase service-role client is not configured for unsubscribe')
364376
return { status: 'unconfigured' }
365377
}
366-
const secret = config.email.unsubscribeSecret
367-
if (!secret) {
368-
emailLogger.error('EMAIL_UNSUBSCRIBE_SECRET is not set — cannot verify unsubscribe tokens')
369-
return { status: 'unconfigured' }
370-
}
371378

372-
const payload = verifyUnsubscribeToken({ token, secret })
379+
const payload = checkUnsubscribeToken(token)
380+
if (payload === 'unconfigured') return { status: 'unconfigured' }
373381
if (!payload) {
374382
return {
375383
status: 'ok',
376-
result: {
377-
success: false,
378-
error: 'invalid_token',
379-
message: 'This unsubscribe link is invalid or has expired.'
380-
}
384+
result: { success: false, error: 'invalid_token', message: INVALID_TOKEN_MESSAGE }
381385
}
382386
}
383387

@@ -393,21 +397,10 @@ async function processUnsubscribe(token: string): Promise<UnsubscribeOutcome> {
393397
}
394398

395399
/**
396-
* One-click unsubscribe from an email link. The token is the only credential —
397-
* there is no session — and every outcome renders an HTML page, never a JSON error.
400+
* Applies the unsubscribe, then renders the outcome as a page, never a JSON error.
401+
* It writes, so only a POST may call it (#445).
398402
*/
399-
emailRouter.get('/unsubscribe', async (c) => {
400-
const token = c.req.query('token')
401-
402-
if (!token) {
403-
return c.html(
404-
renderUnsubscribePage({
405-
title: 'Invalid link',
406-
message: 'This unsubscribe link is missing required information.'
407-
})
408-
)
409-
}
410-
403+
async function applyUnsubscribeAsPage(c: Context, token: string): Promise<Response> {
411404
try {
412405
const outcome = await processUnsubscribe(token)
413406

@@ -453,7 +446,7 @@ emailRouter.get('/unsubscribe', async (c) => {
453446
return c.html(
454447
renderUnsubscribePage({
455448
title: 'Unable to unsubscribe',
456-
message: result.message || 'The unsubscribe link is invalid or has expired.'
449+
message: result.message || INVALID_TOKEN_MESSAGE
457450
})
458451
)
459452
} catch (err) {
@@ -465,18 +458,64 @@ emailRouter.get('/unsubscribe', async (c) => {
465458
})
466459
)
467460
}
461+
}
462+
463+
/**
464+
* The email link. Mail link scanners open every link, so GET only checks the
465+
* token and asks. It never writes (#445). The token is the only credential.
466+
*/
467+
emailRouter.get('/unsubscribe', (c) => {
468+
const token = c.req.query('token')
469+
470+
if (!token) {
471+
return c.html(
472+
renderUnsubscribePage({
473+
title: 'Invalid link',
474+
message: 'This unsubscribe link is missing required information.'
475+
})
476+
)
477+
}
478+
479+
const payload = checkUnsubscribeToken(token)
480+
if (payload === 'unconfigured') {
481+
return c.html(
482+
renderUnsubscribePage({
483+
title: 'Service error',
484+
message: 'Unable to process your request. Please try again later.'
485+
})
486+
)
487+
}
488+
if (!payload) {
489+
return c.html(
490+
renderUnsubscribePage({ title: 'Unable to unsubscribe', message: INVALID_TOKEN_MESSAGE })
491+
)
492+
}
493+
494+
return c.html(
495+
renderUnsubscribePage({
496+
title: 'Unsubscribe?',
497+
message: 'Press Unsubscribe to stop these emails. Nothing changes until you press it.',
498+
confirm: true
499+
})
500+
)
468501
})
469502

470503
/**
471-
* RFC 8058 List-Unsubscribe-Post: mail clients POST "List-Unsubscribe=One-Click"
472-
* in the body, so this answers JSON where the GET above answers HTML.
504+
* The only write. The confirm page's form sends `confirm=yes` and gets a page.
505+
* Any other body is an RFC 8058 mail client, which sends
506+
* "List-Unsubscribe=One-Click" and gets JSON.
473507
*/
474508
emailRouter.post(
475509
'/unsubscribe',
476510
zValidator('query', z.object({ token: z.string().min(1) }), houseEnvelopeHook),
477511
async (c) => {
478512
const { token } = c.req.valid('query')
479513

514+
const form: Record<string, unknown> = await c.req.parseBody().catch(() => ({}))
515+
if (form.confirm === 'yes') {
516+
return applyUnsubscribeAsPage(c, token)
517+
}
518+
480519
try {
481520
const outcome = await processUnsubscribe(token)
482521

‎apps/hocuspocus.server/src/api/routers/admin.router.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ admin.get(
5151
admin.get('/stats/push', adminController.getPushStatsAdmin)
5252
admin.get('/stats/push/pipeline', adminController.getPushPipelineAdmin)
5353
admin.get('/push/subscriptions', adminController.getPushSubscriptionsRaw)
54+
admin.get('/push/gateway', adminController.getPushGatewayAdmin)
5455
admin.get('/system/table-sizes', adminController.getTableSizes)
5556
admin.get('/users', adminController.listUsers)
5657
admin.get('/users/notification-subs', adminController.getUserNotificationSubs)

‎apps/hocuspocus.server/src/api/routers/health.router.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,5 @@ health.get('/live', healthController.checkLiveness)
1515
health.get('/database', healthController.checkDatabaseHealth)
1616
health.get('/redis', healthController.checkRedisHealth)
1717
health.get('/supabase', healthController.checkSupabaseHealth)
18-
health.get('/push', healthController.checkPushHealth)
1918

2019
export default health

‎apps/hocuspocus.server/src/modules/openapi/domain/components.ts‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -144,16 +144,6 @@ const overallHealth: JsonSchema = {
144144
required: ['status', 'timestamp', 'services']
145145
}
146146

147-
const pushHealth: JsonSchema = {
148-
type: 'object',
149-
description: 'Push gateway status. 200 when VAPID is configured and the queue is connected.',
150-
properties: {
151-
vapid_configured: { type: 'boolean' },
152-
queue_connected: { type: 'boolean' }
153-
},
154-
additionalProperties: true
155-
}
156-
157147
/** Row shape is a Prisma projection plus a joined owner profile — not modelled field-by-field. */
158148
const documentSummary: JsonSchema = {
159149
type: 'object',
@@ -287,7 +277,6 @@ export const components: OpenApiComponents = {
287277
RateLimitError: rateLimitError,
288278
HealthCheckResult: healthCheckResult,
289279
OverallHealth: overallHealth,
290-
PushHealth: pushHealth,
291280
DocumentSummary: documentSummary,
292281
DocumentListPage: documentListPage,
293282
TiptapDoc: toJsonSchema(tiptapDocSchema),

0 commit comments

Comments
 (0)