@sjolystinnovation/app-kit 0.5.0 → 0.7.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 CHANGED
@@ -28,8 +28,8 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui` are styled with Tailwind CSS. Import the
32
- package's stylesheet after Tailwind:
31
+ The components in `@sjolystinnovation/app-kit/ui` and `session/react` are styled with Tailwind CSS.
32
+ Import the package's stylesheet after Tailwind:
33
33
 
34
34
  ```css
35
35
  /* src/app/globals.css */
@@ -260,40 +260,108 @@ export const getRecipe = (id: number) =>
260
260
  request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
261
261
  ```
262
262
 
263
+ `requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
264
+ `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
265
+ It never throws. On failure the pages' own revalidate window is the fallback.
266
+
267
+ ### `@sjolystinnovation/app-kit/server`
268
+
269
+ For server components and route handlers only. It imports `next/cache` and `next/server`.
270
+
271
+ `createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
272
+
273
+ - `publicGet(path, { tags? })` gives the data, or null when the API answers 404. An unreachable or
274
+ failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
275
+ of a working one.
276
+ - `publicGetOptional(path, { tags? })` gives null on any failure, for data a page can do without.
277
+ - `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
278
+
279
+ Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
280
+ throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
281
+ encoded or not, gives null without a request, the same as a 404. Run a slug or other user input
282
+ through `encodeURIComponent` before putting it in a path.
283
+
284
+ ```ts
285
+ // src/lib/publicApi.ts
286
+ import { requireEnv } from "@sjolystinnovation/app-kit";
287
+ import { createPublicApi } from "@sjolystinnovation/app-kit/server";
288
+
289
+ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
290
+ requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
291
+ );
292
+ ```
293
+
294
+ `createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
295
+ Each target maps to the page paths it purges. A path with a dynamic segment, such as
296
+ `/builds/[slug]`, purges every page of that route. The target name is purged as a cache tag too, so
297
+ tag the fetches behind it with the same name. The handler answers 403 unless the caller has a live
298
+ session with `role`: signed in within the last seven days, with no refused refresh and an access
299
+ token that has not expired. It answers 415 unless the body is sent as JSON, which
300
+ `requestRevalidate` does, and 400 for a target it does not know.
301
+
302
+ ```ts
303
+ // src/app/api/revalidate/route.ts
304
+ import { createRevalidateRoute } from "@sjolystinnovation/app-kit/server";
305
+
306
+ export const POST = createRevalidateRoute({
307
+ role: "Admin",
308
+ targets: { builds: ["/builds", "/builds/[slug]", "/sitemap.xml"], legal: ["/terms", "/privacy"] },
309
+ });
310
+ ```
311
+
263
312
  ### `@sjolystinnovation/app-kit/session`
264
313
 
265
314
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
266
315
  callbacks.
267
316
 
268
- The gate reads `session.error`. `createAuthOptions` sets it already. An app with its own next-auth
269
- options sets `token.error` in its `jwt` callback and copies it across in its `session` callback:
317
+ The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
318
+ countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
319
+ to fill in the prompt. `createAuthOptions` sets all of these. An app with its own next-auth options sets them in its
320
+ callbacks:
270
321
 
271
322
  ```ts
323
+ const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
324
+
272
325
  callbacks: {
326
+ jwt({ token, user }) {
327
+ if (user) token.loginAt = Date.now();
328
+ // A token without a sign-in time counts as expired.
329
+ if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
330
+ token.error = SESSION_ERRORS.absoluteExpiry;
331
+ }
332
+ // Also set token.error when a refresh is refused.
333
+ return token;
334
+ },
273
335
  session({ session, token }) {
336
+ session.user.id = token.sub ?? "";
274
337
  session.error = token.error;
338
+ session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
275
339
  return session;
276
340
  },
277
341
  },
278
342
  ```
279
343
 
280
- For that to compile, `error` has to be on next-auth's `Session` and `JWT` types. Add it to
344
+ For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
281
345
  the app's existing next-auth type declarations, or create them:
282
346
 
