DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
The Subscription timestamp columns are timezone-less and the app always
writes them as UTC, but bare NOW() is a timestamptz, so LEAST() and the
assignment would go through implicit session-timezone casts — on a non-UTC
session the capped-at-now branch would store a value shifted by the offset.
NOW() AT TIME ZONE 'UTC' stays in the naive-UTC domain throughout.
The tests now keep every timestamp comparison server-side in that same
domain (lower bound from the row's own createdAt instead of a JS-captured
clock): the driver's Date parse/serialize is tz-offset-asymmetric for
timezone-less columns, and the JS clock can't be assumed to agree with the
DB server clock.
Terminal rows written before endedAt was derived on every sync (or first-
synced already-terminal) have endedAt = NULL, which the status-agnostic
isSubscriptionInEffect predicate reads as entitled forever. Close them out
with LEAST(currentPeriodEnd, NOW()), mirroring getEndedAtForSync's fallback
when Stripe omits ended_at.
Measured population in production: 7 rows (2026-07-17), so no batching or
temp-index machinery is needed. After deploy, re-run
scripts/bulldozer-payments-init.ts to re-emit the affected rows so their
grants expire in the timefold.
The invoice.paid reactivation path (clear cancel_at_period_end when a paid
invoice postdates canceledAt) was fragile: the created-after-cancel heuristic
can match unrelated collectible invoices, and the single-subscription guard
counted raw invoice lines, so re-price invoices with proration lines would
silently skip reactivation. Purchase sessions now return a 400 when the
conflicting Stripe sub is canceled or winding down; reactivation-on-repurchase
is deferred (see PR #1773 discussion).
Follow-ups from further review of the wind-down handling:
- getEndedAtForSync now reconciles endedAt on NON-terminal syncs too:
cancel_at_period_end true schedules Stripe's period end, false clears
it. This repairs the eagerly-written endedAt when a pending cancel is
reversed through Stripe's Dashboard/API (previously sticky — Stripe
kept billing while bulldozer still ended the grants) and when the
eager write used a stale pre-renewal boundary. getCanceledAtForSync
now mirrors Stripe's canceled_at including null for the same reason.
- Apply the endedAt/canceledAt derivation to the webhook upsert's
create branch. The first sync for a sub can already be terminal
(out-of-order webhooks, or purchase-session subs whose first local
write is a webhook); such rows were created with endedAt null, which
isSubscriptionInEffect reads as entitled forever. A backfill for
pre-existing rows in that shape is noted as a follow-up (it needs the
bulldozer rows re-emitted, so it isn't a plain SQL migration).
- Source the cancel and refund routes' eager endedAt from the Stripe
update response (new getStripeSubscriptionPeriodEnd helper) instead
of the possibly stale local/bulldozer period end.
- Clear cancel_at_period_end on invoice.paid when the paid invoice was
issued after canceledAt — that can only be the purchase-session
re-price of a winding-down sub, so paying it means "keep me
subscribed". Deliberately NOT cleared at session creation: that runs
before payment (default_incomplete has no rollback), and a declined
card would silently reactivate an explicitly-canceled sub while
leaving it re-priced. The created-after-cancel guard also makes a
late-delivered invoice.paid for the original creation invoice unable
to undo a cancel.
- Rank representative subs cancelable > active > in-effect
(subscriptionDisplayRank) in the products list: a pending-cancel
Stripe sub stays active, so the previous active-only preference could
let it shadow a cancelable sibling of a stackable product and report
is_cancelable false while DELETE would succeed.
- Thread the caller's clock through resolveInEffectPlanSubscription
instead of calling Date.now() inside, keeping plan selection and
usage queries on the same instant.
- Unit tests for the sync helpers, the display rank, and deterministic
clocks in the plan-usage tests.
- Clear cancel_at_period_end when switching plans on a Stripe sub —
paying to switch is an explicit "keep me subscribed", and Stripe
otherwise persists the flag so the newly paid plan would still die
at the period boundary. Also reset the locally written canceledAt /
endedAt wind-down marks so the reactivated sub doesn't end early.
- Prefer active subs over winding-down ones when several in-effect
subs share a productId in the product list (partial cancel of a
stackable product could otherwise hide the cancel button).
- Extract isSubscriptionCancelable and use it in both the product
list's is_cancelable field and the cancel route's filter, so
is_cancelable: false always implies the DELETE returns 400 — for
Stripe subs too, which previously re-ran the cancel silently. The
cancel route now also updates the local row eagerly (mirroring the
refund route) instead of waiting for the webhook sync.
- Give the by-subscription-id cancel branch the same already-canceled
error, and stop claiming past_due/incomplete subs are "already
canceled" — they get their own message.
- Treat canceled-at-period-end subs as replaceable conflicts in
validatePurchaseSession, so buying another plan in the same product
line during a wind-down is no longer rejected as "already has a
one-time purchase". Scoped narrower than isSubscriptionInEffect so
payment retries of incomplete subs aren't rejected as duplicates.
- Make isSubscriptionInEffect's parameter a union so callers must
actually have one of the endedAt fields, and rename
resolveActivePlanSubscription to resolveInEffectPlanSubscription to
match its new semantics.
- Tests: unit tests for both predicates and the plan-usage wind-down
resolution, a validatePurchaseSession wind-down conflict test, and
e2e coverage for stackable partial-cancel shadowing, by-id double
cancel, and same-line replacement during wind-down.
Canceling a subscription without a Stripe backing (test mode, free plans)
writes status=canceled with endedAt set to the end of the current period,
so the customer keeps their entitlements until then. But every read path
classified subscriptions with isActiveSubscription (active/trialing only),
so during the paid-through window the owned product fell through to the
one-time-purchase branch: the account settings payments tab showed
"One-time purchase" with no end date and no way to tell the plan was
winding down, and plan usage reported the free plan while the paid plan's
quotas were still in effect.
Introduce isSubscriptionInEffect (status-agnostic, endedAt-based — the
same semantics Bulldozer's grant timefold and ensure-free-plan already
use) and use it at the read sites: product list classification, plan
usage resolution, and the switch route's sub-vs-OTP occupancy check.
Write sites keep isActiveSubscription so wound-down subs can't be
re-canceled or switched from. The product list now also computes
is_cancelable instead of hardcoding it, double-canceling returns a
dedicated error instead of claiming the product is an OTP, and the
Stripe cancel path uses cancel_at_period_end instead of canceling
immediately, matching the promise made by the confirmation dialog.
The account settings panels render "Ends on <date>" for winding-down
subscriptions.
### Context
We were seeing the txn table and the customers tab under product page
OOM.
It turns out this was because the team icon when loaded would fetch all
of the teams.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes OOM and renderer crashes in the transactions and product customers
tables by stopping per-row refetch storms and avoiding list‑all‑teams
calls. Infinite scroll is now robust, and shared avatar skeletons keep
table rendering smooth.
- **Bug Fixes**
- Data grid (`@hexclave/dashboard-ui-components` `use-data-source`):
queue `loadMore` while a fetch is in flight and replay only after a
successful settle; discard queued requests when pagination mode leaves
infinite; commit the cursor only after a result is delivered; skip
redundant refetches when inputs match a completed fetch; abort in‑flight
requests on unmount.
- Transactions table and product customers tab: wrap `User*`/`Team*`
avatar cells in `Suspense` with a shared `AvatarCellSkeleton` to prevent
per‑row fetch storms and suspend thrash.
- `serverApp.getTeam`/`useTeam` (in `@hexclave/shared` consumers): fetch
a single team by id via a cache; return null for invalid ids; keep
user‑scoped variants membership‑scoped; stabilize hook order. This
removes the “fetch all teams per row” behavior that triggered OOMs.
- Prefetching: cap `/projects/*/teams` to `useTeams({ limit: 1 })` and
remove the heavy owner‑team users prefetch.
- **Refactors**
- Add unit tests for infinite pagination in `use-data-source` (deferred
`loadMore`, aborted resets, error handling, and cursor continuity).
<sup>Written for commit 970abc0f03.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1766?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Added loading placeholders for customer avatar/name rendering across
payment and transaction customer tables to prevent jarring updates while
data loads.
* Improved infinite data loading to correctly queue and replay “load
more” after in-flight requests, handle abort/reset races, and avoid
stale cursor behavior; deferred requests are discarded when leaving
infinite mode.
* Improved user-scoped team lookups by validating team identifiers and
reliably returning `null` for missing/invalid teams.
* **Documentation**
* Clarified that user-level team lookups only return teams the user is a
member of.
* **Tests**
* Added/expanded coverage for infinite pagination cursor correctness,
race conditions, error handling, and mode transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: aman <aman@stack-auth.com>
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
## Summary
- Add an `AGENTS.md` guideline: write comments as if the reader is new
to the codebase but already familiar with the project's goal — explain
the local "why" and non-obvious decisions, not what the project is
trying to achieve.
- Replace `CLAUDE.md` (previously a 5-line stub) with a symlink to
`AGENTS.md` so both entrypoints share a single source of truth.
Link to Devin session:
https://app.devin.ai/sessions/899d319b4b5648d1bb930c2ada976e3f
Requested by: @mantrakp04
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Add comment-writing guidance in AGENTS.md to explain local “why” and
non-obvious decisions for newcomers. Replace the previous `CLAUDE.md`
stub with a symlink to `AGENTS.md` to keep a single source of truth.
<sup>Written for commit 9bd9570c66.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1725?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Documentation**
* Updated guidance on writing comments to focus on local context and
non-obvious decisions for readers new to the codebase.
* Removed a list of documented command examples from the project notes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
Added functionality to log request timings in development mode. Introduced a WeakMap to track request start times and log the elapsed time along with the request method, pathname, and response status after each request. This improves observability during development.
## What
Removes the Next.js compatibility shim layer that the ElysiaJS backend
migration (#1630) introduced, replacing it with standard Web APIs and
de-aliased local runtime helpers. Addresses N2D4's review note on #1630:
> i think we should create a followup PR which cleans these up and uses
the elysia methods directly — so we don't need a nextjs compat layer
forever
Stacked on `migrate-backend-to-elysiajs`.
## Changes
**`next/server` → standard Web APIs (eliminated)**
- `NextRequest` → `Request`, `NextResponse.json()` → `Response.json()`,
`new NextResponse(...)` → `new Response(...)`, `req.nextUrl` → `new
URL(req.url)`
- Hot path (`smart-route-handler`) computes `const requestUrl = new
URL(req.url)` once
- Deleted `lib/next-compat/server.tsx`; rewrote
`server/next-request-shim.ts` → `server/backend-request.ts`
(`createBackendRequest`, returns a plain `Request`)
**`next/headers` + `next/navigation` → `lib/runtime/` (de-aliased)**
- `git mv`'d the real runtime helpers (`headers`, `navigation`,
`request-context`) out of `lib/next-compat/` into `lib/runtime/`,
repointing all consumers to `@/lib/runtime/*`
- The cookie/header/redirect mechanism (AsyncLocalStorage + thrown
redirect errors, driven by `app.ts`) is unchanged — it was only *named*
after Next, never actually Next
**Config + cleanup**
- Dropped the `next/*` path aliases from `tsconfig.json`,
`vitest.config.ts`, `tsdown.config.ts` (removed `nextCompatPlugin`, the
alias map, and the `next` bundling special-case)
- Deleted `fetch.d.ts` and removed the no-op `next: { revalidate }`
fetch option in `changelog/route.tsx` (caching never worked outside the
Next runtime)
- Converted the unreferenced `proxy.tsx` so it still compiles (it's dead
code superseded by `server/middleware.ts` — deletion candidate, left out
of this PR)
No `next/*` imports remain in the backend.
## Verification
- `tsc --noEmit` — passes (exit 0)
- `eslint` on all changed files — clean
## Notes / not in scope
- This removes the **`next/*` façade**. It keeps the underlying
ALS-based request-context mechanism rather than threading Elysia's
native context (`set.cookie`, `set.redirect`) into every handler —
that's a much larger change touching handler signatures across the whole
API surface.
- One internal redirect digest string is still `"NEXT_REDIRECT"`
(matched in both `navigation.tsx` and `app.ts`); renamable but cosmetic.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Remove the `next/*` compatibility layer and switch the backend to
standard Web APIs. This simplifies handlers and config while keeping the
ALS request context and redirect behavior unchanged.
- **Refactors**
- Replaced `NextRequest`/`NextResponse` and `req.nextUrl` with
`Request`/`Response` and `new URL(req.url)` across handlers, proxies,
health/unsubscribe routes, and IDP endpoints.
- Moved runtime helpers to `@/lib/runtime/*` (`headers`, `navigation`,
`request-context`) and updated imports.
- Added `server/backend-request.ts` to build backend `Request`s with
merged headers; removed `server/next-request-shim.ts` and
`lib/next-compat/server.tsx`.
- Updated proxy rewrite to set `x-middleware-rewrite`; IDP routes now
return `Response` directly with 307/308 mapping.
- Removed the `next:{revalidate}` fetch option; deleted
`lib/next-compat/fetch.d.ts`.
- Dropped `next/*` aliases from `tsconfig`, `tsdown`, and `vitest`;
route registry/types now use `Request`.
- **Migration**
- Import from `@/lib/runtime/headers` and `@/lib/runtime/navigation`
instead of `next/headers` and `next/navigation`.
- Route handlers should accept `Request` and use `new URL(req.url)` for
URL parsing.
<sup>Written for commit b4a534ff36.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1652?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Bilal Godil <bg2002@gmail.com>
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
## Summary
New "CLI Auth" app registered in `apps-config.ts` (alpha, parent:
authentication) and `apps-frontend.tsx` (TerminalWindowIcon, `/cli-auth`
route).
**Backend** — `GET /internal/cli-auth`:
- Queries `CliAuthAttempt` (last 50) with computed status from
`usedAt`/`refreshToken`/`expiresAt`
- Joins claimed `refreshToken` values against `ProjectUserRefreshToken`
to find active CLI sessions + user info via `ProjectUser`
- Returns `{ summary, recent_attempts, active_cli_users }`
**Dashboard** — `/cli-auth/page-client.tsx`:
- Fetches via `hexclaveAppInternalsSymbol` →
`sendRequest("/internal/cli-auth", {}, "admin")`
- Renders KPI cards (total/completed/expired/active), active sessions
list with last-active time, and recent attempts with status badges
- Expired sessions collapsed by default under `<details>`
Link to Devin session:
https://app.devin.ai/sessions/0868d1452a024b9da36b9d6a45044ff3
Requested by: @N2D4
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds an alpha CLI Auth dashboard to track CLI login attempts and active
refresh tokens, powered by a hidden admin-only endpoint.
- **New Features**
- Registers `cli-auth` under Authentication; adds `/cli-auth` route with
TerminalWindowIcon and docs link.
- Backend `GET /internal/cli-auth`: summary stats (incl. used_attempts),
last 50 attempts with computed status, and active CLI users by joining
CLI-issued refresh tokens (bounded).
- Dashboard fetches via admin request and shows KPIs, active sessions
(last-active/expiry), and recent attempts; expired sessions are
collapsed.
- **Bug Fixes**
- Use per-project admin app (`useAdminApp`) to avoid
ADMIN_AUTHENTICATION_REQUIRED and ensure correct tenancy scoping.
- Resolve primary emails for active sessions via `ContactChannel` join
to prevent 500s.
- Fix badges by using `DesignBadge` label prop and set expired to red;
add default cases in status switches.
- Bound token lookup to last 200 CLI-issued tokens and use a separate
COUNT(*) for accurate `active_tokens`.
- Align loading skeleton grid with the KPI layout.
- Docs: add `cli-auth` icon.
<sup>Written for commit 292859e921.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1739?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added a **CLI Auth** dashboard page with metrics for active sessions,
an expandable expired session list, and recent login attempts.
* Added a hidden internal analytics endpoint powering the dashboard
(summary, recent attempts, and active users).
* Registered **CLI Auth** in the app catalog/navigation (alpha) and
added its icon to the docs UI.
* **Bug Fixes**
* Improved request/response validation, loading/error states, and
avoided state updates after unmount.
* **Documentation**
* Updated docs indexing settings and added a redirect for a related
guide.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>