pagecast 0.1.0 → 0.1.2

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/src/server.js CHANGED
@@ -26,6 +26,17 @@ export const DEFAULT_CLOUDFLARE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
26
26
  export const DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS = 60 * 1000;
27
27
  export const CLOUDFLARE_OAUTH_SCOPES = ["account:read", "user:read", "pages:write"];
28
28
 
29
+ // Feedback provisioning deploys a Worker + KV, which the base publishing scopes
30
+ // don't permit. These elevate the grant only when the user opts into feedback,
31
+ // so publishing never has to request Workers/KV access up front.
32
+ export const FEEDBACK_OAUTH_SCOPES = [
33
+ "account:read",
34
+ "user:read",
35
+ "pages:write",
36
+ "workers_scripts:write",
37
+ "workers_kv:write"
38
+ ];
39
+
29
40
  const MIME_TYPES = new Map([
30
41
  [".html", "text/html; charset=utf-8"],
31
42
  [".htm", "text/html; charset=utf-8"],
@@ -228,6 +239,62 @@ function cleanCommandOutput(output) {
228
239
  .trim();
229
240
  }
230
241
 
242
+ // --- Feedback Worker provisioning: parse wrangler outputs --------------------
243
+
244
+ // A KV namespace id is a 32-char hex string. `wrangler kv namespace create`
245
+ // prints a TOML/JSON snippet containing `id = "<hex>"` / `"id": "<hex>"`.
246
+ export function parseKvNamespaceId(output) {
247
+ const text = stripAnsi(output || "");
248
+ const match = text.match(/(?:id\s*=\s*|"id"\s*:\s*)"([0-9a-f]{32})"/i);
249
+ return match ? match[1].toLowerCase() : "";
250
+ }
251
+
252
+ // `wrangler kv namespace list` prints a JSON array of { id, title }. Reuse an
253
+ // existing namespace by title so re-running setup is idempotent.
254
+ export function findKvNamespaceId(output, title) {
255
+ const text = stripAnsi(output || "");
256
+ try {
257
+ const start = text.indexOf("[");
258
+ const end = text.lastIndexOf("]");
259
+ if (start >= 0 && end > start) {
260
+ const list = JSON.parse(text.slice(start, end + 1));
261
+ const hit = list.find((entry) => entry && entry.title === title);
262
+ if (hit && /^[0-9a-f]{32}$/i.test(hit.id || "")) {
263
+ return String(hit.id).toLowerCase();
264
+ }
265
+ }
266
+ } catch {
267
+ // Non-JSON or unexpected shape — treat as "not found" and create one.
268
+ }
269
+ return "";
270
+ }
271
+
272
+ // The deployed Worker's public origin, e.g. https://name.sub.workers.dev.
273
+ export function parseWorkerDevUrl(output) {
274
+ const text = stripAnsi(output || "");
275
+ const match = text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev/i);
276
+ return match ? match[0].toLowerCase() : "";
277
+ }
278
+
279
+ // Normalize the persisted feedback (reactions + analytics) settings. Returns
280
+ // null until the feedback Worker has been provisioned, so callers can treat the
281
+ // whole feature as off by checking for a truthy `feedback`.
282
+ function normalizeFeedback(feedback) {
283
+ if (!feedback || typeof feedback !== "object") {
284
+ return null;
285
+ }
286
+ const url = String(feedback.url || "").trim().replace(/\/+$/, "");
287
+ if (!/^https:\/\/[^\s/]+/i.test(url)) {
288
+ return null;
289
+ }
290
+ return {
291
+ url,
292
+ statsToken: String(feedback.statsToken || ""),
293
+ workerName: String(feedback.workerName || ""),
294
+ kvId: String(feedback.kvId || "")
295
+ };
296
+ }
297
+
231
298
  function normalizeConfig(config = {}) {
232
299
  const projectName = normalizePagesProjectName(
233
300
  config.pages?.projectName || DEFAULT_PAGES_PROJECT_NAME
@@ -242,7 +309,8 @@ function normalizeConfig(config = {}) {
242
309
  accountName,
243
310
  branch: DEFAULT_PAGES_BRANCH,
244
311
  baseUrl: pagesBaseUrl(projectName)
245
- }
312
+ },
313
+ feedback: normalizeFeedback(config.feedback)
246
314
  };
247
315
  }
