create-volt 0.13.0 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.15.0] - 2026-06-28
8
+
9
+ ### Added
10
+ - **`pages` add-on** — markdown pages, no database and no admin. Drop `.md`
11
+ files in `pages/` and each is served as HTML at `/<slug>`; front-matter
12
+ `title:` sets the page title. Author them in your editor or with AI. Pulls in
13
+ `marked` (added on enable, tracked by the dependency auto-updater); the
14
+ `pages/` directory is auto-created with a sample on first run. Mounted last,
15
+ so your own app routes always win.
16
+
17
+ ## [0.14.0] - 2026-06-28
18
+
19
+ ### Changed
20
+ - **Adopted the most-secure admin model: ephemeral, shell-only.** Removed the
21
+ persistent role-gated `admin` add-on (from 0.13.0). There is now **no web
22
+ admin** anywhere — the data browser is the ephemeral, localhost-only
23
+ `--studio`, and config is `--edit`; both are shell/SSH-gated. SECURITY.md
24
+ updated to state this as a core property.
25
+
26
+ ### Added
27
+ - Dependency auto-update: `scripts/update-deps.mjs` + a weekly GitHub Action
28
+ bump create-volt's pinned dependency floors to the latest **within the current
29
+ major** (never a breaking major). Repo-only — scaffolded apps are untouched.
30
+ - Refreshed floors: express ^4.22.2, socket.io ^4.8.3, mongodb ^6.21.0,
31
+ mysql2 ^3.22.5, pg ^8.22.0, nodemailer ^6.10.1.
32
+
7
33
  ## [0.13.0] - 2026-06-28
8
34
 
9
35
  ### Added
@@ -187,6 +213,8 @@ All notable changes to `create-volt` are documented here. The format follows
187
213
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
188
214
  and auto-detects npm / pnpm / yarn / bun for the install step.
189
215
 
216
+ [0.15.0]: https://github.com/MIR-2025/volt/releases/tag/v0.15.0
217
+ [0.14.0]: https://github.com/MIR-2025/volt/releases/tag/v0.14.0
190
218
  [0.13.0]: https://github.com/MIR-2025/volt/releases/tag/v0.13.0
191
219
  [0.12.0]: https://github.com/MIR-2025/volt/releases/tag/v0.12.0
192
220
  [0.11.0]: https://github.com/MIR-2025/volt/releases/tag/v0.11.0
package/README.md CHANGED
@@ -82,14 +82,12 @@ MySQL / Postgres) to show data; the memory driver is per-process.
82
82
 
83
83
  ## Security & admin model
84
84
 
