@connextar/house 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +673 -8
  2. package/dist/admin/access.d.ts +134 -0
  3. package/dist/admin/access.d.ts.map +1 -0
  4. package/dist/admin/access.js +157 -0
  5. package/dist/admin/access.js.map +1 -0
  6. package/dist/admin/audit.d.ts +130 -0
  7. package/dist/admin/audit.d.ts.map +1 -0
  8. package/dist/admin/audit.js +132 -0
  9. package/dist/admin/audit.js.map +1 -0
  10. package/dist/admin/bootstrap.d.ts +80 -0
  11. package/dist/admin/bootstrap.d.ts.map +1 -0
  12. package/dist/admin/bootstrap.js +106 -0
  13. package/dist/admin/bootstrap.js.map +1 -0
  14. package/dist/admin/client-index.d.ts +7 -0
  15. package/dist/admin/client-index.d.ts.map +1 -0
  16. package/dist/admin/client-index.js +6 -0
  17. package/dist/admin/client-index.js.map +1 -0
  18. package/dist/admin/client.d.ts +72 -0
  19. package/dist/admin/client.d.ts.map +1 -0
  20. package/dist/admin/client.js +90 -0
  21. package/dist/admin/client.js.map +1 -0
  22. package/dist/admin/index.d.ts +13 -0
  23. package/dist/admin/index.d.ts.map +1 -0
  24. package/dist/admin/index.js +13 -0
  25. package/dist/admin/index.js.map +1 -0
  26. package/dist/admin/proof.d.ts +34 -0
  27. package/dist/admin/proof.d.ts.map +1 -0
  28. package/dist/admin/proof.js +30 -0
  29. package/dist/admin/proof.js.map +1 -0
  30. package/dist/admin/second-factor-index.d.ts +8 -0
  31. package/dist/admin/second-factor-index.d.ts.map +1 -0
  32. package/dist/admin/second-factor-index.js +8 -0
  33. package/dist/admin/second-factor-index.js.map +1 -0
  34. package/dist/admin/second-factor.d.ts +156 -0
  35. package/dist/admin/second-factor.d.ts.map +1 -0
  36. package/dist/admin/second-factor.js +271 -0
  37. package/dist/admin/second-factor.js.map +1 -0
  38. package/dist/admin/testing-index.d.ts +6 -0
  39. package/dist/admin/testing-index.d.ts.map +1 -0
  40. package/dist/admin/testing-index.js +6 -0
  41. package/dist/admin/testing-index.js.map +1 -0
  42. package/dist/admin/testing.d.ts +84 -0
  43. package/dist/admin/testing.d.ts.map +1 -0
  44. package/dist/admin/testing.js +223 -0
  45. package/dist/admin/testing.js.map +1 -0
  46. package/dist/admin/view-as.d.ts +209 -0
  47. package/dist/admin/view-as.d.ts.map +1 -0
  48. package/dist/admin/view-as.js +251 -0
  49. package/dist/admin/view-as.js.map +1 -0
  50. package/dist/guides/guide-figure-zoom.d.ts +33 -8
  51. package/dist/guides/guide-figure-zoom.d.ts.map +1 -1
  52. package/dist/guides/guide-figure-zoom.js +34 -9
  53. package/dist/guides/guide-figure-zoom.js.map +1 -1
  54. package/dist/guides/library.d.ts +22 -10
  55. package/dist/guides/library.d.ts.map +1 -1
  56. package/dist/guides/library.js +3 -0
  57. package/dist/guides/library.js.map +1 -1
  58. package/dist/markdown/guide.d.ts +12 -5
  59. package/dist/markdown/guide.d.ts.map +1 -1
  60. package/dist/markdown/guide.js +9 -5
  61. package/dist/markdown/guide.js.map +1 -1
  62. package/dist/release/changelog.d.ts +19 -0
  63. package/dist/release/changelog.d.ts.map +1 -1
  64. package/dist/release/changelog.js +37 -6
  65. package/dist/release/changelog.js.map +1 -1
  66. package/dist/release/index.d.ts +1 -1
  67. package/dist/release/index.d.ts.map +1 -1
  68. package/dist/release/index.js +1 -1
  69. package/dist/release/index.js.map +1 -1
  70. package/dist/whats-new/body.d.ts +81 -0
  71. package/dist/whats-new/body.d.ts.map +1 -0
  72. package/dist/whats-new/body.js +29 -0
  73. package/dist/whats-new/body.js.map +1 -0
  74. package/dist/whats-new/index.d.ts +24 -0
  75. package/dist/whats-new/index.d.ts.map +1 -0
  76. package/dist/whats-new/index.js +24 -0
  77. package/dist/whats-new/index.js.map +1 -0
  78. package/dist/whats-new/seen.d.ts +23 -0
  79. package/dist/whats-new/seen.d.ts.map +1 -0
  80. package/dist/whats-new/seen.js +33 -0
  81. package/dist/whats-new/seen.js.map +1 -0
  82. package/dist/whats-new/select.d.ts +89 -0
  83. package/dist/whats-new/select.d.ts.map +1 -0
  84. package/dist/whats-new/select.js +117 -0
  85. package/dist/whats-new/select.js.map +1 -0
  86. package/dist/whats-new/use-whats-new.d.ts +36 -0
  87. package/dist/whats-new/use-whats-new.d.ts.map +1 -0
  88. package/dist/whats-new/use-whats-new.js +81 -0
  89. package/dist/whats-new/use-whats-new.js.map +1 -0
  90. package/docs/ReleaseProcess.md +12 -0
  91. package/package.json +21 -1
package/README.md CHANGED
@@ -80,6 +80,13 @@ submitted from a text field lands in the browser's autofill history — and a
80
80
  strength estimate that differs between apps is worse than none. So there is one
81
81
  toggle and one estimate, and the estimate runs on the server too.
82
82
 
83
+ `admin` is the jobs case with six copies. Every app with platform
84
+ administrators wrote its own "used a second factor" plugin, guard, "view as" and
85
+ bootstrap script, and the copies drifted on exactly the parts that matter: which
86
+ sessions may inherit the proof, whether a revoked session still counts, whether
87
+ the view-as cookie is signed. The module is AltEd's model with each of those
88
+ settled the safe way, and the app's words kept.
89
+
83
90
  ## Modules
