@atelic-action/ui 0.12.0 → 0.13.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 +31 -6
- package/dist/gate/config.d.ts +4 -2
- package/dist/gate/config.js +7 -3
- package/dist/gate/request-link.d.ts +3 -2
- package/dist/gate/request-link.js +16 -18
- package/dist/gate/verify.js +3 -1
- package/dist/gate/wall.d.ts +5 -2
- package/dist/gate/wall.js +6 -3
- package/dist/lib/escapeHTML.d.ts +6 -0
- package/dist/lib/escapeHTML.js +13 -0
- package/package.json +2 -1
- package/src/components/GateLogin.tsx +190 -0
- package/src/components/Watermark.tsx +55 -0
- package/src/components/index.ts +3 -0
- package/src/email/render.tsx +3 -12
- package/src/gate/config.ts +6 -3
- package/src/gate/request-link.ts +16 -19
- package/src/gate/verify.ts +4 -1
- package/src/gate/wall.ts +6 -3
- package/src/lib/escapeHTML.ts +13 -0
- package/src/lib/readCookie.ts +20 -0
- package/src/styles/gate.css +207 -0
package/README.md
CHANGED
|
@@ -41,11 +41,12 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
41
41
|
| Import | Exports |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
|
|
44
|
-
| `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`, `Disclosure`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
44
|
+
| `@atelic-action/ui/components` | `NotFound`, the access gate's `GateLogin` and `Watermark` (with `readCookie`), and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`, `Disclosure`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
45
45
|
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
46
46
|
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
47
47
|
| `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
|
|
48
48
|
| `@atelic-action/ui/hooks` | `useScrollSpy` and its `PageStop` type |
|
|
49
|
+
| `@atelic-action/ui/styles/gate.css` | Styles for `GateLogin` and `Watermark` |
|
|
49
50
|
| `@atelic-action/ui/gate` | `signToken`, `verifyToken`, `safeNext`, `needsSession`, and the token types |
|
|
50
51
|
| `@atelic-action/ui/gate/request-link` | `POST`, the handler that emails a sign in link, and `createRequestLink` |
|
|
51
52
|
| `@atelic-action/ui/gate/verify` | `GET`, the handler that turns a link into a session |
|
|
@@ -141,7 +142,7 @@ export { default } from "@atelic-action/ui/gate/middleware";
|
|
|
141
142
|
export const config = { matcher: ["/((?!api/auth/).*)"] };
|
|
142
143
|
```
|
|
143
144
|
|
|
144
|
-
The default middleware holds back everything `needsSession` does: all of a host but the login page, what that page loads (`/assets/` apart from `/assets/doc/`, `/fonts/`, `/brand/`, the favicon and logo), the two functions under `/api
|
|
145
|
+
The default middleware holds back everything `needsSession` does: all of a host but the login page, what that page loads (`/assets/` apart from `/assets/doc/`, `/fonts/`, `/brand/`, the favicon and logo), everything under `/api/auth/` (the two sign in functions, and the one place under `/api/` where a host must add nothing private), `robots.txt`, `health.json`, and a website build proxied under `/proto`. A site whose matcher already leaves its open paths out passes its own test, and one whose link opens something other than a document names it:
|
|
145
146
|
|
|
146
147
|
```ts
|
|
147
148
|
import { createMiddleware } from "@atelic-action/ui/gate/middleware";
|
|
@@ -156,9 +157,9 @@ export const POST = createRequestLink({ noun: "preview" });
|
|
|
156
157
|
| `GATE_SESSION_SECRET` | all three | The signing secret, and the switch: unset, the gate is off. Use 32 random bytes or more |
|
|
157
158
|
| `GATE_ALLOWLIST` | all three | Comma separated emails that may receive a link. Taking an address off, then redeploying, refuses its outstanding link and ends its session |
|
|
158
159
|
| `RESEND_API_KEY` | request link | The sending only key the link goes out on |
|
|
159
|
-
| `GATE_BASE_URL` | request link | The host the link points at; a bare host is read as https. Unset, production uses the project's own domain and a preview uses the deployment it was asked on |
|
|
160
|
-
| `GATE_FROM`, `GATE_SUBJECT`, `GATE_LINK_TTL_MIN` | request link | Sender, subject, and link lifetime in minutes (15) |
|
|
161
|
-
| `GATE_SESSION_DAYS`, `GATE_WATERMARK_MODE` | verify | Session lifetime in days (7), and `pill` or `tiled` |
|
|
160
|
+
| `GATE_BASE_URL` | request link | The host the link points at; a bare host is read as https, and plain http is refused unless it is local. Unset, production uses the project's own domain and a preview uses the deployment it was asked on |
|
|
161
|
+
| `GATE_FROM`, `GATE_SUBJECT`, `GATE_LINK_TTL_MIN` | request link | Sender, subject, and link lifetime in minutes (15, a day at most) |
|
|
162
|
+
| `GATE_SESSION_DAYS`, `GATE_WATERMARK_MODE` | verify | Session lifetime in days (7, a year at most), and `pill` or `tiled` |
|
|
162
163
|
|
|
163
164
|
What it holds to, each with a test in `tst/gate/`:
|
|
164
165
|
|
|
@@ -167,12 +168,36 @@ What it holds to, each with a test in `tst/gate/`:
|
|
|
167
168
|
- The emailed link is never built from a forwarded header, since a caller can name any host there.
|
|
168
169
|
- A link request answers the same way after the same two second wait whether or not the address was on the allowlist, and a send still running shortly before then is given up. A send that fails says so in the function's log and nowhere else.
|
|
169
170
|
- The post sign in destination is held to a path on the same host, 512 characters at most (`safeNext`).
|
|
170
|
-
- An open path opens only as written; the held corner is held in any case.
|
|
171
|
+
- An open path opens only as written; the held corner is held in any case. Under `/api/`, only `/api/auth/` is open.
|
|
171
172
|
|
|
172
173
|
What it does not do, by having no store: a link can be used more than once until it expires, and link requests are not rate limited. Rotating the secret ends every session at once. The `gate_email` and `gate_wm` cookies that feed the watermark are readable and unsigned on purpose: the watermark is drawn in the reader's own browser, so it deters and attributes nothing a reader set on removing it could not already remove.
|
|
173
174
|
|
|
174
175
|
This is the one corner of the package that ships compiled. Vercel runs a function's and a middleware's package imports as shipped and compiles nothing inside `node_modules`, so `bun run build` emits `src/gate/` to `dist/gate/`, and `prepack` runs it before every publish.
|
|
175
176
|
|
|
177
|
+
### The Screen and the Watermark
|
|
178
|
+
|
|
179
|
+
The two pieces a reader sees are ordinary components, in `@atelic-action/ui/components`, styled by `@atelic-action/ui/styles/gate.css` (import it after `components.css`):
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
// src/routes/login.tsx: the screen alone inside the site's scope, no header
|
|
183
|
+
<div className="mkt">
|
|
184
|
+
<GateLogin
|
|
185
|
+
brandName={site.recipient.name}
|
|
186
|
+
eyebrow={site.gate?.eyebrow}
|
|
187
|
+
headline={site.gate?.headline}
|
|
188
|
+
lead={site.gate?.lead}
|
|
189
|
+
builtBy={site.sender.name}
|
|
190
|
+
contactEmail={site.sender.email}
|
|
191
|
+
/>
|
|
192
|
+
</div>;
|
|
193
|
+
|
|
194
|
+
// the site's shell, once, inside its `.mkt` wrapper, so every page behind
|
|
195
|
+
// the gate wears it (the styles are scoped there, like every sheet's)
|
|
196
|
+
<Watermark />;
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`GateLogin` posts to `/api/auth/request-link`; a site served under a base path passes its own `endpoint`. Its button wears the site's `.btn` classes, as the chrome's do. `Watermark` reads the `gate_email` cookie and draws nothing without one. `readCookie`, exported beside them, is the reader `Watermark` uses and a site's own code can: empty during prerender, for a missing cookie, and for one that will not decode. `GateLogin` says so when an address is not an email, holds a second submit while the first is in flight, and announces each outcome to a screen reader.
|
|
200
|
+
|
|
176
201
|
## Email
|
|
177
202
|
|
|
178
203
|
`@atelic-action/ui/email` is the runner email design as React components, ported one to one from the jq library the runners compose their mail from (homebase `runners/lib/email.jq`). The components are born inline: every layout is a table, every style is an inline style object, and there is no CSS file and no `className`, because Gmail strips a style block and ignores media queries on some accounts. The same components therefore mount on a web page as happily as they render into a mail client.
|
package/dist/gate/config.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare function isAllowed(email: string, allowlist: string | undefined):
|
|
|
8
8
|
* A setting that has to be a positive, finite number, or its default. A
|
|
9
9
|
* lifetime of zero, a negative one, or Infinity would mint a token that is
|
|
10
10
|
* already dead or that cannot be signed at all. A fraction is allowed (half
|
|
11
|
-
* a day), so a caller rounds what it derives to whole seconds.
|
|
11
|
+
* a day), so a caller rounds what it derives to whole seconds. Anything past
|
|
12
|
+
* the ceiling is held to it: a lifetime of 1e308 days is a typo, and the
|
|
13
|
+
* seconds it works out to cannot be signed.
|
|
12
14
|
*/
|
|
13
|
-
export declare function positive(value: string | undefined, fallback: number): number;
|
|
15
|
+
export declare function positive(value: string | undefined, fallback: number, ceiling: number): number;
|
package/dist/gate/config.js
CHANGED
|
@@ -16,9 +16,13 @@ export function isAllowed(email, allowlist) {
|
|
|
16
16
|
* A setting that has to be a positive, finite number, or its default. A
|
|
17
17
|
* lifetime of zero, a negative one, or Infinity would mint a token that is
|
|
18
18
|
* already dead or that cannot be signed at all. A fraction is allowed (half
|
|
19
|
-
* a day), so a caller rounds what it derives to whole seconds.
|
|
19
|
+
* a day), so a caller rounds what it derives to whole seconds. Anything past
|
|
20
|
+
* the ceiling is held to it: a lifetime of 1e308 days is a typo, and the
|
|
21
|
+
* seconds it works out to cannot be signed.
|
|
20
22
|
*/
|
|
21
|
-
export function positive(value, fallback) {
|
|
23
|
+
export function positive(value, fallback, ceiling) {
|
|
22
24
|
const parsed = Number(value);
|
|
23
|
-
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
26
|
+
return fallback;
|
|
27
|
+
return Math.min(parsed, ceiling);
|
|
24
28
|
}
|
|
@@ -5,8 +5,9 @@ export interface RequestLinkOptions {
|
|
|
5
5
|
/**
|
|
6
6
|
* Where the emailed link points. Never a forwarded header: a caller can name
|
|
7
7
|
* any host there, and the link carries a signed token. The configured base
|
|
8
|
-
* wins, read as a host (a bare one is taken as https
|
|
9
|
-
*
|
|
8
|
+
* wins, read as a host (a bare one is taken as https, and plain http is
|
|
9
|
+
* accepted only for a local one); on production the project's own domain is
|
|
10
|
+
* next; otherwise the host the platform routed this
|
|
10
11
|
* request to, which is the deployment itself.
|
|
11
12
|
*/
|
|
12
13
|
export declare function linkBase(request: Request, env: Record<string, string | undefined>): string;
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
* - GATE_LINK_TTL_MIN link lifetime in minutes (optional, default 15)
|
|
16
16
|
* - GATE_BASE_URL the host the link points at (optional, see linkBase)
|
|
17
17
|
*/
|
|
18
|
+
import { escapeHTML } from "../lib/escapeHTML.js";
|
|
18
19
|
import { isAllowed, positive } from "./config.js";
|
|
19
20
|
import { safeNext } from "./next.js";
|
|
20
21
|
import { signToken } from "./tokens.js";
|
|
21
22
|
const DEFAULT_FROM = "Atelic <forms@atelic.me>";
|
|
23
|
+
/** A day: a sign in link that outlives it is a standing key, not a link. */
|
|
24
|
+
const LONGEST_LINK_MIN = 24 * 60;
|
|
22
25
|
/** How long every valid request takes to answer, whatever it did. */
|
|
23
26
|
const ANSWER_AFTER_MS = 2000;
|
|
24
27
|
/**
|
|
@@ -36,16 +39,20 @@ function json(status, body) {
|
|
|
36
39
|
/**
|
|
37
40
|
* Where the emailed link points. Never a forwarded header: a caller can name
|
|
38
41
|
* any host there, and the link carries a signed token. The configured base
|
|
39
|
-
* wins, read as a host (a bare one is taken as https
|
|
40
|
-
*
|
|
42
|
+
* wins, read as a host (a bare one is taken as https, and plain http is
|
|
43
|
+
* accepted only for a local one); on production the project's own domain is
|
|
44
|
+
* next; otherwise the host the platform routed this
|
|
41
45
|
* request to, which is the deployment itself.
|
|
42
46
|
*/
|
|
43
47
|
export function linkBase(request, env) {
|
|
44
48
|
const configured = env.GATE_BASE_URL?.trim();
|
|
45
49
|
if (configured) {
|
|
46
50
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
const base = new URL(/^https?:\/\//i.test(configured) ? configured : `https://${configured}`);
|
|
52
|
+
const local = base.hostname === "localhost" || base.hostname === "127.0.0.1";
|
|
53
|
+
if (base.protocol === "https:" || local)
|
|
54
|
+
return base.origin;
|
|
55
|
+
console.error("gate: GATE_BASE_URL is plain http, falling back to the request's own host");
|
|
49
56
|
}
|
|
50
57
|
catch {
|
|
51
58
|
console.error("gate: GATE_BASE_URL is not a URL, falling back to the request's own host");
|
|
@@ -56,18 +63,9 @@ export function linkBase(request, env) {
|
|
|
56
63
|
}
|
|
57
64
|
return new URL(request.url).origin;
|
|
58
65
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.replaceAll("&", "&")
|
|
63
|
-
.replaceAll("<", "<")
|
|
64
|
-
.replaceAll(">", ">")
|
|
65
|
-
.replaceAll('"', """)
|
|
66
|
-
.replaceAll("'", "'");
|
|
67
|
-
}
|
|
68
|
-
function linkEmailHtml(rawLink, rawNoun) {
|
|
69
|
-
const link = escapeHtml(rawLink);
|
|
70
|
-
const noun = escapeHtml(rawNoun);
|
|
66
|
+
function linkEmailHTML(rawLink, rawNoun) {
|
|
67
|
+
const link = escapeHTML(rawLink);
|
|
68
|
+
const noun = escapeHTML(rawNoun);
|
|
71
69
|
// Gmail renders inline styles only, no style blocks.
|
|
72
70
|
return `
|
|
73
71
|
<div style="font-family: Helvetica, Arial, sans-serif; color: #211c17; line-height: 1.6; max-width: 520px;">
|
|
@@ -102,7 +100,7 @@ async function sendLink(to, link, noun) {
|
|
|
102
100
|
from: process.env.GATE_FROM || DEFAULT_FROM,
|
|
103
101
|
to: [to],
|
|
104
102
|
subject: process.env.GATE_SUBJECT || `Your private link to the ${noun}`,
|
|
105
|
-
html:
|
|
103
|
+
html: linkEmailHTML(link, noun),
|
|
106
104
|
text: `Your private link to the ${noun}:\n\n${link}\n\nIf you did not expect this, you can ignore it.`,
|
|
107
105
|
}),
|
|
108
106
|
});
|
|
@@ -138,7 +136,7 @@ export function createRequestLink(options = {}) {
|
|
|
138
136
|
// reads no differently from no send at all.
|
|
139
137
|
const answered = wait(ANSWER_AFTER_MS);
|
|
140
138
|
if (secret && isAllowed(email, process.env.GATE_ALLOWLIST)) {
|
|
141
|
-
const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15);
|
|
139
|
+
const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15, LONGEST_LINK_MIN);
|
|
142
140
|
try {
|
|
143
141
|
const token = await signToken({
|
|
144
142
|
e: email,
|
package/dist/gate/verify.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { isAllowed, positive } from "./config.js";
|
|
15
15
|
import { safeNext } from "./next.js";
|
|
16
16
|
import { signToken, verifyToken } from "./tokens.js";
|
|
17
|
+
/** A year: past it a session is no longer something anyone remembers granting. */
|
|
18
|
+
const LONGEST_SESSION_DAYS = 365;
|
|
17
19
|
function cookie(name, value, maxAge, httpOnly) {
|
|
18
20
|
const flags = ["Path=/", "Secure", "SameSite=Lax", `Max-Age=${maxAge}`];
|
|
19
21
|
if (httpOnly)
|
|
@@ -39,7 +41,7 @@ export async function GET(request) {
|
|
|
39
41
|
if (claims?.p !== "link" || !isAllowed(claims.e, process.env.GATE_ALLOWLIST)) {
|
|
40
42
|
return redirect("/login?e=expired");
|
|
41
43
|
}
|
|
42
|
-
const days = positive(process.env.GATE_SESSION_DAYS, 7);
|
|
44
|
+
const days = positive(process.env.GATE_SESSION_DAYS, 7, LONGEST_SESSION_DAYS);
|
|
43
45
|
// Whole seconds: a browser ignores a Max-Age that is not an integer.
|
|
44
46
|
const maxAge = Math.max(1, Math.round(days * 24 * 60 * 60));
|
|
45
47
|
const session = await signToken({ e: claims.e, x: Math.floor(Date.now() / 1000) + maxAge, p: "session" }, secret);
|
package/dist/gate/wall.d.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* the favicon in its head, and the brand marks the header draws, which every
|
|
12
12
|
* prerendered page preloads because the router's pending page carries the
|
|
13
13
|
* header (each site's e2e/wall.spec.ts fails when the login page asks for
|
|
14
|
-
* anything else); plus
|
|
14
|
+
* anything else); plus everything under /api/auth/, where the two sign in functions
|
|
15
|
+
* live and a host adds nothing private, the login page itself,
|
|
15
16
|
* robots.txt, and the uptime monitor's health.json. /assets/doc/ is the one
|
|
16
17
|
* part of /assets/ held back: the build writes every chunk carrying a
|
|
17
18
|
* private content module there (the private chunks build plugin), so a document's
|
|
@@ -37,7 +38,9 @@
|
|
|
37
38
|
* The path is read the way a file server would read it (decoded, repeated
|
|
38
39
|
* slashes collapsed) so an encoded or doubled slash cannot walk a held file
|
|
39
40
|
* out through an open prefix; anything that will not decode, or still climbs
|
|
40
|
-
* a directory once decoded, is held
|
|
41
|
+
* a directory once decoded, is held, and so is one that still carries a
|
|
42
|
+
* percent sign, since a second decode somewhere downstream would make it a
|
|
43
|
+
* different path than the one judged here. Case is folded for the held corner
|
|
41
44
|
* alone, so no spelling of it slips out, while an open path opens only as
|
|
42
45
|
* written: the host serves files by exact case, and a path that merely looks
|
|
43
46
|
* like an open one names some other file.
|
package/dist/gate/wall.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* the favicon in its head, and the brand marks the header draws, which every
|
|
12
12
|
* prerendered page preloads because the router's pending page carries the
|
|
13
13
|
* header (each site's e2e/wall.spec.ts fails when the login page asks for
|
|
14
|
-
* anything else); plus
|
|
14
|
+
* anything else); plus everything under /api/auth/, where the two sign in functions
|
|
15
|
+
* live and a host adds nothing private, the login page itself,
|
|
15
16
|
* robots.txt, and the uptime monitor's health.json. /assets/doc/ is the one
|
|
16
17
|
* part of /assets/ held back: the build writes every chunk carrying a
|
|
17
18
|
* private content module there (the private chunks build plugin), so a document's
|
|
@@ -49,7 +50,9 @@ const HELD_PREFIXES = ["/assets/doc/"];
|
|
|
49
50
|
* The path is read the way a file server would read it (decoded, repeated
|
|
50
51
|
* slashes collapsed) so an encoded or doubled slash cannot walk a held file
|
|
51
52
|
* out through an open prefix; anything that will not decode, or still climbs
|
|
52
|
-
* a directory once decoded, is held
|
|
53
|
+
* a directory once decoded, is held, and so is one that still carries a
|
|
54
|
+
* percent sign, since a second decode somewhere downstream would make it a
|
|
55
|
+
* different path than the one judged here. Case is folded for the held corner
|
|
53
56
|
* alone, so no spelling of it slips out, while an open path opens only as
|
|
54
57
|
* written: the host serves files by exact case, and a path that merely looks
|
|
55
58
|
* like an open one names some other file.
|
|
@@ -63,7 +66,7 @@ export function needsSession(pathname) {
|
|
|
63
66
|
return true;
|
|
64
67
|
}
|
|
65
68
|
path = path.replace(/\/{2,}/g, "/");
|
|
66
|
-
if (path.includes("..") || path.includes("\\"))
|
|
69
|
+
if (path.includes("..") || path.includes("\\") || path.includes("%"))
|
|
67
70
|
return true;
|
|
68
71
|
const folded = path.toLowerCase();
|
|
69
72
|
if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix)))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text made safe to set inside an HTML element or a quoted attribute. Plain
|
|
3
|
+
* TypeScript with no imports, so the email renderer and the access gate's
|
|
4
|
+
* server side (which ships compiled) both use the one copy.
|
|
5
|
+
*/
|
|
6
|
+
export declare function escapeHTML(value: string): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text made safe to set inside an HTML element or a quoted attribute. Plain
|
|
3
|
+
* TypeScript with no imports, so the email renderer and the access gate's
|
|
4
|
+
* server side (which ships compiled) both use the one copy.
|
|
5
|
+
*/
|
|
6
|
+
export function escapeHTML(value) {
|
|
7
|
+
return value
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, """)
|
|
12
|
+
.replace(/'/g, "'");
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
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",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"./styles/base.css": "./src/styles/base.css",
|
|
30
30
|
"./styles/chrome.css": "./src/styles/chrome.css",
|
|
31
31
|
"./styles/components.css": "./src/styles/components.css",
|
|
32
|
+
"./styles/gate.css": "./src/styles/gate.css",
|
|
32
33
|
"./styles/star-rating.css": "./src/styles/star-rating.css",
|
|
33
34
|
"./gate": {
|
|
34
35
|
"types": "./dist/gate/index.d.ts",
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type Status = "idle" | "sending" | "sent" | "failed";
|
|
4
|
+
|
|
5
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
export interface GateLoginProps {
|
|
8
|
+
/** Who the page behind the gate is for, set at the top of the screen. */
|
|
9
|
+
brandName: string;
|
|
10
|
+
/** The small line over the headline; with it the brand wears a "Preview" tag. */
|
|
11
|
+
eyebrow?: string;
|
|
12
|
+
headline?: string;
|
|
13
|
+
lead?: string;
|
|
14
|
+
/** Who built what is behind the gate, in the fine print. */
|
|
15
|
+
builtBy?: string;
|
|
16
|
+
/** A reply address, in the fine print. */
|
|
17
|
+
contactEmail?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Where the form posts. A site served under a base path passes its own
|
|
20
|
+
* path to the function.
|
|
21
|
+
*/
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The access gate's sign in screen. Collects an email and posts it to the
|
|
27
|
+
* request link function, which emails a sign in link only to allowlisted
|
|
28
|
+
* addresses. The response is the same either way, so this screen always
|
|
29
|
+
* shows the same "check your inbox" confirmation.
|
|
30
|
+
*
|
|
31
|
+
* It renders the screen only: a site wraps it in its `.mkt` scope with no
|
|
32
|
+
* header, since a header would only point at pages the visitor cannot open
|
|
33
|
+
* yet. The button wears the site's own `.btn` classes, as the chrome's do.
|
|
34
|
+
*/
|
|
35
|
+
export function GateLogin({
|
|
36
|
+
brandName,
|
|
37
|
+
eyebrow,
|
|
38
|
+
headline = "A first look, by invitation.",
|
|
39
|
+
lead = "Enter your email and we will send a private link to the document.",
|
|
40
|
+
builtBy,
|
|
41
|
+
contactEmail,
|
|
42
|
+
endpoint = "/api/auth/request-link",
|
|
43
|
+
}: GateLoginProps) {
|
|
44
|
+
const [status, setStatus] = useState<Status>("idle");
|
|
45
|
+
const [sentTo, setSentTo] = useState("");
|
|
46
|
+
const [expired, setExpired] = useState(false);
|
|
47
|
+
const [invalid, setInvalid] = useState(false);
|
|
48
|
+
const input = useRef<HTMLInputElement>(null);
|
|
49
|
+
const confirmation = useRef<HTMLHeadingElement>(null);
|
|
50
|
+
// A ref, since two submits can land before React renders the first one's state.
|
|
51
|
+
const inFlight = useRef(false);
|
|
52
|
+
const errorId = useId();
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const params = new URLSearchParams(window.location.search);
|
|
56
|
+
if (params.get("e") === "expired") setExpired(true);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// The form leaves the page when the confirmation arrives, and focus would
|
|
60
|
+
// fall to the body with it. Moving it to the heading is also what tells a
|
|
61
|
+
// screen reader the request went through.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (status === "sent") confirmation.current?.focus();
|
|
64
|
+
}, [status]);
|
|
65
|
+
|
|
66
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
const form = event.currentTarget;
|
|
69
|
+
// A second submit while the first is in flight would post twice, and a
|
|
70
|
+
// late failure would then pull the confirmation back to the form.
|
|
71
|
+
if (inFlight.current) return;
|
|
72
|
+
const email = String(new FormData(form).get("email") ?? "").trim();
|
|
73
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
74
|
+
// Nothing was sent, so an earlier failure no longer describes anything.
|
|
75
|
+
setStatus("idle");
|
|
76
|
+
setInvalid(true);
|
|
77
|
+
input.current?.focus();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setInvalid(false);
|
|
81
|
+
|
|
82
|
+
const next = new URLSearchParams(window.location.search).get("next") ?? "/";
|
|
83
|
+
inFlight.current = true;
|
|
84
|
+
setStatus("sending");
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(endpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ email, next }),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) throw new Error(`status ${response.status}`);
|
|
92
|
+
setSentTo(email);
|
|
93
|
+
setStatus("sent");
|
|
94
|
+
setExpired(false);
|
|
95
|
+
} catch {
|
|
96
|
+
setStatus("failed");
|
|
97
|
+
} finally {
|
|
98
|
+
inFlight.current = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// "Start over" swaps the confirmation for the form, and the button that
|
|
103
|
+
// held focus goes with it, so focus is handed to the field it leads to.
|
|
104
|
+
function startOver() {
|
|
105
|
+
setStatus("idle");
|
|
106
|
+
requestAnimationFrame(() => input.current?.focus());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="gate-screen">
|
|
111
|
+
<div className="gate-brand">
|
|
112
|
+
<span className="gate-brand-name">{brandName}</span>
|
|
113
|
+
{eyebrow && <span className="gate-brand-sub">Preview</span>}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div className="gate-body">
|
|
117
|
+
{eyebrow && <p className="gate-eyebrow">{eyebrow}</p>}
|
|
118
|
+
|
|
119
|
+
{status === "sent" ? (
|
|
120
|
+
<>
|
|
121
|
+
<h1 className="gate-headline" ref={confirmation} tabIndex={-1}>
|
|
122
|
+
Check your inbox.
|
|
123
|
+
</h1>
|
|
124
|
+
<div className="gate-sent">
|
|
125
|
+
If <strong>{sentTo}</strong> is on the list, a sign in link is on its way. The link
|
|
126
|
+
expires shortly if unused, and once you are in, you stay signed in for a while.
|
|
127
|
+
</div>
|
|
128
|
+
<p className="gate-fine">
|
|
129
|
+
Wrong address?{" "}
|
|
130
|
+
<button type="button" className="gate-link-button" onClick={startOver}>
|
|
131
|
+
Start over
|
|
132
|
+
</button>
|
|
133
|
+
</p>
|
|
134
|
+
</>
|
|
135
|
+
) : (
|
|
136
|
+
<>
|
|
137
|
+
<h1 className="gate-headline">{headline}</h1>
|
|
138
|
+
{expired && (
|
|
139
|
+
<p className="gate-error" role="alert">
|
|
140
|
+
That link has expired. Enter your email and we will send a fresh one.
|
|
141
|
+
</p>
|
|
142
|
+
)}
|
|
143
|
+
<p className="gate-lead">{lead}</p>
|
|
144
|
+
<form className="gate-form" onSubmit={handleSubmit} noValidate>
|
|
145
|
+
<input
|
|
146
|
+
className="gate-input"
|
|
147
|
+
type="email"
|
|
148
|
+
name="email"
|
|
149
|
+
placeholder="you@company.com"
|
|
150
|
+
autoComplete="email"
|
|
151
|
+
aria-label="Your email"
|
|
152
|
+
aria-invalid={invalid || undefined}
|
|
153
|
+
aria-describedby={invalid ? errorId : undefined}
|
|
154
|
+
ref={input}
|
|
155
|
+
required
|
|
156
|
+
/>
|
|
157
|
+
<button
|
|
158
|
+
type="submit"
|
|
159
|
+
className="btn btn-primary btn-lg"
|
|
160
|
+
disabled={status === "sending"}
|
|
161
|
+
>
|
|
162
|
+
{status === "sending" ? "Sending…" : "Email me my link"}
|
|
163
|
+
<span className="arrow" aria-hidden="true">
|
|
164
|
+
→
|
|
165
|
+
</span>
|
|
166
|
+
</button>
|
|
167
|
+
</form>
|
|
168
|
+
{invalid && (
|
|
169
|
+
<p className="gate-error gate-error-after" id={errorId} role="alert">
|
|
170
|
+
That does not look like an email address. Check it and try again.
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
{status === "failed" && (
|
|
174
|
+
<p className="gate-error gate-error-after" role="alert">
|
|
175
|
+
Something went wrong. Please try again in a moment.
|
|
176
|
+
</p>
|
|
177
|
+
)}
|
|
178
|
+
{(builtBy || contactEmail) && (
|
|
179
|
+
<p className="gate-fine">
|
|
180
|
+
{builtBy && `Built by ${builtBy}`}
|
|
181
|
+
{builtBy && contactEmail && " · "}
|
|
182
|
+
{contactEmail && <a href={`mailto:${contactEmail}`}>{contactEmail}</a>}
|
|
183
|
+
</p>
|
|
184
|
+
)}
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { readCookie } from "../lib/readCookie";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The confidential watermark stamped on every page behind the access gate.
|
|
6
|
+
* Reads the signed in viewer's email from the readable `gate_email` cookie
|
|
7
|
+
* (set when a sign in link is verified) and paints a corner pill, plus a
|
|
8
|
+
* faint tiled overlay when the gate runs in "tiled" mode. Renders nothing
|
|
9
|
+
* when there is no session, so it is inert on a public site and during
|
|
10
|
+
* prerender. The email travels into any screenshot. Mount it inside the
|
|
11
|
+
* site's `.mkt` (or `.atelic-ui`) wrapper, where its styles are scoped.
|
|
12
|
+
*
|
|
13
|
+
* It deters and attributes; it does not enforce. The cookies it reads are
|
|
14
|
+
* unsigned and it is drawn in the reader's own browser.
|
|
15
|
+
*/
|
|
16
|
+
export function Watermark() {
|
|
17
|
+
const [email, setEmail] = useState("");
|
|
18
|
+
const [mode, setMode] = useState("pill");
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setEmail(readCookie("gate_email"));
|
|
22
|
+
setMode(readCookie("gate_wm") || "pill");
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
if (!email) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
{mode === "tiled" && (
|
|
30
|
+
<div className="gate-wm-tile-layer" aria-hidden="true">
|
|
31
|
+
{Array.from({ length: 32 }).map((_, index) => {
|
|
32
|
+
const row = Math.floor(index / 4);
|
|
33
|
+
const col = index % 4;
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: fixed static grid, order is stable
|
|
37
|
+
key={index}
|
|
38
|
+
className="gate-wm-tile"
|
|
39
|
+
style={{ top: `${row * 13 + 2}%`, left: `${col * 27 - 6 + (row % 2 ? 13 : 0)}%` }}
|
|
40
|
+
>
|
|
41
|
+
confidential · {email}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
})}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
<div className="gate-wm" aria-hidden="true">
|
|
48
|
+
<span className="gate-wm-dot" />
|
|
49
|
+
<span className="gate-wm-text">
|
|
50
|
+
Confidential preview · <b>{email}</b>
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { readCookie } from "../lib/readCookie";
|
|
1
2
|
export { Callout, type CalloutProps } from "./Callout";
|
|
2
3
|
export { Checklist, type ChecklistItem, type ChecklistProps } from "./Checklist";
|
|
3
4
|
export { Collapsible, type CollapsibleProps } from "./Collapsible";
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
type SortDirection,
|
|
18
19
|
} from "./DataTable";
|
|
19
20
|
export { Disclosure, type DisclosureProps } from "./Disclosure";
|
|
21
|
+
export { GateLogin, type GateLoginProps } from "./GateLogin";
|
|
20
22
|
export { NotFound, type NotFoundProps } from "./NotFound";
|
|
21
23
|
export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
|
|
22
24
|
export { describeStarRating, StarRating, type StarRatingProps } from "./StarRating";
|
|
@@ -34,3 +36,4 @@ export {
|
|
|
34
36
|
type ThresholdZone,
|
|
35
37
|
} from "./ThresholdScale";
|
|
36
38
|
export type { Tone } from "./tone";
|
|
39
|
+
export { Watermark } from "./Watermark";
|
package/src/email/render.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { escapeHTML } from "../lib/escapeHTML";
|
|
3
4
|
import { atelicFonts, atelicPalette, type Fonts, type Palette } from "../tokens";
|
|
4
5
|
import { FailurePage } from "./frame";
|
|
5
6
|
import { EmailThemeProvider } from "./theme";
|
|
@@ -11,16 +12,6 @@ import { EmailThemeProvider } from "./theme";
|
|
|
11
12
|
* inside the 680 column go through React.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
/** jq's `esc`, in the same order, for the strings that land in the shell. */
|
|
15
|
-
function esc(value: string): string {
|
|
16
|
-
return value
|
|
17
|
-
.replace(/&/g, "&")
|
|
18
|
-
.replace(/</g, "<")
|
|
19
|
-
.replace(/>/g, ">")
|
|
20
|
-
.replace(/"/g, """)
|
|
21
|
-
.replace(/'/g, "'");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
15
|
const WRAPPER_OPEN = "<table><tbody>";
|
|
25
16
|
const WRAPPER_CLOSE = "</tbody></table>";
|
|
26
17
|
|
|
@@ -65,13 +56,13 @@ export function renderEmail({
|
|
|
65
56
|
return (
|
|
66
57
|
`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
|
|
67
58
|
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
|
|
68
|
-
`<meta name="color-scheme" content="light"><title>${
|
|
59
|
+
`<meta name="color-scheme" content="light"><title>${escapeHTML(title)}</title>` +
|
|
69
60
|
`<style>body{margin:0;padding:0;background:${ground}}a{color:${palette.ink}}` +
|
|
70
61
|
`details>summary{list-style:none}details>summary::-webkit-details-marker{display:none}` +
|
|
71
62
|
`@media (max-width:700px){.wrap{width:100%!important}}</style></head>` +
|
|
72
63
|
`<body style="margin:0;padding:0;background:${ground};-webkit-text-size-adjust:100%">` +
|
|
73
64
|
(preheader
|
|
74
|
-
? `<span style="display:none;font-size:1px;color:${ground};max-height:0;overflow:hidden">${
|
|
65
|
+
? `<span style="display:none;font-size:1px;color:${ground};max-height:0;overflow:hidden">${escapeHTML(preheader)}</span>`
|
|
75
66
|
: "") +
|
|
76
67
|
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${ground}">` +
|
|
77
68
|
`<tr><td align="center" style="padding:28px 12px 48px">` +
|
package/src/gate/config.ts
CHANGED
|
@@ -17,9 +17,12 @@ export function isAllowed(email: string, allowlist: string | undefined): boolean
|
|
|
17
17
|
* A setting that has to be a positive, finite number, or its default. A
|
|
18
18
|
* lifetime of zero, a negative one, or Infinity would mint a token that is
|
|
19
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.
|
|
20
|
+
* a day), so a caller rounds what it derives to whole seconds. Anything past
|
|
21
|
+
* the ceiling is held to it: a lifetime of 1e308 days is a typo, and the
|
|
22
|
+
* seconds it works out to cannot be signed.
|
|
21
23
|
*/
|
|
22
|
-
export function positive(value: string | undefined, fallback: number): number {
|
|
24
|
+
export function positive(value: string | undefined, fallback: number, ceiling: number): number {
|
|
23
25
|
const parsed = Number(value);
|
|
24
|
-
|
|
26
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
27
|
+
return Math.min(parsed, ceiling);
|
|
25
28
|
}
|
package/src/gate/request-link.ts
CHANGED
|
@@ -15,12 +15,16 @@
|
|
|
15
15
|
* - GATE_LINK_TTL_MIN link lifetime in minutes (optional, default 15)
|
|
16
16
|
* - GATE_BASE_URL the host the link points at (optional, see linkBase)
|
|
17
17
|
*/
|
|
18
|
+
import { escapeHTML } from "../lib/escapeHTML.js";
|
|
18
19
|
import { isAllowed, positive } from "./config.js";
|
|
19
20
|
import { safeNext } from "./next.js";
|
|
20
21
|
import { signToken } from "./tokens.js";
|
|
21
22
|
|
|
22
23
|
const DEFAULT_FROM = "Atelic <forms@atelic.me>";
|
|
23
24
|
|
|
25
|
+
/** A day: a sign in link that outlives it is a standing key, not a link. */
|
|
26
|
+
const LONGEST_LINK_MIN = 24 * 60;
|
|
27
|
+
|
|
24
28
|
/** How long every valid request takes to answer, whatever it did. */
|
|
25
29
|
const ANSWER_AFTER_MS = 2000;
|
|
26
30
|
|
|
@@ -46,16 +50,19 @@ function json(status: number, body: Record<string, unknown>): Response {
|
|
|
46
50
|
/**
|
|
47
51
|
* Where the emailed link points. Never a forwarded header: a caller can name
|
|
48
52
|
* 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
|
|
50
|
-
*
|
|
53
|
+
* wins, read as a host (a bare one is taken as https, and plain http is
|
|
54
|
+
* accepted only for a local one); on production the project's own domain is
|
|
55
|
+
* next; otherwise the host the platform routed this
|
|
51
56
|
* request to, which is the deployment itself.
|
|
52
57
|
*/
|
|
53
58
|
export function linkBase(request: Request, env: Record<string, string | undefined>): string {
|
|
54
59
|
const configured = env.GATE_BASE_URL?.trim();
|
|
55
60
|
if (configured) {
|
|
56
61
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
const base = new URL(/^https?:\/\//i.test(configured) ? configured : `https://${configured}`);
|
|
63
|
+
const local = base.hostname === "localhost" || base.hostname === "127.0.0.1";
|
|
64
|
+
if (base.protocol === "https:" || local) return base.origin;
|
|
65
|
+
console.error("gate: GATE_BASE_URL is plain http, falling back to the request's own host");
|
|
59
66
|
} catch {
|
|
60
67
|
console.error("gate: GATE_BASE_URL is not a URL, falling back to the request's own host");
|
|
61
68
|
}
|
|
@@ -66,19 +73,9 @@ export function linkBase(request: Request, env: Record<string, string | undefine
|
|
|
66
73
|
return new URL(request.url).origin;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.replaceAll("&", "&")
|
|
73
|
-
.replaceAll("<", "<")
|
|
74
|
-
.replaceAll(">", ">")
|
|
75
|
-
.replaceAll('"', """)
|
|
76
|
-
.replaceAll("'", "'");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function linkEmailHtml(rawLink: string, rawNoun: string): string {
|
|
80
|
-
const link = escapeHtml(rawLink);
|
|
81
|
-
const noun = escapeHtml(rawNoun);
|
|
76
|
+
function linkEmailHTML(rawLink: string, rawNoun: string): string {
|
|
77
|
+
const link = escapeHTML(rawLink);
|
|
78
|
+
const noun = escapeHTML(rawNoun);
|
|
82
79
|
// Gmail renders inline styles only, no style blocks.
|
|
83
80
|
return `
|
|
84
81
|
<div style="font-family: Helvetica, Arial, sans-serif; color: #211c17; line-height: 1.6; max-width: 520px;">
|
|
@@ -114,7 +111,7 @@ async function sendLink(to: string, link: string, noun: string): Promise<void> {
|
|
|
114
111
|
from: process.env.GATE_FROM || DEFAULT_FROM,
|
|
115
112
|
to: [to],
|
|
116
113
|
subject: process.env.GATE_SUBJECT || `Your private link to the ${noun}`,
|
|
117
|
-
html:
|
|
114
|
+
html: linkEmailHTML(link, noun),
|
|
118
115
|
text: `Your private link to the ${noun}:\n\n${link}\n\nIf you did not expect this, you can ignore it.`,
|
|
119
116
|
}),
|
|
120
117
|
});
|
|
@@ -155,7 +152,7 @@ export function createRequestLink(options: RequestLinkOptions = {}) {
|
|
|
155
152
|
const answered = wait(ANSWER_AFTER_MS);
|
|
156
153
|
|
|
157
154
|
if (secret && isAllowed(email, process.env.GATE_ALLOWLIST)) {
|
|
158
|
-
const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15);
|
|
155
|
+
const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15, LONGEST_LINK_MIN);
|
|
159
156
|
try {
|
|
160
157
|
const token = await signToken(
|
|
161
158
|
{
|
package/src/gate/verify.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { isAllowed, positive } from "./config.js";
|
|
|
15
15
|
import { safeNext } from "./next.js";
|
|
16
16
|
import { signToken, verifyToken } from "./tokens.js";
|
|
17
17
|
|
|
18
|
+
/** A year: past it a session is no longer something anyone remembers granting. */
|
|
19
|
+
const LONGEST_SESSION_DAYS = 365;
|
|
20
|
+
|
|
18
21
|
function cookie(name: string, value: string, maxAge: number, httpOnly: boolean): string {
|
|
19
22
|
const flags = ["Path=/", "Secure", "SameSite=Lax", `Max-Age=${maxAge}`];
|
|
20
23
|
if (httpOnly) flags.unshift("HttpOnly");
|
|
@@ -42,7 +45,7 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
42
45
|
return redirect("/login?e=expired");
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
const days = positive(process.env.GATE_SESSION_DAYS, 7);
|
|
48
|
+
const days = positive(process.env.GATE_SESSION_DAYS, 7, LONGEST_SESSION_DAYS);
|
|
46
49
|
// Whole seconds: a browser ignores a Max-Age that is not an integer.
|
|
47
50
|
const maxAge = Math.max(1, Math.round(days * 24 * 60 * 60));
|
|
48
51
|
const session = await signToken(
|
package/src/gate/wall.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* the favicon in its head, and the brand marks the header draws, which every
|
|
12
12
|
* prerendered page preloads because the router's pending page carries the
|
|
13
13
|
* header (each site's e2e/wall.spec.ts fails when the login page asks for
|
|
14
|
-
* anything else); plus
|
|
14
|
+
* anything else); plus everything under /api/auth/, where the two sign in functions
|
|
15
|
+
* live and a host adds nothing private, the login page itself,
|
|
15
16
|
* robots.txt, and the uptime monitor's health.json. /assets/doc/ is the one
|
|
16
17
|
* part of /assets/ held back: the build writes every chunk carrying a
|
|
17
18
|
* private content module there (the private chunks build plugin), so a document's
|
|
@@ -53,7 +54,9 @@ const HELD_PREFIXES = ["/assets/doc/"];
|
|
|
53
54
|
* The path is read the way a file server would read it (decoded, repeated
|
|
54
55
|
* slashes collapsed) so an encoded or doubled slash cannot walk a held file
|
|
55
56
|
* out through an open prefix; anything that will not decode, or still climbs
|
|
56
|
-
* a directory once decoded, is held
|
|
57
|
+
* a directory once decoded, is held, and so is one that still carries a
|
|
58
|
+
* percent sign, since a second decode somewhere downstream would make it a
|
|
59
|
+
* different path than the one judged here. Case is folded for the held corner
|
|
57
60
|
* alone, so no spelling of it slips out, while an open path opens only as
|
|
58
61
|
* written: the host serves files by exact case, and a path that merely looks
|
|
59
62
|
* like an open one names some other file.
|
|
@@ -66,7 +69,7 @@ export function needsSession(pathname: string): boolean {
|
|
|
66
69
|
return true;
|
|
67
70
|
}
|
|
68
71
|
path = path.replace(/\/{2,}/g, "/");
|
|
69
|
-
if (path.includes("..") || path.includes("\\")) return true;
|
|
72
|
+
if (path.includes("..") || path.includes("\\") || path.includes("%")) return true;
|
|
70
73
|
const folded = path.toLowerCase();
|
|
71
74
|
if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix))) return true;
|
|
72
75
|
if (OPEN_EXACT.has(path)) return false;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text made safe to set inside an HTML element or a quoted attribute. Plain
|
|
3
|
+
* TypeScript with no imports, so the email renderer and the access gate's
|
|
4
|
+
* server side (which ships compiled) both use the one copy.
|
|
5
|
+
*/
|
|
6
|
+
export function escapeHTML(value: string): string {
|
|
7
|
+
return value
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, """)
|
|
12
|
+
.replace(/'/g, "'");
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read one cookie the page is allowed to see. Returns "" during prerender,
|
|
3
|
+
* when the cookie is not set, and when its value is not valid percent
|
|
4
|
+
* encoding, so a caller can treat all three as absent. The name is matched
|
|
5
|
+
* as written, never as a pattern, so one holding a dot or a bracket reads
|
|
6
|
+
* its own cookie and nothing else.
|
|
7
|
+
*/
|
|
8
|
+
export function readCookie(name: string): string {
|
|
9
|
+
if (typeof document === "undefined" || !name) return "";
|
|
10
|
+
for (const pair of document.cookie.split(";")) {
|
|
11
|
+
const split = pair.indexOf("=");
|
|
12
|
+
if (split === -1 || pair.slice(0, split).trim() !== name) continue;
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent(pair.slice(split + 1).trim());
|
|
15
|
+
} catch {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/* The access gate's two faces: the sign in screen (GateLogin) and the
|
|
2
|
+
confidential watermark (Watermark). Every value is a theme token, so the
|
|
3
|
+
gate recolors with the rest of a site. Scoped under `.mkt` or
|
|
4
|
+
`.atelic-ui`, which also lifts each rule over the base heading and
|
|
5
|
+
paragraph rules inside this layer. */
|
|
6
|
+
@layer atelic-ui {
|
|
7
|
+
:is(.mkt, .atelic-ui) .gate-screen {
|
|
8
|
+
min-height: 100vh;
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
background: var(--surface);
|
|
12
|
+
color: var(--ink);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
:is(.mkt, .atelic-ui) .gate-brand {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: baseline;
|
|
18
|
+
gap: 10px;
|
|
19
|
+
padding: 22px 32px;
|
|
20
|
+
border-bottom: 1px solid var(--neutral-100);
|
|
21
|
+
}
|
|
22
|
+
:is(.mkt, .atelic-ui) .gate-brand-name {
|
|
23
|
+
font-family: var(--font-serif);
|
|
24
|
+
font-style: italic;
|
|
25
|
+
font-size: 24px;
|
|
26
|
+
color: var(--ui-primary, var(--primary));
|
|
27
|
+
}
|
|
28
|
+
:is(.mkt, .atelic-ui) .gate-brand-sub {
|
|
29
|
+
font-family: var(--font-mono);
|
|
30
|
+
font-size: 11px;
|
|
31
|
+
letter-spacing: 0.22em;
|
|
32
|
+
text-transform: uppercase;
|
|
33
|
+
color: var(--ink-soft);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
:is(.mkt, .atelic-ui) .gate-body {
|
|
37
|
+
flex: 1;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
justify-content: center;
|
|
41
|
+
align-items: center;
|
|
42
|
+
text-align: center;
|
|
43
|
+
padding: 52px 24px 44px;
|
|
44
|
+
}
|
|
45
|
+
:is(.mkt, .atelic-ui) .gate-eyebrow {
|
|
46
|
+
font-family: var(--font-mono);
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
letter-spacing: 0.18em;
|
|
49
|
+
text-transform: uppercase;
|
|
50
|
+
color: var(--ui-primary, var(--primary));
|
|
51
|
+
margin: 0 0 16px;
|
|
52
|
+
}
|
|
53
|
+
:is(.mkt, .atelic-ui) .gate-headline {
|
|
54
|
+
font-family: var(--font-serif);
|
|
55
|
+
font-size: clamp(2rem, 4.4vw, 2.7rem);
|
|
56
|
+
line-height: 1.08;
|
|
57
|
+
margin: 0 0 16px;
|
|
58
|
+
max-width: 16ch;
|
|
59
|
+
text-wrap: balance;
|
|
60
|
+
color: var(--ink);
|
|
61
|
+
}
|
|
62
|
+
:is(.mkt, .atelic-ui) .gate-lead {
|
|
63
|
+
font-size: 1.05rem;
|
|
64
|
+
color: var(--ink-soft);
|
|
65
|
+
max-width: 46ch;
|
|
66
|
+
margin: 0 0 30px;
|
|
67
|
+
text-wrap: pretty;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
:is(.mkt, .atelic-ui) .gate-form {
|
|
71
|
+
display: flex;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
width: 100%;
|
|
74
|
+
max-width: 440px;
|
|
75
|
+
flex-wrap: wrap;
|
|
76
|
+
justify-content: center;
|
|
77
|
+
}
|
|
78
|
+
:is(.mkt, .atelic-ui) .gate-input {
|
|
79
|
+
flex: 1;
|
|
80
|
+
min-width: 220px;
|
|
81
|
+
font-family: var(--font-sans);
|
|
82
|
+
font-size: 15px;
|
|
83
|
+
/* The pill, matching the primary Button, so the pair reads as one
|
|
84
|
+
control set (Forni's review of the SkySpec gate, 2026-08-25). */
|
|
85
|
+
padding: 15px 22px;
|
|
86
|
+
border-radius: var(--radius-pill);
|
|
87
|
+
border: 1px solid var(--neutral-200);
|
|
88
|
+
background: var(--ui-card, var(--card));
|
|
89
|
+
color: var(--ink);
|
|
90
|
+
outline: none;
|
|
91
|
+
}
|
|
92
|
+
:is(.mkt, .atelic-ui) .gate-input:focus-visible {
|
|
93
|
+
border-color: var(--ui-primary, var(--primary));
|
|
94
|
+
box-shadow: 0 0 0 3px var(--primary-focus-ring);
|
|
95
|
+
}
|
|
96
|
+
:is(.mkt, .atelic-ui) .gate-input::placeholder {
|
|
97
|
+
color: var(--neutral-500);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
:is(.mkt, .atelic-ui) .gate-fine {
|
|
101
|
+
margin: 26px 0 0;
|
|
102
|
+
font-family: var(--font-mono);
|
|
103
|
+
font-size: 11.5px;
|
|
104
|
+
color: var(--neutral-500);
|
|
105
|
+
}
|
|
106
|
+
:is(.mkt, .atelic-ui) .gate-fine a {
|
|
107
|
+
color: var(--ui-primary, var(--primary));
|
|
108
|
+
text-decoration: none;
|
|
109
|
+
}
|
|
110
|
+
:is(.mkt, .atelic-ui) .gate-fine a:hover {
|
|
111
|
+
text-decoration: underline;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
:is(.mkt, .atelic-ui) .gate-sent {
|
|
115
|
+
margin: 4px auto 0;
|
|
116
|
+
max-width: 440px;
|
|
117
|
+
border: 1px solid var(--neutral-100);
|
|
118
|
+
border-radius: var(--radius-sm);
|
|
119
|
+
background: var(--primary-tint);
|
|
120
|
+
padding: 18px 20px;
|
|
121
|
+
text-align: left;
|
|
122
|
+
color: var(--ink-soft);
|
|
123
|
+
}
|
|
124
|
+
:is(.mkt, .atelic-ui) .gate-sent strong {
|
|
125
|
+
color: var(--ink);
|
|
126
|
+
}
|
|
127
|
+
:is(.mkt, .atelic-ui) .gate-error {
|
|
128
|
+
margin: 0 0 22px;
|
|
129
|
+
max-width: 44ch;
|
|
130
|
+
font-size: 0.95rem;
|
|
131
|
+
color: var(--ink);
|
|
132
|
+
background: var(--surface-alt);
|
|
133
|
+
border: 1px solid var(--neutral-200);
|
|
134
|
+
border-radius: var(--radius-sm);
|
|
135
|
+
padding: 11px 16px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* The "Start over" control reads as a link in the fine print. */
|
|
139
|
+
:is(.mkt, .atelic-ui) .gate-link-button {
|
|
140
|
+
background: none;
|
|
141
|
+
border: none;
|
|
142
|
+
color: var(--ui-primary, var(--primary));
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
font: inherit;
|
|
145
|
+
padding: 0;
|
|
146
|
+
}
|
|
147
|
+
/* The failure line under the form, where the default bottom margin is spare. */
|
|
148
|
+
:is(.mkt, .atelic-ui) .gate-error.gate-error-after {
|
|
149
|
+
margin-top: 22px;
|
|
150
|
+
margin-bottom: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---- Watermark ---- */
|
|
154
|
+
:is(.mkt, .atelic-ui) .gate-wm {
|
|
155
|
+
position: fixed;
|
|
156
|
+
right: 16px;
|
|
157
|
+
bottom: 16px;
|
|
158
|
+
z-index: 2147483000;
|
|
159
|
+
display: flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 9px;
|
|
162
|
+
max-width: calc(100vw - 32px);
|
|
163
|
+
padding: 8px 13px;
|
|
164
|
+
border-radius: 999px;
|
|
165
|
+
font-family: var(--font-mono);
|
|
166
|
+
font-size: 11px;
|
|
167
|
+
letter-spacing: 0.03em;
|
|
168
|
+
background: var(--surface-dark);
|
|
169
|
+
color: var(--surface);
|
|
170
|
+
box-shadow: var(--shadow-md, 0 8px 24px -10px rgba(0, 0, 0, 0.5));
|
|
171
|
+
pointer-events: none;
|
|
172
|
+
}
|
|
173
|
+
:is(.mkt, .atelic-ui) .gate-wm b {
|
|
174
|
+
color: #fff;
|
|
175
|
+
font-weight: 600;
|
|
176
|
+
}
|
|
177
|
+
:is(.mkt, .atelic-ui) .gate-wm-dot {
|
|
178
|
+
width: 7px;
|
|
179
|
+
height: 7px;
|
|
180
|
+
border-radius: 50%;
|
|
181
|
+
background: var(--primary-bright);
|
|
182
|
+
flex-shrink: 0;
|
|
183
|
+
}
|
|
184
|
+
:is(.mkt, .atelic-ui) .gate-wm-text {
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
text-overflow: ellipsis;
|
|
187
|
+
white-space: nowrap;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
:is(.mkt, .atelic-ui) .gate-wm-tile-layer {
|
|
191
|
+
position: fixed;
|
|
192
|
+
inset: 0;
|
|
193
|
+
z-index: 2147482000;
|
|
194
|
+
pointer-events: none;
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
opacity: 0.5;
|
|
197
|
+
}
|
|
198
|
+
:is(.mkt, .atelic-ui) .gate-wm-tile {
|
|
199
|
+
position: absolute;
|
|
200
|
+
font-family: var(--font-mono);
|
|
201
|
+
font-size: 12px;
|
|
202
|
+
color: var(--ui-primary, var(--primary));
|
|
203
|
+
opacity: 0.12;
|
|
204
|
+
white-space: nowrap;
|
|
205
|
+
transform: rotate(-30deg);
|
|
206
|
+
}
|
|
207
|
+
}
|