npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@connextar/house

v0.20.0

Published

The pieces every app we build needs and none of them should own a copy of.

Readme

@connextar/house

The pieces every app we build needs and none of them should own a copy of.

Thirteen codebases grew from one shared way of building, with no shared code. This is the dependency that stops the same fix having to be made eleven times — but only for the things that are genuinely the same everywhere. It is deliberately small, and the reason it is small is worth reading before adding to it.

What is in it, and what is not

The portfolio was measured before anything was extracted. Divergence, against Quickie, of files that exist in six or more apps:

| File | copies | divergence | | ------------------ | ------ | ---------- | | error-shell.tsx | 6/6 | 0% | | check-motion.mjs | 3/6 | 11% | | e2e-local.sh | 6/6 | 30% | | button.tsx | 6/6 | 42% | | dialog.tsx | 5/6 | 62% | | card.tsx | 5/6 | 106% | | lib/api.ts | 6/6 | 152% | | auth.ts | 4/6 | 42–103% |

Everything below about 15% is an extraction. Everything above it is a rewrite wearing an extraction's clothes, and pulling it in here would mean thirteen apps adopting a component none of them currently has. So the rule is:

A shared package pays for itself on code that has not diverged yet. Extract what is identical, and what you are about to write for the second time. Leave the rest where it is and let bev's drift report keep score.

That is why there is no Button here, and why the error shell takes its actions as props instead of rendering its own. Bringing a second design system into an app that already has one is a cost with no matching benefit.

The 0.2.0 modules are the second half of that rule. AltEd built a versioned changelog, a navigation trail, a block editor and guide tables; every other app was about to write each of them for the second time.

ux, in 0.3.0, is the clearest case of that clause yet. Every app in the portfolio writes over fetch and refreshes afterwards, so every one of them has the same dead-click problem — a tick that goes nowhere for half a second, a button that looks broken while working perfectly — and none of them had solved it. It is also the kind of code nobody should write twice: the rules about when to let go of an optimistic change are small, subtle and easy to get quietly wrong.

wizard, in 0.4.0, is the first module extracted from five copies that had already been written. It is here because of what those copies cost: four of the five shipped a defect in it, and not one of the four was a mistake about wizards. PharmaLine created a booking from step two, because a type="button" Continue and a type="submit" Create shared a slot and React switched the one DOM node's type mid-click. Three PM Tool forms refused to save with nothing on screen saying why. MAS checked no step at all on submit, so a second press after a successful create sent a record whose first step had been cleared. Every one of those is a mistake about what a form must do before it agrees to send, and there is one right answer to that — which is exactly the shape of thing that belongs here and nowhere else. The module is the union of the five, not a copy of the best one.

jobs, in 0.11.0, is the same case with two copies. AltEd and Quickie grew their scheduled jobs from one seed and each proved something the other lacked — AltEd a switch with a name and a date against it, Quickie a Run now fenced to the caller's own organisation — and both printed a job's result as its variable names, so production read "0 chargesRaised · 0 totalOutstanding", with an amount of money shown as a number of things. The module is their union, with that made impossible rather than fixed: a count reaches the screen only through a declaration that says its words and its kind. 0.12.0 lets a job offer Run now to owners and operators both — Quickie's operators could not re-run a failed overnight sweep of a per-organisation job — and prunes the ledger without ever deleting a row health is read from.

password, in 0.13.0, is the other case in the rule: something ten apps were each about to write. None of them can show a password being typed, and the one strength meter in the portfolio is a checklist "Password1!" passes. A reveal toggle is easy to write and easy to get quietly wrong — a revealed password submitted from a text field lands in the browser's autofill history — and a strength estimate that differs between apps is worse than none. So there is one toggle and one estimate, and the estimate runs on the server too.

admin is the jobs case with six copies. Every app with platform administrators wrote its own "used a second factor" plugin, guard, "view as" and bootstrap script, and the copies drifted on exactly the parts that matter: which sessions may inherit the proof, whether a revoked session still counts, whether the view-as cookie is signed. The module is AltEd's model with each of those settled the safe way, and the app's words kept.

Modules

@connextar/house/errors

ErrorShell — the frame behind error.tsx and not-found.tsx. Renders plain elements with the house token classes (bg-card, border-border, text-muted-foreground), so it inherits the host app's look without importing a single thing from it. That matters here more than anywhere: it renders precisely when something upstream has already failed.

GlobalErrorShell — the whole of global-error.tsx. This one was the most duplicated file in the portfolio for a structural reason: a global error boundary replaces the root layout, so it cannot import the app's components, which left eight apps each carrying sixty lines of near-identical inline CSS whose only real difference was five hex values. Those five are now props.

// app/global-error.tsx
"use client";
import { GlobalErrorShell } from "@connextar/house/errors";
import "@styles/globals.css";

export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <GlobalErrorShell
      appName="Quickie"
      brand={{ background: "#ffffff", ink: "#0f172a", muted: "#5b6b7f", line: "#e2e8f0", accent: "#0575b0" }}
      error={error}
      reset={reset}
    />
  );
}

@connextar/house/auth

The two-factor sign-in policy. Better Auth's twoFactor plugin hooks /sign-in/email, /sign-in/username and /sign-in/phone-number and nothing else — every other sign-in route issues a session outright, second factor or not. On an app where magic link is a first-class sign-in method that makes two-factor decorative: a stolen mailbox is still enough.

This module decides which routes to refuse, and why. It is here rather than in each app because it has to be the same everywhere and because getting it wrong is silent — two-factor keeps looking enabled while doing nothing.

The lookup is injected, because only the app knows its own user table:

import { shouldBlockForTwoFactor } from "@connextar/house/auth";

const blocked = await shouldBlockForTwoFactor(ctx.path, email, async (address) => {
  const user = await prisma.user.findUnique({ where: { email: address }, select: { twoFactorEnabled: true } });
  return user?.twoFactorEnabled === true;
});

Passkey and social sign-in are deliberately still allowed. A passkey is already possession of a device and is phishing-resistant; a social login delegates to a provider enforcing its own second factor. Neither is weaker than password plus TOTP. A link in an inbox is.

@connextar/house/auth/second-factor

The code screen after a magic link and after a social sign-in, so every sign-in path treats two-factor the same way.