84
91
 
85
92
  ### `@connextar/house/errors`
@@ -548,6 +555,491 @@ PharmaLine (1.6.33).
548
555
  8. Copy: the security settings should say that adding a passkey asks for the
549
556
  code, the password, or a fresh sign-in, and that an email follows.
550
557
 
558
+ ### `@connextar/house/admin`, `/admin/second-factor`, `/admin/client`, `/admin/testing`
559
+
560
+ Who may use administration, how an administrator sees the product as somebody
561
+ else, and what the trail says about either.
562
+
563
+ Six apps have platform administrators, and each wrote the same four things:
564
+ a rule for who gets in, a Better Auth plugin recording which sessions used a
565
+ second factor, a "view as", and a script to grant the first administrator. They
566
+ agree on the rule and disagree on almost everything else. Three record prefixes,
567
+ two places to record from (a database hook in AltEd, Quickie and PharmaLine, an
568
+ after-hook in senco and FB Web), and two of the six forget that a revoked session
569
+ outlives its cookie cache. FB Web lets any session minted by a signed-in one
570
+ inherit the proof, so a passkey registration with `createSession: true` opened its
571
+ admin tools with no code. AltEd's view-as cookie is unsigned by design. Nobody
572
+ records an administrator signing in. This module is AltEd's model, made the
573
+ default everywhere.
574
+
575
+ | Entry point | What | Imports |
576
+ | -------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------- |
577
+ | `@connextar/house/admin` | `adminAccess`, `decideAdminAccess`, `viewAs`, the write guard, audit events, bootstrap | `node:crypto` only |
578
+ | `@connextar/house/admin/second-factor` | `adminSecondFactorSessions()`, the Better Auth plugin | `better-auth/api` |
579
+ | `@connextar/house/admin/client` | `<AdminRefusalNotice>`, `<ViewingAsBanner>` | React, `lucide-react` |
580
+ | `@connextar/house/admin/testing` | `assertAdminGuardCoverage`, `memoryCookieJar` | `node:fs`, `node:path` |
581
+
582
+ `/admin` imports no Better Auth and no Prisma: every lookup is injected. That is
583
+ what lets Canisters, which has its own auth, take the decision, the audit shape
584
+ and "view as" as they are. The plugin is on its own subpath for the same reason.
585
+ `entry-points.test.ts` follows the built imports to keep it that way.
586
+
587
+ #### Who gets in
588
+
589
+ ```ts
590
+ import { adminAccess } from "@connextar/house/admin";
591
+
592
+ export const access = adminAccess(
593
+ {
594
+ isAdmin: async (id) =>
595
+ (await prisma.user.findUnique({ where: { id }, select: { platformAdmin: true } }))?.platformAdmin === true,
596
+ twoFactorEnabled: async (id) =>
597
+ (await prisma.user.findUnique({ where: { id }, select: { twoFactorEnabled: true } }))?.twoFactorEnabled === true,
598
+ sessionLive: async (id, userId) =>
599
+ (await prisma.session.count({ where: { id, userId, expiresAt: { gt: new Date() } } })) > 0,
600
+ // sessionProven: defaults to the plugin's record, read through `auth`
601
+ },
602
+ {
603
+ auth,
604
+ recordPrefix: "admin-second-factor:",
605
+ words: { "two-factor-off": { fixHref: "/settings?tab=security#two-factor" } },
606
+ },
607
+ );
608
+
609
+ const decision = await access.check(currentUser, { viewingAs: !!currentUser.impersonatedBy });
610
+ // { ok: true } or { ok: false, reason, message, fixHref, status }
611
+ ```
612
+
613
+ An administrator gets in only when all of these hold, checked in this order:
614
+
615
+ | `reason` | Status | Refused when |
616
+ | ------------------------------- | ------ | ------------------------------------------------------------------------------------------------- |
617
+ | `signed-out` | 401 | Nobody is signed in. |
618
+ | `not-admin` | 403 | `isAdmin` is not `true`. Read from the database on every request, never from the session payload. |
619
+ | `session-ended` | 401 | The session row is gone or expired. |
620
+ | `two-factor-off` | 403 | Two-factor is off for the account. |
621
+ | `sign-in-without-second-factor` | 403 | This session was not made with the code, a backup code or a passkey. |
622
+ | `viewing-as` | 403 | The request is a "view as". For that hour the administrator is not one. |
623
+
624
+ - **`sessionLive` is always asked, from the session store.** Better Auth serves
625
+ a session from its cookie cache for minutes after it was revoked, and the
626
+ proof record outlives a revoked session. So "sign out everywhere" has to end
627
+ administration at once, not when the cache runs out. senco and FB Web fixed
628
+ this window in their own copies. The shared one cannot have it, because the
629
+ lookup is required.
630
+ - **Fail-closed.** Only `true` counts for any fact. A lookup that answers
631
+ `undefined` refuses, and one that throws fails the request.
632
+ - **A non-administrator costs one lookup.** The other three run together, in
633
+ one round trip, and only for administrators.
634
+ - **The words are the app's.** `words` overrides the `message` and `fixHref`
635
+ of any reason. `adminRefusalFromParam(searchParams.admin, words)` gives the
636
+ settings page the same sentence the route refused with. `toString` and arrays
637
+ give `null`. `decideAdminAccess(facts, words)` is the pure decision, for an app
638
+ that gathers the facts itself (Canisters).
639
+ - `AdminRefusalError` (`status`, `reason`, `refusal`) and
640
+ `assertAdminAccess(decision, makeError?)` are for code that answers by
641
+ throwing. Pass `makeError` to throw the app's own `ForbiddenError`.
642
+
643
+ #### Server: the plugin
644
+
645
+ ```ts
646
+ import { adminSecondFactorSessions } from "@connextar/house/admin/second-factor";
647
+
648
+ plugins: [
649
+ twoFactor({ … }),
650
+ passkey({ … }),
651
+ secondFactorEverywhere(),
652
+ adminSecondFactorSessions({ // after twoFactor() and secondFactorEverywhere(), before nextCookies()
653
+ isAdmin: (id) => isPlatformAdmin(id),
654
+ recordPrefix: "admin-second-factor:", // the app's existing prefix, so its sessions keep counting
655
+ onAdminSignedIn: (event) => writeAuditLog(event),
656
+ }),
657
+ stepUp({ … }),
658
+ magicLink({ … }),
659
+ ],
660
+ ```
661
+
662
+ It replaces six copies. On Better Auth's own `session.create` database hook it
663
+ writes a row in the `verification` table, `<recordPrefix><session id>`, holding
664
+ the user's id and expiring with the session as it was issued. It does this only
665
+ for an administrator's session made by:
666
+
667
+ - `/two-factor/verify-totp` or `/two-factor/verify-backup-code`. That covers the
668
+ code at sign-in after a password, an email link or a social sign-in, and the
669
+ code that ends enrolment, which replaces the enrolling session;
670
+ - `/passkey/verify-authentication`;
671
+ - `/sign-in/email` for an account with two-factor on, when
672
+ `trustedDeviceCounts` is set (the default, AltEd's rule). The two-factor plugin
673
+ deletes that session straight after unless the device's trust cookie, which
674
+ only a correct code issues, lets it stand. A record for a deleted session
675
+ matches nothing.
676
+
677
+ **Only `/change-password` inherits.** It carries over the proof of the session
678
+ it replaces, and expires no later than that proof. Every other route that mints a
679
+ session for a signed-in account proves nothing:
680
+ `/passkey/verify-registration` with `createSession: true` (1.7), `/two-factor/disable`,
681
+ `/two-factor/enable` with `skipVerificationOnEnable`, a social or email-link
682
+ sign-in on a trusted device, `/verify-email`, `/two-factor/verify-otp`, and any
683
+ route a later Better Auth adds. No record, no administration.
684
+
685
+ **The back door is closed.** From a session without the record, an
686
+ administrator cannot re-key two-factor (`/two-factor/enable` once it is on),
687
+ read its key (`/two-factor/get-totp-uri`), make new backup codes, turn it off, or
688
+ add a passkey. These answer `403 ADMIN_SECOND_FACTOR_REQUIRED`. Turning
689
+ two-factor on for the first time stays open. With two-factor still off, a
690
+ passkey waits (`403 ADMIN_PASSKEY_NEEDS_TWO_FACTOR`): a passkey added from a
691
+ password-only session would become a way in that skips the code once enrolment
692
+ is done. `stepUp()` still applies to everybody on top of this, and either
693
+ refusal stops a registration.
694
+
695
+ **A revoked session is refused on those routes for everyone, with a 401.** The
696
+ routes' own `sessionMiddleware` believes the five-minute cookie cache. In FB Web,
697
+ a signed-out administrator's kept cookies could read the authenticator's secret,
698
+ make backup codes, re-key two-factor or register a passkey. The gate reads the
699
+ session authoritatively and refuses a cookie whose row is gone or expired. A
700
+ request with no session at all, such as a passkey-first sign-up, is left to the
701
+ route.
702
+
703
+ **`onAdminSignedIn(event, signIn)`** receives an `admin.signed_in` event for
704
+ every session an administrator signs in with that is still standing when the
705
+ response leaves, proven or not. The method is one of `code`, `backup-code`,
706
+ `passkey`, `trusted-device`, `password`, `email-link`, `social` or `other`.
707
+ It never fires for the password step that is still waiting for its code, for a
708
+ password change, or for a two-factor change rotating the session. No app audits
709
+ sign-ins today. The callback cannot fail the sign-in: an error is logged. With
710
+ Better Auth's `advanced.backgroundTasks` set, it runs in the background. Because
711
+ it has to see the session the two-factor hooks leave standing, `init` refuses to
712
+ start if the plugin is listed before `twoFactor()` or `secondFactorEverywhere()`
713
+ while `onAdminSignedIn` is set. Without the callback, the record does not depend
714
+ on order.
715
+
716
+ | Option | Default | What it does |
717
+ | --------------------- | ---------------------- | --------------------------------------------------------------------------------- |
718
+ | `isAdmin` | required | Whether the account is an administrator now, read from the database. |
719
+ | `recordPrefix` | `admin-second-factor:` | The record's prefix. Pass the app's existing one. |
720
+ | `trustedDeviceCounts` | `true` | A password sign-in on a trusted device counts, for an account with two-factor on. |
721
+ | `onAdminSignedIn` | none | The sign-in event, for the app's audit log. |
722
+ | `standing`, `scope` | `"admin"`, platform | The actor's standing and the event's scope. |
723
+ | `messages` | the house's | `factorChange`, `passkeyNeedsTwoFactor`. |
724
+
725
+ Expired records for the same account are swept whenever a new one is written.
726
+
727
+ The tests (`admin/second-factor.test.ts`) run a real Better Auth instance with
728
+ the memory adapter, the real `twoFactor`, `passkey` and `magicLink` plugins,
729
+ `secondFactorEverywhere()` and `stepUp()`, a software authenticator that both
730
+ registers and signs in, and requests through `auth.handler` with a cookie jar.
731
+ They run against **1.7.2** and **1.6.33**, in the same two Vitest projects as
732
+ `auth/second-factor`. One test pins what the plugin leans on: a database hook
733
+ sees the creating route's path and, on a password change, the request's own
734
+ session, which is already deleted by then. The sign-in event decides "proven"
735
+ by the record's rule rather than reading the record back, because on 1.6 the
736
+ database after-hook has not run yet when the endpoint's after-hooks do.
737
+
738
+ #### View as
739
+
740
+ ```ts
741
+ import { cookies } from "next/headers";
742
+ import { viewAs, refuseWhileViewingAs, viewAsWriteGuard, allowWhileViewing } from "@connextar/house/admin";
743
+
744
+ export const view = viewAs({
745
+ cookieName: "quickie_view_as",
746
+ secret: process.env.BETTER_AUTH_SECRET!,
747
+ cookies, // Next's cookies() fits as it is
748
+ access, // the app's adminAccess
749
+ audit: writeAuditLog,
750
+ names: async (id) => (await prisma.user.findUnique({ where: { id }, select: { name: true } }))?.name,
751
+ });
752
+
753
+ // POST /api/admin/view-as — after the route's admin guard and the app's own target lookup
754
+ const started = await view.start(admin, { id: target.id, name: target.name }, requestMeta(request));
755
+ // DELETE /api/admin/view-as — from the real session, which is not an admin while viewing
756
+ await view.stop(realUser);
757
+ // getCurrentUser(): every request
758
+ const viewing = await view.read(realUser); // { targetId, adminId, expiresAt } or null; never writes
759
+ ```
760
+
761
+ AltEd's rules, made safe by default:
762
+
763
+ - **Signed.** The cookie is HMAC-SHA256 over the target, the administrator who
764
+ started it and the expiry, keyed by `secret` and the cookie name. An edited,
765
+ forged, re-signed or unsigned cookie (AltEd's format) is worthless. AltEd left
766
+ its cookie unsigned on the argument that only an administrator could use it.
767
+ That holds only while every reader checks the binding the same way. Signing
768
+ costs one HMAC and removes the question.
769
+ - **Bound to the administrator, and only while they are one.** `read` checks the
770
+ real session against `adminAccess` on every request. A demotion, a revoked
771
+ session or a sign-in without the second factor ends the view at once. The
772
+ next person to sign in on a shared machine inherits nothing.
773
+ - **Never another administrator.** Refused at `start`, and checked again on
774
+ every `read`, so a promotion mid-view ends it.
775
+ - **Short.** Sixty minutes by default (`minutes`, at most a day). The expiry is
776
+ inside the signed value, so the server decides, not the browser.
777
+ - **Recorded.** `admin.view_as_started` and `admin.view_as_stopped`, with the
778
+ administrator as the actor and `viewingAs` naming the person. `stop` works for
779
+ anybody signed in and records only when the view was that administrator's.
780
+ - **Read-only.** `prisma.$extends(viewAsWriteGuard({ allow, getViewing }))`
781
+ refuses every write while viewing, except the app's allow-list of writes that
782
+ happen as a side effect of reading. The rule is inverted from "block what is
783
+ audited", which AltEd learnt the hard way: deleting an account ran
784
+ `session.deleteMany` and `passkey.deleteMany` before it reached the user row.
785
+ The guard is typed structurally, with no Prisma import. If an app's Prisma
786
+ types object, wrap it in `Prisma.defineExtension(…)`.
787
+
788
+ ```ts
789
+ viewAsWriteGuard({
790
+ allow: allowWhileViewing({
791
+ Session: ["update", "updateMany"], // Better Auth touching the operator's own session
792
+ RateLimit: ["create", "update", "upsert", "updateMany"], // the limiter keeps counting
793
+ AuditLog: ["create"], // the view's own start and stop
794
+ }),
795
+ getViewing: async () => (await getRequestActor())?.viewingAs ?? null,
796
+ makeError: (message) => new ForbiddenError(message),
797
+ });
798
+ ```
799
+
800
+ - **Outside calls** (Stripe, GoCardless, Xero, AI, email, SMS) are refused by
801
+ default, not route by route. Quickie had to add `refuseWhileViewingAs` to thirty
802
+ handlers by hand, and the next route would have had to remember. Instead, build
803
+ one gate over the app's request actor and call it inside the app's own
804
+ wrappers. Every route that sends or charges through them is then covered:
805
+
806
+ ```ts
807
+ export const viewing = viewingAsGate({
808
+ getViewing: async () => (await getRequestActor())?.viewingAs ?? null,
809
+ makeError: (message) => new ForbiddenError(message),
810
+ });
811
+
812
+ export async function sendEmail(message: Email) {
813
+ await viewing.assertNotViewingAs("sending an email"); // fail-closed: a lookup that throws refuses too
814
+ return mailer.send(message);
815
+ }
816
+ ```
817
+
818
+ The gate reads the app's request-scoped actor rather than keeping an
819
+ `AsyncLocalStorage` of its own. The view is resolved inside `getCurrentUser`,
820
+ after its session read, and a store entered there does not propagate back to
821
+ the route's later calls (`view-as.test.ts` pins this). A `withViewingAs(…, fn)`
822
+ wrapper around each handler would bring back the per-route step the gate
823
+ exists to remove. Quickie's and AltEd's actor is keyed on Next's per-request
824
+ `headers()` object, which every call in a request shares.
825
+ `refuseWhileViewingAs(user, "starting a checkout", { makeError })` stays, for
826
+ explicit use first in a route.
827
+
828
+ `start` refuses yourself and another administrator with a `400`, and an
829
+ administrator who does not pass `adminAccess` with that refusal. It sets the
830
+ cookie `httpOnly`, `sameSite: lax`, and `secure` in production.
831
+
832
+ #### Audit
833
+
834
+ ```ts
835
+ import type { AdminAuditEvent } from "@connextar/house/admin";
836
+
837
+ const writeAuditLog = async (event: AdminAuditEvent) =>
838
+ prisma.auditLog.create({
839
+ data: {
840
+ organizationId: event.scope.kind === "tenant" ? event.scope.tenantId : null,
841
+ actorId: event.actor?.id ?? null,
842
+ actorName: event.actor?.name ?? null,
843
+ actorRole: event.actor?.standing ?? null,
844
+ action: event.action,
845
+ entityType: event.entity.type,
846
+ entityId: event.entity.id,
847
+ summary: event.summary,
848
+ changes: { ...event.changes, ...(event.viewingAs ? { viewingAs: event.viewingAs } : {}) },
849
+ },
850
+ });
851
+ ```
852
+
853
+ `AdminAuditEvent` is `{ action, actor: { id, name, standing } | null, viewingAs?, scope: { kind: "platform" } | { kind: "tenant", tenantId }, entity: { type, id }, summary, changes?, at, ip?, userAgent? }`.
854
+ Each app maps it to its own `AuditLog`; the house writes no table. The scope is
855
+ stated outright, because the apps disagree: AltEd has no tenants, while Quickie
856
+ and PM Tool record platform actions with a null `organizationId`. The builders
857
+ are `adminSignedIn`, `roleChanged`, `viewAsStarted`, `viewAsStopped` and
858
+ `platformAction`. Each trims `summary` to the apps' 300-character column.
859
+ `requestMeta(request)` gives `{ ip, userAgent }`. `emitSafely(sink, event)` logs
860
+ and swallows a sink's error, because recording must never break the thing being
861
+ recorded.
862
+
863
+ #### Bootstrap
864
+
865
+ ```ts
866
+ // scripts/bootstrap-admin.ts
867
+ import { bootstrapAdmin, bootstrapAdminArgs } from "@connextar/house/admin";
868
+
869
+ const args = bootstrapAdminArgs(process.argv.slice(2)); // email, --force, --revoke, --allow-without-two-factor
870
+ const result = await bootstrapAdmin(
871
+ {
872
+ findUser: (email) => prisma.user.findUnique({ where: { email } }).then(toBootstrapUser),
873
+ countAdmins: () => prisma.user.count({ where: { platformAdmin: true, deletedAt: null } }),
874
+ setAdmin: (id, isAdmin) => prisma.user.update({ where: { id }, data: { platformAdmin: isAdmin } }).then(() => {}),
875
+ twoFactorEnabled: async (id) => (await prisma.user.findUnique({ where: { id } }))?.twoFactorEnabled === true,
876
+ audit: writeAuditLog,
877
+ },
878
+ { ...args, standing: "platform-admin" },
879
+ );
880
+ console.log(result.message);
881
+ process.exit(result.ok ? 0 : 1);
882
+ ```
883
+
884
+ These are PM Tool's rules, the stricter of the two:
885
+
886
+ - The account must exist, be verified and not be deleted. The script creates
887
+ nothing, as AltEd's did. An unverified row may be somebody else's sign-up with
888
+ this address.
889
+ - The account must already have two-factor on. Otherwise whoever first holds
890
+ its mailbox or a session enrols the code that opens administration.
891
+ - If an administrator already exists, the script refuses unless `--force` is
892
+ given. `--force` never skips two-factor.
893
+ - **Every run is recorded**: the grant and the revoke as `user.role_changed`
894
+ with no actor, and a refusal as `admin.bootstrap_refused`. If the trail cannot
895
+ be written, the script throws, because a person is watching it.
896
+ - `--allow-without-two-factor` is the documented first-run escape, for a
897
+ deployment whose only way to turn two-factor on is behind administration. It
898
+ works only while no administrator exists, and the event records that it was
899
+ used. `adminAccess` still refuses that administrator until they turn
900
+ two-factor on and sign in with it.
901
+ - An option the parser does not know is an error, so a typo never quietly
902
+ grants without the flag that was meant.
903
+
904
+ #### The client
905
+
906
+ ```tsx
907
+ import { AdminRefusalNotice, ViewingAsBanner } from "@connextar/house/admin/client";
908
+
909
+ // Settings › Account & security, from ?admin=
910
+ <AdminRefusalNotice refusal={adminRefusalFromParam(searchParams.admin, access.words)} />
911
+
912
+ // The root layout, while a view is in force
913
+ <ViewingAsBanner name={user.name} expiresAt={view.expiresAt} onStop={stopViewing} />
914
+ ```
915
+
916
+ Both are drawn on the shared theme tokens (`destructive`, `primary`, `muted`,
917
+ `border`), with every label and class overridable. The notice draws nothing
918
+ without a refusal, and draws no link where there is nowhere to go. The banner is
919
+ sticky and has no close button: the only way to make it go is to stop. A
920
+ refusal thrown by `onStop` is reported through the app's
921
+ `ActionFeedbackProvider`, and the banner stays.
922
+
923
+ #### Testing: the guard-coverage sweep
924
+
925
+ ```ts
926
+ import { assertAdminGuardCoverage } from "@connextar/house/admin/testing";
927
+
928
+ it("every admin surface goes through the guard", () => {
929
+ const report = assertAdminGuardCoverage({
930
+ apiDirs: ["app/api/admin"],
931
+ pageDirs: ["app/dashboard/admin"],
932
+ apiGuard: "requirePlatformAdmin",
933
+ pageGuard: "requirePlatformAdminPage",
934
+ exempt: { "app/api/admin/view-as/route.ts DELETE": "stopping a view grants nothing" },
935
+ flagReaders: {
936
+ pattern: /platformAdmin|isPlatformAdmin/,
937
+ dirs: ["app", "components", "lib"],
938
+ allowed: { "lib/server/session.ts": "the guard itself", "components/dashboard/nav.tsx": "display prop" },
939
+ ignore: (file) => file.startsWith("lib/db/generated/"),
940
+ },
941
+ minimum: { routes: 6, pages: 14 },
942
+ });
943
+ expect(report.handlers.length).toBeGreaterThanOrEqual(12);
944
+ });
945
+ ```
946
+
947
+ This is PharmaLine's `admin-guard.test.ts`, the strongest of the apps', with
948
+ Quickie's escape checks added. It reports every problem at once:
949
+
950
+ - the guard is the **first `await`** of every route handler, not merely called
951
+ after a body was parsed or a row read. It looks in route files under the pages
952
+ folder too;
953
+ - a handler exported in a form the sweep cannot read (`export const GET`,
954
+ `export { x as GET }`, a non-async `function`) is reported;
955
+ - every page file awaits the page guard itself, because a layout is not
956
+ re-rendered between its children (PM Tool);
957
+ - a `"use server"` file under either folder is reported, since it is an endpoint
958
+ the sweep cannot see;
959
+ - only allow-listed files mention the raw flag, and an entry that no longer
960
+ mentions it is reported too;
961
+ - a missing folder, an exemption naming no handler, or fewer files than
962
+ `minimum` is a problem, so a moved folder cannot pass with nothing checked.
963
+
964
+ Comments are stripped when looking for the guard and kept when looking for the
965
+ flag. `memoryCookieJar()` stands in for `cookies()` in an app's own "view as"
966
+ tests.
967
+
968
+ #### Adopting it in an app
969
+
970
+ The owner approved building it on AltEd's model and adopting it in phases,
971
+ offering "view as" in every app with platform administrators, and requiring
972
+ two-factor for MAS operators.
973
+
974
+ **(a) Swap the plugin and the guard, behaviour unchanged.** AltEd, Quickie,
975
+ senco, PharmaLine, PM Tool and FB Web.
976
+
977
+ 1. Bump `@connextar/house`.
978
+ 2. In `auth.ts`, replace the app's own plugin, or its `databaseHooks` recorder
979
+ and `hooks.before` factor-change refusal, with `adminSecondFactorSessions()`.
980
+ List it after `twoFactor()` and `secondFactorEverywhere()`. **Pass the app's
981
+ existing prefix**, so sessions already proven stay proven:
982
+
983
+ | App | `recordPrefix` | Replaces |
984
+ | ---------- | ------------------------- | -------------------------------------------------------------------------------------------- |
985
+ | AltEd | `admin-second-factor:` | `lib/auth/admin-second-factor.ts` (`recordSecondFactorSession`, `refuseAdminFactorChange`) |
986
+ | Quickie | `operator-second-factor-` | `lib/admin/operator-access.ts` (`recordSecondFactorSignIn`, `operatorSecurityChangeRefusal`) |
987
+ | PharmaLine | `admin-second-factor:` | `lib/auth/second-factor-session.ts` (`adminSecondFactor()`) |
988
+ | PM Tool | `second-factor-session:` | `lib/auth/second-factor-session.ts` |
989
+ | senco | `second-factor-session:` | `lib/auth/second-factor-session.ts` (`secondFactorSessions()`) |
990
+ | FB Web | `second-factor-session:` | `lib/auth/second-factor-session.ts` (`secondFactorSessions()`) |
991
+
992
+ 3. Rebuild the app's guard on `adminAccess(…)`. AltEd's `adminGateFor`,
993
+ Quickie's `platformAdminRefusal`, PharmaLine's `platformAdminAccess`, PM
994
+ Tool's `checkAdminAccess` and the others keep their names and callers. Their
995
+ bodies become `access.check(…)`, and their reasons map onto the shared ones.
996
+ Pass each app's sentences and `fixHref`s in `words`, so every refusal says
997
+ what it said before.
998
+ 4. Keep the app's own tests of its refusals, and make them pass unchanged. Then
999
+ replace its guard-coverage test with `assertAdminGuardCoverage` and the same
1000
+ allow-list.
1001
+ 5. Three things change on purpose, and the tests say so:
1002
+ - **Inheritance is `/change-password` only.** FB Web's plugin let any session
1003
+ made by a signed-in one inherit (passkey `createSession: true`,
1004
+ `/two-factor/disable`, a trusted-device social sign-in). AltEd let any
1005
+ same-account session inherit.
1006
+ - **A revoked session gets a 401 on the factor-change routes**, for everyone,
1007
+ and administration reads `sessionLive` every time. PharmaLine and PM Tool
1008
+ did not check that the session was live.
1009
+ - **An administrator's passkey waits for two-factor, and only first
1010
+ enrolment is open while it is off.** Quickie let an operator without
1011
+ two-factor add a passkey, and AltEd and Quickie left every two-factor route
1012
+ open while it was off. No app's screens call those routes then.
1013
+
1014
+ **(b) Audit scope and sign-in events.** Map `AdminAuditEvent` to each app's
1015
+ `AuditLog`. Quickie and PM Tool's nullable `organizationId` takes
1016
+ `scope.kind === "platform"`; AltEd, with no tenants, ignores the scope. Pass the
1017
+ sink as `onAdminSignedIn`, and record role changes through `roleChanged` and
1018
+ operator actions through `platformAction` (PM Tool's `recordPlatformAction`
1019
+ becomes a call to it). Move each bootstrap script onto `bootstrapAdmin`. AltEd's
1020
+ stops creating accounts and starts requiring two-factor.
1021
+
1022
+ **(c) "View as" in every app with platform administrators.** AltEd and Quickie
1023
+ move their cookie onto `viewAs` (the signed cookie replaces the old one, so a
1024
+ view in progress at deploy ends once) and their Prisma guard onto
1025
+ `viewAsWriteGuard` with their existing allow-lists. senco, PharmaLine, PM Tool
1026
+ and FB Web gain it: a `POST`/`DELETE /api/admin/view-as` route, `view.read` in
1027
+ `getCurrentUser`, the write guard, `viewingAsGate().assertNotViewingAs()` inside
1028
+ the app's email, SMS, payment and AI wrappers, and `<ViewingAsBanner>` in the
1029
+ root layout. Quickie moves its thirty `refuseWhileViewingAs` calls into its
1030
+ wrappers the same way.
1031
+
1032
+ **(d) The apps without the pieces yet.**
1033
+
1034
+ - **MAS** first adds Better Auth's `twoFactor`, because its operators must use
1035
+ two-factor and today nothing can record a second factor. Then it takes phase
1036
+ (a) whole.
1037
+ - **The Stripe gateway** adds two-factor for its operators and an audit table to
1038
+ map events into.
1039
+ - **Canisters** has its own auth, so it takes only `decideAdminAccess` (it
1040
+ gathers the facts itself), the audit event shape, and "view as". `/admin`
1041
+ imports no Better Auth for exactly this reason.
1042
+
551
1043
  ### `@connextar/house/account`
552
1044
 
553
1045
  The contract for "your data" and "delete my account" — types, the export notice,
@@ -635,6 +1127,159 @@ showed no kind (0.16.0), and a release shown above a hidden, unreviewed one was
635
1127
  measured against the wrong version. An app that worked out the kind itself can
636
1128
  drop that and pass `publicChangelog(CHANGELOG)` straight in.
637
1129
 
1130
+ ### `@connextar/house/whats-new`
1131
+
1132
+ The dialog that tells a signed-in person what changed since they last looked,
1133
+ from the changelog the app already keeps. Without it, a `changed` entry that
1134
+ says what to do now is read only by whoever happens to open `/changelog`.
1135
+
1136
+ The app stores one thing — the version someone last saw, in a nullable column —
1137
+ and everything else follows from it and `CHANGELOG`:
1138
+
1139
+ - **Only the public's words.** `whatsNew` runs on the server, in the signed-in
1140
+ layout. It reads reviewed releases only, drops internal changes and sections,
1141
+ uses each change's `public` wording, and leaves out the generic "Behind the
1142
+ scenes" line. What reaches the browser is what the public changelog would say.
1143
+ - **New or changed, or nothing.** It opens when there is something `new` or
1144
+ `changed` since the stored version. A week of fixes and improvements waits
1145
+ for the next one, and is in the count when it comes. It leads with `changed`
1146
+ — someone may need to act — then `new`, then `improved`, newest release first,
1147
+ five at most, "And 12 more." after them.
1148
+ - **A new account is not greeted by a hundred releases.** No stored version (or
1149
+ one that isn't a version) means nothing shown and `stampSilently`: the hook
1150
+ stores the newest version without a word, and the first dialog that person
1151
+ sees is for the first release after they arrived. Adding the column therefore
1152
+ greets nobody on the day it ships; the next release is the first anyone hears
1153
+ about.
1154
+ - **Once, and however it closes.** It opens once on mount. The button, Escape, a
1155
+ click outside and following the changelog link all count as seen: the dialog
1156
+ closes at once and the newest version is stored. A save that fails is
1157
+ reported through the app's `ActionFeedbackProvider` and the dialog stays shut
1158
+ for the visit — asking again next time is the honest outcome of a failed
1159
+ save, reopening now is not.
1160
+ - **Never while viewing as someone.** `readOnly` keeps it shut and stores
1161
+ nothing, and the seen route refuses the write as well.
1162
+
1163
+ ```ts
1164
+ // app/(signed-in)/layout.tsx — on the server
1165
+ const options = { minDaysBetween: 7 }; // AltEd and PharmaLine; the rest leave it at 0
1166
+ const news = viewingAs || !inAudience ? null : whatsNew(CHANGELOG, user.lastSeenReleaseVersion, options);
1167
+ ```
1168
+
1169
+ ```tsx
1170
+ // components/whats-new-dialog.tsx — "use client"; the layout renders <WhatsNewDialog initial={news} … />
1171
+ const news = useWhatsNew({ initial, markSeen, readOnly: viewingAs });
1172
+ if (!news.data) return null;
1173
+
1174
+ <Dialog open={news.open} onOpenChange={news.setOpen}>
1175
+ <DialogContent>
1176
+ <DialogTitle asChild>
1177
+ <WhatsNewTitle />
1178
+ </DialogTitle>
1179
+ <WhatsNewBody data={news.data} changelogHref="/changelog" title={false} onChangelogClick={news.onChangelogClick} />
1180
+ <WhatsNewActions onDismiss={news.dismiss} />
1181
+ </DialogContent>
1182
+ </Dialog>;
1183
+ ```
1184
+
1185
+ **The dialog is yours**, as the setup guide's panel is: every app already has
1186
+ one, with its own idea of how it appears and where focus goes, so this ships
1187
+ what goes inside it. `WhatsNewTitle` is an `<h2>` that passes its props through
1188
+ (so Radix's `DialogTitle asChild` names the dialog by it); `WhatsNewBody` is the
1189
+ count, the tagged highlights — the changelog's own tags, `tagClassNames` and
1190
+ `renderReleaseText` — and "See everything in the changelog", which goes to the
1191
+ newest release they hadn't seen (`/changelog#v1-4-0`); `WhatsNewActions` is a
1192
+ **Got it** button. A hand-rolled dialog keeps the body's title and gives it an
1193
+ id with `titleId` for `aria-labelledby`; MUI puts the three in its `Dialog`
1194
+ the same way Radix does. `labels` and `link` take the app's words and router.
1195
+ Theme tokens only, and `entry-points.test.ts` holds the module to React — no
1196
+ dialog primitive, router or toast library.
1197
+
1198
+ **Options worth knowing.**
1199
+
1200
+ - `minDaysBetween: 7` — for an app that releases several times a day. It waits
1201
+ that many days after the stored version's release date. No second column is
1202
+ needed: dismissing stores the newest release there was, so its date is close
1203
+ to when the dialog was last seen (exactly so for an app shipping daily).
1204
+ A stored version the changelog doesn't hold has nothing to wait for.
1205
+ - `role` — matched against the optional `roles` on a change or a section, so a
1206
+ change for guardians can be kept from children. A scoped change is shown to
1207
+ its roles only, and to nobody when no `role` is passed.
1208
+ - `highlight: true` on a change puts it in the dialog whatever its type or
1209
+ heading (and is guaranteed a place); `highlight: false` keeps it out. Both
1210
+ fields are checked by `releaseHistoryProblems` and ignored by both
1211
+ changelogs — [`docs/ReleaseProcess.md`](docs/ReleaseProcess.md) §3.
1212
+ - `maxItems`, `highlightTypes`, `showFor`, `skipHeadings` change the defaults
1213
+ above; `now` pins the clock in a test.
1214
+
1215
+ Unseen is decided by version number, not by finding the stored version in the
1216
+ history, so a version since trimmed from the changelog still works, and one
1217
+ newer than the changelog (after a rollback) has nothing unseen and stores
1218
+ nothing.
1219
+
1220
+ #### Adoption, per app
1221
+
1222
+ Each app adds three things.
1223
+
1224
+ 1. **The column**, on the user. Null for everyone at first, which is right (see
1225
+ above).
1226
+
1227
+ ```prisma
1228
+ model User {
1229
+ // …
1230
+ lastSeenReleaseVersion String?
1231
+ }
1232
+ ```
1233
+
1234
+ `npx prisma migrate dev --name whats_new_last_seen` — one nullable column, no
1235
+ backfill. An app that _wants_ to announce the release it ships with
1236
+ backfills the previous version instead.
1237
+
1238
+ 2. **`POST /api/whats-new/seen`**, which checks the body before it stores it
1239
+ and never moves backwards:
1240
+
1241
+ ```ts
1242
+ import { acceptSeenVersion } from "@connextar/house/whats-new";
1243
+
1244
+ export async function POST(request: Request) {
1245
+ const session = await requireSession(); // the app's own; 401 without one
1246
+ if (session.viewingAs) return Response.json({ error: "Viewing as someone." }, { status: 403 });
1247
+ const body = await request.json().catch(() => null);
1248
+ const user = await prisma.user.findUniqueOrThrow({
1249
+ where: { id: session.userId },
1250
+ select: { lastSeenReleaseVersion: true },
1251
+ });
1252
+ const result = acceptSeenVersion(body, CHANGELOG, user.lastSeenReleaseVersion);
1253
+ if (!result.ok) return Response.json({ error: result.reason }, { status: 400 });
1254
+ await prisma.user.update({ where: { id: session.userId }, data: { lastSeenReleaseVersion: result.version } });
1255
+ return Response.json({ ok: true });
1256
+ }
1257
+ ```
1258
+
1259
+ `markSeen` is a `fetch` to it with `keepalive: true` — following the
1260
+ changelog link can unload the page while the request is in flight — that
1261
+ resolves `response.ok`.
1262
+
1263
+ 3. **The dialog**, mounted once in the signed-in layout, with `whatsNew`
1264
+ computed there on the server. Pass `null` to anyone outside the audience
1265
+ rather than mounting it conditionally, so the decision stays in one place.
1266
+
1267
+ | App | Mount | Audience | Options |
1268
+ | ---------- | -------------------------------------- | ------------------------------- | ------------------- |
1269
+ | AltEd | the signed-in layout, **not** `/learn` | everyone outside `/learn` | `minDaysBetween: 7` |
1270
+ | PharmaLine | the signed-in layout | everyone signed in | `minDaysBetween: 7` |
1271
+ | KRP | the signed-in layout | guardians only — never children | |
1272
+ | MAS | the signed-in layout | workspace managers only | |
1273
+ | The rest | the signed-in layout | everyone signed in | |
1274
+ | Every app | — | nobody while viewing as someone | `readOnly` |
1275
+
1276
+ AltEd ships about twenty-five releases a week and PharmaLine about ten; without
1277
+ `minDaysBetween` either could greet someone every day. A week's worth is more
1278
+ than the public changelog's five, so the link goes to the newest unseen release
1279
+ at the top of the page; an app whose signed-in readers should be able to scroll
1280
+ back through all of them passes a larger `limit` to `publicChangelog` on the
1281
+ page, or pages it.
1282
+
638
1283
  ### `@connextar/house/markdown`
639
1284
 
640
1285
  The help-centre guide renderer, and the GitHub-flavoured table parser under it.
@@ -665,6 +1310,12 @@ export function Markdown({ content }: { content: string }) {
665
1310
  ```
666
1311
 
667
1312
  `classNames` replaces the look of any element for an app without theme tokens.
1313
+ `table` and `figure` are each a set of parts merged over the house's, and both are
1314
+ optional: an app that writes out a whole `GuideClassNames` in its own palette
1315
+ (KRP) leaves out the ones it doesn't restyle and gets the house's table and
1316
+ picture classes. (`figure` was required in 0.18.0 alone, so such an app failed
1317
+ typecheck until it added `figure: {}`.) A part added to a class-names or labels
1318
+ type after it first shipped is optional for the same reason.
668
1319
 
669
1320
  **Pictures.** A guide names a screenshot or a diagram on a line of its own, with a
670
1321
  blank line either side:
@@ -737,8 +1388,13 @@ export default async function GuidePage({ params }) {
737
1388
  #### Pictures and zoom
738
1389
 
739
1390
  Pass the registry and `GuideArticle` draws a guide's pictures
740
- ([the Markdown](#connextarhousemarkdown)), each one a button: a "Click to enlarge"
741
- hint in its corner and a zoom-in cursor. A click, Enter or Space opens it in a
1391
+ ([the Markdown](#connextarhousemarkdown)), each one a button with a zoom-in cursor
1392
+ and a hint under the picture, right-aligned above the caption: "Click to enlarge",
1393
+ or "Tap to enlarge" on a touch screen. Both words are in the HTML and
1394
+ `@media (pointer: coarse)` (Tailwind's `pointer-coarse:`) shows one, so it is right
1395
+ before any script runs. The hint sits under the picture rather than over its
1396
+ corner, so it never covers part of a small diagram on a phone (at 375px it used to
1397
+ sit on a box of PharmaLineAI's call-routing diagram). A click, Enter or Space opens it in a
742
1398
  dialog fitted to the screen (the whole screen on a phone):
743
1399
 
744
1400
  - zoom with the buttons, `+` and `−`, ⌘/Ctrl + scroll (a trackpad pinch arrives as
@@ -747,16 +1403,25 @@ dialog fitted to the screen (the whole screen on a phone):
747
1403
  keys — never past its edges;
748
1404
  - a double-click or double-tap, or the zoom level between the buttons, switches
749
1405
  between fit and full size (one image pixel to one CSS pixel); `0` fits again;
750
- - the caption and the alt text are shown under it; Escape or the close button
751
- closes it and focus goes back to the picture. Zoom eases only without
752
- `prefers-reduced-motion`; dragging and pinching never do.
1406
+ - the caption and the alt text are shown under it. On a phone the alt text — long by
1407
+ design, since it says everything the picture shows — waits behind "Show
1408
+ description", and an alt text standing in as the title (no caption) is cut to
1409
+ two lines until then, so the picture keeps most of the screen (PharmaLineAI's
1410
+ screenshots went from 62–67% of an iPhone 13's height to 76–79%). It is still the
1411
+ dialog's description for a screen reader, and it starts folded each time;
1412
+ wider screens show it whole, with no button;
1413
+ - Escape or the close button closes it and focus goes back to the picture. Zoom
1414
+ eases only without `prefers-reduced-motion`; dragging and pinching never do.
753
1415
 
754
1416
  Diagrams zoom too. They are vector, so the dialog lays the diagram out at the size
755
1417
  it had in the article and scales it: fit fills the screen, and "full size" is twice
756
1418
  the fit. `zoom={false}` draws plain pictures. `labels` changes the words (`zoomHint`,
757
- `zoomOpen`, `zoomIn`, `zoomOut`, `zoomToggle`, `zoomLevel`, `zoomClose`,
758
- `zoomInstructions`), `classNames.figure` the picture's parts and `zoomClassNames`
759
- the button and dialog's (`GUIDE_ZOOM_CLASS_NAMES` lists them), all on the theme
1419
+ `zoomHintTouch` — left out, it is your `zoomHint` if you set one, else "Tap to
1420
+ enlarge" — `zoomOpen`, `zoomIn`, `zoomOut`, `zoomToggle`, `zoomLevel`, `zoomClose`,
1421
+ `zoomInstructions`, `zoomShowDescription`, `zoomHideDescription`), `classNames.figure` the picture's parts and `zoomClassNames`
1422
+ the button and dialog's (`GUIDE_ZOOM_CLASS_NAMES` lists them; `hint` is the row
1423
+ under the picture, `hintPointer` and `hintTouch` the two sets of words, `altFolded`,
1424
+ `titleFolded` and `more` the folded description and its button), all on the theme
760
1425
  tokens. `GuideFigure` and `GuideFigureZoom` are there for a page laid out without
761
1426
  `GuideArticle`. The zoom's classes appear only in this package, so the
762
1427
  [Tailwind `@source`](#tailwind-setup-required-for-every-component) line is what