feat(email): admin signup notifications — free + Pro funnel signal#47
Draft
SoapyRED wants to merge 3 commits into
Draft
feat(email): admin signup notifications — free + Pro funnel signal#47SoapyRED wants to merge 3 commits into
SoapyRED wants to merge 3 commits into
Conversation
Diagnosis-only commit. Lands BEFORE any code is written per sprint hard
rule.
Three signup flows traced:
1. Legacy free-tier (POST /api/keys/register) — durable-create at
route.ts:73-79 (kv.set 'key:${apiKey}' + 'email:${email}'). The
route short-circuits returning users earlier at lines 60-71, so
reaching the durable-create branch always means first-time signup.
Data available: email, optional use_case, IP, headers (referer,
x-vercel-ip-country, user-agent), timestamp.
2. Magic-link free-tier (GET /api/auth/verify) — durable-create at
verify/route.ts:31 via createUser() from lib/auth/kv.ts:32-49.
createUser is idempotent (returns existing user if found), so the
notification must fire only when existing was null. Cleanest hook:
double-read getUser() in verify/route.ts before createUser() so the
route can branch on `existed`.
3. Pro-tier (POST /api/stripe/webhook, checkout.session.completed) —
hook after webhook/route.ts:62. Data: session.metadata.email or
session.customer_email, customer ID, session.customer_details.
address.country (when present). No IP/referer (server-to-server).
CRITICAL out-of-scope bug flagged: safeUpdateUserPlan at
app/api/stripe/webhook/route.ts:30-36 recurses into itself instead of
calling updateUserPlan from the import on line 3. Pro KV plan upgrades
have not been persisting since this code shipped. Stripe still acks
with 200 because the try/catch swallows the stack overflow. One-line
fix (rename inner call). Separate PR + backfill sweep needed. NOT in
this PR's scope.
Also flagged out-of-scope: two parallel free-tier KV namespaces never
consolidated (legacy 'key:${apiKey}' + 'email:${email}' vs magic-link
'user:${email}' + 'key:${apiKey}', different apiKey formats, separate
ADR needed).
Existing Resend infrastructure surveyed: lib/email/billing.ts is the
pattern reference (lazy getResend, (resend, args) helper signature,
FROM 'FreightUtils Billing <noreply@freightutils.com>', ADMIN_TO
'contact@freightutils.com'). Non-blocking via .catch + void.
Phase 2 design decisions:
- New file lib/email/admin-signup.ts mirroring billing.ts pattern.
- Helpers: sendAdminFreeSignupEmail, sendAdminProSignupEmail.
- Subjects: "[FreightUtils] New free signup: {email}" /
"[FreightUtils] New Pro signup: {email}".
- Body (text only): email, tier, signup time UTC + UK, country (IP
geo or Stripe address), referer (free flows only), source (legacy
form / magic-link verify / stripe checkout), optional use_case.
- ADMIN_NOTIFICATIONS_ENABLED env: default ON, disabled only when ===
'false'. Killable via Vercel dashboard without rebuild.
- ADMIN_NOTIFICATION_EMAIL env: optional override of ADMIN_TO.
- Rate-limit: KV per-hour bucket admin-signup-notifs:${YYYY-MM-DDTHH},
cap 30/h, console.warn fallback above cap. No full digest mode.
- Non-blocking: .catch on every call site, void to discard the
promise; signup ack independent of admin-email success.
Phase 2 (helper + hook-point wirings) + Phase 3 (kill-switch + rate
limit + non-blocking) from the sprint. Phase 1 diagnosis is the prior
commit at docs/audit/admin-signup-notifications-2026-05-20.md.
lib/email/admin-signup.ts (new):
Mirrors lib/email/billing.ts pattern — lazy Resend init via reused
getResend(), (resend, args) helpers, no module-level side effects.
Two exports:
- notifyAdminFreeSignup({ email, source, useCase?, country?,
referer?, signupTimeIso? })
- notifyAdminProSignup({ email, country?, signupTimeIso? })
Both honour the kill-switch internally (ADMIN_NOTIFICATIONS_ENABLED
=== 'false' disables; any other value including unset keeps it on).
Both rate-limit via KV bucket `admin-signup-notifs:${YYYY-MM-DDTHH}`
capped at 30/hour — beyond cap, console.warn and skip; missing the
cap is preferable to losing the notification, so KV-read failure
fails open. Recipient defaults to contact@freightutils.com,
overridable via ADMIN_NOTIFICATION_EMAIL (e.g. mcristoiu@gmail.com
for direct-to-personal).
Subject lines match sprint spec: "[FreightUtils] New free signup:
{email}" / "[FreightUtils] New Pro signup: {email}". Text body
carries: email, tier, UTC + UK timestamps (Intl.DateTimeFormat
en-GB Europe/London), country, referer (free only), source, optional
use_case. No PII beyond email — no payment details, no Stripe IDs,
no API key value.
Three call sites wired:
app/api/keys/register/route.ts — after the existing kv.set pair on
lines 78-79 ('key:${apiKey}' + 'email:${email}'). The route
already short-circuits returning users earlier (lines 60-71), so
reaching here is always first-time signup. country from
x-vercel-ip-country header, referer from referer header. Uses the
record.created ISO already generated for KV.
app/api/auth/verify/route.ts — pre-read getUser before createUser so
the route can distinguish first-time signup from returning sign-in.
Notification fires only when existed === false. createUser is
itself idempotent so the second-read is cheap. country/referer from
request headers.
app/api/stripe/webhook/route.ts — on checkout.session.completed, after
the existing safeUpdateUserPlan call. country from
session.customer_details.address.country when Stripe collected it
during checkout. No IP/referer (server-to-server).
Non-blocking guarantee at every site: `void notify(...).catch(err =>
console.error(...))`. The signup ack returns 200 even if Resend, KV,
or the helper itself throws. Verified by static contract test in
Phase 4.
OUT-OF-SCOPE NOTE (untouched here, flagged in audit doc):
app/api/stripe/webhook/route.ts:30-36 — safeUpdateUserPlan recurses
into itself instead of calling updateUserPlan from the import on
line 3. Pro KV plan upgrades have silently failed since this shipped.
Separate PR + backfill sweep needed. This sprint adds the notification
ONLY; the new notifyAdminProSignup call runs after the bogus
safeUpdateUserPlan call so the email still fires regardless of the
KV-persistence bug.
… + STATE (Phase 4)
Phase 4 from the sprint — verification + paperwork.
scripts/admin-signup-notify-test.mjs (new):
Static contract test enforcing the non-blocking guarantee + helper
shape + kill-switch logic. Pure Node, no deps (matches lint-*.mjs
pattern in repo).
Asserts:
1. lib/email/admin-signup.ts exports notifyAdminFreeSignup +
notifyAdminProSignup; honours ADMIN_NOTIFICATIONS_ENABLED ===
'false' kill-switch; honours ADMIN_NOTIFICATION_EMAIL override;
defaults to contact@freightutils.com; declares HOURLY_CAP;
uses the documented `admin-signup-notifs:${YYYY-MM-DDTHH}` KV
bucket key shape.
2. Kill-switch boolean logic in isolation: unset → on; "true" →
on; "false" → off; empty → on.
3. Every call site in app/api/keys/register/route.ts,
app/api/auth/verify/route.ts, app/api/stripe/webhook/route.ts
wraps the notify call in BOTH `void` AND `.catch(...)`. Either
guard alone is insufficient. The combined wrapping is what
guarantees signup ack independent of Resend/KV failure.
Pass/fail exit code → wired into `npm run lint`. Runs in <50ms.
package.json:
New lint:admin-signup-notify script; added to the `lint` chain so
CI catches a future regression of the call-site contract.
CHANGELOG.md:
New 2026-05-20 entry tagged Internal — admin signup notifications
live, kill-switch, rate-limit, audit cross-reference, out-of-scope
flag for the Stripe webhook recursion bug.
lib/changelog-data.ts:
Parallel /changelog page entry, tag 'Bug Fix' (the user-visible
framing — Soap was previously blind to the API funnel; this PR
removes that blind spot). Both data sources updated per FAULT 5
hard rule (until they collapse to one source).
STATE.md:
Last-updated 16 May → 20 May. New line in the Observability section
documenting: notification recipients, kill-switch env, rate-limit,
helper location, audit cross-reference, out-of-scope Stripe
webhook recursion bug.
FAULT 5 checklist applied (most items N/A — no new pages, no new API
endpoints, no new MCP tools, no displayed-number changes):
✓ CHANGELOG.md entry added
✓ /changelog page (lib/changelog-data.ts) renders new entry
✓ STATE.md updated
✓ Audit doc landed first (separate commit)
✗ N/A: siteStats.ts, app/sitemap.ts, public/openapi.json,
/api-docs page, nav dropdown, homepage tool grid, MCP
registration, footer links, freightutils-mcp README, npm bump,
Postman, tool-page word count, withAuditRest (no new routes),
generateMetadata (no new pages), indexnow-submit (no new URLs)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced May 21, 2026
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.
Summary
Sprint
admin-signup-notifications. Soap has been blind to the API funnel — Stripe-side notifications cover paid signups, but free-tier API key issuance (the actual top of the funnel) fired no internal alert. This PR adds a transactional admin email on every new free-tier signup AND on every Pro-tier checkout completion.Three commits:
d4b1492 docs(audit)— Phase 1 diagnosis (lands first per hard rule). Traces three signup flows, identifies durable-creation moments, surveys existing Resend infrastructure, designs the helper/hook contract, and flags two out-of-scope findings (see below).191e022 feat(email)—lib/email/admin-signup.tshelper + three call-site wirings (legacy/api/keys/register, magic-link/api/auth/verify, Stripe webhook/api/stripe/webhook).2bedd73 chore(release-hygiene)— Forced-error contract test, lint-chain wiring,CHANGELOG.md,lib/changelog-data.ts,STATE.md.What ships
Helper (
lib/email/admin-signup.ts) — mirrorslib/email/billing.ts:notifyAdminFreeSignup({ email, source, useCase?, country?, referer?, signupTimeIso? })notifyAdminProSignup({ email, country?, signupTimeIso? })Recipient:
contact@freightutils.comby default; overridable viaADMIN_NOTIFICATION_EMAIL(e.g.mcristoiu@gmail.comfor direct-to-personal). Kill-switch:ADMIN_NOTIFICATIONS_ENABLED=falsesilences without a deploy. Rate-limit: 30/h via KV bucketadmin-signup-notifs:${YYYY-MM-DDTHH}; beyond cap →console.warnand skip.Subject:
[FreightUtils] New free signup: {email}[FreightUtils] New Pro signup: {email}Body (text only, mirrors billing.ts admin-email style): email, tier, UTC + UK timestamps (
Intl.DateTimeFormat 'en-GB' Europe/London), country (IP geo for free flows, Stripe billing address for Pro), referer (free flows only — Stripe is server-to-server), source (legacy-form/magic-link-verify/stripe-checkout), optionaluse_case. No PII beyond email; no payment details, no Stripe IDs, no API key values.Three hook points:
app/api/keys/register/route.tskv.set('key:...')+kv.set('email:...')app/api/auth/verify/route.tscreateUserIF pre-getUserreturned nullapp/api/stripe/webhook/route.tscheckout.session.completedaftersafeUpdateUserPlan+console.log('UPGRADE_TO_PRO:')Non-blocking guarantee: every call site uses
void notify(...).catch(err => console.error(...)). The signup ack returns 200 even if Resend, KV, or the helper itself throws. Verified byscripts/admin-signup-notify-test.mjs, which:unset → on;"true" → on;"false" → off; empty → on).voidAND.catch(...)— either alone is insufficient.Lint-chain wired:
npm run lint:admin-signup-notifyruns as part ofnpm run lint.Out-of-scope flags (please surface to a separate PR)
🚨 Critical —
safeUpdateUserPlaninfinite recursionapp/api/stripe/webhook/route.ts:30-36:Should call
updateUserPlan(imported on line 3) — instead recurses into itself. The try/catch swallows the stack overflow, Stripe still acks with 200, but Pro KV plan upgrades have not been persisting since this code shipped. Customers paying Stripe are stuck atplan: 'free'in KV. Theconsole.log('UPGRADE_TO_PRO:')line fires regardless, which is why the bug went unnoticed in Vercel logs.This sprint's new
notifyAdminProSignupfires correctly (no dependency onsafeUpdateUserPlansuccess), so the admin email will land even with the bug present — but Soap should fix this + run a Stripe-cross-reference backfill sweep in a follow-up PR. One-line fix — rename the inner call.Two parallel free-tier KV namespaces
app/api/keys/register/route.tswriteskey:${apiKey}+email:${email}with the legacy{ email, plan, created, use_case? }schema.lib/auth/kv.tscreateUserwritesuser:${email}+key:${apiKey}with theUserinterface. DifferentapiKeyformats (fu_${hex32}vsfu_live_${nanoid24}). A user can exist in one namespace and not the other. The Pro upgrade only updates theuser:${email}namespace. Consolidation needs its own ADR — flagged in audit doc.Self-test plan (post-deploy)
RESEND_API_KEY+ (optionally)ADMIN_NOTIFICATION_EMAIL+ADMIN_NOTIFICATIONS_ENABLEDare set on Vercel production.POST /api/keys/registerwith a throwaway email → confirm Soap inbox receives[FreightUtils] New free signup: {email}within seconds.POST /api/auth/loginthen click the magic link → confirm Soap inbox receives[FreightUtils] New free signup: {email}withSource: magic-link-verify.[FreightUtils] New Pro signup: {email}(this will hit the recursion bug for the KV update, but the email is independent).ADMIN_NOTIFICATIONS_ENABLED=falsein Vercel → re-test free signup → confirm no email arrives, signup ack still 200.FAULT 5 checklist
Most items N/A — no new pages, no new API endpoints, no new MCP tools, no displayed-number changes. Items that apply:
CHANGELOG.mdentry added/changelogpage (lib/changelog-data.ts) renders the new entryLast updated: 20 May 2026+ new line in Observability)d4b1492)scripts/admin-signup-notify-test.mjs)siteStats.ts,app/sitemap.ts,public/openapi.json,/api-docspage, nav dropdown, homepage tool grid, MCP registration, footer links,freightutils-mcpREADME, npm bump, Postman, tool-page word count,withAuditRest(no new routes),generateMetadata(no new pages),indexnow-submit(no new URLs)Sprint exit criteria
docs/audit/admin-signup-notifications-2026-05-20.mdcommitted first (commitd4b1492).ADMIN_NOTIFICATIONS_ENABLEDhonoured.scripts/admin-signup-notify-test.mjs).Audit doc:
docs/audit/admin-signup-notifications-2026-05-20.mdGenerated by Claude Code