@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,59 @@
1
+ /**
2
+ * Access gate: verify a sign in link and start a session.
3
+ *
4
+ * Validates the signed token from the emailed link, sets the signed session
5
+ * cookie (plus a readable email cookie for the watermark), and redirects
6
+ * into the site. A site's `api/auth/verify.ts` re exports `GET` from here.
7
+ *
8
+ * Env (set on the Vercel project):
9
+ * - GATE_SESSION_SECRET signing secret; also the gate's on and off switch
10
+ * - GATE_ALLOWLIST the link's address must still be on it
11
+ * - GATE_SESSION_DAYS session lifetime in days (optional, default 7)
12
+ * - GATE_WATERMARK_MODE "pill" (default) or "tiled"
13
+ */
14
+ import { isAllowed, positive } from "./config.js";
15
+ import { safeNext } from "./next.js";
16
+ import { signToken, verifyToken } from "./tokens.js";
17
+
18
+ function cookie(name: string, value: string, maxAge: number, httpOnly: boolean): string {
19
+ const flags = ["Path=/", "Secure", "SameSite=Lax", `Max-Age=${maxAge}`];
20
+ if (httpOnly) flags.unshift("HttpOnly");
21
+ return `${name}=${encodeURIComponent(value)}; ${flags.join("; ")}`;
22
+ }
23
+
24
+ function redirect(location: string, headers?: Headers): Response {
25
+ const merged = headers ?? new Headers();
26
+ merged.set("Location", location);
27
+ // The request carried a token in its URL and the answer may carry cookies:
28
+ // nothing keeps a copy, and the next page learns nothing of the link.
29
+ merged.set("Cache-Control", "no-store");
30
+ merged.set("Referrer-Policy", "no-referrer");
31
+ return new Response(null, { status: 302, headers: merged });
32
+ }
33
+
34
+ export async function GET(request: Request): Promise<Response> {
35
+ const secret = process.env.GATE_SESSION_SECRET;
36
+ if (!secret) return redirect("/login?e=expired");
37
+
38
+ const token = new URL(request.url).searchParams.get("token");
39
+ const claims = await verifyToken(token, secret);
40
+ // An address taken off the allowlist after its link went out gets no session.
41
+ if (claims?.p !== "link" || !isAllowed(claims.e, process.env.GATE_ALLOWLIST)) {
42
+ return redirect("/login?e=expired");
43
+ }
44
+
45
+ const days = positive(process.env.GATE_SESSION_DAYS, 7);
46
+ // Whole seconds: a browser ignores a Max-Age that is not an integer.
47
+ const maxAge = Math.max(1, Math.round(days * 24 * 60 * 60));
48
+ const session = await signToken(
49
+ { e: claims.e, x: Math.floor(Date.now() / 1000) + maxAge, p: "session" },
50
+ secret,
51
+ );
52
+ const mode = process.env.GATE_WATERMARK_MODE || "pill";
53
+
54
+ const headers = new Headers();
55
+ headers.append("Set-Cookie", cookie("gate_session", session, maxAge, true));
56
+ headers.append("Set-Cookie", cookie("gate_email", claims.e, maxAge, false));
57
+ headers.append("Set-Cookie", cookie("gate_wm", mode, maxAge, false));
58
+ return redirect(safeNext(claims.n), headers);
59
+ }
@@ -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
+
36
+ const OPEN_EXACT = new Set([
37
+ "/login",
38
+ "/login/",
39
+ "/favicon.svg",
40
+ "/logo.svg",
41
+ "/robots.txt",
42
+ "/health.json",
43
+ "/proto",
44
+ ]);
45
+
46
+ const OPEN_PREFIXES = ["/api/auth/", "/assets/", "/brand/", "/fonts/", "/proto/"];
47
+
48
+ /** The one corner of an open prefix that stays behind the wall. */
49
+ const HELD_PREFIXES = ["/assets/doc/"];
50
+
51
+ /**
52
+ * Whether a request for this path needs a session when the wall is armed.
53
+ * The path is read the way a file server would read it (decoded, repeated
54
+ * slashes collapsed) so an encoded or doubled slash cannot walk a held file
55
+ * out through an open prefix; anything that will not decode, or still climbs
56
+ * a directory once decoded, is held. Case is folded for the held corner
57
+ * alone, so no spelling of it slips out, while an open path opens only as
58
+ * written: the host serves files by exact case, and a path that merely looks
59
+ * like an open one names some other file.
60
+ */
61
+ export function needsSession(pathname: string): boolean {
62
+ let path: string;
63
+ try {
64
+ path = decodeURIComponent(pathname);
65
+ } catch {
66
+ return true;
67
+ }
68
+ path = path.replace(/\/{2,}/g, "/");
69
+ if (path.includes("..") || path.includes("\\")) return true;
70
+ const folded = path.toLowerCase();
71
+ if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix))) return true;
72
+ if (OPEN_EXACT.has(path)) return false;
73
+ return !OPEN_PREFIXES.some((prefix) => path.startsWith(prefix));
74
+ }
@@ -148,6 +148,114 @@
148
148
  margin-top: 4px;
