fix(watches): confirm third-party notify addresses; harden token parsing - #14
Merged
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
shiptrack-poller | b0deeb4 | Aug 14 2026, 09:58 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
shiptrack | b0deeb4 | Aug 14 2026, 09:58 AM |
Aswincloud-Bot
approved these changes
Aug 14, 2026
Aswincloud-Bot
left a comment
There was a problem hiding this comment.
Auto-approved: @Aswinmcw is a member of @Aswincloud/admins.
Aswinmcw
force-pushed
the
feat/auth-broker-migration
branch
from
August 14, 2026 10:08
9eeaff0 to
b7ad19a
Compare
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
force-pushed
the
fix/watch-recipient-confirmation
branch
from
August 14, 2026 10:11
b0deeb4 to
3f57c8b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tomainonce #13 merges.1. A watch could email any address, with no consent from the recipient
POST /api/watchestookemailfrom the request body and activated the watch on the spot —createWatch()thenconfirmWatch()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 (
emailAllowedForSitefails open topublicwhenACCESS_MODEis 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:
pending+ confirmation link emailedThe poller only reads
status='active', so an unconfirmed watch sends nothing.PATCHfollows the same rule — repointing at a third party sends the watch back topending; 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/confirmandconfirmEmail(), 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_TOKENcurl 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, soatob()threw aDOMExceptionon 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/authcopy 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:
Token parsing, before → after on the running route:
Plus a table of valid/expired/wrong-purpose/wrong-secret/malformed cases run against the real file: 4 malformed inputs threw
DOMExceptionbefore, all returnnullafter.tsc --noEmitpasses.Worth knowing
POSTcancels the just-created watch (it could never be confirmed, and a dead row shouldn't count against the cap) and returns 502. OnPATCHthe watch stays parked atpendingwith the new address — no alerts go anywhere, and re-saving your own address reactivates it.pendingrows would be the tidier long-term answer.