@atelic-action/ui 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Seals a document into the payload the `Gate` component reads.
3
+ *
4
+ * One content key encrypts the document once (AES-GCM). That key is then
5
+ * wrapped once per reader, under a key derived from that reader's own email
6
+ * address with PBKDF2. So every reader opens the same document with the one
7
+ * thing they always know, and the emitted payload carries no plaintext: not
8
+ * the document, and not even a hash the reader list could be checked
9
+ * against. Nor a reader's address: it is the secret their key derives from,
10
+ * so listing it beside their wrapped key would hand the document to anyone
11
+ * who reads the page's script. Payloads sealed before 2026-10-08 did list
12
+ * it; the gate now tells HubSpot who opened the document from the address
13
+ * the reader typed. The secret is still never anything beyond that address,
14
+ * which is the gate's deliberate, documented security tier. See GATE.md.
15
+ *
16
+ * Node's webcrypto is the same algorithm surface the browser exposes, so the
17
+ * two sides are byte compatible by construction rather than by agreement.
18
+ * The parameters below are the contract; changing one here without changing
19
+ * the matching constant in `Gate` (@atelic-action/ui/artifact) makes every
20
+ * existing payload unopenable.
21
+ *
22
+ * CLI (bin/atelic-seal-document.mjs runs this file's `main` under Node):
23
+ * atelic-seal-document <config.json> [out.json]
24
+ * bun run seal <config.json> [out.json] (the form a site offers)
25
+ *
26
+ * Config shape (paths resolve relative to the config file):
27
+ * { "content": "./proposal.html",
28
+ * "people": [{ "id": "kyle", "email": "kyle@example.com" }] }
29
+ */
30
+ import { webcrypto } from "node:crypto";
31
+ import { readFileSync, writeFileSync } from "node:fs";
32
+ import { dirname, resolve } from "node:path";
33
+ import { argv, exit, stderr, stdout } from "node:process";
34
+
35
+ /** PBKDF2 work factor. Mirrored by Gate; changing it breaks old payloads. */
36
+ export const PBKDF2_ITERATIONS = 200000;
37
+ export const PBKDF2_HASH = "SHA-256";
38
+ /** Byte lengths: content key, GCM nonce, per person PBKDF2 salt. */
39
+ export const KEY_BYTES = 32;
40
+ export const IV_BYTES = 12;
41
+ export const SALT_BYTES = 16;
42
+
43
+ const encoder = new TextEncoder();
44
+
45
+ /**
46
+ * The one normalization both sides share: an email is its letters and
47
+ * digits, uppercased. So "Dana@Example.com", "dana@example.com" and a stray
48
+ * space are all the same key, and a reader typing their own address cannot
49
+ * miss by punctuation or case. Mirrored in Gate; the two implementations
50
+ * must agree exactly.
51
+ */
52
+ export function normalizeEmail(email) {
53
+ return String(email ?? "")
54
+ .toUpperCase()
55
+ .replace(/[^A-Z0-9]/g, "");
56
+ }
57
+
58
+ function toBase64(bytes) {
59
+ return Buffer.from(bytes).toString("base64");
60
+ }
61
+
62
+ function randomBytes(length) {
63
+ return webcrypto.getRandomValues(new Uint8Array(length));
64
+ }
65
+
66
+ /** Derive one reader's wrapping key from their email and their own salt. */
67
+ async function deriveKey(email, salt) {
68
+ const material = await webcrypto.subtle.importKey(
69
+ "raw",
70
+ encoder.encode(normalizeEmail(email)),
71
+ "PBKDF2",
72
+ false,
73
+ ["deriveKey"],
74
+ );
75
+ return webcrypto.subtle.deriveKey(
76
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
77
+ material,
78
+ { name: "AES-GCM", length: 256 },
79
+ false,
80
+ ["encrypt", "decrypt"],
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Encrypt `contentHtml` once and wrap the content key for each person.
86
+ * `people` is [{ id, email }]; the email is the reader's secret and their
87
+ * identity, so both survive into the payload alongside the wrap.
88
+ */
89
+ export async function sealDocument(contentHtml, people) {
90
+ if (typeof contentHtml !== "string" || contentHtml.length === 0) {
91
+ throw new Error("contentHtml is empty; nothing to seal");
92
+ }
93
+ if (!Array.isArray(people) || people.length === 0) {
94
+ throw new Error("no people; a payload nobody can open is not worth building");
95
+ }
96
+
97
+ const contentKeyBytes = randomBytes(KEY_BYTES);
98
+ const contentKey = await webcrypto.subtle.importKey(
99
+ "raw",
100
+ contentKeyBytes,
101
+ { name: "AES-GCM" },
102
+ false,
103
+ ["encrypt"],
104
+ );
105
+ const civ = randomBytes(IV_BYTES);
106
+ const ct = new Uint8Array(
107
+ await webcrypto.subtle.encrypt(
108
+ { name: "AES-GCM", iv: civ },
109
+ contentKey,
110
+ encoder.encode(contentHtml),
111
+ ),
112
+ );
113
+
114
+ const seen = new Set();
115
+ const sealed = [];
116
+ for (const person of people) {
117
+ const id = person?.id;
118
+ if (!id) throw new Error("every person needs an id");
119
+ if (seen.has(id)) throw new Error(`duplicate person id: ${id}`);
120
+ seen.add(id);
121
+ if (!person.email || !normalizeEmail(person.email)) {
122
+ throw new Error(`person ${id} has no email (or none that survives normalization)`);
123
+ }
124
+
125
+ const salt = randomBytes(SALT_BYTES);
126
+ const iv = randomBytes(IV_BYTES);
127
+ const key = await deriveKey(person.email, salt);
128
+ const wrapped = new Uint8Array(
129
+ await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, contentKeyBytes),
130
+ );
131
+ // The id a config gives a reader is often their first name, which
132
+ // beside the site's domain all but spells the address that is their
133
+ // key. So the payload carries a plain ordinal, and the config's id
134
+ // stays in the config, where it names a reader in an error message.
135
+ sealed.push({
136
+ id: `r${sealed.length + 1}`,
137
+ salt: toBase64(salt),
138
+ iv: toBase64(iv),
139
+ wrapped: toBase64(wrapped),
140
+ });
141
+ }
142
+
143
+ return { civ: toBase64(civ), ct: toBase64(ct), people: sealed };
144
+ }
145
+
146
+ /**
147
+ * The command: `args` is the command line after the command's own name.
148
+ * Both paths resolve against the working directory.
149
+ */
150
+ export async function main(args = argv.slice(2)) {
151
+ const [configPath, outPath] = args;
152
+ if (!configPath) {
153
+ stderr.write("usage: atelic-seal-document <config.json> [out.json]\n");
154
+ exit(1);
155
+ }
156
+
157
+ const configFile = resolve(configPath);
158
+ const config = JSON.parse(readFileSync(configFile, "utf8"));
159
+ if (!config.content) throw new Error(`${configPath} has no "content" path`);
160
+
161
+ const contentHtml = readFileSync(resolve(dirname(configFile), config.content), "utf8");
162
+ const payload = await sealDocument(contentHtml, config.people);
163
+
164
+ const out = resolve(outPath ?? "gate-payload.json");
165
+ writeFileSync(out, `${JSON.stringify(payload, null, "\t")}\n`);
166
+ stdout.write(`[gate] ${out} (${payload.people.length} readers)\n`);
167
+ }
168
+
169
+ /** Runs `main` and turns a failure into the one line the command prints. */
170
+ export function cli(args = argv.slice(2)) {
171
+ return main(args).catch((error) => {
172
+ stderr.write(`[gate] ${error.message}\n`);
173
+ exit(1);
174
+ });
175
+ }
@@ -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(/&amp;/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
+ }