Cannabis Freedom Index

Changelog 5d7c813

survey basics

23 files of code, design or docs +6,546 −2 lines

Code, design and documents 23 files

astro.config.mjs +4 −0
@@ -13,3 +13,7 @@ export default defineConfig({
   site: process.env.SITE || "https://cannabisfreedom.fyi",
   base,
+  // A build keeps its own Vite cache. Sharing the dev server's would wipe the dependencies the
+  // running dev server has prepared (Fuse, nostr-tools), and its lazy imports would then fail
+  // until it is restarted.
+  vite: { cacheDir: process.argv.includes("build") ? "node_modules/.vite-build" : "node_modules/.vite" },
 });

docs/DATA-MAINTENANCE.md +45 −0
@@ -600,2 +600,47 @@ The number caps one run. `check` only reads; `save` asks the Internet Archive to
 which it limits to a few requests a minute, so run it in small batches. Run `check` after adding
 sources, and `save` for the ones that come back without a copy.
+
+## The survey (State of Cannabis)
+
+`/survey/` shows the counted results of the yearly survey and `/survey/take/` is the form. Survey
+is in the main menu, after History.
+
+| File | What it is |
+|---|---|
+| `survey/2026.yaml` | The questionnaire of the edition: sections, questions, options, conditions, and the Nostr settings. The form and the results page are built from it. |
+| `survey/results-2026.json` | The counted results the site shows and offers at `/data/survey.json`. Counts only. `"mock": true` means invented numbers, and the page says so. |
+| `scripts/survey-lib.cjs` | Cleans answers against the questionnaire and counts them. A group is published only with 30 respondents, a country only with 5 observations. |
+| `scripts/survey-mock.cjs` | Writes invented results, for designing the page before the survey opens. |
+| `scripts/survey-collect.cjs` | Fetches the real answers, decrypts them and writes the counted results. |
+| `src/lib/survey-client.js` | The browser side: the respondent's key, their stored submissions, signing and the relays. |
+| `scripts/survey-keygen.cjs` | Prints a new key pair for the project. |
+
+**How an answer travels.** The first time someone submits, their browser makes a Nostr key and
+keeps it in its local storage. The personal answers and each place described become one
+replaceable event each (NIP-78, kind 30078, one `d` tag per part), signed with that key, encrypted
+between it and the project's key (NIP-44) and sent to the relays listed in the questionnaire file.
+Submitting again replaces a part; deleting publishes an empty replacement and a NIP-09 deletion
+request. `/survey/me/` shows a browser its own submissions and lets it change them, delete them,
+back up the key or restore one. The key is a pseudonym: it ties one person's parts together but
+not to the person. While `open` is false, or no public key and relays are set, nothing is sent
+and submissions wait in the browser.
+
+**To try it before opening.** Run a relay of your own, then in the browser's console set
+`localStorage["cfi-survey-test"] = JSON.stringify({pubkey: "<hex>", relays: ["ws://localhost:7447"]})`.
+That browser alone then submits to that relay.
+
+**To open the survey.** Make a Nostr key pair for the project (`node scripts/survey-keygen.cjs`) and keep the secret key off this
+repository and off any server. Put the public key (hex) and two or three relays under `nostr:` in
+`survey/2026.yaml`, set `open: true`, and deploy. To publish results, run
+`SURVEY_SECRET_KEY=<hex> node scripts/survey-collect.cjs` on your own machine and commit the
+rewritten `survey/results-2026.json`. Add `--text` to read the free-text answers in the terminal;
+they are stored nowhere.
+
+**Keeping years comparable.** A new edition is a new `survey/<year>.yaml` and results file. Keep
+question ids and option ids unchanged wherever the question is the same.
+
+**Known limits.** Anyone can make as many keys as they like, so one person can still answer many
+times; the collector counts one set of answers per key. Relays see the address a message comes
+from. A relay may ignore deletion requests, which is why a deleted part is also overwritten with
+an empty one. Someone who clears their browser without a backup of the key can no longer change
+their answers.

package-lock.json +105 −0

Not shown: a picture, a generated file, or past the length shown per change.

package.json +1 −0
@@ -16,4 +16,5 @@
     "d3-zoom": "^3.0.0",
     "fuse.js": "^7.5.0",
+    "nostr-tools": "^2.25.2",
     "topojson-client": "^3.1.0",
     "world-atlas": "^2.0.2"