Better Auth's twoFactor plugin only intercepts the password routes (/sign-in/email, /sign-in/username, /sign-in/phone-number). A magic link (/magic-link/verify) and a Google, Microsoft or Apple callback (/callback/:id) hand out a full session with no code. Until now the house apps refused magic links for two-factor accounts, and let social sign-in skip the code. secondFactorEverywhere() fixes both. For an account with two-factor on, a magic link or a social sign-in now ends up exactly where a password sign-in does:

  • No session survives. The session the route just created is deleted, and its cookies (including the cached session_data) are expired.
  • The pending state is Better Auth's own. It uses the same signed two_factor cookie and verification row, so /two-factor/verify-totp and /two-factor/verify-backup-code finish the sign-in unchanged. Attempt limits, account lockout and "trust this device" all keep working.
  • A trusted device skips the code under the plugin's own rule, and its trust cookie is rotated.
  • The browser goes to the code screen instead of callbackURL. The original destination comes along as ?next=, but only when it is a path on this site. Another origin (even one Better Auth trusts), //host, /\host and javascript: are all dropped.
  • A JSON caller, such as a magic-link verify without callbackURL or /sign-in/social with an idToken, gets the plugin's own { twoFactorRedirect: true }. twoFactorClient's onTwoFactorRedirect already handles that.

Passkeys stay a single step. A passkey proves possession of a device and resists phishing, so it is excluded and cannot be added to the list. Adding a passkey is another matter: see auth/step-up.

How it works. This module copies none of Better Auth's pending-state internals: no cookie names, no signing, no identifiers. On these routes it runs the two-factor plugin's own password-sign-in after-hook, then turns the plugin's { twoFactorRedirect: true } into a redirect to the code screen. It depends on three things, and second-factor.test.ts pins each of them against the installed Better Auth:

  • the plugin exposes that hook;
  • the hook still covers only the password routes;
  • the hook answers twoFactorRedirect when it takes the session away.

init also refuses to start if it cannot find the hook. The tests run a real Better Auth instance (memory adapter, the real twoFactor and magicLink plugins, a stub OAuth provider, requests through auth.handler with a cookie jar) against both lines the apps use. The house Vitest project runs them against 1.7.2. The better-auth-1.6 project runs them against 1.6.33 (better-auth-1-6, an npm alias, re-pointed with a Vitest alias). The supported range is >=1.6.33 <1.8.0. Before you widen it, update the dev dependency and read the new two-factor plugin.

The range is not declared as a peer dependency. 0.13.0 declared it as an optional one, and npm still walks an optional peer's own optional peers: through Better Auth to @sveltejs/kit and its vite 8, which made npm install fail in an app with no Better Auth and vite 7 (Canisters). The module is only imported by apps that already have Better Auth, and init's hook check is what actually refuses an incompatible one.

Server

import { secondFactorEverywhere } from "@connextar/house/auth/second-factor";

plugins: [
  twoFactor({ … }),
  passkey({ … }),
  secondFactorSessions(),          // the app's own proof recorder: unchanged
  secondFactorEverywhere(),        // after twoFactor(), before nextCookies()
  magicLink({ … }),
],

| Option | Default | What it does | | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | codeScreenPath | /auth/two-factor | Where the code screen is. Every house app already uses this path. | | nextParam | next | The query parameter that carries the destination. | | appOrigin | origin of Better Auth's baseURL | The origin of the code screen, and the only origin a next may point to. | | routes | all of SECOND_FACTOR_CHALLENGE_ROUTES | /magic-link/verify, /callback/:id, /oauth2/callback/:providerId, /sign-in/social (idToken), /one-tap/callback, /sign-in/email-otp. A route that is not installed never matches. | | providers | "all" | Limits the social routes to these provider ids, e.g. ["google", "microsoft"]. | | enabled | true | Set it to false to opt this app out and keep the plugin listed. The old magic-link refusal comes back (see below). |

Composing with second-factor-session. Your existing plugin needs no change. The session a magic link or a social callback creates is deleted before the response goes out. The session that counts is created by /two-factor/verify-totp or /two-factor/verify-backup-code, and your plugin already records those as proven. The tests check this with a stand-in recorder.

One optional refinement: on a trusted device, a magic-link or social session survives, just as /sign-in/email does today. Your plugin does not count that session as proven, so an administrator is asked to sign in again. That fails safe. If you want those sessions to count the way /sign-in/email does, extend your provesSecondFactor rule with care: providers can leave some callbacks unchallenged.

The code screen

When the code or backup code is accepted, go to the safe next:

import { secondFactorNext } from "@connextar/house/auth";

const { error } = await authClient.twoFactor.verifyTotp({ code, trustDevice });
if (!error) router.replace(secondFactorNext(searchParams)); // "/" if next is missing or unsafe

@connextar/house/auth does not import Better Auth, so client components can use it. safeNextPath(candidate, fallback) is the rule underneath.

Retiring the magic-link refusal

Pass the hook's ctx.context as a fourth argument to shouldBlockForTwoFactor:

if (await shouldBlockForTwoFactor(ctx.path, email, lookup, ctx.context)) throw new APIError("BAD_REQUEST", { … });

While secondFactorEverywhere() is installed, enabled and covering /magic-link/verify, the refusal steps aside and skips the lookup (magicLinkRefusalRetired(ctx.context)). If the challenge is ever switched off, the refusal comes back by itself, so turning the challenge off cannot quietly reopen the magic-link hole. You can delete the refusal outright once every environment runs the plugin. Keeping the fourth argument is the safe middle ground.

Email link scanners

Link scanners such as Microsoft Defender Safe Links, Mimecast and Proofpoint open the link before the person does. A magic link works only once, so the scanner uses it up. With two-factor on, the scanner gets only the pending code state (never a session), and the person's own click lands on ?error=INVALID_TOKEN. Without two-factor it was already this way, except that the scanner got a full session. A test pins this behaviour.

Mitigation, optional and simple: put a confirm page between the email and the verify URL. Scanners fetch pages; they do not press buttons.

// sendMagicLink: email the confirm page instead of Better Auth's URL
import { magicLinkConfirmHref } from "@connextar/house/auth";
sendMagicLink: async ({ email, url }) => send(email, magicLinkConfirmHref(url, "/auth/confirm-link")),

// app/auth/confirm-link/page.tsx: a "Continue signing in" button
import { magicLinkVerifyHref } from "@connextar/house/auth";
const href = magicLinkVerifyHref(searchParams, "/api/auth/magic-link/verify"); // null → "link incomplete"
<a href={href}>Continue signing in</a>

magicLinkVerifyHref rebuilds the verify URL from the app's own fixed path, never from the query. It keeps only the token and same-site redirect targets. Better Auth still origin-checks those targets itself.