149
149
  }
150
150
 
151
+ :is(.mkt, .atelic-ui) .stat-unit {
152
+ margin-left: 0.2em;
153
+ font-size: 0.4em;
154
+ font-weight: 500;
155
+ letter-spacing: 0;
156
+ color: var(--neutral-500);
157
+ }
158
+
159
+ /* ---- StatRow, quiet: a glance row for a working page. A light figure in
160
+ the text color over a small capitalized label, the stats divided by a
161
+ hairline. `--stat-size` sets the figure. ---- */
162
+ :is(.mkt, .atelic-ui) .stat-row.is-quiet {
163
+ display: flex;
164
+ gap: 0;
165
+ }
166
+ :is(.mkt, .atelic-ui) .stat-row.is-quiet .stat {
167
+ flex: 1;
168
+ min-width: 64px;
169
+ padding-right: 16px;
170
+ }
171
+ :is(.mkt, .atelic-ui) .stat-row.is-quiet .stat + .stat {
172
+ padding-left: 16px;
173
+ border-left: 1px solid var(--neutral-200);
174
+ }
175
+ :is(.mkt, .atelic-ui) .stat-row.is-quiet .stat .num {
176
+ align-items: baseline;
177
+ gap: 4px;
178
+ font: 300 var(--stat-size, 28px) / 1 var(--font-sans);
179
+ /* After the shorthand, which would otherwise reset it. */
180
+ font-variant-numeric: tabular-nums;
181
+ letter-spacing: -0.02em;
182
+ color: var(--tone, var(--ink));
183
+ }
184
+ :is(.mkt, .atelic-ui) .stat-row.is-quiet .stat-unit {
185
+ margin-left: 0;
186
+ font: 500 12px / 1 var(--font-mono);
187
+ }
188
+ :is(.mkt, .atelic-ui) .stat-row.is-quiet .stat .lbl {
189
+ margin-top: 7px;
190
+ font: 500 10px / 1.2 var(--font-sans);
191
+ letter-spacing: 0.1em;
192
+ text-transform: uppercase;
193
+ color: var(--neutral-500);
194
+ }
195
+
196
+ /* ---- Disclosure: a section the page opens and closes. The toggle's
197
+ ::after covers the whole header row, so the meta and the chevron fold
198
+ it too while staying out of its name. ---- */
199
+ :is(.mkt, .atelic-ui) .disclosure {
200
+ border: 1px solid var(--neutral-200);
201
+ border-radius: 16px;
202
+ background: var(--ui-card, var(--card));
203
+ overflow: hidden;
204
+ }
205
+ :is(.mkt, .atelic-ui) .disclosure-head {
206
+ position: relative;
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 10px;
210
+ padding: 14px 18px;
211
+ }
212
+ :is(.mkt, .atelic-ui) .disclosure-title {
213
+ display: flex;
214
+ min-width: 0;
215
+ margin: 0;
216
+ font: inherit;
217
+ }
218
+ :is(.mkt, .atelic-ui) .disclosure-toggle {
219
+ border: none;
220
+ background: transparent;
221
+ padding: 0;
222
+ text-align: left;
223
+ font: 500 17px / 1 var(--font-sans);
224
+ letter-spacing: -0.01em;
225
+ color: var(--ink);
226
+ cursor: pointer;
227
+ }
228
+ :is(.mkt, .atelic-ui) .disclosure-toggle::after {
229
+ content: "";
230
+ position: absolute;
231
+ inset: 0;
232
+ }
233
+ :is(.mkt, .atelic-ui) .disclosure-toggle:focus-visible {
234
+ outline: none;
235
+ }
236
+ :is(.mkt, .atelic-ui) .disclosure-toggle:focus-visible::after {
237
+ outline: 2px solid var(--disclosure-focus, var(--ink));
238
+ outline-offset: -2px;
239
+ border-radius: 15px;
240
+ }
241
+ :is(.mkt, .atelic-ui) .disclosure-meta {
242
+ display: inline-flex;
243
+ align-items: center;
244
+ color: var(--neutral-500);
245
+ }
246
+ :is(.mkt, .atelic-ui) .disclosure-chevron {
247
+ margin-left: auto;
248
+ flex-shrink: 0;
249
+ color: var(--neutral-500);
250
+ transition: transform 200ms ease;
251
+ }
252
+ :is(.mkt, .atelic-ui) .disclosure.is-open .disclosure-chevron {
253
+ transform: rotate(180deg);
254
+ }
255
+ :is(.mkt, .atelic-ui) .disclosure-body {
256
+ border-top: 1px solid var(--neutral-200);
257
+ }
258
+
151
259
  /* ---- Collapsible: a labeled line that opens to the detail behind it ---- */
152
260
  :is(.mkt, .atelic-ui) .collapsible > summary {
153
261
  display: flex;