@atelic-action/ui 0.10.0 → 0.12.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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What the access wall holds back. With the wall armed, every request needs
3
+ * a session except what the login page itself needs to render: the practice's
4
+ * FOOTPRINT.md says anything carrying a private signal is never hosted
5
+ * publicly, and on a private artifact that includes the owner's screenshots
6
+ * under /images/, the printed PDF, and the built chunks a document's content
7
+ * compiles into.
8
+ *
9
+ * The open paths are read from what the built /login page actually loads:
10
+ * its hashed CSS and JS under /assets/, the fonts its stylesheet declares,
11
+ * the favicon in its head, and the brand marks the header draws, which every
12
+ * prerendered page preloads because the router's pending page carries the
13
+ * header (each site's e2e/wall.spec.ts fails when the login page asks for
14
+ * anything else); plus the two sign in functions under /api/auth/, the login page itself,
15
+ * robots.txt, and the uptime monitor's health.json. /assets/doc/ is the one
16
+ * part of /assets/ held back: the build writes every chunk carrying a
17
+ * private content module there (the private chunks build plugin), so a document's
18
+ * copy and numbers never sit in a file the login page can load.
19
+ *
20
+ * A logo is a brand mark and is not private; a screenshot is evidence and
21
+ * may be. So brand marks, the sender's and the recipient's, live under
22
+ * public/brand/ and stay open, so the header and the login page can draw
23
+ * them, and the owner's screens live under public/images/ and are held. The
24
+ * sender's mark at /logo.svg and the favicon stay open where they are,
25
+ * because every cut already points at them there.
26
+ *
27
+ * The business's website build, when one is previewed, is proxied under
28
+ * /proto by a rewrite in the host's vercel.json. It is a public thing by design and carries no private reading, so
29
+ * it stays open on a private host too: a prospect reads the prototype
30
+ * without a sign in while the documents beside it stay behind the wall.
31
+ *
32
+ * Pure and dependency free, so the middleware imports it and the tests call
33
+ * it directly.
34
+ */
35
+ const OPEN_EXACT = new Set([
36
+ "/login",
37
+ "/login/",
38
+ "/favicon.svg",
39
+ "/logo.svg",
40
+ "/robots.txt",
41
+ "/health.json",
42
+ "/proto",
43
+ ]);
44
+ const OPEN_PREFIXES = ["/api/auth/", "/assets/", "/brand/", "/fonts/", "/proto/"];
45
+ /** The one corner of an open prefix that stays behind the wall. */
46
+ const HELD_PREFIXES = ["/assets/doc/"];
47
+ /**
48
+ * Whether a request for this path needs a session when the wall is armed.
49
+ * The path is read the way a file server would read it (decoded, repeated
50
+ * slashes collapsed) so an encoded or doubled slash cannot walk a held file
51
+ * out through an open prefix; anything that will not decode, or still climbs
52
+ * a directory once decoded, is held. Case is folded for the held corner
53
+ * alone, so no spelling of it slips out, while an open path opens only as
54
+ * written: the host serves files by exact case, and a path that merely looks
55
+ * like an open one names some other file.
56
+ */
57
+ export function needsSession(pathname) {
58
+ let path;
59
+ try {
60
+ path = decodeURIComponent(pathname);
61
+ }
62
+ catch {
63
+ return true;
64
+ }
65
+ path = path.replace(/\/{2,}/g, "/");
66
+ if (path.includes("..") || path.includes("\\"))
67
+ return true;
68
+ const folded = path.toLowerCase();
69
+ if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix)))
70
+ return true;
71
+ if (OPEN_EXACT.has(path))
72
+ return false;
73
+ return !OPEN_PREFIXES.some((prefix) => path.startsWith(prefix));
74
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.10.0",
4
- "description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
3
+ "version": "0.12.0",
4
+ "description": "Shared UI for the Atelic templates: site chrome, base styles, the component library, and the access gate they install",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "repository": {
@@ -12,7 +12,8 @@
12
12
  "access": "public"
13
13
  },