Adopting it in an app

This applies to AltEd, Quickie, PM Tool, senco, KRP, FB Web (1.7.2) and PharmaLine (1.6.33). All of them already use /auth/two-factor and ?next=.

  1. Bump @connextar/house.
  2. In auth.ts, add secondFactorEverywhere() after twoFactor() and before nextCookies(), if the app has it.
  3. In the hooks.before refusal, pass ctx.context to shouldBlockForTwoFactor, or delete the refusal and the app's lib/auth/two-factor.ts binding. Do not change the deleted-account or suspended-account checks.
  4. On the code screen, send the person to secondFactorNext(searchParams) after a successful code, or keep the app's own safe-next helper if it already validates next.
  5. Update the copy. Remove the settings dialog line "email sign-in links stop working on this account… Passkeys and Google sign-in keep working" and replace it with something like "you'll be asked for your code after an email link or Google sign-in too; passkeys stay one step".
  6. Update comments that say social or magic-link sessions skip the second factor (second-factor-session.ts, AltEd admin-second-factor.ts). The admin proof rule itself does not change.
  7. Add an app test: a magic link for a two-factor account redirects to /auth/two-factor?next=… with no session, and the code then signs in with proof.
  8. Optional: the confirm page for link scanners.

@connextar/house/auth/step-up, /auth/step-up/client

A new way in needs a recent proof of an existing one.

@better-auth/passkey lets any session under a day old add a passkey. /passkey/generate-register-options and /passkey/verify-registration check only freshSessionMiddleware (session.freshAge, one day by default): no password, no code. A passkey then signs in in one step, because it is left out of the second-factor challenge on purpose. So a stolen or borrowed session becomes a lasting way in that skips two-factor, and a password reset does not remove it, because a reset revokes sessions, not passkeys. Think of a shared computer left signed in, a cookie taken through XSS, or a trusted-device sign-in that skipped the code. All seven house apps with passkeys had this hole. Their own "second factor proven" plugins closed it for platform administrators only.

Two-factor management is not part of this hole. Better Auth's /two-factor/enable, /two-factor/disable and /two-factor/generate-backup-codes already ask for the password.

stepUp() refuses both passkey registration routes until the session holds a confirmation younger than the window (ten minutes by default). What counts as a confirmation depends on the account:

| The account has | needs | What confirms it | | ----------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | two-factor on | code | The authenticator code or a backup code, entered now. The client calls Better Auth's own /two-factor/verify-totp or /two-factor/verify-backup-code while signed in. | | no two-factor, a password | password | The password, sent to POST /step-up/password. | | neither (email link or social only) | recent-sign-in | A sign-in within the window: the session itself is younger than windowSeconds. There is nothing to type, so the screen asks them to sign in again. |

  • A confirmation belongs to one session. It is a row in Better Auth's verification table (step-up:<session id>, with the user id as its value, expiring after the window). A confirmation made by another session, or recorded for another user, never counts. Using it does not spend it: the options call and the verify call both need it, and a second passkey added inside the window needs nothing more.
  • The session is read from the session store, never from the cookie cache. A revoked session gets a 401 even while its cached cookie is still valid.
  • The refusal is 403 with { code: "STEP_UP_REQUIRED", message: "Confirm it's you before adding a passkey.", needs }, so the client knows which prompt to show.
  • Wrong answers. Better Auth's two-factor lockout and attempt limit apply only to a sign-in. A code checked with a live session is held back by the rate limiter and nothing else. So each session gets maxAttempts wrong answers (five by default, codes and passwords together). After that it has to sign in again, and its live code checks are refused too (403 STEP_UP_LOCKED). A stolen session cannot keep guessing. The count is kept for 400 days past the session's expiry (since 0.19.2): Better Auth moves a session's expiry on as it is used, so a count kept only until then let a long-lived session's wrong answers lapse and refill.
  • Deleting a passkey is not gated. Removing a way in does not add one.
  • The apps' administrator rule is unchanged and still applies on top of this. An administrator's passkey needs both a second-factor-proven session and a step-up confirmation. Either refusal can stop it, and neither breaks the other.

How the code path works. None of the two-factor plugin is copied: no secret, no decryption. Called with a live session, Better Auth's verify routes check the code and create nothing. They answer with the token of the session they were called with. When such a call succeeds, this plugin records a confirmation for that session. It does this only if the account already had two-factor on before the call. On an account still enrolling, the same call ends enrolment and replaces the session, and it proves only that the person holds the authenticator they just added. A code that finishes a sign-in does not count either: it created the session rather than confirming one, so the person confirms once more before adding a passkey. step-up.test.ts pins each of these facts, and init refuses to start if the two-factor plugin is installed without its verify routes, or the passkey plugin without its registration routes.