85
- Volt is secure by default, and deliberately has **no standing admin route** —
86
- the config wizard (`--edit`) and data browser (`--studio`) are ephemeral,
87
- localhost-only processes; **shell/SSH access is the auth**. Several admins? Give
88
- each an SSH key — stronger than a shared web panel, with nothing public to
89
- attack. A persistent, role-gated web admin exists only as an **opt-in** add-on
90
- (`admin`, gated by auth + an `ADMIN_EMAILS` allowlist) for when you truly need
91
- browser-only admins. Plus: escaping by default (no XSS), server-side validation
92
- + caps, security headers, and `HttpOnly`+`SameSite` cookies. See
85
+ Volt is secure by default, and deliberately has **no web admin** — nothing like
86
+ `/wp-admin` anywhere. The config wizard (`--edit`) and data browser (`--studio`)
87
+ are ephemeral, localhost-only processes; **shell/SSH access is the auth**.
88
+ Several admins? Give each an SSH key — stronger than a shared web panel, with
89
+ nothing public to attack. Plus: escaping by default (no XSS), server-side
90
+ validation + caps, security headers, and `HttpOnly`+`SameSite` cookies. See
93
91
  [SECURITY.md](https://github.com/MIR-2025/volt/blob/main/SECURITY.md).
94
92
 
95
93
  ## Updating Volt
@@ -0,0 +1,79 @@
1
+ // pages.js — markdown pages. Drop *.md files in pages/ and each is served as
2
+ // HTML at /<slug>. No database, no admin: author them in your editor or with AI.
3
+ // Pages are code-owned files (trusted), so their markdown HTML is served as-is.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import express from "express";
7
+ import { marked } from "marked";
8
+
9
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
10
+
11
+ const SAMPLE = `---
12
+ title: Welcome
13
+ ---
14
+
15
+ # Your Volt pages
16
+
17
+ This page is a markdown file at \`pages/welcome.md\`, served at \`/welcome\`.
18
+
19
+ - Drop more \`.md\` files in \`pages/\` — each becomes a page at \`/<filename>\`.
20
+ - Add front-matter to set the title:
21
+
22
+ \`\`\`
23
+ ---
24
+ title: About us
25
+ ---
26
+ \`\`\`
27
+
28
+ Author them in your editor, or ask an AI to write them. No database, no admin.
29
+ `;
30
+
31
+ function ensure(dir) {
32
+ if (!fs.existsSync(dir)) {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ fs.writeFileSync(path.join(dir, "welcome.md"), SAMPLE);
35
+ }
36
+ }
37
+
38
+ const FM = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
39
+ function parse(src) {
40
+ const m = src.match(FM);
41
+ if (!m) return { meta: {}, body: src };
42
+ const meta = {};
43
+ for (const line of m[1].split(/\r?\n/)) {
44
+ const i = line.indexOf(":");
45
+ if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
46
+ }
47
+ return { meta, body: src.slice(m[0].length) };
48
+ }
49
+
50
+ function shell(title, inner) {
51
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
52
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
53
+ <title>${esc(title)}</title>
54
+ <style>
55
+ :root { color-scheme: light dark }
56
+ body { max-width: 720px; margin: 2.5rem auto; padding: 0 1.1rem; font: 17px/1.7 system-ui, -apple-system, sans-serif; }
57
+ h1, h2, h3 { line-height: 1.25; margin: 1.6rem 0 .6rem }
58
+ pre { background: #0b0d11; color: #cfe3ff; padding: 1rem; border-radius: 10px; overflow: auto }
59
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em }
60
+ :not(pre) > code { background: rgba(127,127,127,.18); padding: .1em .35em; border-radius: 5px }
61
+ img { max-width: 100% } a { color: #0b67d6 }
62
+ blockquote { border-left: 3px solid #ccc; margin: 1rem 0; padding: .2rem 1rem; opacity: .8 }
63
+ table { border-collapse: collapse } td, th { border: 1px solid #ccc; padding: .4rem .7rem }
64
+ </style></head><body>${inner}</body></html>`;
65
+ }
66
+
67
+ export function pagesRouter({ dir }) {
68
+ ensure(dir);
69
+ const r = express.Router();
70
+ r.get("/:slug", (req, res, next) => {
71
+ const slug = req.params.slug;
72
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return next(); // safe slug only — no traversal
73
+ const file = path.join(dir, slug + ".md");
74
+ if (!fs.existsSync(file)) return next();
75
+ const { meta, body } = parse(fs.readFileSync(file, "utf8"));
76
+ res.type("html").send(shell(meta.title || slug, marked.parse(body)));
77
+ });
78
+ return r;
79
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Markdown pages: drop .md files in pages/ and each is served as HTML at /<slug>. No database, no admin — author them in your editor or with AI.",
3
+ "dependsOn": [],
4
+ "sentinel": "lib/pages.js",
5
+ "install": ["marked"],
6
+ "optional": {},
7
+ "wiring": "Add markdown files to the pages/ directory (created automatically on first run). pages/about.md is served at /about; front-matter `title:` sets the page title."
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "dev": "node server.js"
11
11
  },
12
12
  "dependencies": {
13
- "express": "^4.19.2",
14
- "socket.io": "^4.7.5"
13
+ "express": "^4.22.2",
14
+ "socket.io": "^4.8.3"
15
15
  }
16
16
  }
@@ -86,11 +86,4 @@ if (enabled.includes("realtime")) {
86
86
  /* realtime UI unavailable */
87
87
  }
88
88
  }
89
- if (enabled.includes("admin")) {
90
- try {
91
- nodes.push((await import("/admin-ui.js")).adminPanel());
92
- } catch {
93
- /* admin UI unavailable */
94
- }
95
- }
96
89
  mount("#app", ...nodes);
@@ -21,8 +21,8 @@ const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
- const PKG_VERSIONS = { mongodb: "^6.8.0", mysql2: "^3.11.0", pg: "^8.12.0", nodemailer: "^6.9.0" };
25
- const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", admin: "admin.js" };
24
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5" };
25
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js" };
26
26
 
27
27
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
28
28
  function readEnvFile() {
@@ -101,11 +101,6 @@ async function startApp() {
101
101
  if (enabled.has("db")) store = await (await addonMod("db")).createStore();
102
102
  if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
103
103
  if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
104
- if (enabled.has("admin") && store && enabled.has("auth")) {
105
- const adminEmails = String(process.env.ADMIN_EMAILS || "").split(",").map((s) => s.trim()).filter(Boolean);
106
- const requireAuth = (await addonMod("auth")).requireAuth(store);
107
- app.use((await addonMod("admin")).adminRouter({ store, requireAuth, adminEmails }));
108
- }
109
104
 
110
105
  // expose which add-ons are on, and serve each enabled add-on's frontend assets
111
106
  app.get("/__volt/addons", (_req, res) => res.json([...enabled]));
@@ -116,6 +111,9 @@ async function startApp() {
116
111
 
117
112
  app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
118
113
 
114
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
115
+ if (enabled.has("pages")) app.use((await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
116
+
119
117
  const server = http.createServer(app);
120
118
  const io = new SocketServer(server);
121
119
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
@@ -161,6 +159,7 @@ function neededPackages(env) {
161
159
  if (driver === "mysql") want.push("mysql2");
162
160
  if (driver === "postgres") want.push("pg");
163
161
  if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
162
+ if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
164
163
  return want.filter((p) => !deps[p]);
165
164
  }
166
165
 
@@ -17,7 +17,6 @@ const state = signal({
17
17
  dbUrl: current.DATABASE_URL || "",
18
18
  smtpUrl: current.SMTP_URL || "",
19
19
  mailFrom: current.MAIL_FROM || "",
20
- adminEmails: current.ADMIN_EMAILS || "",
21
20
  port: current.PORT || String(defaultPort),
22
21
  });
23
22
  const set = (patch) => state({ ...state(), ...patch });
@@ -54,7 +53,6 @@ function genEnv(s) {
54
53
  else out.push("# SMTP_URL= # unset → emails print to the console");
55
54
  if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
56
55
  }
57
- if (eff.includes("admin")) out.push(`ADMIN_EMAILS=${clean(s.adminEmails)}`);
58
56
  return out.join("\n") + "\n";
59
57
  }
60
58
  const env = computed(() => genEnv(state()));
@@ -150,7 +148,6 @@ mount(
150
148
  ${field("PORT", "port", String(defaultPort))}
151
149
  ${() => (eff().includes("db") ? dbSettings() : null)}
152
150
  ${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
153
- ${() => (eff().includes("admin") ? field("ADMIN_EMAILS (comma-separated)", "adminEmails", "you@example.com") : null)}
154
151
  </div>`,
155
152
  html`<div class="card-x p-4 mb-3">
156
153
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -10,12 +10,12 @@
10
10
  "dev": "node server.js"
11
11
  },
12
12
  "dependencies": {
13
- "express": "^4.19.2",
14
- "socket.io": "^4.7.5"
13
+ "express": "^4.22.2",
14
+ "socket.io": "^4.8.3"
15
15
  },
16
16
  "optionalDependencies": {
17
- "mongodb": "^6.8.0",
18
- "mysql2": "^3.11.0",
19
- "pg": "^8.12.0"
17
+ "mongodb": "^6.21.0",
18
+ "mysql2": "^3.22.5",
19
+ "pg": "^8.22.0"
20
20
  }
21
21
  }
@@ -10,7 +10,7 @@
10
10
  "dev": "node server.js"
11
11
  },
12
12
  "dependencies": {
13
- "express": "^4.19.2",
14
- "socket.io": "^4.7.5"
13
+ "express": "^4.22.2",
14
+ "socket.io": "^4.8.3"
15
15
  }
16
16
  }
@@ -137,21 +137,10 @@ if (hasChat) {
137
137
  /* chat UI unavailable */
138
138
  }
139
139
  }
140
- // Admin tab appears only for users in the ADMIN_EMAILS allowlist.
141
- if (enabled.includes("admin")) {
142
- try {
143
- const r = await (await fetch("/admin/api/me")).json();
144
- if (r.isAdmin) sections.admin = (await import("/admin-ui.js")).adminPanel();
145
- } catch {
146
- /* not signed in / not admin */
147
- }
148
- }
149
-
150
140
  const TABS = [
151
141
  ["home", "Home"],
152
142
  ...(hasAuth ? [["notes", "Notes"]] : []),
153
143
  ...(sections.chat ? [["chat", "Chat"]] : []),
154
- ...(sections.admin ? [["admin", "Admin"]] : []),
155
144
  ["account", "Account"],
156
145
  ];
157
146
 
@@ -22,8 +22,8 @@ const ENV_PATH = path.join(__dirname, ".env");
22
22
  const PKG_PATH = path.join(__dirname, "package.json");
23
23
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
- const PKG_VERSIONS = { mongodb: "^6.8.0", mysql2: "^3.11.0", pg: "^8.12.0", nodemailer: "^6.9.0" };
26
- const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", admin: "admin.js" };
25
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5" };
26
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js" };
27
27
 
28
28
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
29
29
  function readEnvFile() {
@@ -102,11 +102,6 @@ async function startApp() {
102
102
  if (enabled.has("db")) store = await (await addonMod("db")).createStore();
103
103
  if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
104
104
  if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
105
- if (enabled.has("admin") && store && enabled.has("auth")) {
106
- const adminEmails = String(process.env.ADMIN_EMAILS || "").split(",").map((s) => s.trim()).filter(Boolean);
107
- const requireAuth = (await addonMod("auth")).requireAuth(store);
108
- app.use((await addonMod("admin")).adminRouter({ store, requireAuth, adminEmails }));
109
- }
110
105
 
111
106
  // notes — a per-user CRUD example (auth-gated, owner-scoped, db-backed)
112
107
  if (enabled.has("db") && enabled.has("auth") && store) {
@@ -142,6 +137,9 @@ async function startApp() {
142
137
 
143
138
  app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
144
139
 
140
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
141
+ if (enabled.has("pages")) app.use((await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
142
+
145
143
  const server = http.createServer(app);
146
144
  const io = new SocketServer(server);
147
145
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
@@ -187,6 +185,7 @@ function neededPackages(env) {
187
185
  if (driver === "mysql") want.push("mysql2");
188
186
  if (driver === "postgres") want.push("pg");
189
187
  if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
188
+ if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
190
189
  return want.filter((p) => !deps[p]);
191
190
  }
192
191
 
@@ -17,7 +17,6 @@ const state = signal({
17
17
  dbUrl: current.DATABASE_URL || "",
18
18
  smtpUrl: current.SMTP_URL || "",
19
19
  mailFrom: current.MAIL_FROM || "",
20
- adminEmails: current.ADMIN_EMAILS || "",
21
20
  port: current.PORT || String(defaultPort),
22
21
  });
23
22
  const set = (patch) => state({ ...state(), ...patch });
@@ -54,7 +53,6 @@ function genEnv(s) {
54
53
  else out.push("# SMTP_URL= # unset → emails print to the console");
55
54
  if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
56
55
  }
57
- if (eff.includes("admin")) out.push(`ADMIN_EMAILS=${clean(s.adminEmails)}`);
58
56
  return out.join("\n") + "\n";
59
57
  }
60
58
  const env = computed(() => genEnv(state()));
@@ -150,7 +148,6 @@ mount(
150
148
  ${field("PORT", "port", String(defaultPort))}
151
149
  ${() => (eff().includes("db") ? dbSettings() : null)}
152
150
  ${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
153
- ${() => (eff().includes("admin") ? field("ADMIN_EMAILS (comma-separated)", "adminEmails", "you@example.com") : null)}
154
151
  </div>`,
155
152
  html`<div class="card-x p-4 mb-3">
156
153
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -1,44 +0,0 @@
1
- // admin.js — opt-in, role-gated web admin (data browser) for the running app.
2
- // This is the ONE deliberately-persistent privileged surface in Volt, for
3
- // browser-only admins. Every route requires a session AND membership in the
4
- // ADMIN_EMAILS allowlist. Internal collections (auth tokens/sessions) are hidden.
5
- // Prefer `npm run dev -- --studio` (ephemeral) unless you truly need this.
6
-
7
- import express from "express";
8
-
9
- const HIDDEN = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
10
- const visible = (n) => n && !HIDDEN.has(n);
11
-
12
- export function adminRouter({ store, requireAuth, adminEmails }) {
13
- const allow = new Set((adminEmails || []).map((e) => String(e).trim().toLowerCase()).filter(Boolean));
14
- const isAdmin = (email) => allow.has(String(email || "").toLowerCase());
15
-
16
- const r = express.Router();
17
- r.use(requireAuth); // must be signed in for anything under /admin/api
18
-
19
- // any signed-in user may ask whether *they* are an admin (drives the UI)
20
- r.get("/admin/api/me", (req, res) => res.json({ email: req.user.email, isAdmin: isAdmin(req.user.email) }));
21
-
22
- // everything below is admins-only
23
- r.use((req, res, next) => (isAdmin(req.user.email) ? next() : res.status(403).json({ ok: false, error: "Admins only." })));
24
-
25
- r.get("/admin/api/collections", async (_req, res) => {
26
- const all = (await store.collections()) || [];
27
- res.json({ driver: store.name, collections: all.filter(visible) });
28
- });
29
- r.get("/admin/api/collection", async (req, res) => {
30
- const name = String(req.query.name || "");
31
- if (!visible(name)) return res.status(403).json({ ok: false, error: "hidden" });
32
- res.json({ ok: true, name, docs: (await store.collection(name).all()).slice(0, 500) });
33
- });
34
- r.delete("/admin/api/doc", async (req, res) => {
35
- const name = String(req.query.name || "");
36
- const id = String(req.query.id || "");
37
- if (!visible(name)) return res.status(403).json({ ok: false, error: "hidden" });
38
- if (!id) return res.status(400).json({ ok: false, error: "missing id" });
39
- await store.collection(name).delete(id);
40
- res.json({ ok: true });
41
- });
42
-
43
- return r;
44
- }
@@ -1,62 +0,0 @@
1
- // admin-ui.js — the role-gated admin panel (frontend for the admin add-on).
2
- // Renders nothing for non-admins; for admins, a data browser over /admin/api/*.
3
- // Mounted by the app when the admin add-on is enabled.
4
- import { signal, html } from "/volt.js";
5
-
6
- const j = async (url, opts) => {
7
- const res = await fetch(url, opts);
8
- return { status: res.status, body: await res.json().catch(() => ({})) };
9
- };
10
-
11
- export function adminPanel() {
12
- const ready = signal(false);
13
- const isAdmin = signal(false);
14
- const driver = signal("");
15
- const collections = signal([]);
16
- const current = signal("");
17
- const docs = signal([]);
18
- const note = signal("");
19
-
20
- async function init() {
21
- const { status, body } = await j("/admin/api/me");
22
- if (status === 200) isAdmin(!!body.isAdmin);
23
- ready(true);
24
- if (isAdmin()) load();
25
- }
26
- async function load() {
27
- const { body } = await j("/admin/api/collections");
28
- driver(body.driver || "");
29
- collections(body.collections || []);
30
- if (!current() && collections().length) open(collections()[0]);
31
- }
32
- async function open(name) {
33
- current(name);
34
- const { body } = await j(`/admin/api/collection?name=${encodeURIComponent(name)}`);
35
- docs(body.ok ? body.docs : []);
36
- }
37
- async function del(id) {
38
- await j(`/admin/api/doc?name=${encodeURIComponent(current())}&id=${encodeURIComponent(id)}`, { method: "DELETE" });
39
- docs(docs().filter((d) => d.id !== id));
40
- note("Deleted.");
41
- }
42
- init();
43
-
44
- const tab = (name) =>
45
- html`<button class=${() => "btn btn-sm " + (current() === name ? "btn-primary" : "btn-outline-secondary")} onclick=${() => open(name)}>${name}</button>`;
46
- const row = (d) =>
47
- html`<div class="d-flex justify-content-between align-items-start gap-2 py-1" style="border-top:1px solid #232a36">
48
- <pre class="mb-0 small flex-grow-1" style="white-space:pre-wrap;color:#cfe3ff">${JSON.stringify(d, null, 2)}</pre>
49
- <button class="btn btn-sm btn-outline-danger" onclick=${() => del(d.id)}>✕</button>
50
- </div>`;
51
-
52
- // hidden entirely for non-admins (and until we know)
53
- return html`${() =>
54
- !ready() || !isAdmin()
55
- ? null
56
- : html`<div class="card-x p-4 mb-4">
57
- <h2 class="h6 mb-3">Admin <span class="text-muted small">— data ${() => (driver() ? "· " + driver() : "")}</span></h2>
58
- <div class="d-flex flex-wrap gap-1 mb-2">${() => (collections().length ? collections().map(tab) : html`<span class="text-muted small">No collections yet.</span>`)}</div>
59
- <div style="max-height:260px;overflow:auto">${() => (docs().length ? docs().map(row) : html`<span class="text-muted small">Empty.</span>`)}</div>
60
- ${() => (note() ? html`<p class="small text-muted mb-0 mt-2">${note}</p>` : null)}
61
- </div>`}`;
62
- }
@@ -1,8 +0,0 @@
1
- {
2
- "description": "Opt-in role-gated web admin: a persistent /admin data browser for browser-only admins (auth + ADMIN_EMAILS allowlist). Adds a standing surface — prefer `--studio` unless you need non-shell admins.",
3
- "dependsOn": ["db", "auth"],
4
- "sentinel": "lib/admin.js",
5
- "install": ["express"],
6
- "optional": {},
7
- "wiring": "Set ADMIN_EMAILS in .env to a comma-separated allowlist of admin emails. Only those signed-in users see the admin panel and can reach /admin/api/*."
8
- }