@atelic-action/ui 0.11.0 → 0.12.1
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 +58 -1
- package/dist/gate/config.d.ts +15 -0
- package/dist/gate/config.js +28 -0
- package/dist/gate/index.d.ts +3 -0
- package/dist/gate/index.js +3 -0
- package/dist/gate/middleware.d.ts +7 -0
- package/dist/gate/middleware.js +56 -0
- package/dist/gate/next.d.ts +1 -0
- package/dist/gate/next.js +23 -0
- package/dist/gate/request-link.d.ts +16 -0
- package/dist/gate/request-link.js +161 -0
- package/dist/gate/tokens.d.ts +18 -0
- package/dist/gate/tokens.js +43 -0
- package/dist/gate/verify.d.ts +1 -0
- package/dist/gate/verify.js +54 -0
- package/dist/gate/wall.d.ts +47 -0
- package/dist/gate/wall.js +76 -0
- package/dist/lib/escapeHTML.d.ts +6 -0
- package/dist/lib/escapeHTML.js +13 -0
- package/package.json +28 -4
- package/src/email/render.tsx +3 -12
- package/src/gate/config.ts +28 -0
- package/src/gate/index.ts +3 -0
- package/src/gate/middleware.ts +64 -0
- package/src/gate/next.ts +21 -0
- package/src/gate/request-link.ts +181 -0
- package/src/gate/tokens.ts +57 -0
- package/src/gate/verify.ts +62 -0
- package/src/gate/wall.ts +76 -0
- package/src/lib/escapeHTML.ts +13 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Shared UI for the Atelic templates: the site chrome (header, menu, footer, sticky CTA bar, and credit band), the not found page and the routing that keeps it alive, the scroll spy hook, and the one base stylesheet. The marketing and artifact templates install it instead of carrying their own copies, so a chrome fix lands once and every site picks it up with `bun update`.
|
|
4
4
|
|
|
5
|
-
The package ships source, not a build. Its TSX and CSS arrive as written and compile inside each site's own Vite.
|
|
5
|
+
The package ships source, not a build. Its TSX and CSS arrive as written and compile inside each site's own Vite. The one exception is the access gate's server side, which ships compiled (see [The Access Gate](#the-access-gate)).
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -46,6 +46,10 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
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/gate` | `signToken`, `verifyToken`, `safeNext`, `needsSession`, and the token types |
|
|
50
|
+
| `@atelic-action/ui/gate/request-link` | `POST`, the handler that emails a sign in link, and `createRequestLink` |
|
|
51
|
+
| `@atelic-action/ui/gate/verify` | `GET`, the handler that turns a link into a session |
|
|
52
|
+
| `@atelic-action/ui/gate/middleware` | The routing middleware as the default export, and `createMiddleware` |
|
|
49
53
|
| `@atelic-action/ui/routing` | `staticNotFoundRouting`, the router options behind the not found page |
|
|
50
54
|
| `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
|
|
51
55
|
| `@atelic-action/ui/styles/chrome.css` | Styles for everything under `chrome` |
|
|
@@ -116,6 +120,59 @@ export function NotFoundPage() {
|
|
|
116
120
|
|
|
117
121
|
`staticNotFoundRouting` is for prerendered sites only. On a live app the pending state is a real loading moment and the page would flash through every slow load, so an app sets `defaultNotFoundComponent` alone. A browser test is the only proof any of this works; template-marketing's `e2e/not-found.spec.ts` is the one to copy.
|
|
118
122
|
|
|
123
|
+
## The Access Gate
|
|
124
|
+
|
|
125
|
+
The gate puts a whole host behind an email sign in with no database: a reader on the allowlist asks for a link, the link starts a signed session cookie, and the middleware holds every page and file back from anyone without one. It is opt in and fails open, so a host with no `GATE_SESSION_SECRET` is fully public.
|
|
126
|
+
|
|
127
|
+
Vercel finds these three files by path, so a site keeps them, each a re export:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// api/auth/request-link.ts
|
|
131
|
+
export { POST } from "@atelic-action/ui/gate/request-link";
|
|
132
|
+
|
|
133
|
+
// api/auth/verify.ts
|
|
134
|
+
export { GET } from "@atelic-action/ui/gate/verify";
|
|
135
|
+
|
|
136
|
+
// middleware.ts
|
|
137
|
+
export { default } from "@atelic-action/ui/gate/middleware";
|
|
138
|
+
|
|
139
|
+
// The platform reads the matcher from this file and nowhere else. Only the
|
|
140
|
+
// two sign in functions skip the middleware; any other function is held.
|
|
141
|
+
export const config = { matcher: ["/((?!api/auth/).*)"] };
|
|
142
|
+
```
|
|
143
|
+
|
|
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), 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
|
+
```ts
|
|
147
|
+
import { createMiddleware } from "@atelic-action/ui/gate/middleware";
|
|
148
|
+
export default createMiddleware({ needsSession: () => true });
|
|
149
|
+
|
|
150
|
+
import { createRequestLink } from "@atelic-action/ui/gate/request-link";
|
|
151
|
+
export const POST = createRequestLink({ noun: "preview" });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Env | Read by | What it does |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `GATE_SESSION_SECRET` | all three | The signing secret, and the switch: unset, the gate is off. Use 32 random bytes or more |
|
|
157
|
+
| `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
|
+
| `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, 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 |
|
|
160
|
+
| `GATE_FROM`, `GATE_SUBJECT`, `GATE_LINK_TTL_MIN` | request link | Sender, subject, and link lifetime in minutes (15, a day at most) |
|
|
161
|
+
| `GATE_SESSION_DAYS`, `GATE_WATERMARK_MODE` | verify | Session lifetime in days (7, a year at most), and `pill` or `tiled` |
|
|
162
|
+
|
|
163
|
+
What it holds to, each with a test in `tst/gate/`:
|
|
164
|
+
|
|
165
|
+
- Give each host its own secret. A token carries no host, so two hosts sharing a secret would accept each other's sessions.
|
|
166
|
+
- Tokens are compact JWTs under HS256, signed and verified by [jose](https://github.com/panva/jose). The algorithm is pinned, and a link token is never accepted as a session or a session as a link.
|
|
167
|
+
- The emailed link is never built from a forwarded header, since a caller can name any host there.
|
|
168
|
+
- 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
|
+
- 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. Under `/api/`, only `/api/auth/` is open.
|
|
171
|
+
|
|
172
|
+
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
|
+
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
|
+
|
|
119
176
|
## Email
|
|
120
177
|
|
|
121
178
|
`@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.
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
/** Whether an address is on the comma separated allowlist. */
|
|
6
|
+
export declare function isAllowed(email: string, allowlist: string | undefined): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* A setting that has to be a positive, finite number, or its default. A
|
|
9
|
+
* lifetime of zero, a negative one, or Infinity would mint a token that is
|
|
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. 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.
|
|
14
|
+
*/
|
|
15
|
+
export declare function positive(value: string | undefined, fallback: number, ceiling: number): number;
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
/** Whether an address is on the comma separated allowlist. */
|
|
6
|
+
export function isAllowed(email, allowlist) {
|
|
7
|
+
const wanted = email.trim().toLowerCase();
|
|
8
|
+
if (!wanted)
|
|
9
|
+
return false;
|
|
10
|
+
return (allowlist ?? "")
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
13
|
+
.includes(wanted);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A setting that has to be a positive, finite number, or its default. A
|
|
17
|
+
* lifetime of zero, a negative one, or Infinity would mint a token that is
|
|
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. 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.
|
|
22
|
+
*/
|
|
23
|
+
export function positive(value, fallback, ceiling) {
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
26
|
+
return fallback;
|
|
27
|
+
return Math.min(parsed, ceiling);
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface MiddlewareOptions {
|
|
2
|
+
/** Whether a path needs a session once the gate is armed. */
|
|
3
|
+
needsSession?: (pathname: string) => boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function createMiddleware(options?: MiddlewareOptions): (request: Request) => Promise<Response | undefined>;
|
|
6
|
+
declare const _default: (request: Request) => Promise<Response | undefined>;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
function readCookie(header, name) {
|
|
27
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
28
|
+
if (!match)
|
|
29
|
+
return undefined;
|
|
30
|
+
try {
|
|
31
|
+
return decodeURIComponent(match[1]);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function createMiddleware(options = {}) {
|
|
38
|
+
const needsSession = options.needsSession ?? wallNeedsSession;
|
|
39
|
+
return async function middleware(request) {
|
|
40
|
+
const secret = process.env.GATE_SESSION_SECRET;
|
|
41
|
+
if (!secret)
|
|
42
|
+
return; // gate off, let everything through
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
if (!needsSession(url.pathname))
|
|
45
|
+
return; // what the login page needs to render
|
|
46
|
+
const cookies = request.headers.get("cookie") ?? "";
|
|
47
|
+
const claims = await verifyToken(readCookie(cookies, "gate_session"), secret);
|
|
48
|
+
if (claims?.p === "session" && isAllowed(claims.e, process.env.GATE_ALLOWLIST)) {
|
|
49
|
+
return; // valid session, continue to the site
|
|
50
|
+
}
|
|
51
|
+
const login = new URL("/login", url.origin);
|
|
52
|
+
login.searchParams.set("next", url.pathname + url.search);
|
|
53
|
+
return Response.redirect(login.toString(), 302);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export default createMiddleware();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function safeNext(value: unknown): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
export function safeNext(value) {
|
|
13
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//"))
|
|
14
|
+
return "/";
|
|
15
|
+
if (value.length > LONGEST)
|
|
16
|
+
return "/";
|
|
17
|
+
for (let i = 0; i < value.length; i++) {
|
|
18
|
+
const code = value.charCodeAt(i);
|
|
19
|
+
if (code === 0x5c || code <= 0x20 || code >= 0x7f)
|
|
20
|
+
return "/";
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface RequestLinkOptions {
|
|
2
|
+
/** What the link opens, as the email names it. Defaults to "document". */
|
|
3
|
+
noun?: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Where the emailed link points. Never a forwarded header: a caller can name
|
|
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, 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
|
|
11
|
+
* request to, which is the deployment itself.
|
|
12
|
+
*/
|
|
13
|
+
export declare function linkBase(request: Request, env: Record<string, string | undefined>): string;
|
|
14
|
+
export declare function createRequestLink(options?: RequestLinkOptions): (request: Request) => Promise<Response>;
|
|
15
|
+
/** The handler a document host exports as is. */
|
|
16
|
+
export declare const POST: (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,161 @@
|
|
|
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 { escapeHTML } from "../lib/escapeHTML.js";
|
|
19
|
+
import { isAllowed, positive } from "./config.js";
|
|
20
|
+
import { safeNext } from "./next.js";
|
|
21
|
+
import { signToken } from "./tokens.js";
|
|
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;
|
|
25
|
+
/** How long every valid request takes to answer, whatever it did. */
|
|
26
|
+
const ANSWER_AFTER_MS = 2000;
|
|
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
|
+
function json(status, body) {
|
|
34
|
+
return new Response(JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Where the emailed link points. Never a forwarded header: a caller can name
|
|
41
|
+
* any host there, and the link carries a signed token. The configured base
|
|
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
|
|
45
|
+
* request to, which is the deployment itself.
|
|
46
|
+
*/
|
|
47
|
+
export function linkBase(request, env) {
|
|
48
|
+
const configured = env.GATE_BASE_URL?.trim();
|
|
49
|
+
if (configured) {
|
|
50
|
+
try {
|
|
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");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
console.error("gate: GATE_BASE_URL is not a URL, falling back to the request's own host");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (env.VERCEL_ENV === "production" && env.VERCEL_PROJECT_PRODUCTION_URL) {
|
|
62
|
+
return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`;
|
|
63
|
+
}
|
|
64
|
+
return new URL(request.url).origin;
|
|
65
|
+
}
|
|
66
|
+
function linkEmailHTML(rawLink, rawNoun) {
|
|
67
|
+
const link = escapeHTML(rawLink);
|
|
68
|
+
const noun = escapeHTML(rawNoun);
|
|
69
|
+
// Gmail renders inline styles only, no style blocks.
|
|
70
|
+
return `
|
|
71
|
+
<div style="font-family: Helvetica, Arial, sans-serif; color: #211c17; line-height: 1.6; max-width: 520px;">
|
|
72
|
+
<p style="margin: 0 0 12px; font-size: 24px; font-weight: 600;">Your private link to the ${noun}.</p>
|
|
73
|
+
<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>
|
|
74
|
+
<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} →</a>
|
|
75
|
+
<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>
|
|
76
|
+
<p style="margin: 28px 0 0; font-size: 13px; color: #8a7c68;">If you did not expect this, you can ignore it.</p>
|
|
77
|
+
</div>`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Sends the link, and says so in the function's log when it could not. The
|
|
81
|
+
* log is the only place a failed send shows: the response never tells.
|
|
82
|
+
*/
|
|
83
|
+
async function sendLink(to, link, noun) {
|
|
84
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
85
|
+
if (!apiKey) {
|
|
86
|
+
console.error("gate: no RESEND_API_KEY, so no sign in link was sent");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const giveUp = setTimeout(() => controller.abort(), SEND_WITHIN_MS);
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
headers: {
|
|
96
|
+
Authorization: `Bearer ${apiKey}`,
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
from: process.env.GATE_FROM || DEFAULT_FROM,
|
|
101
|
+
to: [to],
|
|
102
|
+
subject: process.env.GATE_SUBJECT || `Your private link to the ${noun}`,
|
|
103
|
+
html: linkEmailHTML(link, noun),
|
|
104
|
+
text: `Your private link to the ${noun}:\n\n${link}\n\nIf you did not expect this, you can ignore it.`,
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
console.error(`gate: Resend answered ${response.status}, so no sign in link was sent`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearTimeout(giveUp);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
116
|
+
export function createRequestLink(options = {}) {
|
|
117
|
+
const noun = options.noun ?? "document";
|
|
118
|
+
return async function POST(request) {
|
|
119
|
+
let data;
|
|
120
|
+
try {
|
|
121
|
+
data = await request.json();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return json(400, { ok: false, error: "Invalid request body." });
|
|
125
|
+
}
|
|
126
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
127
|
+
return json(400, { ok: false, error: "Invalid request body." });
|
|
128
|
+
}
|
|
129
|
+
const fields = data;
|
|
130
|
+
const email = String(fields.email ?? "")
|
|
131
|
+
.trim()
|
|
132
|
+
.toLowerCase();
|
|
133
|
+
const next = safeNext(fields.next);
|
|
134
|
+
const secret = process.env.GATE_SESSION_SECRET;
|
|
135
|
+
// Every valid request answers after the same wait, so a send in flight
|
|
136
|
+
// reads no differently from no send at all.
|
|
137
|
+
const answered = wait(ANSWER_AFTER_MS);
|
|
138
|
+
if (secret && isAllowed(email, process.env.GATE_ALLOWLIST)) {
|
|
139
|
+
const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15, LONGEST_LINK_MIN);
|
|
140
|
+
try {
|
|
141
|
+
const token = await signToken({
|
|
142
|
+
e: email,
|
|
143
|
+
x: Math.floor(Date.now() / 1000) + Math.max(1, Math.round(ttlMinutes * 60)),
|
|
144
|
+
p: "link",
|
|
145
|
+
n: next,
|
|
146
|
+
}, secret);
|
|
147
|
+
const link = `${linkBase(request, process.env)}/api/auth/verify?token=${encodeURIComponent(token)}`;
|
|
148
|
+
await sendLink(email, link, noun);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
// The response must not reveal whether a send happened; the log may.
|
|
152
|
+
const reason = error instanceof Error ? error.name : "unknown error";
|
|
153
|
+
console.error(`gate: the sign in link was not sent (${reason})`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
await answered;
|
|
157
|
+
return json(200, { ok: true });
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** The handler a document host exports as is. */
|
|
161
|
+
export const POST = createRequestLink();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type TokenPurpose = "link" | "session";
|
|
2
|
+
export interface TokenClaims {
|
|
3
|
+
/** Subject email. */
|
|
4
|
+
e: string;
|
|
5
|
+
/** Expiry, epoch seconds. */
|
|
6
|
+
x: number;
|
|
7
|
+
/** Purpose; keeps a short lived link token from being replayed as a session. */
|
|
8
|
+
p: TokenPurpose;
|
|
9
|
+
/** Post sign in path (link tokens only). */
|
|
10
|
+
n?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function signToken(claims: TokenClaims, secret: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* The claims of a token this secret signed and that has not expired, or null
|
|
15
|
+
* for anything else. The algorithm is pinned, so a token naming another one
|
|
16
|
+
* (or none) is refused before its signature is read.
|
|
17
|
+
*/
|
|
18
|
+
export declare function verifyToken(token: string | undefined | null, secret: string): Promise<TokenClaims | null>;
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
const encoder = new TextEncoder();
|
|
10
|
+
export async function signToken(claims, secret) {
|
|
11
|
+
const body = { p: claims.p };
|
|
12
|
+
if (claims.n !== undefined)
|
|
13
|
+
body.n = claims.n;
|
|
14
|
+
return new SignJWT(body)
|
|
15
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
16
|
+
.setSubject(claims.e)
|
|
17
|
+
.setExpirationTime(claims.x)
|
|
18
|
+
.sign(encoder.encode(secret));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The claims of a token this secret signed and that has not expired, or null
|
|
22
|
+
* for anything else. The algorithm is pinned, so a token naming another one
|
|
23
|
+
* (or none) is refused before its signature is read.
|
|
24
|
+
*/
|
|
25
|
+
export async function verifyToken(token, secret) {
|
|
26
|
+
if (!token || !secret)
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
const { payload } = await jwtVerify(token, encoder.encode(secret), {
|
|
30
|
+
algorithms: ["HS256"],
|
|
31
|
+
requiredClaims: ["sub", "exp"],
|
|
32
|
+
});
|
|
33
|
+
const { sub, exp, p, n } = payload;
|
|
34
|
+
if (typeof sub !== "string" || typeof exp !== "number")
|
|
35
|
+
return null;
|
|
36
|
+
if (p !== "link" && p !== "session")
|
|
37
|
+
return null;
|
|
38
|
+
return { e: sub, x: exp, p, ...(typeof n === "string" ? { n } : {}) };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function GET(request: Request): Promise<Response>;
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
/** A year: past it a session is no longer something anyone remembers granting. */
|
|
18
|
+
const LONGEST_SESSION_DAYS = 365;
|
|
19
|
+
function cookie(name, value, maxAge, httpOnly) {
|
|
20
|
+
const flags = ["Path=/", "Secure", "SameSite=Lax", `Max-Age=${maxAge}`];
|
|
21
|
+
if (httpOnly)
|
|
22
|
+
flags.unshift("HttpOnly");
|
|
23
|
+
return `${name}=${encodeURIComponent(value)}; ${flags.join("; ")}`;
|
|
24
|
+
}
|
|
25
|
+
function redirect(location, headers) {
|
|
26
|
+
const merged = headers ?? new Headers();
|
|
27
|
+
merged.set("Location", location);
|
|
28
|
+
// The request carried a token in its URL and the answer may carry cookies:
|
|
29
|
+
// nothing keeps a copy, and the next page learns nothing of the link.
|
|
30
|
+
merged.set("Cache-Control", "no-store");
|
|
31
|
+
merged.set("Referrer-Policy", "no-referrer");
|
|
32
|
+
return new Response(null, { status: 302, headers: merged });
|
|
33
|
+
}
|
|
34
|
+
export async function GET(request) {
|
|
35
|
+
const secret = process.env.GATE_SESSION_SECRET;
|
|
36
|
+
if (!secret)
|
|
37
|
+
return redirect("/login?e=expired");
|
|
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
|
+
const days = positive(process.env.GATE_SESSION_DAYS, 7, LONGEST_SESSION_DAYS);
|
|
45
|
+
// Whole seconds: a browser ignores a Max-Age that is not an integer.
|
|
46
|
+
const maxAge = Math.max(1, Math.round(days * 24 * 60 * 60));
|
|
47
|
+
const session = await signToken({ e: claims.e, x: Math.floor(Date.now() / 1000) + maxAge, p: "session" }, secret);
|
|
48
|
+
const mode = process.env.GATE_WATERMARK_MODE || "pill";
|
|
49
|
+
const headers = new Headers();
|
|
50
|
+
headers.append("Set-Cookie", cookie("gate_session", session, maxAge, true));
|
|
51
|
+
headers.append("Set-Cookie", cookie("gate_email", claims.e, maxAge, false));
|
|
52
|
+
headers.append("Set-Cookie", cookie("gate_wm", mode, maxAge, false));
|
|
53
|
+
return redirect(safeNext(claims.n), headers);
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
* Whether a request for this path needs a session when the wall is armed.
|
|
37
|
+
* The path is read the way a file server would read it (decoded, repeated
|
|
38
|
+
* slashes collapsed) so an encoded or doubled slash cannot walk a held file
|
|
39
|
+
* out through an open prefix; anything that will not decode, or still climbs
|
|
40
|
+
* a directory once decoded, is held, and so is one that still carries a
|
|
41
|
+
* percent sign, since a second decode somewhere downstream would make it a
|
|
42
|
+
* different path than the one judged here. Case is folded for the held corner
|
|
43
|
+
* alone, so no spelling of it slips out, while an open path opens only as
|
|
44
|
+
* written: the host serves files by exact case, and a path that merely looks
|
|
45
|
+
* like an open one names some other file.
|
|
46
|
+
*/
|
|
47
|
+
export declare function needsSession(pathname: string): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
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, and so is one that still carries a
|
|
53
|
+
* percent sign, since a second decode somewhere downstream would make it a
|
|
54
|
+
* different path than the one judged here. Case is folded for the held corner
|
|
55
|
+
* alone, so no spelling of it slips out, while an open path opens only as
|
|
56
|
+
* written: the host serves files by exact case, and a path that merely looks
|
|
57
|
+
* like an open one names some other file.
|
|
58
|
+
*/
|
|
59
|
+
export function needsSession(pathname) {
|
|
60
|
+
let path;
|
|
61
|
+
try {
|
|
62
|
+
path = decodeURIComponent(pathname);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
path = path.replace(/\/{2,}/g, "/");
|
|
68
|
+
if (path.includes("..") || path.includes("\\") || path.includes("%"))
|
|
69
|
+
return true;
|
|
70
|
+
const folded = path.toLowerCase();
|
|
71
|
+
if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix)))
|
|
72
|
+
return true;
|
|
73
|
+
if (OPEN_EXACT.has(path))
|
|
74
|
+
return false;
|
|
75
|
+
return !OPEN_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
76
|
+
}
|