sproutkit-code 1.6.1 → 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 +306 -29
  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]);
@@ -81459,6 +81697,7 @@ export default defineConfig({ plugins: [react()] });`;
81459
81697
  ADMIN_PASSWORD_HASH: adminHash,
81460
81698
  SESSION_SECRET: sessionSecret,
81461
81699
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
81700
+ REPORT_KEY: reportKeyFor(lead.id),
81462
81701
  BUSINESS_NAME: lead.name
81463
81702
  });
81464
81703
  const vercelJson = JSON.stringify({
@@ -81473,6 +81712,7 @@ export default defineConfig({ plugins: [react()] });`;
81473
81712
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81474
81713
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81475
81714
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_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" }
@@ -81498,7 +81738,11 @@ export default defineConfig({ plugins: [react()] });`;
81498
81738
  await setProjectEnv(projectName, vercelToken, {
81499
81739
  BLOB_READ_WRITE_TOKEN: blobToken,
81500
81740
  SITE_ID: siteUidFor(lead.id),
81501
- BUSINESS_NAME: lead.name
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)
81502
81746
  });
81503
81747
  }
81504
81748
  const totalMb = allFiles.reduce((s, f) => s + f.data.length, 0) / 1048576;
@@ -81583,7 +81827,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81583
81827
  const viteConfigCms = `import { defineConfig } from 'vite';
81584
81828
  import react from '@vitejs/plugin-react';
81585
81829
  export default defineConfig({ plugins: [react()] });`;
81586
- 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" });
81587
81831
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
81588
81832
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
81589
81833
  }
@@ -81594,6 +81838,7 @@ export default defineConfig({ plugins: [react()] });`;
81594
81838
  ADMIN_PASSWORD_HASH: adminHash,
81595
81839
  SESSION_SECRET: sessionSecret,
81596
81840
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
81841
+ REPORT_KEY: reportKeyFor(lead.id),
81597
81842
  BUSINESS_NAME: lead.name
81598
81843
  };
81599
81844
  if (existingUid)
@@ -81693,7 +81938,8 @@ export default defineConfig({ plugins: [react()] });`;
81693
81938
  BLOB_READ_WRITE_TOKEN: blobToken,
81694
81939
  ADMIN_PASSWORD_HASH: adminHash,
81695
81940
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81696
- AUTH_ENC_KEY: authEncKeyFor(lead.id)
81941
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
81942
+ REPORT_KEY: reportKeyFor(lead.id)
81697
81943
  };
81698
81944
  if (bookingExistingUid)
81699
81945
  adminEnv.SITE_ID = bookingExistingUid;
@@ -81741,6 +81987,7 @@ export default defineConfig({ plugins: [react()] });`;
81741
81987
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81742
81988
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81743
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" },
81744
81991
  { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81745
81992
  { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81746
81993
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
@@ -81789,7 +82036,8 @@ export default defineConfig({ plugins: [react()] });`;
81789
82036
  // Admin panel auth.
81790
82037
  ADMIN_PASSWORD_HASH: adminHash,
81791
82038
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81792
- AUTH_ENC_KEY: authEncKeyFor(lead.id)
82039
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
82040
+ REPORT_KEY: reportKeyFor(lead.id)
81793
82041
  });
81794
82042
  const files = [
81795
82043
  { file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
@@ -81803,6 +82051,7 @@ export default defineConfig({ plugins: [react()] });`;
81803
82051
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81804
82052
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81805
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" },
81806
82055
  { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81807
82056
  { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81808
82057
  // Serve /admin without a trailing-slash redirect dance.
@@ -81986,12 +82235,13 @@ export default defineConfig({ plugins: [react()] });`,
81986
82235
  ]
81987
82236
  }),
81988
82237
  encoding: "utf-8"
81989
- }, { 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" });
81990
82239
  const env = {
81991
82240
  BLOB_READ_WRITE_TOKEN: blobToken,
81992
82241
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
81993
82242
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81994
82243
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
82244
+ REPORT_KEY: reportKeyFor(lead.id),
81995
82245
  BUSINESS_NAME: lead.name
81996
82246
  };
81997
82247
  if (siteUid)
@@ -83148,8 +83398,33 @@ var require_site_stats = __commonJS({
83148
83398
  exports2.fetchSiteStats = fetchSiteStats;
83149
83399
  exports2.pctChange = pctChange;
83150
83400
  var axios_1 = __importDefault(require_axios());
83151
- async function fetchSiteStats(siteUrl, adminPassword) {
83401
+ async function fetchSiteStats(siteUrl, adminPassword, reportKey) {
83152
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
+ }
83153
83428
  let cookie;
83154
83429
  try {
83155
83430
  const login = await axios_1.default.post(`${base}/api/admin/login`, { password: adminPassword }, {
@@ -95946,20 +96221,22 @@ Interested?`;
95946
96221
  }
95947
96222
  case "site_report": {
95948
96223
  const id = Number(input.id);
95949
- 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]);
95950
96227
  if (!lead)
95951
96228
  throw new Error(`Lead #${id} not found`);
95952
96229
  const base = lead.site_url || lead.booking_url;
95953
96230
  if (!base)
95954
96231
  return { ok: false, error: `#${id} ${lead.name} has no live site yet.` };
95955
96232
  const password = lead.admin_password || lead.booking_admin_password;
95956
- if (!password) {
96233
+ if (!password && !lead.report_key) {
95957
96234
  return {
95958
96235
  ok: false,
95959
- 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.`
95960
96237
  };
95961
96238
  }
95962
- const stats = await (0, site_stats_1.fetchSiteStats)(base, password);
96239
+ const stats = await (0, site_stats_1.fetchSiteStats)(base, password, lead.report_key);
95963
96240
  if (!stats.ok)
95964
96241
  return { ok: false, error: stats.error, business: lead.name };
95965
96242
  return { ok: true, id, business: lead.name, url: base, ...stats.data };
@@ -96997,7 +97274,7 @@ var require_package2 = __commonJS({
96997
97274
  "package.json"(exports2, module2) {
96998
97275
  module2.exports = {
96999
97276
  name: "sproutkit-code",
97000
- version: "1.6.1",
97277
+ version: "1.7.0",
97001
97278
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
97002
97279
  bin: {
97003
97280
  sproutkit: "./dist/index.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.6.1",
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"