@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_factorcookie and verification row, so/two-factor/verify-totpand/two-factor/verify-backup-codefinish 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,/\hostandjavascript:are all dropped. - A JSON caller, such as a magic-link verify without
callbackURLor/sign-in/socialwith anidToken, gets the plugin's own{ twoFactorRedirect: true }.twoFactorClient'sonTwoFactorRedirectalready 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
twoFactorRedirectwhen 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=.
- Bump
@connextar/house. - In
auth.ts, addsecondFactorEverywhere()aftertwoFactor()and beforenextCookies(), if the app has it. - In the
hooks.beforerefusal, passctx.contexttoshouldBlockForTwoFactor, or delete the refusal and the app'slib/auth/two-factor.tsbinding. Do not change the deleted-account or suspended-account checks. - 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 validatesnext. - 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".
- Update comments that say social or magic-link sessions skip the second factor
(
second-factor-session.ts, AltEdadmin-second-factor.ts). The admin proof rule itself does not change. - 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. - 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
verificationtable (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
403with{ 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
maxAttemptswrong 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":
status(). IfsessionFreshis false, show the sign-in-again message and stop: the passkey plugin refuses a session older than Better Auth'ssession.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. Ifconfirmed, go to step 3. (Setting a first password has no such limit; that screen can ignoresessionFresh.)- Show the prompt that
needsasks for, in the app's own dialog with its ownPasswordInput:code: a code field, thenauthClient.twoFactor.verifyTotp({ code }), orverifyBackupCode({ code })behind "Use a backup code".password: a password field, thenauthClient.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.
authClient.passkey.addPasskey({ name }).- 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 requestThe 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).
- Bump
@connextar/houseto 0.14.0. - In
auth.ts, addstepUp({ onPasskeyAdded })aftertwoFactor()andpasskey(), and beforenextCookies(). - Write
sendPasskeyAddedEmailnext to the app's other auth emails, and pass it inonPasskeyAdded. - Add
stepUpClient()to the auth client. - In the "Add a passkey" flow, call
status()first, show the prompt thatneedsasks for, thenaddPasskey. Retry once onisStepUpRequired. The code prompt callstwoFactor.verifyTotporverifyBackupCodewhile signed in. Do not route it through the sign-in code screen. - Leave the admin second-factor proof rule (
second-factor-session.ts, AltEdadmin-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. - Add an app test: a signed-in session cannot get passkey registration options until it confirms, and then it can.
- 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. |
sessionLiveis 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
truecounts for any fact. A lookup that answersundefinedrefuses, 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.
wordsoverrides themessageandfixHrefof any reason.adminRefusalFromParam(searchParams.admin, words)gives the settings page the same sentence the route refused with.toStringand arrays givenull.decideAdminAccess(facts, words)is the pure decision, for an app that gathers the facts itself (Canisters). AdminRefusalError(status,reason,refusal) andassertAdminAccess(decision, makeError?)are for code that answers by throwing. PassmakeErrorto throw the app's ownForbiddenError.
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-totpor/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/emailfor an account with two-factor on, whentrustedDeviceCountsis 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 writesAltEd'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
secretand 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.
readchecks the real session againstadminAccesson 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 everyread, 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_startedandadmin.view_as_stopped, with the administrator as the actor andviewingAsnaming the person.stopworks 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 ransession.deleteManyandpasskey.deleteManybefore it reached the user row. The guard is typed structurally, with no Prisma import. If an app's Prisma types object, wrap it inPrisma.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
refuseWhileViewingAsto 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
AsyncLocalStorageof its own. The view is resolved insidegetCurrentUser, after its session read, and a store entered there does not propagate back to the route's later calls (view-as.test.tspins this). AwithViewingAs(…, 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-requestheaders()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
--forceis given.--forcenever skips two-factor. - Every run is recorded: the grant and the revoke as
user.role_changedwith no actor, and a refusal asadmin.bootstrap_refused. If the trail cannot be written, the script throws, because a person is watching it. --allow-without-two-factoris 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.adminAccessstill 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
awaitof 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-asyncfunction) 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
minimumis 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.
Bump
@connextar/house.In
auth.ts, replace the app's own plugin, or itsdatabaseHooksrecorder andhooks.beforefactor-change refusal, withadminSecondFactorSessions(). List it aftertwoFactor()andsecondFactorEverywhere(). 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()) |Rebuild the app's guard on
adminAccess(…). AltEd'sadminGateFor, Quickie'splatformAdminRefusal, PharmaLine'splatformAdminAccess, PM Tool'scheckAdminAccessand the others keep their names and callers. Their bodies becomeaccess.check(…), and their reasons map onto the shared ones. Pass each app's sentences andfixHrefs inwords, so every refusal says what it said before.Keep the app's own tests of its refusals, and make them pass unchanged. Then replace its guard-coverage test with
assertAdminGuardCoverageand the same allow-list.Three things change on purpose, and the tests say so:
- Inheritance is
/change-passwordonly. FB Web's plugin let any session made by a signed-in one inherit (passkeycreateSession: 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
sessionLiveevery 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.
- Inheritance is
(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"./adminimports 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: Cascadeis the app already saying "personal, goes with them";onDelete: SetNullis "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
