create-wordjs 2.3.0 → 2.4.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.
Files changed (3) hide show
  1. package/README.md +61 -5
  2. package/index.js +707 -77
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,7 +25,9 @@ npx create-wordjs@latest join frontend --gateway <ip> --token <t> --ca-hash <fp>
25
25
  That single command takes you from nothing to the browser install wizard:
26
26
 
27
27
  1. Downloads the latest **pre-compiled** WordJS release ZIP from GitHub — no build step,
28
- no TypeScript compilation on your machine.
28
+ no TypeScript compilation on your machine — and verifies its **SHA-256** against the
29
+ `wordjs-<tag>.zip.sha256` asset published with the release before extracting anything
30
+ (see [Integrity](#integrity)).
29
31
  2. Extracts it into `my-site/` and installs the runtime dependencies (`npm run release:install`).
30
32
  3. Generates a one-time install token and starts the server (`npm run start:mono`), printing a
31
33
  clickable URL:
@@ -46,13 +48,17 @@ in CI — plus a pure-JS *SQLite (legacy / WASM)* fallback for hosts where the n
46
48
 
47
49
  | Option | Description |
48
50
  | --- | --- |
49
- | `--zip <path-or-url>` | Use a local release ZIP (or a direct ZIP URL) instead of querying the GitHub API. Handy offline or when rate-limited. |
51
+ | `--zip <path-or-url>` | Use a local release ZIP (or a direct **`https://`** ZIP URL — plain `http://` is refused) instead of querying the GitHub API. Handy offline or when rate-limited. |
52
+ | `--sha256 <hex>` | Refuse the ZIP unless its SHA-256 is exactly this (the value in the release's `.sha256` asset). Use it to pin a `--zip` file or URL; with a GitHub download it is checked on top of the release checksum. |
50
53
  | `--version <tag>` | Install a specific release (e.g. `--version v2.1.0`) instead of the latest. |
51
54
  | `--http` | Serve plain HTTP instead of self-signed HTTPS (sets `WORDJS_HTTP=1`). |
52
55
  | `--no-start` | Scaffold and install dependencies only — start the server yourself later. |
53
56
  | `--yes`, `-y` | Skip the confirmation prompt (required when `upgrade` runs non-interactively). |
54
57
  | `--force` | (`upgrade`) Re-apply even if the site is already on the target version. |
55
58
  | `--no-install` | (`upgrade`) Swap the code only; skip `npm run release:install`. |
59
+ | `--systemd` | (create, `upgrade`; Linux) Also write a systemd unit, `wordjs.service`, to a private staging directory outside the site and print how to install it. The unit runs WordJS as a dedicated non-root account with **no Linux capabilities**. With create it implies `--no-start`. See [Run it as a service](#run-it-as-a-service-linux). |
60
+ | `--port <n>` | (with `--systemd`) Public port written into the unit (`PORT=<n>`); default: the site's configured port, else `3000`. Below 1024 it also stages `60-wordjs-ports.conf`. |
61
+ | `--service-user <name>` | (with `--systemd`) The account the service runs as (default `wordjs`; `root` is refused). |
56
62
  | `-h`, `--help` | Show usage. |
57
63
 
58
64
  Separate-mode options:
@@ -62,7 +68,7 @@ Separate-mode options:
62
68
  | `--host <ip/dns>` | (`gateway`) The address the other machines dial to reach this gateway. |
63
69
  | `--gateway <ip/dns>` | (`join`) The gateway's address. |
64
70
  | `--token <join-token>` | (`join`) A single-use token minted on the gateway. |
65
- | `--ca-hash <sha256>` | (`join`) Pin the cluster-CA fingerprint the gateway printed (MITM guard). |
71
+ | `--ca-hash <sha256>` | (`join`) **Required.** The cluster-CA fingerprint the gateway printed. The gateway's TLS certificate must chain to exactly that CA before the join token is sent (MITM guard). |
66
72
  | `--advertise <ip/dns>` | (`join`) This node's routable address the gateway will proxy to. |
67
73
  | `--enroll-port <port>` | (`join`) Gateway token-enrollment port (default `3101`). |
68
74
 
@@ -78,6 +84,38 @@ directory (`backend/data`), `backend/uploads/`, `wordjs-config.json`, `.env`, ga
78
84
  before touching an existing install — on a non-interactive shell it refuses unless you pass `--yes`.
79
85
  Restart the server afterwards (schema migrations run automatically on the next start).
80
86
 
87
+ ## Run it as a service (Linux)
88
+
89
+ ```bash
90
+ npx create-wordjs@latest /srv/wordjs --systemd --port 443 # new site
91
+ npx create-wordjs@latest upgrade /srv/wordjs --systemd # existing site, even if already up to date
92
+ ```
93
+
94
+ `--systemd` writes `wordjs.service` — `User=wordjs`, `NoNewPrivileges=yes`, an **empty**
95
+ `CapabilityBoundingSet=`, `ProtectSystem=strict` with the site directory as the only writable path,
96
+ `PrivateTmp=yes`, `ProtectHome=yes`, `Restart=on-failure`, `ExecStart` pointing at the node that ran the
97
+ command — to a fresh private staging directory (mode `0700`, **outside** the site, which the service
98
+ account will own: a file root installs into `/etc` must not be one the service can rewrite first), and
99
+ prints the steps to install it (create the account, `chown` the site to it, `install -o root -g root -m
100
+ 0644` the unit into `/etc/systemd/system/`, `systemctl enable --now wordjs`, read
101
+ `backend/data/install-token`; on `upgrade`, `enable` plus `restart`, after stopping whatever served the
102
+ site before). It does not run anything as root itself, refuses to run as the service account, checks the
103
+ site and node paths before downloading anything, and with create it does not start the server: the first
104
+ boot must run as the service account so that account owns the database and certificates it creates.
105
+
106
+ **Ports below 1024 (80/443).** The unit never grants `CAP_NET_BIND_SERVICE`. For a port below 1024,
107
+ `--systemd` also stages `60-wordjs-ports.conf`, a `sysctl.d` drop-in that sets
108
+ `net.ipv4.ip_unprivileged_port_start` to that port. Read its trade-off before installing it: it lets
109
+ **every** unprivileged process on the host bind ports from that value up to 1023, not just WordJS. On a
110
+ shared host, prefer a reverse proxy (nginx, Caddy) on 80/443 forwarding to WordJS on a high port.
111
+
112
+ **Do not `setcap` node or add `AmbientCapabilities=`.** Earlier versions of this tool printed a `setcap`
113
+ command for port 25; it is withdrawn. An ambient capability is inherited by the plugin sandbox, which has to
114
+ strip it (a non-root service with `AmbientCapabilities=CAP_NET_BIND_SERVICE` once left every plugin
115
+ refused); a file capability on node applies to every node script on the machine, runs node in
116
+ secure-execution mode, and disappears silently on the next node upgrade. Details, measurements and how to
117
+ remove an existing grant: [documentation/deployment.md](https://github.com/jaimemartinez/wordjs/blob/main/documentation/deployment.md#-running-as-a-service-systemd-non-root).
118
+
81
119
  ## Separate mode (multi-machine)
82
120
 
83
121
  Run the gateway, backend and frontend on **different machines**, joined into one mTLS cluster with
@@ -100,7 +138,8 @@ npx create-wordjs@latest join backend --gateway 10.0.0.1 --token <t> --ca-hash
100
138
  npx create-wordjs@latest join frontend --gateway 10.0.0.1 --token <t> --ca-hash <fp> --advertise 10.0.0.3
101
139
  ```
102
140
 
103
- Each `join` downloads the release, enrolls against the gateway (the token authorizes exactly one
141
+ `--ca-hash` is required: `join` refuses to enroll without it, and there is no trust-on-first-use
142
+ opt-out. Each `join` downloads the release, enrolls against the gateway (the token authorizes exactly one
104
143
  certificate signing; it is burned afterwards, and the ones `gateway` printed also expire after 120
105
144
  minutes — mint more on the gateway with `node scripts/cluster.js token <backend|frontend>`, which
106
145
  defaults to a 60-minute TTL and takes `--ttl <minutes>`), then starts the service, which registers
@@ -123,10 +162,27 @@ need `openssl` on the PATH. Full details, port matrix and the manual (source-che
123
162
  - **GitHub rate limit / offline**: the release lookup uses the unauthenticated GitHub API. If it
124
163
  is rate-limited or you're offline, download `wordjs-v*.zip` from the
125
164
  [releases page](https://github.com/jaimemartinez/wordjs/releases) and run
126
- `npx create-wordjs@latest my-site --zip ./wordjs-v2.1.0.zip`.
165
+ `npx create-wordjs@latest my-site --zip ./wordjs-v2.1.0.zip` — add
166
+ `--sha256 <hex>` with the value from `wordjs-v2.1.0.zip.sha256` to have it verified.
127
167
  - **Existing directories**: the target directory must not exist (or must be empty) — the tool
128
168
  refuses to overwrite anything.
129
169
 
170
+ ## Integrity
171
+
172
+ - **GitHub downloads are verified.** Every release publishes `wordjs-<tag>.zip.sha256`
173
+ (`sha256sum` format) next to the bundle. `create-wordjs` — including `upgrade`, `gateway` and
174
+ `join` — downloads it and refuses to extract a ZIP whose SHA-256 differs. Releases published
175
+ **before** the checksum asset was introduced have nothing to verify against: they still install
176
+ (so `--version <old-tag>` rollbacks keep working) but with a clear warning that the download was
177
+ not verified. The checksum is fetched from the same GitHub release over HTTPS, so it catches a
178
+ corrupted or swapped download, not a compromised release.
179
+ - **`--zip` sources**: a URL must be `https://` (plain `http://` and other schemes are refused); a
180
+ local path is accepted as is. Neither is verified unless you pass `--sha256 <hex>` (a URL
181
+ without it prints a warning).
182
+ - **Extraction is contained**: every ZIP entry is checked before anything is written. An archive
183
+ with an absolute path, a `..` segment, an entry resolving outside the target directory, or a
184
+ symbolic-link entry is refused as a whole.
185
+
130
186
  ## What gets created
131
187
 
132
188
  A ready-to-run WordJS bundle: backend (pre-compiled to `dist/`), frontend (pre-built `.next`),
package/index.js CHANGED
@@ -16,6 +16,10 @@
16
16
  * With --no-start there is no token from here: the server mints its own on its first boot and
17
17
  * prints it in its banner only when ITS stdout is a TTY (or WORDJS_PRINT_INSTALL_TOKEN=1);
18
18
  * otherwise it writes it to <dir>/backend/data/install-token (mode 0600) and prints the path.
19
+ * With --systemd (Linux) it does not start anything: it stages wordjs.service, a unit that runs the
20
+ * site as a dedicated non-root account with no Linux capabilities (plus a sysctl.d drop-in when the
21
+ * port is below 1024), in a private directory OUTSIDE the site, and prints how to install it. See
22
+ * buildSystemdFiles for why no capability, and createSystemdStagingDir for why not in the site.
19
23
  *
20
24
  * Plain Node, no TypeScript. Only runtime dependency: adm-zip (ZIP extraction).
21
25
  */
@@ -52,7 +56,9 @@ Usage:
52
56
  npx create-wordjs@latest join <role> [dir] [opts] Join this machine to a gateway as backend|frontend
53
57
 
54
58
  Options:
55
- --zip <path-or-url> Use a local release ZIP (or a direct ZIP URL) instead of asking GitHub.
59
+ --zip <path-or-url> Use a local release ZIP (or a direct https:// ZIP URL) instead of asking GitHub.
60
+ --sha256 <hex> Require the ZIP to have this SHA-256 (pin a checksum for --zip; also checked
61
+ on top of the release's own .sha256 asset).
56
62
  --version <tag> Install/upgrade to a specific release tag (e.g. v2.1.0) instead of the latest.
57
63
  --http Serve plain HTTP instead of self-signed HTTPS (sets WORDJS_HTTP=1). (create)
58
64
  --no-start Scaffold + install dependencies only; don't start the server.
@@ -62,9 +68,19 @@ Options:
62
68
  --host <ip/dns> (gateway) The address other machines dial to reach this gateway.
63
69
  --gateway <ip/dns> (join) The gateway's address.
64
70
  --token <join-token> (join) A single-use token minted on the gateway (cluster token <role>).
65
- --ca-hash <sha256> (join) Pin the cluster CA fingerprint the gateway prints (MITM guard).
71
+ --ca-hash <sha256> (join) REQUIRED. The cluster CA fingerprint the gateway prints; the gateway's
72
+ TLS certificate must chain to it before the token is sent (MITM guard).
66
73
  --advertise <ip/dns> (join) This node's routable address the gateway will proxy to.
67
74
  --enroll-port <port> (join) Gateway token-enrollment port (default 3101).
75
+ --systemd (create, upgrade; Linux) Also write a systemd unit, wordjs.service, to a
76
+ private staging directory (never the site, which the service will own) and
77
+ print how to install it. The unit runs WordJS as a dedicated non-root
78
+ account holding NO Linux capabilities. With create it implies --no-start:
79
+ the first boot must run as that account.
80
+ --port <n> (with --systemd) Public port, written into the unit as PORT=<n> (default:
81
+ the site's configured port, else 3000). Below 1024 it also stages
82
+ 60-wordjs-ports.conf (net.ipv4.ip_unprivileged_port_start), never a capability.
83
+ --service-user <name> (with --systemd) The account the service runs as (default: wordjs). Not root.
68
84
  -h, --help Show this help.
69
85
 
70
86
  Examples:
@@ -72,6 +88,11 @@ Examples:
72
88
  npx create-wordjs@latest my-site --version v2.1.0
73
89
  npx create-wordjs@latest upgrade # from inside your site directory
74
90
  npx create-wordjs@latest upgrade ./my-site --yes
91
+ npx create-wordjs@latest /srv/wordjs --systemd --port 443
92
+
93
+ Ports below 1024 (80/443) on Linux: put a reverse proxy (nginx, Caddy) in front of WordJS on a high
94
+ port, or lower net.ipv4.ip_unprivileged_port_start (--systemd writes that drop-in for you). Do not
95
+ give node a capability with setcap or AmbientCapabilities=. See documentation/deployment.md.
75
96
 
76
97
  Separate mode (three machines) — run one command per machine:
77
98
  # on the gateway machine (prints ready-to-paste join commands with fresh tokens):
@@ -99,6 +120,8 @@ function parseArgs(argv) {
99
120
  const opts = {
100
121
  mode: 'create', dir: null, zip: null, version: null, http: false, start: true, yes: false, force: false, install: true,
101
122
  role: null, gateway: null, token: null, caHash: null, advertise: null, enrollPort: null, host: null,
123
+ systemd: false, port: null, serviceUser: null,
124
+ sha256: null,
102
125
  };
103
126
  // A leading subcommand selects the mode (default is the monolith create flow).
104
127
  if (['upgrade', 'gateway', 'join'].includes(argv[0])) { opts.mode = argv[0]; argv = argv.slice(1); }
@@ -107,6 +130,13 @@ function parseArgs(argv) {
107
130
  const a = argv[i];
108
131
  if (a === '-h' || a === '--help') { console.log(HELP); process.exit(0); }
109
132
  else if (a === '--zip') { opts.zip = argv[++i] || fail('--zip needs a value (path or URL to a wordjs-*.zip).'); }
133
+ else if (a === '--sha256') {
134
+ const v = argv[++i];
135
+ if (!v) fail('--sha256 needs a value (the 64-character hex SHA-256 of the ZIP).');
136
+ const hex = normalizeSha256(v);
137
+ if (!hex) fail(`--sha256 must be a 64-character hex SHA-256, got "${v}".`);
138
+ opts.sha256 = hex;
139
+ }
110
140
  else if (a === '--version') { opts.version = argv[++i] || fail('--version needs a value (a release tag, e.g. v2.1.0).'); }
111
141
  else if (a === '--http') opts.http = true;
112
142
  else if (a === '--no-start') opts.start = false;
@@ -120,9 +150,28 @@ function parseArgs(argv) {
120
150
  else if (a === '--advertise') opts.advertise = argv[++i] || fail('--advertise needs this node\'s ip/dns.');
121
151
  else if (a === '--enroll-port') opts.enrollPort = argv[++i] || fail('--enroll-port needs a port.');
122
152
  else if (a === '--host') opts.host = argv[++i] || fail('--host needs the gateway ip/dns.');
153
+ else if (a === '--systemd') opts.systemd = true;
154
+ else if (a === '--port') opts.port = argv[++i] || fail('--port needs a value (1-65535).');
155
+ else if (a === '--service-user') opts.serviceUser = argv[++i] || fail('--service-user needs an account name.');
123
156
  else if (a.startsWith('-')) fail(`Unknown option: ${a}`, 'Run with --help to see the available options.');
124
157
  else positionals.push(a);
125
158
  }
159
+ if ((opts.port !== null || opts.serviceUser !== null) && !opts.systemd) {
160
+ fail('--port and --service-user only apply together with --systemd.',
161
+ 'Without a unit, the port comes from the PORT environment variable or the site config (default 3000).');
162
+ }
163
+ if (opts.systemd) {
164
+ if (opts.mode !== 'create' && opts.mode !== 'upgrade') {
165
+ fail(`--systemd is not available for "${opts.mode}".`, 'Use it when creating or upgrading a single-machine site.');
166
+ }
167
+ if (opts.port !== null) {
168
+ const n = /^\d{1,5}$/.test(opts.port) ? Number(opts.port) : NaN;
169
+ if (!(n >= 1 && n <= 65535)) fail(`--port must be a whole number from 1 to 65535 (got "${opts.port}").`);
170
+ opts.port = n;
171
+ }
172
+ const problem = serviceUserProblem(opts.serviceUser === null ? DEFAULT_SERVICE_USER : opts.serviceUser);
173
+ if (problem) fail(problem.message, problem.hint);
174
+ }
126
175
  // Positionals: `join <role> [dir]` takes the role first; every other mode takes just [dir].
127
176
  if (opts.mode === 'join' && !opts.role) opts.role = positionals.shift() || null;
128
177
  opts.dir = positionals.shift() || null;
@@ -145,7 +194,13 @@ function request(url, headers, redirectsLeft = 5) {
145
194
  const req = https.get(url, { headers: { 'user-agent': 'create-wordjs', ...headers } }, (res) => {
146
195
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
147
196
  res.resume(); // GitHub release assets redirect to objects.githubusercontent.com
148
- resolve(request(new URL(res.headers.location, url).toString(), headers, redirectsLeft - 1));
197
+ const next = new URL(res.headers.location, url);
198
+ // Never let a redirect downgrade the transfer to plain HTTP (or any other scheme).
199
+ if (next.protocol !== 'https:') {
200
+ reject(new Error(`Refusing to follow a redirect from ${url} to a non-https URL (${next.protocol}//${next.host}).`));
201
+ return;
202
+ }
203
+ resolve(request(next.toString(), headers, redirectsLeft - 1));
149
204
  return;
150
205
  }
151
206
  resolve(res);
@@ -164,6 +219,62 @@ function readBody(res) {
164
219
  });
165
220
  }
166
221
 
222
+ // --- integrity: SHA-256 of the release ZIP --------------------------------------------------------
223
+ //
224
+ // The bundle we download becomes the site's code, so it is verified before a single entry is
225
+ // extracted. release.yml publishes `wordjs-<tag>.zip.sha256` (sha256sum format: `<hex> <name>`) next
226
+ // to every tag-named bundle; the GitHub flow downloads it and refuses a ZIP whose digest differs.
227
+ // Releases published before that asset existed have no checksum to check against — those install
228
+ // with a loud warning rather than not at all. `--sha256 <hex>` pins a digest the user obtained out of
229
+ // band, for `--zip` sources (and on top of the release checksum when both are present).
230
+
231
+ /** Lower-case 64-char hex, or null. Accepts the `AA:BB:…` colon form some tools print. */
232
+ function normalizeSha256(value) {
233
+ const hex = String(value == null ? '' : value).trim().replace(/:/g, '').toLowerCase();
234
+ return /^[0-9a-f]{64}$/.test(hex) ? hex : null;
235
+ }
236
+
237
+ /**
238
+ * Read the digest for `expectedName` out of a sha256sum-style checksum file. A line naming a
239
+ * DIFFERENT file is ignored (a checksum file for another asset must not vouch for this one); a bare
240
+ * digest with no name is accepted. Returns null when no usable line is found — the caller fails closed.
241
+ */
242
+ function parseChecksumFile(text, expectedName) {
243
+ for (const raw of String(text || '').split(/\r?\n/)) {
244
+ // Split instead of one regex with `\s+…(.+)`: linear on any input (CodeQL js/polynomial-redos).
245
+ const line = raw.trim();
246
+ const sep = line.search(/\s/);
247
+ const digest = sep === -1 ? line : line.slice(0, sep);
248
+ if (!/^[0-9a-fA-F]{64}$/.test(digest)) continue;
249
+ const name = sep === -1 ? '' : line.slice(sep).trim().replace(/^\*/, '');
250
+ if (name && expectedName && path.basename(name).toLowerCase() !== String(expectedName).toLowerCase()) continue;
251
+ return digest.toLowerCase();
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function sha256File(file) {
257
+ return new Promise((resolve, reject) => {
258
+ const hash = crypto.createHash('sha256');
259
+ fs.createReadStream(file)
260
+ .on('error', reject)
261
+ .on('data', (c) => hash.update(c))
262
+ .on('end', () => resolve(hash.digest('hex')));
263
+ });
264
+ }
265
+
266
+ /**
267
+ * What a `--zip` value points at: an https:// URL, a local file, or something refused. Plain http://
268
+ * (and any other URL scheme) is refused — a bundle fetched over an unauthenticated channel can be
269
+ * swapped in transit, and it becomes the site's code. Windows paths (`C:\…`) are files, not URLs.
270
+ */
271
+ function classifyZipSource(zip) {
272
+ const value = String(zip || '');
273
+ if (/^https:\/\//i.test(value)) return { kind: 'url', url: value };
274
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return { kind: 'refused', scheme: value.slice(0, value.indexOf(':')).toLowerCase() };
275
+ return { kind: 'file', path: value };
276
+ }
277
+
167
278
  async function githubJson(url) {
168
279
  let res;
169
280
  try {
@@ -214,6 +325,12 @@ function pickBundleAsset(assets, tagName) {
214
325
  return loose.length === 1 ? loose[0] : null;
215
326
  }
216
327
 
328
+ /** The `<bundle>.sha256` asset for the bundle we picked — matched by EXACT name, never by shape. */
329
+ function pickChecksumAsset(assets, bundleName) {
330
+ const wanted = `${bundleName}.sha256`.toLowerCase();
331
+ return (Array.isArray(assets) ? assets : []).find((a) => String(a && a.name || '').toLowerCase() === wanted) || null;
332
+ }
333
+
217
334
  /** Every asset matching the loose `wordjs-*.zip` shape — used to explain an ambiguous refusal. */
218
335
  function looseBundleCandidates(assets) {
219
336
  const list = Array.isArray(assets) ? assets : [];
@@ -240,7 +357,25 @@ async function resolveReleaseAsset(tag) {
240
357
  }
241
358
  fail(`Release ${release.tag_name} has no wordjs-*.zip asset.`, 'Pass --zip <path-or-url> instead.');
242
359
  }
243
- return { name: asset.name, url: asset.browser_download_url, tag: release.tag_name };
360
+ const sum = pickChecksumAsset(release.assets, asset.name);
361
+ return {
362
+ name: asset.name, url: asset.browser_download_url, tag: release.tag_name,
363
+ checksumName: sum ? sum.name : null, checksumUrl: sum ? sum.browser_download_url : null,
364
+ };
365
+ }
366
+
367
+ /** Download a small text asset (the checksum file). Anything but a 200 is fatal: fail closed. */
368
+ async function downloadText(url, maxBytes = 64 * 1024) {
369
+ let res;
370
+ try { res = await request(url, { accept: 'application/octet-stream' }); }
371
+ catch (e) { fail(`Could not download ${url} (${e.message}).`, 'Check your network and try again.'); }
372
+ if (res.statusCode !== 200) {
373
+ res.resume();
374
+ fail(`Download failed (HTTP ${res.statusCode}) for ${url}.`, 'The release lists a checksum file that could not be fetched; refusing to install unverified.');
375
+ }
376
+ const body = await readBody(res);
377
+ if (body.length > maxBytes) fail(`${url} is implausibly large for a checksum file.`);
378
+ return body;
244
379
  }
245
380
 
246
381
  async function download(url, dest, label) {
@@ -277,10 +412,84 @@ async function download(url, dest, label) {
277
412
 
278
413
  // --- extraction + scaffolding ------------------------------------------------------------------
279
414
 
415
+ // S_IFMT / S_IFLNK out of the high 16 bits of a ZIP entry's external attributes (Unix creators).
416
+ const S_IFMT = 0o170000;
417
+ const S_IFLNK = 0o120000;
418
+
419
+ /**
420
+ * Where `entryName` would land under `root` — or a thrown error when it must not be written at all.
421
+ * adm-zip's own extractAllTo already strips `../` and leading `/` (it rewrites rather than refuses),
422
+ * but a release bundle that contains such an entry is not a release bundle: refuse the whole archive
423
+ * explicitly instead of trusting a silent rewrite in a dependency (same rule as the backend's
424
+ * installPluginFromZip). Refused: absolute paths (POSIX or Windows drive/UNC), any `..` segment, and
425
+ * anything whose resolved path is not inside root.
426
+ */
427
+ function containedEntryPath(root, entryName) {
428
+ const rel = String(entryName).replace(/\\/g, '/');
429
+ if (!rel || rel.includes('\0')) throw new Error(`Refusing ZIP entry with an invalid name: ${JSON.stringify(entryName)}`);
430
+ if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel) || path.isAbsolute(rel) || path.win32.isAbsolute(rel)) {
431
+ throw new Error(`Refusing ZIP entry with an absolute path: ${entryName}`);
432
+ }
433
+ if (rel.split('/').includes('..')) throw new Error(`Refusing ZIP entry that climbs out of the target (Zip Slip): ${entryName}`);
434
+ const dest = path.resolve(root, rel);
435
+ if (dest !== root && !dest.startsWith(root + path.sep)) {
436
+ throw new Error(`Refusing ZIP entry that resolves outside the target (Zip Slip): ${entryName}`);
437
+ }
438
+ return dest;
439
+ }
440
+
441
+ /** A symlink entry (Unix mode stored in the external attributes). The bundle never contains one. */
442
+ function isSymlinkEntry(entry) {
443
+ const attr = Number(entry && entry.header && entry.header.attr) >>> 0;
444
+ return ((attr >>> 16) & S_IFMT) === S_IFLNK;
445
+ }
446
+
447
+ /**
448
+ * The mode an installed file gets: 0755 when the archive marked it executable for anyone, else 0644.
449
+ * Never group- or world-writable, whatever the archive says — and an archive built on Windows carries
450
+ * no Unix mode at all, which must not be read as "anything goes".
451
+ */
452
+ function installedFileMode(entry) {
453
+ const unix = ((Number(entry && entry.header && entry.header.attr) >>> 0) >>> 16) & 0o777;
454
+ return (unix & 0o111) ? 0o755 : 0o644;
455
+ }
456
+
280
457
  function extractZip(zipPath, targetDir) {
281
458
  const AdmZip = require('adm-zip'); // lazy so --help works even before deps are installed
282
459
  const zip = new AdmZip(zipPath);
283
- zip.extractAllTo(targetDir, true);
460
+ // Vet EVERY entry before writing ANY: a refused archive leaves nothing half-extracted behind.
461
+ const root = path.resolve(targetDir);
462
+ const entries = zip.getEntries();
463
+ const planned = entries.map((entry) => {
464
+ const dest = containedEntryPath(root, entry.entryName);
465
+ if (isSymlinkEntry(entry)) throw new Error(`Refusing ZIP entry that is a symbolic link: ${entry.entryName}`);
466
+ return { entry, dest };
467
+ });
468
+ // Written entry by entry, NOT with adm-zip's extractAllTo: its writeFileTo does chmod(path, attr ||
469
+ // 0o666), and chmod ignores the umask, so every installed file — code that may later run as root —
470
+ // came out world-writable, and an upgrade over an existing 0644 install turned it into 0666 as well.
471
+ for (const { entry, dest } of planned) {
472
+ if (entry.isDirectory) {
473
+ fs.mkdirSync(dest, { recursive: true, mode: 0o755 });
474
+ continue;
475
+ }
476
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o755 });
477
+ const mode = installedFileMode(entry);
478
+ let existing = null;
479
+ try { existing = fs.lstatSync(dest); } catch { /* new file */ }
480
+ if (existing) {
481
+ // An upgrade writes over an existing tree: never through a link someone placed there, and
482
+ // tighten a file left writable by an older installer BEFORE putting new code into it.
483
+ if (existing.isSymbolicLink() || !existing.isFile()) {
484
+ throw new Error(`Refusing to overwrite ${path.relative(root, dest)}: it is not a regular file in the existing install`);
485
+ }
486
+ fs.chmodSync(dest, mode);
487
+ }
488
+ fs.writeFileSync(dest, entry.getData(), { mode });
489
+ // `mode` only applies on creation and is narrowed by the umask; restate it so the result is the
490
+ // same for every caller and every existing file.
491
+ fs.chmodSync(dest, mode);
492
+ }
284
493
  // Official bundles put files at the ZIP root; tolerate a single wrapper folder too.
285
494
  if (!fs.existsSync(path.join(targetDir, 'package.json'))) {
286
495
  const entries = fs.readdirSync(targetDir);
@@ -296,6 +505,76 @@ function extractZip(zipPath, targetDir) {
296
505
  }
297
506
  }
298
507
 
508
+ /**
509
+ * Obtain the release ZIP — a local file, an https:// URL, or the GitHub release (latest or --version)
510
+ * — and verify its SHA-256 BEFORE anything is extracted. Shared by create, upgrade, gateway and join so
511
+ * every path that installs code applies the same rule. Returns { zipPath, tag, cleanup }.
512
+ */
513
+ async function obtainBundleZip(opts) {
514
+ let tmpDir = null;
515
+ const cleanup = () => { if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } } };
516
+ let zipPath;
517
+ let tag = opts.version || null;
518
+ const checks = []; // [{ expected, source }] — every one must match
519
+ if (opts.sha256) checks.push({ expected: opts.sha256, source: '--sha256' });
520
+
521
+ if (opts.zip) {
522
+ const src = classifyZipSource(opts.zip);
523
+ if (src.kind === 'refused') {
524
+ fail(`Refusing --zip ${opts.zip}: only https:// URLs and local file paths are accepted (got ${src.scheme}://).`,
525
+ 'A bundle fetched over plain http:// can be replaced in transit. Serve it over https, or download it yourself and pass the local path (with --sha256 <hex>).');
526
+ }
527
+ if (src.kind === 'file') {
528
+ zipPath = path.resolve(process.cwd(), src.path);
529
+ if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
530
+ console.log(` Using local bundle: ${zipPath}`);
531
+ } else {
532
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
533
+ zipPath = path.join(tmpDir, 'wordjs.zip');
534
+ await download(src.url, zipPath, 'wordjs.zip');
535
+ if (!opts.sha256) {
536
+ console.warn(' ⚠️ No --sha256 given for this --zip URL — its integrity is NOT verified.');
537
+ console.warn(' Pass --sha256 <hex> (from the release\'s .sha256 asset) to pin it.');
538
+ }
539
+ }
540
+ } else {
541
+ console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
542
+ const asset = await resolveReleaseAsset(opts.version);
543
+ tag = asset.tag;
544
+ console.log(` Found ${asset.tag} → ${asset.name}`);
545
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
546
+ zipPath = path.join(tmpDir, asset.name);
547
+ await download(asset.url, zipPath, asset.name);
548
+ if (asset.checksumUrl) {
549
+ const expected = parseChecksumFile(await downloadText(asset.checksumUrl), asset.name);
550
+ if (!expected) {
551
+ cleanup();
552
+ fail(`${asset.checksumName} does not contain a SHA-256 for ${asset.name}.`, 'Refusing to install a bundle that cannot be verified.');
553
+ }
554
+ checks.push({ expected, source: asset.checksumName });
555
+ } else {
556
+ // Releases published before the checksum asset existed. Installing them still works (so
557
+ // `--version <old-tag>` rollbacks keep working), but say plainly that nothing was verified.
558
+ console.warn(` ⚠️ Release ${asset.tag} publishes no ${asset.name}.sha256 — the download's integrity`);
559
+ console.warn(' could NOT be verified (releases before the checksum asset was introduced).');
560
+ console.warn(' Pin one with --sha256 <hex> if you have it from a trusted source.');
561
+ }
562
+ }
563
+
564
+ if (checks.length) {
565
+ const actual = await sha256File(zipPath);
566
+ for (const { expected, source } of checks) {
567
+ if (actual !== expected) {
568
+ cleanup();
569
+ fail(`SHA-256 mismatch for the release ZIP (checked against ${source}).`,
570
+ `expected ${expected}\n got ${actual}\n Refusing to install — the download is corrupt or has been tampered with.`);
571
+ }
572
+ }
573
+ console.log(` ✓ SHA-256 verified (${checks.map((c) => c.source).join(' + ')})`);
574
+ }
575
+ return { zipPath, tag, cleanup };
576
+ }
577
+
299
578
  function runNpmScript(script, cwd, extraEnv) {
300
579
  // A single command string with shell:true resolves npm/npm.cmd on every platform (and avoids
301
580
  // Node's DEP0190 warning about args-array + shell). The string is fixed — no user input in it.
@@ -352,6 +631,360 @@ function ensureHttpsConfig(targetDir) {
352
631
  }
353
632
  }
354
633
 
634
+ // --- systemd service (--systemd) ---------------------------------------------------------------
635
+ //
636
+ // WHY THE UNIT GRANTS NOTHING, AND WHY THIS INSTALLER STOPPED PRINTING `setcap`.
637
+ //
638
+ // The familiar way to let a non-root Node service bind 80/443 (or 25) is CAP_NET_BIND_SERVICE: either a
639
+ // file capability set on the node binary with `setcap`, which this installer used to recommend, or
640
+ // `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit. Both collide with the plugin sandbox. On Linux
641
+ // every isolated plugin starts through backend/scripts/landlock-seccomp-shim.pl, which must shed every
642
+ // capability it inherited before it confines the plugin. An AMBIENT capability is inherited by the shim
643
+ // across execve; the shim took it for root, ran a root-only drop (setgroups, securebits, the bounding set
644
+ // need CAP_SETGID/CAP_SETPCAP, which a non-root service does not hold), failed with EPERM and every plugin
645
+ // was refused: the production failure behind this option. (The shim now sheds a non-root service's
646
+ // capabilities on its own, but a service that needs none should hold none, and the core reports one that
647
+ // does.) A FILE capability on node fails differently, and it is worth
648
+ // being exact about how, because it does NOT reproduce that failure. Measured on Linux 7.0 / node 22
649
+ // (a capped copy of node, run as an unprivileged user):
650
+ // . it reaches no further than the binary: perl carries no file capability, so the shim it spawns
651
+ // starts with an empty permitted set, and the plugin's node, exec'd under no_new_privs, gets none;
652
+ // . it reaches EVERY script that binary runs, for every user on the machine: any `node -e` could
653
+ // bind port 81, not only WordJS;
654
+ // . it puts node in secure-execution mode (AT_SECURE=1): glibc strips TMPDIR and the LD_* family from
655
+ // its environment, and the confined plugin child, exec'd from the same binary, inherits AT_SECURE
656
+ // without the capability, so there Node also ignores NODE_OPTIONS and TMPDIR is gone;
657
+ // . it lives in an xattr on the binary's inode, so a node upgrade (a new file renamed over the old
658
+ // one) or any rewrite of the file silently removes it, and the next restart cannot bind its port.
659
+ //
660
+ // So the unit holds no capability at all, and a port below 1024 is reached the way the kernel offers an
661
+ // unprivileged service: net.ipv4.ip_unprivileged_port_start, written as a sysctl.d drop-in beside the
662
+ // unit. That has a cost the drop-in spells out (it lowers the floor for every unprivileged process in the
663
+ // network namespace, not for WordJS alone), which is why a reverse proxy on 80/443 in front of a high
664
+ // port is offered as the alternative everywhere this advice appears.
665
+ const DEFAULT_SERVICE_USER = 'wordjs';
666
+ const SYSTEMD_UNIT_FILE = 'wordjs.service';
667
+ const SYSCTL_DROPIN_FILE = '60-wordjs-ports.conf';
668
+ // The kernel default of net.ipv4.ip_unprivileged_port_start: binding a port below it needs privilege.
669
+ const PRIVILEGED_PORT_LIMIT = 1024;
670
+ // systemd's PATH for system services. WordJS runs `npm` BY NAME to install a plugin's declared
671
+ // dependencies at activation, and npm's own shebang finds node through PATH, so when node lives anywhere
672
+ // else its directory is prepended in the unit; otherwise activation would fail with ENOENT.
673
+ const SYSTEMD_DEFAULT_PATH = ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin'];
674
+
675
+ /** Why `name` cannot be the service account, or null when it can. */
676
+ function serviceUserProblem(name) {
677
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(String(name))) {
678
+ return {
679
+ message: `"${name}" is not a usable account name for --service-user.`,
680
+ hint: 'Use lowercase letters, digits, "_" or "-", starting with a letter or "_" (for example: wordjs).',
681
+ };
682
+ }
683
+ if (name === 'root') {
684
+ return {
685
+ message: 'WordJS must not run as root.',
686
+ hint: 'Use a dedicated account (the default is wordjs); the steps printed with the unit create it.',
687
+ };
688
+ }
689
+ return null;
690
+ }
691
+
692
+ // A unit file is not a shell, but it is not inert either: `%` starts a specifier, `$` an environment
693
+ // expansion in ExecStart=, whitespace separates ExecStart= arguments and ReadWritePaths= entries, and
694
+ // quotes and backslashes are parsed. Rather than escape a path for three different parsers, accept only
695
+ // paths that need no escaping in any of them and ask for a plain one otherwise.
696
+ function unitSafePath(p) {
697
+ return typeof p === 'string' && /^\/[A-Za-z0-9._@+,/-]*$/.test(p) && !p.split('/').includes('..');
698
+ }
699
+
700
+ function underAny(p, roots) {
701
+ return roots.some((r) => p === r || p.startsWith(r + '/'));
702
+ }
703
+
704
+ /**
705
+ * The sysctl.d drop-in that lets an unprivileged service bind from `start` up, with its trade-off
706
+ * written into the file itself: whoever later finds it in /etc/sysctl.d/ should not have to guess why
707
+ * it is there or what it costs.
708
+ */
709
+ function sysctlDropIn(start, { http = false } = {}) {
710
+ const lines = [
711
+ `# WordJS: allow unprivileged processes to bind ports from ${start} up (written by create-wordjs --systemd).`,
712
+ '#',
713
+ '# This is NOT a grant to WordJS alone. It lowers the privileged-port floor for EVERY unprivileged',
714
+ `# process in this network namespace, so any local user or service may bind ports ${start}-1023 too, and`,
715
+ '# could take the port first while WordJS is stopped. Use it on a host you do not share with untrusted',
716
+ '# users; otherwise put a reverse proxy (nginx, Caddy) on 80/443 and keep WordJS on a high port.',
717
+ '# It covers IPv6 as well, despite its name. A container has its own network namespace and value.',
718
+ ];
719
+ if (!http && start > 80) {
720
+ lines.push(`# Let's Encrypt HTTP-01 (acme.http01Port: 80 in wordjs-config.json) needs this lowered to 80.`);
721
+ }
722
+ lines.push(
723
+ '#',
724
+ `# Install: sudo install -o root -g root -m 0644 ${SYSCTL_DROPIN_FILE} /etc/sysctl.d/ && sudo sysctl --system`,
725
+ `# Withdraw: sudo rm /etc/sysctl.d/${SYSCTL_DROPIN_FILE} && sudo sysctl -w net.ipv4.ip_unprivileged_port_start=1024 && sudo sysctl --system`,
726
+ `net.ipv4.ip_unprivileged_port_start = ${start}`,
727
+ '',
728
+ );
729
+ return lines.join('\n');
730
+ }
731
+
732
+ /**
733
+ * Everything --systemd writes, as text: the unit, the sysctl drop-in when a bound port is below 1024, and
734
+ * the warnings worth printing. Pure (POSIX paths in, strings out) so it can be exercised directly.
735
+ *
736
+ * port the explicit --port, or null. Only an explicit port is written into the unit: an
737
+ * inherited value would silently override a port changed in the config later.
738
+ * configuredPort the port the site's config already sets (gatewayPort), or null.
739
+ * acmeHttp01Port acme.http01Port from the config, or null: monolith.js binds it beside an HTTPS
740
+ * listener, so it counts toward the privileged-port floor.
741
+ */
742
+ function buildSystemdFiles({ installDir, nodePath, user = DEFAULT_SERVICE_USER, port = null, configuredPort = null, acmeHttp01Port = null, http = false }) {
743
+ for (const [what, p] of [['site directory', installDir], ['node binary', nodePath]]) {
744
+ if (!unitSafePath(p)) {
745
+ const err = new Error(`The ${what} path "${p}" cannot go into a systemd unit as is: it must be absolute, without spaces, quotes, backslashes, "%" or "$".`);
746
+ err.hint = what === 'site directory'
747
+ ? 'Install the site under a plain path such as /srv/wordjs.'
748
+ : 'Install Node.js under a plain path such as /usr/bin/node or /usr/local/bin/node.';
749
+ throw err;
750
+ }
751
+ }
752
+ const problem = serviceUserProblem(user);
753
+ if (problem) { const err = new Error(problem.message); err.hint = problem.hint; throw err; }
754
+
755
+ const publicPort = port || configuredPort || 3000;
756
+ const bound = [publicPort];
757
+ if (!http && acmeHttp01Port) bound.push(acmeHttp01Port);
758
+ const privileged = bound.filter((p) => p < PRIVILEGED_PORT_LIMIT);
759
+ const sysctlStart = privileged.length ? Math.min(...privileged) : null;
760
+
761
+ // Two hardening directives depend on WHERE things are, and each is dropped (with a warning) rather
762
+ // than shipped in a shape that cannot start: ProtectHome= hides /home, /root and /run/user, which
763
+ // breaks a site or a node (nvm, fnm, asdf) living there; PrivateTmp= gives the service an empty
764
+ // /tmp and /var/tmp, which would hide a site installed there.
765
+ const homeRoots = ['/home', '/root', '/run/user'];
766
+ const tmpRoots = ['/tmp', '/var/tmp'];
767
+ const warnings = [];
768
+ if (underAny(installDir, homeRoots)) {
769
+ warnings.push(`The site is inside a home directory (${installDir}). A service account usually cannot reach it there, and the unit leaves out ProtectHome=. Prefer /srv/<name> or /opt/<name>.`);
770
+ }
771
+ if (underAny(nodePath, homeRoots)) {
772
+ warnings.push(`node runs from ${nodePath}, inside a home directory (nvm, fnm, asdf…). The service account usually cannot execute it there: install Node.js system-wide and point ExecStart= at it, or re-run this with that node.`);
773
+ }
774
+ if (underAny(installDir, tmpRoots)) {
775
+ warnings.push(`The site is under a temporary directory (${installDir}), which the system may clean. The unit leaves out PrivateTmp= because it would hide the site; move it to /srv or /opt.`);
776
+ }
777
+
778
+ const nodeDir = path.posix.dirname(nodePath);
779
+ const env = ['NODE_ENV=production'];
780
+ if (port) env.push(`PORT=${port}`);
781
+ if (http) env.push('WORDJS_HTTP=1');
782
+ // HOME pinned inside the one writable tree: npm keeps its cache and reads its config under $HOME
783
+ // when it installs plugin dependencies, and ProtectHome= would hide a home under /home.
784
+ env.push(`HOME=${installDir}`);
785
+ if (!SYSTEMD_DEFAULT_PATH.includes(nodeDir)) env.push(`PATH=${[nodeDir, ...SYSTEMD_DEFAULT_PATH].join(':')}`);
786
+
787
+ const unit = [
788
+ '# WordJS - generated by create-wordjs --systemd.',
789
+ '#',
790
+ `# Runs the site as the unprivileged account "${user}" and grants it NO Linux capabilities. Keep it`,
791
+ '# that way: do not add AmbientCapabilities=, widen CapabilityBoundingSet= or setcap the node binary',
792
+ '# to reach a port below 1024. On Linux every isolated plugin runs under a Landlock/seccomp sandbox',
793
+ '# that has to shed any capability the service holds before it can confine the plugin.',
794
+ sysctlStart
795
+ ? `# Port ${publicPort} is below 1024: install ${SYSCTL_DROPIN_FILE} (next to this file) or use a reverse proxy.`
796
+ : '# Ports below 1024: lower net.ipv4.ip_unprivileged_port_start, or use a reverse proxy.',
797
+ '# See documentation/deployment.md, "Running as a service".',
798
+ '',
799
+ '[Unit]',
800
+ 'Description=WordJS (monolith)',
801
+ 'Documentation=https://github.com/jaimemartinez/wordjs/blob/main/documentation/deployment.md',
802
+ 'After=network-online.target',
803
+ 'Wants=network-online.target',
804
+ '',
805
+ '[Service]',
806
+ 'Type=simple',
807
+ `User=${user}`,
808
+ `WorkingDirectory=${installDir}`,
809
+ `ExecStart=${nodePath} ${installDir}/monolith.js prod`,
810
+ ...env.map((e) => `Environment=${e}`),
811
+ 'Restart=on-failure',
812
+ 'RestartSec=5',
813
+ '',
814
+ '# Hardening. Each line takes something away; none grants anything.',
815
+ '# NoNewPrivileges, empty CapabilityBoundingSet: nothing the service runs can gain privileges',
816
+ '# (no setuid binary, no file capability). The plugin sandbox sets no_new_privs itself and',
817
+ '# needs no capability, so it is unaffected.',
818
+ '# ProtectSystem=strict + ReadWritePaths: the filesystem is read-only except the site directory,',
819
+ '# where WordJS writes its database, uploads, backups, plugins, themes and build caches.',
820
+ '# PrivateTmp, ProtectHome: a private /tmp, and no view of /home, /root or /run/user.',
821
+ 'NoNewPrivileges=yes',
822
+ 'CapabilityBoundingSet=',
823
+ ...(underAny(installDir, tmpRoots) ? [] : ['PrivateTmp=yes']),
824
+ 'ProtectSystem=strict',
825
+ `ReadWritePaths=${installDir}`,
826
+ ...((underAny(installDir, homeRoots) || underAny(nodePath, homeRoots)) ? [] : ['ProtectHome=yes']),
827
+ '',
828
+ '[Install]',
829
+ 'WantedBy=multi-user.target',
830
+ '',
831
+ ].join('\n');
832
+
833
+ return {
834
+ unit,
835
+ sysctl: sysctlStart ? { start: sysctlStart, content: sysctlDropIn(sysctlStart, { http }) } : null,
836
+ publicPort,
837
+ warnings,
838
+ };
839
+ }
840
+
841
+ /**
842
+ * The steps to install what buildSystemdFiles produced, as printable lines (pure, for the same reason).
843
+ * `stageDir` is where writeSystemdFiles put the two files - never the site directory (see there).
844
+ */
845
+ function systemdInstallSteps({ installDir, user = DEFAULT_SERVICE_USER, built, http = false, upgrade = false, nodePath = process.execPath, stageDir }) {
846
+ const staged = (f) => path.posix.join(stageDir, f);
847
+ const lines = [];
848
+ let n = 0;
849
+ const step = (title, ...cmds) => { lines.push(` ${++n}. ${title}`); for (const c of cmds) lines.push(` ${c}`); };
850
+ lines.push('To run it as a service (as root):');
851
+ step('Create the service account (skip if it exists):',
852
+ `sudo useradd --system --home-dir ${installDir} --shell /usr/sbin/nologin ${user}`);
853
+ step('Give it the site directory:', `sudo chown -R ${user}: ${installDir}`);
854
+ // `install -o root -g root -m 0644` and not `cp`: root takes the staged file's CONTENT into a file it
855
+ // creates itself, root-owned and not writable by anyone else, whatever the staged copy's mode was.
856
+ if (built.sysctl) {
857
+ step(`Let it bind port ${built.publicPort} without a capability (read the trade-off in the file first):`,
858
+ `sudo install -o root -g root -m 0644 ${staged(SYSCTL_DROPIN_FILE)} /etc/sysctl.d/${SYSCTL_DROPIN_FILE} && sudo sysctl --system`);
859
+ } else if (upgrade) {
860
+ // A site that moved to a high port (behind a proxy, say) no longer needs the floor lowered, and an
861
+ // installed drop-in would go on letting every unprivileged process bind below 1024 for no reason.
862
+ // Setting 1024 first and re-applying the rest leaves any OTHER drop-in's value in force.
863
+ step(`Port ${built.publicPort} needs no lowered port floor. If an earlier run installed /etc/sysctl.d/${SYSCTL_DROPIN_FILE}, withdraw it:`,
864
+ `sudo rm -f /etc/sysctl.d/${SYSCTL_DROPIN_FILE} && sudo sysctl -w net.ipv4.ip_unprivileged_port_start=1024 && sudo sysctl --system`);
865
+ }
866
+ step('Install the unit and start it:',
867
+ `sudo install -o root -g root -m 0644 ${staged(SYSTEMD_UNIT_FILE)} /etc/systemd/system/${SYSTEMD_UNIT_FILE}`,
868
+ // enable on upgrade too: a site started with `npm run start:mono`, pm2 or a unit of another name
869
+ // has no enabled wordjs.service, and a restart alone does not bring it back after a reboot.
870
+ upgrade ? 'sudo systemctl daemon-reload && sudo systemctl enable wordjs && sudo systemctl restart wordjs'
871
+ : 'sudo systemctl daemon-reload && sudo systemctl enable --now wordjs');
872
+ if (!upgrade) {
873
+ const proto = http ? 'http' : 'https';
874
+ const defaultPort = http ? 80 : 443;
875
+ const hostPort = built.publicPort === defaultPort ? '<your-host>' : `<your-host>:${built.publicPort}`;
876
+ step('Finish setup in the browser with the one-time install token the first boot writes:',
877
+ `sudo cat ${path.posix.join(installDir, 'backend', 'data', 'install-token')}`,
878
+ `then open ${proto}://${hostPort}/install#token=<that token>`);
879
+ } else {
880
+ lines.push(' Before step ' + n + ', stop and disable whatever served this site until now, or it and wordjs.service',
881
+ ' will fight over the port: a unit with another name (sudo systemctl disable --now <name>), pm2',
882
+ ' (pm2 delete <name> && pm2 save), or a foreground `npm run start:mono`. A unit that was also named',
883
+ ' wordjs.service is replaced by step ' + n + ' - including an AmbientCapabilities= or capability-carrying',
884
+ ` CapabilityBoundingSet= in it. If node carries a file capability (check with getcap ${nodePath}),`,
885
+ ` remove it: sudo setcap -r ${nodePath}`);
886
+ }
887
+ lines.push(` The staged copies in ${stageDir} are not needed afterwards: rm -r ${stageDir}`);
888
+ lines.push(' Logs: journalctl -u wordjs -f');
889
+ return lines;
890
+ }
891
+
892
+ /**
893
+ * A fresh, private directory to stage the files root will install.
894
+ *
895
+ * NEVER THE SITE DIRECTORY. These two files are read by root and installed into /etc - the unit decides
896
+ * which account the service runs as and what it may do; the drop-in is applied with `sysctl --system`.
897
+ * The site directory is handed to the service account by the very next printed step (`chown -R`), so a
898
+ * compromised WordJS process - or a plugin that got out of its sandbox - could rewrite either file
899
+ * between that step and root's copy (`User=root`, an ExecStartPre=; `kernel.modprobe = …`), or put a
900
+ * symlink in its place so this CLI, run with sudo, wrote through it into any file root can write.
901
+ * Measured: as the service account, a planted symlink made a root run of the old writer overwrite a
902
+ * root-only 0600 file, and a plain `mv` replaced the staged drop-in root was told to apply.
903
+ * mkdtemp creates a new directory, mode 0700, owned by whoever runs this CLI, under a name nobody could
904
+ * predict; the service account cannot enter it.
905
+ */
906
+ function createSystemdStagingDir() {
907
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'wordjs-systemd-'));
908
+ }
909
+
910
+ /**
911
+ * Write the unit (and the sysctl drop-in when one is needed) into `stageDir`, a directory from
912
+ * createSystemdStagingDir(). Each file is created EXCLUSIVELY (`wx` = O_CREAT|O_EXCL): an existing file -
913
+ * or a symlink planted under the name - makes the write fail rather than go through it.
914
+ */
915
+ function writeSystemdFiles(stageDir, built) {
916
+ const unitPath = path.join(stageDir, SYSTEMD_UNIT_FILE);
917
+ const sysctlPath = path.join(stageDir, SYSCTL_DROPIN_FILE);
918
+ fs.writeFileSync(unitPath, built.unit, { flag: 'wx', mode: 0o644 });
919
+ if (built.sysctl) fs.writeFileSync(sysctlPath, built.sysctl.content, { flag: 'wx', mode: 0o644 });
920
+ return { unitPath, sysctlPath: built.sysctl ? sysctlPath : null };
921
+ }
922
+
923
+ /**
924
+ * Why --systemd cannot run for this site, with this node and as this account - or null. Checked before
925
+ * anything is downloaded, extracted or installed: a create used to fetch and install the whole release
926
+ * and only THEN refuse the path, leaving the populated directory behind, and an upgrade applied the new
927
+ * version and then exited 1.
928
+ */
929
+ function systemdPreflight({ installDir, nodePath = process.execPath, user = DEFAULT_SERVICE_USER, runningAs = null }) {
930
+ for (const [what, p] of [['site directory', installDir], ['node binary', nodePath]]) {
931
+ if (!unitSafePath(p)) {
932
+ return {
933
+ message: `The ${what} path "${p}" cannot go into a systemd unit as is: it must be absolute, without spaces, quotes, backslashes, "%" or "$".`,
934
+ hint: what === 'site directory'
935
+ ? 'Install the site under a plain path such as /srv/wordjs.'
936
+ : 'Install Node.js under a plain path such as /usr/bin/node or /usr/local/bin/node.',
937
+ };
938
+ }
939
+ }
940
+ // The files root will install must not be writable by the service. Staged by the service account
941
+ // itself, they would be.
942
+ if (runningAs && runningAs === user) {
943
+ return {
944
+ message: `This is running as "${user}", the account the service will run as.`,
945
+ hint: 'Run create-wordjs --systemd as your own account or with sudo: the unit it stages for root must not be writable by the service.',
946
+ };
947
+ }
948
+ return null;
949
+ }
950
+
951
+ /** --systemd for a site on disk: read its config, stage the files, print the steps. Throws (with .hint) instead of exiting, so a caller's cleanup still runs. */
952
+ function emitSystemd(installDir, opts) {
953
+ const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } };
954
+ // The same precedence monolith.js applies (minus the PORT env var, which is what --port writes).
955
+ const appConfig = readJson(path.join(installDir, 'backend', 'wordjs-config.json'));
956
+ const gwConfig = readJson(path.join(installDir, 'gateway', 'gateway-config.json'));
957
+ const user = opts.serviceUser || DEFAULT_SERVICE_USER;
958
+ const built = buildSystemdFiles({
959
+ installDir,
960
+ nodePath: process.execPath,
961
+ user,
962
+ port: opts.port,
963
+ configuredPort: Number(appConfig.gatewayPort || gwConfig.gatewayPort) || null,
964
+ acmeHttp01Port: Number(appConfig.acme && appConfig.acme.http01Port) || null,
965
+ http: opts.http,
966
+ });
967
+ const stageDir = createSystemdStagingDir();
968
+ const written = writeSystemdFiles(stageDir, built);
969
+ console.log('');
970
+ console.log(` systemd unit staged: ${written.unitPath}`);
971
+ if (written.sysctlPath) console.log(` sysctl drop-in for port ${built.publicPort}: ${written.sysctlPath}`);
972
+ console.log(' (staged outside the site on purpose: the service account will own the site, and must not be able to edit what root installs)');
973
+ for (const w of built.warnings) console.log(` ⚠️ ${w}`);
974
+ console.log('');
975
+ for (const l of systemdInstallSteps({ installDir, user, built, http: opts.http, upgrade: opts.mode === 'upgrade', stageDir })) console.log(` ${l}`);
976
+ }
977
+
978
+ // Printed on Linux after a plain (non --systemd) create, in place of the `setcap` line this installer
979
+ // used to print. Why setcap is not the answer is explained above buildSystemdFiles.
980
+ const LOW_PORT_ADVICE = [
981
+ ' • Serving on 80/443 later? A non-root process cannot bind a port below 1024 by default.',
982
+ ' Put a reverse proxy (nginx, Caddy) on 80/443 in front of this port, or lower',
983
+ ' net.ipv4.ip_unprivileged_port_start. Do not give node a capability (setcap): see',
984
+ ' documentation/deployment.md. A service unit that does this right: re-run with --systemd',
985
+ ' (for this site: npx create-wordjs@latest upgrade <dir> --systemd).',
986
+ ];
987
+
355
988
  // --- upgrade -----------------------------------------------------------------------------------
356
989
 
357
990
  function confirm(question) {
@@ -399,30 +1032,16 @@ function copyMerge(src, dest, rel = '') {
399
1032
  // Download (or use a local/URL) release ZIP and extract it into a fresh temp dir. Returns the
400
1033
  // extracted app root + a cleanup fn. Reuses the same resolution the create flow uses.
401
1034
  async function obtainReleaseToTemp(opts) {
402
- let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wordjs-upgrade-'));
403
- const cleanup = () => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } };
1035
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wordjs-upgrade-'));
1036
+ let zipCleanup = () => {};
1037
+ const cleanup = () => { zipCleanup(); try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } };
404
1038
  try {
405
- let zipPath;
406
- let tag = opts.version || null;
407
- if (opts.zip && !/^https?:\/\//i.test(opts.zip)) {
408
- zipPath = path.resolve(process.cwd(), opts.zip);
409
- if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
410
- } else {
411
- let url = opts.zip;
412
- let name = 'wordjs.zip';
413
- if (!url) {
414
- console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
415
- const asset = await resolveReleaseAsset(opts.version);
416
- url = asset.url; name = asset.name; tag = asset.tag;
417
- console.log(` Found ${asset.tag} → ${asset.name}`);
418
- }
419
- zipPath = path.join(tmpDir, name);
420
- await download(url, zipPath, name);
421
- }
1039
+ const obtained = await obtainBundleZip(opts);
1040
+ zipCleanup = obtained.cleanup;
422
1041
  const extractDir = path.join(tmpDir, 'extracted');
423
1042
  fs.mkdirSync(extractDir, { recursive: true });
424
- extractZip(zipPath, extractDir);
425
- return { extractDir, tag, cleanup };
1043
+ extractZip(obtained.zipPath, extractDir);
1044
+ return { extractDir, tag: obtained.tag, cleanup };
426
1045
  } catch (e) {
427
1046
  cleanup();
428
1047
  throw e;
@@ -463,7 +1082,11 @@ async function upgrade(opts) {
463
1082
  console.log(` Target version: v${newVersion}${tag ? ` (${tag})` : ''}`);
464
1083
 
465
1084
  if (curVersion === newVersion && !opts.force) {
466
- console.log(`\n✅ Already on v${curVersion}. Nothing to upgrade. (use --force to re-apply the same version)\n`);
1085
+ console.log(`\n✅ Already on v${curVersion}. Nothing to upgrade. (use --force to re-apply the same version)`);
1086
+ // --systemd on an up-to-date site is still a request for the unit: it is how an existing
1087
+ // install replaces a hand-written unit (one with AmbientCapabilities=, say) with this one.
1088
+ if (opts.systemd) emitSystemd(installDir, opts);
1089
+ console.log('');
467
1090
  return;
468
1091
  }
469
1092
 
@@ -527,6 +1150,7 @@ async function upgrade(opts) {
527
1150
  console.log(` • otherwise: stop it, then cd ${opts.dir === '.' ? installDir : opts.dir} && npm run start:mono`);
528
1151
  console.log('');
529
1152
  console.log(' Rollback: re-run with --version <old-tag> (your data stays intact).');
1153
+ if (opts.systemd) emitSystemd(installDir, opts);
530
1154
  console.log(line + '\n');
531
1155
  } finally {
532
1156
  cleanup();
@@ -545,27 +1169,11 @@ async function scaffoldBundle(opts, targetDir) {
545
1169
  fs.mkdirSync(targetDir, { recursive: true });
546
1170
  }
547
1171
 
548
- let tmpDir = null, zipPath = null;
549
- if (opts.zip && !/^https?:\/\//i.test(opts.zip)) {
550
- zipPath = path.resolve(process.cwd(), opts.zip);
551
- if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
552
- console.log(` Using local bundle: ${zipPath}`);
553
- } else {
554
- let url = opts.zip, name = 'wordjs.zip';
555
- if (!url) {
556
- console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
557
- const asset = await resolveReleaseAsset(opts.version);
558
- url = asset.url; name = asset.name;
559
- console.log(` Found ${asset.tag} → ${asset.name}`);
560
- }
561
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
562
- zipPath = path.join(tmpDir, name);
563
- await download(url, zipPath, name);
564
- }
1172
+ const { zipPath, cleanup } = await obtainBundleZip(opts);
565
1173
 
566
1174
  console.log(` Extracting into ${targetDir}…`);
567
1175
  try { extractZip(zipPath, targetDir); }
568
- finally { if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } } }
1176
+ finally { cleanup(); }
569
1177
 
570
1178
  let pkg = {};
571
1179
  try { pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8')); } catch { /* handled below */ }
@@ -593,7 +1201,10 @@ async function gateway(opts) {
593
1201
  runNode('scripts/cluster.js', ['init', '--host', host], targetDir);
594
1202
 
595
1203
  // Read the CA fingerprint and mint a token per role (capturing the raw token for the join command).
596
- const fp = (runNodeCapture('scripts/cluster.js', ['info'], targetDir).match(/CA fingerprint:\s*([0-9a-f]{64})/) || [])[1] || '<fingerprint>';
1204
+ // The join commands below are only safe with the real fingerprint in them (join requires --ca-hash),
1205
+ // so a missing one is fatal here rather than a '<fingerprint>' placeholder pasted onto the nodes.
1206
+ const fp = (runNodeCapture('scripts/cluster.js', ['info'], targetDir).match(/CA fingerprint:\s*([0-9a-f]{64})/) || [])[1];
1207
+ if (!fp) fail('Could not read the cluster CA fingerprint from "node scripts/cluster.js info".', `Run it yourself in ${opts.dir} and pass the value to join as --ca-hash.`);
597
1208
  const mint = (role) => (runNodeCapture('scripts/cluster.js', ['token', role, '--ttl', '120'], targetDir)
598
1209
  .match(new RegExp(`wjc\\.${role}\\.[A-Za-z0-9_-]+`)) || [])[0] || '<token>';
599
1210
  const beTok = mint('backend'), feTok = mint('frontend');
@@ -633,7 +1244,13 @@ async function join(opts) {
633
1244
  }
634
1245
  if (!opts.gateway) fail('--gateway <gateway-ip/dns> is required for join.');
635
1246
  if (!opts.token) fail('--token <join-token> is required for join.', `Mint one on the gateway: node scripts/cluster.js token ${opts.role}`);
636
- if (!opts.caHash) console.warn(' ⚠️ No --ca-hash given — skipping the MITM fingerprint check (fine on a trusted network).');
1247
+ // The CA pin is what stops an on-path attacker from receiving the token, the cluster secret and a
1248
+ // CA-signed cert during enrollment, so it is required. Checked HERE, before the download + install.
1249
+ if (!opts.caHash) {
1250
+ fail('--ca-hash <sha256> is required for join.',
1251
+ 'Use the fingerprint the gateway printed (node scripts/cluster.js info on the gateway). Enrolling without it\n would be trust-on-first-use, which is not supported.');
1252
+ }
1253
+ if (!normalizeSha256(opts.caHash)) fail(`--ca-hash must be the 64-character hex CA fingerprint, got "${opts.caHash}".`);
637
1254
 
638
1255
  const targetDir = path.resolve(process.cwd(), opts.dir);
639
1256
  console.log(`\n🚀 create-wordjs · join ${opts.role} (separate mode)\n`);
@@ -642,7 +1259,7 @@ async function join(opts) {
642
1259
  const advertise = opts.advertise || firstLanIp();
643
1260
  const args = ['--role', opts.role, '--gateway', opts.gateway, '--enroll-port', String(opts.enrollPort || 3101),
644
1261
  '--token', opts.token, '--advertise', advertise];
645
- if (opts.caHash) args.push('--ca-hash', opts.caHash);
1262
+ args.push('--ca-hash', opts.caHash);
646
1263
  if (opts.start) args.push('--start');
647
1264
 
648
1265
  console.log(`\n🎟️ Enrolling ${opts.role} with gateway ${opts.gateway} (advertise ${advertise})…\n`);
@@ -661,6 +1278,26 @@ async function join(opts) {
661
1278
 
662
1279
  async function main() {
663
1280
  const opts = parseArgs(process.argv.slice(2));
1281
+ // Checked before anything is downloaded: the unit names this machine's node and site paths, so it
1282
+ // can only be generated on the Linux host that will run it.
1283
+ if (opts.systemd && process.platform !== 'linux') {
1284
+ fail('--systemd writes a Linux systemd unit, and this machine is not running Linux.',
1285
+ 'Run create-wordjs with --systemd on the Linux host that will run the site.');
1286
+ }
1287
+ // ...and so is everything else the unit depends on: the site path, the node path and who is running
1288
+ // this. Before, a path the unit cannot hold was found only after the whole release had been fetched
1289
+ // and installed (create) or applied (upgrade), which then exited 1 with the work done.
1290
+ if (opts.systemd) {
1291
+ let runningAs = null;
1292
+ try { runningAs = os.userInfo().username; } catch { /* unknown: the check below is skipped */ }
1293
+ const problem = systemdPreflight({
1294
+ installDir: path.resolve(process.cwd(), opts.dir),
1295
+ nodePath: process.execPath,
1296
+ user: opts.serviceUser || DEFAULT_SERVICE_USER,
1297
+ runningAs,
1298
+ });
1299
+ if (problem) fail(problem.message, problem.hint);
1300
+ }
664
1301
  if (opts.mode === 'upgrade') return upgrade(opts);
665
1302
  if (opts.mode === 'gateway') return gateway(opts);
666
1303
  if (opts.mode === 'join') return join(opts);
@@ -678,34 +1315,16 @@ async function main() {
678
1315
 
679
1316
  console.log('\n🚀 create-wordjs\n');
680
1317
 
681
- // 1) Obtain the release ZIP (local path, direct URL, or GitHub latest/tagged release).
682
- let tmpDir = null;
683
- let zipPath = null;
684
- if (opts.zip && !/^https?:\/\//i.test(opts.zip)) {
685
- zipPath = path.resolve(process.cwd(), opts.zip);
686
- if (!fs.existsSync(zipPath)) fail(`ZIP not found: ${zipPath}`);
687
- console.log(` Using local bundle: ${zipPath}`);
688
- } else {
689
- let url = opts.zip;
690
- let name = 'wordjs.zip';
691
- if (!url) {
692
- console.log(opts.version ? ` Looking up release ${opts.version} of ${REPO}…` : ` Looking up the latest release of ${REPO}…`);
693
- const asset = await resolveReleaseAsset(opts.version);
694
- url = asset.url;
695
- name = asset.name;
696
- console.log(` Found ${asset.tag} → ${asset.name}`);
697
- }
698
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wordjs-'));
699
- zipPath = path.join(tmpDir, name);
700
- await download(url, zipPath, name);
701
- }
1318
+ // 1) Obtain the release ZIP (local path, https URL, or GitHub latest/tagged release) and verify
1319
+ // its SHA-256 before anything is extracted.
1320
+ const { zipPath, cleanup } = await obtainBundleZip(opts);
702
1321
 
703
1322
  // 2) Extract + sanity-check that this really is a WordJS release bundle.
704
1323
  console.log(` Extracting into ${targetDir}…`);
705
1324
  try {
706
1325
  extractZip(zipPath, targetDir);
707
1326
  } finally {
708
- if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } }
1327
+ cleanup();
709
1328
  }
710
1329
  const pkgPath = path.join(targetDir, 'package.json');
711
1330
  let pkg = {};
@@ -725,6 +1344,17 @@ async function main() {
725
1344
  const proto = opts.http ? 'http' : 'https';
726
1345
  const line = '━'.repeat(64);
727
1346
 
1347
+ // --systemd implies --no-start: started from here, the first boot would run as whoever ran npx and
1348
+ // leave the database, install token and certificates owned by that account instead of the service's.
1349
+ if (opts.systemd) {
1350
+ console.log(`\n${line}`);
1351
+ console.log(`✅ WordJS scaffolded into ${targetDir} (dependencies installed). Not started: the first`);
1352
+ console.log(' boot must run as the service account, so that it owns the files it creates.');
1353
+ emitSystemd(targetDir, opts);
1354
+ console.log(line + '\n');
1355
+ return;
1356
+ }
1357
+
728
1358
  if (!opts.start) {
729
1359
  console.log(`\n${line}`);
730
1360
  console.log(`✅ WordJS scaffolded into ${opts.dir} (dependencies installed).`);
@@ -772,11 +1402,7 @@ async function main() {
772
1402
  console.log(' backend/data/install-token, mode 0600)');
773
1403
  if (process.platform === 'linux') {
774
1404
  console.log('');
775
- console.log(' • RECEIVING email from the internet? WordJS listens on port 25 (the MX port).');
776
- console.log(' Binding a port below 1024 as a non-root user needs a one-time grant — run once:');
777
- console.log(' sudo setcap cap_net_bind_service=+ep "$(readlink -f "$(command -v node)")"');
778
- console.log(' Without it, inbound falls back to port 2525 (sending + local mail still work);');
779
- console.log(' the admin Email → Server Admin screen shows the live listener status either way.');
1405
+ for (const l of LOW_PORT_ADVICE) console.log(l);
780
1406
  }
781
1407
  console.log(line + '\n');
782
1408
 
@@ -792,7 +1418,11 @@ async function main() {
792
1418
 
793
1419
  // Run only when invoked as the CLI, so the pure helpers above can be required and exercised.
794
1420
  if (require.main === module) {
795
- main().catch((e) => fail(e && e.message ? e.message : String(e)));
1421
+ main().catch((e) => fail(e && e.message ? e.message : String(e), e && e.hint));
796
1422
  }
797
1423
 
798
- module.exports = { pickBundleAsset, extractZip };
1424
+ module.exports = {
1425
+ pickBundleAsset, pickChecksumAsset, extractZip, normalizeSha256, parseChecksumFile, classifyZipSource, sha256File,
1426
+ parseArgs, buildSystemdFiles, systemdInstallSteps, writeSystemdFiles,
1427
+ sysctlDropIn, serviceUserProblem, systemdPreflight, createSystemdStagingDir, LOW_PORT_ADVICE, HELP, installedFileMode,
1428
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wordjs",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Create a WordJS site with one command — the self-hosted CMS where third-party plugins run in an OS-isolated process with per-capability permission grants. SSR/SEO out of the box, SQLite by default, no PHP.",
5
5
  "license": "MIT",
6
6
  "author": "Jaime Martinez (https://github.com/jaimemartinez)",