@connextar/house 0.17.0 → 0.19.0
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.
- package/README.md +850 -0
- package/dist/admin/access.d.ts +134 -0
- package/dist/admin/access.d.ts.map +1 -0
- package/dist/admin/access.js +157 -0
- package/dist/admin/access.js.map +1 -0
- package/dist/admin/audit.d.ts +130 -0
- package/dist/admin/audit.d.ts.map +1 -0
- package/dist/admin/audit.js +132 -0
- package/dist/admin/audit.js.map +1 -0
- package/dist/admin/bootstrap.d.ts +80 -0
- package/dist/admin/bootstrap.d.ts.map +1 -0
- package/dist/admin/bootstrap.js +106 -0
- package/dist/admin/bootstrap.js.map +1 -0
- package/dist/admin/client-index.d.ts +7 -0
- package/dist/admin/client-index.d.ts.map +1 -0
- package/dist/admin/client-index.js +6 -0
- package/dist/admin/client-index.js.map +1 -0
- package/dist/admin/client.d.ts +72 -0
- package/dist/admin/client.d.ts.map +1 -0
- package/dist/admin/client.js +90 -0
- package/dist/admin/client.js.map +1 -0
- package/dist/admin/index.d.ts +13 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +13 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/proof.d.ts +34 -0
- package/dist/admin/proof.d.ts.map +1 -0
- package/dist/admin/proof.js +30 -0
- package/dist/admin/proof.js.map +1 -0
- package/dist/admin/second-factor-index.d.ts +8 -0
- package/dist/admin/second-factor-index.d.ts.map +1 -0
- package/dist/admin/second-factor-index.js +8 -0
- package/dist/admin/second-factor-index.js.map +1 -0
- package/dist/admin/second-factor.d.ts +156 -0
- package/dist/admin/second-factor.d.ts.map +1 -0
- package/dist/admin/second-factor.js +271 -0
- package/dist/admin/second-factor.js.map +1 -0
- package/dist/admin/testing-index.d.ts +6 -0
- package/dist/admin/testing-index.d.ts.map +1 -0
- package/dist/admin/testing-index.js +6 -0
- package/dist/admin/testing-index.js.map +1 -0
- package/dist/admin/testing.d.ts +84 -0
- package/dist/admin/testing.d.ts.map +1 -0
- package/dist/admin/testing.js +223 -0
- package/dist/admin/testing.js.map +1 -0
- package/dist/admin/view-as.d.ts +209 -0
- package/dist/admin/view-as.d.ts.map +1 -0
- package/dist/admin/view-as.js +251 -0
- package/dist/admin/view-as.js.map +1 -0
- package/dist/guides/guide-article.d.ts +13 -2
- package/dist/guides/guide-article.d.ts.map +1 -1
- package/dist/guides/guide-article.js +5 -3
- package/dist/guides/guide-article.js.map +1 -1
- package/dist/guides/guide-figure-zoom.d.ts +73 -0
- package/dist/guides/guide-figure-zoom.d.ts.map +1 -0
- package/dist/guides/guide-figure-zoom.js +279 -0
- package/dist/guides/guide-figure-zoom.js.map +1 -0
- package/dist/guides/guide-figure.d.ts +21 -0
- package/dist/guides/guide-figure.d.ts.map +1 -0
- package/dist/guides/guide-figure.js +19 -0
- package/dist/guides/guide-figure.js.map +1 -0
- package/dist/guides/index.d.ts +2 -0
- package/dist/guides/index.d.ts.map +1 -1
- package/dist/guides/index.js +2 -0
- package/dist/guides/index.js.map +1 -1
- package/dist/guides/library.d.ts +19 -1
- package/dist/guides/library.d.ts.map +1 -1
- package/dist/guides/library.js +9 -0
- package/dist/guides/library.js.map +1 -1
- package/dist/health/health.d.ts +65 -0
- package/dist/health/health.d.ts.map +1 -0
- package/dist/health/health.js +93 -0
- package/dist/health/health.js.map +1 -0
- package/dist/health/index.d.ts +2 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +2 -0
- package/dist/health/index.js.map +1 -0
- package/dist/markdown/figure.d.ts +44 -0
- package/dist/markdown/figure.d.ts.map +1 -0
- package/dist/markdown/figure.js +56 -0
- package/dist/markdown/figure.js.map +1 -0
- package/dist/markdown/figures.d.ts +94 -0
- package/dist/markdown/figures.d.ts.map +1 -0
- package/dist/markdown/figures.js +126 -0
- package/dist/markdown/figures.js.map +1 -0
- package/dist/markdown/guide.d.ts +26 -7
- package/dist/markdown/guide.d.ts.map +1 -1
- package/dist/markdown/guide.js +27 -6
- package/dist/markdown/guide.js.map +1 -1
- package/dist/markdown/index.d.ts +2 -0
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +2 -0
- package/dist/markdown/index.js.map +1 -1
- package/dist/release/changelog.d.ts +19 -0
- package/dist/release/changelog.d.ts.map +1 -1
- package/dist/release/changelog.js +37 -6
- package/dist/release/changelog.js.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +1 -1
- package/dist/release/index.js.map +1 -1
- package/dist/whats-new/body.d.ts +81 -0
- package/dist/whats-new/body.d.ts.map +1 -0
- package/dist/whats-new/body.js +29 -0
- package/dist/whats-new/body.js.map +1 -0
- package/dist/whats-new/index.d.ts +24 -0
- package/dist/whats-new/index.d.ts.map +1 -0
- package/dist/whats-new/index.js +24 -0
- package/dist/whats-new/index.js.map +1 -0
- package/dist/whats-new/seen.d.ts +23 -0
- package/dist/whats-new/seen.d.ts.map +1 -0
- package/dist/whats-new/seen.js +33 -0
- package/dist/whats-new/seen.js.map +1 -0
- package/dist/whats-new/select.d.ts +89 -0
- package/dist/whats-new/select.d.ts.map +1 -0
- package/dist/whats-new/select.js +117 -0
- package/dist/whats-new/select.js.map +1 -0
- package/dist/whats-new/use-whats-new.d.ts +36 -0
- package/dist/whats-new/use-whats-new.d.ts.map +1 -0
- package/dist/whats-new/use-whats-new.js +81 -0
- package/dist/whats-new/use-whats-new.js.map +1 -0
- package/docs/ReleaseProcess.md +12 -0
- package/package.json +25 -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,40 @@ 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.
|
|
1319
|
+
|
|
1320
|
+
**Pictures.** A guide names a screenshot or a diagram on a line of its own, with a
|
|
1321
|
+
blank line either side:
|
|
1322
|
+
|
|
1323
|
+
```md
|
|
1324
|
+

|
|
1325
|
+
|
|
1326
|
+

|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
`GuideMarkdown` draws one only when it is in the app's registry, `figures` — a
|
|
1330
|
+
screenshot with its `width` and `height` (so the page doesn't jump as it loads) and
|
|
1331
|
+
optionally a `dark` capture of the same size, or a diagram by name — and has alt
|
|
1332
|
+
text; anything else is left out, so a guide can't pull an image in from elsewhere
|
|
1333
|
+
and a typo never shows a broken image. (Before 0.18.0 such a line came out as a
|
|
1334
|
+
`!` and a link.) A screenshot is a plain lazy `<img>`; with a `dark` capture both
|
|
1335
|
+
are drawn and the theme's `dark:` variant shows one; without one, the light
|
|
1336
|
+
picture is toned down a little on a dark page. A diagram registered as a component
|
|
1337
|
+
is given the alt text as `label` for its own `<svg role="img" aria-label>`; one
|
|
1338
|
+
registered as an element is wrapped in `role="img"`. Headings keep their ids
|
|
1339
|
+
across pictures, and a picture ends a list.
|
|
1340
|
+
|
|
1341
|
+
`guideFigureProblems(body, figures, { minAltLength })` is the check an app's test
|
|
1342
|
+
asserts empty for every guide: an unregistered picture, missing (or too short) alt
|
|
1343
|
+
text, a picture without a blank line either side or indented as if in a list, and
|
|
1344
|
+
an image written inside a line of text. `splitGuideFigures` is the same split as
|
|
1345
|
+
data; `renderFigure` replaces the drawing. `GuideArticle` adds zoom — see
|
|
1346
|
+
[Pictures and zoom](#pictures-and-zoom).
|
|
668
1347
|
|
|
669
1348
|
### `@connextar/house/guides`
|
|
670
1349
|
|
|
@@ -706,6 +1385,177 @@ export default async function GuidePage({ params }) {
|
|
|
706
1385
|
}
|
|
707
1386
|
```
|
|
708
1387
|
|
|
1388
|
+
#### Pictures and zoom
|
|
1389
|
+
|
|
1390
|
+
Pass the registry and `GuideArticle` draws a guide's pictures
|
|
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
|
|
1398
|
+
dialog fitted to the screen (the whole screen on a phone):
|
|
1399
|
+
|
|
1400
|
+
- zoom with the buttons, `+` and `−`, ⌘/Ctrl + scroll (a trackpad pinch arrives as
|
|
1401
|
+
one) or a touch pinch, from fit up to four times full size, at the pointer;
|
|
1402
|
+
- when it is bigger than the screen, drag it, scroll it, or move it with the arrow
|
|
1403
|
+
keys — never past its edges;
|
|
1404
|
+
- a double-click or double-tap, or the zoom level between the buttons, switches
|
|
1405
|
+
between fit and full size (one image pixel to one CSS pixel); `0` fits again;
|
|
1406
|
+
- the caption and the alt text are shown under it; Escape or the close button
|
|
1407
|
+
closes it and focus goes back to the picture. Zoom eases only without
|
|
1408
|
+
`prefers-reduced-motion`; dragging and pinching never do.
|
|
1409
|
+
|
|
1410
|
+
Diagrams zoom too. They are vector, so the dialog lays the diagram out at the size
|
|
1411
|
+
it had in the article and scales it: fit fills the screen, and "full size" is twice
|
|
1412
|
+
the fit. `zoom={false}` draws plain pictures. `labels` changes the words (`zoomHint`,
|
|
1413
|
+
`zoomHintTouch` — left out, it is your `zoomHint` if you set one, else "Tap to
|
|
1414
|
+
enlarge" — `zoomOpen`, `zoomIn`, `zoomOut`, `zoomToggle`, `zoomLevel`, `zoomClose`,
|
|
1415
|
+
`zoomInstructions`), `classNames.figure` the picture's parts and `zoomClassNames`
|
|
1416
|
+
the button and dialog's (`GUIDE_ZOOM_CLASS_NAMES` lists them; `hint` is the row
|
|
1417
|
+
under the picture, `hintPointer` and `hintTouch` the two sets of words), all on the theme
|
|
1418
|
+
tokens. `GuideFigure` and `GuideFigureZoom` are there for a page laid out without
|
|
1419
|
+
`GuideArticle`. The zoom's classes appear only in this package, so the
|
|
1420
|
+
[Tailwind `@source`](#tailwind-setup-required-for-every-component) line is what
|
|
1421
|
+
styles it.
|
|
1422
|
+
|
|
1423
|
+
Adopting it (PharmaLineAI's v0.23.1 pictures, which this generalises):
|
|
1424
|
+
|
|
1425
|
+
1. **Screenshots** live in `public/guide-images/`, as WebP about 1600px wide (sharp
|
|
1426
|
+
in a 768px column on a 2× screen) and under ~150 KB each. A dark capture, if
|
|
1427
|
+
the app makes one, sits beside it at the same size (`calls-log-dark.webp`).
|
|
1428
|
+
2. **The registry.** Sizes in `lib/guide-screenshots.json`, written by the capture
|
|
1429
|
+
script, and the diagrams beside them:
|
|
1430
|
+
|
|
1431
|
+
```json
|
|
1432
|
+
{
|
|
1433
|
+
"/guide-images/calls-log.webp": { "width": 1600, "height": 986 },
|
|
1434
|
+
"/guide-images/overview.webp": { "width": 1600, "height": 506, "dark": "/guide-images/overview-dark.webp" }
|
|
1435
|
+
}
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
```tsx
|
|
1439
|
+
// lib/guide-figures.tsx
|
|
1440
|
+
import type { GuideFigures } from "@connextar/house/markdown";
|
|
1441
|
+
|
|
1442
|
+
import { CallRouting, IdentityCheck } from "@components/guides/guide-diagrams";
|
|
1443
|
+
import screenshots from "@lib/guide-screenshots.json";
|
|
1444
|
+
|
|
1445
|
+
export const GUIDE_FIGURES: GuideFigures = {
|
|
1446
|
+
screenshots,
|
|
1447
|
+
diagrams: { "call-routing": CallRouting, "identity-check": IdentityCheck }, // each takes { label }
|
|
1448
|
+
};
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
3. **The pages** pass it: `<GuideArticle … figures={GUIDE_FIGURES} />`, on the public
|
|
1452
|
+
and the signed-in guide pages alike. A diagram component is drawn on the server;
|
|
1453
|
+
only what it drew crosses into the dialog.
|
|
1454
|
+
4. **The app's test** reads the real guides:
|
|
1455
|
+
|
|
1456
|
+
```ts
|
|
1457
|
+
for (const { slug } of GUIDES) {
|
|
1458
|
+
expect(guideFigureProblems(body(slug), GUIDE_FIGURES, { minAltLength: 20 }), slug).toEqual([]);
|
|
1459
|
+
}
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1462
|
+
and, because the package can't see the app's disk, checks the files: every
|
|
1463
|
+
registered screenshot (and `dark`) exists at its recorded size (the app's own
|
|
1464
|
+
`sharp`), every file in `public/guide-images/` is registered and used, all
|
|
1465
|
+
WebP and small.
|
|
1466
|
+
|
|
1467
|
+
5. **Capturing them (optional).** PharmaLineAI's `scripts/guide-screenshots.ts` is
|
|
1468
|
+
the pattern, and stays in the app — this package ships no Playwright. It runs
|
|
1469
|
+
only against the local harness (it refuses any base URL that isn't
|
|
1470
|
+
`localhost`/`127.0.0.1`), signed in as the seeded owner from the harness's
|
|
1471
|
+
cookie jar, in headless Chromium at 1440px, 2× pixels, light mode and reduced
|
|
1472
|
+
motion. Each shot names a page, an optional `before` step (open a call, pick a
|
|
1473
|
+
setting), and a crop to the panel its guide talks about, found from the page
|
|
1474
|
+
(a heading, a bordered card). It hides the dev overlay, replaces seed text a
|
|
1475
|
+
public page mustn't show (a real brand in a seed name), puts back any seed data
|
|
1476
|
+
it changed for a shot in a `finally`, resizes to 1600px WebP with sharp, and
|
|
1477
|
+
writes the sizes to `lib/guide-screenshots.json`. A dark set is the same run
|
|
1478
|
+
with `colorScheme: "dark"` writing `-dark.webp` files and each entry's `dark`.
|
|
1479
|
+
|
|
1480
|
+
PharmaLineAI's own `components/guides/guide-article.tsx` and the split in
|
|
1481
|
+
`lib/guide-figures.ts` go; its diagrams and its JSON stay, and its list of diagram
|
|
1482
|
+
names becomes the `diagrams` record.
|
|
1483
|
+
|
|
1484
|
+
### `@connextar/house/health`
|
|
1485
|
+
|
|
1486
|
+
A liveness check that says which release is running, so a deploy can be confirmed
|
|
1487
|
+
from outside: `curl https://app.example/healthz` answers `ok 0.18.0 2c9a3cf`.
|
|
1488
|
+
From PharmaLineAI's voice relay (v0.24.1), for every app.
|
|
1489
|
+
|
|
1490
|
+
- `buildInfo(version, env = process.env)` → `{ version?, commit? }`. The version
|
|
1491
|
+
must be semver-shaped (`1.2.3`, `1.2.3-rc.1`); the commit is the first of
|
|
1492
|
+
`VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT` and `GIT_COMMIT` that is a whole
|
|
1493
|
+
hex hash, cut to 7. Anything else is dropped, never echoed: the endpoint is
|
|
1494
|
+
public.
|
|
1495
|
+
- `healthText(build)` → `ok 1.2.3 abc1234` (just `ok` when nothing is known).
|
|
1496
|
+
- `healthResponse(build, { format })` → always 200, `Cache-Control: no-store`;
|
|
1497
|
+
`text/plain` by default, `{ "status": "ok", "version", "commit" }` for
|
|
1498
|
+
`format: "json"`. `healthFormat(request)` picks: `?format=json|text`, then an
|
|
1499
|
+
`Accept` that names `application/json`; a browser, curl and a host's checker get
|
|
1500
|
+
text.
|
|
1501
|
+
- `createHealthRoute({ version })` → `{ GET }`, the two above as a Next route
|
|
1502
|
+
handler. Next answers `HEAD` from `GET` itself. No Next import, no package at all:
|
|
1503
|
+
it is web `Request` and `Response`.
|
|
1504
|
+
|
|
1505
|
+
The whole adoption is one file, `app/healthz/route.ts` (`src/app/…` adds a `../`):
|
|
1506
|
+
|
|
1507
|
+
```ts
|
|
1508
|
+
import { createHealthRoute } from "@connextar/house/health";
|
|
1509
|
+
|
|
1510
|
+
// A route handler is server-only, so package.json is bundled into the server and never reaches the browser.
|
|
1511
|
+
import pkg from "../../package.json";
|
|
1512
|
+
|
|
1513
|
+
export const { GET } = createHealthRoute({ version: pkg.version });
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
(Next's default `tsconfig` has `resolveJsonModule`.) **Not**
|
|
1517
|
+
`process.env.npm_package_version`: npm sets it only in a process npm itself
|
|
1518
|
+
started — `node -e 'console.log(process.env.npm_package_version)'` prints
|
|
1519
|
+
`undefined`, and the same line under `npm run` prints the version. A Vercel
|
|
1520
|
+
function is started by Vercel's runtime, not `npm start`, so it is unset when the
|
|
1521
|
+
route runs; it is set during `npm run build`, so it could be inlined through
|
|
1522
|
+
`next.config`'s `env`, but only for as long as the build goes through npm. It works
|
|
1523
|
+
on Render under `npm start`, which is why it looks reliable until it isn't.
|
|
1524
|
+
|
|
1525
|
+
The commit: Vercel sets `VERCEL_GIT_COMMIT_SHA` on Git deployments while "Automatically
|
|
1526
|
+
expose System Environment Variables" is on (the default); Render sets
|
|
1527
|
+
`RENDER_GIT_COMMIT`; anywhere else, set `GIT_COMMIT`. On Render, point the service's
|
|
1528
|
+
Health Check Path at `/healthz`; it needs the 200 and nothing else. A plain Node
|
|
1529
|
+
server (PharmaLineAI's relay) uses `buildInfo` and `healthText` directly.
|
|
1530
|
+
|
|
1531
|
+
**Let it through.** `/healthz` must answer without signing in: exclude it from the
|
|
1532
|
+
app's `proxy.ts` (`middleware.ts` before Next 16) — e.g.
|
|
1533
|
+
`matcher: ["/((?!healthz|_next/static|_next/image|favicon.ico).*)"]` — and from any
|
|
1534
|
+
auth redirect, maintenance switch or locale rewrite in it. Vercel Deployment
|
|
1535
|
+
Protection on preview deployments answers 401 before the app does; that is
|
|
1536
|
+
expected, and production should be unprotected.
|
|
1537
|
+
|
|
1538
|
+
**Liveness only.** It touches no database and no provider, so it can't be slow and
|
|
1539
|
+
a provider's outage never makes a host restart a healthy app. An app that wants a
|
|
1540
|
+
readiness check writes its own, separately — never as the host's health check:
|
|
1541
|
+
|
|
1542
|
+
```ts
|
|
1543
|
+
// app/readyz/route.ts — the app's own; says only ready / not ready
|
|
1544
|
+
export async function GET() {
|
|
1545
|
+
const ok = await Promise.race([
|
|
1546
|
+
prisma.$queryRaw`select 1`.then(
|
|
1547
|
+
() => true,
|
|
1548
|
+
() => false,
|
|
1549
|
+
),
|
|
1550
|
+
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2000)),
|
|
1551
|
+
]);
|
|
1552
|
+
return new Response(ok ? "ready" : "not ready", {
|
|
1553
|
+
status: ok ? 200 : 503,
|
|
1554
|
+
headers: { "cache-control": "no-store", "content-type": "text/plain; charset=utf-8" },
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
```
|
|
1558
|
+
|
|
709
1559
|
### `@connextar/house/trail`
|
|
710
1560
|
|
|
711
1561
|
Where you came from, rather than where the URL says you are. A breadcrumb built
|