create-volt 0.9.0 → 0.10.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,23 @@ 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.10.0] - 2026-06-28
8
+
9
+ ### Added
10
+ - Frontend UI for the user-facing add-ons, auto-mounted when enabled:
11
+ - **auth** → a magic-link sign-in panel (email → link → signed-in state)
12
+ - **realtime** → a live chat panel (rooms, presence, typing, messages)
13
+ Each add-on serves its own `/<name>-ui.js` (only when enabled); `public/app.js`
14
+ mounts them alongside the demo. The server exposes `GET /__volt/addons`.
15
+
16
+ ### Security
17
+ - Security headers on every response: `X-Content-Type-Options: nosniff`,
18
+ `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: same-origin`, `X-Powered-By` off.
19
+ - Hardened forms: typed / length-capped / autocompleted inputs; all user content
20
+ renders through Volt holes (text nodes — HTML-escaped, no innerHTML); server
21
+ validation + caps (email ≤ 320, chat ≤ 500); `.env` values stripped of newlines;
22
+ session cookies `HttpOnly` + `SameSite=Lax`.
23
+
7
24
  ## [0.9.0] - 2026-06-28
8
25
 
9
26
  ### Changed
@@ -129,6 +146,7 @@ All notable changes to `create-volt` are documented here. The format follows
129
146
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
130
147
  and auto-detects npm / pnpm / yarn / bun for the install step.
131
148
 
