sproutkit-code 1.6.0 → 1.7.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.
Files changed (2) hide show
  1. package/dist/index.js +321 -31
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -225,6 +225,10 @@ var require_db = __commonJS({
225
225
  // redeploys: SESSION_SECRET is regenerated every deploy, so deriving from
226
226
  // it would make the client's self-chosen password undecryptable each time.
227
227
  "auth_enc_key TEXT",
228
+ // Read-only per-site key so `sproutkit report` can read visitor stats
229
+ // WITHOUT the client's password. Means handing the client full ownership
230
+ // doesn't cost the agency its reporting.
231
+ "report_key TEXT",
228
232
  // Reply loop: when we last contacted them, and the latest outcome. Kept on
229
233
  // the lead (as well as contact_log) so `followups` is a single cheap query.
230
234
  "last_contacted_at TEXT",
@@ -31319,6 +31323,7 @@ var require_analytics_runtime = __commonJS({
31319
31323
  Object.defineProperty(exports2, "__esModule", { value: true });
31320
31324
  exports2.TRACK_SNIPPET = exports2.API_STATS_JS = exports2.API_TRACK_JS = void 0;
31321
31325
  exports2.API_TRACK_JS = `import { list, put } from "@vercel/blob";
31326
+ import { createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
31322
31327
 
31323
31328
  export const config = { runtime: "nodejs" };
31324
31329
 
@@ -31333,6 +31338,54 @@ const EVENTS = ["view", "call", "book", "directions", "email", "menu"];
31333
31338
  // the file has to stay small enough to read on every request.
31334
31339
  const MAX_DAYS = 400;
31335
31340
 
31341
+
31342
+ // stats.json lives in a PUBLIC Blob at a non-random path, and the store
31343
+ // hostname leaks through every photo URL on the site \u2014 so the file's URL must
31344
+ // be assumed guessable. Visitor counts are the client's commercial data and the
31345
+ // agency's renewal evidence, so the contents are encrypted with AES-256-GCM
31346
+ // under AUTH_ENC_KEY (a server-side env var). A direct public read yields
31347
+ // opaque bytes instead of the numbers.
31348
+ function statsKey() {
31349
+ const material = process.env.AUTH_ENC_KEY || "";
31350
+ if (!material) return null;
31351
+ return createHash("sha256").update("sproutkit-stats:" + material).digest();
31352
+ }
31353
+
31354
+ function sealStats(obj) {
31355
+ const key = statsKey();
31356
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
31357
+ const iv = randomBytes(12);
31358
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
31359
+ const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
31360
+ return JSON.stringify({
31361
+ v: 1,
31362
+ iv: iv.toString("base64"),
31363
+ tag: cipher.getAuthTag().toString("base64"),
31364
+ ct: ct.toString("base64"),
31365
+ });
31366
+ }
31367
+
31368
+ function openStats(raw) {
31369
+ if (!raw || typeof raw !== "object") return null;
31370
+ // Unencrypted shapes: v0 (no key available) and pre-encryption files that
31371
+ // stored { days } directly. Both must keep working.
31372
+ if (raw.v === 0 && raw.plain) return raw.plain;
31373
+ if (raw.days && raw.v === undefined) return raw;
31374
+ if (raw.v !== 1) return null;
31375
+ const key = statsKey();
31376
+ if (!key) return null;
31377
+ try {
31378
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
31379
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
31380
+ return JSON.parse(
31381
+ Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
31382
+ );
31383
+ } catch {
31384
+ // Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
31385
+ return null;
31386
+ }
31387
+ }
31388
+
31336
31389
  function today() {
31337
31390
  return new Date().toISOString().slice(0, 10);
31338
31391
  }
@@ -31344,7 +31397,7 @@ async function readStats() {
31344
31397
  if (!target) return { days: {} };
31345
31398
  const res = await fetch(target.url, { cache: "no-store" });
31346
31399
  if (!res.ok) return { days: {} };
31347
- const j = await res.json();
31400
+ const j = openStats(await res.json());
31348
31401
  return j && typeof j === "object" && j.days ? j : { days: {} };
31349
31402
  } catch {
31350
31403
  return { days: {} };
@@ -31391,7 +31444,7 @@ export default async function handler(req, res) {
31391
31444
  }
31392
31445
 
31393
31446
  try {
31394
- await put(STATS_KEY, JSON.stringify(stats), {
31447
+ await put(STATS_KEY, sealStats(stats), {
31395
31448
  access: "public",
31396
31449
  addRandomSuffix: false,
31397
31450
  allowOverwrite: true,
@@ -31404,12 +31457,110 @@ export default async function handler(req, res) {
31404
31457
  }
31405
31458
  `;
31406
31459
  exports2.API_STATS_JS = `import { list } from "@vercel/blob";
31407
- import { createHmac, timingSafeEqual } from "node:crypto";
31460
+ import {
31461
+ createHmac,
31462
+ timingSafeEqual,
31463
+ createHash,
31464
+ createCipheriv,
31465
+ createDecipheriv,
31466
+ randomBytes,
31467
+ } from "node:crypto";
31408
31468
 
31409
31469
  export const config = { runtime: "nodejs" };
31410
31470
 
31411
31471
  const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
31412
31472
  const STATS_KEY = PFX + "stats.json";
31473
+ const AUTH_KEY = PFX + "admin-auth.json";
31474
+
31475
+
31476
+ // stats.json lives in a PUBLIC Blob at a non-random path, and the store
31477
+ // hostname leaks through every photo URL on the site \u2014 so the file's URL must
31478
+ // be assumed guessable. Visitor counts are the client's commercial data and the
31479
+ // agency's renewal evidence, so the contents are encrypted with AES-256-GCM
31480
+ // under AUTH_ENC_KEY (a server-side env var). A direct public read yields
31481
+ // opaque bytes instead of the numbers.
31482
+ function statsKey() {
31483
+ const material = process.env.AUTH_ENC_KEY || "";
31484
+ if (!material) return null;
31485
+ return createHash("sha256").update("sproutkit-stats:" + material).digest();
31486
+ }
31487
+
31488
+ function sealStats(obj) {
31489
+ const key = statsKey();
31490
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
31491
+ const iv = randomBytes(12);
31492
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
31493
+ const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
31494
+ return JSON.stringify({
31495
+ v: 1,
31496
+ iv: iv.toString("base64"),
31497
+ tag: cipher.getAuthTag().toString("base64"),
31498
+ ct: ct.toString("base64"),
31499
+ });
31500
+ }
31501
+
31502
+ function openStats(raw) {
31503
+ if (!raw || typeof raw !== "object") return null;
31504
+ // Unencrypted shapes: v0 (no key available) and pre-encryption files that
31505
+ // stored { days } directly. Both must keep working.
31506
+ if (raw.v === 0 && raw.plain) return raw.plain;
31507
+ if (raw.days && raw.v === undefined) return raw;
31508
+ if (raw.v !== 1) return null;
31509
+ const key = statsKey();
31510
+ if (!key) return null;
31511
+ try {
31512
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
31513
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
31514
+ return JSON.parse(
31515
+ Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
31516
+ );
31517
+ } catch {
31518
+ // Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
31519
+ return null;
31520
+ }
31521
+ }
31522
+
31523
+
31524
+ // Tokens carry the credential version they were minted under. A password change
31525
+ // bumps it, so cookies issued earlier stop working \u2014 without this, "I changed my
31526
+ // password" wouldn't revoke anyone for up to 8 hours, and the client couldn't
31527
+ // actually take their site back from the agency.
31528
+ //
31529
+ // Fails OPEN when the blob is unreadable: a Blob hiccup must not lock an owner
31530
+ // out of their own site. The password itself is still required to get a token.
31531
+ async function tokenVersionOk(data) {
31532
+ try {
31533
+ const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
31534
+ const target = blobs.find((b) => b.pathname === AUTH_KEY);
31535
+ if (!target) return true;
31536
+ const res = await fetch(target.url, { cache: "no-store" });
31537
+ if (!res.ok) return true;
31538
+ const rec = openStatsRecord(await res.json());
31539
+ const current = rec && Number(rec.tokenVersion) ? Number(rec.tokenVersion) : 0;
31540
+ return (Number(data && data.ver) || 0) >= current;
31541
+ } catch {
31542
+ return true;
31543
+ }
31544
+ }
31545
+
31546
+ // admin-auth.json is sealed with a different key label than stats.json.
31547
+ function openStatsRecord(raw) {
31548
+ if (!raw || typeof raw !== "object") return null;
31549
+ if (raw.v === 0 && raw.plain) return raw.plain;
31550
+ if (raw.v !== 1) return null;
31551
+ const material = process.env.AUTH_ENC_KEY || "";
31552
+ if (!material) return null;
31553
+ try {
31554
+ const key = createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
31555
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
31556
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
31557
+ return JSON.parse(
31558
+ Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
31559
+ );
31560
+ } catch {
31561
+ return null;
31562
+ }
31563
+ }
31413
31564
 
31414
31565
  function verifySession(req) {
31415
31566
  const cookie = req.headers.cookie || "";
@@ -31427,7 +31578,8 @@ function verifySession(req) {
31427
31578
  const data = JSON.parse(
31428
31579
  Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
31429
31580
  );
31430
- return typeof data.exp === "number" && data.exp > Date.now() / 1000;
31581
+ if (!(typeof data.exp === "number" && data.exp > Date.now() / 1000)) return false;
31582
+ return data;
31431
31583
  } catch {
31432
31584
  return false;
31433
31585
  }
@@ -31450,8 +31602,32 @@ function shiftDays(n) {
31450
31602
  return d.toISOString().slice(0, 10);
31451
31603
  }
31452
31604
 
31605
+ // The agency needs these numbers for renewal conversations, but must NOT need
31606
+ // the client's password to get them \u2014 otherwise the agency permanently holds the
31607
+ // client's login, and the client changing it silently breaks reporting.
31608
+ //
31609
+ // REPORT_KEY is a separate, per-site, read-only credential. It is accepted ONLY
31610
+ // here: it cannot edit content, read bookings, or change the password. So handing
31611
+ // the client full ownership and keeping agency reporting are no longer in
31612
+ // conflict.
31613
+ function verifyReportKey(req) {
31614
+ const expected = process.env.REPORT_KEY;
31615
+ if (!expected) return false;
31616
+ const header = req.headers["authorization"] || req.headers["x-report-key"] || "";
31617
+ const provided = String(header).replace(/^Bearer\\s+/i, "");
31618
+ if (!provided || provided.length !== expected.length) return false;
31619
+ try {
31620
+ return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
31621
+ } catch {
31622
+ return false;
31623
+ }
31624
+ }
31625
+
31453
31626
  export default async function handler(req, res) {
31454
- if (!verifySession(req)) {
31627
+ const claims = verifySession(req);
31628
+ const viaSession = claims ? await tokenVersionOk(claims) : false;
31629
+ const viaReportKey = viaSession ? false : verifyReportKey(req);
31630
+ if (!viaSession && !viaReportKey) {
31455
31631
  res.status(401).json({ error: "unauthorized" });
31456
31632
  return;
31457
31633
  }
@@ -31462,7 +31638,7 @@ export default async function handler(req, res) {
31462
31638
  if (target) {
31463
31639
  const r = await fetch(target.url, { cache: "no-store" });
31464
31640
  if (r.ok) {
31465
- const j = await r.json();
31641
+ const j = openStats(await r.json());
31466
31642
  if (j && j.days) stats = j;
31467
31643
  }
31468
31644
  }
@@ -31473,6 +31649,7 @@ export default async function handler(req, res) {
31473
31649
  const today = shiftDays(0);
31474
31650
  res.setHeader("Cache-Control", "no-store");
31475
31651
  res.status(200).json({
31652
+ via: viaSession ? "owner" : "report_key",
31476
31653
  last30: windowTotals(stats.days, shiftDays(-29), today),
31477
31654
  // The comparison window is what makes the number mean something.
31478
31655
  prev30: windowTotals(stats.days, shiftDays(-59), shiftDays(-30)),
@@ -33339,7 +33516,7 @@ var require_cms_runtime = __commonJS({
33339
33516
  };
33340
33517
  })();
33341
33518
  Object.defineProperty(exports2, "__esModule", { value: true });
33342
- exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33519
+ exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_LOGOUT_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33343
33520
  exports2.generateFriendlyPassword = generateFriendlyPassword;
33344
33521
  exports2.hashPassword = hashPassword;
33345
33522
  exports2.generateSessionSecret = generateSessionSecret;
@@ -33715,11 +33892,23 @@ export default async function handler(req, res) {
33715
33892
  delete throttle[who];
33716
33893
  await writeJson(THROTTLE_KEY, throttle);
33717
33894
  }
33718
- const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 8; // 8h
33719
- const token = sign({ role: "admin", exp }, secret);
33895
+ // 2h, not 8h. /api/stats checks the credential version and revokes instantly,
33896
+ // but the content/upload/booking endpoints don't (each is a standalone
33897
+ // serverless file and adding the check to all of them means duplicating the
33898
+ // auth-blob crypto five more times). So a cookie issued before a password
33899
+ // change can still WRITE for up to this long. Two hours keeps that window
33900
+ // small; the honest fix is a shared auth module, which needs a bundler for the
33901
+ // generated site.
33902
+ const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 2;
33903
+ // Stamp the credential version into the token. Changing the password bumps
33904
+ // this, which instantly invalidates every cookie issued before the change \u2014
33905
+ // otherwise "I changed my password" wouldn't actually lock anyone out for up
33906
+ // to 8 hours, which makes handover a false promise.
33907
+ const ver = (override && Number(override.tokenVersion)) || 0;
33908
+ const token = sign({ role: "admin", ver, exp }, secret);
33720
33909
  res.setHeader(
33721
33910
  "Set-Cookie",
33722
- "sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 * 8
33911
+ "sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 * 2
33723
33912
  );
33724
33913
  res.status(200).json({ ok: true });
33725
33914
  }
@@ -33861,7 +34050,7 @@ function readBody(req) {
33861
34050
  });
33862
34051
  }
33863
34052
 
33864
- async function currentHash() {
34053
+ async function currentRecord() {
33865
34054
  try {
33866
34055
  const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
33867
34056
  const target = blobs.find((b) => b.pathname === AUTH_KEY);
@@ -33869,13 +34058,13 @@ async function currentHash() {
33869
34058
  const res = await fetch(target.url, { cache: "no-store" });
33870
34059
  if (res.ok) {
33871
34060
  const j = openAuth(await res.json());
33872
- if (j && typeof j.passwordHash === "string" && j.passwordHash) return j.passwordHash;
34061
+ if (j && typeof j.passwordHash === "string" && j.passwordHash) return j;
33873
34062
  }
33874
34063
  }
33875
34064
  } catch {
33876
34065
  // fall through to the env var
33877
34066
  }
33878
- return process.env.ADMIN_PASSWORD_HASH || "";
34067
+ return null;
33879
34068
  }
33880
34069
 
33881
34070
  export default async function handler(req, res) {
@@ -33916,7 +34105,8 @@ export default async function handler(req, res) {
33916
34105
  return;
33917
34106
  }
33918
34107
 
33919
- const existing = await currentHash();
34108
+ const existingRecord = await currentRecord();
34109
+ const existing = existingRecord ? existingRecord.passwordHash : process.env.ADMIN_PASSWORD_HASH || "";
33920
34110
  if (!existing) {
33921
34111
  res.status(500).json({ error: "not_configured" });
33922
34112
  return;
@@ -33933,7 +34123,13 @@ export default async function handler(req, res) {
33933
34123
  try {
33934
34124
  // clientChanged is read (unencrypted) by nothing sensitive \u2014 it only tells
33935
34125
  // the agency "your stored copy is no longer the live password".
33936
- await put(AUTH_KEY, sealAuth({ passwordHash, updatedAt: new Date().toISOString(), clientChanged: true }), {
34126
+ const nextVersion = (Number(existingRecord && existingRecord.tokenVersion) || 0) + 1;
34127
+ await put(AUTH_KEY, sealAuth({
34128
+ passwordHash,
34129
+ updatedAt: new Date().toISOString(),
34130
+ clientChanged: true,
34131
+ tokenVersion: nextVersion,
34132
+ }), {
33937
34133
  access: "public",
33938
34134
  addRandomSuffix: false,
33939
34135
  allowOverwrite: true,
@@ -33943,6 +34139,20 @@ export default async function handler(req, res) {
33943
34139
  res.status(500).json({ error: "save_failed", detail: (e && e.message) || "unknown" });
33944
34140
  return;
33945
34141
  }
34142
+ // Clear the caller's own cookie too \u2014 their old token is now invalid, so
34143
+ // leaving it set would just produce confusing 401s until they reload.
34144
+ res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
34145
+ res.status(200).json({ ok: true, signedOut: true });
34146
+ }
34147
+ `;
34148
+ exports2.API_ADMIN_LOGOUT_JS = `export const config = { runtime: "nodejs" };
34149
+
34150
+ export default function handler(req, res) {
34151
+ if (req.method !== "POST") {
34152
+ res.status(405).json({ error: "method_not_allowed" });
34153
+ return;
34154
+ }
34155
+ res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
33946
34156
  res.status(200).json({ ok: true });
33947
34157
  }
33948
34158
  `;
@@ -34727,7 +34937,11 @@ export default async function handler(req, res) {
34727
34937
  }
34728
34938
 
34729
34939
  function TabBar(props) {
34730
- return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12 } },
34940
+ async function signOut() {
34941
+ try { await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
34942
+ window.location.reload();
34943
+ }
34944
+ return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12, alignItems: "center" } },
34731
34945
  props.tabs.map(function (t) {
34732
34946
  var active = props.section[0] === t.id;
34733
34947
  return h("button", {
@@ -34741,7 +34955,19 @@ export default async function handler(req, res) {
34741
34955
  font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
34742
34956
  }
34743
34957
  }, t.label);
34744
- })
34958
+ }).concat([
34959
+ // Without this, a shared or public computer keeps a live admin session.
34960
+ h("button", {
34961
+ key: "__signout",
34962
+ onClick: signOut,
34963
+ title: "Sign out",
34964
+ style: {
34965
+ marginLeft: "auto", background: "transparent", color: "var(--muted)",
34966
+ border: "1px solid var(--border)", borderRadius: 999, padding: "8px 14px",
34967
+ font: "inherit", fontSize: 12, cursor: "pointer"
34968
+ }
34969
+ }, "Sign out")
34970
+ ])
34745
34971
  );
