Skip to content

fix: subscription follow-ups to #215 - #216

Merged
KingPin merged 4 commits into
mainfrom
fix/subscription-followups
Oct 30, 2026
Merged

KingPin merged 4 commits into
mainfrom
fix/subscription-followups

Conversation

@KingPin

@KingPin KingPin commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Follow-up review of the subscription system after #215.

Fixes

  • Pending cap no longer locks an address out for good. countPendingSubscriptionsForEmail counted every never-confirmed row and nothing deletes them, so five forged subscribes left an address unable to subscribe by email again. It now counts live pending rows from the last 7 days (PENDING_WINDOW_MS); cancelled pending rows no longer hold a slot. No migration: once cancelled rows are excluded, created_at is the pending-since time.
  • Confirm link retires after unsubscribe. A confirmed-then-cancelled row's kept-alive confirm link rendered "you're subscribed". getSubscriptionByConfirmToken now resolves nothing for that row, and gets the same 64-hex shape guard as the unsubscribe lookup, so junk tokens on the un-rate-limited confirm GET cost no D1 read.

Cleanup

  • Referrer-Policy relaxation now reuses SELF_ORIGIN_POST_PATHS via isSelfOriginFormPath in lib/cors.ts instead of a second regex in index.ts. /one-click (no form) is back on no-referrer.
  • Stale line-number references and the "unindexed token lookup" note in api.subscriptions.ts.
  • docs/ANTISPAM.md describes the windowed cap.

Testing

lint, typecheck, test (3278), manifest:check, build, size pass locally. The four new behavior tests fail on main without the source changes. Each commit typechecks and passes the subscription suites on its own.

countPendingSubscriptionsForEmail counted every never-confirmed row, and
nothing ever deletes one, so a stranger naming an address on five posts
left it unable to subscribe by email again. Rows the reader cancelled
also kept holding a slot.

The cap now counts live pending rows created in the last seven days.
created_at is a sound "pending since" once cancelled rows are excluded:
the only upsert branch that turns an old row back into a pending one
leaves unsubscribed_at set.
confirmSubscription keeps the confirm token alive so a re-click after a
mail-client prefetch still lands on the success page. After the reader
unsubscribed, that same link went on rendering "you're subscribed".
getSubscriptionByConfirmToken now resolves nothing for a confirmed and
then cancelled row; re-subscribing mints a fresh link as before.

It also gets the same 64-hex shape guard as getSubscriptionByToken, so
junk tokens on the un-rate-limited confirm GET cost no D1 read.
The Referrer-Policy relaxation and the self-origin CSRF check have to
cover the same form pages, but index.ts kept its own regex. Export
isSelfOriginFormPath from lib/cors.ts and use it in both places, so a
new form can't land in one list and not the other. The RFC 8058
one-click path, which serves no form, is back on no-referrer.
@KingPin
KingPin merged commit 0fcb28c into main Oct 30, 2026
6 checks passed
@KingPin
KingPin deleted the fix/subscription-followups branch October 30, 2026 18:23
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