@fullwell/fullwell 1.1.11 → 1.1.13

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,146 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ LocalHouseholdError,
7
+ acquireLocalLock,
8
+ ensurePrivateDirectory,
9
+ hasForbiddenAscii,
10
+ releaseLocalLock,
11
+ writePrivateFile,
12
+ } from "./local-household.mjs";
13
+
14
+ const SCHEMA_VERSION = 1;
15
+ const MAX_PROFILE_BYTES = 4 * 1024;
16
+ const MAX_DISPLAY_NAME_LENGTH = 108;
17
+
18
+ function fail(code, message) {
19
+ throw new LocalHouseholdError(code, message);
20
+ }
21
+
22
+ function displayName(value, errorCode = "VALIDATION_FAILED") {
23
+ if (typeof value !== "string" || !value.isWellFormed() || hasForbiddenAscii(value)) {
24
+ fail(errorCode, `display_name must be trimmed text of at most ${MAX_DISPLAY_NAME_LENGTH} characters`);
25
+ }
26
+ const trimmed = value.trim();
27
+ if (trimmed.length < 1
28
+ || trimmed.length > MAX_DISPLAY_NAME_LENGTH
29
+ || (errorCode === "CORRUPT_LOCAL_PROFILE" && trimmed !== value)) {
30
+ fail(errorCode, `display_name must be trimmed text of at most ${MAX_DISPLAY_NAME_LENGTH} characters`);
31
+ }
32
+ return trimmed;
33
+ }
34
+
35
+ function revision(value, errorCode = "VALIDATION_FAILED") {
36
+ if (!Number.isSafeInteger(value) || value < 0) fail(errorCode, "expected_revision must be a non-negative integer");
37
+ return value;
38
+ }
39
+
40
+ function timestamp(value, label) {
41
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
42
+ fail("CORRUPT_LOCAL_PROFILE", `${label} is invalid`);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ function parseProfile(value) {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ fail("CORRUPT_LOCAL_PROFILE", "local profile must be an object");
50
+ }
51
+ const allowed = new Set(["schema_version", "revision", "display_name", "created_at", "updated_at"]);
52
+ for (const key of Object.keys(value)) {
53
+ if (!allowed.has(key)) fail("CORRUPT_LOCAL_PROFILE", `local profile contains unsupported field ${key}`);
54
+ }
55
+ if (value.schema_version !== SCHEMA_VERSION) fail("CORRUPT_LOCAL_PROFILE", "local profile schema version is unsupported");
56
+ const parsedRevision = revision(value.revision, "CORRUPT_LOCAL_PROFILE");
57
+ if (parsedRevision === 0) fail("CORRUPT_LOCAL_PROFILE", "local profile revision must be positive");
58
+ return {
59
+ schema_version: SCHEMA_VERSION,
60
+ revision: parsedRevision,
61
+ display_name: displayName(value.display_name, "CORRUPT_LOCAL_PROFILE"),
62
+ created_at: timestamp(value.created_at, "created_at"),
63
+ updated_at: timestamp(value.updated_at, "updated_at"),
64
+ };
65
+ }
66
+
67
+ function publicProfile(profile) {
68
+ return {
69
+ revision: profile.revision,
70
+ display_name: profile.display_name,
71
+ default_household_name: defaultHouseholdName(profile.display_name),
72
+ };
73
+ }
74
+
75
+ async function readProfile(filePath) {
76
+ try {
77
+ const metadata = await lstat(filePath);
78
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PROFILE_BYTES) {
79
+ fail("CORRUPT_LOCAL_PROFILE", "local profile file is not a bounded regular file");
80
+ }
81
+ const content = await readFile(filePath);
82
+ if (content.length > MAX_PROFILE_BYTES) fail("CORRUPT_LOCAL_PROFILE", "local profile exceeds its size limit");
83
+ try {
84
+ return parseProfile(JSON.parse(content.toString("utf8")));
85
+ } catch (error) {
86
+ if (error instanceof LocalHouseholdError) throw error;
87
+ fail("CORRUPT_LOCAL_PROFILE", "local profile is not valid JSON");
88
+ }
89
+ } catch (error) {
90
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return null;
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ async function writeProfile(root, profile) {
96
+ const serialized = `${JSON.stringify(profile, null, 2)}\n`;
97
+ if (Buffer.byteLength(serialized) > MAX_PROFILE_BYTES) fail("LOCAL_PROFILE_TOO_LARGE", "local profile exceeds its size limit");
98
+ await writePrivateFile(root, localProfilePath(root), serialized, MAX_PROFILE_BYTES, "local profile");
99
+ }
100
+
101
+ export function localProfilePath(root) {
102
+ return path.join(path.resolve(root), "fullwell", "local", "profile.json");
103
+ }
104
+
105
+ export function defaultHouseholdName(name) {
106
+ const validated = displayName(name);
107
+ return `${validated}${/[sS]$/.test(validated) ? "'" : "'s"} Household`;
108
+ }
109
+
110
+ export async function loadLocalProfile(root) {
111
+ const profile = await readProfile(localProfilePath(root));
112
+ return profile === null ? { status: "missing" } : { status: "found", ...publicProfile(profile) };
113
+ }
114
+
115
+ export async function updateLocalProfile(root, input, now = new Date()) {
116
+ if (typeof input !== "object" || input === null || Array.isArray(input)) fail("VALIDATION_FAILED", "profile update must be an object");
117
+ const allowed = new Set(["expected_revision", "display_name"]);
118
+ for (const key of Object.keys(input)) {
119
+ if (!allowed.has(key)) fail("VALIDATION_FAILED", `profile update contains unsupported field ${key}`);
120
+ }
121
+ const expectedRevision = revision(input.expected_revision);
122
+ const nextDisplayName = displayName(input.display_name);
123
+ const filePath = localProfilePath(root);
124
+ const directory = path.dirname(filePath);
125
+ await ensurePrivateDirectory(root, directory);
126
+ const lock = await acquireLocalLock(directory, now, { lockName: ".profile.lock" });
127
+ try {
128
+ const current = await readProfile(filePath);
129
+ const currentRevision = current?.revision ?? 0;
130
+ if (currentRevision !== expectedRevision) {
131
+ fail("LOCAL_PROFILE_CONFLICT", `local profile revision is ${currentRevision}, not ${expectedRevision}`);
132
+ }
133
+ const updatedAt = now.toISOString();
134
+ const profile = {
135
+ schema_version: SCHEMA_VERSION,
136
+ revision: currentRevision + 1,
137
+ display_name: nextDisplayName,
138
+ created_at: current?.created_at ?? updatedAt,
139
+ updated_at: updatedAt,
140
+ };
141
+ await writeProfile(root, profile);
142
+ return { status: current === null ? "created" : "updated", ...publicProfile(profile) };
143
+ } finally {
144
+ await releaseLocalLock(lock);
145
+ }
146
+ }
@@ -0,0 +1,358 @@
1
+ import { constants } from "node:fs";
2
+ import {
3
+ lstat,
4
+ open,
5
+ readdir,
6
+ realpath,
7
+ rm,
8
+ } from "node:fs/promises";
9
+ import { createHash } from "node:crypto";
10
+ import path from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ import {
14
+ LocalHouseholdError,
15
+ acquireLocalLock,
16
+ ensurePrivateDirectory,
17
+ hasForbiddenAscii,
18
+ releaseLocalLock,
19
+ writePrivateFile,
20
+ } from "./local-household.mjs";
21
+
22
+ const MAX_CARDS = 48;
23
+ const MAX_HTML_BYTES = 1_048_576;
24
+ const MAX_MANIFEST_BYTES = 64 * 1024;
25
+ const MAX_RETAINED_BOARDS = 20;
26
+ const BOARD_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
27
+ const BOARD_ID_PATTERN = /^lrb_[0-9a-f]{32}$/;
28
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
29
+
30
+ const BOARD_STYLE = `
31
+ :root{color:#1e2420;background:#fbfaf6;font-family:"Avenir Next",Avenir,"Gill Sans","Trebuchet MS",sans-serif;--paper:#fbfaf6;--surface:#fff;--ink:#1e2420;--muted:#626a63;--rule:#d8ddd6;--leaf:#236245;--tomato:#c9422f;--saffron:#e0a629;--sky:#d9ecf0;--serif:"Iowan Old Style","Palatino Linotype","Book Antiqua",serif}
32
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top right,#d9ecf0 0,transparent 36rem),var(--paper);color:var(--ink);line-height:1.5}a{color:inherit;text-underline-offset:3px}:focus-visible{outline:3px solid #87c9d7;outline-offset:3px}.board{width:min(1180px,calc(100% - 40px));margin:0 auto;padding:64px 0 80px}.eyebrow{margin:0 0 10px;color:var(--leaf);font-size:.78rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase}h1,h2{font-family:var(--serif);font-weight:600}h1{max-width:16ch;margin:0;font-size:clamp(2.4rem,7vw,4.8rem);line-height:.98}.intro{max-width:44rem;margin:20px 0 44px;color:var(--muted);font-size:1.05rem}.context{display:inline-block;margin-top:16px;padding:5px 9px;border:1px solid var(--rule);background:var(--surface);font-size:.82rem;font-weight:800}.recipe-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin:0;padding:0;list-style:none}.card{display:flex;min-width:0;flex-direction:column;overflow:hidden;border:1px solid var(--rule);border-radius:6px;background:var(--surface)}.media{position:relative;min-height:210px;overflow:hidden;background:repeating-linear-gradient(135deg,#eef0eb,#eef0eb 12px,#e6e9e3 12px,#e6e9e3 24px)}.media img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.image-fallback{position:absolute;inset:0;display:grid;place-items:center;padding:24px;color:var(--muted);text-align:center}.card-body{display:flex;flex:1;flex-direction:column;padding:20px}.source{margin:0 0 8px;color:var(--leaf);font-size:.78rem;font-weight:800;text-transform:uppercase}.card h2{margin:0 0 10px;font-size:1.55rem;line-height:1.08}.reason{margin:0 0 18px;color:var(--muted)}.badges{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 16px;padding:0;list-style:none}.badge{padding:3px 7px;border:1px solid var(--rule);border-radius:999px;background:#f1f3ef;font-size:.75rem;font-weight:800}.slot{margin:auto 0 12px;padding-top:12px;border-top:1px solid var(--rule);font-weight:800}.compatibility{display:grid;grid-template-columns:1.4rem 1fr;gap:8px;margin:0;padding:12px;background:#fffaf0;font-size:.86rem}.compatibility--appears_compatible{background:#f4faf6}.compatibility--needs_recheck{background:#fff7f5}.compatibility-mark{display:grid;width:1.35rem;height:1.35rem;place-items:center;border-radius:50%;background:var(--saffron);font-weight:900}.compatibility--appears_compatible .compatibility-mark{background:var(--leaf);color:#fff}.compatibility--needs_recheck .compatibility-mark{background:var(--tomato);color:#fff}.links{display:flex;flex-wrap:wrap;gap:12px;margin-top:16px;font-size:.85rem;font-weight:800}.privacy{margin:48px 0 0;padding:18px;border-left:4px solid var(--leaf);background:var(--sky);font-size:.9rem}.privacy p{margin:0}
33
+ @media(max-width:860px){.recipe-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.board{width:calc(100% - 28px);padding-top:42px}.recipe-grid{grid-template-columns:1fr}.media{min-height:190px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media print{.board{width:100%;padding:20px}.recipe-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
34
+ `;
35
+
36
+ function fail(code, message) {
37
+ throw new LocalHouseholdError(code, message);
38
+ }
39
+
40
+ function assertExactKeys(value, allowed, label) {
41
+ for (const key of Object.keys(value)) {
42
+ if (!allowed.has(key)) fail("VALIDATION_FAILED", `${label} contains an unsupported field: ${key}`);
43
+ }
44
+ }
45
+
46
+ function assertObject(value, label) {
47
+ if (value === null || typeof value !== "object" || Array.isArray(value)) fail("VALIDATION_FAILED", `${label} must be an object`);
48
+ return value;
49
+ }
50
+
51
+ function assertText(value, label, maximum, { nullable = false } = {}) {
52
+ if (nullable && value === null) return null;
53
+ if (typeof value !== "string"
54
+ || value.length < 1
55
+ || value.length > maximum
56
+ || !value.isWellFormed()
57
+ || value.trim() !== value
58
+ || hasForbiddenAscii(value)) {
59
+ fail("VALIDATION_FAILED", `${label} must be trimmed text of at most ${maximum} characters`);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function assertHttpsUrl(value, label, { nullable = false } = {}) {
65
+ if (nullable && value === null) return null;
66
+ if (typeof value !== "string" || value.length > 2_048 || hasForbiddenAscii(value, true)) {
67
+ fail("VALIDATION_FAILED", `${label} must be a bounded HTTPS URL`);
68
+ }
69
+ let url;
70
+ try {
71
+ url = new URL(value);
72
+ } catch {
73
+ fail("VALIDATION_FAILED", `${label} must be a valid HTTPS URL`);
74
+ }
75
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "") {
76
+ fail("VALIDATION_FAILED", `${label} must be a credential-free HTTPS URL`);
77
+ }
78
+ return url.toString();
79
+ }
80
+
81
+ function parseCard(input, index) {
82
+ const value = assertObject(input, `cards[${index}]`);
83
+ assertExactKeys(value, new Set([
84
+ "id",
85
+ "title",
86
+ "image_url",
87
+ "image_page_url",
88
+ "recipe_url",
89
+ "source_label",
90
+ "why_recommended",
91
+ "journal_statuses",
92
+ "proposed_slot",
93
+ "compatibility",
94
+ "compatibility_caveat",
95
+ ]), `cards[${index}]`);
96
+ if (!Array.isArray(value.journal_statuses) || value.journal_statuses.length > 3) {
97
+ fail("VALIDATION_FAILED", `cards[${index}].journal_statuses is invalid`);
98
+ }
99
+ const journalStatuses = value.journal_statuses.map((status) => {
100
+ if (!["Saved", "Cooked", "Liked"].includes(status)) fail("VALIDATION_FAILED", `cards[${index}] contains an unsupported journal status`);
101
+ return status;
102
+ });
103
+ if (new Set(journalStatuses).size !== journalStatuses.length) fail("VALIDATION_FAILED", `cards[${index}] journal statuses must be unique`);
104
+ if (!["appears_compatible", "incomplete_evidence", "needs_recheck"].includes(value.compatibility)) {
105
+ fail("VALIDATION_FAILED", `cards[${index}].compatibility is unsupported`);
106
+ }
107
+ const imageUrl = assertHttpsUrl(value.image_url, `cards[${index}].image_url`, { nullable: true });
108
+ const imagePageUrl = assertHttpsUrl(value.image_page_url, `cards[${index}].image_page_url`, { nullable: true });
109
+ if ((imageUrl === null) !== (imagePageUrl === null)) {
110
+ fail("VALIDATION_FAILED", `cards[${index}] requires image_page_url exactly when image_url is present`);
111
+ }
112
+ return {
113
+ id: assertText(value.id, `cards[${index}].id`, 120),
114
+ title: assertText(value.title, `cards[${index}].title`, 300),
115
+ image_url: imageUrl,
116
+ image_page_url: imagePageUrl,
117
+ recipe_url: assertHttpsUrl(value.recipe_url, `cards[${index}].recipe_url`, { nullable: true }),
118
+ source_label: assertText(value.source_label, `cards[${index}].source_label`, 200),
119
+ why_recommended: assertText(value.why_recommended, `cards[${index}].why_recommended`, 1_000),
120
+ journal_statuses: journalStatuses,
121
+ proposed_slot: assertText(value.proposed_slot, `cards[${index}].proposed_slot`, 120, { nullable: true }),
122
+ compatibility: value.compatibility,
123
+ compatibility_caveat: assertText(value.compatibility_caveat, `cards[${index}].compatibility_caveat`, 1_000),
124
+ };
125
+ }
126
+
127
+ function parseInput(input) {
128
+ const value = assertObject(input, "recipe board input");
129
+ assertExactKeys(value, new Set(["idempotency_key", "title", "context_label", "cards"]), "recipe board input");
130
+ if (typeof value.idempotency_key !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value.idempotency_key)) {
131
+ fail("VALIDATION_FAILED", "idempotency_key is invalid");
132
+ }
133
+ if (!Array.isArray(value.cards) || value.cards.length < 1 || value.cards.length > MAX_CARDS) {
134
+ fail("VALIDATION_FAILED", `cards must contain between 1 and ${MAX_CARDS} recipes`);
135
+ }
136
+ const cards = value.cards.map(parseCard);
137
+ if (new Set(cards.map(({ id }) => id)).size !== cards.length) fail("VALIDATION_FAILED", "recipe board card IDs must be unique");
138
+ return {
139
+ idempotency_key: value.idempotency_key,
140
+ title: assertText(value.title, "title", 300),
141
+ context_label: assertText(value.context_label, "context_label", 200, { nullable: true }),
142
+ cards,
143
+ };
144
+ }
145
+
146
+ function escapeHtml(value) {
147
+ return value
148
+ .replaceAll("&", "&amp;")
149
+ .replaceAll("<", "&lt;")
150
+ .replaceAll(">", "&gt;")
151
+ .replaceAll('"', "&quot;")
152
+ .replaceAll("'", "&#39;");
153
+ }
154
+
155
+ function compatibilityLabel(value) {
156
+ if (value === "appears_compatible") return "Appears compatible";
157
+ if (value === "needs_recheck") return "Needs recheck";
158
+ return "Incomplete ingredient evidence";
159
+ }
160
+
161
+ function renderCard(card) {
162
+ const image = card.image_url === null
163
+ ? `<div class="media"><div class="image-fallback" role="img" aria-label="No image available for ${escapeHtml(card.title)}">No image available</div></div>`
164
+ : `<div class="media"><div class="image-fallback" aria-hidden="true">Image unavailable</div><img src="${escapeHtml(card.image_url)}" alt="${escapeHtml(card.title)}" width="640" height="420" loading="lazy" crossorigin="anonymous" referrerpolicy="no-referrer"></div>`;
165
+ const statuses = card.journal_statuses.length === 0
166
+ ? ""
167
+ : `<ul class="badges" aria-label="Journal status">${card.journal_statuses.map((status) => `<li class="badge">${status}</li>`).join("")}</ul>`;
168
+ const slot = card.proposed_slot === null ? "" : `<p class="slot">${escapeHtml(card.proposed_slot)}</p>`;
169
+ const links = [
170
+ card.recipe_url === null ? "" : `<a href="${escapeHtml(card.recipe_url)}" target="_blank" rel="noopener noreferrer">Open recipe</a>`,
171
+ card.image_page_url === null ? "" : `<a href="${escapeHtml(card.image_page_url)}" target="_blank" rel="noopener noreferrer">Image source</a>`,
172
+ ].filter(Boolean).join("");
173
+ const compatibilityMark = card.compatibility === "appears_compatible" ? "+" : "!";
174
+ return `<li><article class="card">${image}<div class="card-body"><p class="source">${escapeHtml(card.source_label)}</p><h2>${escapeHtml(card.title)}</h2><p class="reason">${escapeHtml(card.why_recommended)}</p>${statuses}${slot}<p class="compatibility compatibility--${card.compatibility}"><span class="compatibility-mark" aria-hidden="true">${compatibilityMark}</span><span><strong>${compatibilityLabel(card.compatibility)}.</strong> ${escapeHtml(card.compatibility_caveat)}</span></p>${links === "" ? "" : `<nav class="links" aria-label="Sources for ${escapeHtml(card.title)}">${links}</nav>`}</div></article></li>`;
175
+ }
176
+
177
+ function renderBoard(input) {
178
+ const context = input.context_label === null ? "" : `<p class="context">${escapeHtml(input.context_label)}</p>`;
179
+ const csp = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; connect-src 'none'; object-src 'none'; frame-src 'none'; media-src 'none'; form-action 'none'; base-uri 'none'";
180
+ return `<!doctype html>
181
+ <html lang="en">
182
+ <head>
183
+ <meta charset="utf-8">
184
+ <meta name="viewport" content="width=device-width,initial-scale=1">
185
+ <meta name="referrer" content="no-referrer">
186
+ <meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">
187
+ <title>${escapeHtml(input.title)} - Fullwell</title>
188
+ <style>${BOARD_STYLE}</style>
189
+ </head>
190
+ <body>
191
+ <main class="board">
192
+ <header><p class="eyebrow">Private Fullwell recipe board</p><h1>${escapeHtml(input.title)}</h1>${context}<p class="intro">These are the same recommendations from our conversation, arranged visually. This local snapshot does not edit your journal.</p></header>
193
+ <ol class="recipe-grid">${input.cards.map(renderCard).join("")}</ol>
194
+ <aside class="privacy"><p><strong>Private local snapshot.</strong> No Fullwell login is required. Images use anonymous requests but load directly from source sites, which can still receive ordinary network metadata. Recipe links open separately and may use your existing site state.</p></aside>
195
+ </main>
196
+ </body>
197
+ </html>
198
+ `;
199
+ }
200
+
201
+ export function localRecipeBoardsPath(root) {
202
+ return path.join(path.resolve(root), "fullwell", "local", "views", "recipe-boards");
203
+ }
204
+
205
+ function boardPath(root, boardId) {
206
+ return path.join(localRecipeBoardsPath(root), boardId);
207
+ }
208
+
209
+ async function readBoundedJson(filePath, maximumBytes) {
210
+ let handle;
211
+ try {
212
+ const fileStat = await lstat(filePath);
213
+ if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.size > maximumBytes) return null;
214
+ handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
215
+ return JSON.parse(await handle.readFile("utf8"));
216
+ } catch (error) {
217
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return null;
218
+ throw error;
219
+ } finally {
220
+ await handle?.close();
221
+ }
222
+ }
223
+
224
+ function parseManifest(value, boardId) {
225
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
226
+ const expected = ["board_id", "card_count", "created_at", "fingerprint", "html_sha256", "idempotency_key", "remote_image_count", "schema_version"];
227
+ if (Object.keys(value).sort().join(",") !== expected.join(",")) return null;
228
+ if (value.schema_version !== 1 || value.board_id !== boardId || !BOARD_ID_PATTERN.test(value.board_id)) return null;
229
+ if (typeof value.idempotency_key !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value.idempotency_key)) return null;
230
+ if (typeof value.fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(value.fingerprint)) return null;
231
+ if (typeof value.html_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(value.html_sha256)) return null;
232
+ if (typeof value.created_at !== "string" || !Number.isFinite(Date.parse(value.created_at))) return null;
233
+ if (!Number.isSafeInteger(value.card_count) || value.card_count < 1 || value.card_count > MAX_CARDS) return null;
234
+ if (!Number.isSafeInteger(value.remote_image_count) || value.remote_image_count < 0 || value.remote_image_count > value.card_count) return null;
235
+ return value;
236
+ }
237
+
238
+ async function assertDurableBoardFile(filePath, expectedDigest) {
239
+ const fileStat = await lstat(filePath);
240
+ if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.size > MAX_HTML_BYTES) {
241
+ fail("CORRUPT_LOCAL_RECIPE_BOARD", "recipe board HTML is not a bounded regular file");
242
+ }
243
+ const handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
244
+ try {
245
+ const content = await handle.readFile();
246
+ const digest = createHash("sha256").update(content).digest("hex");
247
+ if (digest !== expectedDigest) fail("CORRUPT_LOCAL_RECIPE_BOARD", "recipe board HTML failed its integrity check");
248
+ } finally {
249
+ await handle.close();
250
+ }
251
+ }
252
+
253
+ function resultFromManifest(root, manifest, status) {
254
+ const filePath = path.join(boardPath(root, manifest.board_id), "index.html");
255
+ return {
256
+ status,
257
+ board_id: manifest.board_id,
258
+ file_path: filePath,
259
+ file_url: pathToFileURL(filePath).href,
260
+ card_count: manifest.card_count,
261
+ remote_image_count: manifest.remote_image_count,
262
+ created_at: manifest.created_at,
263
+ };
264
+ }
265
+
266
+ async function cleanupBoards(root, currentBoardId, now) {
267
+ const boardsRoot = localRecipeBoardsPath(root);
268
+ const rootRealPath = await realpath(boardsRoot);
269
+ const candidates = [];
270
+ const invalid = [];
271
+ for (const entry of await readdir(boardsRoot, { withFileTypes: true })) {
272
+ if (!entry.isDirectory() || !BOARD_ID_PATTERN.test(entry.name) || entry.name === currentBoardId) continue;
273
+ const directory = path.join(boardsRoot, entry.name);
274
+ const directoryStat = await lstat(directory);
275
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) continue;
276
+ const manifest = parseManifest(await readBoundedJson(path.join(directory, "manifest.json"), MAX_MANIFEST_BYTES), entry.name);
277
+ if (manifest === null) {
278
+ invalid.push({ directory });
279
+ continue;
280
+ }
281
+ try {
282
+ await assertDurableBoardFile(path.join(directory, "index.html"), manifest.html_sha256);
283
+ candidates.push({ directory, manifest });
284
+ } catch (error) {
285
+ if (error?.code !== "ENOENT"
286
+ && !(error instanceof LocalHouseholdError && error.code === "CORRUPT_LOCAL_RECIPE_BOARD")) {
287
+ throw error;
288
+ }
289
+ invalid.push({ directory });
290
+ }
291
+ }
292
+ candidates.sort((left, right) => Date.parse(right.manifest.created_at) - Date.parse(left.manifest.created_at));
293
+ const removable = [
294
+ ...invalid,
295
+ ...candidates.filter(({ manifest }, index) =>
296
+ index >= MAX_RETAINED_BOARDS - 1 || now.getTime() - Date.parse(manifest.created_at) > BOARD_RETENTION_MS),
297
+ ];
298
+ for (const { directory } of removable) {
299
+ const parent = path.dirname(await realpath(directory));
300
+ if (parent !== rootRealPath) fail("UNSAFE_LOCAL_PATH", "generated recipe board escaped its private directory");
301
+ await rm(directory, { recursive: true, force: false });
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Creates one deterministic, private, static recipe-board snapshot.
307
+ *
308
+ * The renderer has no network or browser authority. A manifest written after
309
+ * the HTML is the durable commit marker for exact retry and changed-key
310
+ * conflict detection.
311
+ */
312
+ export async function createLocalRecipeBoard(root, input, now = new Date()) {
313
+ const request = parseInput(input);
314
+ const fingerprint = createHash("sha256").update(JSON.stringify(request)).digest("hex");
315
+ const boardId = `lrb_${createHash("sha256").update(`recipe-board:${request.idempotency_key}`).digest("hex").slice(0, 32)}`;
316
+ const boardsRoot = localRecipeBoardsPath(root);
317
+ await ensurePrivateDirectory(root, boardsRoot);
318
+ const lock = await acquireLocalLock(boardsRoot, now, {
319
+ lockName: ".recipe-boards.lock",
320
+ waitForLiveWriter: true,
321
+ });
322
+ try {
323
+ const directory = boardPath(root, boardId);
324
+ await ensurePrivateDirectory(root, directory);
325
+ const manifestPath = path.join(directory, "manifest.json");
326
+ const existing = parseManifest(await readBoundedJson(manifestPath, MAX_MANIFEST_BYTES), boardId);
327
+ if (existing !== null) {
328
+ if (existing.fingerprint !== fingerprint) fail("IDEMPOTENCY_CONFLICT", "idempotency_key was already used for different recipe-board input");
329
+ await assertDurableBoardFile(path.join(directory, "index.html"), existing.html_sha256);
330
+ await cleanupBoards(root, boardId, now);
331
+ return resultFromManifest(root, existing, "replayed");
332
+ }
333
+ try {
334
+ await lstat(manifestPath);
335
+ fail("CORRUPT_LOCAL_RECIPE_BOARD", "recipe board manifest is invalid");
336
+ } catch (error) {
337
+ if (error?.code !== "ENOENT") throw error;
338
+ }
339
+
340
+ const html = renderBoard(request);
341
+ const manifest = {
342
+ schema_version: 1,
343
+ board_id: boardId,
344
+ idempotency_key: request.idempotency_key,
345
+ fingerprint,
346
+ html_sha256: createHash("sha256").update(html).digest("hex"),
347
+ card_count: request.cards.length,
348
+ remote_image_count: request.cards.filter(({ image_url: imageUrl }) => imageUrl !== null).length,
349
+ created_at: now.toISOString(),
350
+ };
351
+ await writePrivateFile(root, path.join(directory, "index.html"), html, MAX_HTML_BYTES, "recipe board HTML");
352
+ await writePrivateFile(root, manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, MAX_MANIFEST_BYTES, "recipe board manifest");
353
+ await cleanupBoards(root, boardId, now);
354
+ return resultFromManifest(root, manifest, "created");
355
+ } finally {
356
+ await releaseLocalLock(lock);
357
+ }
358
+ }
@@ -0,0 +1,61 @@
1
+ import { execFile } from "node:child_process";
2
+ import { lstat, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import { LocalHouseholdError } from "./local-household.mjs";
8
+
9
+ const executeFile = promisify(execFile);
10
+ const RUNNER_LABEL = "com.fullwell.local-runner";
11
+
12
+ function processExitCode(error) {
13
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "number"
14
+ ? error.code
15
+ : null;
16
+ }
17
+
18
+ async function existsAsFile(filePath) {
19
+ try {
20
+ const metadata = await lstat(filePath);
21
+ if (!metadata.isFile() && !metadata.isSymbolicLink()) {
22
+ throw new LocalHouseholdError("UNSAFE_LOCAL_PATH", "Fullwell runner definition is not a regular file");
23
+ }
24
+ return true;
25
+ } catch (error) {
26
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false;
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ export async function stopLocalWhatsAppRunner({
32
+ platform = process.platform,
33
+ home = homedir(),
34
+ uid = process.getuid?.() ?? process.geteuid?.() ?? 0,
35
+ execute = async (file, args) => await executeFile(file, args, { encoding: "utf8", maxBuffer: 64 * 1024 }),
36
+ } = {}) {
37
+ if (platform !== "darwin") {
38
+ throw new LocalHouseholdError("RUNNER_CONTROL_UNAVAILABLE", "The Fullwell WhatsApp runner can only be stopped on macOS");
39
+ }
40
+ const service = `gui/${uid}/${RUNNER_LABEL}`;
41
+ const plistPath = path.join(home, "Library", "LaunchAgents", `${RUNNER_LABEL}.plist`);
42
+ const definitionExisted = await existsAsFile(plistPath);
43
+ try {
44
+ await execute("/bin/launchctl", ["bootout", service]);
45
+ } catch (error) {
46
+ if (processExitCode(error) === null || processExitCode(error) === 0) throw error;
47
+ }
48
+ try {
49
+ await execute("/bin/launchctl", ["print", service]);
50
+ throw new LocalHouseholdError("RUNNER_STOP_FAILED", "The Fullwell WhatsApp runner is still running");
51
+ } catch (error) {
52
+ if (error instanceof LocalHouseholdError) throw error;
53
+ if (processExitCode(error) === null || processExitCode(error) === 0) throw error;
54
+ }
55
+ await rm(plistPath, { force: true });
56
+ return {
57
+ status: definitionExisted ? "stopped" : "already_stopped",
58
+ connection_preserved: true,
59
+ restart_command: "fullwell-runner install",
60
+ };
61
+ }