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