Conversation
apps/api/CLAUDE.md is auto-loaded for any file under apps/api, so its 23k chars were paid on every task there. Editing apps/api/src/modules/** loaded 47k chars of CLAUDE.md in total (root + api + modules). Moved to .claude/skills/ (loaded on invocation only): - auth-server: BetterAuth singleton, pipeline, rate-limit, CSRF, cookies - events-outbox: transactional outbox, BetterAuth bridge, retention sweeps - storage-uploads: presign to PUT to confirm, S3-compatible port - billing-entitlements: tiers, seats, feature flags, quota gating - compliance-consent: policy versioning, cookie consent - email-delivery: email queue, delivery worker, Resend batching Kept always-loaded: layout, module boundary, removability, CQRS, DI, Hono RPC and its /api/v1 exception, logging, observability, org scoping. apps/api/CLAUDE.md: 23104 -> 10383 chars. Cumulative load under apps/api/src/modules/**: 47k -> 34k. No content lost, only relocated. The recursive sub-CLAUDE.md pattern is unchanged and stays the right tool for path-scoped rules; skills only carry what is subsystem-scoped.
Migration 0016 unconditionally created api_token/notification/notification_preference, which already exist with no journal row on any dev database that ran `pnpm db:push` after those schema files landed on dev. Guard those three tables' CREATE TABLE / CREATE INDEX / ADD CONSTRAINT with existence checks so the migration applies cleanly on both a fresh database and a drifted one. sso_provider/scim_provider stay unguarded — they are new in every starting state and a genuine collision there must still fail loudly. Also trims the sso-paths.ts header to what a future reader needs and fixes a self-contradicting sentence in the task-1 report.
…it under impersonation
…sion history activeOrganizationIdFor picked the user's most-recently-created session row regardless of which org the /sso/register request body actually named, and without an expiry filter — a stale or unrelated business-tier session could approve registration on a different, lower-tier org (gate bypass). providersLimit only ever receives `user` (verified in the plugin's dist), so it cannot see the target org and is no longer where the tier check lives; it stays a flat per-user ceiling. The business-tier gate moves to hooks.before on "/sso/register", reading body.organizationId directly.
…ration SAML config is customer-supplied at registration; without normalisation at the door an org can weaken its own signing posture and re-open the XML-signature-wrapping surface behind the 2025-2026 SAML CVEs. Weak flags are forced to the safe value; a weak algorithm is rejected outright rather than silently upgraded, since an IdP configured for SHA-1 would fail every assertion after a silent upgrade and the operator would debug the wrong end of the connection. Also unifies the two hooks.before branches on /sso/register onto the SSO_PATHS.register constant (R18) and runs normalisation after the entitlement gate: a request refused for lack of entitlement need not have its body rewritten first. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…re string The exact-match array only caught signatureAlgorithm/digestAlgorithm values equal to "sha1" or "md5". @better-auth/sso accepts the same weak algorithm as "rsa-sha1", "ecdsa-sha1" and full xmldsig URIs (http://www.w3.org/2000/09/xmldsig#rsa-sha1), all of which sailed through the exact match into Result.ok — the guard read as protection it did not provide. Switch to a case-insensitive substring match on the family name; no accepted strong algorithm (sha256/sha384/sha512 and their rsa-/ecdsa-/URI forms) contains "sha1" or "md5" as a substring, so the match is sound without enumerating every prefix. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…bridge
Wires sso.provider.{registered,updated,deleted}, sso.domain.verified, and
sso.login.{success,failure} into the existing hooks.after/before bridge in
auth.ts. ctx.path in hooks.after is the endpoint's registered route pattern,
not the resolved URL (verified against better-auth's dispatchAuthEndpoint
source and live), so providerId is read from ctx.params instead of parsing
the path. Both SAML callback and ACS endpoints delegate to the same session
-creating code path and are handled identically. sso.login.success is
verified to fire on both the JIT-provisioning and returning-user paths via a
live Keycloak OIDC round-trip; sso.login.failure is verified via a real
domain-not-verified rejection and an unknown-SAML-provider rejection.
sso.enforcement.changed (the SSO_ENFORCEMENT_CHANGED catalog entry) is out of
scope here — Task 10 owns its emit site.
This file opens on "All SOTA 2026". Measured on 2026-08-17, it no longer is: TypeScript is a major behind, mise.toml pins pnpm 10.33.2 while packageManager declares 11.0.9 — whichever wins is accidental — and E.1 is about to hand-wire a locale layout across 32 code-based route files, in the shape TanStack now recommends generating instead. G.1a runtime and package manager, G.1b TypeScript 7, G.1c Postgres 18, G.1d dependency floor, G.1e file-based routing sequenced before E.1. Two findings shape the scope. Every primary key in the schema is text() filled by BetterAuth, so Postgres 18's native uuidv7 applies to new application tables only — converting the existing ones is a BetterAuth question and has no place in a version bump. And most dependencies are declared with a caret, so what is stale is the lockfile, not the ranges: only typescript, @hono/zod-validator (a caret does not cross a 0.x minor) and @types/pg need an edit. Versions read from the npm registry, nodejs.org and Docker Hub; re-measure before executing. Claude-Session: https://claude.ai/code/session_01CPKJJ6jFtLQGSKYCHJauNQ
…ion owner as actor Wires the SCIM half of the BetterAuth event bridge (hooks.after) for all six scim.* event types, mirroring Task 6's SSO bridge. The provisioned user is the event subject; the SCIM connection owner (resolved via a new scimConnectionOwner query) is the actor, per rule #7. Also widens the /api/auth/* verb list (GET/POST only until now) to include PUT/PATCH/DELETE — SCIM 2.0 requires them on /Users/:userId, and without this change three of the six event types could never fire in this deployment (a routing-produced rule #6 orphan, not a missing emit site). Confirmed BetterAuth's own router still 404s any unregistered method/path combo after the change, and the full auth test suite is unaffected. Deviates from the task brief where ctx.context.returned is undefined for PATCH/DELETE SCIM responses (204, no body) — the subject id falls back to ctx.params.userId there instead of assuming a returned shape that never exists for those two verbs.
Pure domainOf/isSsoEnforcedFor pair, testable without a database via an injected lookup, plus the real Drizzle lookup in auth-queries.ts that jointly requires domain match, domain_verified, and organization.sso_enforced before treating a domain as enforced.
@better-auth/sso persists ssoProvider.domain verbatim (no casing
normalization on register/update), while domainOf() always lowercases
the email's domain before the enforcement lookup. A provider
registered with a mixed-case domain (e.g. "Acme.com") therefore never
matched, silently letting enterprise users bypass SSO enforcement via
password/magic-link/passkey.
- Write side: lowercase body.domain in the existing /sso/register
hooks.before handler, so newly registered providers store the
canonical form.
- Read side: compare via eq(sql`lower(${ssoProvider.domain})`, domain)
instead of a bare eq(), so existing/legacy rows with mixed-case
domains still match. Uses a typed sql fragment (column reference
stays ${ssoProvider.domain}), not ilike, since ilike's wildcard
semantics on "%"/"_" would turn this into a false-positive risk
instead of a bypass.
Verified against a real Postgres instance: a provider stored with
domain "Acme.com" now resolves for a query domain of "acme.com".
No index exists today on ssoProvider.domain (only providerId is
indexed), so this is not a performance regression; a future index to
support this comparison would need to be a functional index on
lower(domain) rather than a plain btree on domain.
/sso/update-provider persists `domain` verbatim on its own update statement (same as /sso/register), so leaving it un-normalized would have made the read-side lower() the sole guard against a mixed-case domain reaching the enforcement query for any provider edited after registration. Confirmed against the plugin's route list that /sso/register and /sso/update-provider are the only two endpoints that write ssoProvider.domain — every other "model: ssoProvider" site is a read (providers list, get-provider, delete-provider snapshot, domain verification, callback/sign-in lookups).
… paths Blocks password sign-in, sign-up, and magic-link at hooks.before, and passkey (no email in body) at databaseHooks.session.create.before, for any org whose verified domain has SSO enforced. Deviates from the task brief on two points, both known defects fixed before implementation: `primaryAccountFor`/`emailFor` did not exist anywhere (reused the existing findLatestLinkedAccount instead of duplicating it, added only emailFor); and the brief's `providerId !== "sso"/"oidc"` check is wrong (the SSO plugin sets account.providerId to the registered provider's own id, never a fixed string) and would have locked every enforced org's SSO login out of its own session creation — replaced with a lookup against the registered ssoProvider table via the existing findSsoProviderByProviderId. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
Adds the break-glass path Task 9's enforcement needed: a platform-admin
route to lift a locked-out org's SSO requirement, and (ruling R2) the
org-owner route to turn enforcement on in the first place. Both share
one AdminActionService.setSsoEnforcement method (promoted to
shared/services since it now has two module-boundary consumers),
writing organization.sso_enforced and emitting
EventTypes.SSO_ENFORCEMENT_CHANGED in the same transaction, with the
triggering actor threaded explicitly from c.get("user").id.
Also fixes a pre-existing bun test mock-leak: several test files
stubbed multiTenantSchema without organization.id, which broke once a
service started reading it at runtime.
Builds the /settings/sso page: identity provider registration (OIDC/SAML tabs), domain verification with a copyable TXT record, SCIM token generation via the shared secret-reveal dialog, and an org owner/admin SSO enforcement toggle wired to the Task 10 API route. Gated on the business-tier "sso" entitlement with an upsell fallback, and on organization:update at the route level since registration/SCIM require org owner or admin per the better-auth sso/scim plugins. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…ing a second one authClient.sso.providers() has no orderBy server-side, so "the org's provider" could silently resolve to a different row across reloads/refetches once an org had more than one. Sort the list by providerId (the only stable field the endpoint returns) once in ssoProvidersQueryOptions, and route every card through a single primaryProviderFor() resolver instead of each re-filtering the list. The provider card also no longer offers to register a second provider once one exists — that was never scoped, and a silently non-deterministic second provider was worse than no control. Also documents the intentional providerId slug collision left to the server's uniqueness check. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
Adds a "Sign in with SSO" control on /sign-in (collapsible email step, authClient.signIn.sso) and redirects a password sign-in straight into the SSO flow on an SSO_REQUIRED rejection, using the providerId the server already returns instead of re-deriving it from the email. Task 12's docker-compose Keycloak service was already shipped in Task 1 (R11) and is untouched here. Closes out phase C.7: ROADMAP/FEATURES/EVENTS/ HISTORY updated with the real event counts (67->80 / 28->34 public / 39->46 internal, recomputed from packages/events/src/event-types.ts and visibility-map.ts) and the two stale claims corrected (no sso access-control statement exists; SCIM DELETE is an org departure, not a grace-period deletion). docs/SSO-LOCAL.md documents the local Keycloak round-trip, including two traps found only by running it live. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…age, and a11y flake - include providerId on the passkey-leg 403 SSO_REQUIRED body, matching the other three enforced sign-in paths; verified live via the shared databaseHooks.session.create.before hook - extract redirectToSsoIfRequired into auth-error.ts and reuse it from use-sign-up and use-magic-link, which previously dead-ended on a raw SSO_REQUIRED toast - pin playwright workers to 4 in the a11y suite: the API's per-user global rate limit was tripped by parallel workers sharing one seeded identity, intermittently failing the sso settings page under load - extend the sign-in keyboard tab-order test to cover the sso trigger and the email field it reveals - correct OVERVIEW.md and MODULES.md, which still described sso/scim as an unshipped roadmap item
…quest SCIM provisioning went through neither of the two authoritative seat gates: `@better-auth/scim` writes the member row with a raw `adapter.create`, so `beforeAddMember`/`afterAddMember` never fire. Combined with `/scim/generate-token` carrying no tier gate at all, a Free-tier owner could mint a token and provision past `maxMembers` with no billing enforcement, no audit row and no webhook delivery. The token endpoint now goes through `assertSsoEntitlementFor` — extracted on this second occurrence and shared with `/sso/register` — `POST /scim/v2/Users` is capped before the endpoint runs, and `org.member.joined` is emitted from the after-hook with the provisioned user as subject and the connection owner as actor. Removal already reached `afterRemoveMember`, so only its actor is corrected; a second emit would have duplicated audit rows and webhook deliveries. The passkey enforcement leg was disabled for exactly the users it protects: keyed on whether the user owns an SSO-linked account, it waved through every later sign-in of anyone who had ever used the IdP — passkey included — which is the deprovisioning guarantee enforcement is sold on. The discriminator is now the request: BetterAuth passes the endpoint context to `session.create.before`, so only the four SSO callback paths (plus admin impersonation, where the actor is a platform admin, not the enforced user) skip the guard. `sso.login.failure` is public but was emitted with no `organizationId`, which `WebhookFanoutSubscriber` drops before the visibility check — the one signal telling a customer their IdP broke reached nobody. The provider row loaded one line above already carries it. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
`redirectToSsoIfRequired` covered the three email-bearing hooks but not the two passkey ones, while the docs claimed all four paths redirect. The explicit passkey button surfaced a bare `SSO_REQUIRED` toast and autofill returned silently, so the one leg a user reaches without typing an email was the one with no way forward. The autofill leg redirects without a toast: conditional UI must stay quiet on expected failures, but an enforced domain is not one. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
`check:a11y` failed reproducibly and the rationale shipped with it named the wrong bucket. Measured: the bucket that overflows is the IP-keyed one (61 against a 60/min ceiling, twice in a row) while the per-user bucket sat at 39 — `sessionMiddleware` nulls the user for `/api/auth/*`, so a signed-in page's session and organization queries are counted against the IP next to every unauthenticated page load. Worker count bounds neither: the ceiling is per minute across the whole run and the IP is identical from every worker. The real defect is the tuning. A signed-in page view fires up to 8 API calls, so a 60/min burst window allowed about seven navigations per minute before a legitimate user got a 429. The minute window becomes the burst window (300) and the hour window (1800) stays the sustained anti-abuse ceiling; every credential path keeps its own far tighter fail-closed policy. Gate green three consecutive runs, 21/21. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
Corrects the two mechanism descriptions the code no longer matched (the enforcement guard keys on the request, not on the user's account linkage; the scim entitlement gate now covers token generation) and records what this round left open: the seat cap is a pre-endpoint count rather than a reservation, the organization plugin misattributes the actor on every non-scim member hook, and `/api/auth/*` is rate-limited per IP even for signed-in users. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…ted kicks
scimDeprovisionActors was a global Map keyed on a bare userId, written
unconditionally in hooks.before and consumed only by afterRemoveMember —
a hook that never fires when the SCIM DELETE 404s or the user holds no
member row. A stranded entry then poisoned any later removal of that
user, by any admin, in any org, naming the SCIM connection owner as the
actor of an unrelated kick (audit-trail forgery on org.member.removed).
Introduce RequestSnapshots<T>, a small keyed store with a freshness TTL
and a consumer-supplied accepts() match, and use it for all three
before/after snapshot maps in auth.ts (SCIM deprovision actor, SSO
provider delete, SCIM connection delete). scimDeprovisionActors now
carries {actorUserId, organizationId} and the consumer requires an
organization match before trusting it; a non-matching entry is left in
place rather than deleted, since it may belong to a request still in
flight.
Reproduced live: a bearer-token DELETE against an unrelated user (404
"user not found") plants an entry; kicking that same user from a
different org through the ordinary remove-member flow previously
attributed the kick to the SCIM connection owner. Post-fix, the same
sequence falls back to the pre-existing (documented, unrelated) actor
gap instead of the forged identity.
Also: rewrite the seat-cap docblock, which claimed the SCIM path reused
assertSeatAvailableFor when it actually inlined the check because that
helper throws AppErrorException and SCIM needs an APIError. Extract
seatCapFor() as the shared predicate both callers now call, with the
comment stating the real reason for the two refusal shapes. And give
the SCIM seat-cap rejection (and future SCIM-thrown errors) an RFC 7644
error body via a small scimError() helper, since Okta/Entra expect
urn:ietf:params:scim:api:messages:2.0:Error rather than a generic
provider error.
/api/auth/send-verification-email is a public POST taking an arbitrary email with no session, the same "make the server email a stranger" primitive as request-password-reset and sign-in/magic-link — but it had no dedicated policy, so it only inherited the burst-tuned global window (300/min) after that window was widened from 60 to 300 for legitimate navigation traffic. Give it the same fail-closed 3/15min policy as its siblings, mounted in the same guard block ahead of the global rate limiter.
Line 830 stated the passkey leg of SSO enforcement was keyed off whether the session's linked account already belongs to a registered SSO provider — an SSO-linked account is permanent, so that would wave through every later passkey sign-in for a deprovisioned user. Fix round 2 item 2 replaced that with a request-path check. Correct the entry inline as a "note, superseded" rather than silently rewriting it, in the same spirit as the existing round-1 flake correction further down this file.
…tration
/sso/register forced sha256 and signed assertions; /sso/update-provider
normalized only the domain. The plugin merges samlConfig as
`updates.X ?? current.X`, validates algorithms only when they appear in the
update body, and defaults to onDeprecated: "warn" since no saml.algorithms
is set at mount — so an org admin could register a compliant provider and
then PATCH { wantAssertionsSigned: false, signatureAlgorithm: "sha1" }.
samlify takes wantMessageSigned from that flag, so assertions stopped
needing a signature at all.
normalizeSamlConfig is already partial-update-safe: identity fields pass
through only when sent, security fields are always forced strong.
Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
The SCIM branches in the global hooks.before resolved the token owner by base64-decoding the Authorization header with no check against the stored hash, and runBeforeHooks executes before the endpoint's own bearer auth. providerId is a deterministic slug of the org domain, so any member could guess it: a forged token returned 402 with maxMembers when the org was full versus 401 when it was not (a pre-auth cross-tenant billing oracle), and a 404-ing DELETE still planted a scimDeprovisionActors entry, letting a same-org kick within the TTL be attributed to the SCIM connection owner. R36's org-match guard closed only the cross-org case. verifiedScimConnectionOwner hash-verifies the token before the owner is resolved. The seat cap still runs for verified requests. Adds a dedicated fail-closed rate-limit policy for /scim/*, which had none. Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…ed address `check-fanout-preferences.ts` defaulted to a maintainer's personal address while `db:seed` created `[email protected]`, so `pnpm --filter api check:fanout` threw on every freshly seeded environment: "no user for [email protected]". A verification gate that does not run is a gate that does not exist — and this one is the only executable proof of the preference cascade, whose SQL no unit test can reach. The two literals had already drifted once, so the default now lives in one place both read (`scripts/seed-account.ts`); `SEED_EMAIL` overrides them together or neither. A personal address also has no business in a generic boilerplate, least of all as the default target of a `DELETE FROM notification`. The script also joins the `requireLocalDatabase` family it belonged to all along: it wipes rows for the resolved user, so a misconfigured `DATABASE_URL` must fail fast rather than delete someone's data. Verified green against a freshly seeded local database with no `SEED_EMAIL` set: 8/8. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
…ation
`POST /notifications/read` scopes its UPDATE to the caller, so a foreign id
changed nothing — and the route still answered `200 {ok:true}`. The isolation
was never in doubt; the contract was. The repo's rule is explicit: wrong owner
is `Option.none()` on a read and `NOT_FOUND` on a write, never a 403 that
leaks the row's existence. A 200 that claims an action it did not perform is
no better than the 403: the client cannot tell "marked" from "ignored", and
its optimistic update decremented the badge by every id it sent, foreign ones
included.
The store now reports the ids it actually wrote, and the route compares them
against what was asked. A single unmatched id fails the whole batch with
`404 NOTIFICATION_NOT_FOUND` and rolls the transaction back — all of it
applied or none of it, never a silent partial. Repeats in the request body are
deduplicated first, so an id sent twice is not mistaken for a missing row.
Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
GET /notifications already returned nextCursor but the bell only ever fetched the first page, so anything past the last 20 notifications was unreachable. Switches the list to a TanStack infinite query, adds a "load more" control that disappears once nextCursor is null, and reworks the read-state patch (applyRead) plus the SSE invalidation to operate over every loaded page instead of a single one - so mark/mark-all stay accurate across pages and a live push no longer refetches every page the user has scrolled through.
`GET /notifications` returned the stored event payload verbatim, and the fan-out copies the event's payload verbatim into the row. Today the exposure is nil — `org.member.invited` carries its `invitationId` to an audience that already holds the invite capability — but there was no allowlist anywhere on the path, so any field added to a notifiable event's payload tomorrow would reach the recipient's browser by default, silently and forever. `NotificationConfig` now requires `payloadFields`, and the route projects through `publicNotificationPayload()`. Required, with empty as a valid answer: declaring nothing is a decision, inheriting everything is not — and the compiler now asks the question of anyone adding a notifiable event. The stored row keeps the full payload; only the read path narrows, so debugging and retention lose nothing. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
…ey were written Three test files replaced `@packages/events` with a hand-kept copy of the catalog, and all three had frozen at the same commit: seventeen event types missing, `notification.read` among them. `mock.module` leaks across the whole process, so the copy did not fail its own file — it silently turned `EventTypes.X` into `undefined` in whichever *other* file happened to run after it. The new `notification.read` assertion failed in the full suite and passed in isolation, which is the shape this defect always takes. Each mock now spreads the real module and re-declares only what it means to stub, so a type added tomorrow is present everywhere and the deliberate stubs still win. The repo already required exposing the superset for the `drizzle` mocks; the same reasoning applies here. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
Switching organization left the previous org's data on screen — and coming back served the entry the second org had overwritten, empty until a manual reload. The server scopes these responses on `session.activeOrganizationId` (`requireOrg`, or the handler reading it directly), but nothing in the URL or the call site said so, so a single cache entry served every organization. Clearing the cache on switch would have worked once; a key that carries the organization works for every query added later, and keeps each org's entry so going back is both instant and correct. Keyed: webhook endpoints/deliveries/delivery detail, API tokens, subscription, current membership — the list established from the back routes, not from names. SSO stays untouched: `GET /sso/providers` filters on the user, not the active org, and the provider detail is already keyed by `providerId`. Absence is `null`, never `undefined`, which a serialized key drops — collapsing "no org" and "org X" onto one entry, the very bug being fixed. Surfaces the server refuses without an org carry the guard in the factory (a call site that spreads the options would otherwise overwrite it); surfaces where `null` is a real scope keep answering it. `ensureOrgPermission` resolves the same org id in `beforeLoad`, so preloading and rendering share one entry. Every `invalidateQueries` follows the new segment — a key that gains one while an invalidation keeps the old prefix is a silent no-op.
Same defect a4cc582 fixed elsewhere, in the one query that commit could not reach: the route is requireOrg, so the response belongs to the active organization, but the key named only "org-preferences". One cache entry served every organization, and with staleTime 30s the switch back re-served the other one's matrix. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
Same defect as a879d54 in the notification stream hub, but in the event pipeline: a dead `pg` connection emits `error` then `end`, both scheduled a reconnection, and the dying client was never unwired — listeners stayed attached and kept relaying NOTIFY, kept scheduling further reconnections. Every Postgres blip in production permanently added a live LISTEN connection and multiplied event processing. Applies the same fix shape as the hub: uniqueness held by a single pending reconnect timer (not a `connecting` flag, which would wedge if `error` fires while `connect()` is in flight), a dying client torn down exactly once via a `disposerFor` closure, `listenClient` cleared only when it still points at the dying client, and `reconnectBackoff` reset only on a connection that actually reached `LISTEN`. `pg.Client` is narrowed to an injectable structural shape (`OutboxListenClient`) so the contract is provable without a database, mirroring the hub's `NotificationListenClient`. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
check-sweep-lock.ts acquires/releases real sweep leases and deletes rows
through purgeBatchWithTimeout against the real DATABASE_URL, exactly like
check-enqueue/check-marksent/check-wipe-rollback/check-fanout-preferences —
but it was missing their shared requireLocalDatabase("...") guard, so a
misconfigured DATABASE_URL could run its mutations against a shared or
production database instead of failing fast.
Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
The server sends event: ping every 25s (apps/api .../notifications/routes.ts, stream.sleep(25_000)) but the client had no time guard on it. A silent-but- open connection — a proxy that keeps the socket up without relaying anything — left connected=true forever: no reconnection, no polling fallback, and a frozen unread count with no signal to the user that anything broke. consume() now arms a stall timeout (2x the ping interval plus a margin) that cancels the reader when nothing arrives in time. Cancelling resolves the pending read as done, so consume() returns normally and the existing reconnect/backoff loop in useNotificationStream takes over. The timer is rearmed on every frame received, ping included, and always cleared in a finally so it can't outlive the read loop. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
The `frequency` column and its selector shipped and persisted, but nothing read them: the fan-out wrote `email_pending_at = occurredAt` unconditionally and the flush selected `email_pending_at <= now()`, so `hourly` and `daily` behaved exactly like `immediate`. A preference set to daily produced a digest seconds later. Read it at the fan-out, not at the flush. The fan-out already joins the recipient's preference row to decide `enabled`; resolving `frequency` through the same org-lock -> user -> org-default -> fallback cascade costs one more COALESCE on a join that exists anyway, where reading it at the flush would mean re-resolving every recipient's preference on every tick. So the flush's selection did not change at all — the whole feature is one column's meaning: `email_pending_at` is now "when this may be mailed", never "when this happened". Windows anchor on wall-clock boundaries (`digestDueAt`): the next full hour, or the next NOTIFICATION_DIGEST_HOUR_UTC (default 08:00). Deliberately not "24h after the last send", which drifts a little further every cycle and is reproducible in no test. The boundary is strict — an event landing exactly on the anchor goes in the next window, since the digest for the one it sits on has already been cut. The three candidate timestamps are computed in TypeScript and bound as parameters; only the choice between them is SQL, so the window rule stays somewhere a unit test can reach. `forced` events never defer. They join no preference row, so the fan-out emits no frequency branch for them at all — which is the point: every forced event in the catalogue (password changed, MFA toggled, passkey added, deletion requested, payment failed, subscription cancelled, token created) loses its value if it arrives tomorrow. The flush now takes the `sweep_lock` lease, same primitive as the retention sweeps. `FOR UPDATE SKIP LOCKED` already made a double send impossible, but two concurrent runs could each claim half of one user's due rows and each mail a digest — no duplicate, two emails for a window that promised one. An empty window still sends nothing, and a replay sends nothing. Verification: `digestDueAt` is pure and covered by 9 unit tests. The parts that live in SQL are covered by `pnpm --filter api check:digest` (`scripts/check-digest-window.ts`, 19 assertions against a real Postgres through the real signed route) — a mocked tx evaluates no CASE, and asserting on generated SQL text is banned here for good reason (it depends on the real `sql` tag, which another file's `mock.module` replaces process-wide). Proven in execution: the three cadences write the three expected due dates, a forced event stays immediate under a daily preference, an empty window enqueues nothing, a due window groups four notifications into one email, and of two concurrent runs exactly one works while the other answers `skipped`. Time travel is done by dating rows into the past, not by waiting. No new event type — the catalogue stays at 82. This changes when an existing write becomes eligible, not what is written. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
The create form offers two scopes, "Personal" (organizationId: null, the default) and the active organization, but the list only ever asked for one: `organizationId: session.activeOrganizationId ?? null`. Every user owns a personal organization, so the `?? null` branch is unreachable and a token created with the Personal scope never appeared for its owner — a live, valid credential nobody could see, and therefore nobody could revoke. `TokenOwner` becomes a closed union — `personal` | `orgAndPersonal` — so the selected set is named by the variant instead of encoded in a nullable field, and a third scope has to be declared rather than smuggled in as a flag. The repository widens its organization leg to "no organization OR this one" (`or(isNull, eq)`), always AND-joined on `userId`; `ownerReaches` states the same rule in the application layer and `ApiTokenService` re-applies it to what the repository returns, so a row out of reach is absent, never 403. Revocation inherits the widened scope, which is what makes a personal token revocable again. The list now mixes both scopes, so each row carries a scope badge — the organization's name, or "Personal" — behind new keys in both catalogs. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
bun's mock.module replaces a module for the whole process, not for the
file that calls it, so the suite's verdict was a function of file order.
That is not theoretical: `bun test src/modules/admin src/modules/api-token`
ran 63 of 73 tests and reported 3 failures and an error, while the same
files under --isolate run all 73 and pass. Three separate defects in this
session traced back to it, including hand-kept @packages/events copies
that blanked 17 event types in *other* files, whose assertions then passed
against undefined.
--isolate makes the property structural rather than disciplinary, and
--parallel makes the suite faster than it was sequentially (2.5s -> 1.1s).
The hand-copied export surfaces go away with the leak that motivated them:
what remains spreads the real module and derives its maps from the real
catalog, because a frozen copy is correct exactly once.
module-isolation.{1,2}-*.test.ts is a named canary — one file replaces a
module nothing else imports, the other asserts it never saw the
replacement — so losing isolation fails there rather than as an
unexplained cascade elsewhere. Verified: it fails without --isolate.
pino-pretty ships to a worker thread and a per-file registry would spawn
and tear one down per file, racing into "the worker thread exited"; the
logger stays in-process and silent under test, where nothing asserts on it.
865 pass on three randomized seeds.
Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
check-digest-window.ts is a 19-case executable check written precisely because the frequency cascade lives inside an INSERT ... SELECT and the due filter inside a SELECT ... FOR UPDATE SKIP LOCKED — neither of which a mocked tx evaluates. Its siblings check:fanout and check:sweep-lock are CI steps; this one was declared in package.json and called by nothing, which is the exact failure the rule it cites warns about: a rule everyone believes is enforced and nothing enforces. The counts said 82/35/47 in one place and 67/28/39 in another, in the same file. Only current-state claims are corrected; the per-phase lines that record what a catalog looked like at its own phase stay as they are. Also adds PATCH /api/v1/me to the token-reachable list, which docs/PUBLIC-API.md documents at length as the public API's only write. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
The flush now re-checks the current email preference (same org/user precedence as the fan-out) instead of trusting the decision made at insertion, drops rows whose channel was turned off since (clearing emailPendingAt instead of leaving them eligible forever), and pages through the whole due backlog inside one lease so a run bigger than batchSize no longer splits into two digests. Also closes an untested reconnect race (error landing while connect() is in flight) in both the notification stream hub and the outbox dispatcher, aligns OutboxDispatcher.start() with NotificationStreamHub's re-entrance guard, and replaces a stale "confinement test exists" docstring on the api-token repository with an executable check against a real database (check:api-token-visibility, wired into CI). Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
notInArray(n.id, claimedIds) grew one bound parameter per row across the whole backlog — pathological NOT IN and a hard wall at Postgres's 65535-param limit on a large digest window. The real fix was to stop deferring the "kept for sending" rows: mark their emailSentAt in the same transaction, in the same loop iteration that selected them, instead of waiting for the merged digest to be built after every page had been read. Once both outcomes (dropped -> emailPendingAt = null, sent -> emailSentAt = now) happen per page, the WHERE clause alone keeps a row from resurfacing on the next page and the accumulator is gone — memory and query size stop growing with backlog size. check-digest-window.ts [8] now also proves no row is processed twice across pages and a replay of the same paged window sends nothing. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
`check:wipe-rollback`, `check:enqueue` and `check:marksent` sit on the same rail as the four already wired: each proves a behaviour that lives inside SQL (a transaction rollback, `onConflictDoNothing`, a `CASE` plus an attempts increment), which a mocked transaction can only assert was called, never that Postgres agreed. They were written, scripted and documented, and nothing ran them — so they proved nothing. All three create and remove their own fixtures, so they are placed after the seed and leave the audited account untouched for the a11y gate that follows. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
The branch shipped 50 commits and the documents describing them stopped being true along the way. Corrected against commands, never against prose: - The event catalog moved for the first time since E.1a. `notification.read` makes it 82 / 35 public / 47 internal, and eight documents still claimed 81, 80 or 67. Where a document was recording a phase's *own* contribution rather than the running total, the stale total is dropped instead of refreshed — a value sheet is not a history. - `docs/HISTORY.md` gains what H.1 delivered beyond its plan: the dead 2FA path, the unbounded modal primitives, the replayable verification example, the public API reference, the org-scoped cache keys, the notification rail audit, the invisible personal tokens, and the test isolation that revealed ten tests were never running. - `apps/api/CLAUDE.md` still described `index.ts` as the place routes are mounted and the error handler installed; both moved to `app.ts` when app construction was split from server boot. - Three counts were wrong before this branch and are corrected too: README's a11y page count, `HEALTH-PROBES.md`'s claim that shutdown stops two runners when it stops four, and the policy gate's route count (19 business routes plus `PATCH /api/v1/me`, not 20 plus one). - `EVENTS.md`'s receiver snippet verified the first `v1=` only, contradicting the rotation paragraph directly above it, and checked the signature before the timestamp. Aligned with the snippet the product actually hands out. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
Deleting `if (this.reconnectTimer !== null) return;` from either service left every reconnection test passing, so the line that *is* the connection-leak fix was asserted by nothing. The reason the existing tests miss it is worth stating, because it is not an oversight in them: `dispose()` removes the client's listeners synchronously, so the `end` that `pg` emits right after `error` no longer reaches a handler — the double schedule cannot come from that pair any more. It comes from the one path left: `error` fires while `connect()` is still in flight and schedules, then `connect()` rejects and `connectListener`'s catch schedules again. Both tests now drive exactly that, and both fail with the guard removed. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
…ed it `check:digest` case 5 required the losing run to report `skipped: true`, which only happens when it reaches the lease before the winner releases it. On a slower runner the two runs serialize instead, the loser reports `skipped: false, flushed: 0`, and the check failed with nothing wrong — the window still produced exactly one digest, which the next two assertions already prove. The invariant is one window, one digest. The lease's contention semantics are covered against the same table by `check:sweep-lock`; what belongs here is that the second run sent nothing, whichever way it lost. Also stop the `if: failure()` API-log step from failing on its own when the run died before the API started — a missing-file error printed over the real one. Claude-Session: https://claude.ai/code/session_01CMYjy6MearjUeEkE3CT3ju
feat: back ↔ front surface parity (phase H.1) + the defects the audit surfaced
The header, its settings tab strip, the legal footer and every page each picked their own `max-w-*`: 7xl for the chrome, 6xl for the dashboard, 5xl for admin/webhooks/pricing, 3xl for settings. On a 2540px viewport the tab strip started 282px left of the card below it — every value was defensible on its own, and together they aligned to nothing. The rule between the two header rows made it worse: `border-t` sat on the constrained container, so it rendered as a 1280px segment floating inside a full-bleed bar. Widths now live in one cva, `pageContainerVariants`: `app` (5xl) for the chrome and the product pages, `prose` (3xl) for legal and reference text, which is bound by measure rather than by chrome alignment, `form` (md) for the lone-form pages, `wide` (7xl) for the footer. Call sites keep only their own `flex`/`gap-*`/`py-*`; horizontal padding comes from the container. When the business surface goes full-width behind a sidebar, one value in this file moves and the chrome follows. The footer is `wide` and not `app` for a measured reason: at 976px of usable width the six French labels total 905px on their own, leaving 14px per gap. It is centred, so it was never anchored to the content column anyway. Its links also drop the `underline` variant — 144px of tab padding for a rule that never draws, since no legal link is ever active — for a new `size="sm"` on `plain`. Verified in the browser: header, tabs and footer all resolve to the same box, and the footer holds one line at 873px (en) and 1078px (fr).
…ction `PUT /notifications/preferences` and `PUT /notifications/org-preferences` called `upsertPreference(...)` and then `emitEvent(...)` with no transaction between them, unlike `/read` and `/read-all` in the same file, which wrap both in `ITransactionService.run`. A crash or an outbox write failure between the two left the preference changed with no event emitted at all — root rule 6 says the emit belongs in the same TX for exactly this reason, and for the org route the lost event is `notification.org_preference.updated`, which carries the `locked` flag and is compliance-tagged, so the audit trail loses a change nothing can reconstruct afterwards. `upsertPreference` gains the optional `tx` its sibling store methods already take, and both handlers move inside `ITransactionService.run`. The two new tests assert the transaction handle itself, not the call count: counting calls passes just as well when both writes happen outside a transaction. Removing `tx` from the emit makes them fail.
…sing `/settings/webhooks`, `/settings/api-tokens` and `/admin/audit-log` rooted their page in a `<section>` with no `<main>` anywhere up the tree — the settings layout and the shell are both plain `<div>`s — so a screen reader had no main landmark to jump to on any of them. All three also wrote `<h1 className="text-2xl font-semibold">` instead of `TypographyH1`, the exact pattern `src/features/README.md` names as forbidden: the heading scale then lives at three call sites instead of in the theme. None of this was caught because none of the three pages was in the audit list. `/settings/api-tokens`, `/settings/notifications` and `/settings/webhooks` join it — a gate that does not know about a page is not a gate for that page. 28 audit tests now, up from 22.
`CONSENT_RETENTION_DAYS` and `NOTIFICATION_RETENTION_DAYS` are read by `env.ts` and by the sweep routes but were the only retention knobs absent from `apps/api/.env.example`. Both carry a default, so nothing broke — but a knob nobody can see is a knob nobody tunes, and the other six are listed right above them. `docs/FEATURES.md` claimed 4 public + 3 authenticated pages and 18 tests; the real figure was 4 + 4 before this release added three more, and the test count was never 18. Both counts now come from `a11y/pages.ts`, along with the README's page total.
|
🎉 This PR is included in version 1.24.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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.
Release PR. Everything on
devsince the last tag — 321 commits, 667 files (lock file aside), all of it already merged through PRs #59-#66 except the last commit.What lands
featfixrefactorperfdocs/test/chore/cisemantic-release will cut a minor.
Feature surface, by the PRs that built it:
markSent, sweep timeoutsen/frcatalogs in@packages/i18n/api/v1reference, a11y gate widened to 8 pages, seven real-database checks wired into CINew since #66
c9095d91— the shell and the pages it wraps resolved to four differentmax-w-*, so the settings tab strip started 282px left of the card below it, and the rule between the two header rows rendered as a 1280px segment inside a full-bleed bar. Widths now live in one cva (pageContainerVariants).Gates
pnpm check·type-check·check:unused·check:duplication(35 clones, 1.15%) · 869 api tests · 328 app tests · 22 a11y tests — all green locally. CI runs the same plus seven checks against a real Postgres.Merge
Merge commit, not squash — semantic-release reads every conventional commit.