283
347
  ```ts
284
348
  // src/types/next-auth.d.ts
285
349
  import "next-auth";
286
350
  import "next-auth/jwt";
351
+ import type { DefaultSession } from "next-auth";
287
352
 
288
353
  declare module "next-auth" {
289
354
  interface Session {
355
+ user: { id: string } & DefaultSession["user"];
290
356
  error?: string | undefined;
357
+ absoluteExpiresAt?: number | undefined;
291
358
  }
292
359
  }
293
360
 
294
361
  declare module "next-auth/jwt" {
295
362
  interface JWT {
296
363
  error?: string | undefined;
364
+ loginAt?: number | undefined;
297
365
  }
298
366
  }
299
367
  ```
@@ -335,37 +403,8 @@ A session that turns signed out with no prompt open blanks the page. A page the
335
403
  dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
336
404
  them blanks a page that another one kept.
337
405
 
338
- The hook does not navigate. The gate sends the user to the login page itself, and never while the
339
- prompt is open. The app passes the login URL in, so an app with a locale in the path can build it
340
- for the current locale:
341
-
342
- ```tsx
343
- "use client";
344
-
345
- import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
346
- import { useRouter } from "next/navigation";
347
- import { useEffect } from "react";
348
-
349
- export function ProtectedGate({
350
- loginHref,
351
- children,
352
- }: {
353
- loginHref: string;
354
- children: React.ReactNode;
355
- }) {
356
- const router = useRouter();
357
- const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
358
-
359
- useEffect(() => {
360
- if (promptOpen) return;
361
- if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
362
- router.push(loginHref);
363
- }
364
- }, [status, usable, wasUsable, promptOpen, router, loginHref]);
365
-
366
- return mayRender ? children : <p>Loading…</p>;
367
- }
368
- ```
406
+ The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
407
+ layout that needs its own decision, such as a role check, so it agrees with the gate.
369
408
 
370
409
  | Field | Description |
371
410
  | --- | --- |