On an account with two-factor on, someone who has the session and the password can re-key the authenticator (Better Auth's /two-factor/enable) and then produce codes. So for those accounts the real bar is "the code, or the password". It is never the session alone. That only stops holding under twoFactor({ allowPasswordless: true }), which no house app sets, and init warns if one does.

The tests run a real Better Auth instance: memory adapter, the real twoFactor, passkey and magicLink plugins, a software authenticator, and requests through auth.handler with a cookie jar. They run against 1.7.2 with @better-auth/passkey 1.7.2 and against 1.6.33 with @better-auth/passkey 1.6.33 (better-auth-passkey-1-6, re-pointed by the better-auth-1.6 Vitest project, the same way as second-factor).

Server

import { stepUp } from "@connextar/house/auth/step-up";

plugins: [
  twoFactor({ … }),
  passkey({ … }),
  secondFactorSessions(),          // the app's own admin proof: unchanged
  secondFactorEverywhere(),
  stepUp({                         // after twoFactor() and passkey(), before nextCookies()
    onPasskeyAdded: ({ user, passkey }) => sendPasskeyAddedEmail(user.email, passkey.name),
  }),
  magicLink({ … }),
],

| Option | Default | What it does | | ---------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | windowSeconds | 600 | How long a confirmation lasts, and how young a session must be to count as a recent sign-in. | | routes | STEP_UP_PASSKEY_ROUTES | The routes that need a confirmation. To add one, keep the defaults: [...STEP_UP_PASSKEY_ROUTES, "/my/route"]. Other routes get a plain message. Router endpoints only: a server-only method such as setPassword has no path, so listing it guards nothing (see below). | | maxAttempts | 5 | Wrong codes and passwords one session may give before it has to sign in again. | | onPasskeyAdded | none | Called after /passkey/verify-registration succeeds. See below. | | enabled | true | false turns the refusal off and keeps the endpoints, so a client that asks for status still gets an answer. onPasskeyAdded still runs. |

POST /step-up/password is rate-limited the same way as Better Auth's own two-factor routes: three tries in ten seconds, wherever the app's rate limiter is on. A wrong password gets Better Auth's own 400 INVALID_PASSWORD. An account that does not confirm with a password gets 400 STEP_UP_PASSWORD_NOT_ACCEPTED with its needs.

A passkey-first sign-up (passkey({ registration: { requireSession: false } })) is let through when there is no session, because there is no existing way in to prove. With a session, the rule applies as usual.

The client

import { stepUpClient } from "@connextar/house/auth/step-up/client";

export const authClient = createAuthClient({ plugins: [twoFactorClient(), passkeyClient(), stepUpClient()] });

authClient.stepUp.status() answers { needs, confirmed, confirmedUntil, locked, sessionFresh }, and authClient.stepUp.password({ password }) gives the same shape on success. Better Auth's client parser turns confirmedUntil into a Date. The client entry imports nothing from Better Auth at runtime. The helpers are also on @connextar/house/auth.

The flow for "Add a passkey":

  1. status(). If sessionFresh is false, show the sign-in-again message and stop: the passkey plugin refuses a session older than Better Auth's session.freshAge (a day by default) whatever it confirms, and with month-long sessions that is most people. Asking for a password first and then saying "sign in again" is the thing to avoid. If confirmed, go to step 3. (Setting a first password has no such limit; that screen can ignore sessionFresh.)
  2. Show the prompt that needs asks for, in the app's own dialog with its own PasswordInput:
    • code: a code field, then authClient.twoFactor.verifyTotp({ code }), or verifyBackupCode({ code }) behind "Use a backup code".
    • password: a password field, then authClient.stepUp.password({ password }).
    • recent-sign-in: "For your security, sign in again to add a passkey", with a button that signs out and returns to the sign-in page with ?next= back here.
    • locked: the same sign-in-again message.
  3. authClient.passkey.addPasskey({ name }).
  4. If that answers STEP_UP_REQUIRED (the window ran out while the person was at the browser's passkey sheet), prompt and retry once:
import { isStepUpRequired, stepUpNeedsOf } from "@connextar/house/auth/step-up/client";

let result = await authClient.passkey.addPasskey({ name });
if (isStepUpRequired(result.error) && (await confirmItsYou(stepUpNeedsOf(result.error)))) {
  result = await authClient.passkey.addPasskey({ name });
}

There is no React component here. Each app already has its own dialog, its own PasswordInput and its own code field.

The email

onPasskeyAdded({ user, passkey: { id, name?, createdAt? }, request? }) runs once for each passkey added, after the passkey is stored. Use it to send "A passkey was added to your account" through the app's own mailer. The email should say when, give the name if there is one, and include "If this wasn't you, remove it in Settings → Security and change your password". The callback cannot fail or undo the add: an error is logged and the passkey stays. If Better Auth's advanced.backgroundTasks is set (for example waitUntil on Vercel), the callback runs in the background. Otherwise the response waits for it. request is there for the app's own audit line (IP address, user agent).

Setting a first password is the same hole

An account that signs in by email link or Google has no password, and the house apps let it add one from Settings through their own POST /api/auth/set-password, which calls Better Auth's setPassword. A session alone was enough, so a borrowed session could add a password: a new way in, exactly like a passkey.

routes cannot guard it. setPassword is server-only and has no path (checked on 1.7.2 by senco's adoption, which pins auth.api.setPassword.path as undefined), so a hook never sees it. Check in the route instead:

const status = await auth.api.stepUpStatus({ headers: request.headers });
if (!status.confirmed) {
  return Response.json(
    { code: "STEP_UP_REQUIRED", message: "Confirm it's you before adding a password.", needs: status.needs },
    { status: 403 },
  );
}
await auth.api.setPassword({ body: { newPassword }, headers: request.headers });
// then email "A password was added to your account", never failing the request

The answer has the same shape as the plugin's refusal, so isStepUpRequired recognises it and the same dialog serves both. Such an account has no password to type, so needs is recent-sign-in, or code if it has two-factor.

Adopting it in an app

This applies to AltEd, Quickie, PM Tool, senco, KRP, FB Web (1.7.2) and PharmaLine (1.6.33).

  1. Bump @connextar/house to 0.14.0.
  2. In auth.ts, add stepUp({ onPasskeyAdded }) after twoFactor() and passkey(), and before nextCookies().
  3. Write sendPasskeyAddedEmail next to the app's other auth emails, and pass it in onPasskeyAdded.
  4. Add stepUpClient() to the auth client.
  5. In the "Add a passkey" flow, call status() first, show the prompt that needs asks for, then addPasskey. Retry once on isStepUpRequired. The code prompt calls twoFactor.verifyTotp or verifyBackupCode while signed in. Do not route it through the sign-in code screen.
  6. Leave the admin second-factor proof rule (second-factor-session.ts, AltEd admin-second-factor.ts) as it is. Update only its comment that says "Better Auth lets any fresh session register a passkey": that is now true only for administrators who have confirmed.
  7. Add an app test: a signed-in session cannot get passkey registration options until it confirms, and then it can.
  8. Copy: the security settings should say that adding a passkey asks for the code, the password, or a fresh sign-in, and that an email follows.

@connextar/house/admin, /admin/second-factor, /admin/client, /admin/testing

Who may use administration, how an administrator sees the product as somebody else, and what the trail says about either.

Six apps have platform administrators, and each wrote the same four things: a rule for who gets in, a Better Auth plugin recording which sessions used a second factor, a "view as", and a script to grant the first administrator. They agree on the rule and disagree on almost everything else. Three record prefixes, two places to record from (a database hook in AltEd, Quickie and PharmaLine, an after-hook in senco and FB Web), and two of the six forget that a revoked session outlives its cookie cache. FB Web lets any session minted by a signed-in one inherit the proof, so a passkey registration with createSession: true opened its admin tools with no code. AltEd's view-as cookie is unsigned by design. Nobody records an administrator signing in. This module is AltEd's model, made the default everywhere.

| Entry point | What | Imports | | -------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------- | | @connextar/house/admin | adminAccess, decideAdminAccess, viewAs, the write guard, audit events, bootstrap | node:crypto only | | @connextar/house/admin/second-factor | adminSecondFactorSessions(), the Better Auth plugin | better-auth/api | | @connextar/house/admin/client | <AdminRefusalNotice>, <ViewingAsBanner> | React, lucide-react | | @connextar/house/admin/testing | assertAdminGuardCoverage, memoryCookieJar | node:fs, node:path |

/admin imports no Better Auth and no Prisma: every lookup is injected. That is what lets Canisters, which has its own auth, take the decision, the audit shape and "view as" as they are. The plugin is on its own subpath for the same reason. entry-points.test.ts follows the built imports to keep it that way.

Who gets in

import { adminAccess } from "@connextar/house/admin";

export const access = adminAccess(
  {
    isAdmin: async (id) =>
      (await prisma.user.findUnique({ where: { id }, select: { platformAdmin: true } }))?.platformAdmin === true,
    twoFactorEnabled: async (id) =>
      (await prisma.user.findUnique({ where: { id }, select: { twoFactorEnabled: true } }))?.twoFactorEnabled === true,
    sessionLive: async (id, userId) =>
      (await prisma.session.count({ where: { id, userId, expiresAt: { gt: new Date() } } })) > 0,
    // sessionProven: defaults to the plugin's record, read through `auth`
  },
  {
    auth,
    recordPrefix: "admin-second-factor:",
    words: { "two-factor-off": { fixHref: "/settings?tab=security#two-factor" } },
  },
);

const decision = await access.check(currentUser, { viewingAs: !!currentUser.impersonatedBy });
// { ok: true } or { ok: false, reason, message, fixHref, status }

An administrator gets in only when all of these hold, checked in this order:

| reason | Status | Refused when | | ------------------------------- | ------ | ------------------------------------------------------------------------------------------------- | | signed-out | 401 | Nobody is signed in. | | not-admin | 403 | isAdmin is not true. Read from the database on every request, never from the session payload. | | session-ended | 401 | The session row is gone or expired. | | two-factor-off | 403 | Two-factor is off for the account. | | sign-in-without-second-factor | 403 | This session was not made with the code, a backup code or a passkey. | | viewing-as | 403 | The request is a "view as". For that hour the administrator is not one. |

  • sessionLive is always asked, from the session store. Better Auth serves a session from its cookie cache for minutes after it was revoked, and the proof record outlives a revoked session. So "sign out everywhere" has to end administration at once, not when the cache runs out. senco and FB Web fixed this window in their own copies. The shared one cannot have it, because the lookup is required.
  • Fail-closed. Only true counts for any fact. A lookup that answers undefined refuses, and one that throws fails the request.
  • A non-administrator costs one lookup. The other three run together, in one round trip, and only for administrators.
  • The words are the app's. words overrides the message and fixHref of any reason. adminRefusalFromParam(searchParams.admin, words) gives the settings page the same sentence the route refused with. toString and arrays give null. decideAdminAccess(facts, words) is the pure decision, for an app that gathers the facts itself (Canisters).
  • AdminRefusalError (status, reason, refusal) and assertAdminAccess(decision, makeError?) are for code that answers by throwing. Pass makeError to throw the app's own ForbiddenError.

Server: the plugin

import { adminSecondFactorSessions } from "@connextar/house/admin/second-factor";

plugins: [
  twoFactor({ … }),
  passkey({ … }),
  secondFactorEverywhere(),
  adminSecondFactorSessions({        // after twoFactor() and secondFactorEverywhere(), before nextCookies()
    isAdmin: (id) => isPlatformAdmin(id),
    recordPrefix: "admin-second-factor:",    // the app's existing prefix, so its sessions keep counting
    onAdminSignedIn: (event) => writeAuditLog(event),
  }),
  stepUp({ … }),
  magicLink({ … }),
],

It replaces six copies. On Better Auth's own session.create database hook it writes a row in the verification table, <recordPrefix><session id>, holding the user's id and expiring with the session as it was issued. It does this only for an administrator's session made by:

  • /two-factor/verify-totp or /two-factor/verify-backup-code. That covers the code at sign-in after a password, an email link or a social sign-in, and the code that ends enrolment, which replaces the enrolling session;
  • /passkey/verify-authentication;
  • /sign-in/email for an account with two-factor on, when trustedDeviceCounts is set (the default, AltEd's rule). The two-factor plugin deletes that session straight after unless the device's trust cookie, which only a correct code issues, lets it stand. A record for a deleted session matches nothing.

Only /change-password inherits. It carries over the proof of the session it replaces, and expires no later than that proof. Every other route that mints a session for a signed-in account proves nothing: /passkey/verify-registration with createSession: true (1.7), /two-factor/disable, /two-factor/enable with skipVerificationOnEnable, a social or email-link sign-in on a trusted device, /verify-email, /two-factor/verify-otp, and any route a later Better Auth adds. No record, no administration.

The back door is closed. From a session without the record, an administrator cannot re-key two-factor (/two-factor/enable once it is on), read its key (/two-factor/get-totp-uri), make new backup codes, turn it off, or add a passkey. These answer 403 ADMIN_SECOND_FACTOR_REQUIRED. Turning two-factor on for the first time stays open. With two-factor still off, a passkey waits (403 ADMIN_PASSKEY_NEEDS_TWO_FACTOR): a passkey added from a password-only session would become a way in that skips the code once enrolment is done. stepUp() still applies to everybody on top of this, and either refusal stops a registration.

A revoked session is refused on those routes for everyone, with a 401. The routes' own sessionMiddleware believes the five-minute cookie cache. In FB Web, a signed-out administrator's kept cookies could read the authenticator's secret, make backup codes, re-key two-factor or register a passkey. The gate reads the session authoritatively and refuses a cookie whose row is gone or expired. A request with no session at all, such as a passkey-first sign-up, is left to the route.

onAdminSignedIn(event, signIn) receives an admin.signed_in event for every session an administrator signs in with that is still standing when the response leaves, proven or not. The method is one of code, backup-code, passkey, trusted-device, password, email-link, social or other. It never fires for the password step that is still waiting for its code, for a password change, or for a two-factor change rotating the session. No app audits sign-ins today. The callback cannot fail the sign-in: an error is logged. With Better Auth's advanced.backgroundTasks set, it runs in the background. Because it has to see the session the two-factor hooks leave standing, init refuses to start if the plugin is listed before twoFactor() or secondFactorEverywhere() while onAdminSignedIn is set. Without the callback, the record does not depend on order.

| Option | Default | What it does | | --------------------- | ---------------------- | --------------------------------------------------------------------------------- | | isAdmin | required | Whether the account is an administrator now, read from the database. | | recordPrefix | admin-second-factor: | The record's prefix. Pass the app's existing one. | | trustedDeviceCounts | true | A password sign-in on a trusted device counts, for an account with two-factor on. | | onAdminSignedIn | none | The sign-in event, for the app's audit log. | | standing, scope | "admin", platform | The actor's standing and the event's scope. | | messages | the house's | factorChange, passkeyNeedsTwoFactor. |

Expired records for the same account are swept whenever a new one is written.

The tests (admin/second-factor.test.ts) run a real Better Auth instance with the memory adapter, the real twoFactor, passkey and magicLink plugins, secondFactorEverywhere() and stepUp(), a software authenticator that both registers and signs in, and requests through auth.handler with a cookie jar. They run against 1.7.2 and 1.6.33, in the same two Vitest projects as auth/second-factor. One test pins what the plugin leans on: a database hook sees the creating route's path and, on a password change, the request's own session, which is already deleted by then. The sign-in event decides "proven" by the record's rule rather than reading the record back, because on 1.6 the database after-hook has not run yet when the endpoint's after-hooks do.

View as

import { cookies } from "next/headers";
import { viewAs, refuseWhileViewingAs, viewAsWriteGuard, allowWhileViewing } from "@connextar/house/admin";

export const view = viewAs({
  cookieName: "quickie_view_as",
  secret: process.env.BETTER_AUTH_SECRET!,
  cookies, // Next's cookies() fits as it is
  access, // the app's adminAccess
  audit: writeAuditLog,
  names: async (id) => (await prisma.user.findUnique({ where: { id }, select: { name: true } }))?.name,
});

// POST /api/admin/view-as — after the route's admin guard and the app's own target lookup
const started = await view.start(admin, { id: target.id, name: target.name }, requestMeta(request));
// DELETE /api/admin/view-as — from the real session, which is not an admin while viewing
await view.stop(realUser);
// getCurrentUser(): every request
const viewing = await view.read(realUser); // { targetId, adminId, expiresAt } or null; never writes

AltEd's rules, made safe by default:

  • Signed. The cookie is HMAC-SHA256 over the target, the administrator who started it and the expiry, keyed by secret and the cookie name. An edited, forged, re-signed or unsigned cookie (AltEd's format) is worthless. AltEd left its cookie unsigned on the argument that only an administrator could use it. That holds only while every reader checks the binding the same way. Signing costs one HMAC and removes the question.

  • Bound to the administrator, and only while they are one. read checks the real session against adminAccess on every request. A demotion, a revoked session or a sign-in without the second factor ends the view at once. The next person to sign in on a shared machine inherits nothing.

  • Never another administrator. Refused at start, and checked again on every read, so a promotion mid-view ends it.

  • Short. Sixty minutes by default (minutes, at most a day). The expiry is inside the signed value, so the server decides, not the browser.

  • Recorded. admin.view_as_started and admin.view_as_stopped, with the administrator as the actor and viewingAs naming the person. stop works for anybody signed in and records only when the view was that administrator's.

  • Read-only. prisma.$extends(viewAsWriteGuard({ allow, getViewing })) refuses every write while viewing, except the app's allow-list of writes that happen as a side effect of reading. The rule is inverted from "block what is audited", which AltEd learnt the hard way: deleting an account ran session.deleteMany and passkey.deleteMany before it reached the user row. The guard is typed structurally, with no Prisma import. If an app's Prisma types object, wrap it in Prisma.defineExtension(…).

    viewAsWriteGuard({
      allow: allowWhileViewing({
        Session: ["update", "updateMany"], // Better Auth touching the operator's own session
        RateLimit: ["create", "update", "upsert", "updateMany"], // the limiter keeps counting
        AuditLog: ["create"], // the view's own start and stop
      }),
      getViewing: async () => (await getRequestActor())?.viewingAs ?? null,
      makeError: (message) => new ForbiddenError(message),
    });
  • Outside calls (Stripe, GoCardless, Xero, AI, email, SMS) are refused by default, not route by route. Quickie had to add refuseWhileViewingAs to thirty handlers by hand, and the next route would have had to remember. Instead, build one gate over the app's request actor and call it inside the app's own wrappers. Every route that sends or charges through them is then covered:

    export const viewing = viewingAsGate({
      getViewing: async () => (await getRequestActor())?.viewingAs ?? null,
      makeError: (message) => new ForbiddenError(message),
    });
    
    export async function sendEmail(message: Email) {
      await viewing.assertNotViewingAs("sending an email"); // fail-closed: a lookup that throws refuses too
      return mailer.send(message);
    }

    The gate reads the app's request-scoped actor rather than keeping an AsyncLocalStorage of its own. The view is resolved inside getCurrentUser, after its session read, and a store entered there does not propagate back to the route's later calls (view-as.test.ts pins this). A withViewingAs(…, fn) wrapper around each handler would bring back the per-route step the gate exists to remove. Quickie's and AltEd's actor is keyed on Next's per-request headers() object, which every call in a request shares. refuseWhileViewingAs(user, "starting a checkout", { makeError }) stays, for explicit use first in a route.

start refuses yourself and another administrator with a 400, and an administrator who does not pass adminAccess with that refusal. It sets the cookie httpOnly, sameSite: lax, and secure in production.

Audit

import type { AdminAuditEvent } from "@connextar/house/admin";

const writeAuditLog = async (event: AdminAuditEvent) =>
  prisma.auditLog.create({
    data: {
      organizationId: event.scope.kind === "tenant" ? event.scope.tenantId : null,
      actorId: event.actor?.id ?? null,
      actorName: event.actor?.name ?? null,
      actorRole: event.actor?.standing ?? null,
      action: event.action,
      entityType: event.entity.type,
      entityId: event.entity.id,
      summary: event.summary,
      changes: { ...event.changes, ...(event.viewingAs ? { viewingAs: event.viewingAs } : {}) },
    },
  });

AdminAuditEvent is { action, actor: { id, name, standing } | null, viewingAs?, scope: { kind: "platform" } | { kind: "tenant", tenantId }, entity: { type, id }, summary, changes?, at, ip?, userAgent? }. Each app maps it to its own AuditLog; the house writes no table. The scope is stated outright, because the apps disagree: AltEd has no tenants, while Quickie and PM Tool record platform actions with a null organizationId. The builders are adminSignedIn, roleChanged, viewAsStarted, viewAsStopped and platformAction. Each trims summary to the apps' 300-character column. requestMeta(request) gives { ip, userAgent }. emitSafely(sink, event) logs and swallows a sink's error, because recording must never break the thing being recorded.

Bootstrap

// scripts/bootstrap-admin.ts
import { bootstrapAdmin, bootstrapAdminArgs } from "@connextar/house/admin";

const args = bootstrapAdminArgs(process.argv.slice(2)); // email, --force, --revoke, --allow-without-two-factor
const result = await bootstrapAdmin(
  {
    findUser: (email) => prisma.user.findUnique({ where: { email } }).then(toBootstrapUser),
    countAdmins: () => prisma.user.count({ where: { platformAdmin: true, deletedAt: null } }),
    setAdmin: (id, isAdmin) => prisma.user.update({ where: { id }, data: { platformAdmin: isAdmin } }).then(() => {}),
    twoFactorEnabled: async (id) => (await prisma.user.findUnique({ where: { id } }))?.twoFactorEnabled === true,
    audit: writeAuditLog,
  },
  { ...args, standing: "platform-admin" },
);
console.log(result.message);
process.exit(result.ok ? 0 : 1);

These are PM Tool's rules, the stricter of the two:

  • The account must exist, be verified and not be deleted. The script creates nothing, as AltEd's did. An unverified row may be somebody else's sign-up with this address.
  • The account must already have two-factor on. Otherwise whoever first holds its mailbox or a session enrols the code that opens administration.
  • If an administrator already exists, the script refuses unless --force is given. --force never skips two-factor.
  • Every run is recorded: the grant and the revoke as user.role_changed with no actor, and a refusal as admin.bootstrap_refused. If the trail cannot be written, the script throws, because a person is watching it.
  • --allow-without-two-factor is the documented first-run escape, for a deployment whose only way to turn two-factor on is behind administration. It works only while no administrator exists, and the event records that it was used. adminAccess still refuses that administrator until they turn two-factor on and sign in with it.
  • An option the parser does not know is an error, so a typo never quietly grants without the flag that was meant.

The client

import { AdminRefusalNotice, ViewingAsBanner } from "@connextar/house/admin/client";

// Settings › Account & security, from ?admin=
<AdminRefusalNotice refusal={adminRefusalFromParam(searchParams.admin, access.words)} />

// The root layout, while a view is in force
<ViewingAsBanner name={user.name} expiresAt={view.expiresAt} onStop={stopViewing} />

Both are drawn on the shared theme tokens (destructive, primary, muted, border), with every label and class overridable. The notice draws nothing without a refusal, and draws no link where there is nowhere to go. The banner is sticky and has no close button: the only way to make it go is to stop. A refusal thrown by onStop is reported through the app's ActionFeedbackProvider, and the banner stays.

Testing: the guard-coverage sweep

import { assertAdminGuardCoverage } from "@connextar/house/admin/testing";

it("every admin surface goes through the guard", () => {
  const report = assertAdminGuardCoverage({
    apiDirs: ["app/api/admin"],
    pageDirs: ["app/dashboard/admin"],
    apiGuard: "requirePlatformAdmin",
    pageGuard: "requirePlatformAdminPage",
    exempt: { "app/api/admin/view-as/route.ts DELETE": "stopping a view grants nothing" },
    flagReaders: {
      pattern: /platformAdmin|isPlatformAdmin/,
      dirs: ["app", "components", "lib"],
      allowed: { "lib/server/session.ts": "the guard itself", "components/dashboard/nav.tsx": "display prop" },
      ignore: (file) => file.startsWith("lib/db/generated/"),
    },
    minimum: { routes: 6, pages: 14 },
  });
  expect(report.handlers.length).toBeGreaterThanOrEqual(12);
});

This is PharmaLine's admin-guard.test.ts, the strongest of the apps', with Quickie's escape checks added. It reports every problem at once:

  • the guard is the first await of every route handler, not merely called after a body was parsed or a row read. It looks in route files under the pages folder too;
  • a handler exported in a form the sweep cannot read (export const GET, export { x as GET }, a non-async function) is reported;
  • every page file awaits the page guard itself, because a layout is not re-rendered between its children (PM Tool);
  • a "use server" file under either folder is reported, since it is an endpoint the sweep cannot see;
  • only allow-listed files mention the raw flag, and an entry that no longer mentions it is reported too;
  • a missing folder, an exemption naming no handler, or fewer files than minimum is a problem, so a moved folder cannot pass with nothing checked.

Comments are stripped when looking for the guard and kept when looking for the flag. memoryCookieJar() stands in for cookies() in an app's own "view as" tests.

Adopting it in an app

The owner approved building it on AltEd's model and adopting it in phases, offering "view as" in every app with platform administrators, and requiring two-factor for MAS operators.

(a) Swap the plugin and the guard, behaviour unchanged. AltEd, Quickie, senco, PharmaLine, PM Tool and FB Web.

  1. Bump @connextar/house.

  2. In auth.ts, replace the app's own plugin, or its databaseHooks recorder and hooks.before factor-change refusal, with adminSecondFactorSessions(). List it after twoFactor() and secondFactorEverywhere(). Pass the app's existing prefix, so sessions already proven stay proven:

    | App | recordPrefix | Replaces | | ---------- | ------------------------- | -------------------------------------------------------------------------------------------- | | AltEd | admin-second-factor: | lib/auth/admin-second-factor.ts (recordSecondFactorSession, refuseAdminFactorChange) | | Quickie | operator-second-factor- | lib/admin/operator-access.ts (recordSecondFactorSignIn, operatorSecurityChangeRefusal) | | PharmaLine | admin-second-factor: | lib/auth/second-factor-session.ts (adminSecondFactor()) | | PM Tool | second-factor-session: | lib/auth/second-factor-session.ts | | senco | second-factor-session: | lib/auth/second-factor-session.ts (secondFactorSessions()) | | FB Web | second-factor-session: | lib/auth/second-factor-session.ts (secondFactorSessions()) |

  3. Rebuild the app's guard on adminAccess(…). AltEd's adminGateFor, Quickie's platformAdminRefusal, PharmaLine's platformAdminAccess, PM Tool's checkAdminAccess and the others keep their names and callers. Their bodies become access.check(…), and their reasons map onto the shared ones. Pass each app's sentences and fixHrefs in words, so every refusal says what it said before.

  4. Keep the app's own tests of its refusals, and make them pass unchanged. Then replace its guard-coverage test with assertAdminGuardCoverage and the same allow-list.

  5. Three things change on purpose, and the tests say so:

    • Inheritance is /change-password only. FB Web's plugin let any session made by a signed-in one inherit (passkey createSession: true, /two-factor/disable, a trusted-device social sign-in). AltEd let any same-account session inherit.
    • A revoked session gets a 401 on the factor-change routes, for everyone, and administration reads sessionLive every time. PharmaLine and PM Tool did not check that the session was live.
    • An administrator's passkey waits for two-factor, and only first enrolment is open while it is off. Quickie let an operator without two-factor add a passkey, and AltEd and Quickie left every two-factor route open while it was off. No app's screens call those routes then.

(b) Audit scope and sign-in events. Map AdminAuditEvent to each app's AuditLog. Quickie and PM Tool's nullable organizationId takes scope.kind === "platform"; AltEd, with no tenants, ignores the scope. Pass the sink as onAdminSignedIn, and record role changes through roleChanged and operator actions through platformAction (PM Tool's recordPlatformAction becomes a call to it). Move each bootstrap script onto bootstrapAdmin. AltEd's stops creating accounts and starts requiring two-factor.

(c) "View as" in every app with platform administrators. AltEd and Quickie move their cookie onto viewAs (the signed cookie replaces the old one, so a view in progress at deploy ends once) and their Prisma guard onto viewAsWriteGuard with their existing allow-lists. senco, PharmaLine, PM Tool and FB Web gain it: a POST/DELETE /api/admin/view-as route, view.read in getCurrentUser, the write guard, viewingAsGate().assertNotViewingAs() inside the app's email, SMS, payment and AI wrappers, and <ViewingAsBanner> in the root layout. Quickie moves its thirty refuseWhileViewingAs calls into its wrappers the same way.

(d) The apps without the pieces yet.

  • MAS first adds Better Auth's twoFactor, because its operators must use two-factor and today nothing can record a second factor. Then it takes phase (a) whole.
  • The Stripe gateway adds two-factor for its operators and an audit table to map events into.
  • Canisters has its own auth, so it takes only decideAdminAccess (it gathers the facts itself), the audit event shape, and "view as". /admin imports no Better Auth for exactly this reason.

@connextar/house/account

The contract for "your data" and "delete my account" — types, the export notice, and the sole-owner refusal message. The implementation stays per-app, because an export is a walk over that app's Prisma schema. What must not be per-app is the shape of the answer, since that is what a person receives and a regulator reads.

Two decisions are encoded here rather than left to be rediscovered:

  • An export summarises authored work rather than including it. A note somebody wrote about a customer is the customer's personal data with an author attached; shipping the contents would turn a subject access request into a way for one employee to walk out with the customer book.
  • Deletion follows the schema. onDelete: Cascade is the app already saying "personal, goes with them"; onDelete: SetNull is "business record, outlives them". Invoices have to survive, and an audit trail erasable by its subject was never an audit trail.

App-specific sections go in extras, so one app's furniture does not become everyone's obligation.

@connextar/house/release

Version arithmetic and the release-history guard behind every app's changelog. releaseHistoryProblems(changelog, { pkg, lock }) returns, as sentences, every way the changelog, package.json and the lockfile disagree — a version that skips a step, dates out of order, a release with no title. An app's lib/release.test.ts asserts it is empty. nextVersion(current, kind) applies the 0.x rule (a MAJOR-class change bumps the minor before 1.0.0).

The changelog has two readers. The team reads every change in its own words; the public reads publicChangelog(CHANGELOG) — the newest five reviewed releases, with audience: "internal" changes and sections left out (operations, infrastructure, limits, security mechanics), public: { … } wording in place of the team's where that says too much, and one generic "Behind the scenes" line wherever something was left out. A release reaches the public only once it has its public review (even public: {}), so a forgotten review hides a release rather than leaking it, and publicChangelogProblems(CHANGELOG) — asserted empty beside releaseHistoryProblems — fails until the newest five have one.

The process itself — how to classify a change, how to write the notes, the runbook — is docs/ReleaseProcess.md, shipped in the package. Each app's own docs/operations/ReleaseProcess.md links to it and adds only what is its own: its public API, its trigger documents, its history note.

Beside it, docs/RoutineOperations.md is the method for the recurring checks that keep an app ready, aligned and compliant — security rounds, release gates, bills, backups, the privacy policy against the code — each with when it last ran and when it's next due. An app copies docs/templates/RoutineOperations.md to its own docs/operations/RoutineOperations.md: a short summary of the method, then its own register and run log. Its AGENTS.md tells every agent to read the Next due column first.

@connextar/house/changelog

<Changelog releases={CHANGELOG} /> — the body of a /changelog page. Each release shows its version, whether it was a major, minor or patch release, its date and a one-line summary; changes are grouped under headings, each with a bold headline and a tag. A list of releases sits beside the content on wide screens and becomes a scrolling strip on a phone. The app keeps its hero and calls to action around it, and maps the four change types onto its palette with tagClassNames.

A long history goes a page at a time: paging={{ page, pageSize, href }}, still passing the whole CHANGELOG, so "Latest" and each release's kind stay right on later pages. A pager under the releases links newer, older and every page by its version range. changelogPageCount sizes the app's static pages, and changelogPageOf(CHANGELOG, version, pageSize) lets an old #v0-12-0 link find the page it moved to. (AltEd's single page had reached 1.8 MB of HTML at 108 releases.)

One URL serves both readers: the page passes publicChangelog(CHANGELOG) to everyone, and the whole CHANGELOG with showAudience to the people allowed the team's view (each app decides who — PharmaLineAI's platform admins). showAudience marks internal changes, releases not yet public, and the public wording under each change that has one, so the team can see exactly what the public reads.

Each release the public list shows carries previousVersion, the release it really followed in the whole history, and <Changelog> labels it major, minor or patch from that. Without it the oldest of the five had nothing under it and showed no kind (0.16.0), and a release shown above a hidden, unreviewed one was measured against the wrong version. An app that worked out the kind itself can drop that and pass publicChangelog(CHANGELOG) straight in.

@connextar/house/whats-new

The dialog that tells a signed-in person what changed since they last looked, from the changelog the app already keeps. Without it, a changed entry that says what to do now is read only by whoever happens to open /changelog.

The app stores one thing — the version someone last saw, in a nullable column — and everything else follo