14
14
  "files": [
15
- "src"
15
+ "src",
16
+ "dist"
16
17
  ],
17
18
  "sideEffects": [
18
19
  "**/*.css"
@@ -29,16 +30,38 @@
29
30
  "./styles/chrome.css": "./src/styles/chrome.css",
30
31
  "./styles/components.css": "./src/styles/components.css",
31
32
  "./styles/star-rating.css": "./src/styles/star-rating.css",
33
+ "./gate": {
34
+ "types": "./dist/gate/index.d.ts",
35
+ "default": "./dist/gate/index.js"
36
+ },
37
+ "./gate/middleware": {
38
+ "types": "./dist/gate/middleware.d.ts",
39
+ "default": "./dist/gate/middleware.js"
40
+ },
41
+ "./gate/request-link": {
42
+ "types": "./dist/gate/request-link.d.ts",
43
+ "default": "./dist/gate/request-link.js"
44
+ },
45
+ "./gate/verify": {
46
+ "types": "./dist/gate/verify.d.ts",
47
+ "default": "./dist/gate/verify.js"
48
+ },
32
49
  "./package.json": "./package.json"
33
50
  },
34
51
  "scripts": {
52
+ "build": "rm -rf dist && tsc -p tsconfig.server.json",
53
+ "build:smoke": "node --input-type=module -e \"for (const entry of ['', '/middleware', '/request-link', '/verify']) await import('@atelic-action/ui/gate' + entry)\"",
54
+ "prepack": "bun run build",
35
55
  "lint": "biome check .",
36
56
  "lint:fix": "biome check . --write",
37
- "typecheck": "tsc --noEmit && tsc --noEmit -p gallery",
57
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p gallery",
38
58
  "test:run": "vitest run",
39
59
  "gallery:dev": "vite gallery",
40
60
  "gallery:build": "vite build gallery"
41
61
  },
