/checkout/pro and /checkout/scale failed with "Plan cannot be purchased"
for every user since the Next 16 upgrade (ea1b00c).
The checkout page still typed `params` as a plain object and read
`params.planName` synchronously. Next 16 removed the sync compat shim, so
the value was undefined, axios dropped the key, and the API ran
findOne({ name: undefined }). Mongoose 9 returns null for that where
older versions stripped the key, so the guard threw a message about a
misconfigured plan for a request that never named one. Plan data and
Polar product ids were correct throughout.
- unwrap params with React's use() and drop the `as string` cast that
suppressed the type error
- split the conflated guard into PLAN_NAME_REQUIRED, PLAN_NOT_FOUND and
the existing unpurchasable case, each logged server side
- stop retrying a rejected checkout twice: a 400 is not transient
- mark the Polar product id indexes sparse, they have been failing to
build with E11000 on every boot because most plans leave them unset
Checkout now defaults to yearly. A URL that names an interval still
redirects straight through, so the marketing funnel is unchanged; the
in-app CTAs that name none get an interval chooser with yearly
preselected instead of a silent monthly default.
Also redesigns the checkout states, which were the only place in the
dashboard hardcoding gray-100/white and so rendering a light slab in dark
mode, and the plan picker, which painted the accent on every tier.
Tests: a checkout e2e spec asserting the request body carries planName,
which is what would have caught this, plus API cases pinning the three
guard failures apart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve auth.service.spec.ts by keeping dev's suite and porting the
password-reset lockout coverage from #238 into it.
Harden the lockout from #238 while merging:
- claim each attempt with an atomic findOneAndUpdate $inc, so concurrent
guesses cannot all read the same count and slip past the cap
- match records predating the counter, which have no attempts field
- lift MAX_PASSWORD_RESET_ATTEMPTS to module scope and trim comments
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getDevicesForUser did find({ user }) with no projection, so the device
list endpoint returned the full document, including the fcmToken push
credential and the hardware serial, straight to the browser. Neither is
used by the dashboard or the Android client.
The query now projects both fields out with '-fcmToken -serial'. Only the
user-facing list is narrowed; getDeviceById, which feeds the guards and
internal logic, is untouched.
Guarded by the existing getDevicesForUser test, rewritten to assert the
projection and seen to fail against the pre-change single-argument call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
findActiveApiKeyByClientKey builds a fallback lookup as
new RegExp(`^${prefix}`) from the client-supplied key prefix and runs it
as a Mongo $regex. A prefix containing regex metacharacters compiles to
the wrong pattern or, for something like "((((", throws SyntaxError and
fails the request. On the authentication hot path that is a denial and a
correctness hole.
The prefix is now escaped with escapeRegExp before it reaches the RegExp.
escapeRegExp already existed under gateway with its own spec; it is moved
to src/common so auth and gateway share one copy, and the gateway import
is updated. No behaviour change for the gateway caller.
Guarded by the existing lock (a legitimate key still resolves via the
masked hit and the prefix fallback) plus a new case, written first and
seen to fail against the pre-fix code (it threw "Unterminated group"):
a key with metacharacters now compiles to a literal pattern and resolves
to no match instead of throwing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
validateEmail and validatePassword are async and signal failure by
throwing (a rejected promise). Three callers invoked them without await:
register (both) and changePassword. An unawaited rejected promise is
dropped, so execution continued past the check: registration accepted a
malformed email or an out-of-range password, and a password change
accepted a too-short new password. The validators were effectively dead.
Adding await makes the existing rules take effect at their intended
point. Legitimate clients are unaffected: the web signup and
change-password forms already enforce a valid email and the length
bounds, so this only rejects input the service was always meant to
reject, with a clean 400 instead of persisting bad data.
Guards were written first and seen to fail against the pre-fix code
(register with a bad email or short password still created the user;
changePassword with a short password still saved), then pass after the
await is added. The valid-input paths are asserted to still succeed.
This is the dependency-free half of what a global ValidationPipe would
have covered. The pipe itself is deferred: class-transformer is not
installed, so enabling it now would add a dependency and risk crashing
once any DTO gains a decorator. Recommended as a separate follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
support.service had no tests. These lock both flows against current code:
- createSupportMessage: the 24h rate limit rejects a fourth request
without persisting or emailing; the success path saves the message,
strips the turnstile token before persistence, emails the requester,
and returns success.
- requestAccountDeletion: a missing or invalid user id and an unknown
user are both NotFound; a second request is a Conflict; the success
path records accountDeletionRequestedAt with the reason and emails the
user.
6 tests, all passing against current code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
webhook.service was 1039 lines with zero tests. These specs pin the
parts a regression would hurt most, without changing any code:
- validateDeliveryUrl: accepts normal https; rejects non-http(s) and
malformed URLs; rejects loopback and private hosts including the cloud
metadata IP. This locks the SSRF guard so it cannot silently regress.
- Signing secret validation: a secret under 20 characters is rejected on
both create and update.
- attemptWebhookDelivery signing: the X-Signature header equals an
independently computed HMAC-SHA256 of the JSON payload under the
subscription secret, and the signature changes with the secret.
- Delivery abort: when the subscription is inactive or soft-deleted, the
attempt is marked aborted and saved and no HTTP request is made.
axios is mocked so no request leaves the process; private methods are
exercised through the instance.
16 tests, all passing against current code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
auth.service was 521 lines with only the withoutPassword helper covered.
These specs pin the flows a regression would hurt most, all against the
current unchanged code:
- validateEmail / validatePassword: boundary and malformed-input cases,
including the 6 and 128 character password edges.
- findActiveApiKeyByClientKey: the exact-masked hit path and the legacy
prefix-regex fallback, asserting the revoked-key exclusion is applied on
both lookups. This is the behaviour lock for the upcoming regex-escape
fix: it captures how a legitimate key resolves today.
- generateApiKey: the raw key is returned once, and only a masked value
plus a bcrypt hash of the key are persisted (never the raw key).
- changePassword: a wrong old password is rejected without saving; the
success path replaces the stored hash.
- resetPassword: a missing or non-matching OTP is rejected without saving;
the success path updates the password and closes the reset window.
Note (to be fixed under the refactor part, not here): changePassword
calls validatePassword without await, so the length rule does not
actually block a weak password on change. Left as-is in this behaviour
-lock commit and tracked for a guarded fix.
16 tests, all passing against current code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guards that prevent cross-tenant access had zero tests, which is the
highest-risk gap in the backend: a silent regression here would let one
account reach another account's devices or api keys.
These specs pin the current authz contract without changing any code:
- AuthGuard: valid bearer resolves the user; invalid/expired bearer is
401; a valid x-api-key resolves via findActiveApiKeyByClientKey plus a
matching bcrypt hash and attaches request.apiKey; a non-matching hash,
an unknown key, and no credentials are all rejected; a token that
resolves an id for a user that no longer exists is 401.
- CanModifyDevice / CanModifyApiKey: owner passes; non-owner is rejected
(the cross-tenant case); admin passes regardless; an invalid ObjectId
is 400 before any lookup; a missing record is rejected.
Guards are plain constructor-injected classes, so they are instantiated
directly with mocked services and a minimal ExecutionContext, which is
lighter than a testing module and equally faithful.
17 tests, all passing against the current unchanged guards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comment named endpoints and described prior behaviour, which is more
detail than a public repo needs. The assertions are unchanged and still
enforce the same guarantee.
The remaining comments in this area state forward-looking rules ("never
return this to a client") rather than describing what used to happen,
which is what a contributor actually needs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user schema had no projection on `password` and no select: false, so
every read of a user document loaded the bcrypt hash. Three paths then
sent it to the client:
- GET /auth/who-am-i returns req.user, which the auth guard populates
with an unprojected usersService.findOne. Every session check.
- The login response returns the whole user document.
- The register response returns the freshly created document.
The hash is bcrypt, so it is not directly usable, and this is not a
plaintext leak. It still matters: it hands an attacker unlimited offline
guessing with no rate limit, no lockout and no audit trail, against
whatever password strength the user chose. It also lands anywhere a
response goes, which for this app includes Microsoft Clarity session
recording, plus devtools, browser extensions, error reporting and any
XSS. Password reuse makes a cracked hash a problem beyond this app.
Fixed in two layers. The schema marks the hash select: false, so nothing
loads it by default and any future read is safe by construction. The two
flows that genuinely need it, login and change-password, ask for it
explicitly via usersService.findOneWithPassword.
select: false does not cover a document held in memory, so the login and
register responses also strip it through withoutPassword before
returning. Those were the two paths that had it loaded on purpose or had
just written it.
The schema guard was confirmed to fail against the pre-change schema
before being trusted.
Verified: 61 API tests pass (from 53). Typecheck introduces no new
errors (2 pre-existing before this change, 1 after).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Message history had no way to find an old message. The endpoint only accepted
page, limit and type, so a client-side search box could only ever filter the
20 rows already loaded, which is more misleading than having no search.
getMessages takes an optional `search` and matches it against the message
body, recipient and sender. It composes with the existing type filter.
The term is escaped before it reaches RegExp. This is not theoretical: an
unescaped "(" throws a SyntaxError and fails the request, "." would match any
character instead of a dot, and a nested quantifier is a ReDoS vector. The
escaping is a separate, directly tested unit.
Note on performance: the existing { device, type, receivedAt } index does not
serve a regex $or, so a search is a scan within one device's messages. That is
fine at current per-device volumes; a text index is the follow-up if it stops
being fine.
Frontend: useDeviceMessages takes `search`, joins it to the query key so each
term caches separately, and builds the query with URLSearchParams so terms
containing & or = are encoded rather than corrupting the URL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard account card showed base plan limits and ignored the
per-user custom limits admins can set, so a user whose limits were
raised saw stale numbers. Surface the effective values and make the
overrides legible.
- api: include the effective bulkSendLimit in the current-subscription
usage payload (the only effective limit it was missing)
- subscription card: render the effective limit with the standard plan
value struck through, an amber "Custom" chip and tooltip, a "Custom
limits" header badge, a new Devices tile, and daily/monthly usage
meters (green under 80%, amber approaching, red at the limit; "0 left"
when over the limit in grace)
- add a rolling-window info note: daily resets at 00:00 UTC, monthly is
a rolling 30 days, separate from the subscription start/renewal dates
- compact the card and lay the four limits out as a 2x2 grid
- bulk send: cap CSV rows by the effective bulk limit so a raised
override is honored instead of the base plan limit
- remove the unused account-settings.tsx duplicate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The password-reset flow issues a 6-digit numeric OTP that lives for
20 minutes. The verification path (auth.service.resetPassword) had no
per-record failed-attempt counter and did not invalidate the reset
record on failure, so an attacker who triggered a reset email could
brute-force the OTP in-window (1e6 guesses).
Track a durable 'attempts' counter on the PasswordReset document and
invalidate the record after 5 failed submissions. A correct guess
after lockout is rejected as well.
Polar's subscription.canceled signals a scheduled cancellation (access
continues until the period end), while subscription.revoked is the event
that should actually remove access. The webhook handler was downgrading
immediately on cancel and never handled revoke, so a Pro user dropped to
Free the moment they canceled instead of at their real period end.
- cancelSubscription now records cancelAtPeriodEnd + currentPeriodEnd and
keeps the subscription active instead of deactivating it
- add revokeSubscription for the real downgrade, wired to the
subscription.revoked event in the Polar webhook handler
- add controller + service specs covering event dispatch and the
record-vs-downgrade DB semantics
- add jest moduleNameMapper so specs can resolve src/* path aliases
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2.8 Kotlin client stopped sending `enabled` on registration, so the
backend created devices with the schema default `enabled: false` ("Disabled"),
which users could not activate. Default new registrations to enabled on the
server (so existing 2.8 clients are fixed without an app update), still gated
by the device-limit check, and send `enabled = true` from onboarding.
Also make the gateway toggle always give feedback: show a success toast on
enable/disable and surface the server's reason (e.g. device-limit 429) on
failure instead of silently snapping back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A paid subscriber upgrading (pro -> scale) or downgrading (scale -> pro)
previously got a brand-new Polar checkout, ending up with two live Polar
subscriptions and double billing. Now an active paid subscription is
updated in place via Polar's subscription update API.
- store polarSubscriptionId/polarCustomerId/cancelAtPeriodEnd on
subscriptions (recovered via externalCustomerId for legacy records)
- POST /billing/checkout returns a planChange preview for paid users;
new POST /billing/change-plan executes it (uncancels a scheduled
cancellation first, org-default proration, idempotent with webhooks)
- allow monthly<->yearly interval switches; keep ALREADY_ON_PLAN only
for same plan + same interval; block custom plans (CONTACT_BILLING)
- map Polar 402/403/409 errors to actionable messages; run plan-change
detection before cached checkout-session reuse
- checkout page shows a confirmation screen before applying the change;
account page shows a success toast
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- switchPlan now resolves plan strictly by polarProductId (no name fallback when product ID is provided); throws on unknown product ID
- cancelSubscription: new method that deactivates only the subscription matching the cancelled product, instead of wiping all active subscriptions
- Webhook handler: subscription.created/active/updated no longer hardcodes newPlanName='pro'; subscription.cancelled/canceled use cancelSubscription; subscription.revoked commented out
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add Scale plan ($29.99/mo, 25k SMS/mo, 15 devices) between Pro and
Custom in the subscription priority chain. Update dashboard upgrade
prompts to surface Scale for Pro users approaching their monthly limit,
and expose Scale upgrade links in subscription-info and account-settings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add deviceLimit to plans (default -1 = unlimited) with per-subscription
customDeviceLimit override, resolved in getEffectiveLimits and exposed
via the usage object. Gateway blocks device creation and disabled to
enabled transitions with 429 once the enabled-device count reaches the
limit; already-enabled devices are never affected and the check fails
open on lookup errors. Send a throttled device_limit_reached email
notification and show approaching/reached banners with an upgrade CTA
in the dashboard device list.
Also replace the isYearly checkout field with billingInterval
('monthly' | 'yearly') across DTO, service, and checkout page (legacy
?billing= param still accepted until the marketing site redeploys).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Order the polar products array from the isYearly flag so the chosen
interval is preselected at checkout, forward the ?billing= param from
the checkout page as isYearly, and only reuse cached checkout sessions
that match the requested plan and billing interval and are neither
completed nor abandoned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pnpm v10 is now the npm "latest" tag but generates a different lockfile
format than v9.0 used in this repo, causing --frozen-lockfile to fail.
Replace corepack pnpm@latest with npm install -g pnpm@9 in api and web
Dockerfiles. Also bump docker/* actions to v3/v6 to resolve Node.js 20
deprecation warnings ahead of the June 2026 forced migration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add fourth_reminder to AbandonedEmailType and schema enum
- Replace expiry-based timing with createdAt-based timing so the
schedule is independent of Stripe session expiry windows
- Register all 6 emails in emailSchedule with correct delays:
10 min, 1 hr, 24 hr, 3 d, 7 d, 14 d after session creation
- Add isCompleted filter to query so paid users are never emailed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Complete rewrite of all 6 abandoned checkout templates for better
conversion and deliverability:
- Remove social media icons from all templates (promotions tab signal)
- Remove fake discounts, fabricated testimonials, and unverifiable
claims (money-back guarantee, Calendly link)
- Give each email a distinct purpose: recovery nudge (10min), feature
comparison table (1hr), cost objection handling (24hr), personal
founder message (3d), honest comparison + low pressure (7d),
graceful farewell + feedback ask (14d)
- Add opt-out notice to every email footer (required for marketing emails)
- Fix spam trigger subject '⏰ Your textbee pro upgrade is waiting!'
to 'Your TextBee checkout is still open'
- Standardise year to 2026 and brand to on-brand orange throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Added `lastEnabledAt` property to track when a user re-enables a previously disabled webhook, preventing immediate auto-disable due to historical failures.
- Updated `WebhookService` to set `lastEnabledAt` when a webhook is re-enabled and adjusted the auto-disable logic to respect a grace period based on this new property.
- Updated device query in GatewayService to cast the filter object, addressing a type collision with the reserved `model` field in Mongoose 9.6. This change maintains the runtime behavior while ensuring type compatibility.
Mongoose 9 updateOne expects an ObjectId for _id; webhookSubscriptionId
was inferred as ObjectId | WebhookSubscription in CI, causing TS2769.
Use webhookSubscription._id after findById instead.
Made-with: Cursor
- Added a new method in AuthService to find active API keys using a masked match and fallback to regex.
- Updated OptionalAuthGuard and AuthGuard to utilize the new method for improved API key validation.
- Introduced an index on the apiKey field in the ApiKey schema for optimized query performance.
- Added a query parameter to filter API keys by status (active, revoked, all) in the getApiKey endpoint.
- Updated the AuthService to handle status filtering logic for API key retrieval.
- Modified the frontend to support status-based API key listing and added a button to view revoked keys.
- Implemented a check for the existence of the insertMany method in the SMS model to enhance flexibility.
- Added a fallback mechanism for models that do not support insertMany, allowing for individual document creation.
- Improved SMS document insertion process by maintaining performance while ensuring compatibility with various model types.
- Introduced batching for SMS document insertion to improve performance.
- Added metadata tracking for SMS to FCM message mapping.
- Implemented error handling for mismatched SMS records and queue payloads.
- Updated SMS queue service to support dynamic batch sizes and immediate queue delays.
- Refactored SMS status updates in SmsQueueProcessor to batch updates for failed and dispatched SMS records.
- Improved error handling by collecting failed SMS details and updating their status in a single operation.
- Updated the SMS queue registration to use asynchronous configuration with dynamic limits from the ConfigService.
- Updated getFcmErrorCode function to remove 'messaging/' prefix from error codes.
- Introduced getFcmErrorMessage function to provide actionable feedback for invalid device tokens.
- Enhanced error messages in SMS processing to utilize the new getFcmErrorMessage function for better clarity.
- Added `dispatchedAt` property to SMS schema and updated status options to include 'dispatched'.
- Implemented logic in SmsQueueProcessor to mark SMS as 'dispatched' upon successful FCM push.
- Enhanced error handling for SMS failures, including specific error codes for FCM delivery issues.
- Updated SmsStatusUpdateTask to handle both 'pending' and 'dispatched' statuses for timeout updates.