@atelic-action/ui 0.15.1 → 0.16.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 -8
- package/bin/atelic-check-access.ts +5 -0
- package/bin/atelic-check-links.ts +5 -0
- package/bin/atelic-check-wall.ts +5 -0
- package/bin/atelic-print-sheets.sh +69 -0
- package/bin/atelic-reader-link.ts +5 -0
- package/bin/atelic-seal-document.mjs +6 -0
- package/bin/atelic-serve-static.ts +5 -0
- package/bin/atelic-stamp-health.ts +5 -0
- package/dist/vite/artifact-config.d.ts +24 -0
- package/dist/vite/artifact-config.js +83 -0
- package/dist/vite/index.d.ts +7 -0
- package/dist/vite/index.js +7 -0
- package/package.json +28 -5
- package/src/scripts/build-gate-payload.mjs +177 -0
- package/src/scripts/check-access.ts +93 -0
- package/src/scripts/check-links.ts +104 -0
- package/src/scripts/check-wall.ts +357 -0
- package/src/scripts/reader-link.ts +108 -0
- package/src/scripts/serve-static.ts +47 -0
- package/src/scripts/site-config.ts +34 -0
- package/src/scripts/stamp-health.ts +29 -0
- package/src/vite/artifact-config.ts +105 -0
- package/src/vite/index.ts +12 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build guard for a private artifact, and the first step of `bun run
|
|
3
|
+
* build`. The practice's FOOTPRINT.md, Privacy: anything that carries a
|
|
4
|
+
* private signal is never hosted publicly, and a private artifact deploys
|
|
5
|
+
* only to a host with the wall armed. So when site.config.ts says
|
|
6
|
+
* `access: "private"` and the build is running on Vercel, the three
|
|
7
|
+
* variables that arm the wall must be set on the project, or the build
|
|
8
|
+
* fails before anything ships. A local build of a private artifact passes
|
|
9
|
+
* with a warning, because the wall only ever runs on the host.
|
|
10
|
+
*
|
|
11
|
+
* Usage: atelic-check-access (run by `bun run build`, from the site's root)
|
|
12
|
+
* Exits 1 when a private artifact would deploy without its wall.
|
|
13
|
+
*/
|
|
14
|
+
import { loadSite } from "./site-config";
|
|
15
|
+
|
|
16
|
+
/** What arms the wall (ACCESS-GATE.md, Turn It On). */
|
|
17
|
+
export const WALL_VARIABLES = ["GATE_SESSION_SECRET", "GATE_ALLOWLIST", "RESEND_API_KEY"] as const;
|
|
18
|
+
|
|
19
|
+
export interface AccessDecision {
|
|
20
|
+
verdict: "pass" | "warn" | "fail";
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function list(names: readonly string[]): string {
|
|
25
|
+
if (names.length === 1) return names[0];
|
|
26
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The decision, kept pure so it can be tested without a build: the
|
|
31
|
+
* artifact's `access` line and the environment the build runs in.
|
|
32
|
+
* `VERCEL` is set by Vercel on every build, and nowhere else.
|
|
33
|
+
*/
|
|
34
|
+
export function decideAccess(
|
|
35
|
+
access: unknown,
|
|
36
|
+
env: Record<string, string | undefined>,
|
|
37
|
+
): AccessDecision {
|
|
38
|
+
if (access === "public") {
|
|
39
|
+
return {
|
|
40
|
+
verdict: "pass",
|
|
41
|
+
message: "[access] public: built from public signals, no wall needed.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (access !== "private") {
|
|
45
|
+
return {
|
|
46
|
+
verdict: "fail",
|
|
47
|
+
message:
|
|
48
|
+
'[access] src/site.config.ts has no access line. Set access to "public" (built only from public signals) or "private" (anything from the owner\'s own consoles), per the practice\'s FOOTPRINT.md, Privacy.',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const missing = WALL_VARIABLES.filter((name) => !env[name]?.trim());
|
|
52
|
+
if (!env.VERCEL) {
|
|
53
|
+
const owed = missing.length
|
|
54
|
+
? ` Before it deploys, set ${list(missing)} on its Vercel project, or the build there fails.`
|
|
55
|
+
: "";
|
|
56
|
+
return {
|
|
57
|
+
verdict: "warn",
|
|
58
|
+
message: `[access] private, built locally: the wall only runs on the host, so this build is not a deploy.${owed}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (missing.length === 0) {
|
|
62
|
+
return {
|
|
63
|
+
verdict: "pass",
|
|
64
|
+
message: "[access] private, and the wall's variables are set on this project.",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
verdict: "fail",
|
|
69
|
+
message: `[access] This artifact is private (src/site.config.ts, access: "private"), and the wall is not armed on this Vercel project. Set ${list(missing)} in the project's Settings, Environment Variables, for this environment, then redeploy. ACCESS-GATE.md says what each one holds. Nothing private deploys without the wall.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The command: reads the config of the site in `cwd` and decides. The
|
|
75
|
+
* config is found from the working directory, since this file no longer
|
|
76
|
+
* lives in the site.
|
|
77
|
+
*/
|
|
78
|
+
export async function main(cwd: string = process.cwd()): Promise<void> {
|
|
79
|
+
let access: unknown;
|
|
80
|
+
try {
|
|
81
|
+
access = (await loadSite(cwd)).access;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`[access] ${(error as Error).message}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const decision = decideAccess(access, process.env);
|
|
87
|
+
if (decision.verdict === "fail") {
|
|
88
|
+
console.error(decision.message);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
if (decision.verdict === "warn") console.warn(decision.message);
|
|
92
|
+
else console.log(decision.message);
|
|
93
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External link checker: scans every prerendered page in dist/client for
|
|
3
|
+
* outbound hrefs and media sources, then verifies each URL responds.
|
|
4
|
+
* Internal link integrity is already enforced at build time by the
|
|
5
|
+
* prerender crawler; this covers the links that rot silently — booking
|
|
6
|
+
* portals, map embeds, hosted images, font stylesheets.
|
|
7
|
+
*
|
|
8
|
+
* Usage: atelic-check-links, or `bun run check:links` in a site (requires a
|
|
9
|
+
* completed `bun build`, and runs from the site's root)
|
|
10
|
+
* Exits 1 if any external link is dead.
|
|
11
|
+
*/
|
|
12
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const ROOT = "dist/client";
|
|
16
|
+
const ATTR_URL = /(?:href|src)="(https?:\/\/[^"]+)"/g;
|
|
17
|
+
// Hosts that reject bots or are placeholders; skip rather than false-alarm.
|
|
18
|
+
// Substack 403s datacenter IPs (GitHub runners) regardless of user agent.
|
|
19
|
+
const SKIP_HOSTS = new Set(["example.com", "schema.org", "www.schema.org", "substack.com"]);
|
|
20
|
+
const SKIP_SUFFIXES = [".example.com", ".substack.com"];
|
|
21
|
+
|
|
22
|
+
function htmlFiles(dir: string): string[] {
|
|
23
|
+
return readdirSync(dir).flatMap((name) => {
|
|
24
|
+
const path = join(dir, name);
|
|
25
|
+
if (statSync(path).isDirectory()) return htmlFiles(path);
|
|
26
|
+
return name.endsWith(".html") ? [path] : [];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function canonicalHost(html: string): string | null {
|
|
31
|
+
const match = html.match(/rel="canonical" href="(https?:\/\/[^"]+)"/);
|
|
32
|
+
return match ? new URL(match[1]).hostname : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectUrls(): Map<string, Set<string>> {
|
|
36
|
+
const urls = new Map<string, Set<string>>();
|
|
37
|
+
const files = htmlFiles(ROOT);
|
|
38
|
+
// The site's own canonical/OG URLs reference the production domain, which
|
|
39
|
+
// may not be cut over yet — and internal integrity is already enforced by
|
|
40
|
+
// the prerender crawler. Skip the canonical host entirely.
|
|
41
|
+
const ownHost = files.map((f) => canonicalHost(readFileSync(f, "utf8"))).find(Boolean);
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
// Preconnect hints are bare origins (they often 404 at "/") — they warm
|
|
44
|
+
// connections, they aren't content links.
|
|
45
|
+
const html = readFileSync(file, "utf8").replace(/<link rel="preconnect"[^>]*>/g, "");
|
|
46
|
+
for (const match of html.matchAll(ATTR_URL)) {
|
|
47
|
+
const url = match[1].replace(/&/g, "&");
|
|
48
|
+
const host = new URL(url).hostname;
|
|
49
|
+
if (SKIP_HOSTS.has(host) || SKIP_SUFFIXES.some((s) => host.endsWith(s))) continue;
|
|
50
|
+
if (ownHost && (host === ownHost || host === `www.${ownHost}`)) continue;
|
|
51
|
+
if (!urls.has(url)) urls.set(url, new Set());
|
|
52
|
+
urls.get(url)?.add(file.replace(`${ROOT}/`, ""));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return urls;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function probe(url: string): Promise<number> {
|
|
59
|
+
const attempt = async (method: "HEAD" | "GET") => {
|
|
60
|
+
const res = await fetch(url, {
|
|
61
|
+
method,
|
|
62
|
+
redirect: "follow",
|
|
63
|
+
signal: AbortSignal.timeout(15000),
|
|
64
|
+
headers: { "user-agent": "Mozilla/5.0 (link-check; +https://atelic.me)" },
|
|
65
|
+
});
|
|
66
|
+
return res.status;
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
const status = await attempt("HEAD");
|
|
70
|
+
// Some hosts reject HEAD; retry with GET before judging.
|
|
71
|
+
return status >= 400 ? await attempt("GET") : status;
|
|
72
|
+
} catch {
|
|
73
|
+
try {
|
|
74
|
+
return await attempt("GET");
|
|
75
|
+
} catch {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The command: checks the build in the working directory. */
|
|
82
|
+
export async function main(): Promise<void> {
|
|
83
|
+
const urls = collectUrls();
|
|
84
|
+
console.log(`[links] checking ${urls.size} external URLs across ${ROOT}`);
|
|
85
|
+
|
|
86
|
+
let failures = 0;
|
|
87
|
+
for (const [url, pages] of urls) {
|
|
88
|
+
const status = await probe(url);
|
|
89
|
+
const ok = status >= 200 && status < 400;
|
|
90
|
+
if (!ok) {
|
|
91
|
+
failures++;
|
|
92
|
+
console.error(` DEAD (${status || "no response"}): ${url}`);
|
|
93
|
+
console.error(` referenced by: ${[...pages].join(", ")}`);
|
|
94
|
+
} else {
|
|
95
|
+
console.log(` ok (${status}): ${url}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (failures > 0) {
|
|
100
|
+
console.error(`[links] ${failures} dead external link(s)`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
console.log("[links] all external links alive");
|
|
104
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deploy proof for a private artifact: the wall answers before any link
|
|
3
|
+
* goes out (the practice's FOOTPRINT.md, Privacy). It asks the live host
|
|
4
|
+
* for each document with no cookie and passes only when every one answers
|
|
5
|
+
* with the wall's redirect to /login. A document that answers 200 is
|
|
6
|
+
* readable by anyone holding the link, and a 5xx is a host that is down
|
|
7
|
+
* rather than walled; both fail.
|
|
8
|
+
*
|
|
9
|
+
* The documents are /writeup, plus /report and /proposal when this repo
|
|
10
|
+
* carries those routes. The wall holds files too (needsSession in @atelic-action/ui/gate), so it
|
|
11
|
+
* also asks for the printed copy at /writeup.pdf, the first real screenshot
|
|
12
|
+
* under public/images/, and, when the repo has been built, the first chunk
|
|
13
|
+
* under /assets/doc/, where a document's content compiles to. Each of those
|
|
14
|
+
* must answer with the same redirect: the wall holds a path whether or not
|
|
15
|
+
* the file is there, so a 404 means the wall never ran for it.
|
|
16
|
+
*
|
|
17
|
+
* One thing must stay open on purpose: when public/brand/ has a file, it
|
|
18
|
+
* asks for the first and requires a 200. Brand marks live there because the
|
|
19
|
+
* header draws them on every page, the login page included, and a mark the
|
|
20
|
+
* wall held would leave the login page asking for a file it cannot have.
|
|
21
|
+
*
|
|
22
|
+
* Then it proves the two sign in functions answer, because a wall whose
|
|
23
|
+
* functions cannot load holds every page and can send no link. It posts an
|
|
24
|
+
* address on no allowlist to /api/auth/request-link, so nothing is ever
|
|
25
|
+
* sent, and requires the 200 every address gets. It asks
|
|
26
|
+
* /api/auth/verify for a token that is not one and requires the function's
|
|
27
|
+
* own 302 back to /login?e=expired. A 5xx from either is a function that
|
|
28
|
+
* failed to load: on 2026-10-01 both failed on an extensionless import.
|
|
29
|
+
*
|
|
30
|
+
* Usage: atelic-check-wall <host> [path ...], or `bun run check:wall <host>`
|
|
31
|
+
* in a site, from the site's root
|
|
32
|
+
* bun run check:wall summit.atelic.me
|
|
33
|
+
* bun run check:wall https://summit.atelic.me /writeup /report
|
|
34
|
+
* The sign in functions are asked only when no path is named.
|
|
35
|
+
* Exits 1 unless every path answers as it must.
|
|
36
|
+
*/
|
|
37
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One thing to ask the host for. Every probe but a sign in one is a GET with
|
|
42
|
+
* no body.
|
|
43
|
+
*/
|
|
44
|
+
export type Probe =
|
|
45
|
+
| {
|
|
46
|
+
path: string;
|
|
47
|
+
/**
|
|
48
|
+
* A page the wall must stand in front of, a file it must hold back, or a
|
|
49
|
+
* brand mark it must leave open.
|
|
50
|
+
*/
|
|
51
|
+
kind: "document" | "file" | "open";
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
path: string;
|
|
55
|
+
/**
|
|
56
|
+
* A sign in function that must answer for itself: the request for a
|
|
57
|
+
* link is a POST that must answer 200, and the verify is a GET that
|
|
58
|
+
* must answer its own redirect back to /login.
|
|
59
|
+
*/
|
|
60
|
+
kind: "signin";
|
|
61
|
+
method: "GET" | "POST";
|
|
62
|
+
/** Sent as JSON with a POST. */
|
|
63
|
+
body?: Record<string, string>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The shape of an answer the classifier reads; a fetch Response fits it. */
|
|
67
|
+
export interface WallAnswer {
|
|
68
|
+
status: number;
|
|
69
|
+
headers: { get(name: string): string | null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WallVerdict {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
reason: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A bare host or a full URL, as its origin. */
|
|
78
|
+
export function toOrigin(host: string): string {
|
|
79
|
+
const withScheme = /^https?:\/\//.test(host) ? host : `https://${host}`;
|
|
80
|
+
return new URL(withScheme).origin;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface WallTargets {
|
|
84
|
+
/** Whether this repo carries a route, by its file name in src/routes/. */
|
|
85
|
+
hasRoute: (name: string) => boolean;
|
|
86
|
+
/** The first file under public/images/, as a path from the host root. */
|
|
87
|
+
image?: string;
|
|
88
|
+
/** The first chunk under dist/client/assets/doc/, when the repo is built. */
|
|
89
|
+
docChunk?: string;
|
|
90
|
+
/** The first file under public/brand/, which must stay open. */
|
|
91
|
+
brand?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The paths to ask for: the documents this repo carries, the held files, then the open mark. */
|
|
95
|
+
export function wallProbes({ hasRoute, image, docChunk, brand }: WallTargets): Probe[] {
|
|
96
|
+
const probes: Probe[] = [{ path: "/writeup", kind: "document" }];
|
|
97
|
+
if (hasRoute("report")) probes.push({ path: "/report", kind: "document" });
|
|
98
|
+
if (hasRoute("proposal")) probes.push({ path: "/proposal", kind: "document" });
|
|
99
|
+
probes.push({ path: "/writeup.pdf", kind: "file" });
|
|
100
|
+
if (image) probes.push({ path: image, kind: "file" });
|
|
101
|
+
if (docChunk) probes.push({ path: docChunk, kind: "file" });
|
|
102
|
+
if (brand) probes.push({ path: brand, kind: "open" });
|
|
103
|
+
return probes;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The address the sign in request posts: on no allowlist, so no link is ever sent. */
|
|
107
|
+
export const CHECK_EMAIL = "wall-check@example.com";
|
|
108
|
+
|
|
109
|
+
/** The two sign in functions, asked by default after the wall's own probes. */
|
|
110
|
+
export function signinProbes(): Probe[] {
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
path: "/api/auth/request-link",
|
|
114
|
+
kind: "signin",
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: { email: CHECK_EMAIL },
|
|
117
|
+
},
|
|
118
|
+
{ path: "/api/auth/verify?token=not-a-token", kind: "signin", method: "GET" },
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The first file in a directory, sorted, as `prefix/name`; none if it is empty or absent. */
|
|
123
|
+
export function firstFile(dir: string, prefix: string): string | undefined {
|
|
124
|
+
if (!existsSync(dir)) return undefined;
|
|
125
|
+
const name = readdirSync(dir, { withFileTypes: true })
|
|
126
|
+
.filter((entry) => entry.isFile() && !entry.name.startsWith("."))
|
|
127
|
+
.map((entry) => entry.name)
|
|
128
|
+
.sort()[0];
|
|
129
|
+
return name ? `${prefix}/${encodeURIComponent(name)}` : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const HOST_DOWN =
|
|
133
|
+
"the host is failing, not walled. On 2026-09-09 a new artifact host answered MIDDLEWARE_INVOCATION_FAILED on every page (ATE-537): verify the host answers before any link goes out.";
|
|
134
|
+
|
|
135
|
+
/** Whether a brand mark answered open, as it must. */
|
|
136
|
+
function classifyOpen(answer: WallAnswer, origin: string): WallVerdict {
|
|
137
|
+
const { status } = answer;
|
|
138
|
+
if (status === 200) {
|
|
139
|
+
return { ok: true, reason: `${status}: open on purpose, a brand mark the header draws` };
|
|
140
|
+
}
|
|
141
|
+
if (status >= 300 && status < 400) {
|
|
142
|
+
const location = answer.headers.get("location");
|
|
143
|
+
const toLogin = location && new URL(location, origin).pathname === "/login";
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
reason: toLogin
|
|
147
|
+
? `${status} to /login: the wall holds a brand mark, so the login page asks for a file it cannot have. Brand marks stay open under /brand/ (needsSession in @atelic-action/ui/gate)`
|
|
148
|
+
: `${status} to ${location ?? "nowhere"}: a brand mark should answer 200`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (status > 200 && status < 300) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: `${status}: a brand mark should answer 200 with the file, not another success code`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (status === 404) {
|
|
158
|
+
return { ok: false, reason: "404: the brand mark is not on the host. Check the deploy" };
|
|
159
|
+
}
|
|
160
|
+
if (status >= 500) return { ok: false, reason: `${status}: ${HOST_DOWN}` };
|
|
161
|
+
if (status === 0) return { ok: false, reason: `no answer: ${HOST_DOWN}` };
|
|
162
|
+
return { ok: false, reason: `${status}: a brand mark should answer 200` };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const FUNCTION_FAILED =
|
|
166
|
+
"the sign in function failed to load. On 2026-10-01 the first host with the wall armed answered FUNCTION_INVOCATION_FAILED here (ERR_MODULE_NOT_FOUND on an extensionless import), so the wall held every page and could send no link: the two files under api/auth/ must each be a re export from @atelic-action/ui, whose gate ships compiled for this reason";
|
|
167
|
+
|
|
168
|
+
/** Whether a sign in function answered for itself, as it must. */
|
|
169
|
+
function classifySignin(
|
|
170
|
+
probe: Extract<Probe, { kind: "signin" }>,
|
|
171
|
+
answer: WallAnswer,
|
|
172
|
+
origin: string,
|
|
173
|
+
): WallVerdict {
|
|
174
|
+
const { status } = answer;
|
|
175
|
+
if (status >= 500) return { ok: false, reason: `${status}: ${FUNCTION_FAILED}` };
|
|
176
|
+
if (status === 0) return { ok: false, reason: `no answer: ${HOST_DOWN}` };
|
|
177
|
+
if (status === 401 || status === 403) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: `${status}: Vercel Deployment Protection or another lock answered, not the function. Turn Deployment Protection off (DEPLOY.md)`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (status === 404) {
|
|
184
|
+
return { ok: false, reason: "404: the sign in function is not on the host. Check the deploy" };
|
|
185
|
+
}
|
|
186
|
+
const location = answer.headers.get("location");
|
|
187
|
+
if (probe.method === "POST") {
|
|
188
|
+
if (status === 200) {
|
|
189
|
+
return { ok: true, reason: "200: the request for a link answered, sending nothing" };
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
reason: `${status}${location ? ` to ${location}` : ""}: the request for a link answers 200 to every address`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// A token that is not one gets the function's own redirect, marked expired;
|
|
197
|
+
// the wall's redirect carries ?next= instead, so the two cannot be mistaken.
|
|
198
|
+
const target = location ? new URL(location, origin) : undefined;
|
|
199
|
+
if (
|
|
200
|
+
status === 302 &&
|
|
201
|
+
target?.pathname === "/login" &&
|
|
202
|
+
target.searchParams.get("e") === "expired"
|
|
203
|
+
) {
|
|
204
|
+
return { ok: true, reason: "302 to /login?e=expired: the verify answered a bad token" };
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: `${status}${location ? ` to ${location}` : ""}: the verify answers a bad token with a 302 to /login?e=expired`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whether one answer is the wall (or, for a brand mark, open, or for a sign
|
|
214
|
+
* in function, its own answer), and if not, what it is instead.
|
|
215
|
+
*/
|
|
216
|
+
export function classifyWall(probe: Probe, answer: WallAnswer, origin: string): WallVerdict {
|
|
217
|
+
if (probe.kind === "open") return classifyOpen(answer, origin);
|
|
218
|
+
if (probe.kind === "signin") return classifySignin(probe, answer, origin);
|
|
219
|
+
const { status } = answer;
|
|
220
|
+
if (status >= 300 && status < 400) {
|
|
221
|
+
const location = answer.headers.get("location");
|
|
222
|
+
if (location && new URL(location, origin).pathname === "/login") {
|
|
223
|
+
return { ok: true, reason: `${status} to /login: the wall answered` };
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
reason: `${status} to ${location ?? "nowhere"}, not the wall's /login: something other than the wall answered`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (status >= 200 && status < 300) {
|
|
231
|
+
const type = answer.headers.get("content-type") ?? "no content type";
|
|
232
|
+
return probe.kind === "file"
|
|
233
|
+
? {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: `${status} (${type}): the file is served outside the wall, so anyone with its address can open it. Check the middleware is the current one (needsSession in @atelic-action/ui/gate) and that the wall is armed`,
|
|
236
|
+
}
|
|
237
|
+
: {
|
|
238
|
+
ok: false,
|
|
239
|
+
reason: `${status} (${type}): the document is served with no wall, so anyone with the link can read it. Arm the wall (ACCESS-GATE.md), redeploy, and run this again`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (status === 404) {
|
|
243
|
+
return probe.kind === "file"
|
|
244
|
+
? {
|
|
245
|
+
ok: false,
|
|
246
|
+
reason:
|
|
247
|
+
"404: the wall never ran for this file. It holds a path whether or not the file is there, so a miss means the middleware let the request through",
|
|
248
|
+
}
|
|
249
|
+
: {
|
|
250
|
+
ok: false,
|
|
251
|
+
reason: "404: the host does not serve this page. Check the deploy and the prerender list",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (status === 401 || status === 403) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
reason: `${status}: Vercel Deployment Protection or another lock answered, not the wall. Turn Deployment Protection off (DEPLOY.md) so the wall is what a reader meets`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (status >= 500) return { ok: false, reason: `${status}: ${HOST_DOWN}` };
|
|
261
|
+
if (status === 0) return { ok: false, reason: `no answer: ${HOST_DOWN}` };
|
|
262
|
+
return { ok: false, reason: `${status}: an answer the wall never gives` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type Fetcher = (url: string, init: RequestInit) => Promise<WallAnswer>;
|
|
266
|
+
|
|
267
|
+
/** The request for one probe: a GET unless a sign in probe says otherwise, with no cookie. */
|
|
268
|
+
export function probeRequest(probe: Probe): RequestInit {
|
|
269
|
+
const headers: Record<string, string> = {
|
|
270
|
+
"user-agent": "Mozilla/5.0 (wall-check; +https://atelic.me)",
|
|
271
|
+
};
|
|
272
|
+
const init: RequestInit = {
|
|
273
|
+
method: probe.kind === "signin" ? probe.method : "GET",
|
|
274
|
+
redirect: "manual",
|
|
275
|
+
headers,
|
|
276
|
+
signal: AbortSignal.timeout(15000),
|
|
277
|
+
};
|
|
278
|
+
if (probe.kind === "signin" && probe.body) {
|
|
279
|
+
headers["content-type"] = "application/json";
|
|
280
|
+
init.body = JSON.stringify(probe.body);
|
|
281
|
+
}
|
|
282
|
+
return init;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Asks for every probe with no cookie and never follows a redirect. */
|
|
286
|
+
export async function checkWall(origin: string, probes: Probe[], fetcher: Fetcher) {
|
|
287
|
+
const results: Array<Probe & WallVerdict> = [];
|
|
288
|
+
for (const probe of probes) {
|
|
289
|
+
let answer: WallAnswer;
|
|
290
|
+
try {
|
|
291
|
+
answer = await fetcher(`${origin}${probe.path}`, probeRequest(probe));
|
|
292
|
+
} catch {
|
|
293
|
+
answer = { status: 0, headers: { get: () => null } };
|
|
294
|
+
}
|
|
295
|
+
results.push({ ...probe, ...classifyWall(probe, answer, origin) });
|
|
296
|
+
}
|
|
297
|
+
return results;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The command. The routes, the images, the brand marks, and the build it
|
|
302
|
+
* reads are the site's, found from the working directory.
|
|
303
|
+
*/
|
|
304
|
+
export async function main(args: string[], root: string = process.cwd()): Promise<void> {
|
|
305
|
+
const [host, ...paths] = args;
|
|
306
|
+
if (!host) {
|
|
307
|
+
console.error("usage: bun run check:wall <host> [path ...]");
|
|
308
|
+
process.exit(2);
|
|
309
|
+
}
|
|
310
|
+
const origin = toOrigin(host);
|
|
311
|
+
const probes: Probe[] = paths.length
|
|
312
|
+
? paths.map((path) => ({
|
|
313
|
+
path,
|
|
314
|
+
kind: path.startsWith("/brand/")
|
|
315
|
+
? "open"
|
|
316
|
+
: /\.[a-z0-9]+$/i.test(path)
|
|
317
|
+
? "file"
|
|
318
|
+
: "document",
|
|
319
|
+
}))
|
|
320
|
+
: [
|
|
321
|
+
...wallProbes({
|
|
322
|
+
hasRoute: (name) => existsSync(join(root, "src", "routes", `${name}.tsx`)),
|
|
323
|
+
image: firstFile(join(root, "public", "images"), "/images"),
|
|
324
|
+
docChunk: firstFile(join(root, "dist", "client", "assets", "doc"), "/assets/doc"),
|
|
325
|
+
brand: firstFile(join(root, "public", "brand"), "/brand"),
|
|
326
|
+
}),
|
|
327
|
+
...signinProbes(),
|
|
328
|
+
];
|
|
329
|
+
if (!probes.some((probe) => probe.path.startsWith("/images/"))) {
|
|
330
|
+
console.warn("[wall] no file under public/images/ to ask for; the screenshots go unproven");
|
|
331
|
+
}
|
|
332
|
+
if (!probes.some((probe) => probe.path.startsWith("/assets/doc/"))) {
|
|
333
|
+
console.warn(
|
|
334
|
+
"[wall] no build in dist/client/assets/doc/; run bun run build first to prove the content chunks",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
console.log(`[wall] asking ${origin} for ${probes.length} paths with no cookie`);
|
|
338
|
+
const results = await checkWall(origin, probes, (url, init) => fetch(url, init));
|
|
339
|
+
for (const result of results) {
|
|
340
|
+
const line = ` ${result.ok ? "ok" : "FAIL"} ${result.path}: ${result.reason}`;
|
|
341
|
+
if (result.ok) console.log(line);
|
|
342
|
+
else console.error(line);
|
|
343
|
+
}
|
|
344
|
+
const failed = results.filter((result) => !result.ok).length;
|
|
345
|
+
if (failed > 0) {
|
|
346
|
+
console.error(
|
|
347
|
+
`[wall] ${failed} of ${results.length} failed. No link goes out until this passes.`,
|
|
348
|
+
);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
const signin = probes.some((probe) => probe.kind === "signin");
|
|
352
|
+
console.log(
|
|
353
|
+
signin
|
|
354
|
+
? "[wall] the wall holds every document and file asked for, the brand mark is open, and the sign in functions answered"
|
|
355
|
+
: "[wall] the wall holds every document and file asked for, and the brand mark is open",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a named reader and print the two things that come of it: the line
|
|
3
|
+
* that goes in site.config.ts and the link that goes in the email. Every
|
|
4
|
+
* link sent to a named person carries their token, so their open lands on
|
|
5
|
+
* their contact in HubSpot (DEPLOY.md, Who Opened It).
|
|
6
|
+
*
|
|
7
|
+
* The config line holds the reader sealed: a digest of the token and the
|
|
8
|
+
* email encrypted under it. The token itself appears only in the link, so
|
|
9
|
+
* keep the link; it cannot be printed again.
|
|
10
|
+
*
|
|
11
|
+
* A writeup's reader also gets the printed copy's QR: public/images/qr.png
|
|
12
|
+
* is written to carry the same link, so a scan of the sheet lands on that
|
|
13
|
+
* person's contact as an emailed open does. Mint the reader before printing.
|
|
14
|
+
*
|
|
15
|
+
* The sheet belongs to the first reader named. A later reader never
|
|
16
|
+
* repoints it: with a reader already in analytics.identify the QR is left
|
|
17
|
+
* alone unless --qr asks for it.
|
|
18
|
+
*
|
|
19
|
+
* Usage: atelic-reader-link <email> [path] [--token <existing token>] [--qr | --no-qr],
|
|
20
|
+
* or `bun run reader <email>` in a site, from the site's root.
|
|
21
|
+
* path defaults to /writeup. --token seals an email under a token that is
|
|
22
|
+
* already in a sent link, so that link keeps working. --qr writes the QR
|
|
23
|
+
* for this reader even when another is already named; --no-qr never writes it.
|
|
24
|
+
*/
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import QRCode from "qrcode";
|
|
27
|
+
import { mintToken, readerLink, sealReader } from "../artifact/readers";
|
|
28
|
+
import { loadSite } from "./site-config";
|
|
29
|
+
|
|
30
|
+
export const USAGE =
|
|
31
|
+
"Usage: bun run reader <email> [path] [--token <existing token>] [--qr | --no-qr]";
|
|
32
|
+
|
|
33
|
+
/** What the command line asked for. */
|
|
34
|
+
export interface ReaderArgs {
|
|
35
|
+
email: string;
|
|
36
|
+
/** The page the link opens, always with one leading slash and none trailing. */
|
|
37
|
+
path: string;
|
|
38
|
+
/** A token already in a sent link, when --token gave one. */
|
|
39
|
+
token?: string;
|
|
40
|
+
/** --qr: write the QR for this reader even when another is already named. */
|
|
41
|
+
forceQr: boolean;
|
|
42
|
+
/** --no-qr: never write the QR. */
|
|
43
|
+
noQr: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reads the command line; undefined when it does not name a reader, which is a usage error. */
|
|
47
|
+
export function parseReaderArgs(flags: string[]): ReaderArgs | undefined {
|
|
48
|
+
const noQr = flags.includes("--no-qr");
|
|
49
|
+
const forceQr = flags.includes("--qr");
|
|
50
|
+
const args = flags.filter((flag) => flag !== "--no-qr" && flag !== "--qr");
|
|
51
|
+
const at = args.indexOf("--token");
|
|
52
|
+
const given = at === -1 ? undefined : args[at + 1];
|
|
53
|
+
const rest = at === -1 ? args : args.filter((_, index) => index !== at && index !== at + 1);
|
|
54
|
+
const [email, givenPath = "/writeup"] = rest;
|
|
55
|
+
// "writeup", "/writeup", and "/writeup/" are one page.
|
|
56
|
+
const path = `/${givenPath.replace(/^\/+|\/+$/g, "")}`;
|
|
57
|
+
|
|
58
|
+
if (!email?.includes("@") || (at !== -1 && !given)) return undefined;
|
|
59
|
+
return { email, path, token: given, forceQr, noQr };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The command. The config it reads and the QR it writes are the site's,
|
|
64
|
+
* found from the working directory.
|
|
65
|
+
*/
|
|
66
|
+
export async function main(flags: string[], cwd: string = process.cwd()): Promise<void> {
|
|
67
|
+
const parsed = parseReaderArgs(flags);
|
|
68
|
+
if (!parsed) {
|
|
69
|
+
console.error(USAGE);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const { email, path, forceQr, noQr } = parsed;
|
|
73
|
+
let site: Awaited<ReturnType<typeof loadSite>>;
|
|
74
|
+
try {
|
|
75
|
+
site = await loadSite(cwd);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(`[reader] ${(error as Error).message}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const token = parsed.token ?? mintToken();
|
|
82
|
+
const reader = await sealReader(email, token);
|
|
83
|
+
console.log("Add to analytics.identify in src/site.config.ts:");
|
|
84
|
+
console.log(` { id: "${reader.id}", sealed: "${reader.sealed}" }, // ${email.split("@")[1]}`);
|
|
85
|
+
console.log("");
|
|
86
|
+
console.log("Send this link, and only to that person. Keep it: it cannot be printed again.");
|
|
87
|
+
const link = readerLink(site.url, token, path);
|
|
88
|
+
console.log(` ${link}`);
|
|
89
|
+
|
|
90
|
+
// The sheet's QR is the reader's link. A private artifact identifies by sign
|
|
91
|
+
// in and never by a token, so its QR stays on the bare host.
|
|
92
|
+
const named = site.analytics?.identify.length ?? 0;
|
|
93
|
+
const sheetIsFree = named === 0 || forceQr;
|
|
94
|
+
if (path === "/writeup" && !noQr && site.access === "public" && !sheetIsFree) {
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log(
|
|
97
|
+
"Left public/images/qr.png alone: a reader is already named, and the sheet is theirs. Pass --qr to give the sheet to this reader.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (path === "/writeup" && !noQr && site.access === "public" && sheetIsFree) {
|
|
101
|
+
const qr = resolve(cwd, "public/images/qr.png");
|
|
102
|
+
await QRCode.toFile(qr, link, { width: 330, margin: 4, errorCorrectionLevel: "M" });
|
|
103
|
+
console.log("");
|
|
104
|
+
console.log(
|
|
105
|
+
"Wrote public/images/qr.png with that link. Print the sheet after this, never before.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|