34746
34972
  }
34747
34973
 
@@ -34768,8 +34994,11 @@ export default async function handler(req, res) {
34768
34994
  var data = null;
34769
34995
  try { data = await res.json(); } catch (_) { data = null; }
34770
34996
  if (res.ok) {
34771
- msg[1]("Password changed. Use the new one next time you sign in.");
34997
+ msg[1]("Password changed \u2014 signing you back in\u2026");
34772
34998
  cur[1](""); next[1](""); again[1]("");
34999
+ // The server invalidated this session, so stay logged in would just
35000
+ // produce confusing 401s. Reload straight into the login screen.
35001
+ setTimeout(function () { window.location.reload(); }, 1200);
34773
35002
  } else if (res.status === 401) {
34774
35003
  err[1]((data && data.error) === "wrong_current_password"
34775
35004
  ? "That's not your current password."
@@ -80691,6 +80920,15 @@ var require_builder = __commonJS({
80691
80920
  db2.run("UPDATE leads SET site_uid = ? WHERE id = ?", [uid, leadId]);
80692
80921
  return uid;
80693
80922
  }
80923
+ function reportKeyFor(leadId) {
80924
+ const db2 = (0, db_1.getDb)();
80925
+ const row = db2.get("SELECT report_key FROM leads WHERE id = ?", [leadId]);
80926
+ if (row?.report_key)
80927
+ return row.report_key;
80928
+ const key = (0, crypto_1.randomUUID)().replace(/-/g, "") + (0, crypto_1.randomUUID)().replace(/-/g, "");
80929
+ db2.run("UPDATE leads SET report_key = ? WHERE id = ?", [key, leadId]);
80930
+ return key;
80931
+ }
80694
80932
  function authEncKeyFor(leadId) {
80695
80933
  const db2 = (0, db_1.getDb)();
80696
80934
  const row = db2.get("SELECT auth_enc_key FROM leads WHERE id = ?", [leadId]);
@@ -81377,7 +81615,9 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81377
81615
  version: "0.0.1",
81378
81616
  type: "module",
81379
81617
  scripts: { build: "vite build", dev: "vite" },
81380
- dependencies: { ...baseDeps },
81618
+ // @vercel/blob even without CMS: /api/track writes the visit counters, and
81619
+ // analytics ships on every site.
81620
+ dependencies: { ...baseDeps, "@vercel/blob": "^0.23.4" },
81381
81621
  devDependencies: { "@vitejs/plugin-react": "^4.3.1", vite: "^5.4.2" }
81382
81622
  });
81383
81623
  const viteConfig = `import { defineConfig } from 'vite';
@@ -81457,6 +81697,7 @@ export default defineConfig({ plugins: [react()] });`;
81457
81697
  ADMIN_PASSWORD_HASH: adminHash,
81458
81698
  SESSION_SECRET: sessionSecret,
81459
81699
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
81700
+ REPORT_KEY: reportKeyFor(lead.id),
81460
81701
  BUSINESS_NAME: lead.name
81461
81702
  });
81462
81703
  const vercelJson = JSON.stringify({
@@ -81471,23 +81712,39 @@ export default defineConfig({ plugins: [react()] });`;
81471
81712
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81472
81713
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81473
81714
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81474
- { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81475
- { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81715
+ { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81476
81716
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
81477
81717
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81478
81718
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
81479
81719
  ];
81480
81720
  cmsAdmin = { url: "", password: adminPassword };
81481
81721
  }
81722
+ const analyticsFiles = [
81723
+ { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81724
+ { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }
81725
+ ];
81482
81726
  const allFiles = [
81483
81727
  { file: "index.html", data: indexHtml, encoding: "utf-8" },
81484
81728
  { file: "src/main.jsx", data: mainJsx, encoding: "utf-8" },
81485
81729
  { file: "src/App.jsx", data: appCode, encoding: "utf-8" },
81486
81730
  { file: "package.json", data: finalPackageJson, encoding: "utf-8" },
81487
81731
  { file: "vite.config.js", data: viteConfig, encoding: "utf-8" },
81732
+ ...analyticsFiles,
81488
81733
  ...cmsExtraFiles,
81489
81734
  ...photoFiles
81490
81735
  ];
81736
+ if (!includeCms && blobToken) {
81737
+ onProgress("Setting analytics env vars\u2026");
81738
+ await setProjectEnv(projectName, vercelToken, {
81739
+ BLOB_READ_WRITE_TOKEN: blobToken,
81740
+ SITE_ID: siteUidFor(lead.id),
81741
+ BUSINESS_NAME: lead.name,
81742
+ // Encrypts stats.json — the Blob is public and its path is guessable, so
81743
+ // without this the visit counts are readable by anyone who finds the URL.
81744
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
81745
+ REPORT_KEY: reportKeyFor(lead.id)
81746
+ });
81747
+ }
81491
81748
  const totalMb = allFiles.reduce((s, f) => s + f.data.length, 0) / 1048576;
81492
81749
  onProgress(`Uploading ${allFiles.length} files (${totalMb.toFixed(1)} MB)\u2026`);
81493
81750
  const res = await postVercelDeployment({
@@ -81570,7 +81827,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81570
81827
  const viteConfigCms = `import { defineConfig } from 'vite';
81571
81828
  import react from '@vitejs/plugin-react';
81572
81829
  export default defineConfig({ plugins: [react()] });`;
81573
- filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81830
+ filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81574
81831
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
81575
81832
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
81576
81833
  }
@@ -81581,6 +81838,7 @@ export default defineConfig({ plugins: [react()] });`;
81581
81838
  ADMIN_PASSWORD_HASH: adminHash,
81582
81839
  SESSION_SECRET: sessionSecret,
81583
81840
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
81841
+ REPORT_KEY: reportKeyFor(lead.id),
81584
81842
  BUSINESS_NAME: lead.name
81585
81843
  };
81586
81844
  if (existingUid)
@@ -81680,7 +81938,8 @@ export default defineConfig({ plugins: [react()] });`;
81680
81938
  BLOB_READ_WRITE_TOKEN: blobToken,
81681
81939
  ADMIN_PASSWORD_HASH: adminHash,
81682
81940
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81683
- AUTH_ENC_KEY: authEncKeyFor(lead.id)
81941
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
81942
+ REPORT_KEY: reportKeyFor(lead.id)
81684
81943
  };
81685
81944
  if (bookingExistingUid)
81686
81945
  adminEnv.SITE_ID = bookingExistingUid;
@@ -81728,6 +81987,7 @@ export default defineConfig({ plugins: [react()] });`;
81728
81987
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81729
81988
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81730
81989
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81990
+ { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81731
81991
  { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81732
81992
  { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81733
81993
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
@@ -81776,7 +82036,8 @@ export default defineConfig({ plugins: [react()] });`;
81776
82036
  // Admin panel auth.
81777
82037
  ADMIN_PASSWORD_HASH: adminHash,
81778
82038
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81779
- AUTH_ENC_KEY: authEncKeyFor(lead.id)
82039
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
82040
+ REPORT_KEY: reportKeyFor(lead.id)
81780
82041
  });
81781
82042
  const files = [
81782
82043
  { file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
@@ -81790,6 +82051,7 @@ export default defineConfig({ plugins: [react()] });`;
81790
82051
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81791
82052
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81792
82053
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
82054
+ { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81793
82055
  { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81794
82056
  { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81795
82057
  // Serve /admin without a trailing-slash redirect dance.
@@ -81973,12 +82235,13 @@ export default defineConfig({ plugins: [react()] });`,
81973
82235
  ]
81974
82236
  }),
81975
82237
  encoding: "utf-8"
81976
- }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
82238
+ }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81977
82239
  const env = {
81978
82240
  BLOB_READ_WRITE_TOKEN: blobToken,
81979
82241
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
81980
82242
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81981
82243
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
82244
+ REPORT_KEY: reportKeyFor(lead.id),
81982
82245
  BUSINESS_NAME: lead.name
81983
82246
  };
81984
82247
  if (siteUid)
@@ -83135,8 +83398,33 @@ var require_site_stats = __commonJS({
83135
83398
  exports2.fetchSiteStats = fetchSiteStats;
83136
83399
  exports2.pctChange = pctChange;
83137
83400
  var axios_1 = __importDefault(require_axios());
83138
- async function fetchSiteStats(siteUrl, adminPassword) {
83401
+ async function fetchSiteStats(siteUrl, adminPassword, reportKey) {
83139
83402
  const base = siteUrl.replace(/\/$/, "");
83403
+ if (reportKey) {
83404
+ try {
83405
+ const res = await axios_1.default.get(`${base}/api/stats`, {
83406
+ timeout: 2e4,
83407
+ headers: { Authorization: `Bearer ${reportKey}` },
83408
+ validateStatus: () => true
83409
+ });
83410
+ if (res.status === 200)
83411
+ return { ok: true, data: res.data };
83412
+ if (res.status === 404) {
83413
+ return {
83414
+ ok: false,
83415
+ error: "This site was built before analytics shipped. Run `sproutkit regenerate` on it to add them."
83416
+ };
83417
+ }
83418
+ } catch (e) {
83419
+ return { ok: false, error: `Couldn't reach the site: ${e.message}` };
83420
+ }
83421
+ }
83422
+ if (!adminPassword) {
83423
+ return {
83424
+ ok: false,
83425
+ error: "No report key on this site and no stored admin password. Rebuild it (`sproutkit regenerate`) to provision a read-only report key."
83426
+ };
83427
+ }
83140
83428
  let cookie;
83141
83429
  try {
83142
83430
  const login = await axios_1.default.post(`${base}/api/admin/login`, { password: adminPassword }, {
@@ -95933,20 +96221,22 @@ Interested?`;
95933
96221
  }
95934
96222
  case "site_report": {
95935
96223
  const id = Number(input.id);
95936
- const lead = db2.get("SELECT id, name, site_url, booking_url, admin_url, admin_password, booking_admin_password FROM leads WHERE id = ?", [id]);
96224
+ const lead = db2.get(`SELECT id, name, site_url, booking_url, admin_url, admin_password,
96225
+ booking_admin_password, report_key
96226
+ FROM leads WHERE id = ?`, [id]);
95937
96227
  if (!lead)
95938
96228
  throw new Error(`Lead #${id} not found`);
95939
96229
  const base = lead.site_url || lead.booking_url;
95940
96230
  if (!base)
95941
96231
  return { ok: false, error: `#${id} ${lead.name} has no live site yet.` };
95942
96232
  const password = lead.admin_password || lead.booking_admin_password;
95943
- if (!password) {
96233
+ if (!password && !lead.report_key) {
95944
96234
  return {
95945
96235
  ok: false,
95946
- error: `No admin password stored for #${id}, so I can't read its stats. Rebuild or hand over the site to provision one.`
96236
+ error: `No report key or admin password stored for #${id}, so I can't read its stats. Run \`sproutkit regenerate ${id}\` to provision a read-only report key.`
95947
96237
  };
95948
96238
  }
95949
- const stats = await (0, site_stats_1.fetchSiteStats)(base, password);
96239
+ const stats = await (0, site_stats_1.fetchSiteStats)(base, password, lead.report_key);
95950
96240
  if (!stats.ok)
95951
96241
  return { ok: false, error: stats.error, business: lead.name };
95952
96242
  return { ok: true, id, business: lead.name, url: base, ...stats.data };
@@ -96984,7 +97274,7 @@ var require_package2 = __commonJS({
96984
97274
  "package.json"(exports2, module2) {
96985
97275
  module2.exports = {
96986
97276
  name: "sproutkit-code",
96987
- version: "1.6.0",
97277
+ version: "1.7.0",
96988
97278
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
96989
97279
  bin: {
96990
97280
  sproutkit: "./dist/index.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"