248
316
 
@@ -889,7 +957,19 @@ export function createConfigStore({ dataDir = path.join(PROJECT_ROOT, ".pagecast
889
957
  projectName: projectName === undefined ? config.pages.projectName : projectName,
890
958
  accountId: nextAccountId,
891
959
  accountName: nextAccountName
892
- }
960
+ },
961
+ // Preserve feedback config — a pages update (e.g. persisting the account on
962
+ // publish) must not wipe an already-provisioned feedback Worker.
963
+ feedback: config.feedback
964
+ });
965
+ await save();
966
+ return get();
967
+ }
968
+
969
+ async function updateFeedback(feedback) {
970
+ config = normalizeConfig({
971
+ pages: config.pages,
972
+ feedback: feedback === null ? null : { ...(config.feedback || {}), ...feedback }
893
973
  });
894
974
  await save();
895
975
  return get();
@@ -899,6 +979,7 @@ export function createConfigStore({ dataDir = path.join(PROJECT_ROOT, ".pagecast
899
979
  init,
900
980
  get,
901
981
  updatePages,
982
+ updateFeedback,
902
983
  configPath
903
984
  };
904
985
  }
@@ -923,11 +1004,41 @@ export function createDeployQueue() {
923
1004
  return { enqueue };
924
1005
  }
925
1006
 
1007
+ // Insert the feedback widget into a published HTML document. The widget (served
1008
+ // by the user's feedback Worker) beacons a view and renders the reactions bar.
1009
+ // Injected just before </body> so it loads after page content. `url` is the
1010
+ // Worker origin and `slug` keys this page's stats. Returns the HTML unchanged
1011
+ // when feedback is not configured. Pure + exported for testing.
1012
+ export function injectFeedbackWidget(html, { url, slug } = {}) {
1013
+ const baseUrl = String(url || "").trim().replace(/\/+$/, "");
1014
+ const pageSlug = String(slug || "").trim();
1015
+ if (!baseUrl || !pageSlug) {
1016
+ return html;
1017
+ }
1018
+ const esc = (value) =>
1019
+ String(value)
1020
+ .replace(/&/g, "&amp;")
1021
+ .replace(/"/g, "&quot;")
1022
+ .replace(/</g, "&lt;")
1023
+ .replace(/>/g, "&gt;");
1024
+ const tag =
1025
+ `<script src="${esc(`${baseUrl}/widget.js`)}" data-slug="${esc(pageSlug)}" defer></script>`;
1026
+ // Avoid double-injecting if the document already carries the widget.
1027
+ if (html.includes(`data-slug="${esc(pageSlug)}"`) && html.includes("/widget.js")) {
1028
+ return html;
1029
+ }
1030
+ if (/<\/body>/i.test(html)) {
1031
+ return html.replace(/<\/body>/i, `${tag}\n</body>`);
1032
+ }
1033
+ return `${html}\n${tag}\n`;
1034
+ }
1035
+
926
1036
  export function createCloudflarePagesPublisher({
927
1037
  dataDir = path.join(PROJECT_ROOT, ".pagecast"),
928
1038
  spawnImpl = spawn,
929
1039
  timeoutMs = 180000,
930
- getRedirects = () => []
1040
+ getRedirects = () => [],
1041
+ getFeedback = () => null
931
1042
  } = {}) {
932
1043
  const siteRoot = path.join(dataDir, "pages-site");
933
1044
  const deployRoot = path.join(dataDir, "pages-deploy");
@@ -966,18 +1077,28 @@ export function createCloudflarePagesPublisher({
966
1077
  }
967
1078
 
968
1079
  async function stagePublication(report, publication) {
969
- const destinationRoot = publicationDir(publication.slug || publication.token);
1080
+ const slug = publication.slug || publication.token;
1081
+ const destinationRoot = publicationDir(slug);
970
1082
  const sourceRoot = publishSourceFor(report);
971
1083
  await copyPublicTree(sourceRoot, destinationRoot);
1084
+
1085
+ const indexPath = path.join(destinationRoot, "index.html");
1086
+ let html;
972
1087
  if (isMarkdownFileName(report.entryFile)) {
973
1088
  // Render the raw markdown entry to real HTML so the published Cloudflare
974
1089
  // site serves a proper document; sibling assets were copied above.
975
1090
  const markdown = await fs.readFile(path.join(sourceRoot, report.entryFile), "utf8");
976
- const html = markdownToHtml(markdown, { title: report.name });
977
- await fs.writeFile(path.join(destinationRoot, "index.html"), html, "utf8");
1091
+ html = markdownToHtml(markdown, { title: report.name });
978
1092
  } else {
979
- await fs.copyFile(path.join(sourceRoot, report.entryFile), path.join(destinationRoot, "index.html"));
1093
+ html = await fs.readFile(path.join(sourceRoot, report.entryFile), "utf8");
980
1094
  }
1095
+
1096
+ // Inject the reactions + analytics widget when feedback is provisioned.
1097
+ const feedback = getFeedback();
1098
+ if (feedback?.url) {
1099
+ html = injectFeedbackWidget(html, { url: feedback.url, slug });
1100
+ }
1101
+ await fs.writeFile(indexPath, html, "utf8");
981
1102
  }
982
1103
 
983
1104
  async function removePublication(slug) {
@@ -1258,8 +1379,8 @@ export function createCloudflareAuthManager({
1258
1379
  // connect/refresh time; the cache is invalidated whenever login state changes.
1259
1380
  let sessionCache = null;
1260
1381
 
1261
- async function login() {
1262
- const scopedArgs = CLOUDFLARE_OAUTH_SCOPES.flatMap((scope) => ["--scopes", scope]);
1382
+ async function login(scopes = CLOUDFLARE_OAUTH_SCOPES) {
1383
+ const scopedArgs = scopes.flatMap((scope) => ["--scopes", scope]);
1263
1384
  await runWrangler(["login", ...scopedArgs], loginTimeoutMs);
1264
1385
  sessionCache = null;
1265
1386
  }
@@ -1342,6 +1463,98 @@ export function createCloudflareAuthManager({
1342
1463
  return name;
1343
1464
  }
1344
1465
 
1466
+ // Provision the feedback Worker: reuse-or-create a KV namespace, stage the
1467
+ // worker + a generated wrangler.toml, and deploy it to the account's
1468
+ // workers.dev. Returns { url, kvId, workerName, statsToken }. Side-effecting
1469
+ // (creates real Cloudflare resources) — only run on explicit user action.
1470
+ async function setupFeedback({
1471
+ accountId = "",
1472
+ workerName = "pagecast-feedback",
1473
+ workerSource = "",
1474
+ statsToken = "",
1475
+ deployDir,
1476
+ timeoutMs = 120000
1477
+ } = {}) {
1478
+ if (!workerSource) {
1479
+ throw appError("Feedback Worker source was not found in the package.", 500);
1480
+ }
1481
+ if (!deployDir) {
1482
+ throw appError("A deploy directory is required to set up feedback.", 500);
1483
+ }
1484
+ const env = accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {};
1485
+ const kvTitle = `${workerName}-store`;
1486
+
1487
+ const provision = async () => {
1488
+ // 1. Reuse or create the KV namespace.
1489
+ let kvId = "";
1490
+ try {
1491
+ const listOut = await runWrangler(["kv", "namespace", "list"], timeoutMs, env);
1492
+ kvId = findKvNamespaceId(listOut, kvTitle);
1493
+ } catch {
1494
+ // Listing isn't available/authorized — fall through to create.
1495
+ }
1496
+ if (!kvId) {
1497
+ const createOut = await runWrangler(
1498
+ ["kv", "namespace", "create", kvTitle],
1499
+ timeoutMs,
1500
+ env
1501
+ );
1502
+ kvId = parseKvNamespaceId(createOut);
1503
+ }
1504
+ if (!kvId) {
1505
+ throw appError("Could not create the feedback KV namespace.", 502);
1506
+ }
1507
+
1508
+ // 2. Stage worker.js + a generated wrangler.toml in a clean temp dir.
1509
+ await fs.rm(deployDir, { recursive: true, force: true });
1510
+ await fs.mkdir(deployDir, { recursive: true });
1511
+ await fs.writeFile(path.join(deployDir, "worker.js"), workerSource, "utf8");
1512
+ const toml = [
1513
+ `name = "${workerName}"`,
1514
+ `main = "worker.js"`,
1515
+ `compatibility_date = "2024-09-01"`,
1516
+ `workers_dev = true`,
1517
+ ``,
1518
+ `[[kv_namespaces]]`,
1519
+ `binding = "PAGECAST_FEEDBACK"`,
1520
+ `id = "${kvId}"`,
1521
+ ``,
1522
+ `[vars]`,
1523
+ `PAGECAST_STATS_TOKEN = "${statsToken}"`,
1524
+ ``
1525
+ ].join("\n");
1526
+ await fs.writeFile(path.join(deployDir, "wrangler.toml"), toml, "utf8");
1527
+
1528
+ // 3. Deploy. Wrangler resolves `main` relative to the config file's dir.
1529
+ const deployOut = await runWrangler(
1530
+ ["deploy", "--config", path.join(deployDir, "wrangler.toml")],
1531
+ timeoutMs,
1532
+ env
1533
+ );
1534
+ const url = parseWorkerDevUrl(deployOut);
1535
+ if (!url) {
1536
+ throw appError(
1537
+ "Feedback Worker deployed but no workers.dev URL was returned. Enable a workers.dev subdomain in your Cloudflare dashboard, then retry.",
1538
+ 502
1539
+ );
1540
+ }
1541
+ return { url, kvId, workerName, statsToken };
1542
+ };
1543
+
1544
+ try {
1545
+ return await provision();
1546
+ } catch (error) {
1547
+ // The base publishing OAuth lacks Workers/KV permission, surfacing as a
1548
+ // Cloudflare "Authentication error [code: 10000]". Elevate the grant to the
1549
+ // feedback scopes once, then retry. (No-op if a token is already broad.)
1550
+ if (/code:\s*10000|authentication error/i.test(stripAnsi(error.message || ""))) {
1551
+ await login(FEEDBACK_OAUTH_SCOPES);
1552
+ return await provision();
1553
+ }
1554
+ throw error;
1555
+ }
1556
+ }
1557
+
1345
1558
  function cachedSession() {
1346
1559
  return sessionCache ? sessionCache.value : { loggedIn: false, accounts: [] };
1347
1560
  }
@@ -1369,6 +1582,7 @@ export function createCloudflareAuthManager({
1369
1582
  loginAndListProjects,
1370
1583
  whoami,
1371
1584
  ensureProject,
1585
+ setupFeedback,
1372
1586
  cachedSession,
1373
1587
  refreshSession,
1374
1588
  invalidateSession
@@ -2034,6 +2248,28 @@ export function createReportStore({
2034
2248
  return { statusCode: 404, message: "Report asset was not found." };
2035
2249
  }
2036
2250
 
2251
+ // Symlink-escape guard for sibling assets. A symlink inside the report folder
2252
+ // (e.g. leak.txt -> /etc/passwd) passes the lexical isPathInside check above,
2253
+ // but fs.stat follows it, so without this a crafted symlink could serve files
2254
+ // outside the report root. Resolve the real path and re-verify containment.
2255
+ // (This mirrors the symlink rejection already enforced when staging snapshots
2256
+ // for Cloudflare.) The entry file itself is exempt: a `path` report can point
2257
+ // at a file the user deliberately chose, including a symlink.
2258
+ if (relativeAssetPath !== "") {
2259
+ try {
2260
+ const realRoot = await fs.realpath(rootDir);
2261
+ const realTarget = await fs.realpath(targetPath);
2262
+ if (!isPathInside(realRoot, realTarget)) {
2263
+ return { statusCode: 403, message: "Asset path is not allowed." };
2264
+ }
2265
+ } catch (error) {
2266
+ if (error.code === "ENOENT") {
2267
+ return { statusCode: 404, message: "Report asset was not found." };
2268
+ }
2269
+ throw error;
2270
+ }
2271
+ }
2272
+
2037
2273
  // When the requested asset IS a markdown entry, render it to HTML in memory
2038
2274
  // so the local preview serves a real document. Sibling assets (images, css)
2039
2275
  // continue to resolve as files. Published snapshots are rendered on disk by
@@ -2960,6 +3196,46 @@ export function createPublicHandler({ store }) {
2960
3196
  };
2961
3197
  }
2962
3198
 
3199
+ // DNS-rebinding defense for the admin server. The admin API is unauthenticated
3200
+ // and can run shell (folder build commands), so it must only answer requests
3201
+ // addressed to a loopback host. A malicious web page that rebinds its own domain
3202
+ // to 127.0.0.1 still sends *its* Host header (e.g. "evil.example"), which fails
3203
+ // this check, while the real admin UI on 127.0.0.1/localhost passes.
3204
+ export function isLoopbackHostHeader(hostHeader, bindHost) {
3205
+ if (!hostHeader) {
3206
+ // No Host header (HTTP/1.0, some internal callers) — the request cannot have
3207
+ // come from a rebound browser origin, so allow it.
3208
+ return true;
3209
+ }
3210
+ let hostname = String(hostHeader);
3211
+ const ipv6 = /^\[([^\]]+)\](?::\d+)?$/.exec(hostname);
3212
+ if (ipv6) {
3213
+ // Bracketed IPv6: "[::1]:4173" -> "::1".
3214
+ hostname = ipv6[1];
3215
+ } else if ((hostname.match(/:/g) || []).length <= 1) {
3216
+ // "host:port" or bare "host" — strip a single trailing ":port" only. A value
3217
+ // with more than one colon is a bare IPv6 literal (e.g. "::1") and is left
3218
+ // intact rather than truncated at its first colon.
3219
+ const colon = hostname.lastIndexOf(":");
3220
+ if (colon > -1) {
3221
+ hostname = hostname.slice(0, colon);
3222
+ }
3223
+ }
3224
+ hostname = hostname.toLowerCase();
3225
+ if (hostname === "localhost" || hostname === "::1") {
3226
+ return true;
3227
+ }
3228
+ // The entire 127.0.0.0/8 block is loopback.
3229
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
3230
+ return true;
3231
+ }
3232
+ // An explicitly configured non-default bind host is trusted by definition.
3233
+ if (bindHost && hostname === String(bindHost).toLowerCase()) {
3234
+ return true;
3235
+ }
3236
+ return false;
3237
+ }
3238
+
2963
3239
  export function createAdminHandler({
2964
3240
  store,
2965
3241
  configStore,
@@ -2970,10 +3246,19 @@ export function createAdminHandler({
2970
3246
  getLocalPublicBaseUrl,
2971
3247
  tunnelManager,
2972
3248
  deployQueue,
2973
- watchManager
3249
+ watchManager,
3250
+ bindHost = DEFAULT_HOST
2974
3251
  }) {
2975
3252
  return async function adminHandler(req, res) {
2976
3253
  try {
3254
+ if (!isLoopbackHostHeader(req.headers.host, bindHost)) {
3255
+ sendText(
3256
+ res,
3257
+ 403,
3258
+ "Forbidden: the Pagecast admin server only accepts loopback (localhost) requests."
3259
+ );
3260
+ return;
3261
+ }
2977
3262
  const url = new URL(req.url, `http://${req.headers.host || DEFAULT_HOST}`);
2978
3263
 
2979
3264
  if (url.pathname.startsWith("/api/")) {
@@ -3094,6 +3379,60 @@ async function handleApi(
3094
3379
  return;
3095
3380
  }
3096
3381
 
3382
+ // Provision (or re-provision) the feedback Worker + KV on the user's account.
3383
+ // Creates real Cloudflare resources, so it only runs on this explicit action.
3384
+ if (url.pathname === "/api/feedback/setup" && req.method === "POST") {
3385
+ const body = await readJsonBody(req);
3386
+ const pages = configStore.get().pages;
3387
+ const accountId = normalizeAccountId(body.accountId || pages.accountId || "");
3388
+ const workerPath = path.join(PROJECT_ROOT, "feedback", "worker.js");
3389
+ let workerSource;
3390
+ try {
3391
+ workerSource = await fs.readFile(workerPath, "utf8");
3392
+ } catch {
3393
+ sendError(res, appError("Feedback Worker source not found in the package.", 500));
3394
+ return;
3395
+ }
3396
+ const existing = configStore.get().feedback;
3397
+ const statsToken = existing?.statsToken || randomBytes(24).toString("hex");
3398
+ const dataDir = path.dirname(configStore.configPath);
3399
+ try {
3400
+ const result = await cloudflareAuth.setupFeedback({
3401
+ accountId,
3402
+ workerSource,
3403
+ statsToken,
3404
+ deployDir: path.join(dataDir, "feedback-deploy")
3405
+ });
3406
+ const config = await configStore.updateFeedback(result);
3407
+ sendJson(res, 200, { config, feedback: config.feedback });
3408
+ } catch (error) {
3409
+ sendError(res, error);
3410
+ }
3411
+ return;
3412
+ }
3413
+
3414
+ // Read aggregate stats for a published page back from the feedback Worker.
3415
+ // Proxied through the local server so the stats token never reaches the UI.
3416
+ if (url.pathname === "/api/feedback/stats" && req.method === "GET") {
3417
+ const feedback = configStore.get().feedback;
3418
+ if (!feedback?.url) {
3419
+ sendJson(res, 200, { ok: true, configured: false, stats: null });
3420
+ return;
3421
+ }
3422
+ const slug = url.searchParams.get("slug") || "";
3423
+ const statsUrl =
3424
+ `${feedback.url}/api/v1/stats?slug=${encodeURIComponent(slug)}` +
3425
+ `&token=${encodeURIComponent(feedback.statsToken)}`;
3426
+ try {
3427
+ const response = await fetch(statsUrl);
3428
+ const data = await response.json().catch(() => ({}));
3429
+ sendJson(res, 200, { ok: response.ok, configured: true, ...data });
3430
+ } catch {
3431
+ sendError(res, appError("Could not reach the feedback service.", 502));
3432
+ }
3433
+ return;
3434
+ }
3435
+
3097
3436
  if (url.pathname === "/api/cloudflare/login" && req.method === "POST") {
3098
3437
  await readJsonBody(req);
3099
3438
  await cloudflareAuth.login();
@@ -3522,14 +3861,20 @@ export async function startServers({
3522
3861
  dataDir,
3523
3862
  spawnImpl: pagesDeploySpawnImpl,
3524
3863
  timeoutMs: pagesDeployTimeoutMs,
3525
- getRedirects: () => store.listRedirects()
3864
+ getRedirects: () => store.listRedirects(),
3865
+ getFeedback: () => configStore.get().feedback
3526
3866
  });
3527
3867
  const deployQueue = createDeployQueue();
3528
3868
  const watchManager = createWatchManager({
3529
3869
  store,
3530
3870
  pagesPublisher,
3531
3871
  configStore,
3532
- deployQueue
3872
+ deployQueue,
3873
+ // Auto-sync runs in the background; surface failures (e.g. expired Cloudflare
3874
+ // auth) instead of swallowing them, so a silently-broken watch is visible.
3875
+ onError: (error) => {
3876
+ console.warn(`Pagecast auto-sync failed: ${error?.message || error}`);
3877
+ }
3533
3878
  });
3534
3879
  for (const report of store.listAutoSyncReports()) {
3535
3880
  watchManager.register(report.id);
@@ -3557,7 +3902,8 @@ export async function startServers({
3557
3902
  getLocalPublicBaseUrl: () => localPublicBaseUrl,
3558
3903
  tunnelManager,
3559
3904
  deployQueue,
3560
- watchManager
3905
+ watchManager,
3906
+ bindHost: host
3561
3907
  })
3562
3908
  );
3563
3909
 
@@ -3608,7 +3954,10 @@ async function createHeadlessCloudflareContext({
3608
3954
  const pagesPublisher = createCloudflarePagesPublisher({
3609
3955
  dataDir,
3610
3956
  spawnImpl: pagesDeploySpawnImpl,
3611
- timeoutMs: pagesDeployTimeoutMs
3957
+ timeoutMs: pagesDeployTimeoutMs,
3958
+ // Headless/CLI publishes (incl. the agent skill's `npx pagecast publish`)
3959
+ // must inject the feedback widget too, not just the running app.
3960
+ getFeedback: () => configStore.get().feedback
3612
3961
  });
3613
3962
  return { configStore, cloudflareAuth, pagesPublisher };
3614
3963
  }
@@ -3701,6 +4050,45 @@ export async function setupCloudflarePages({
3701
4050
  };
3702
4051
  }
3703
4052
 
4053
+ // Provision the feedback Worker + KV on the user's account and persist the
4054
+ // resulting config. Reuses an existing stats token/namespace on re-run.
4055
+ export async function setupCloudflareFeedback({
4056
+ accountId,
4057
+ dataDir = path.join(PROJECT_ROOT, ".pagecast"),
4058
+ cloudflareAuthSpawnImpl = spawn,
4059
+ cloudflareListTimeoutMs = DEFAULT_CLOUDFLARE_LIST_TIMEOUT_MS,
4060
+ feedbackTimeoutMs = 120000
4061
+ } = {}) {
4062
+ const { configStore, cloudflareAuth } = await createHeadlessCloudflareContext({
4063
+ dataDir,
4064
+ cloudflareAuthSpawnImpl,
4065
+ cloudflareListTimeoutMs
4066
+ });
4067
+ const pages = configStore.get().pages;
4068
+ const resolvedAccountId = normalizeAccountId(accountId || pages.accountId || "");
4069
+
4070
+ const workerPath = path.join(PROJECT_ROOT, "feedback", "worker.js");
4071
+ let workerSource;
4072
+ try {
4073
+ workerSource = await fs.readFile(workerPath, "utf8");
4074
+ } catch {
4075
+ throw appError("Feedback Worker source not found in the package.", 500);
4076
+ }
4077
+
4078
+ const existing = configStore.get().feedback;
4079
+ const statsToken = existing?.statsToken || randomBytes(24).toString("hex");
4080
+
4081
+ const result = await cloudflareAuth.setupFeedback({
4082
+ accountId: resolvedAccountId,
4083
+ workerSource,
4084
+ statsToken,
4085
+ deployDir: path.join(dataDir, "feedback-deploy")
4086
+ });
4087
+
4088
+ const config = await configStore.updateFeedback(result);
4089
+ return { config, feedback: config.feedback };
4090
+ }
4091
+
3704
4092
  export async function getCloudflarePagesStatus({
3705
4093
  dataDir = path.join(PROJECT_ROOT, ".pagecast"),
3706
4094
  cloudflareAuthSpawnImpl = spawn,
@@ -1 +0,0 @@
1
- import{r as O,g as S}from"./motion-vendor-CDgKLzlB.js";var d={exports:{}},n={};var l;function R(){if(l)return n;l=1;var u=O();function g(e){var r="https://react.dev/errors/"+e;if(1<arguments.length){r+="?args[]="+encodeURIComponent(arguments[1]);for(var t=2;t<arguments.length;t++)r+="&args[]="+encodeURIComponent(arguments[t])}return"Minified React error #"+e+"; visit "+r+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function a(){}var i={d:{f:a,r:function(){throw Error(g(522))},D:a,C:a,L:a,m:a,X:a,S:a,M:a},p:0,findDOMNode:null},m=Symbol.for("react.portal");function v(e,r,t){var c=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:m,key:c==null?null:""+c,children:e,containerInfo:r,implementation:t}}var f=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function s(e,r){if(e==="font")return"";if(typeof r=="string")return r==="use-credentials"?r:""}return n.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=i,n.createPortal=function(e,r){var t=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!r||r.nodeType!==1&&r.nodeType!==9&&r.nodeType!==11)throw Error(g(299));return v(e,r,null,t)},n.flushSync=function(e){var r=f.T,t=i.p;try{if(f.T=null,i.p=2,e)return e()}finally{f.T=r,i.p=t,i.d.f()}},n.preconnect=function(e,r){typeof e=="string"&&(r?(r=r.crossOrigin,r=typeof r=="string"?r==="use-credentials"?r:"":void 0):r=null,i.d.C(e,r))},n.prefetchDNS=function(e){typeof e=="string"&&i.d.D(e)},n.preinit=function(e,r){if(typeof e=="string"&&r&&typeof r.as=="string"){var t=r.as,c=s(t,r.crossOrigin),y=typeof r.integrity=="string"?r.integrity:void 0,o=typeof r.fetchPriority=="string"?r.fetchPriority:void 0;t==="style"?i.d.S(e,typeof r.precedence=="string"?r.precedence:void 0,{crossOrigin:c,integrity:y,fetchPriority:o}):t==="script"&&i.d.X(e,{crossOrigin:c,integrity:y,fetchPriority:o,nonce:typeof r.nonce=="string"?r.nonce:void 0})}},n.preinitModule=function(e,r){if(typeof e=="string")if(typeof r=="object"&&r!==null){if(r.as==null||r.as==="script"){var t=s(r.as,r.crossOrigin);i.d.M(e,{crossOrigin:t,integrity:typeof r.integrity=="string"?r.integrity:void 0,nonce:typeof r.nonce=="string"?r.nonce:void 0})}}else r==null&&i.d.M(e)},n.preload=function(e,r){if(typeof e=="string"&&typeof r=="object"&&r!==null&&typeof r.as=="string"){var t=r.as,c=s(t,r.crossOrigin);i.d.L(e,t,{crossOrigin:c,integrity:typeof r.integrity=="string"?r.integrity:void 0,nonce:typeof r.nonce=="string"?r.nonce:void 0,type:typeof r.type=="string"?r.type:void 0,fetchPriority:typeof r.fetchPriority=="string"?r.fetchPriority:void 0,referrerPolicy:typeof r.referrerPolicy=="string"?r.referrerPolicy:void 0,imageSrcSet:typeof r.imageSrcSet=="string"?r.imageSrcSet:void 0,imageSizes:typeof r.imageSizes=="string"?r.imageSizes:void 0,media:typeof r.media=="string"?r.media:void 0})}},n.preloadModule=function(e,r){if(typeof e=="string")if(r){var t=s(r.as,r.crossOrigin);i.d.m(e,{as:typeof r.as=="string"&&r.as!=="script"?r.as:void 0,crossOrigin:t,integrity:typeof r.integrity=="string"?r.integrity:void 0})}else i.d.m(e)},n.requestFormReset=function(e){i.d.r(e)},n.unstable_batchedUpdates=function(e,r){return e(r)},n.useFormState=function(e,r,t){return f.H.useFormState(e,r,t)},n.useFormStatus=function(){return f.H.useHostTransitionStatus()},n.version="19.2.6",n}var _;function E(){if(_)return d.exports;_=1;function u(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(u)}catch(g){console.error(g)}}return u(),d.exports=R(),d.exports}var T=E();const h=S(T);export{h as R,T as a,E as r};