create-volt 0.9.0 → 0.11.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 +35 -0
- package/README.md +13 -0
- package/addons/auth/files/lib/auth.js +1 -1
- package/addons/auth/files/public/auth-ui.js +62 -0
- package/addons/db/files/lib/stores/memory.js +1 -1
- package/addons/db/files/lib/stores/mongo.js +8 -1
- package/addons/db/files/lib/stores/sql.js +3 -0
- package/addons/db/files/public/db-admin-ui.js +72 -0
- package/addons/realtime/files/public/chat-ui.js +116 -0
- package/index.js +16 -0
- package/package.json +1 -1
- package/templates/default/public/app.js +24 -1
- package/templates/default/server.js +102 -2
- package/templates/default/setup/setup.js +8 -7
- package/templates/default/setup/studio.html +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ 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.11.0] - 2026-06-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`create-volt studio`** (and `npm run dev -- --studio`) — an ephemeral,
|
|
11
|
+
localhost-only **data browser**, à la Prisma Studio. Browse collections and
|
|
12
|
+
documents across any driver (memory / MongoDB / MySQL / Postgres) and delete
|
|
13
|
+
docs. It's **never a route in the running app** — it exists only while you run
|
|
14
|
+
it, binds `127.0.0.1`, and disappears on Ctrl-C (shell/SSH access is the auth).
|
|
15
|
+
Internal collections (auth tokens/sessions) are hidden.
|
|
16
|
+
- Stores gained `collections()` (enumerate collection names) on every adapter.
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
- Admin/data surfaces are **ephemeral by design** — no standing `/admin` route in
|
|
20
|
+
the running app to attack (verified: the app 404s admin routes). Same model as
|
|
21
|
+
the config editor: on-demand, loopback-only, gone when the app runs.
|
|
22
|
+
|
|
23
|
+
## [0.10.0] - 2026-06-28
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Frontend UI for the user-facing add-ons, auto-mounted when enabled:
|
|
27
|
+
- **auth** → a magic-link sign-in panel (email → link → signed-in state)
|
|
28
|
+
- **realtime** → a live chat panel (rooms, presence, typing, messages)
|
|
29
|
+
Each add-on serves its own `/<name>-ui.js` (only when enabled); `public/app.js`
|
|
30
|
+
mounts them alongside the demo. The server exposes `GET /__volt/addons`.
|
|
31
|
+
|
|
32
|
+
### Security
|
|
33
|
+
- Security headers on every response: `X-Content-Type-Options: nosniff`,
|
|
34
|
+
`X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: same-origin`, `X-Powered-By` off.
|
|
35
|
+
- Hardened forms: typed / length-capped / autocompleted inputs; all user content
|
|
36
|
+
renders through Volt holes (text nodes — HTML-escaped, no innerHTML); server
|
|
37
|
+
validation + caps (email ≤ 320, chat ≤ 500); `.env` values stripped of newlines;
|
|
38
|
+
session cookies `HttpOnly` + `SameSite=Lax`.
|
|
39
|
+
|
|
7
40
|
## [0.9.0] - 2026-06-28
|
|
8
41
|
|
|
9
42
|
### Changed
|
|
@@ -129,6 +162,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
129
162
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
130
163
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
131
164
|
|
|
165
|
+
[0.11.0]: https://github.com/MIR-2025/volt/releases/tag/v0.11.0
|
|
166
|
+
[0.10.0]: https://github.com/MIR-2025/volt/releases/tag/v0.10.0
|
|
132
167
|
[0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
|
|
133
168
|
[0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.0
|
|
134
169
|
[0.7.0]: https://github.com/MIR-2025/volt/releases/tag/v0.7.0
|
package/README.md
CHANGED
|
@@ -66,6 +66,19 @@ SSH-tunnel hint on a remote box). Enabling an add-on wires its **backend**
|
|
|
66
66
|
automatically — the **frontend** UI (login form, chat) is yours to build, or
|
|
67
67
|
start from `--template guestbook`, which has it wired end-to-end.
|
|
68
68
|
|
|
69
|
+
## Studio (data browser)
|
|
70
|
+
|
|
71
|
+
Browse your database on demand — ephemeral and localhost-only, like Prisma Studio:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm run dev -- --studio # or: npx create-volt studio
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It connects the DB in your `.env`, lets you view/delete documents across any
|
|
78
|
+
driver, and is **never** a route in the running app (no standing `/admin` to
|
|
79
|
+
attack — shell/SSH access is the gate). Needs a persistent driver (MongoDB /
|
|
80
|
+
MySQL / Postgres) to show data; the memory driver is per-process.
|
|
81
|
+
|
|
69
82
|
## Updating Volt
|
|
70
83
|
|
|
71
84
|
Volt is vendored as a single file (`public/volt.js`), not an npm dependency.
|
|
@@ -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
|
+
}
|
|
@@ -39,5 +39,12 @@ export async function createMongoStore({ uri, dbName }) {
|
|
|
39
39
|
};
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
name: "mongodb",
|
|
44
|
+
async init() {},
|
|
45
|
+
collection,
|
|
46
|
+
async collections() {
|
|
47
|
+
return (await db.listCollections().toArray()).map((c) => c.name);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
43
50
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// db-admin-ui.js — a tiny, auth-gated data browser (frontend for the db add-on).
|
|
2
|
+
// Served at /db-admin-ui.js; mounted by public/app.js only when db + auth are on.
|
|
3
|
+
// Works for every driver — it reads through the generic store API on the server.
|
|
4
|
+
// Documents render as escaped JSON text (Volt holes → text nodes), never HTML.
|
|
5
|
+
import { signal, computed, html } from "/volt.js";
|
|
6
|
+
|
|
7
|
+
const j = async (url, opts) => {
|
|
8
|
+
const res = await fetch(url, opts);
|
|
9
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function dbAdminPanel() {
|
|
13
|
+
const ready = signal(false); // signed in + loaded
|
|
14
|
+
const denied = signal(false); // 401
|
|
15
|
+
const driver = signal("");
|
|
16
|
+
const collections = signal([]);
|
|
17
|
+
const current = signal("");
|
|
18
|
+
const docs = signal([]);
|
|
19
|
+
const note = signal("");
|
|
20
|
+
|
|
21
|
+
async function loadCollections() {
|
|
22
|
+
const { status, body } = await j("/admin/db/collections");
|
|
23
|
+
if (status === 401) {
|
|
24
|
+
denied(true);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
denied(false);
|
|
28
|
+
driver(body.driver || "");
|
|
29
|
+
collections(body.collections || []);
|
|
30
|
+
ready(true);
|
|
31
|
+
if (!current() && collections().length) openCollection(collections()[0]);
|
|
32
|
+
}
|
|
33
|
+
async function openCollection(name) {
|
|
34
|
+
current(name);
|
|
35
|
+
note("");
|
|
36
|
+
const { body } = await j(`/admin/db/collection?name=${encodeURIComponent(name)}`);
|
|
37
|
+
docs(body.ok ? body.docs : []);
|
|
38
|
+
}
|
|
39
|
+
async function del(id) {
|
|
40
|
+
if (!id) return;
|
|
41
|
+
await j(`/admin/db/doc?name=${encodeURIComponent(current())}&id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
42
|
+
docs(docs().filter((d) => d.id !== id));
|
|
43
|
+
note("Deleted.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
loadCollections();
|
|
47
|
+
|
|
48
|
+
const collTab = (name) =>
|
|
49
|
+
html`<button class=${() => "btn btn-sm " + (current() === name ? "btn-primary" : "btn-outline-secondary")} onclick=${() => openCollection(name)}>${name}</button>`;
|
|
50
|
+
|
|
51
|
+
const docRow = (d) =>
|
|
52
|
+
html`<div class="d-flex justify-content-between align-items-start gap-2 py-1" style="border-top:1px solid #232a36">
|
|
53
|
+
<pre class="mb-0 small flex-grow-1" style="white-space:pre-wrap;color:#cfe3ff">${JSON.stringify(d, null, 2)}</pre>
|
|
54
|
+
<button class="btn btn-sm btn-outline-danger" onclick=${() => del(d.id)}>✕</button>
|
|
55
|
+
</div>`;
|
|
56
|
+
|
|
57
|
+
const heading = computed(() => `Data ${driver() ? `— ${driver()}` : ""}`);
|
|
58
|
+
|
|
59
|
+
return html`<div class="card-x p-4 mb-4">
|
|
60
|
+
<h2 class="h6 mb-3">${heading} <span class="text-muted small">— admin</span></h2>
|
|
61
|
+
${() =>
|
|
62
|
+
denied()
|
|
63
|
+
? html`<p class="text-muted small mb-0">Sign in to browse your data.</p>`
|
|
64
|
+
: !ready()
|
|
65
|
+
? html`<p class="text-muted small mb-0">Loading…</p>`
|
|
66
|
+
: html`<div class="d-flex flex-wrap gap-1 mb-2">${() => (collections().length ? collections().map(collTab) : html`<span class="text-muted small">No collections yet.</span>`)}</div>
|
|
67
|
+
<div style="max-height:260px;overflow:auto">
|
|
68
|
+
${() => (docs().length ? docs().map(docRow) : html`<span class="text-muted small">Empty.</span>`)}
|
|
69
|
+
</div>
|
|
70
|
+
${() => (note() ? html`<p class="small text-muted mb-0 mt-2">${note}</p>` : null)}`}
|
|
71
|
+
</div>`;
|
|
72
|
+
}
|
|
@@ -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/index.js
CHANGED
|
@@ -36,6 +36,7 @@ ${bold("Usage")}
|
|
|
36
36
|
npx create-volt <project-directory> [options]
|
|
37
37
|
npx create-volt@latest update # refresh public/volt.js in an existing app
|
|
38
38
|
npx create-volt@latest config # open the app's setup wizard (edit add-ons + settings)
|
|
39
|
+
npx create-volt@latest studio # browse your data — ephemeral, localhost (like Prisma Studio)
|
|
39
40
|
|
|
40
41
|
${bold("Options")}
|
|
41
42
|
--template <name> Starter template: default | guestbook (default: default)
|
|
@@ -131,6 +132,21 @@ if (positionals[0] === "config") {
|
|
|
131
132
|
process.exit(res.status ?? 0);
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
// --- `studio` subcommand: ephemeral, localhost-only data browser (server.js --studio) ---
|
|
136
|
+
if (positionals[0] === "studio") {
|
|
137
|
+
const cwd = process.cwd();
|
|
138
|
+
if (!fs.existsSync(path.join(cwd, "server.js"))) {
|
|
139
|
+
die(`No ${cyan("server.js")} here — run ${cyan("create-volt studio")} from inside a Volt app.`);
|
|
140
|
+
}
|
|
141
|
+
if (portArg) process.env.PORT = String(Number(portArg) || "");
|
|
142
|
+
const res = spawnSync(process.execPath, ["server.js", "--studio"], {
|
|
143
|
+
cwd,
|
|
144
|
+
stdio: "inherit",
|
|
145
|
+
env: flags.has("--no-open") ? { ...process.env, VOLT_NO_OPEN: "1" } : process.env,
|
|
146
|
+
});
|
|
147
|
+
process.exit(res.status ?? 0);
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
// Resolve the dev port: --port wins, else derive it from today's date as
|
|
135
151
|
// two-digit-year + month (no leading zero) + two-digit-day (house convention),
|
|
136
152
|
// so apps scaffolded on different days don't collide on the same port.
|
package/package.json
CHANGED
|
@@ -63,4 +63,27 @@ function Todos() {
|
|
|
63
63
|
</div>`;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
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);
|
|
@@ -285,9 +299,95 @@ function readEnvFileLines() {
|
|
|
285
299
|
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
286
300
|
}
|
|
287
301
|
|
|
288
|
-
// ---
|
|
302
|
+
// --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
|
|
303
|
+
// Not a route in the running app — it only exists while you run `--studio`, on
|
|
304
|
+
// loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
|
|
305
|
+
const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
|
|
306
|
+
async function startStudio() {
|
|
307
|
+
loadEnv();
|
|
308
|
+
if (!enabledFrom(process.env).has("db")) {
|
|
309
|
+
console.error("Studio needs the db add-on. Enable it: npm run dev -- --edit");
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
let store;
|
|
313
|
+
try {
|
|
314
|
+
store = await (await addonMod("db")).createStore();
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.error("Studio: couldn't connect the store — " + e.message);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
|
|
320
|
+
const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
|
|
321
|
+
const assets = {
|
|
322
|
+
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
323
|
+
"/db-admin-ui.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(ADDONS_DIR, "db", "files", "public", "db-admin-ui.js"))],
|
|
324
|
+
};
|
|
325
|
+
const studioHtml = fs.readFileSync(path.join(__dirname, "setup", "studio.html"));
|
|
326
|
+
const json = (res, code, obj) => {
|
|
327
|
+
res.statusCode = code;
|
|
328
|
+
res.setHeader("Content-Type", "application/json");
|
|
329
|
+
res.end(JSON.stringify(obj));
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const server = http.createServer(async (req, res) => {
|
|
333
|
+
const u = new URL(req.url, "http://localhost");
|
|
334
|
+
const p = u.pathname;
|
|
335
|
+
try {
|
|
336
|
+
if (req.method === "GET" && p === "/") {
|
|
337
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
338
|
+
return res.end(studioHtml);
|
|
339
|
+
}
|
|
340
|
+
if (req.method === "GET" && assets[p]) {
|
|
341
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
342
|
+
return res.end(assets[p][1]);
|
|
343
|
+
}
|
|
344
|
+
if (req.method === "GET" && p === "/admin/db/collections") {
|
|
345
|
+
const all = (await store.collections()) || [];
|
|
346
|
+
return json(res, 200, { driver: store.name, collections: all.filter(visible) });
|
|
347
|
+
}
|
|
348
|
+
if (req.method === "GET" && p === "/admin/db/collection") {
|
|
349
|
+
const name = u.searchParams.get("name") || "";
|
|
350
|
+
if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
|
|
351
|
+
const docs = (await store.collection(name).all()).slice(0, 500);
|
|
352
|
+
return json(res, 200, { ok: true, name, docs });
|
|
353
|
+
}
|
|
354
|
+
if (req.method === "DELETE" && p === "/admin/db/doc") {
|
|
355
|
+
const name = u.searchParams.get("name") || "";
|
|
356
|
+
const id = u.searchParams.get("id") || "";
|
|
357
|
+
if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
|
|
358
|
+
if (!id) return json(res, 400, { ok: false, error: "missing id" });
|
|
359
|
+
await store.collection(name).delete(id);
|
|
360
|
+
return json(res, 200, { ok: true });
|
|
361
|
+
}
|
|
362
|
+
res.statusCode = 404;
|
|
363
|
+
res.end("not found");
|
|
364
|
+
} catch (e) {
|
|
365
|
+
json(res, 500, { ok: false, error: e.message });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
370
|
+
const url = `http://localhost:${PORT}`;
|
|
371
|
+
console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
|
|
372
|
+
console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
|
|
373
|
+
const ssh = process.env.SSH_CONNECTION;
|
|
374
|
+
if (ssh) {
|
|
375
|
+
const host = ssh.split(" ")[2];
|
|
376
|
+
const user = process.env.USER || process.env.USERNAME || "you";
|
|
377
|
+
console.log(" Remote box — from your LOCAL machine:");
|
|
378
|
+
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
379
|
+
console.log(` …then open ${url}.`);
|
|
380
|
+
}
|
|
381
|
+
console.log("");
|
|
382
|
+
openBrowser(url);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --- gate: studio / setup (first run, --edit) / the app ---
|
|
289
387
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
290
|
-
if (
|
|
388
|
+
if (process.argv.includes("--studio")) {
|
|
389
|
+
startStudio();
|
|
390
|
+
} else if (editMode || !fs.existsSync(ENV_PATH)) {
|
|
291
391
|
startSetup();
|
|
292
392
|
} else {
|
|
293
393
|
loadEnv();
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Volt Studio</title>
|
|
7
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
8
|
+
<style>
|
|
9
|
+
body { background: #0f1115; color: #e7e9ee; }
|
|
10
|
+
.accent { color: #ffd24a; }
|
|
11
|
+
.card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<main class="container py-5" style="max-width: 820px;">
|
|
16
|
+
<header class="mb-4">
|
|
17
|
+
<h1 class="h3"><span class="accent">⚡ Volt Studio</span> <small class="text-muted">data browser</small></h1>
|
|
18
|
+
<p class="text-muted mb-0">Browse the database in your <code>.env</code>. Disposable & localhost-only — close the terminal when done.</p>
|
|
19
|
+
</header>
|
|
20
|
+
<div id="app"></div>
|
|
21
|
+
</main>
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { mount } from "/volt.js";
|
|
24
|
+
import { dbAdminPanel } from "/db-admin-ui.js";
|
|
25
|
+
mount("#app", dbAdminPanel());
|
|
26
|
+
</script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|