@motujs/host 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scorbutics
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@motujs/host",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Server for the shared motu lagoon host: stores published region previews and serves a live URL per branch.",
5
+ "keywords": [
6
+ "motu",
7
+ "react",
8
+ "islands",
9
+ "ui-architecture",
10
+ "state-management",
11
+ "preview-server",
12
+ "hosting",
13
+ "ui-preview"
14
+ ],
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Scorbutics/motu.git",
19
+ "directory": "packages/host"
20
+ },
21
+ "homepage": "https://github.com/Scorbutics/motu#readme",
22
+ "type": "module",
23
+ "bin": {
24
+ "motu-host": "src/cli.mjs"
25
+ },
26
+ "main": "src/store.mjs",
27
+ "dependencies": {
28
+ "@motujs/chrome": "0.1.0",
29
+ "@motujs/coverage": "0.1.0"
30
+ },
31
+ "files": [
32
+ "src"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "test": "node --test test/*.test.mjs"
39
+ }
6
40
  }
package/src/access.mjs ADDED
@@ -0,0 +1,180 @@
1
+ // WHO MAY READ, AND WHO MAY WRITE WHAT — the host's access policy, in one place.
2
+ //
3
+ // The host began with a single global token that gated every POST and nothing else: reads were
4
+ // entirely public, and one credential could do everything. That is the right shape for a personal
5
+ // preview server and the wrong one for two things it is being asked to do now.
6
+ //
7
+ // A LAGOON MAY NEED TO BE PRIVATE. Some pages are the point of the host — a link you send someone.
8
+ // Others carry a coverage corpus, which is a picture of what a production application does. Both
9
+ // live here, so visibility has to be per repo rather than a property of the whole host.
10
+ //
11
+ // AN INGEST CREDENTIAL LIVES SOMEWHERE ELSE. A coverage proxy forwards a corpus to this host from
12
+ // an application's own server, so its token sits in that application's environment. With one global
13
+ // token, a leak from any adopting app is write access to EVERY repo here — including the ability to
14
+ // overwrite published lagoons. So an ingest token is scoped to one repo and can do exactly one
15
+ // thing.
16
+ //
17
+ // THE DEFAULT IS WHAT THE HOST ALREADY DID. No access file means: every repo public, the global token
18
+ // admits every write. Upgrading changes nothing until somebody writes a policy down.
19
+ //
20
+ // TOKENS ARE STORED HASHED. The file sits in the store directory beside the objects, and a backup of
21
+ // a preview server should not be a credential leak. Comparison is constant-time over the digests, and
22
+ // digests are a fixed 32 bytes — so the compare is total and cannot throw on a length mismatch, which
23
+ // is the failure mode that turns a wrong token into a 500 that distinguishes it from a wrong-length
24
+ // one.
25
+ import { createHash, timingSafeEqual } from 'node:crypto';
26
+ import { existsSync, readFileSync } from 'node:fs';
27
+ import { resolve } from 'node:path';
28
+
29
+ const ACCESS_FILE = 'access.json';
30
+
31
+ /** sha256 of a secret, as bytes. Always 32 of them, whatever went in. */
32
+ export function digest(value) {
33
+ return createHash('sha256').update(String(value ?? ''), 'utf8').digest();
34
+ }
35
+
36
+ /** Constant-time equality over digests — total, so a wrong length is a mismatch and never a throw. */
37
+ export function secretMatches(offered, expectedHashHex) {
38
+ if (!expectedHashHex) return false;
39
+ let expected;
40
+ try {
41
+ expected = Buffer.from(String(expectedHashHex), 'hex');
42
+ } catch {
43
+ return false;
44
+ }
45
+ if (expected.length !== 32) return false;
46
+ return timingSafeEqual(digest(offered), expected);
47
+ }
48
+
49
+ /**
50
+ * Read the policy from the store directory.
51
+ *
52
+ * Re-read per request rather than cached: this file changes when a person edits it, the host is a
53
+ * long-running process nobody wants to restart, and it is a few hundred bytes. A malformed file is
54
+ * treated as ABSENT and reported by the caller — never as "deny everything", because locking an
55
+ * operator out of their own host over a stray comma is a worse failure than staying as open as the
56
+ * host was yesterday.
57
+ */
58
+ export function loadAccess(dir) {
59
+ const file = resolve(dir, ACCESS_FILE);
60
+ if (!existsSync(file)) return { repos: {}, readHash: null, defaultVisibility: 'public', malformed: false };
61
+ try {
62
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
63
+ return {
64
+ repos: raw && typeof raw.repos === 'object' && raw.repos ? raw.repos : {},
65
+ readHash: typeof raw?.readHash === 'string' ? raw.readHash : null,
66
+ // WHICH WAY THE HOST LEANS for a repo nobody has written a policy for. It lives HERE, in the
67
+ // policy file, rather than in a flag or an environment variable, for the reason the `access`
68
+ // subcommand already learned the hard way: a value the service reads from its unit file is a
69
+ // value your shell does not have, so the two disagree silently and the command that reports
70
+ // the policy reports a different one from the host enforcing it. The file is re-read per
71
+ // request, so flipping this takes effect without a restart.
72
+ //
73
+ // Anything but the exact string 'private' is public, so a typo opens the host rather than
74
+ // closing it — the same direction as `malformed`, and for the same reason: locking an operator
75
+ // out of their own host over a stray character is the worse failure.
76
+ defaultVisibility: raw?.defaultVisibility === 'private' ? 'private' : 'public',
77
+ malformed: false,
78
+ };
79
+ } catch {
80
+ return { repos: {}, readHash: null, defaultVisibility: 'public', malformed: true };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Is this repo's content public?
86
+ *
87
+ * A repo with a policy answers for itself. One WITHOUT falls back to the host's default, which is
88
+ * `public` unless somebody wrote `defaultVisibility` down — so the host still behaves exactly as it
89
+ * did before this existed, and a personal host on a public domain can close the door for everything
90
+ * at once instead of remembering to do it per repo.
91
+ *
92
+ * THIS IS WHAT CLOSES THE WINDOW. Visibility is per repo and a repo comes into existence by being
93
+ * published to, so with a public default there is a gap between the first publish landing and
94
+ * somebody marking it private. An agent publishing an ephemeral per-branch lagoon cannot close that
95
+ * gap itself — nothing in the wire protocol sets visibility — so the only place it CAN be closed is
96
+ * before the repo exists.
97
+ */
98
+ export function isPublic(access, repo) {
99
+ const declared = access.repos?.[repo]?.visibility;
100
+ if (declared) return declared !== 'private';
101
+ return access.defaultVisibility !== 'private';
102
+ }
103
+
104
+ /**
105
+ * May this request READ this repo?
106
+ *
107
+ * A public repo: always. A private one: the admin token, or the host's read secret — which arrives as
108
+ * a cookie, because the reader is a browser following a link and cannot set a header.
109
+ */
110
+ export function canRead(access, repo, { adminOk, readSecret }) {
111
+ if (isPublic(access, repo)) return true;
112
+ if (adminOk) return true;
113
+ // TWO SCOPES, and the narrow one exists because of where these end up.
114
+ //
115
+ // The host-wide secret opens EVERY private repo. That is right for a person carrying one cookie,
116
+ // and wrong for a credential that has to live in an application's production environment: an
117
+ // adopting app needs to read back its OWN accepted set, and giving it a key to every private
118
+ // lagoon on the host to do that undoes the reason ingest tokens are scoped at all.
119
+ //
120
+ // Checked in this order so a repo token is enough on its own, and the host-wide one still works
121
+ // everywhere — including on a repo that also has its own.
122
+ if (secretMatches(readSecret, access.repos?.[repo]?.readHash)) return true;
123
+ return secretMatches(readSecret, access.readHash);
124
+ }
125
+
126
+ /**
127
+ * The read secret, from wherever this reader could put it.
128
+ *
129
+ * A BROWSER CANNOT SET A HEADER when it follows a link, so a person reading a private lagoon carries
130
+ * a cookie. A SERVER CANNOT SEND A COOKIE it was never given, so an application reading a corpus back
131
+ * — a status page, a CI job — carries a bearer. Same secret, two transports, because the constraint
132
+ * is the caller's and not the policy's.
133
+ *
134
+ * The ingest token deliberately does NOT open this door: it is write-only so that a credential
135
+ * sitting in somebody else's production environment cannot be used to read what is stored here.
136
+ */
137
+ export function readSecretFrom({ cookieHeader, bearer }) {
138
+ return cookieValue(cookieHeader, READ_COOKIE) ?? (bearer || null);
139
+ }
140
+
141
+ /**
142
+ * May this request INGEST for this repo?
143
+ *
144
+ * Deliberately narrow: an ingest token grants ONE repo and only the corpus route. It cannot publish a
145
+ * lagoon, cannot register a live frame, cannot read anything, and cannot touch another repo — because
146
+ * it lives in the environment of an application this host does not control.
147
+ */
148
+ export function canIngest(access, repo, offered) {
149
+ const hash = access.repos?.[repo]?.ingestHash;
150
+ return secretMatches(offered, hash);
151
+ }
152
+
153
+ /**
154
+ * The ONE repo an ingest token belongs to, or null.
155
+ *
156
+ * An ingest token is already scoped to exactly one repo, so the repo is a fact about the token, not
157
+ * something the application should have to repeat. Asking it to was one of the four values a
158
+ * production deployment had to get right for coverage to land, and the host has never stored a
159
+ * corpus. Fewer values to set is fewer ways to be silently wrong.
160
+ */
161
+ export function ingestRepoOf(access, offered) {
162
+ if (!offered) return null;
163
+ for (const [repo, policy] of Object.entries(access.repos ?? {})) {
164
+ if (secretMatches(offered, policy?.ingestHash)) return repo;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /** The cookie a reader carries once they have opened a private link. */
170
+ export const READ_COOKIE = 'motu_read';
171
+
172
+ /** Pull one cookie out of a request without a parser dependency. */
173
+ export function cookieValue(header, name) {
174
+ for (const part of String(header ?? '').split(';')) {
175
+ const eq = part.indexOf('=');
176
+ if (eq < 0) continue;
177
+ if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
178
+ }
179
+ return null;
180
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ // `motu-host` — run the lagoon host.
3
+ //
4
+ // Its own binary, not `motu lagoon host`, and that is a boundary rather than a convenience: every
5
+ // `motu` subcommand resolves ONE project's motu.config.json at import time, and this process serves
6
+ // many repositories and belongs to none of them.
7
+ //
8
+ // motu-host --token $(openssl rand -hex 24) accept uploads, serve on 127.0.0.1:8818
9
+ // motu-host --host --max-records 1000 bind the LAN too, keep 1000 records per repo
10
+ //
11
+ // Reads are open; uploads need the token. Without one the host still serves what it already holds
12
+ // and refuses every write, which is the right shape for putting a read-only mirror somewhere.
13
+ import { networkInterfaces } from 'node:os';
14
+ import { createLagoonHost } from './server.mjs';
15
+ import { DEFAULT_MAX_RECORDS, DEFAULT_MAX_BYTES } from './store.mjs';
16
+
17
+ const color = {
18
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
19
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
20
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
21
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
22
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
23
+ };
24
+
25
+ function parse(args) {
26
+ const out = { _: [] };
27
+ for (let i = 0; i < args.length; i++) {
28
+ const a = args[i];
29
+ if (a.startsWith('--no-')) out[a.slice(5)] = false;
30
+ else if (a.startsWith('--')) {
31
+ const key = a.slice(2);
32
+ const next = args[i + 1];
33
+ if (next !== undefined && !next.startsWith('--')) {
34
+ out[key] = next;
35
+ i++;
36
+ } else out[key] = true;
37
+ } else out._.push(a);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ const argv = parse(process.argv.slice(2));
43
+
44
+ if (argv.help || argv.h) {
45
+ console.log(`${color.bold('motu-host')} — serve published lagoons
46
+
47
+ --port <n> default 8818
48
+ --host also bind 0.0.0.0 (LAN), not just loopback
49
+ --dir <path> store location (default $MOTU_HOST_DIR or ~/.motu/host)
50
+ --max-records <n> publish records kept per repo (default ${DEFAULT_MAX_RECORDS})
51
+ --max-bytes <n> bytes kept per repo (default ${(DEFAULT_MAX_BYTES / 1073741824).toFixed(0)} GB) — whichever cap binds first wins
52
+ --token <secret> required for uploads (or $MOTU_HOST_TOKEN)
53
+
54
+ ${color.dim('$MOTU_LIVE_ALLOW hosts a live lagoon may be announced from, beyond loopback')}
55
+ ${color.dim(' comma-separated; a leading dot is a suffix. Unset = loopback only.')}
56
+ ${color.dim(' MOTU_LIVE_ALLOW=.my-tailnet.ts.net,192.168.1.20')}
57
+ ${color.dim(' This host FETCHES what it is told, so every name here is one it can be aimed at.')}
58
+ `);
59
+ process.exit(0);
60
+ }
61
+
62
+ const port = Number.parseInt(String(argv.port ?? 8818), 10);
63
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
64
+ console.error(color.red(`✗ --port must be 1-65535, got "${argv.port}"`));
65
+ process.exit(1);
66
+ }
67
+ const maxRecords = Number.parseInt(String(argv['max-records'] ?? process.env.MOTU_HOST_MAX_RECORDS ?? DEFAULT_MAX_RECORDS), 10);
68
+ if (!Number.isInteger(maxRecords) || maxRecords < 1) {
69
+ console.error(color.red(`✗ --max-records must be a positive integer, got "${argv['max-records']}"`));
70
+ process.exit(1);
71
+ }
72
+ const maxBytes = Number.parseInt(String(argv['max-bytes'] ?? process.env.MOTU_HOST_MAX_BYTES ?? DEFAULT_MAX_BYTES), 10);
73
+ if (!Number.isInteger(maxBytes) || maxBytes < 1) {
74
+ console.error(color.red(`✗ --max-bytes must be a positive integer, got "${argv['max-bytes']}"`));
75
+ process.exit(1);
76
+ }
77
+ // `motu-host access` — mint a credential, or make a repo private.
78
+ //
79
+ // THE HASHES ARE NOT HAND-WRITABLE, which is the only reason this subcommand exists. access.json
80
+ // stores sha256 digests so a backup of a preview server is not a credential leak, and asking somebody
81
+ // to produce one with an openssl incantation is asking them to skip the feature.
82
+ //
83
+ // The secret is printed ONCE and never stored — the file keeps only its digest, so there is nothing
84
+ // here to read it back from. That is the point, and it is worth saying out loud at the moment
85
+ // somebody is deciding whether to copy it.
86
+ if (argv._[0] === 'access') {
87
+ const { digest, loadAccess } = await import('./access.mjs');
88
+ const { randomBytes } = await import('node:crypto');
89
+ const { writeFileSync, mkdirSync, existsSync } = await import('node:fs');
90
+ const { homedir } = await import('node:os');
91
+ const { resolve } = await import('node:path');
92
+ const { storeDir } = await import('./store.mjs');
93
+ const dir = storeDir(typeof argv.dir === 'string' ? argv.dir : undefined);
94
+
95
+ // IS THIS THE DIRECTORY THE RUNNING HOST READS? Almost the only way to get this wrong, and it fails
96
+ // silently: the policy is written, the command says ✓, and nothing changes.
97
+ //
98
+ // It happened. The service sets MOTU_HOST_DIR in its unit file, which is not in a shell's
99
+ // environment — so `motu-host access --repo X --private` resolved the DEFAULT `~/.motu/host`,
100
+ // created it, wrote a correct policy into it, and reported success while the live host at
101
+ // ~/.local/share/motu-host went on serving the repo to everyone. Sharing `storeDir()` between the
102
+ // two was not enough, because what differs is the ENVIRONMENT, not the expression.
103
+ //
104
+ // `index.json` is the store's own file, so its absence means this directory is not a store — either
105
+ // the wrong one, or a host that has never run. Refused rather than warned: a warning above a ✓ is
106
+ // read as a ✓, and the whole failure mode here is a command that looks like it worked.
107
+ if (!existsSync(resolve(dir, 'index.json'))) {
108
+ const guesses = [
109
+ process.env.MOTU_HOST_DIR,
110
+ resolve(homedir(), '.local/share/motu-host'),
111
+ resolve(homedir(), '.motu/host'),
112
+ ].filter((d, i, all) => d && all.indexOf(d) === i && existsSync(resolve(d, 'index.json')));
113
+ console.error(color.red(`✗ no lagoon host store in ${dir}`));
114
+ console.error(color.dim(' There is no index.json there, so this is not the directory a host is using — and'));
115
+ console.error(color.dim(' writing a policy into it would report success and change nothing.'));
116
+ if (guesses.length) {
117
+ console.error('');
118
+ console.error(color.dim(' A store does exist here:'));
119
+ for (const g of guesses) console.error(` --dir ${g}`);
120
+ console.error('');
121
+ console.error(color.dim(' The service sets MOTU_HOST_DIR in its unit file, which your shell does not inherit.'));
122
+ } else {
123
+ console.error(color.dim(' Pass --dir <path>, or set MOTU_HOST_DIR to the directory the host runs with.'));
124
+ }
125
+ process.exit(1);
126
+ }
127
+
128
+ const file = resolve(dir, 'access.json');
129
+ const access = loadAccess(dir);
130
+ if (access.malformed) {
131
+ console.error(color.red(`✗ ${file} is not valid JSON — refusing to overwrite it`));
132
+ process.exit(1);
133
+ }
134
+ // CARRY WHAT WE ARE NOT CHANGING. This object REPLACES the file, so a key omitted here is a key
135
+ // deleted — and the one that matters is `defaultVisibility`: minting a read token would have
136
+ // silently reopened every repo on the host.
137
+ const out = { readHash: access.readHash, defaultVisibility: access.defaultVisibility, repos: { ...access.repos } };
138
+ const repo = typeof argv.repo === 'string' ? argv.repo : null;
139
+ const minted = [];
140
+
141
+ if (argv.read) {
142
+ const secret = randomBytes(32).toString('hex');
143
+ if (repo) {
144
+ // `--read --repo X` is the NARROW one: it opens that repo and nothing else. This is what an
145
+ // application holds so it can read back its own accepted set, and what you hand somebody who
146
+ // should see one project rather than all of them.
147
+ out.repos[repo] = { ...(out.repos[repo] ?? {}), readHash: digest(secret).toString('hex') };
148
+ minted.push([`read token for ${repo} (that repo only)`, secret]);
149
+ } else {
150
+ out.readHash = digest(secret).toString('hex');
151
+ minted.push(['read secret (opens EVERY private lagoon — for your browser, not an app)', secret]);
152
+ }
153
+ }
154
+ if (argv.ingest) {
155
+ if (!repo) {
156
+ console.error(color.red('✗ --ingest needs --repo <owner/name>: an ingest token grants exactly one repo'));
157
+ process.exit(1);
158
+ }
159
+ const secret = randomBytes(32).toString('hex');
160
+ out.repos[repo] = { ...(out.repos[repo] ?? {}), ingestHash: digest(secret).toString('hex') };
161
+ minted.push([`ingest token for ${repo}`, secret]);
162
+ }
163
+ if (argv.private || argv.public) {
164
+ if (!repo) {
165
+ console.error(color.red('✗ --private/--public needs --repo <owner/name>'));
166
+ process.exit(1);
167
+ }
168
+ out.repos[repo] = { ...(out.repos[repo] ?? {}), visibility: argv.private ? 'private' : 'public' };
169
+ }
170
+ // WHICH WAY A REPO NOBODY HAS DECIDED ABOUT LEANS. Its own switch rather than a spelling of
171
+ // --private, because it is a different act: --private closes ONE repo that already exists, this
172
+ // decides what happens to every repo that does NOT exist yet — including the ones an agent is
173
+ // about to create by publishing to them.
174
+ let defaultChanged = false;
175
+ if (argv.default !== undefined) {
176
+ const want = String(argv.default);
177
+ if (want !== 'private' && want !== 'public') {
178
+ console.error(color.red(`✗ --default takes private or public, got "${want}"`));
179
+ process.exit(1);
180
+ }
181
+ defaultChanged = out.defaultVisibility !== want;
182
+ out.defaultVisibility = want;
183
+ }
184
+ if (!minted.length && !argv.private && !argv.public && argv.default === undefined) {
185
+ console.log('');
186
+ console.log(' motu-host access --read mint the secret that opens EVERY private lagoon');
187
+ console.log(' motu-host access --repo <r> --read mint one that opens only that repo');
188
+ console.log(' motu-host access --repo <r> --ingest mint a write-only coverage token for one repo');
189
+ console.log(' motu-host access --repo <r> --private stop serving that repo to strangers');
190
+ console.log(' motu-host access --repo <r> --public serve it again');
191
+ console.log(' motu-host access --default private close every repo nobody decided about');
192
+ console.log('');
193
+ const names = Object.keys(access.repos);
194
+ console.log(color.dim(` ${file}`));
195
+ console.log(color.dim(` ${names.length} repo(s) with a policy${names.length ? `: ${names.join(', ')}` : ''}`));
196
+ // SAID OUT LOUD, because "no policy" reads as public and is the one thing here nobody can infer
197
+ // from the repo list — a host leaning private lists nothing and serves nothing to strangers.
198
+ console.log(
199
+ access.defaultVisibility === 'private'
200
+ ? color.dim(' default for a repo with no policy: PRIVATE — including ones not published yet')
201
+ : color.dim(' default for a repo with no policy: public (motu-host access --default private)'),
202
+ );
203
+ console.log(color.dim(` read secret ${access.readHash ? 'set' : 'NOT set — every private lagoon is closed to everyone but the admin token'}`));
204
+ process.exit(0);
205
+ }
206
+
207
+ mkdirSync(dir, { recursive: true });
208
+ writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
209
+ console.log('');
210
+ for (const [what, secret] of minted) {
211
+ console.log(` ${color.green('✓')} ${what}`);
212
+ console.log(` ${secret}`);
213
+ }
214
+ if (argv.private) console.log(` ${color.green('✓')} ${repo} is private — strangers get 404, not 403`);
215
+ if (argv.public) console.log(` ${color.green('✓')} ${repo} is public`);
216
+ if (argv.default !== undefined) {
217
+ const now = out.defaultVisibility;
218
+ console.log(
219
+ ` ${color.green('✓')} repos with no policy of their own are ${now.toUpperCase()}${defaultChanged ? '' : ' (unchanged)'}`,
220
+ );
221
+ if (now === 'private')
222
+ console.log(color.dim(' a publish now creates a CLOSED repo — mint a reader with --read, then visit /?k=<secret> once'));
223
+ }
224
+ if (minted.length) {
225
+ console.log('');
226
+ console.log(color.dim(' Shown once. Only the digest is stored, so this cannot be read back — mint a new one if it is lost.'));
227
+ if (argv.read) console.log(color.dim(' A reader opens a private link by visiting it with ?k=<secret> once.'));
228
+ }
229
+ console.log(color.dim(` ${file}`));
230
+ console.log('');
231
+ process.exit(0);
232
+ }
233
+
234
+ const token = typeof argv.token === 'string' ? argv.token : process.env.MOTU_HOST_TOKEN || null;
235
+ const lan = argv.host === true || argv.host === '0.0.0.0';
236
+ const bind = lan ? '0.0.0.0' : '127.0.0.1';
237
+
238
+ const { server, store } = createLagoonHost({ dir: typeof argv.dir === 'string' ? argv.dir : undefined, maxRecords, maxBytes, token });
239
+
240
+ server.on('error', (err) => {
241
+ if (err.code === 'EADDRINUSE') console.error(color.red(`✗ port ${port} is already in use — pass --port <n>`));
242
+ else if (err.code === 'EACCES') console.error(color.red(`✗ not allowed to bind port ${port} — pick one above 1024`));
243
+ else console.error(color.red(`✗ ${err.message}`));
244
+ process.exit(1);
245
+ });
246
+
247
+ function lanAddress() {
248
+ for (const [name, addrs] of Object.entries(networkInterfaces())) {
249
+ if (/^(docker|br-|veth|virbr|tun|tap)/.test(name)) continue;
250
+ for (const a of addrs ?? []) if (a.family === 'IPv4' && !a.internal) return a.address;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ server.listen(port, bind, () => {
256
+ const s = store.stats();
257
+ console.log('');
258
+ console.log(`${color.green('✓')} ${color.bold('motu lagoon host')} — ${s.repos} repo(s), ${s.blobs} object(s), ${(s.bytes / 1048576).toFixed(1)} MB`);
259
+ console.log(` http://localhost:${port}`);
260
+ const ip = lan ? lanAddress() : null;
261
+ if (ip) console.log(` http://${ip}:${port} ${color.dim('(LAN)')}`);
262
+ console.log(color.dim(` store ${s.root} · cap ${maxRecords} records or ${(maxBytes / 1073741824).toFixed(0)} GB per repo`));
263
+ if (!token) console.log(color.yellow(' no token — uploads are refused. Pass --token or set MOTU_HOST_TOKEN.'));
264
+ // SAID EVERY START, because this one is easy to leave on. It only ever answers an unproxied
265
+ // loopback request, so it cannot be reached through a tunnel — but an operator who forgot it is set
266
+ // should be reminded by the thing they look at when they start the host.
267
+ if (process.env.MOTU_HOST_OPEN_LOCAL === '1')
268
+ console.log(
269
+ color.yellow(' MOTU_HOST_OPEN_LOCAL=1 — private repos are READABLE by unproxied requests from this machine.'),
270
+ );
271
+ console.log('');
272
+ console.log(color.dim(' Publish into it: motu lagoon publish --remote http://localhost:' + port));
273
+ console.log(color.dim(' Ctrl-C to stop.'));
274
+ });
@@ -0,0 +1,65 @@
1
+ // Putting the document skeleton back on a published fragment.
2
+ //
3
+ // `motu lagoon publish` deliberately strips <!doctype>/<html>/<head>/<body>, because the Artifact
4
+ // host supplies its own. A browser will limp along without them, but it gets no charset and no
5
+ // viewport meta — so the page renders desktop-width on exactly the device this whole feature exists
6
+ // to serve. The CLI's `motu lagoon serve` already re-wraps for the same reason; this is that rule,
7
+ // applied on the hosted side, to the same bytes.
8
+ //
9
+ // Tolerant on purpose: if a fragment ever arrives already wrapped (an older publish, someone's
10
+ // curl), serve it as-is rather than nesting a second <html> inside the first.
11
+
12
+ const HAS_SKELETON = /<html[\s>]/i;
13
+
14
+ export function wrapFragment(fragment, { title } = {}) {
15
+ const body = typeof fragment === 'string' ? fragment : fragment.toString('utf8');
16
+ if (HAS_SKELETON.test(body)) return body;
17
+ const head = title ? `<title>${escapeHtml(title)}</title>\n` : '';
18
+ return `<!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="utf-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
23
+ ${head}</head>
24
+ <body>
25
+ ${body}</body>
26
+ </html>
27
+ `;
28
+ }
29
+
30
+ export function escapeHtml(s) {
31
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
32
+ }
33
+
34
+ /**
35
+ * Tell a served page which repo it belongs to, so it can ask this host about itself.
36
+ *
37
+ * A published lagoon is a self-contained document, so `wrapFragment` returns it untouched — there is
38
+ * no skeleton to add a tag to. The host inserts one here instead, at the moment it serves the bytes,
39
+ * because it is the only party that knows the answer: the page was built before anyone chose where to
40
+ * publish it, and inside a gallery frame the URL says nothing about the repo either.
41
+ *
42
+ * A REPO NAME, AND NOTHING ELSE. Not a URL and not a credential — the page asks its OWN origin, and
43
+ * whoever is reading it is already authorised (or not) by the same cookie that let them open the
44
+ * page. That is what keeps a published lagoon free of the two things it must never carry.
45
+ *
46
+ * WRAP FIRST, THEN STAMP — the order matters and it is not obvious. A published lagoon is a whole
47
+ * document nested inside the skeleton `wrapFragment` adds, so stamping the fragment puts the tag in
48
+ * the OUTER document's body. It is in the bytes, curl finds it, and the page's own React render
49
+ * replaces the body and takes it with it: `document.querySelector('meta[name=motu-repo]')` returns
50
+ * null in the one place that needed it. Stamping the wrapped document puts it in the outer <head>,
51
+ * which nothing on the page touches.
52
+ *
53
+ * Fails open: a document with no <head> to insert into is returned exactly as it arrived. A page that
54
+ * cannot look up its coverage is a page missing one panel, and mangling somebody's HTML to avoid that
55
+ * is a bad trade.
56
+ */
57
+ export function withRepoMeta(bytes, repo) {
58
+ if (!repo) return bytes;
59
+ const html = typeof bytes === 'string' ? bytes : bytes.toString('utf8');
60
+ const at = html.search(/<head[^>]*>/i);
61
+ if (at < 0) return bytes;
62
+ const insert = html.indexOf('>', at) + 1;
63
+ const tag = `\n<meta name="motu-repo" content="${escapeHtml(repo)}" />`;
64
+ return Buffer.from(html.slice(0, insert) + tag + html.slice(insert), 'utf8');
65
+ }
@@ -0,0 +1,68 @@
1
+ // A CHUNKED lagoon, as the host stores and serves it.
2
+ //
3
+ // `motu lagoon publish --remote` (and a pushed draft) sends a MANIFEST rather than one self-contained
4
+ // page: the page's HTML, and each file it loads named by the sha256 of its content. The chunks
5
+ // themselves arrive first, as blobs, so an edit in one region costs that region's chunk.
6
+ //
7
+ // { "motu": "lagoon-manifest/1", "html": "<fragment>", "files": { "region-members.js": "<sha256>" } }
8
+ //
9
+ // The page's references read `__MOTU_ASSETS__/<file>`; serving fills that with
10
+ // `/__motu_assets/<manifest hash>`, and that route answers each file from the manifest. The address is
11
+ // keyed by the manifest's content, so it can be cached forever, and a `latest` page that moves on
12
+ // never mixes chunks from two builds. The CLI side is `packages/cli/src/lib/lagoon-bundle.mjs`; the
13
+ // format strings below must match it.
14
+
15
+ export const MANIFEST_FORMAT = 'lagoon-manifest/1';
16
+ export const MANIFEST_TYPE = 'application/vnd.motu.lagoon+json';
17
+ export const ASSETS_PLACEHOLDER = '__MOTU_ASSETS__';
18
+
19
+ const HASH = /^[0-9a-f]{64}$/;
20
+ const NAME = /^[A-Za-z0-9._@+-][A-Za-z0-9._@+/-]*$/;
21
+
22
+ /** The manifest in these bytes, or null when they are a single-file page (or not a manifest). */
23
+ export function parseManifest(bytes) {
24
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes), 'utf8');
25
+ // Cheap test first: a page is HTML and never starts with `{`.
26
+ let i = 0;
27
+ while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x0a || buf[i] === 0x0d || buf[i] === 0x09)) i++;
28
+ if (buf[i] !== 0x7b) return null;
29
+ let m;
30
+ try {
31
+ m = JSON.parse(buf.toString('utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ if (!m || m.motu !== MANIFEST_FORMAT || typeof m.html !== 'string' || !m.files || typeof m.files !== 'object') return null;
36
+ for (const [name, h] of Object.entries(m.files)) {
37
+ if (!NAME.test(name) || name.includes('..') || typeof h !== 'string' || !HASH.test(h)) return null;
38
+ }
39
+ return { html: m.html, files: m.files };
40
+ }
41
+
42
+ /** The page of a manifest, with its chunks' address filled in. */
43
+ export function manifestPage(manifest, manifestHash) {
44
+ return manifest.html.split(ASSETS_PLACEHOLDER).join(`/__motu_assets/${manifestHash}`);
45
+ }
46
+
47
+ const TYPES = {
48
+ js: 'text/javascript; charset=utf-8',
49
+ mjs: 'text/javascript; charset=utf-8',
50
+ css: 'text/css; charset=utf-8',
51
+ json: 'application/json',
52
+ svg: 'image/svg+xml',
53
+ png: 'image/png',
54
+ jpg: 'image/jpeg',
55
+ jpeg: 'image/jpeg',
56
+ gif: 'image/gif',
57
+ webp: 'image/webp',
58
+ avif: 'image/avif',
59
+ ico: 'image/x-icon',
60
+ woff: 'font/woff',
61
+ woff2: 'font/woff2',
62
+ ttf: 'font/ttf',
63
+ otf: 'font/otf',
64
+ wasm: 'application/wasm',
65
+ mp4: 'video/mp4',
66
+ webm: 'video/webm',
67
+ };
68
+ export const contentTypeOf = (name) => TYPES[String(name).split('.').pop().toLowerCase()] ?? 'application/octet-stream';