@@ -378,6 +417,84 @@ export function ProtectedGate({
378
417
 
379
418
  `SessionGate` is the type of the result.
380
419
 
420
+ ### `AppSessionProvider`
421
+
422
+ From `@sjolystinnovation/app-kit/session/react`. next-auth's `SessionProvider`, reading the session
423
+ again every four minutes. Each read lets the jwt callback renew the access token before it expires,
424
+ so an open tab does not find a dead session on the next save. It also keeps what `useSession`
425
+ reports in this tab up to date. next-auth polls only once a session exists.
426
+
427
+ ```tsx
428
+ <AppSessionProvider>{children}</AppSessionProvider>
429
+ ```
430
+
431
+ | Prop | Type | Description |
432
+ | --- | --- | --- |
433
+ | `refetchInterval` | `number` | Seconds between reads. Defaults to 240. Keep it under five minutes, the time before expiry when a refresh falls due. |
434
+
435
+ ### `ProtectedGate`
436
+
437
+ From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
438
+ page only when `useSessionGate` allows it, and sends a signed-out visitor or a session dead on
439
+ arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
440
+
441
+ ```tsx
442
+ <ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
443
+ {children}
444
+ </ProtectedGate>
445
+ ```
446
+
447
+ | Prop | Type | Description |
448
+ | --- | --- | --- |
449
+ | `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
450
+ | `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
451
+
452
+ ### `RoleGate`
453
+
454
+ From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
455
+ Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session there is
456
+ no user and so no roles. The page stays up only on the path where the session last had the role.
457
+
458
+ ```tsx
459
+ <RoleGate role="Admin" fallback={<p>Loading…</p>} denied={<p>You do not have access.</p>}>
460
+ {children}
461
+ </RoleGate>
462
+ ```
463
+
464
+ | Prop | Type | Description |
465
+ | --- | --- | --- |
466
+ | `role` | `string` | The role the page needs, as it appears in `session.user.roles`. |
467
+ | `fallback` | `ReactNode` | What shows while the session or its roles are not known yet. Defaults to nothing. |
468
+ | `denied` | `ReactNode` | What a signed-in user without the role sees. Give this or `deniedHref`. |
469
+ | `deniedHref` | `string` | Where a signed-in user without the role is sent instead. |
470
+
471
+ ### `SessionExpiryGuard`
472
+
473
+ From `@sjolystinnovation/app-kit/session/react`. Render it once, inside the `SessionProvider`. It
474
+ shows:
475
+
476
+ - a banner in the last 30 minutes before `session.absoluteExpiresAt`, with the minutes left. That is
477
+ seven days after sign-in with `createAuthOptions`.
478
+ - a banner when the session is dead
479
+ - the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
480
+
481
+ A sign-in in the prompt keeps the page and its form. A sign-in in the prompt as a different user is
482
+ signed out and sent to `loginHref`, so the prompt never hands the page to another user.
483
+
484
+ ```tsx
485
+ <SessionExpiryGuard
486
+ loginHref="/login"
487
+ onRestored={() => toast.success("You are signed in again.")}
488
+ />
489
+ ```
490
+
491
+ | Prop | Type | Description |
492
+ | --- | --- | --- |
493
+ | `loginHref` | `string` | Where a sign-in as a different user is sent after it is signed out. |
494
+ | `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
495
+ | `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
496
+ | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
497
+
381
498
  ### `useFormDraft(config, options)`
382
499
 
383
500
  From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
@@ -415,6 +532,40 @@ Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook t
415
532
  `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
416
533
  removed when its form next reads it.
417
534
 
535
+ ### `CredentialsForm`
536
+
537
+ From `@sjolystinnovation/app-kit/ui`. Email and password sign-in through next-auth's credentials
538
+ provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
539
+ the prompt.
540
+
541
+ ```tsx
542
+ <CredentialsForm onSignedIn={() => router.push("/")} strings={{ signIn: "Logg inn" }} />
543
+ ```
544
+
545
+ | Prop | Type | Description |
546
+ | --- | --- | --- |
547
+ | `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected` with a message to refuse the sign-in. |
548
+ | `initialEmail` | `string` | The email the field starts with. |
549
+ | `children` | `ReactNode` | Extra buttons next to the submit button. |
550
+ | `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
551
+ | `onPendingChange` | `(pending: boolean) => void` | Runs when a sign-in starts and ends, such as to keep a dialog open until it finishes. |
552
+
553
+ A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
554
+ `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
555
+ or one that throws, shows `somethingWentWrong`.
556
+
557
+ `TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
558
+ key is optional and may be `undefined`, and a missing or undefined key shows the English default.
559
+
560
+ ### `TextInput`
561
+
562
+ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look and props as
563
+ `PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
564
+
565
+ ```tsx
566
+ <TextInput label="Email" name="email" type="email" required error={errors.email} />
567
+ ```
568
+
418
569
  ### `Alert`
419
570
 
420
571
  ```tsx
@@ -426,7 +577,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
426
577
  | Prop | Type | Default | Description |
427
578
  | --- | --- | --- | --- |
428
579
  | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
429
- | `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
580
+ | `role` | `"alert" \| "status" \| "none"` | `"alert"` | A screen reader announces `status` without interrupting, on every change of its text. Use `none` when a separate live region announces the text. |
430
581
  | `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
431
582
  | `children` | `ReactNode` | | Content. |
432
583
 
@@ -452,6 +603,16 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
452
603
 
453
604
  Pass the two toggle labels in the app's language. The defaults are English.
454
605
 
606
+ ### `toast`
607
+
608
+ From `@sjolystinnovation/app-kit/toast`. sonner's `toast`, except that `toast.error` shows nothing
609
+ while the re-sign-in prompt is open. The prompt already says why the request failed. Every other
610
+ toast passes through, and so do the error states of `toast.promise`.
611
+
612
+ ```ts
613
+ import { toast } from "@sjolystinnovation/app-kit/toast";
614
+ ```
615
+
455
616
  ## Theming
456
617
 
457
618
  The components draw every colour, corner radius and label size from theme variables with a light
@@ -495,10 +656,22 @@ selector such as `.dark` for a scoped theme:
495
656
  | `--input-focus-ring-width` | `2px` |
496
657
  | `--color-input-toggle` | gray 400, the show password icon |
497
658
  | `--color-input-toggle-hover` | gray 600 |
498
-
499
- A default that points at another variable, such as `--color-input-focus-ring` at `--color-primary`,
500
- is read once for the whole page. A scoped theme that changes `--color-primary` should set
501
- `--color-input-focus-ring` in the same selector.
659
+ | `--radius-button` | `var(--radius-lg)` |
660
+ | `--color-button-primary-bg` | `var(--color-primary)` |
661
+ | `--color-button-primary-text` | `var(--color-primary-foreground)` |
662
+ | `--color-button-secondary-border` | gray 300 |
663
+ | `--color-button-secondary-text` | gray 700 |
664
+ | `--color-button-secondary-hover-bg` | gray 50 |
665
+ | `--radius-dialog` | `var(--radius-xl)` |
666
+ | `--color-dialog-backdrop` | black at 40% |
667
+ | `--color-dialog-bg` | white |
668
+ | `--color-dialog-title` | gray 900 |
669
+ | `--color-dialog-text` | gray 600 |
670
+
671
+ A default that points at another variable is read once for the whole page. The focus ring and the
672
+ primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
673
+ changes those also sets `--color-input-focus-ring`, `--color-button-primary-bg` and
674
+ `--color-button-primary-text` in the same selector.
502
675
 
503
676
  An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
504
677
  with it and has to set the ones it uses itself.
@@ -507,9 +680,12 @@ with it and has to set the ones it uses itself.
507
680
 
508
681
  - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
509
682
  app installs itself.
683
+ - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
684
+ copy, because two copies keep two separate toast stores.
510
685
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
511
- - For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring uses
512
- the theme's `primary` colour unless `--color-input-focus-ring` is set.
686
+ - For `@sjolystinnovation/app-kit/ui` and the components in `session/react`: Tailwind CSS 4 and
687
+ `@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
688
+ `primary-foreground` colours, unless their own variables are set.
513
689
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
514
690
  `node16` and `nodenext` reject.
515
691
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -21,7 +21,9 @@
21
21
  "./forms": "./src/forms/index.ts",
22
22
  "./auth": "./src/auth/index.ts",
23
23
  "./api": "./src/api/index.ts",
24
+ "./server": "./src/server/index.ts",
24
25
  "./ui": "./src/ui/index.ts",
26
+ "./toast": "./src/toast/index.ts",
25
27
  "./styles.css": "./src/styles.css"
26
28
  },
27
29
  "files": [
@@ -41,7 +43,13 @@
41
43
  "next": "^16",
42
44
  "next-auth": "^4.24",
43
45
  "react": "^19",
44
- "react-dom": "^19"
46
+ "react-dom": "^19",
47
+ "sonner": "^2"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "sonner": {
51
+ "optional": true
52
+ }
45
53
  },
46
54
  "devDependencies": {
47
55
  "@heroicons/react": "^2.2.0",
@@ -57,6 +65,7 @@
57
65
  "next-auth": "^4.24.15",
58
66
  "react": "^19.3.0",
59
67
  "react-dom": "^19.3.0",
68
+ "sonner": "^2.0.8",
60
69
  "typescript": "^6.0.3",
61
70
  "vitest": "^5.0.3"
62
71
  },
package/src/api/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createApiClient } from "./createApiClient";
2
2
  export { request } from "./request";
3
+ export { requestRevalidate } from "./requestRevalidate";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Asks the app's revalidate route to purge the pages behind `target`, so an admin edit shows at
3
+ * once. Best effort: on failure the pages' revalidate window is the fallback.
4
+ */
5
+ export async function requestRevalidate(target: string, endpoint = "/api/revalidate"): Promise<void> {
6
+ try {
7
+ await fetch(endpoint, {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify({ target }),
11
+ });
12
+ } catch {
13
+ // Best effort, see above.
14
+ }
15
+ }
@@ -4,7 +4,7 @@ import type { NextAuthOptions, Session, User } from "next-auth";
4
4
  import CredentialsProvider from "next-auth/providers/credentials";
5
5
  import type { JWT } from "next-auth/jwt";
6
6
  import { resolveJwtSecret, type SessionConfig } from "../config";
7
- import { SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
7
+ import { ABSOLUTE_SESSION_MAX_AGE, CLOCK_TOLERANCE_S, SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
8
8
 
9
9
  /** The signed-in user, as the session carries it. */
10
10
  export interface SessionUser {
@@ -60,8 +60,6 @@ export type AuthOptionsSettings = {
60
60
  }
61
61
  );
62
62
 
63
- const ABSOLUTE_SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
64
-
65
63
  // Refresh ahead of expiry, so the token is still fresh when the user saves.
66
64
  const MAX_REFRESH_SKEW_MS = 5 * 60 * 1000;
67
65
 
@@ -72,9 +70,6 @@ const MIN_REFRESH_GAP_MS = 30 * 1000;
72
70
  // How long a refresh result is remembered. It is also the wait before a failed refresh is retried.
73
71
  const REMEMBER_ROTATION_MS = 60 * 1000;
74
72
 
75
- // How far this server's clock may differ from the API's when a token's times are checked.
76
- const CLOCK_TOLERANCE_S = 60;
77
-
78
73
  // Sign-in and session reads wait for the API, so a hung connection must not hold them forever.
79
74
  const API_TIMEOUT_MS = 10_000;
80
75
 
@@ -0,0 +1,4 @@
1
+ export { createPublicApi, PublicApiError } from "./publicApi";
2
+ export type { PublicApiOptions, PublicGetOptions, PublicResponse } from "./publicApi";
3
+ export { createRevalidateRoute } from "./revalidateRoute";
4
+ export type { RevalidateRouteOptions } from "./revalidateRoute";
@@ -0,0 +1,104 @@
1
+ export interface PublicResponse<T> {
2
+ data: T;
3
+ /** From the `Last-Modified` header, when the endpoint reports one. */
4
+ lastModified: Date | null;
5
+ }
6
+
7
+ export interface PublicGetOptions {
8
+ /** Cache tags, so a revalidate route can purge this fetch by tag. */
9
+ tags?: string[] | undefined;
10
+ }
11
+
12
+ export interface PublicApiOptions {
13
+ /** Seconds a cached response stays fresh. Defaults to 60. */
14
+ revalidate?: number | undefined;
15
+ }
16
+
17
+ /** The API could not be reached, or answered with no usable content. */
18
+ export class PublicApiError extends Error {
19
+ readonly path: string;
20
+ readonly status: number | null;
21
+
22
+ constructor(path: string, status: number | null, options?: { cause?: unknown }) {
23
+ super(`GET ${path} failed${status == null ? "" : ` with ${status}`}`, options);
24
+ this.name = "PublicApiError";
25
+ this.path = path;
26
+ this.status = status;
27
+ }
28
+ }
29
+
30
+ /** Reads `Last-Modified`, ignoring a header that is missing or not a date. */
31
+ function lastModifiedOf(response: Response): Date | null {
32
+ const header = response.headers.get("last-modified");
33
+ if (!header) return null;
34
+ const date = new Date(header);
35
+ return Number.isNaN(date.getTime()) ? null : date;
36
+ }
37
+
38
+ /**
39
+ * Cached GETs against the API's anonymous endpoints, for server components and route handlers.
40
+ *
41
+ * A 404 gives null. An unreachable or failing API throws `PublicApiError`, so a failed fetch is
42
+ * never cached as an empty page in place of a working one.
43
+ */
44
+ export function createPublicApi(baseUrl: string, options: PublicApiOptions = {}) {
45
+ if (!baseUrl) {
46
+ throw new Error("createPublicApi needs the API base URL, and baseUrl is empty.");
47
+ }
48
+ const revalidate = options.revalidate ?? 60;
49
+ const base = new URL(baseUrl);
50
+ const basePath = base.pathname.replace(/\/$/, "");
51
+
52
+ // The path is joined onto the base URL. A path that does not start with a single "/" is a bug in
53
+ // the caller, so it throws. A path with a dot segment, often a slug from the URL, names no resource,
54
+ // so it gives null, the same as a 404. A slug goes through encodeURIComponent first.
55
+ function urlFor(path: string): string | null {
56
+ if (!path.startsWith("/") || path.startsWith("//")) {
57
+ throw new Error(`Public API path must start with a single "/" under the base URL: ${path}`);
58
+ }
59
+ const segments = path.split(/[?#]/, 1)[0]!.replace(/%2e/gi, ".").split("/");
60
+ if (segments.some((segment) => segment === "." || segment === "..")) return null;
61
+ const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
62
+ if (url.origin !== base.origin || !url.pathname.startsWith(`${basePath}/`)) return null;
63
+ return url.toString();
64
+ }
65
+
66
+ async function publicGetWithMeta<T>(path: string, opts: PublicGetOptions = {}): Promise<PublicResponse<T> | null> {
67
+ const url = urlFor(path);
68
+ if (url == null) return null;
69
+ let response: Response;
70
+ try {
71
+ response = await fetch(url, {
72
+ next: opts.tags ? { revalidate, tags: opts.tags } : { revalidate },
73
+ });
74
+ } catch (cause) {
75
+ throw new PublicApiError(path, null, { cause });
76
+ }
77
+
78
+ if (response.status === 404) return null;
79
+ if (!response.ok) throw new PublicApiError(path, response.status);
80
+
81
+ try {
82
+ return { data: (await response.json()) as T, lastModified: lastModifiedOf(response) };
83
+ } catch (cause) {
84
+ throw new PublicApiError(path, response.status, { cause });
85
+ }
86
+ }
87
+
88
+ /** The data alone. Null when the resource does not exist. */
89
+ async function publicGet<T>(path: string, opts: PublicGetOptions = {}): Promise<T | null> {
90
+ return (await publicGetWithMeta<T>(path, opts))?.data ?? null;
91
+ }
92
+
93
+ /** For data a page can do without. Null on any failure, so the page still renders. */
94
+ async function publicGetOptional<T>(path: string, opts: PublicGetOptions = {}): Promise<T | null> {
95
+ try {
96
+ return await publicGet<T>(path, opts);
97
+ } catch (error) {
98
+ if (error instanceof PublicApiError) return null;
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ return { publicGet, publicGetOptional, publicGetWithMeta };
104
+ }
@@ -0,0 +1,77 @@
1
+ import { revalidatePath, revalidateTag } from "next/cache";
2
+ import { NextResponse, type NextRequest } from "next/server";
3
+ import jwt from "jsonwebtoken";
4
+ import { getToken, type JWT } from "next-auth/jwt";
5
+ import { ABSOLUTE_SESSION_MAX_AGE, CLOCK_TOLERANCE_S, isTerminalSessionError } from "../session/expiry";
6
+
7
+ export interface RevalidateRouteOptions {
8
+ /**
9
+ * Each target the client may send, with the page paths it purges. A path with a dynamic segment,
10
+ * such as `/builds/[slug]`, purges every page of that route. The target name is also purged as a
11
+ * cache tag, so tag fetches with it.
12
+ */
13
+ targets: Record<string, readonly string[]>;
14
+ /** The role allowed to purge, as it appears in the session user's roles. */
15
+ role: string;
16
+ }
17
+
18
+ type Token = JWT | null;
19
+
20
+ const rolesOf = (token: Token): string[] => (token?.user as { roles?: string[] } | undefined)?.roles ?? [];
21
+
22
+ // getToken only decrypts the cookie, without the jwt callback. So the checks that callback makes are
23
+ // made here: a refused refresh still carries its roles, and the cookie outlives the seven days. The
24
+ // roles must also come from an access token that has not expired, so a copied cookie of a user who
25
+ // lost the role stops working within the token's lifetime.
26
+ function isLive(token: Token): boolean {
27
+ if (!token || isTerminalSessionError(token.error as string | undefined)) return false;
28
+ const loginAt = token.loginAt;
29
+ if (typeof loginAt !== "number" || Date.now() - loginAt > ABSOLUTE_SESSION_MAX_AGE) return false;
30
+ return accessTokenValid(token.accessToken);
31
+ }
32
+
33
+ // The cookie is encrypted and authenticated with NEXTAUTH_SECRET, so the access token in it is the
34
+ // one the API issued, and its expiry can be read without verifying the signature again.
35
+ function accessTokenValid(accessToken: unknown): boolean {
36
+ if (typeof accessToken !== "string") return false;
37
+ const claims = jwt.decode(accessToken);
38
+ const exp = claims && typeof claims === "object" ? claims.exp : undefined;
39
+ return typeof exp === "number" && exp * 1000 + CLOCK_TOLERANCE_S * 1000 > Date.now();
40
+ }
41
+
42
+ /**
43
+ * A `POST` route handler that purges cached pages after an edit, for `app/api/revalidate/route.ts`.
44
+ * It answers 403 unless the caller has a live session with `role`, 415 unless the body is JSON, and
45
+ * 400 for a target it does not know.
46
+ */
47
+ export function createRevalidateRoute({ targets, role }: RevalidateRouteOptions) {
48
+ return async function POST(req: NextRequest): Promise<NextResponse> {
49
+ // getToken reads NEXTAUTH_SECRET itself, the same way next-auth does. It is asked for both cookie
50
+ // names, because behind a TLS proxy next-auth can sign in with the secure cookie while getToken
51
+ // would look for the plain one.
52
+ const token = (await getToken({ req, secureCookie: true })) ?? (await getToken({ req, secureCookie: false }));
53
+ if (!isLive(token) || !rolesOf(token).includes(role)) {
54
+ return NextResponse.json({ message: "Forbidden" }, { status: 403 });
55
+ }
56
+
57
+ // JSON only, so a plain form on a sibling subdomain cannot post here with the session cookie.
58
+ if (!req.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
59
+ return NextResponse.json({ message: "Expected JSON" }, { status: 415 });
60
+ }
61
+
62
+ const body: unknown = await req.json().catch(() => null);
63
+ const target = (body as { target?: unknown } | null)?.target;
64
+ if (typeof target !== "string" || !Object.hasOwn(targets, target)) {
65
+ return NextResponse.json({ message: "Unknown target" }, { status: 400 });
66
+ }
67
+
68
+ for (const path of targets[target] ?? []) {
69
+ if (path.includes("[")) revalidatePath(path, "page");
70
+ else revalidatePath(path);
71
+ }
72
+ // By tag as well, because a path purge misses fetches that another route rendered. expire 0
73
+ // makes them stale at once.
74
+ revalidateTag(target, { expire: 0 });
75
+ return NextResponse.json({ revalidated: true, target });
76
+ };
77
+ }
@@ -1,3 +1,9 @@
1
+ /** How long a session lasts after sign-in, however often it is refreshed. Seven days. */
2
+ export const ABSOLUTE_SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
3
+
4
+ /** How far this server's clock may differ from the API's when a token's times are checked. */
5
+ export const CLOCK_TOLERANCE_S = 60;
6
+
1
7
  /**
2
8
  * Errors the jwt callback sets when the session cannot be recovered without a new sign-in. A
3
9
  * transient refresh failure, such as a network error or an API restart, sets no error, so the
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { SessionProvider } from "next-auth/react";
4
+ import type { ReactNode } from "react";
5
+
6
+ /** Four minutes, shorter than the five minutes before expiry when a refresh falls due. */
7
+ const SESSION_POLL_SECONDS = 4 * 60;
8
+
9
+ export interface AppSessionProviderProps {
10
+ /** Seconds between session reads. Defaults to four minutes. */
11
+ refetchInterval?: number | undefined;
12
+ children: ReactNode;
13
+ }
14
+
15
+ /**
16
+ * next-auth's `SessionProvider`, reading the session again on a timer. Each read lets the jwt
17
+ * callback renew the access token ahead of expiry, so an open tab stays usable and does not find a
18
+ * dead session on the next save. It also keeps what `useSession` reports in this tab up to date.
19
+ * next-auth polls only once a session exists, so signed-out visitors cost nothing.
20
+ */
21
+ export function AppSessionProvider({ refetchInterval = SESSION_POLL_SECONDS, children }: AppSessionProviderProps) {
22
+ return <SessionProvider refetchInterval={refetchInterval}>{children}</SessionProvider>;
23
+ }