scripts/survey-collect.cjs +77 −0
@@ -0,0 +1,77 @@
+// Turns submitted survey answers into the counted results the site publishes:
+//   node scripts/survey-collect.cjs                       fetch from the relays in survey/<year>.yaml
+//   node scripts/survey-collect.cjs --from events.jsonl   read saved events instead
+// The secret key is read from SURVEY_SECRET_KEY, in the environment or in the git-ignored .env.
+// Add --out <file> to write somewhere other than survey/results-<year>.json.
+// Every event is checked (signature, kind, topic), decrypted with the project's secret key, cleaned
+// against the questionnaire and counted by survey-lib.cjs. Only survey/results-<year>.json is
+// written: counts, never single answers. Free text is printed to the terminal with --text, for
+// the researchers, and is stored nowhere by this script. The secret key is read from the
+// environment and must never be put in a file of this repository.
+const fs = require("fs");
+const path = require("path");
+const { verifyEvent } = require("nostr-tools/pure");
+const { decrypt, getConversationKey } = require("nostr-tools/nip44");
+const { ROOT, loadSurvey, loadRegions, clean, aggregate } = require("./survey-lib.cjs");
+
+const EDITION = 2026;
+const args = process.argv.slice(2);
+const from = args.includes("--from") ? args[args.indexOf("--from") + 1] : null;
+const survey = loadSurvey(EDITION);
+const countries = loadRegions();
+const { kind, topic, relays } = survey.nostr;
+// the key comes from the environment, or from the git-ignored .env file beside package.json
+const fromEnvFile = () => { try { return fs.readFileSync(path.join(ROOT, ".env"), "utf8").match(/^SURVEY_SECRET_KEY=([0-9a-f]{64})\s*$/m)?.[1]; } catch { return undefined; } };
+const hex = process.env.SURVEY_SECRET_KEY ?? fromEnvFile();
+if (hex && !/^[0-9a-f]{64}$/.test(hex)) { console.error("SURVEY_SECRET_KEY must be 64 hex characters"); process.exit(1); }
+const secret = hex ? Uint8Array.from(Buffer.from(hex, "hex")) : null;
+
+async function fetchEvents() {
+  if (from) return fs.readFileSync(from, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
+  if (!relays.length) { console.error(`survey/${EDITION}.yaml lists no relays; use --from <file>`); process.exit(1); }
+  const { SimplePool } = require("nostr-tools/pool");
+  const pool = new SimplePool();
+  const events = await pool.querySync(relays, { kinds: [kind], "#t": [topic] });
+  pool.close(relays);
+  return events;
+}
+
+(async () => {
+  const responses = [], observations = [], texts = [];
+  const dropped = { invalid: 0, superseded: 0, deleted: 0, unreadable: 0, other: 0 };
+  // Each part of a submission is a replaceable event: only the newest version under one key and
+  // one `d` tag counts, whatever older copies a relay still returns.
+  const newest = new Map();
+  for (const ev of await fetchEvents()) {
+    const d = ev.tags?.find((t) => t[0] === "d")?.[1];
+    if (ev.kind !== kind || !d?.startsWith(`${topic}:`) || !ev.tags.some((t) => t[0] === "t" && t[1] === topic) || !verifyEvent(ev)) { dropped.invalid++; continue; }
+    const slot = `${ev.pubkey}:${d}`, had = newest.get(slot);
+    if (had) dropped.superseded++;
+    if (!had || had.created_at < ev.created_at) newest.set(slot, ev);
+  }
+  for (const ev of newest.values()) {
+    let payload;
+    try {
+      const plain = ev.content.startsWith("{") ? ev.content : decrypt(ev.content, getConversationKey(secret, ev.pubkey));
+      payload = JSON.parse(plain);
+    } catch { dropped.unreadable++; continue; }
+    if (payload?.deleted) { dropped.deleted++; continue; } // the respondent removed this part
+    if (payload?.edition !== EDITION || typeof payload.answers !== "object") { dropped.other++; continue; }
+    if (payload.type === "response") {
+      const r = clean(survey.all, payload.answers, countries);
+      // the two questions without which an answer cannot be placed in any group
+      if (!r.residence || !r.relation) { dropped.other++; continue; }
+      responses.push(r);
+    } else if (payload.type === "observation") {
+      const o = clean(survey.territory.questions, payload.answers, countries);
+      if (!o.country || !o.tie) { dropped.other++; continue; }
+      observations.push(o);
+    } else { dropped.other++; continue; }
+    for (const q of [...survey.all, ...survey.territory.questions].filter((q) => q.type === "text")) if (payload.answers[q.id]) texts.push([q.id, String(payload.answers[q.id]).slice(0, 4000)]);
+  }
+  const out = aggregate(survey, responses, observations, countries, { mock: false, generated: new Date().toISOString().slice(0, 10) });
+  const file = args.includes("--out") ? args[args.indexOf("--out") + 1] : path.join(ROOT, "survey", `results-${EDITION}.json`);
+  fs.writeFileSync(file, JSON.stringify(out, null, 1) + "\n");
+  console.log(`collected: ${responses.length} responses, ${observations.length} observations | dropped: ${JSON.stringify(dropped)} → ${path.relative(ROOT, file)}`);
+  if (args.includes("--text")) for (const [id, text] of texts) console.log(`\n[${id}] ${text}`);
+})();

scripts/survey-keygen.cjs +6 −0
@@ -0,0 +1,6 @@
+// Makes the project's Nostr key pair for the survey:  node scripts/survey-keygen.cjs
+// The public key goes into survey/<year>.yaml. The secret key is printed once, here, and nowhere
+// else: store it offline. Whoever holds it can read every answer.
+const { generateSecretKey, getPublicKey } = require("nostr-tools/pure");
+const sk = generateSecretKey();
+console.log(`public key (for survey/<year>.yaml, nostr.pubkey):\n  ${getPublicKey(sk)}\n\nsecret key (SURVEY_SECRET_KEY for survey-collect.cjs; keep it offline):\n  ${Buffer.from(sk).toString("hex")}`);

scripts/survey-lib.cjs +131 −0
@@ -0,0 +1,131 @@
+// Shared by survey-mock.cjs and survey-collect.cjs: reads the questionnaire of an edition
+// (survey/<year>.yaml), cleans raw answers against it and counts them into the aggregated file
+// that the site publishes (survey/results-<year>.json). Only counts leave this module: no free
+// text, no single response, and nothing for a group too small to hide in.
+const fs = require("fs");
+const path = require("path");
+const yaml = require("js-yaml");
+
+const ROOT = path.join(__dirname, "..");
+const MIN_SEGMENT = 30; // a filter (e.g. one region) is published only with this many respondents
+const MIN_TERRITORY = 5; // a country only with this many observations
+
+// US dollars for one unit of each currency a price may be given in (survey/currencies.yaml)
+const money = yaml.load(fs.readFileSync(path.join(ROOT, "survey", "currencies.yaml"), "utf8"));
+const USD = Object.fromEntries(Object.entries(money.per_eur).map(([code, perEur]) => [code, money.per_eur.USD / perEur]));
+
+function loadSurvey(edition) {
+  const survey = yaml.load(fs.readFileSync(path.join(ROOT, "survey", `${edition}.yaml`), "utf8"));
+  survey.all = survey.sections.flatMap((s) => s.questions);
+  return survey;
+}
+
+// region of every country of the index, for the regional filters
+function loadRegions() {
+  const dir = path.join(ROOT, "countries");
+  const out = {};
+  for (const f of fs.readdirSync(dir)) {
+    const file = f.endsWith(".yaml") ? path.join(dir, f) : path.join(dir, f, "index.yaml");
+    if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue;
+    const d = yaml.load(fs.readFileSync(file, "utf8"));
+    out[f.replace(/\.yaml$/, "").toUpperCase()] = d;
+  }
+  return out;
+}
+
+// Keep only what the questionnaire allows: known ids, known options, numbers in range.
+function clean(questions, raw, countries) {
+  const out = {};
+  for (const q of questions) {
+    const v = raw?.[q.id];
+    if (v == null || v === "") continue;
+    if (q.type === "single" && typeof v === "string" && v in q.options) out[q.id] = v;
+    else if (q.type === "multi" && Array.isArray(v)) {
+      const xs = [...new Set(v.filter((x) => typeof x === "string" && x in q.options))];
+      if (xs.length) out[q.id] = xs;
+    } else if (q.type === "scale" && Number.isInteger(v) && v >= 1 && v <= 5) out[q.id] = v;
+    else if (q.type === "number" && typeof v === "number" && v > 0 && v < 1e7) out[q.id] = v;
+    else if (q.type === "currency" && typeof v === "string" && USD[v]) out[q.id] = v;
+    else if (q.type === "country" && typeof v === "string" && countries[v.toUpperCase()]) out[q.id] = v.toUpperCase();
+    else if (q.type === "text" && typeof v === "string") out[q.id] = v.slice(0, 4000);
+  }
+  return out;
+}
+
+const median = (xs) => {
+  const s = [...xs].sort((a, b) => a - b);
+  return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
+};
+const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
+const round = (x, d = 2) => Math.round(x * 10 ** d) / 10 ** d;
+
+// responses: cleaned personal answers; observations: cleaned territory observations
+function aggregate(survey, responses, observations, countries, extra = {}) {
+  const segmentsOf = (r) => {
+    const rel = r.relation ?? [];
+    const out = ["all"];
+    if (rel.includes("use") || rel.includes("patient")) out.push("users");
+    if (rel.includes("grow")) out.push("growers");
+    if (rel.includes("never")) out.push("never");
+    const region = countries[r.residence]?.region;
+    if (region) out.push(`region:${region}`);
+    return out;
+  };
+  const members = {};
+  for (const r of responses) for (const s of segmentsOf(r)) (members[s] ??= []).push(r);
+  const label = (id) => ({ all: "Everyone", users: "People who use it", growers: "Growers", never: "Never used it" })[id] ?? id.replace("region:", "");
+  const segments = Object.entries(members)
+    .filter(([id, rs]) => id === "all" || rs.length >= MIN_SEGMENT)
+    .map(([id, rs]) => ({ id, label: label(id), n: rs.length }))
+    .sort((a, b) => (a.id.startsWith("region:") - b.id.startsWith("region:")) || b.n - a.n);
+
+  const questions = {};
+  for (const q of survey.all.filter((q) => q.type !== "text")) {
+    const entry = { n: {}, counts: {} };
+    if (q.type === "scale") entry.mean = {};
+    for (const { id } of segments) {
+      const answers = members[id].map((r) => r[q.id]).filter((v) => v != null);
+      entry.n[id] = answers.length;
+      const counts = {};
+      if (q.type === "scale") for (let k = 1; k <= 5; k++) counts[k] = 0;
+      else if (q.options) for (const o of Object.keys(q.options)) counts[o] = 0;
+      for (const v of answers) for (const x of [].concat(v)) counts[x] = (counts[x] ?? 0) + 1;
+      entry.counts[id] = counts;
+      if (q.type === "scale" && answers.length) entry.mean[id] = round(mean(answers));
+    }
+    questions[q.id] = entry;
+  }
+
+  const byCountry = {};
+  for (const o of observations) (byCountry[o.country] ??= []).push(o);
+  const avg = (os, id) => { const xs = os.map((o) => o[id]).filter((v) => v != null); return xs.length ? round(mean(xs)) : null; };
+  const territories = Object.entries(byCountry)
+    .filter(([, os]) => os.length >= MIN_TERRITORY)
+    .map(([country, os]) => {
+      // every price in US dollars at the rates on file; a price with no currency was asked in
+      // dollars, and one outside 20 cents to 200 dollars a gram is taken for a slip and left out
+      const prices = (src) => os.filter((o) => o.t_price != null && (!src || o.t_price_source === src))
+        .map((o) => o.t_price * USD[o.t_currency ?? "USD"]).filter((usd) => usd >= 0.2 && usd <= 200);
+      const count = (id) => { const c = {}; for (const o of os) if (o[id]) c[o[id]] = (c[o[id]] ?? 0) + 1; return c; };
+      return {
+        country, n: os.length, tie: count("tie"),
+        acceptance: avg(os, "t_acceptance"), enforcement: avg(os, "t_enforcement"), access: avg(os, "t_access"),
+        public: count("t_public"),
+        price: prices().length >= MIN_TERRITORY ? round(median(prices())) : null, price_n: prices().length,
+        price_legal: prices("legal").length >= MIN_TERRITORY ? round(median(prices("legal"))) : null,
+        price_illegal: prices("illegal").length >= MIN_TERRITORY ? round(median(prices("illegal"))) : null,
+      };
+    })
+    .sort((a, b) => b.n - a.n);
+
+  return {
+    edition: survey.edition, ...extra,
+    respondents: responses.length, observations: observations.length,
+    countries_lived: new Set(responses.map((r) => r.residence).filter(Boolean)).size,
+    countries_described: Object.keys(byCountry).length,
+    thresholds: { segment: MIN_SEGMENT, territory: MIN_TERRITORY }, rates_date: money.date,
+    segments, questions, territories,
+  };
+}
+
+module.exports = { ROOT, loadSurvey, loadRegions, clean, aggregate };

scripts/survey-mock.cjs +118 −0
@@ -0,0 +1,118 @@
+// Invented survey answers, so that /survey/ can be designed and tested before the survey opens:
+//   node scripts/survey-mock.cjs [respondents]
+// Writes survey/results-<edition>.json with "mock": true, which the page announces loudly. The
+// answers are random draws shaped by each country's index score; they are NOT findings. The same
+// counting code (survey-lib.cjs) is used for real answers, so the file has the real shape.
+const fs = require("fs");
+const path = require("path");
+const { ROOT, loadSurvey, loadRegions, clean, aggregate } = require("./survey-lib.cjs");
+
+const EDITION = 2026;
+const N = +process.argv[2] || 2600;
+const survey = loadSurvey(EDITION);
+const countries = loadRegions();
+
+// a fixed seed: the same invented data on every run
+let seed = 20261012;
+const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
+const gauss = () => (rnd() + rnd() + rnd() + rnd() - 2) * 0.87;
+const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
+const scale = (x) => clamp(Math.round(x), 1, 5);
+function pick(weights) { // {option: weight}
+  let r = rnd() * Object.values(weights).reduce((a, b) => a + b, 0);
+  for (const [k, w] of Object.entries(weights)) if ((r -= w) <= 0) return k;
+  return Object.keys(weights)[0];
+}
+const some = (probs) => Object.entries(probs).filter(([, p]) => rnd() < p).map(([k]) => k);
+
+const WEIGHTS = { possession: 25, cultivation: 25, enforcement: 20, sharing: 10, consumption: 10, access: 10 };
+const score = (id) => Object.entries(WEIGHTS).reduce((a, [k, w]) => a + (countries[id].scores[k] * w) / 10, 0); // 0–100
+
+// who answers a web survey in English: mostly Europe and the Americas, a long tail elsewhere
+const HOME = { US: 300, DE: 260, GB: 170, CA: 150, CZ: 140, NL: 110, ES: 110, FR: 100, PL: 95, IT: 90, AU: 85, BR: 70, MX: 60, CH: 55, AT: 50, PT: 45, TH: 45, ZA: 45, IN: 45, SE: 35, UY: 30, AR: 35, CO: 30, IE: 30, BE: 30, DK: 28, NO: 22, FI: 22, IL: 25, JP: 22, NZ: 22, GR: 18, HU: 18, RO: 16, TR: 16, MA: 14, NG: 14, KE: 10, PH: 12, ID: 10, SG: 6, KR: 8, EG: 6, AE: 5, JM: 12, CL: 20, MT: 10, LU: 8 };
+for (const id of Object.keys(HOME)) if (!countries[id]) delete HOME[id];
+const ANY = Object.fromEntries(Object.keys(countries).filter((id) => countries[id].scores).map((id) => [id, HOME[id] ? HOME[id] / 4 + 3 : 1]));
+// an invented street price per country, cheaper where cannabis is freer or grown locally
+const basePrice = Object.fromEntries(Object.keys(ANY).map((id) => [id, clamp(13 - score(id) * 0.09 + gauss() * 4, 1.5, 28)]));
+
+// each country also differs from what its law predicts, so the dots do not fall on a line
+const quirk = Object.fromEntries(Object.keys(ANY).map((id) => [id, { a: gauss() * 0.55, e: gauss() * 0.6, x: gauss() * 0.5 }]));
+const responses = [], observations = [];
+for (let i = 0; i < N; i++) {
+  const home = pick(HOME), s = score(home) / 100; // 0–1, how free the law is at home
+  const liberal = gauss(); // the person's own leaning
+  const r = { residence: home };
+  r.age = pick({ a18: 22, a25: 34, a35: 22, a45: 12, a55: 7, a65: 3 });
+  r.gender = pick({ man: 62, woman: 30, other: 4, skip: 4 });
+  r.setting = pick({ city: 52, town: 33, rural: 15 });
+  const kind = pick({ use: 68, past: 12, never: 14, study: 6 });
+  r.relation = kind === "use" ? ["use"] : kind === "past" ? ["past"] : kind === "never" ? ["never"] : ["research", ...(rnd() < 0.5 ? ["use"] : ["never"])];
+  const uses = r.relation.includes("use");
+  if (uses && rnd() < 0.14 + s * 0.3) r.relation.push("grow");
+  if (uses && rnd() < 0.12) r.relation.push("patient");
+  if (rnd() < 0.05) r.relation.push("work");
+
+  if (uses || kind === "past") {
+    r.frequency = uses ? pick({ daily: 30, weekly: 30, monthly: 20, yearly: 20 }) : pick({ none: 80, yearly: 20 });
+    r.first_age = pick({ u15: 12, a15: 38, a18: 28, a21: 14, o25: 8 });
+    r.methods = some({ joint_tobacco: home === "US" || home === "CA" ? 0.1 : 0.55, joint_pure: 0.45, pipe: 0.3, vaporiser: 0.28 + s * 0.2, edibles: 0.2 + s * 0.25, oil: 0.12, concentrates: 0.06 + s * 0.15 });
+    r.products = some({ flower: 0.9, hashish: ["MA", "ES", "FR", "NL", "DK", "PT"].includes(home) ? 0.6 : 0.2, edibles: 0.2 + s * 0.3, cartridges: 0.08 + s * 0.3, extracts: 0.1, cbd: 0.25 });
+    r.reasons = some({ relax: 0.8, social: 0.5, sleep: 0.45, pain: 0.25, creative: 0.3, ritual: 0.08, habit: 0.2 });
+    r.company = pick({ alone: 30, others: 30, both: 40 });
+  }
+  if (r.relation.includes("grow")) {
+    r.grow_years = pick({ y1: 20, y3: 38, y10: 28, y10p: 14 });
+    r.grow_where = some({ indoor: 0.55, balcony: 0.3, outdoor: 0.4 });
+    r.grow_plants = pick({ p2: 35, p5: 40, p10: 15, p10p: 10 });
+    r.grow_reasons = some({ quality: 0.7, cost: 0.6, market: 0.5, enjoy: 0.65, medical: 0.2, share: 0.25 });
+    r.grow_challenges = some({ law: 0.75 - s * 0.6, neighbours: 0.4, pests: 0.45, cost: 0.3, knowledge: 0.25, seeds: 0.25 - s * 0.15 });
+    r.grow_legal = countries[home].scores.cultivation >= 4 ? pick({ legal: 80, tolerated: 10, unsure: 10 }) : pick({ illegal: 65, tolerated: 20, unsure: 15 });
+  }
+  if (uses) {
+    const legalShop = countries[home].scores.access >= 4;
+    r.source = some({ shop: legalShop ? 0.6 : 0.03, club: ["ES", "DE", "UY", "MT"].includes(home) ? 0.3 : 0.02, own: r.relation.includes("grow") ? 0.9 : 0, gift: 0.3, friends: 0.45, dealer: legalShop ? 0.2 : 0.55, online: 0.1 });
+    r.spend = pick({ s0: 12, s25: 28, s75: 32, s150: 18, s150p: 10 });
+    r.affordable = scale(3.2 + gauss());
+  }
+  r.acceptance = scale(1.6 + s * 3 + quirk[home].a + liberal * 0.3 + gauss() * 0.8);
+  r.versus_alcohol = pick({ more: 6 + s * 20, same: 20 + s * 30, less: 74 - s * 50 });
+  if (uses || kind === "past") {
+    r.family = pick({ open: 20 + s * 40, some: 40, hidden: 35 - s * 25, na: 5 });
+    r.work = pick({ yes: 10 + s * 35, maybe: 35, no: 50 - s * 35, na: 5 });
+    const hit = some({ family: 0.3 - s * 0.15, job: 0.1 - s * 0.05, doctor: 0.15, housing: 0.05, friends: 0.08 });
+    r.stigma = hit.length ? hit : ["none"];
+  }
+  r.tradition = ["IN", "MA", "JM", "NP", "ZA", "MX", "TH", "NG"].includes(home) ? pick({ yes: 65, no: 15, unsure: 20 }) : pick({ yes: 12, no: 55, unsure: 33 });
+  r.generations = pick({ more: 70, same: 22, less: 8 });
+  r.law_known = scale(3.4 + (uses ? 0.5 : -0.4) + gauss() * 0.9);
+  r.law_fair = scale(1.3 + s * 2.6 - liberal * 0.5 + (kind === "never" ? 1 : 0) + gauss() * 0.8);
+  r.obey = uses ? pick({ obey: 4, risk: 30, quiet: 54, open: 12 }) : pick({ obey: 40, risk: 38, quiet: 19, open: 3 });
+  r.police_trust = scale(3 - (uses ? 0.6 : 0) - (1 - s) * 0.6 + gauss());
+  r.police_contact = uses ? pick({ none: 52 + s * 20, stopped: 24 - s * 8, fined: 14 - s * 6, arrested: 7 - s * 4, convicted: 3 - s * 2 }) : pick({ none: 92, stopped: 6, fined: 2 });
+  r.who_decides = pick({ person: 55 + liberal * 12, doctors: 12, voters: 14, parliament: 16, tradition: 3 });
+  r.policy = kind === "never" ? pick({ ban: 14, medical: 30, decrim: 20, regulated: 30, free: 6 }) : pick({ ban: 1, medical: 5, decrim: 14, regulated: 55, free: 25 });
+  r.opinion_change = pick({ more: 58, same: 34, less: 8 });
+  responses.push(clean(survey.all, r, countries));
+
+  // one observation of home, and for some people one or two of places they visited
+  const places = [[home, "live"], ...Array.from({ length: pick({ 0: 45, 1: 35, 2: 20 }) | 0 }, () => [pick(ANY), pick({ visited: 75, lived: 25 })])];
+  for (const [id, tie] of places) {
+    if (id === home && tie !== "live") continue;
+    const t = score(id) / 100, legal = countries[id].scores.access >= 4;
+    const o = { country: id, tie, when: tie === "live" ? "now" : pick({ now: 40, y5: 40, older: 20 }) };
+    o.t_acceptance = scale(1.6 + t * 3 + quirk[id].a + gauss() * 0.8);
+    o.t_enforcement = scale(4.3 - t * 3 + quirk[id].e + gauss() * 0.8);
+    o.t_access = scale(2.2 + t * 2.6 + quirk[id].x + gauss() * 0.8);
+    o.t_public = pick({ open: 4 + t * 55, discreet: 30, hidden: 45 - t * 35, rare: 21 - t * 18 });
+    if (rnd() < 0.6) {
+      o.t_price_source = legal && rnd() < 0.6 ? "legal" : "illegal";
+      o.t_price = Math.round(clamp(basePrice[id] * (o.t_price_source === "legal" ? 0.9 : 1) * (1 + gauss() * 0.25), 0.5, 60) * 10) / 10;
+    }
+    observations.push(clean(survey.territory.questions, o, countries));
+  }
+}
+
+const out = aggregate(survey, responses, observations, countries, { mock: true, generated: "2026-10-12" });
+const file = path.join(ROOT, "survey", `results-${EDITION}.json`);
+fs.writeFileSync(file, JSON.stringify(out, null, 1) + "\n");
+console.log(`mock: ${out.respondents} respondents, ${out.observations} observations, ${out.territories.length} countries published → ${path.relative(ROOT, file)} (${Math.round(fs.statSync(file).size / 1024)} KB)`);

src/components/Question.astro +43 −0
@@ -0,0 +1,43 @@
+---
+// One question of the survey form (survey/<year>.yaml), as a labelled field. The form's script
+// reads the answer from `data-q`/`data-type`; `data-if` holds the condition for showing it.
+import { countries } from "../lib/countries.js";
+import { currencies } from "../lib/survey.js";
+
+const { q } = Astro.props;
+const byName = [...countries].sort((a, b) => a.name.localeCompare(b.name));
+const options = q.options ? Object.entries(q.options) : [];
+---
+<div class="sv-field" data-q={q.id} data-type={q.type} data-label={q.text} data-required={q.required ? "" : undefined} data-if={q.if ? JSON.stringify(q.if) : undefined}>
+  <p class="sv-qtext" id={`q-${q.id}`}>{q.text}{q.required && <span class="sv-req" title="Required"> *</span>}</p>
+  {q.help && <p class="sv-help">{q.help}</p>}
+  {(q.type === "single" || q.type === "multi") && (
+    <div class="sv-options" role={q.type === "single" ? "radiogroup" : "group"} aria-labelledby={`q-${q.id}`}>
+      {options.map(([value, label]) => (
+        <label><input type={q.type === "single" ? "radio" : "checkbox"} name={q.id} value={value} /> <span>{label}</span></label>
+      ))}
+    </div>
+  )}
+  {q.type === "scale" && (
+    <div class="sv-scale" role="radiogroup" aria-labelledby={`q-${q.id}`}>
+      <span class="sv-end">{q.low}</span>
+      {[1, 2, 3, 4, 5].map((k) => <label><input type="radio" name={q.id} value={k} /> <span>{k}</span></label>)}
+      <span class="sv-end">{q.high}</span>
+    </div>
+  )}
+  {q.type === "country" && (
+    <select name={q.id} aria-labelledby={`q-${q.id}`}>
+      <option value="">Choose a country…</option>
+      {/* the name comes first, so that typing a letter jumps to the country */}
+      {byName.map((c) => <option value={c.id}>{c.name} {c.flag}</option>)}
+    </select>
+  )}
+  {/* a text box, so that "6,5" and "6.5" are both accepted; the form's script reads the number */}
+  {q.type === "number" && <input type="text" class="sv-number" name={q.id} inputmode="decimal" autocomplete="off" maxlength="12" aria-labelledby={`q-${q.id}`} />}
+  {q.type === "currency" && (
+    <select name={q.id} aria-labelledby={`q-${q.id}`}>
+      {currencies.map(([code, name]) => <option value={code}>{code} · {name}</option>)}
+    </select>
+  )}
+  {q.type === "text" && <textarea name={q.id} rows="4" maxlength="4000" aria-labelledby={`q-${q.id}`}></textarea>}
+</div>

src/layouts/Base.astro +1 −0
@@ -61,4 +61,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
       <a href={`${base}#methodology`}>Methodology</a>
       <a href={`${base}history/`}>History</a>
+      <a href={`${base}survey/`}>Survey</a>
       <a href={`${base}can-i/`}>Can I?</a>
       <a href={`${base}compare/`}>Compare</a>

src/lib/survey-client.js +134 −0
@@ -0,0 +1,134 @@
+// The survey in the browser: the respondent's key, their stored submissions, and the relays.
+// Loaded only when it is needed (on "Submit", and on /survey/me/).
+//
+// A respondent has one Nostr key, made in their browser the first time they submit and kept in
+// its local storage. It is a pseudonym: nothing ties it to a person, but it does tie together the
+// messages signed with it, which is what lets someone come back, correct an answer or delete it.
+// Each part of a submission (the personal answers, and every place described) is one replaceable
+// event (NIP-78, kind 30078) under its own `d` tag; publishing it again replaces it. The content
+// is encrypted (NIP-44) between the respondent's key and the project's, so the relays store
+// ciphertext that only those two can read.
+import { generateSecretKey, getPublicKey, finalizeEvent } from "nostr-tools/pure";
+import { encrypt, decrypt, getConversationKey } from "nostr-tools/nip44";
+import { nsecEncode, npubEncode, naddrEncode, decode } from "nostr-tools/nip19";
+import { SimplePool } from "nostr-tools/pool";
+
+const KEY = "cfi-survey-key";
+const toHex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
+const fromHex = (h) => Uint8Array.from(h.match(/../g).map((x) => parseInt(x, 16)));
+const get = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
+const set = (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); return true; } catch { return false; } };
+
+// For trying the whole flow against a relay of your own before the survey opens: put
+// {"pubkey": "<hex>", "relays": ["ws://localhost:7447"]} under "cfi-survey-test" in local storage.
+export function effective(cfg) {
+  try {
+    const t = JSON.parse(get("cfi-survey-test") ?? "null");
+    if (/^[0-9a-f]{64}$/.test(t?.pubkey ?? "") && t.relays?.length) return { ...cfg, pubkey: t.pubkey, relays: t.relays, open: true, test: true };
+  } catch {}
+  return cfg;
+}
+
+// ── The key ──
+export const hasKey = () => /^[0-9a-f]{64}$/.test(get(KEY) ?? "");
+function secret(create = false) {
+  if (hasKey()) return fromHex(get(KEY));
+  if (!create) return null;
+  const sk = generateSecretKey();
+  if (!set(KEY, toHex(sk))) throw new Error("this browser does not allow storing the key");
+  return sk;
+}
+export function identity() {
+  const sk = secret();
+  if (!sk) return null;
+  const pubkey = getPublicKey(sk);
+  return { pubkey, npub: npubEncode(pubkey) };
+}
+export const backup = () => nsecEncode(secret());
+export function restore(text) {
+  const value = text.trim();
+  let sk;
+  if (/^[0-9a-f]{64}$/.test(value)) sk = fromHex(value);
+  else { const d = decode(value); if (d.type !== "nsec") throw new Error("that is not a secret key (it should start with nsec1)"); sk = d.data; }
+  getPublicKey(sk); // throws if it is not a valid key
+  set(KEY, toHex(sk));
+}
+export const forget = () => set(KEY, null);
+
+// ── What this browser knows about its own submissions ──
+// { items: [{ type: "response" | "observation", id, answers, updated, event, relays }] }
+const storeKey = (cfg) => `cfi-survey-${cfg.edition}-mine`;
+export function mine(cfg) { try { return JSON.parse(get(storeKey(cfg)) ?? "null") ?? { items: [] }; } catch { return { items: [] }; } }
+export const remember = (cfg, store) => set(storeKey(cfg), JSON.stringify(store));
+export const newId = () => toHex(crypto.getRandomValues(new Uint8Array(6)));
+
+// ── Events ──
+const dTag = (cfg, item) => `${cfg.topic}:${item.type === "response" ? "response" : `place:${item.id}`}`;
+// `removed` signs an empty replacement, which overwrites the earlier version on the relays
+export function sign(cfg, item, removed = false) {
+  const sk = secret(true);
+  const plain = JSON.stringify(removed ? { type: item.type, edition: cfg.edition, deleted: true } : { type: item.type, edition: cfg.edition, answers: item.answers });
+  const tags = [["d", dTag(cfg, item)], ["t", cfg.topic], ["alt", `State of Cannabis ${cfg.edition}: an anonymous survey answer`]];
+  if (cfg.pubkey) tags.push(["p", cfg.pubkey]);
+  return finalizeEvent({ kind: cfg.kind, created_at: Math.floor(Date.now() / 1000), tags, content: cfg.pubkey ? encrypt(plain, getConversationKey(sk, cfg.pubkey)) : plain }, sk);
+}
+// a deletion request (NIP-09) for events of ours, for relays that honour it
+export function deletion(cfg, events) {
+  const sk = secret();
+  const tags = events.flatMap((e) => [["e", e.id], ["a", `${cfg.kind}:${e.pubkey}:${e.tags.find((t) => t[0] === "d")[1]}`]]);
+  return finalizeEvent({ kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [...tags, ["k", String(cfg.kind)]], content: "" }, sk);
+}
+
+// Where a stored part can be looked at from outside: its event id, and its address on njump.me,
+// a public Nostr viewer. The address names the replaceable event (kind, key and `d` tag), so the
+// link keeps pointing at the newest version after an edit. The viewer shows only ciphertext.
+export function reference(cfg, item) {
+  const e = item.event;
+  if (!e) return null;
+  const naddr = naddrEncode({ kind: e.kind, pubkey: e.pubkey, identifier: e.tags.find((t) => t[0] === "d")?.[1] ?? "", relays: (cfg.relays ?? []).slice(0, 2) });
+  return { id: e.id, url: `https://njump.me/${naddr}` };
+}
+
+// ── Relays ──
+const within = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(new Error("timed out")), ms))]);
+// Offers every event to every relay; returns for each event how many relays stored it.
+export async function publish(cfg, events) {
+  const pool = new SimplePool();
+  try {
+    return await Promise.all(events.map(async (event) => {
+      const results = await Promise.allSettled(pool.publish(cfg.relays, event).map((p) => within(p, 8000)));
+      return { id: event.id, relays: results.filter((r) => r.status === "fulfilled").length, error: results.find((r) => r.status === "rejected")?.reason?.message ?? null };
+    }));
+  } finally { pool.close(cfg.relays); }
+}
+// What the relays hold under this browser's key, decrypted: the newest version of each part, and
+// on how many relays that version sits. Each relay is asked by itself, so that one slow or
+// unreachable relay neither hides the others nor inflates the count.
+export async function fetchMine(cfg) {
+  const sk = secret();
+  if (!sk || !cfg.open) return [];
+  const filter = { kinds: [cfg.kind], authors: [getPublicKey(sk)], "#t": [cfg.topic] };
+  const perRelay = await Promise.all(cfg.relays.map(async (url) => {
+    const pool = new SimplePool();
+    try { return await within(pool.querySync([url], filter), 8000); } catch { return []; } finally { pool.close([url]); }
+  }));
+  const newest = new Map(); // d tag → { event, relays }
+  for (const events of perRelay) {
+    for (const e of events) {
+      const d = e.tags.find((t) => t[0] === "d")?.[1];
+      if (!d) continue;
+      const had = newest.get(d);
+      if (!had || had.event.created_at < e.created_at) newest.set(d, { event: e, relays: 1 });
+      else if (had.event.id === e.id) had.relays++;
+    }
+  }
+  const out = [];
+  for (const [d, { event, relays }] of newest) {
+    try {
+      const p = JSON.parse(event.content.startsWith("{") ? event.content : decrypt(event.content, getConversationKey(sk, cfg.pubkey)));
+      if (p.deleted || p.edition !== cfg.edition) continue;
+      out.push({ type: p.type, id: p.type === "response" ? "response" : d.split(":place:")[1], answers: p.answers, updated: event.created_at, event, relays });
+    } catch {}
+  }
+  return out;
+}

src/lib/survey.js +20 −0
@@ -0,0 +1,20 @@
+// State of Cannabis: the questionnaire of the current edition (survey/<year>.yaml) and its counted
+// results (survey/results-<year>.json, written by scripts/survey-mock.cjs or survey-collect.cjs).
+// The form and the results page are both built from these two files.
+import yaml from "js-yaml";
+import raw from "../../survey/2026.yaml?raw";
+import results from "../../survey/results-2026.json";
… 15 more lines
src/pages/culture.astro +1 −1

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/data/survey.json.js +6 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/search.json.js +1 −1

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/sitemap.xml.js +2 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/survey/index.astro +225 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/survey/me.astro +201 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/survey/take.astro +289 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/styles/global.css +198 −0

Not shown: a picture, a generated file, or past the length shown per change.

survey/2026.yaml +348 −0

Not shown: a picture, a generated file, or past the length shown per change.

survey/currencies.yaml +100 −0

Not shown: a picture, a generated file, or past the length shown per change.

survey/results-2026.json +4490 −0

Not shown: a picture, a generated file, or past the length shown per change.

    ↑↓ to move, ↵ to open.