62
+ "dependencies": {
63
+ "jose": "^6.2.12"
64
+ },
42
65
  "peerDependencies": {
43
66
  "lucide-react": "^1.23.0",
44
67
  "react": "^19.0.0",
@@ -48,6 +71,7 @@
48
71
  "@biomejs/biome": "^2.5.13",
49
72
  "@testing-library/jest-dom": "^6.6.2",
50
73
  "@testing-library/react": "^16.0.1",
74
+ "@types/node": "^24.0.0",
51
75
  "@types/react": "^19.0.0",
52
76
  "@types/react-dom": "^19.0.0",
53
77
  "@vitejs/plugin-react": "^6.1.1",
@@ -0,0 +1,73 @@
1
+ import { ChevronDown } from "lucide-react";
2
+ import { type ReactNode, useId } from "react";
3
+
4
+ export interface DisclosureProps {
5
+ /** What the section is, and the toggle's accessible name. */
6
+ label: ReactNode;
7
+ open: boolean;
8
+ onToggle: () => void;
9
+ /**
10
+ * Sits beside the label and outside the toggle's name: a count, a flag, a
11
+ * small chart. A tap on it still folds the section.
12
+ */
13
+ meta?: ReactNode;
14
+ /** Makes the label a real heading at this level; leave it out for none. */
15
+ headingLevel?: 2 | 3 | 4 | 5 | 6;
16
+ /** The panel's id; minted when left out. */
17
+ panelId?: string;
18
+ className?: string;
19
+ children: ReactNode;
20
+ }
21
+
22
+ /**
23
+ * A section the page opens and closes: a header row that folds a body the
24
+ * caller controls. Where `Collapsible` is a native details element that keeps
25
+ * its own state and its detail in the page, this one is for an app. The
26
+ * caller holds `open`, so it can remember which sections were folded, and a
27
+ * closed body is not rendered, so a long list costs nothing until asked for.
28
+ *
29
+ * The label is the toggle, so its name reads "Produce, collapsed" and not
30
+ * "Produce 10 items"; `meta` is a sibling for that reason. The toggle's
31
+ * `::after` stretches over the whole header row, so a tap anywhere on it,
32
+ * meta and chevron included, folds the section.
33
+ */
34
+ export function Disclosure({
35
+ label,
36
+ open,
37
+ onToggle,
38
+ meta,
39
+ headingLevel,
40
+ panelId,
41
+ className,
42
+ children,
43
+ }: DisclosureProps) {
44
+ const minted = useId();
45
+ const id = panelId ?? minted;
46
+ const Root = headingLevel ? "section" : "div";
47
+ const Title = headingLevel ? (`h${headingLevel}` as const) : "div";
48
+
49
+ return (
50
+ <Root className={["disclosure", open && "is-open", className].filter(Boolean).join(" ")}>
51
+ <div className="disclosure-head">
52
+ <Title className="disclosure-title">
53
+ <button
54
+ type="button"
55
+ className="disclosure-toggle"
56
+ onClick={onToggle}
57
+ aria-expanded={open}
58
+ aria-controls={id}
59
+ >
60
+ {label}
61
+ </button>
62
+ </Title>
63
+ {meta != null && meta !== false && <span className="disclosure-meta">{meta}</span>}
64
+ <ChevronDown className="disclosure-chevron" size={16} aria-hidden="true" />
65
+ </div>
66
+ {open && (
67
+ <div className="disclosure-body" id={id}>
68
+ {children}
69
+ </div>
70
+ )}
71
+ </Root>
72
+ );
73
+ }
@@ -22,6 +22,8 @@ export interface StatDelta {
22
22
  export interface StatItem {
23
23
  /** The big figure, e.g. "$199", "3,948", "23%"; may embed an inline glyph. */
24
24
  value: ReactNode;
25
+ /** The figure's unit, set small after it on the same baseline, e.g. "mi". */
26
+ unit?: ReactNode;
25
27
  /** An arrow before the figure, when the figure is itself a change. */
26
28
  trend?: StatDirection;
27
29
  /** What the figure counts. */
@@ -38,6 +40,13 @@ export interface StatItem {
38
40
 
39
41
  export interface StatRowProps {
40
42
  stats: StatItem[];
43
+ /**
44
+ * How loud the row is. `loud`, the default, is a report's headline figures:
45
+ * heavy, large, in the accent. `quiet` is a glance row for a working page:
46
+ * a light figure in the text color over a small capitalized label, the
47
+ * stats divided by a hairline. A tone still colors a quiet figure.
48
+ */
49
+ variant?: "loud" | "quiet";
41
50
  className?: string;
42
51
  }
43
52
 
@@ -50,9 +59,13 @@ const spoken = { up: "Up", down: "Down", flat: "Unchanged" } as const;
50
59
  * closes the stat, with the period it covers. The row wraps to as many
51
60
  * columns as fit, and arrows are read aloud as up or down.
52
61
  */
53
- export function StatRow({ stats, className }: StatRowProps) {
62
+ export function StatRow({ stats, variant = "loud", className }: StatRowProps) {
54
63
  return (
55
- <div className={["stat-row", className].filter(Boolean).join(" ")}>
64
+ <div
65
+ className={["stat-row", variant === "quiet" && "is-quiet", className]
66
+ .filter(Boolean)
67
+ .join(" ")}
68
+ >
56
69
  {stats.map((stat, i) => {
57
70
  const Trend = stat.trend ? arrow[stat.trend] : null;
58
71
  return (
@@ -63,6 +76,7 @@ export function StatRow({ stats, className }: StatRowProps) {
63
76
  <Trend className="stat-trend" role="img" aria-label={spoken[stat.trend]} />
64
77
  )}
65
78
  {stat.value}
79
+ {stat.unit != null && <span className="stat-unit">{stat.unit}</span>}
66
80
  </div>
67
81
  {stat.deltas?.map((delta) => {
68
82
  const Icon = arrow[delta.direction];
@@ -16,6 +16,7 @@ export {
16
16
  type DataTableValueCell,
17
17
  type SortDirection,
18
18
  } from "./DataTable";
19
+ export { Disclosure, type DisclosureProps } from "./Disclosure";
19
20
  export { NotFound, type NotFoundProps } from "./NotFound";
20
21
  export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
21
22
  export { describeStarRating, StarRating, type StarRatingProps } from "./StarRating";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * How the gate reads its settings from the environment. Shared by the two
3
+ * sign in functions and the middleware, so each reads a value the same way.
4
+ */
5
+
6
+ /** Whether an address is on the comma separated allowlist. */
7
+ export function isAllowed(email: string, allowlist: string | undefined): boolean {
8
+ const wanted = email.trim().toLowerCase();
9
+ if (!wanted) return false;
10
+ return (allowlist ?? "")
11
+ .split(",")
12
+ .map((entry) => entry.trim().toLowerCase())
13
+ .includes(wanted);
14
+ }
15
+
16
+ /**
17
+ * A setting that has to be a positive, finite number, or its default. A
18
+ * lifetime of zero, a negative one, or Infinity would mint a token that is
19
+ * already dead or that cannot be signed at all. A fraction is allowed (half
20
+ * a day), so a caller rounds what it derives to whole seconds.
21
+ */
22
+ export function positive(value: string | undefined, fallback: number): number {
23
+ const parsed = Number(value);
24
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
25
+ }
@@ -0,0 +1,3 @@
1
+ export { safeNext } from "./next.js";
2
+ export { signToken, type TokenClaims, type TokenPurpose, verifyToken } from "./tokens.js";
3
+ export { needsSession } from "./wall.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Access gate: the routing middleware.
3
+ *
4
+ * Opt in and fail open: with no GATE_SESSION_SECRET set the gate is dormant
5
+ * and every request passes, so a site is fully public. Set the secret (and an
6
+ * allowlist) on the Vercel project and this same code starts enforcing: a
7
+ * request without a valid session cookie is redirected to /login. The
8
+ * session's address has to be on the allowlist still, so taking one off (and
9
+ * redeploying) ends that reader's session.
10
+ *
11
+ * A site's root `middleware.ts` exports this as its default beside its own
12
+ * `config`, which the platform reads from that file and nowhere else:
13
+ *
14
+ * export { default } from "@atelic-action/ui/gate/middleware";
15
+ * export const config = { matcher: ["/((?!api/auth/).*)"] };
16
+ *
17
+ * That matcher keeps only the two sign in functions away from the middleware,
18
+ * so any other function a host adds is held like a page. The default holds
19
+ * back everything `needsSession` in wall.ts does. A site
20
+ * whose matcher already leaves its open paths out passes its own test to
21
+ * `createMiddleware`.
22
+ */
23
+ import { isAllowed } from "./config.js";
24
+ import { verifyToken } from "./tokens.js";
25
+ import { needsSession as wallNeedsSession } from "./wall.js";
26
+
27
+ export interface MiddlewareOptions {
28
+ /** Whether a path needs a session once the gate is armed. */
29
+ needsSession?: (pathname: string) => boolean;
30
+ }
31
+
32
+ function readCookie(header: string, name: string): string | undefined {
33
+ const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
34
+ if (!match) return undefined;
35
+ try {
36
+ return decodeURIComponent(match[1]);
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ export function createMiddleware(options: MiddlewareOptions = {}) {
43
+ const needsSession = options.needsSession ?? wallNeedsSession;
44
+
45
+ return async function middleware(request: Request): Promise<Response | undefined> {
46
+ const secret = process.env.GATE_SESSION_SECRET;
47
+ if (!secret) return; // gate off, let everything through
48
+
49
+ const url = new URL(request.url);
50
+ if (!needsSession(url.pathname)) return; // what the login page needs to render
51
+
52
+ const cookies = request.headers.get("cookie") ?? "";
53
+ const claims = await verifyToken(readCookie(cookies, "gate_session"), secret);
54
+ if (claims?.p === "session" && isAllowed(claims.e, process.env.GATE_ALLOWLIST)) {
55
+ return; // valid session, continue to the site
56
+ }
57
+
58
+ const login = new URL("/login", url.origin);
59
+ login.searchParams.set("next", url.pathname + url.search);
60
+ return Response.redirect(login.toString(), 302);
61
+ };
62
+ }
63
+
64
+ export default createMiddleware();
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The post sign in destination, held to a path on this host. A value that
3
+ * starts with one slash is not enough: a browser reads `//host` as protocol
4
+ * relative and `/\host` the same way, and drops tabs and newlines before it
5
+ * parses, so `/\t/host` arrives as `//host`. The value also becomes a
6
+ * Location header, which throws on anything outside Latin 1, so the path is
7
+ * held to printable ASCII, and to a length no real path here comes near, so
8
+ * a request cannot grow the emailed link without bound. Anything else falls
9
+ * back to the root.
10
+ */
11
+ const LONGEST = 512;
12
+
13
+ export function safeNext(value: unknown): string {
14
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return "/";
15
+ if (value.length > LONGEST) return "/";
16
+ for (let i = 0; i < value.length; i++) {
17
+ const code = value.charCodeAt(i);
18
+ if (code === 0x5c || code <= 0x20 || code >= 0x7f) return "/";
19
+ }
20
+ return value;
21
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Access gate: request a sign in link.
3
+ *
4
+ * Emails a signed link through Resend, but only to an allowlisted address.
5
+ * The response is the same whether or not the email matched, in its body and
6
+ * in how long it takes, so the allowlist cannot be probed. A site's
7
+ * `api/auth/request-link.ts` re exports `POST` from here.
8
+ *
9
+ * Env (set on the Vercel project):
10
+ * - GATE_SESSION_SECRET signing secret; also the gate's on and off switch
11
+ * - GATE_ALLOWLIST comma separated emails allowed to receive a link
12
+ * - RESEND_API_KEY sending only Resend key
13
+ * - GATE_FROM sender display name (optional)
14
+ * - GATE_SUBJECT email subject (optional)
15
+ * - GATE_LINK_TTL_MIN link lifetime in minutes (optional, default 15)
16
+ * - GATE_BASE_URL the host the link points at (optional, see linkBase)
17
+ */
18
+ import { isAllowed, positive } from "./config.js";
19
+ import { safeNext } from "./next.js";
20
+ import { signToken } from "./tokens.js";
21
+
22
+ const DEFAULT_FROM = "Atelic <forms@atelic.me>";
23
+
24
+ /** How long every valid request takes to answer, whatever it did. */
25
+ const ANSWER_AFTER_MS = 2000;
26
+
27
+ /**
28
+ * How long a send may run before it is given up. Shorter than the answer, so
29
+ * a slow send can never make an allowlisted address answer later than a
30
+ * stranger.
31
+ */
32
+ const SEND_WITHIN_MS = 1700;
33
+
34
+ export interface RequestLinkOptions {
35
+ /** What the link opens, as the email names it. Defaults to "document". */
36
+ noun?: string;
37
+ }
38
+
39
+ function json(status: number, body: Record<string, unknown>): Response {
40
+ return new Response(JSON.stringify(body), {
41
+ status,
42
+ headers: { "Content-Type": "application/json" },
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Where the emailed link points. Never a forwarded header: a caller can name
48
+ * any host there, and the link carries a signed token. The configured base
49
+ * wins, read as a host (a bare one is taken as https); on production the
50
+ * project's own domain is next; otherwise the host the platform routed this
51
+ * request to, which is the deployment itself.
52
+ */
53
+ export function linkBase(request: Request, env: Record<string, string | undefined>): string {
54
+ const configured = env.GATE_BASE_URL?.trim();
55
+ if (configured) {
56
+ try {
57
+ return new URL(/^https?:\/\//i.test(configured) ? configured : `https://${configured}`)
58
+ .origin;
59
+ } catch {
60
+ console.error("gate: GATE_BASE_URL is not a URL, falling back to the request's own host");
61
+ }
62
+ }
63
+ if (env.VERCEL_ENV === "production" && env.VERCEL_PROJECT_PRODUCTION_URL) {
64
+ return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`;
65
+ }
66
+ return new URL(request.url).origin;
67
+ }
68
+
69
+ /** Text made safe to set inside an HTML element or a quoted attribute. */
70
+ function escapeHtml(text: string): string {
71
+ return text
72
+ .replaceAll("&", "&amp;")
73
+ .replaceAll("<", "&lt;")
74
+ .replaceAll(">", "&gt;")
75
+ .replaceAll('"', "&quot;")
76
+ .replaceAll("'", "&#39;");
77
+ }
78
+
79
+ function linkEmailHtml(rawLink: string, rawNoun: string): string {
80
+ const link = escapeHtml(rawLink);
81
+ const noun = escapeHtml(rawNoun);
82
+ // Gmail renders inline styles only, no style blocks.
83
+ return `
84
+ <div style="font-family: Helvetica, Arial, sans-serif; color: #211c17; line-height: 1.6; max-width: 520px;">
85
+ <p style="margin: 0 0 12px; font-size: 24px; font-weight: 600;">Your private link to the ${noun}.</p>
86
+ <p style="margin: 0 0 24px; color: #4a4137;">Click below to open the site. The link is tied to your inbox and expires shortly. Once you are in, you stay signed in for a while.</p>
87
+ <a href="${link}" style="display: inline-block; background: #211c17; color: #ffffff; font-weight: 600; text-decoration: none; padding: 14px 26px; border-radius: 8px;">Open the ${noun} &rarr;</a>
88
+ <p style="margin: 24px 0 0; font-size: 13px; color: #4a4137;">If the button does not work, paste this link into your browser:<br><span style="color: #6b5f4f; word-break: break-all;">${link}</span></p>
89
+ <p style="margin: 28px 0 0; font-size: 13px; color: #8a7c68;">If you did not expect this, you can ignore it.</p>
90
+ </div>`;
91
+ }
92
+
93
+ /**
94
+ * Sends the link, and says so in the function's log when it could not. The
95
+ * log is the only place a failed send shows: the response never tells.
96
+ */
97
+ async function sendLink(to: string, link: string, noun: string): Promise<void> {
98
+ const apiKey = process.env.RESEND_API_KEY;
99
+ if (!apiKey) {
100
+ console.error("gate: no RESEND_API_KEY, so no sign in link was sent");
101
+ return;
102
+ }
103
+ const controller = new AbortController();
104
+ const giveUp = setTimeout(() => controller.abort(), SEND_WITHIN_MS);
105
+ try {
106
+ const response = await fetch("https://api.resend.com/emails", {
107
+ method: "POST",
108
+ signal: controller.signal,
109
+ headers: {
110
+ Authorization: `Bearer ${apiKey}`,
111
+ "Content-Type": "application/json",
112
+ },
113
+ body: JSON.stringify({
114
+ from: process.env.GATE_FROM || DEFAULT_FROM,
115
+ to: [to],
116
+ subject: process.env.GATE_SUBJECT || `Your private link to the ${noun}`,
117
+ html: linkEmailHtml(link, noun),
118
+ text: `Your private link to the ${noun}:\n\n${link}\n\nIf you did not expect this, you can ignore it.`,
119
+ }),
120
+ });
121
+ if (!response.ok) {
122
+ console.error(`gate: Resend answered ${response.status}, so no sign in link was sent`);
123
+ }
124
+ } finally {
125
+ clearTimeout(giveUp);
126
+ }
127
+ }
128
+
129
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
130
+
131
+ export function createRequestLink(options: RequestLinkOptions = {}) {
132
+ const noun = options.noun ?? "document";
133
+
134
+ return async function POST(request: Request): Promise<Response> {
135
+ let data: unknown;
136
+ try {
137
+ data = await request.json();
138
+ } catch {
139
+ return json(400, { ok: false, error: "Invalid request body." });
140
+ }
141
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
142
+ return json(400, { ok: false, error: "Invalid request body." });
143
+ }
144
+ const fields = data as Record<string, unknown>;
145
+
146
+ const email = String(fields.email ?? "")
147
+ .trim()
148
+ .toLowerCase();
149
+ const next = safeNext(fields.next);
150
+
151
+ const secret = process.env.GATE_SESSION_SECRET;
152
+
153
+ // Every valid request answers after the same wait, so a send in flight
154
+ // reads no differently from no send at all.
155
+ const answered = wait(ANSWER_AFTER_MS);
156
+
157
+ if (secret && isAllowed(email, process.env.GATE_ALLOWLIST)) {
158
+ const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15);
159
+ try {
160
+ const token = await signToken(
161
+ {
162
+ e: email,
163
+ x: Math.floor(Date.now() / 1000) + Math.max(1, Math.round(ttlMinutes * 60)),
164
+ p: "link",
165
+ n: next,
166
+ },
167
+ secret,
168
+ );
169
+ const link = `${linkBase(request, process.env)}/api/auth/verify?token=${encodeURIComponent(token)}`;
170
+ await sendLink(email, link, noun);
171
+ } catch (error) {
172
+ // The response must not reveal whether a send happened; the log may.
173
+ const reason = error instanceof Error ? error.name : "unknown error";
174
+ console.error(`gate: the sign in link was not sent (${reason})`);
175
+ }
176
+ }
177
+
178
+ await answered;
179
+ return json(200, { ok: true });
180
+ };
181
+ }
182
+
183
+ /** The handler a document host exports as is. */
184
+ export const POST = createRequestLink();
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Signed tokens for the access gate: compact JWTs under HS256, signed and
3
+ * verified by jose over Web Crypto, so the same module runs in the routing
4
+ * middleware and in the serverless sign in functions. The secret is always
5
+ * passed in by the caller from the environment, so nothing here can reach a
6
+ * client bundle.
7
+ */
8
+ import { jwtVerify, SignJWT } from "jose";
9
+
10
+ export type TokenPurpose = "link" | "session";
11
+
12
+ export interface TokenClaims {
13
+ /** Subject email. */
14
+ e: string;
15
+ /** Expiry, epoch seconds. */
16
+ x: number;
17
+ /** Purpose; keeps a short lived link token from being replayed as a session. */
18
+ p: TokenPurpose;
19
+ /** Post sign in path (link tokens only). */
20
+ n?: string;
21
+ }
22
+
23
+ const encoder = new TextEncoder();
24
+
25
+ export async function signToken(claims: TokenClaims, secret: string): Promise<string> {
26
+ const body: Record<string, unknown> = { p: claims.p };
27
+ if (claims.n !== undefined) body.n = claims.n;
28
+ return new SignJWT(body)
29
+ .setProtectedHeader({ alg: "HS256" })
30
+ .setSubject(claims.e)
31
+ .setExpirationTime(claims.x)
32
+ .sign(encoder.encode(secret));
33
+ }
34
+
35
+ /**
36
+ * The claims of a token this secret signed and that has not expired, or null
37
+ * for anything else. The algorithm is pinned, so a token naming another one
38
+ * (or none) is refused before its signature is read.
39
+ */
40
+ export async function verifyToken(
41
+ token: string | undefined | null,
42
+ secret: string,
43
+ ): Promise<TokenClaims | null> {
44
+ if (!token || !secret) return null;
45
+ try {
46
+ const { payload } = await jwtVerify(token, encoder.encode(secret), {
47
+ algorithms: ["HS256"],
48
+ requiredClaims: ["sub", "exp"],
49
+ });
50
+ const { sub, exp, p, n } = payload;
51
+ if (typeof sub !== "string" || typeof exp !== "number") return null;
52
+ if (p !== "link" && p !== "session") return null;
53
+ return { e: sub, x: exp, p, ...(typeof n === "string" ? { n } : {}) };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }