@connextar/house 0.18.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 +662 -4
- 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-figure-zoom.d.ts +19 -8
- package/dist/guides/guide-figure-zoom.d.ts.map +1 -1
- package/dist/guides/guide-figure-zoom.js +14 -7
- package/dist/guides/guide-figure-zoom.js.map +1 -1
- package/dist/guides/library.d.ts +15 -10
- package/dist/guides/library.d.ts.map +1 -1
- package/dist/guides/library.js +1 -0
- package/dist/guides/library.js.map +1 -1
- package/dist/markdown/guide.d.ts +12 -5
- package/dist/markdown/guide.d.ts.map +1 -1
- package/dist/markdown/guide.js +9 -5
- package/dist/markdown/guide.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 +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
|
|
741
|
-
|
|
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
|
|
@@ -754,9 +1410,11 @@ dialog fitted to the screen (the whole screen on a phone):
|
|
|
754
1410
|
Diagrams zoom too. They are vector, so the dialog lays the diagram out at the size
|
|
755
1411
|
it had in the article and scales it: fit fills the screen, and "full size" is twice
|
|
756
1412
|
the fit. `zoom={false}` draws plain pictures. `labels` changes the words (`zoomHint`,
|
|
757
|
-
`
|
|
1413
|
+
`zoomHintTouch` — left out, it is your `zoomHint` if you set one, else "Tap to
|
|
1414
|
+
enlarge" — `zoomOpen`, `zoomIn`, `zoomOut`, `zoomToggle`, `zoomLevel`, `zoomClose`,
|
|
758
1415
|
`zoomInstructions`), `classNames.figure` the picture's parts and `zoomClassNames`
|
|
759
|
-
the button and dialog's (`GUIDE_ZOOM_CLASS_NAMES` lists them
|
|
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
|
|
760
1418
|
tokens. `GuideFigure` and `GuideFigureZoom` are there for a page laid out without
|
|
761
1419
|
`GuideArticle`. The zoom's classes appear only in this package, so the
|
|
762
1420
|
[Tailwind `@source`](#tailwind-setup-required-for-every-component) line is what
|