149
+ [0.10.0]: https://github.com/MIR-2025/volt/releases/tag/v0.10.0
132
150
  [0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
133
151
  [0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.0
134
152
  [0.7.0]: https://github.com/MIR-2025/volt/releases/tag/v0.7.0
@@ -72,7 +72,7 @@ export function authRouter({ store, mailer }) {
72
72
 
73
73
  router.post("/api/login", async (req, res) => {
74
74
  const email = normalize(req.body?.email);
75
- if (!validEmail(email)) return res.status(400).json({ ok: false, error: "Enter a valid email." });
75
+ if (!validEmail(email) || email.length > 320) return res.status(400).json({ ok: false, error: "Enter a valid email." });
76
76
  const tok = token();
77
77
  await tokens.put(tok, { email, ua: req.headers["user-agent"] || "", expiresAt: Date.now() + TOKEN_TTL, used: false });
78
78
  const link = `${req.protocol}://${req.get("host")}/verify?token=${tok}`;
@@ -0,0 +1,62 @@
1
+ // auth-ui.js — magic-link sign-in panel (frontend for the auth add-on).
2
+ // Served at /auth-ui.js when auth is enabled; mounted by public/app.js.
3
+ // All dynamic text is rendered through Volt holes, which create text nodes
4
+ // (HTML-escaped) — so emails/notices can't inject markup.
5
+ import { signal, html } from "/volt.js";
6
+
7
+ const api = async (url, body) => {
8
+ const res = await fetch(url, {
9
+ method: body ? "POST" : "GET",
10
+ headers: body ? { "Content-Type": "application/json" } : undefined,
11
+ body: body ? JSON.stringify(body) : undefined,
12
+ });
13
+ return res.json().catch(() => ({}));
14
+ };
15
+
16
+ // local part only, for a friendlier "signed in as" (still escaped on render)
17
+ const shortName = (email) => String(email || "").split("@")[0];
18
+
19
+ export function authPanel() {
20
+ const me = signal(null);
21
+ const email = signal("");
22
+ const notice = signal("");
23
+ const busy = signal(false);
24
+
25
+ api("/api/me").then((r) => me(r.email || null));
26
+
27
+ async function sendLink(e) {
28
+ e?.preventDefault?.();
29
+ const addr = email().trim();
30
+ if (!addr || busy()) return;
31
+ busy(true);
32
+ notice("Sending…");
33
+ const r = await api("/api/login", { email: addr });
34
+ busy(false);
35
+ notice(r.ok ? (r.dev ? "Magic link printed to the server console — open it to sign in." : `Magic link sent to ${addr}.`) : r.error || "Could not send link.");
36
+ }
37
+ async function logout() {
38
+ await api("/api/logout", {});
39
+ me(null);
40
+ notice("");
41
+ }
42
+
43
+ const signedOut = () =>
44
+ html`<form class="d-flex gap-2" onsubmit=${sendLink} autocomplete="on">
45
+ <input class="form-control" type="email" name="email" placeholder="you@example.com"
46
+ maxlength="320" autocomplete="email" inputmode="email" required
47
+ value=${email} oninput=${(e) => email(e.target.value)} />
48
+ <button class="btn btn-primary" type="submit" disabled=${() => busy()}>Send magic link</button>
49
+ </form>`;
50
+
51
+ const signedIn = () =>
52
+ html`<div class="d-flex justify-content-between align-items-center">
53
+ <span class="text-muted small">Signed in as <span class="accent fw-semibold">${() => shortName(me())}</span></span>
54
+ <button class="btn btn-sm btn-outline-secondary" onclick=${logout}>Sign out</button>
55
+ </div>`;
56
+
57
+ return html`<div class="card-x p-4 mb-4">
58
+ <h2 class="h6 mb-3">Account <span class="text-muted small">— magic-link auth</span></h2>
59
+ ${() => (me() ? signedIn() : signedOut())}
60
+ ${() => (notice() ? html`<p class="small text-muted mb-0 mt-2">${notice}</p>` : null)}
61
+ </div>`;
62
+ }
@@ -0,0 +1,116 @@
1
+ // chat-ui.js — live chat panel (frontend for the realtime add-on). Served at
2
+ // /chat-ui.js when realtime is enabled; mounted by public/app.js.
3
+ // Identity comes from the auth session (if auth is on), else "guest-…".
4
+ // All message text and names render through Volt holes → text nodes (escaped),
5
+ // so nothing a user types can inject markup.
6
+ import { signal, computed, html } from "/volt.js";
7
+ import { createChat } from "/chat-client.js";
8
+
9
+ const ROOMS = ["general", "random"];
10
+ const MAX = 500; // keep in sync with the server cap
11
+
12
+ const fmtTime = (ts) => {
13
+ try {
14
+ return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
15
+ } catch {
16
+ return "";
17
+ }
18
+ };
19
+
20
+ export function chatPanel() {
21
+ const room = signal(ROOMS[0]);
22
+ const messages = signal([]);
23
+ const online = signal([]);
24
+ const typers = signal([]);
25
+ const draft = signal("");
26
+
27
+ const timers = new Map();
28
+ const sawTyping = (name) => {
29
+ clearTimeout(timers.get(name));
30
+ if (!typers().includes(name)) typers([...typers(), name]);
31
+ timers.set(
32
+ name,
33
+ setTimeout(() => {
34
+ typers(typers().filter((n) => n !== name));
35
+ timers.delete(name);
36
+ }, 2500),
37
+ );
38
+ };
39
+
40
+ let chat;
41
+ try {
42
+ chat = createChat({
43
+ onHistory: ({ room: r, messages: m }) => {
44
+ if (r === room()) messages(m);
45
+ },
46
+ onMessage: (m) => {
47
+ if (m.room === room()) messages([...messages(), m]);
48
+ },
49
+ onPresence: ({ room: r, users }) => {
50
+ if (r === room()) online(users);
51
+ },
52
+ onTyping: ({ name }) => sawTyping(name),
53
+ });
54
+ chat.join(room());
55
+ } catch {
56
+ /* socket.io client missing — panel still renders, just inert */
57
+ }
58
+
59
+ const switchRoom = (r) => {
60
+ if (r === room() || !chat) return;
61
+ room(r);
62
+ messages([]);
63
+ online([]);
64
+ typers([]);
65
+ chat.join(r);
66
+ };
67
+
68
+ let lastTyped = 0;
69
+ const onType = (e) => {
70
+ draft(e.target.value);
71
+ const now = Date.now();
72
+ if (chat && now - lastTyped > 800) {
73
+ chat.typing(room());
74
+ lastTyped = now;
75
+ }
76
+ };
77
+ const send = () => {
78
+ const body = draft().trim();
79
+ if (!body || !chat) return;
80
+ chat.send(room(), body.slice(0, MAX));
81
+ draft("");
82
+ };
83
+
84
+ const typingLine = computed(() => {
85
+ const t = typers().filter((n) => n);
86
+ if (!t.length) return "";
87
+ return t.length === 1 ? `${t[0]} is typing…` : `${t.length} people are typing…`;
88
+ });
89
+
90
+ const roomTab = (r) =>
91
+ html`<button class=${() => "btn btn-sm " + (room() === r ? "btn-primary" : "btn-outline-secondary")} onclick=${() => switchRoom(r)}>#${r}</button>`;
92
+
93
+ const messageRow = (m) =>
94
+ html`<div class="py-1 small">
95
+ <span class="accent fw-semibold">${m.name}</span>
96
+ <span class="text-muted ms-1">${fmtTime(m.createdAt)}</span>
97
+ <div>${m.body}</div>
98
+ </div>`;
99
+
100
+ return html`<div class="card-x p-4 mb-4">
101
+ <div class="d-flex justify-content-between align-items-center mb-2">
102
+ <h2 class="h6 mb-0">Chat <span class="text-muted small">— realtime</span></h2>
103
+ <div class="d-flex gap-1">${ROOMS.map(roomTab)}</div>
104
+ </div>
105
+ <div class="small text-muted mb-2">Online: ${() => online().join(", ") || "—"}</div>
106
+ <div style="max-height:220px;overflow:auto">
107
+ ${() => (messages().length ? messages().map(messageRow) : html`<span class="text-muted small">No messages yet.</span>`)}
108
+ </div>
109
+ <div style="height:1.2em">${() => (typingLine() ? html`<span class="text-muted small fst-italic">${typingLine}</span>` : null)}</div>
110
+ <div class="input-group mt-2">
111
+ <input class="form-control" placeholder=${() => "Message #" + room() + "…"} maxlength=${String(MAX)}
112
+ value=${draft} oninput=${onType} onkeydown=${(e) => e.key === "Enter" && send()} />
113
+ <button class="btn btn-primary" onclick=${send}>Send</button>
114
+ </div>
115
+ </div>`;
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": {
@@ -63,4 +63,27 @@ function Todos() {
63
63
  </div>`;
64
64
  }
65
65
 
66
- mount("#app", Counter(), Todos());
66
+ // Mount the demo, plus the UI for any enabled add-ons (auth, realtime, …).
67
+ // Add-ons serve their own /…-ui.js when turned on in the setup wizard.
68
+ const nodes = [Counter(), Todos()];
69
+ let enabled = [];
70
+ try {
71
+ enabled = await (await fetch("/__volt/addons")).json();
72
+ } catch {
73
+ /* older app without the endpoint — just the demo */
74
+ }
75
+ if (enabled.includes("auth")) {
76
+ try {
77
+ nodes.unshift((await import("/auth-ui.js")).authPanel());
78
+ } catch {
79
+ /* auth UI unavailable */
80
+ }
81
+ }
82
+ if (enabled.includes("realtime")) {
83
+ try {
84
+ nodes.push((await import("/chat-ui.js")).chatPanel());
85
+ } catch {
86
+ /* realtime UI unavailable */
87
+ }
88
+ }
89
+ mount("#app", ...nodes);
@@ -87,6 +87,13 @@ async function startApp() {
87
87
  const PORT = Number(process.env.PORT) || DEFAULT_PORT;
88
88
  const enabled = enabledFrom(process.env);
89
89
  const app = express();
90
+ app.disable("x-powered-by");
91
+ app.use((_req, res, next) => {
92
+ res.setHeader("X-Content-Type-Options", "nosniff");
93
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
94
+ res.setHeader("Referrer-Policy", "same-origin");
95
+ next();
96
+ });
90
97
  app.use(express.static(path.join(__dirname, "public")));
91
98
 
92
99
  let store = null;
@@ -95,6 +102,13 @@ async function startApp() {
95
102
  if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
96
103
  if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
97
104
 
105
+ // expose which add-ons are on, and serve each enabled add-on's frontend assets
106
+ app.get("/__volt/addons", (_req, res) => res.json([...enabled]));
107
+ for (const n of enabled) {
108
+ const pub = path.join(ADDONS_DIR, n, "files", "public");
109
+ if (fs.existsSync(pub)) app.use(express.static(pub));
110
+ }
111
+
98
112
  app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
99
113
 
100
114
  const server = http.createServer(app);
@@ -35,22 +35,23 @@ function effective(s) {
35
35
  return order.filter((n) => want.has(n));
36
36
  }
37
37
 
38
+ const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
38
39
  function genEnv(s) {
39
40
  const eff = effective(s);
40
- const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${s.port}`];
41
+ const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
41
42
  if (eff.includes("db")) {
42
- out.push(`DB_DRIVER=${s.dbDriver}`);
43
+ out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
43
44
  if (s.dbDriver === "mongodb") {
44
- out.push(`MONGODB_URI=${s.mongoUri}`);
45
- if (s.mongoDb) out.push(`MONGODB_DATABASE=${s.mongoDb}`);
45
+ out.push(`MONGODB_URI=${clean(s.mongoUri)}`);
46
+ if (s.mongoDb) out.push(`MONGODB_DATABASE=${clean(s.mongoDb)}`);
46
47
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
47
- out.push(`DATABASE_URL=${s.dbUrl}`);
48
+ out.push(`DATABASE_URL=${clean(s.dbUrl)}`);
48
49
  }
49
50
  }
50
51
  if (eff.includes("mailer")) {
51
- if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
52
+ if (s.smtpUrl) out.push(`SMTP_URL=${clean(s.smtpUrl)}`);
52
53
  else out.push("# SMTP_URL= # unset → emails print to the console");
53
- if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
54
+ if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
54
55
  }
55
56
  return out.join("\n") + "\n";
56
57
  }