create-volt 0.3.1 → 0.5.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 +69 -0
- package/README.md +28 -0
- package/index.js +50 -11
- package/package.json +3 -2
- package/{template → templates/default}/README.md +29 -0
- package/{template → templates/default}/views/index.html +3 -0
- package/templates/guestbook/README.md +71 -0
- package/templates/guestbook/gitignore +5 -0
- package/templates/guestbook/lib/auth.js +63 -0
- package/templates/guestbook/lib/mailer.js +39 -0
- package/templates/guestbook/lib/store.js +43 -0
- package/templates/guestbook/lib/stores/memory.js +49 -0
- package/templates/guestbook/lib/stores/mongo.js +68 -0
- package/templates/guestbook/lib/stores/sql.js +120 -0
- package/templates/guestbook/package.json +21 -0
- package/templates/guestbook/public/app.js +112 -0
- package/templates/guestbook/public/volt.js +265 -0
- package/templates/guestbook/router.js +110 -0
- package/templates/guestbook/server.js +36 -0
- package/templates/guestbook/views/confirm.html +53 -0
- package/templates/guestbook/views/index.html +38 -0
- package/templates/guestbook/views/partials/header.html +4 -0
- /package/{template → templates/default}/gitignore +0 -0
- /package/{template → templates/default}/package.json +0 -0
- /package/{template → templates/default}/public/app.js +0 -0
- /package/{template → templates/default}/public/volt.js +0 -0
- /package/{template → templates/default}/server.js +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `create-volt` are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.5.0] - 2026-06-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Multiple starter templates via `--template <name>`. The default stays the
|
|
11
|
+
Counter + Todos demo; `--template guestbook` scaffolds a full real-world app:
|
|
12
|
+
magic-link auth, Socket.io real-time, and pluggable **MongoDB / MySQL /
|
|
13
|
+
Postgres** storage (with an in-memory dev fallback so it runs with no setup).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Templates now live under `templates/<name>/` (was a single `template/`).
|
|
17
|
+
- The "files created" summary is derived from the chosen template.
|
|
18
|
+
|
|
19
|
+
## [0.4.0] - 2026-06-28
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `create-volt update` command: refresh `public/volt.js` in an existing app to
|
|
23
|
+
the library version bundled with create-volt. Run `npx create-volt@latest
|
|
24
|
+
update` inside an app. Only touches the library file — never your `app.js`,
|
|
25
|
+
`server.js`, or chosen port. Supports `--dry-run` to check without writing.
|
|
26
|
+
|
|
27
|
+
## [0.3.2] - 2026-06-28
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- Scaffolded apps' `README.md` now has a **Dev port** section explaining the
|
|
31
|
+
date-derived port and how to override it (`PORT` env / `--port`).
|
|
32
|
+
- Package README shows `--port` directly in the Usage block.
|
|
33
|
+
|
|
34
|
+
## [0.3.1] - 2026-06-28
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- Internal: releases now publish from GitHub Actions via npm **Trusted
|
|
38
|
+
Publishing** (OIDC, with provenance) — no functional changes to scaffolded apps.
|
|
39
|
+
|
|
40
|
+
## [0.3.0] - 2026-06-28
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- `--port <number>` flag to set the new app's dev port.
|
|
44
|
+
- The dev port now **defaults to the creation date** (two-digit year + month +
|
|
45
|
+
two-digit day, e.g. `2026-06-28` → `26628`), so apps scaffolded on different
|
|
46
|
+
days don't collide. The chosen port is stamped into the generated `server.js`.
|
|
47
|
+
|
|
48
|
+
## [0.2.0] - 2026-06-28
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
- Git auto-init: scaffolded apps start as a git repository with an initial
|
|
52
|
+
commit (`--no-git` to skip).
|
|
53
|
+
- `--dry-run` flag: preview the files and actions without writing anything.
|
|
54
|
+
|
|
55
|
+
## [0.1.0] - 2026-06-28
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- Initial release. Scaffolds a no-build, signals-based Volt app: the `volt.js`
|
|
59
|
+
library, a Counter + Todos demo, an Express + Socket.io dev server with file
|
|
60
|
+
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
61
|
+
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
62
|
+
|
|
63
|
+
[0.5.0]: https://github.com/MIR-2025/volt/releases/tag/v0.5.0
|
|
64
|
+
[0.4.0]: https://github.com/MIR-2025/volt/releases/tag/v0.4.0
|
|
65
|
+
[0.3.2]: https://github.com/MIR-2025/volt/releases/tag/v0.3.2
|
|
66
|
+
[0.3.1]: https://github.com/MIR-2025/volt/releases/tag/v0.3.1
|
|
67
|
+
[0.3.0]: https://github.com/MIR-2025/volt/releases/tag/v0.3.0
|
|
68
|
+
[0.2.0]: https://github.com/MIR-2025/volt/releases/tag/v0.2.0
|
|
69
|
+
[0.1.0]: https://github.com/MIR-2025/volt/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -14,8 +14,23 @@ npx create-volt my-app
|
|
|
14
14
|
pnpm create volt my-app
|
|
15
15
|
yarn create volt my-app
|
|
16
16
|
bun create volt my-app
|
|
17
|
+
|
|
18
|
+
# choose the dev port (default: derived from today's date)
|
|
19
|
+
npm create volt@latest my-app -- --port 26630
|
|
20
|
+
|
|
21
|
+
# pick a starter template
|
|
22
|
+
npm create volt@latest my-app -- --template guestbook
|
|
17
23
|
```
|
|
18
24
|
|
|
25
|
+
## Templates
|
|
26
|
+
|
|
27
|
+
Pick one with `--template` (default: `default`):
|
|
28
|
+
|
|
29
|
+
| Template | What you get |
|
|
30
|
+
| ----------- | -------------------------------------------------------------------- |
|
|
31
|
+
| `default` | The Counter + Todos demo on the Volt signal engine. Minimal. |
|
|
32
|
+
| `guestbook` | A real app: magic-link auth, Socket.io real-time message board, and pluggable **MongoDB / MySQL / Postgres** storage (in-memory by default, so it runs with zero setup). |
|
|
33
|
+
|
|
19
34
|
Then:
|
|
20
35
|
|
|
21
36
|
```bash
|
|
@@ -25,6 +40,19 @@ npm run dev # → http://localhost:26628
|
|
|
25
40
|
|
|
26
41
|
Edit `public/app.js` and save — the page hot-reloads itself.
|
|
27
42
|
|
|
43
|
+
## Updating Volt
|
|
44
|
+
|
|
45
|
+
Volt is vendored as a single file (`public/volt.js`), not an npm dependency.
|
|
46
|
+
To pull the latest library into an existing app, run from its directory:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx create-volt@latest update # refresh public/volt.js
|
|
50
|
+
npx create-volt@latest update --dry-run # just check if an update is available
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It only rewrites `public/volt.js` — your `app.js`, `server.js`, and dev port are
|
|
54
|
+
left untouched. Review the change with `git diff public/volt.js`.
|
|
55
|
+
|
|
28
56
|
## Options
|
|
29
57
|
|
|
30
58
|
| Flag | Effect |
|
package/index.js
CHANGED
|
@@ -34,9 +34,11 @@ ${bold("⚡ create-volt")} — scaffold a new Volt app
|
|
|
34
34
|
${bold("Usage")}
|
|
35
35
|
npm create volt@latest <project-directory> [options]
|
|
36
36
|
npx create-volt <project-directory> [options]
|
|
37
|
+
npx create-volt@latest update # refresh public/volt.js in an existing app
|
|
37
38
|
|
|
38
39
|
${bold("Options")}
|
|
39
|
-
--
|
|
40
|
+
--template <name> Starter template: default | guestbook (default: default)
|
|
41
|
+
--port <number> Dev port for the app (default: derived from today's date)
|
|
40
42
|
--skip-install Don't run the package manager install step
|
|
41
43
|
--no-git Don't initialize a git repository
|
|
42
44
|
--dry-run Show what would be created without writing anything
|
|
@@ -54,10 +56,13 @@ const argv = process.argv.slice(2);
|
|
|
54
56
|
const flags = new Set();
|
|
55
57
|
const positionals = [];
|
|
56
58
|
let portArg = null;
|
|
59
|
+
let templateArg = null;
|
|
57
60
|
for (let i = 0; i < argv.length; i++) {
|
|
58
61
|
const a = argv[i];
|
|
59
62
|
if (a === "--port") portArg = argv[++i];
|
|
60
63
|
else if (a.startsWith("--port=")) portArg = a.slice("--port=".length);
|
|
64
|
+
else if (a === "--template") templateArg = argv[++i];
|
|
65
|
+
else if (a.startsWith("--template=")) templateArg = a.slice("--template=".length);
|
|
61
66
|
else if (a.startsWith("-")) flags.add(a);
|
|
62
67
|
else positionals.push(a);
|
|
63
68
|
}
|
|
@@ -76,6 +81,32 @@ const force = flags.has("--force");
|
|
|
76
81
|
const dryRun = flags.has("--dry-run");
|
|
77
82
|
const noGit = flags.has("--no-git");
|
|
78
83
|
|
|
84
|
+
// --- `update` subcommand: refresh public/volt.js in the current app to the
|
|
85
|
+
// version bundled with this create-volt (so `npx create-volt@latest update`
|
|
86
|
+
// pulls the latest library). Only touches the library file — never the user's
|
|
87
|
+
// app.js, server.js, or port. ---
|
|
88
|
+
if (positionals[0] === "update") {
|
|
89
|
+
const target = path.join(process.cwd(), "public", "volt.js");
|
|
90
|
+
if (!fs.existsSync(target)) {
|
|
91
|
+
die(`No ${cyan("public/volt.js")} here — run ${cyan("create-volt update")} from inside a Volt app.`);
|
|
92
|
+
}
|
|
93
|
+
const latest = fs.readFileSync(path.join(__dirname, "templates", "default", "public", "volt.js"), "utf8");
|
|
94
|
+
const current = fs.readFileSync(target, "utf8");
|
|
95
|
+
if (current === latest) {
|
|
96
|
+
console.log(`\n${green("✔")} ${bold("public/volt.js")} is already current (create-volt ${pkg.version}).\n`);
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
if (dryRun) {
|
|
100
|
+
console.log(`\n${yellow("!")} An update is available for ${bold("public/volt.js")} (create-volt ${pkg.version}).`);
|
|
101
|
+
console.log(` Re-run without ${cyan("--dry-run")} to apply.\n`);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
fs.writeFileSync(target, latest);
|
|
105
|
+
console.log(`\n${green("✔")} Updated ${bold("public/volt.js")} to the version in create-volt ${pkg.version}.`);
|
|
106
|
+
console.log(` Review the change with ${cyan("git diff public/volt.js")}.\n`);
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
// Resolve the dev port: --port wins, else derive it from today's date as
|
|
80
111
|
// two-digit-year + month (no leading zero) + two-digit-day (house convention),
|
|
81
112
|
// so apps scaffolded on different days don't collide on the same port.
|
|
@@ -112,7 +143,19 @@ if (!/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(path.basena
|
|
|
112
143
|
|
|
113
144
|
const targetDir = path.resolve(process.cwd(), projectName);
|
|
114
145
|
const appName = path.basename(targetDir);
|
|
115
|
-
|
|
146
|
+
|
|
147
|
+
// Resolve the starter template (default | guestbook | …).
|
|
148
|
+
const templatesDir = path.join(__dirname, "templates");
|
|
149
|
+
const templateName = templateArg || "default";
|
|
150
|
+
const templateDir = path.join(templatesDir, templateName);
|
|
151
|
+
if (!fs.existsSync(templateDir) || !fs.statSync(templateDir).isDirectory()) {
|
|
152
|
+
const available = fs
|
|
153
|
+
.readdirSync(templatesDir, { withFileTypes: true })
|
|
154
|
+
.filter((e) => e.isDirectory())
|
|
155
|
+
.map((e) => e.name)
|
|
156
|
+
.join(", ");
|
|
157
|
+
die(`Unknown template "${templateName}". Available: ${available}.`);
|
|
158
|
+
}
|
|
116
159
|
|
|
117
160
|
// List every file in the template, relative to its root (for dry-run preview).
|
|
118
161
|
function listTemplateFiles(dir, base = dir) {
|
|
@@ -135,7 +178,7 @@ if (fs.existsSync(targetDir)) {
|
|
|
135
178
|
|
|
136
179
|
// --- dry run: print the plan and exit without writing anything ---
|
|
137
180
|
if (dryRun) {
|
|
138
|
-
console.log(`\n${bold("⚡ Dry run")} — would create a Volt app in ${cyan(targetDir)}\n`);
|
|
181
|
+
console.log(`\n${bold("⚡ Dry run")} — would create a ${cyan(templateName)} Volt app in ${cyan(targetDir)}\n`);
|
|
139
182
|
console.log("Would write:");
|
|
140
183
|
for (const f of listTemplateFiles(templateDir).sort()) {
|
|
141
184
|
// the shipped "gitignore" is renamed to ".gitignore" on scaffold
|
|
@@ -180,14 +223,10 @@ if (fs.existsSync(appReadme)) {
|
|
|
180
223
|
fs.writeFileSync(appReadme, fs.readFileSync(appReadme, "utf8").replace(/localhost:\d+/g, `localhost:${port}`));
|
|
181
224
|
}
|
|
182
225
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
|
|
187
|
-
"server.js — dev server with hot reload",
|
|
188
|
-
];
|
|
189
|
-
console.log(green("✔") + " Files created:");
|
|
190
|
-
for (const line of created) console.log(" " + dim(line));
|
|
226
|
+
console.log(green("✔") + ` Created a ${cyan(templateName)} app — files:`);
|
|
227
|
+
for (const f of listTemplateFiles(templateDir).sort()) {
|
|
228
|
+
console.log(" " + dim(f === "gitignore" ? ".gitignore" : f));
|
|
229
|
+
}
|
|
191
230
|
console.log("");
|
|
192
231
|
|
|
193
232
|
// --- detect the package manager that invoked us (npm / pnpm / yarn / bun) ---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-volt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
|
-
"
|
|
11
|
+
"templates",
|
|
12
|
+
"CHANGELOG.md"
|
|
12
13
|
],
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=16.7.0"
|
|
@@ -4,6 +4,8 @@ A tiny, **no-build**, signals-based UI app with **Socket.io hot reload**.
|
|
|
4
4
|
Not React: no JSX, no virtual DOM, no re-render-the-world. State lives in
|
|
5
5
|
*signals*; only the exact text/attribute that changed updates.
|
|
6
6
|
|
|
7
|
+
📖 **[How to build a Volt app →](https://github.com/MIR-2025/volt#readme)**
|
|
8
|
+
|
|
7
9
|
## Run
|
|
8
10
|
|
|
9
11
|
```bash
|
|
@@ -14,6 +16,33 @@ npm run dev # → http://localhost:26628 (PORT env to override)
|
|
|
14
16
|
Edit anything in `public/` or `views/` and save — the dev server pushes a
|
|
15
17
|
reload over Socket.io and the page refreshes itself.
|
|
16
18
|
|
|
19
|
+
## Dev port
|
|
20
|
+
|
|
21
|
+
This app's dev port is set in `server.js` (chosen when the project was
|
|
22
|
+
scaffolded). Override it at launch with the `PORT` env var:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
PORT=4000 npm run dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Scaffolding more apps? `create-volt` defaults each one's port to its creation
|
|
29
|
+
date and takes `--port <number>` to avoid collisions on the same day:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm create volt@latest api-app -- --port 26630
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Updating Volt
|
|
36
|
+
|
|
37
|
+
`public/volt.js` is a vendored file, not an npm dependency. Pull the latest
|
|
38
|
+
library version with:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx create-volt@latest update
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This rewrites only `public/volt.js` — your app code and port stay as-is.
|
|
45
|
+
|
|
17
46
|
## Project layout
|
|
18
47
|
|
|
19
48
|
```
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
|
|
31
31
|
<footer class="text-center text-muted mt-5">
|
|
32
32
|
<small>Both cards run on the same signal engine — one uses <code>el()</code>, the other <code>html``</code>.</small>
|
|
33
|
+
<div class="mt-2">
|
|
34
|
+
<small><a href="https://github.com/MIR-2025/volt#readme" target="_blank" rel="noopener">📖 How to build a Volt app →</a></small>
|
|
35
|
+
</div>
|
|
33
36
|
</footer>
|
|
34
37
|
</main>
|
|
35
38
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# 📖 Guestbook — a real Volt app
|
|
2
|
+
|
|
3
|
+
A live message board built with [Volt](https://github.com/MIR-2025/volt):
|
|
4
|
+
fine-grained signals on the frontend, **Socket.io** for real-time updates,
|
|
5
|
+
**magic-link** sign-in (no passwords), and **pluggable storage** —
|
|
6
|
+
in-memory by default, or MongoDB / MySQL / Postgres for persistence.
|
|
7
|
+
|
|
8
|
+
## Run
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install # if you scaffolded with --skip-install
|
|
12
|
+
npm run dev # → http://localhost:26629
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Out of the box it uses an **in-memory store** and prints magic-link emails to
|
|
16
|
+
the **server console** — so you can try the whole flow with zero setup:
|
|
17
|
+
|
|
18
|
+
1. Enter your email, click **Send magic link**.
|
|
19
|
+
2. Copy the link printed in the terminal, open it.
|
|
20
|
+
3. Click **Confirm login** (must be the same browser).
|
|
21
|
+
4. Post a message — open a second tab to watch it appear live.
|
|
22
|
+
|
|
23
|
+
## Storage backends
|
|
24
|
+
|
|
25
|
+
Pick a backend with `DB_DRIVER` (default `memory`). Real drivers are lazy-loaded,
|
|
26
|
+
so install only the one you use.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# MongoDB
|
|
30
|
+
DB_DRIVER=mongodb MONGODB_URI="mongodb://user:<password>@host:27017/guestbook?authSource=admin" \
|
|
31
|
+
MONGODB_DATABASE=guestbook npm start
|
|
32
|
+
|
|
33
|
+
# MySQL (npm install mysql2)
|
|
34
|
+
DB_DRIVER=mysql DATABASE_URL="mysql://user:<password>@host:3306/guestbook" npm start
|
|
35
|
+
|
|
36
|
+
# Postgres (npm install pg)
|
|
37
|
+
DB_DRIVER=postgres DATABASE_URL="postgres://user:<password>@host:5432/guestbook" npm start
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Tables/collections are created automatically on first run. Never hard-code the
|
|
41
|
+
credential — keep it in your environment / `.env`.
|
|
42
|
+
|
|
43
|
+
## Email (magic links)
|
|
44
|
+
|
|
45
|
+
In dev, links are printed to the console. For real email, set `SMTP_URL` (and
|
|
46
|
+
`MAIL_FROM`) and `npm install nodemailer`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
SMTP_URL="smtp://user:pass@smtp.example.com:587" MAIL_FROM="Guestbook <no-reply@you.com>" npm start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Layout
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
server.js Express + Socket.io + storage/mailer wiring
|
|
56
|
+
router.js routes; composes views with a header include
|
|
57
|
+
lib/store.js backend selector (memory | mongodb | mysql | postgres)
|
|
58
|
+
lib/stores/ the adapters (same interface each)
|
|
59
|
+
lib/auth.js magic-link tokens + sessions
|
|
60
|
+
lib/mailer.js console (dev) or SMTP (prod)
|
|
61
|
+
public/volt.js the Volt library (no build step)
|
|
62
|
+
public/app.js the frontend — signals + Socket.io
|
|
63
|
+
views/ index, confirm page, header partial
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Production
|
|
67
|
+
|
|
68
|
+
Runs anywhere Node does. Under PM2: `pm2 start server.js --name guestbook`.
|
|
69
|
+
Set `PORT`, `DB_DRIVER`, the connection string, and `SMTP_URL` in the environment.
|
|
70
|
+
|
|
71
|
+
Scaffolded with [`create-volt`](https://www.npmjs.com/package/create-volt) — `--template guestbook`.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// auth.js — magic-link login (no passwords), per the house auth convention:
|
|
2
|
+
// 1. user submits email → we store a one-time token + the requesting UA,
|
|
3
|
+
// and email a link containing the token
|
|
4
|
+
// 2. opening the link shows a confirm page (must be the same browser/UA)
|
|
5
|
+
// 3. clicking "Confirm" consumes the token and starts a session cookie
|
|
6
|
+
//
|
|
7
|
+
// Tokens expire after TOKEN_TTL and are single-use. Sessions are random ids
|
|
8
|
+
// kept in the store and carried in an httpOnly cookie.
|
|
9
|
+
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
|
|
12
|
+
const TOKEN_TTL = 15 * 60 * 1000; // 15 minutes
|
|
13
|
+
const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
14
|
+
export const SESSION_COOKIE = "gb_sid";
|
|
15
|
+
|
|
16
|
+
const token = () => crypto.randomBytes(32).toString("hex");
|
|
17
|
+
const normalizeEmail = (e) => String(e || "").trim().toLowerCase();
|
|
18
|
+
const validEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
|
19
|
+
|
|
20
|
+
// Step 1: create + email a magic link. Returns { ok } or throws on bad input.
|
|
21
|
+
export async function requestLogin(store, mailer, { email, ua, baseUrl }) {
|
|
22
|
+
const addr = normalizeEmail(email);
|
|
23
|
+
if (!validEmail(addr)) throw new Error("Please enter a valid email address.");
|
|
24
|
+
const tok = token();
|
|
25
|
+
await store.putToken({ token: tok, email: addr, ua: ua || "", expiresAt: Date.now() + TOKEN_TTL });
|
|
26
|
+
const link = `${baseUrl}/verify?token=${tok}`;
|
|
27
|
+
await mailer.sendMagicLink(addr, link);
|
|
28
|
+
return { ok: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Step 3: confirm a token → start a session. Returns { sessionId, email }.
|
|
32
|
+
export async function confirmLogin(store, { token: tok, ua }) {
|
|
33
|
+
const rec = await store.getToken(tok);
|
|
34
|
+
if (!rec) throw new Error("This login link is invalid.");
|
|
35
|
+
if (rec.used) throw new Error("This login link was already used.");
|
|
36
|
+
if (rec.expiresAt < Date.now()) throw new Error("This login link has expired.");
|
|
37
|
+
if (rec.ua && ua && rec.ua !== ua) throw new Error("Open the link in the same browser you requested it from.");
|
|
38
|
+
|
|
39
|
+
await store.useToken(tok);
|
|
40
|
+
const sessionId = token();
|
|
41
|
+
await store.putSession({ id: sessionId, email: rec.email, expiresAt: Date.now() + SESSION_TTL });
|
|
42
|
+
return { sessionId, email: rec.email };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse a cookie header into a plain object.
|
|
46
|
+
export function parseCookies(header = "") {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const part of header.split(";")) {
|
|
49
|
+
const i = part.indexOf("=");
|
|
50
|
+
if (i === -1) continue;
|
|
51
|
+
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve the current session from a request, or null.
|
|
57
|
+
export async function sessionFromReq(store, req) {
|
|
58
|
+
const sid = parseCookies(req.headers.cookie)[SESSION_COOKIE];
|
|
59
|
+
if (!sid) return null;
|
|
60
|
+
return await store.getSession(sid);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const cookieMaxAgeSeconds = SESSION_TTL / 1000;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// mailer.js — sends the magic-link email. In dev (no SMTP configured) it just
|
|
2
|
+
// prints the link to the console so you can copy it; in production it uses
|
|
3
|
+
// nodemailer if SMTP_URL is set and the package is installed.
|
|
4
|
+
|
|
5
|
+
export async function createMailer() {
|
|
6
|
+
const smtp = process.env.SMTP_URL;
|
|
7
|
+
const from = process.env.MAIL_FROM || "Guestbook <no-reply@example.com>";
|
|
8
|
+
|
|
9
|
+
if (smtp) {
|
|
10
|
+
let nodemailer;
|
|
11
|
+
try {
|
|
12
|
+
nodemailer = (await import("nodemailer")).default;
|
|
13
|
+
} catch {
|
|
14
|
+
console.warn("[mailer] SMTP_URL set but 'nodemailer' isn't installed — falling back to console. Run: npm install nodemailer");
|
|
15
|
+
}
|
|
16
|
+
if (nodemailer) {
|
|
17
|
+
const transport = nodemailer.createTransport(smtp);
|
|
18
|
+
return {
|
|
19
|
+
name: "smtp",
|
|
20
|
+
async sendMagicLink(email, link) {
|
|
21
|
+
await transport.sendMail({
|
|
22
|
+
to: email,
|
|
23
|
+
from,
|
|
24
|
+
subject: "Your guestbook login link",
|
|
25
|
+
text: `Click to sign in: ${link}\n\nThis link expires shortly and can only be used once.`,
|
|
26
|
+
html: `<p>Click to sign in:</p><p><a href="${link}">${link}</a></p><p>This link expires shortly and can only be used once.</p>`,
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
name: "console",
|
|
35
|
+
async sendMagicLink(email, link) {
|
|
36
|
+
console.log(`\n📨 Magic link for ${email}:\n ${link}\n (dev mode — paste this into your browser)\n`);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// store.js — pick a storage backend from the environment. Defaults to the
|
|
2
|
+
// in-memory store so the app runs with zero setup; point DB_DRIVER at a real
|
|
3
|
+
// database (mongodb | mysql | postgres) for persistence.
|
|
4
|
+
//
|
|
5
|
+
// DB_DRIVER=memory (default)
|
|
6
|
+
// DB_DRIVER=mongodb MONGODB_URI=... [MONGODB_DATABASE=...]
|
|
7
|
+
// DB_DRIVER=mysql DATABASE_URL=mysql://user:pass@host:3306/db
|
|
8
|
+
// DB_DRIVER=postgres DATABASE_URL=postgres://user:pass@host:5432/db
|
|
9
|
+
|
|
10
|
+
import { createMemoryStore } from "./stores/memory.js";
|
|
11
|
+
import { createMongoStore } from "./stores/mongo.js";
|
|
12
|
+
import { createSqlStore } from "./stores/sql.js";
|
|
13
|
+
|
|
14
|
+
export async function createStore() {
|
|
15
|
+
const driver = (process.env.DB_DRIVER || "memory").toLowerCase();
|
|
16
|
+
|
|
17
|
+
let store;
|
|
18
|
+
switch (driver) {
|
|
19
|
+
case "memory":
|
|
20
|
+
store = createMemoryStore();
|
|
21
|
+
break;
|
|
22
|
+
case "mongodb":
|
|
23
|
+
case "mongo":
|
|
24
|
+
store = await createMongoStore({
|
|
25
|
+
uri: process.env.MONGODB_URI,
|
|
26
|
+
dbName: process.env.MONGODB_DATABASE,
|
|
27
|
+
});
|
|
28
|
+
break;
|
|
29
|
+
case "mysql":
|
|
30
|
+
store = await createSqlStore({ dialect: "mysql", uri: process.env.DATABASE_URL });
|
|
31
|
+
break;
|
|
32
|
+
case "postgres":
|
|
33
|
+
case "postgresql":
|
|
34
|
+
case "pg":
|
|
35
|
+
store = await createSqlStore({ dialect: "postgres", uri: process.env.DATABASE_URL });
|
|
36
|
+
break;
|
|
37
|
+
default:
|
|
38
|
+
throw new Error(`Unknown DB_DRIVER "${driver}" (use memory | mongodb | mysql | postgres)`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await store.init();
|
|
42
|
+
return store;
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// memory.js — in-memory store. The zero-dependency dev fallback so the app
|
|
2
|
+
// runs out of the box; data is lost on restart. Same interface as the real
|
|
3
|
+
// MongoDB / MySQL / Postgres adapters.
|
|
4
|
+
|
|
5
|
+
export function createMemoryStore() {
|
|
6
|
+
const tokens = new Map(); // token -> { token, email, ua, expiresAt, used }
|
|
7
|
+
const sessions = new Map(); // id -> { id, email, expiresAt }
|
|
8
|
+
const messages = []; // { id, email, body, createdAt }
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
name: "memory",
|
|
12
|
+
async init() {},
|
|
13
|
+
|
|
14
|
+
async putToken(t) {
|
|
15
|
+
tokens.set(t.token, { ...t, used: false });
|
|
16
|
+
},
|
|
17
|
+
async getToken(token) {
|
|
18
|
+
return tokens.get(token) || null;
|
|
19
|
+
},
|
|
20
|
+
async useToken(token) {
|
|
21
|
+
const t = tokens.get(token);
|
|
22
|
+
if (t) t.used = true;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async putSession(s) {
|
|
26
|
+
sessions.set(s.id, s);
|
|
27
|
+
},
|
|
28
|
+
async getSession(id) {
|
|
29
|
+
const s = sessions.get(id);
|
|
30
|
+
if (!s) return null;
|
|
31
|
+
if (s.expiresAt < Date.now()) {
|
|
32
|
+
sessions.delete(id);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
},
|
|
37
|
+
async delSession(id) {
|
|
38
|
+
sessions.delete(id);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async addMessage(m) {
|
|
42
|
+
messages.push(m);
|
|
43
|
+
return m;
|
|
44
|
+
},
|
|
45
|
+
async listMessages(limit = 100) {
|
|
46
|
+
return messages.slice(-limit);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// mongo.js — MongoDB adapter. Lazy-loads the `mongodb` driver so the app only
|
|
2
|
+
// needs it when DB_DRIVER=mongodb. Connection string per instructions.md:
|
|
3
|
+
// MONGODB_URI=mongodb://user:<password>@host:port/{dbname}?authSource=admin...
|
|
4
|
+
|
|
5
|
+
export async function createMongoStore({ uri, dbName }) {
|
|
6
|
+
let MongoClient;
|
|
7
|
+
try {
|
|
8
|
+
({ MongoClient } = await import("mongodb"));
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error("DB_DRIVER=mongodb but the 'mongodb' package isn't installed. Run: npm install mongodb");
|
|
11
|
+
}
|
|
12
|
+
if (!uri) throw new Error("DB_DRIVER=mongodb requires MONGODB_URI");
|
|
13
|
+
|
|
14
|
+
const client = new MongoClient(uri);
|
|
15
|
+
await client.connect();
|
|
16
|
+
const db = client.db(dbName || undefined);
|
|
17
|
+
const tokens = db.collection("login_tokens");
|
|
18
|
+
const sessions = db.collection("sessions");
|
|
19
|
+
const messages = db.collection("messages");
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name: "mongodb",
|
|
23
|
+
async init() {
|
|
24
|
+
await tokens.createIndex({ token: 1 }, { unique: true });
|
|
25
|
+
await sessions.createIndex({ id: 1 }, { unique: true });
|
|
26
|
+
await messages.createIndex({ createdAt: 1 });
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async putToken(t) {
|
|
30
|
+
await tokens.insertOne({ ...t, used: false });
|
|
31
|
+
},
|
|
32
|
+
async getToken(token) {
|
|
33
|
+
return await tokens.findOne({ token }, { projection: { _id: 0 } });
|
|
34
|
+
},
|
|
35
|
+
async useToken(token) {
|
|
36
|
+
await tokens.updateOne({ token }, { $set: { used: true } });
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async putSession(s) {
|
|
40
|
+
await sessions.insertOne(s);
|
|
41
|
+
},
|
|
42
|
+
async getSession(id) {
|
|
43
|
+
const s = await sessions.findOne({ id }, { projection: { _id: 0 } });
|
|
44
|
+
if (!s) return null;
|
|
45
|
+
if (s.expiresAt < Date.now()) {
|
|
46
|
+
await sessions.deleteOne({ id });
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return s;
|
|
50
|
+
},
|
|
51
|
+
async delSession(id) {
|
|
52
|
+
await sessions.deleteOne({ id });
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async addMessage(m) {
|
|
56
|
+
await messages.insertOne({ ...m });
|
|
57
|
+
return m;
|
|
58
|
+
},
|
|
59
|
+
async listMessages(limit = 100) {
|
|
60
|
+
const rows = await messages
|
|
61
|
+
.find({}, { projection: { _id: 0 } })
|
|
62
|
+
.sort({ createdAt: 1 })
|
|
63
|
+
.limit(limit)
|
|
64
|
+
.toArray();
|
|
65
|
+
return rows;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|