Skip to content

fix(watches): confirm third-party notify addresses; harden token parsing - #14

Merged
Aswinmcw merged 2 commits into
mainfrom
fix/watch-recipient-confirmation
Aug 14, 2026
Merged

fix(watches): confirm third-party notify addresses; harden token parsing#14
Aswinmcw merged 2 commits into
mainfrom
fix/watch-recipient-confirmation

Conversation

@Aswinmcw

Copy link
Copy Markdown
Member

Two fixes from a review pass. Stacked on #13 — base is feat/auth-broker-migration, so this diff shows only the two fix commits. GitHub will retarget it to main once #13 merges.


1. A watch could email any address, with no consent from the recipient

POST /api/watches took email from the request body and activated the watch on the spot — createWatch() then confirmWatch() back to back. PATCH /api/watches/[id] could repoint an existing watch the same way. Nothing checked that the recipient wanted mail from us.

Signup is open by default (emailAllowedForSite fails open to public when ACCESS_MODE is unset), so one verified account was the entire cost of entry. From there: register watches against a victim's address and generate mail from our Resend domain — a "watch created" notice immediately, then one per status change, indefinitely. Also a deliverability problem, since those land as unsolicited mail under our sending identity.

Now:

notify address behaviour
the account's own activates immediately, as before
anyone else's watch parks at pending + confirmation link emailed

The poller only reads status='active', so an unconfirmed watch sends nothing. PATCH follows the same rule — repointing at a third party sends the watch back to pending; moving it back to your own address releases it. Cancelled/completed watches can't be revived by an edit (the status update is scoped to in-flight rows).

This revives /api/watches/confirm and confirmEmail(), both of which were already in the tree and orphaned when the double opt-in was dropped — that was review item #2, closed by the same change.

The ADMIN_TOKEN curl path is deliberately unchanged: that token is the operator's own credential, not something a signup can obtain.

Also caps an account at 50 in-flight watches (429 watch_limit_reached), bounding both the mail one account can generate and its share of the poller's 50-per-tick budget.

2. Malformed tokens returned 500 instead of "invalid link"

In src/lib/tokens.ts, b64urlDecode(sig) sat outside the try/catch, so atob() threw a DOMException on any signature that isn't valid base64url — a link a mail client wrapped or truncated came back as a 500 rather than the "This link is no longer valid" page the route already renders. The extracted @aswincloud/auth copy of this file guards the decode; ours had drifted.


Verification

Against a local D1 + dev server, with Resend pointed at an invalid key so nothing could actually be sent:

own address           → 201 {status:"active"}, row active
third-party address   → row never reaches 'active'
pending row           → excluded from the poller's due-query
confirm link clicked  → row flips to active
PATCH → third party   → row parked at 'pending'
PATCH label only      → stays active
51st watch            → 429 watch_limit_reached

Token parsing, before → after on the running route:

token=abc.!!!!  HTTP 500 → HTTP 400
token=%%%.%%%   HTTP 500 → HTTP 400
token=garbage   HTTP 400 → HTTP 400   (no ".", rejected earlier)

Plus a table of valid/expired/wrong-purpose/wrong-secret/malformed cases run against the real file: 4 malformed inputs threw DOMException before, all return null after. tsc --noEmit passes.

Worth knowing

  • If the confirmation email fails to send, POST cancels the just-created watch (it could never be confirmed, and a dead row shouldn't count against the cap) and returns 502. On PATCH the watch stays parked at pending with the new address — no alerts go anywhere, and re-saving your own address reactivates it.
  • Unconfirmed watches are never purged, so they sit against a user's cap until cancelled. Fine at a 50 ceiling; a sweep for stale pending rows would be the tidier long-term answer.

@Aswinmcw
Aswinmcw requested review from a team and Aswin-coder as code owners August 14, 2026 09:58
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
shiptrack-poller b0deeb4 Aug 14 2026, 09:58 AM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
shiptrack b0deeb4 Aug 14 2026, 09:58 AM

@Aswincloud-Bot Aswincloud-Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: @Aswinmcw is a member of @Aswincloud/admins.

@Aswinmcw
Aswinmcw force-pushed the feat/auth-broker-migration branch from 9eeaff0 to b7ad19a Compare August 14, 2026 10:08
Aswin-coder and others added 2 commits August 14, 2026 10:10
b64urlDecode(sig) sat outside the try/catch, so atob() threw a
DOMException on any signature that isn't valid base64url. A link a mail
client wrapped or truncated came back as a 500 instead of the "This link
is no longer valid" page the unsubscribe/confirm routes are written to
render.

Verified against the running route: token=abc.!!!! returned HTTP 500
before, HTTP 400 after. The extracted @aswincloud/auth copy of this file
already guards the decode this way; this brings ours back in line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A watch took its notify address straight from the request body and was
activated on the spot (createWatch → confirmWatch), so any signed-in
account could point watches at an address that never agreed to hear from
us and generate mail from our sending domain — one notice immediately,
then one per status change. PATCH could repoint an existing watch the
same way. Signup is open by default (ACCESS_MODE unset ⇒ public), so one
verified account was the whole cost of entry.

Now the account's own address still activates immediately — it's
self-evidently consented — and anything else parks the watch at 'pending'
and emails a confirmation link. The poller only reads status='active',
so nothing is sent to an address that hasn't clicked through. PATCH
applies the same rule: repointing at someone else sends the watch back to
'pending'; moving it back to your own address releases it. The confirm
route and confirmEmail template this revives were already in the tree,
orphaned when the double opt-in was dropped.

The ADMIN_TOKEN curl path is unchanged — that token is the operator's own
credential, not something a signup can obtain.

Also caps an account at 50 in-flight watches (429 past that), which
bounds both the mail one account can generate and its share of the
poller's 50-per-tick budget.

Verified end-to-end against a local D1 + dev server:
  - own address            → 201 {status:"active"}, row active
  - third-party address    → row never reaches 'active'
  - pending row            → excluded from the poller's due-query
  - confirm link clicked   → row flips to active
  - PATCH → third party    → row parked at 'pending'
  - PATCH label only       → stays active
  - 51st watch             → 429 watch_limit_reached

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Aswinmcw
Aswinmcw force-pushed the fix/watch-recipient-confirmation branch from b0deeb4 to 3f57c8b Compare August 14, 2026 10:11
@Aswinmcw
Aswinmcw changed the base branch from feat/auth-broker-migration to main August 14, 2026 10:12
@Aswinmcw
Aswinmcw added this pull request to the merge queue Aug 14, 2026
Merged via the queue into main with commit 5acba62 Aug 14, 2026
1 check passed
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.

3 participants