locadot 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.
- package/CHANGELOG.md +15 -0
- package/README.md +17 -1
- package/dist/cli/commands/hosts.js +1 -1
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/protect.js +76 -0
- package/dist/cli/commands/tunnel.js +12 -1
- package/dist/cli/commands/ui.js +6 -3
- package/dist/constants/index.js +1 -0
- package/dist/dashboard/api.js +11 -1
- package/dist/dashboard/index.js +2 -0
- package/dist/dashboard/page.js +445 -6
- package/dist/index.js +11 -0
- package/dist/lib/host-auth.js +123 -0
- package/dist/lib/hosts.js +53 -0
- package/dist/proxy/cloudflare-logins.js +197 -0
- package/dist/proxy/protect.js +215 -0
- package/dist/proxy/router.js +5 -0
- package/dist/proxy/tunnel.js +16 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.4.0 (2026-09-29)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Password protection per mapping: `locadot protect --host app.localhost --shared --remote --local` (or the lock on a
|
|
7
|
+
row in the dashboard) asks for a password on the shared link, for connected machines and/or locally, whichever you
|
|
8
|
+
pick; none by default. Visitors sign in on a locadot page and get a cookie for that host that isn't sent upstream.
|
|
9
|
+
`PUT /api/hosts/:host {"protect": {"password": "…", "scopes": [...]}}`, `{"protect": null}` to remove.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Sharing on a custom domain used whatever Cloudflare login was on the machine, so a hostname outside that login's zone
|
|
13
|
+
showed as live while cloudflared had created `<hostname>.<other zone>` instead. locadot now keeps one login per zone,
|
|
14
|
+
signs you in for the hostname's zone when it has none (the dashboard's Share dialog opens Cloudflare in a new tab and
|
|
15
|
+
follows the sign-in until the share is live), and fails with a clear error when the authorized zone doesn't include it.
|
|
16
|
+
- `locadot tunnel --host …` retries a share that failed; it used to report the old error again.
|
|
17
|
+
|
|
3
18
|
## 2.3.0 (2026-09-29)
|
|
4
19
|
|
|
5
20
|
### Added
|
package/README.md
CHANGED
|
@@ -65,6 +65,7 @@ npx locadot open # opens the dashboard at htt
|
|
|
65
65
|
| `locadot add --host <name>.localhost --target <url>` | Map a domain to any upstream: `3000`, `127.0.0.1:8080`, `http://10.0.0.5:8080/app`, `https://google.com`. |
|
|
66
66
|
| `locadot update --host <name>.localhost --port/--target …` | Change the destination of an existing domain. |
|
|
67
67
|
| `locadot remove --host <name>.localhost` (`rm`) | Remove a domain. |
|
|
68
|
+
| `locadot protect --host <name>.localhost [--shared] [--remote] [--local] [--stdin] [--off]` | Ask for a password on the shared link, for connected machines and/or locally (none by default). Prompts for the password, or reads it with `--stdin`. |
|
|
68
69
|
| `locadot allow --host <name>.localhost [addresses…] [--rm] [--clear]` | Let people on a tunnel or remote access reach internal addresses (`localhost:3000`) through a `--cors` domain. With no addresses, shows the list. Also `add … --allow a,b`. |
|
|
69
70
|
| `locadot list` (`ls`, `host`) `[--json]` | Show all mappings. |
|
|
70
71
|
| `locadot clear:hosts` | Remove all mappings. |
|
|
@@ -96,7 +97,7 @@ Options for `add` / `update`:
|
|
|
96
97
|
| Command | What it does |
|
|
97
98
|
| --- | --- |
|
|
98
99
|
| `locadot tunnel --host app.localhost` | Share a mapping on a public `https://<random>.trycloudflare.com` URL through a Cloudflare quick tunnel. No Cloudflare account is needed. `cloudflared` is downloaded on first use. |
|
|
99
|
-
| `locadot tunnel --host app.localhost --domain app.example.com` | Share it on your own hostname instead, on a domain in your Cloudflare account.
|
|
100
|
+
| `locadot tunnel --host app.localhost --domain app.example.com` | Share it on your own hostname instead, on a domain in your Cloudflare account. If locadot has no Cloudflare login for the hostname's zone, it asks you to sign in and authorize that zone (the link is printed; the dashboard opens it in a new tab). Each zone you authorize is kept in the state dir, so domains in different zones or accounts each get their own login; `~/.cloudflared/cert.pem` is used but never replaced. If the zone you authorize doesn't include the hostname, sharing fails with an error rather than going live on the wrong name. locadot creates a named tunnel for the mapping (`locadot-app-localhost`), points the hostname's DNS at it (replacing an existing record) and runs it. The URL stays the same across restarts. |
|
|
100
101
|
| `locadot tunnel --host app.localhost --off` | Stop sharing it. The custom hostname is remembered for the dashboard; sharing again without `--domain` uses a random URL. |
|
|
101
102
|
| `locadot tunnel` | List the shared mappings and their public URLs. |
|
|
102
103
|
| `locadot tunnel:install` | Download `cloudflared` into the state dir (`<state>/bin`). Set `LOCADOT_CLOUDFLARED` to use your own binary; one on `PATH` is also picked up. |
|
|
@@ -120,6 +121,20 @@ In the dashboard, click **Access** on the row. An entry is `host:port`, or a hos
|
|
|
120
121
|
and `0.0.0.0` count as `localhost`. Only the page's own requests can use it, and locadot's own ports stay blocked even
|
|
121
122
|
when all of `localhost` is allowed. Allow only what the app needs: anyone who can open the mapping can reach these addresses.
|
|
122
123
|
|
|
124
|
+
**Password protection.** Ask for a password before a mapping opens, only where you choose: on the shared link, for
|
|
125
|
+
machines connected through remote access, and/or locally (this machine and your network). Nothing is picked by default.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
locadot protect --host app.localhost --shared # prompts for the password; --stdin reads it from a pipe
|
|
129
|
+
locadot protect --host app.localhost --shared --local # change where it asks, keeping the password
|
|
130
|
+
locadot protect --host app.localhost # set a new password (signs everyone out)
|
|
131
|
+
locadot protect --host app.localhost --off
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
In the dashboard, click the lock on the row. Visitors get a sign-in page and stay signed in for 7 days through a cookie
|
|
135
|
+
for that host, which is never passed on to your app. Ten wrong passwords in five minutes lock that visitor out for a
|
|
136
|
+
while. Passwords are stored as scrypt hashes in `.locadot-host-passwords.json` (0600), not in the registry.
|
|
137
|
+
|
|
123
138
|
### Remote access (locadot to locadot)
|
|
124
139
|
|
|
125
140
|
One machine (the sender) publishes its whole locadot on a Cloudflare hostname. Other machines (receivers) connect with a
|
|
@@ -262,6 +277,7 @@ each time the proxy starts.
|
|
|
262
277
|
| `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false, "cors": false }` | Add a mapping (201; 409 if it exists). |
|
|
263
278
|
| `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false, "cors": true }` | Change a mapping (404 if unknown). |
|
|
264
279
|
| `PUT /api/hosts/:host` | `{ "allow": ["localhost:3000"] }` | Replace the internal addresses shared visitors may reach (normalized; `[]` clears; 400 if one is invalid). |
|
|
280
|
+
| `PUT /api/hosts/:host` | `{ "protect": { "password": "…", "scopes": ["shared", "remote", "local"] } }` | Ask for a password on those paths in (at least one; `password` may be left out to keep the current one). `{ "protect": null }` removes it. Rows show `protect: { scopes, updatedAt }`, never the password. |
|
|
265
281
|
| `PUT /api/hosts/:host` | `{ "tunnel": true, "domain": "app.example.com" }` | Share on a public URL: with `domain`, on that hostname through a named Cloudflare tunnel, without it on a random trycloudflare.com URL. `{ "tunnel": false }` stops sharing (400 for a bad hostname, 409 if another mapping uses it). |
|
|
266
282
|
| `DELETE /api/hosts/:host` | | Remove a mapping. |
|
|
267
283
|
| `POST /api/startup` | `{ "enabled": true }` | Start at boot on/off. |
|
|
@@ -126,7 +126,7 @@ async function list(options) {
|
|
|
126
126
|
}
|
|
127
127
|
const width = Math.max(...hosts.map(([host]) => (0, urls_1.urlFor)(host).length));
|
|
128
128
|
for (const [host, entry] of hosts) {
|
|
129
|
-
(0, shared_1.print)(`${(0, urls_1.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}${entry.allow?.length ? ` (allow ${entry.allow.join(", ")})` : ""}`);
|
|
129
|
+
(0, shared_1.print)(`${(0, urls_1.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}${entry.allow?.length ? ` (allow ${entry.allow.join(", ")})` : ""}${entry.protect ? ` (password: ${entry.protect.scopes.join(", ")})` : ""}`);
|
|
130
130
|
}
|
|
131
131
|
(0, shared_1.print)(`☑️ Total: ${hosts.length}.`);
|
|
132
132
|
}
|
|
@@ -40,6 +40,7 @@ const system = __importStar(require("./system"));
|
|
|
40
40
|
const tunnel = __importStar(require("./tunnel"));
|
|
41
41
|
const remote = __importStar(require("./remote"));
|
|
42
42
|
const ui = __importStar(require("./ui"));
|
|
43
|
+
const protect_1 = require("./protect");
|
|
43
44
|
const doctor_1 = require("./doctor");
|
|
44
|
-
const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, ...ui, doctor: doctor_1.doctor };
|
|
45
|
+
const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, ...ui, protect: protect_1.protect, doctor: doctor_1.doctor };
|
|
45
46
|
exports.default = Commands;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.protect = protect;
|
|
40
|
+
const localhost_1 = __importStar(require("../../lib/localhost"));
|
|
41
|
+
const hosts_1 = __importDefault(require("../../lib/hosts"));
|
|
42
|
+
const host_auth_1 = __importStar(require("../../lib/host-auth"));
|
|
43
|
+
const registry_1 = __importDefault(require("../../lib/registry"));
|
|
44
|
+
const urls_1 = require("../../lib/urls");
|
|
45
|
+
const shared_1 = require("../shared");
|
|
46
|
+
const ui_1 = require("./ui");
|
|
47
|
+
const PLACES = { shared: "the shared link", remote: "connected machines", local: "this machine" };
|
|
48
|
+
/** Asks for a password on a mapping, on the paths picked with --shared/--remote/--local; --off removes it. */
|
|
49
|
+
async function protect(options) {
|
|
50
|
+
const host = localhost_1.default.requireHost(options.host);
|
|
51
|
+
if (options.off) {
|
|
52
|
+
await hosts_1.default.setProtect({ host, off: true });
|
|
53
|
+
(0, shared_1.print)(`🔓 ${(0, urls_1.urlFor)(host)} no longer asks for a password.`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const entry = registry_1.default.read().hosts[host];
|
|
57
|
+
if (!entry)
|
|
58
|
+
throw new localhost_1.InputError(`❌ ${host} isn't mapped. Add it first: locadot add --host ${host} --port <port>`);
|
|
59
|
+
const picked = host_auth_1.SCOPES.filter((scope) => options[scope]);
|
|
60
|
+
const scopes = picked.length ? picked : entry.protect?.scopes ?? [];
|
|
61
|
+
if (!scopes.length)
|
|
62
|
+
throw new localhost_1.InputError(`❌ Pick where to ask for the password: --shared, --remote and/or --local.`);
|
|
63
|
+
let password = options.password;
|
|
64
|
+
// Picking places for an already protected mapping keeps its password; otherwise ask for one.
|
|
65
|
+
if (password === undefined && (options.stdin || !host_auth_1.default.has(host) || !picked.length)) {
|
|
66
|
+
if (options.stdin || !process.stdin.isTTY)
|
|
67
|
+
password = await (0, ui_1.readStdin)();
|
|
68
|
+
else {
|
|
69
|
+
password = await (0, ui_1.askHidden)(`Password for ${host}: `);
|
|
70
|
+
if ((await (0, ui_1.askHidden)("Repeat it: ")) !== password)
|
|
71
|
+
throw new localhost_1.InputError("❌ The passwords don't match.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const { entry: saved } = await hosts_1.default.setProtect({ host, password, scopes });
|
|
75
|
+
(0, shared_1.print)(`🔒 ${(0, urls_1.urlFor)(host)} asks for a password on ${saved.protect.scopes.map((scope) => PLACES[scope]).join(", ")}.`);
|
|
76
|
+
}
|
|
@@ -91,6 +91,17 @@ async function tunnel(options) {
|
|
|
91
91
|
}
|
|
92
92
|
if (!(0, tunnel_1.cloudflaredInfo)(true).installed)
|
|
93
93
|
await installTunnel();
|
|
94
|
+
// The proxy leaves a failed tunnel alone when the registry doesn't change, so clear it first to retry.
|
|
95
|
+
const current = await tunnelRows().then((rows) => rows.find((row) => row.host === host)?.tunnel, () => undefined);
|
|
96
|
+
if (current?.status === "error") {
|
|
97
|
+
await hosts_1.default.setTunnel({ host, tunnel: false });
|
|
98
|
+
for (let i = 0; i < 20; i++) {
|
|
99
|
+
const state = await tunnelRows().then((rows) => rows.find((row) => row.host === host)?.tunnel, () => undefined);
|
|
100
|
+
if (!state || state.status === "off")
|
|
101
|
+
break;
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
94
105
|
await hosts_1.default.setTunnel({ host, tunnel: true, domain: options.domain });
|
|
95
106
|
await (0, shared_1.ensureRunning)();
|
|
96
107
|
let deadline = Date.now() + TUNNEL_TIMEOUT_MS;
|
|
@@ -100,7 +111,7 @@ async function tunnel(options) {
|
|
|
100
111
|
if (state?.status === "login" && state.loginUrl && !loginShown) {
|
|
101
112
|
loginShown = true;
|
|
102
113
|
deadline = Date.now() + 10 * 60000;
|
|
103
|
-
(0, shared_1.print)(`🔑 Log in to Cloudflare to finish: ${state.loginUrl}`);
|
|
114
|
+
(0, shared_1.print)(`🔑 Log in to Cloudflare to finish${state.domain ? ` and pick the zone ${state.domain} is in` : ""}: ${state.loginUrl}`);
|
|
104
115
|
}
|
|
105
116
|
if (state?.status === "up") {
|
|
106
117
|
(0, shared_1.print)(`🌍 ${host} is public at ${state.url}`);
|
package/dist/cli/commands/ui.js
CHANGED
|
@@ -36,6 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.askHidden = exports.readStdin = void 0;
|
|
39
40
|
exports.uiPassword = uiPassword;
|
|
40
41
|
const readline_1 = __importDefault(require("readline"));
|
|
41
42
|
const localhost_1 = require("../../lib/localhost");
|
|
@@ -48,6 +49,7 @@ const readStdin = () => new Promise((resolve, reject) => {
|
|
|
48
49
|
process.stdin.on("end", () => resolve(data.split(/\r?\n/)[0]));
|
|
49
50
|
process.stdin.on("error", reject);
|
|
50
51
|
});
|
|
52
|
+
exports.readStdin = readStdin;
|
|
51
53
|
/** Reads a line without echoing it. */
|
|
52
54
|
const askHidden = (question) => new Promise((resolve) => {
|
|
53
55
|
const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
@@ -66,6 +68,7 @@ const askHidden = (question) => new Promise((resolve) => {
|
|
|
66
68
|
resolve(answer);
|
|
67
69
|
});
|
|
68
70
|
});
|
|
71
|
+
exports.askHidden = askHidden;
|
|
69
72
|
/** Sets, resets or removes the dashboard password. Works whether or not the proxy is running. */
|
|
70
73
|
async function uiPassword(options) {
|
|
71
74
|
if (options.off) {
|
|
@@ -76,11 +79,11 @@ async function uiPassword(options) {
|
|
|
76
79
|
}
|
|
77
80
|
let password;
|
|
78
81
|
if (options.stdin || !process.stdin.isTTY) {
|
|
79
|
-
password = await readStdin();
|
|
82
|
+
password = await (0, exports.readStdin)();
|
|
80
83
|
}
|
|
81
84
|
else {
|
|
82
|
-
password = await askHidden("New dashboard password: ");
|
|
83
|
-
const again = await askHidden("Repeat it: ");
|
|
85
|
+
password = await (0, exports.askHidden)("New dashboard password: ");
|
|
86
|
+
const again = await (0, exports.askHidden)("Repeat it: ");
|
|
84
87
|
if (password !== again)
|
|
85
88
|
throw new localhost_1.InputError("❌ The passwords don't match.");
|
|
86
89
|
}
|
package/dist/constants/index.js
CHANGED
|
@@ -55,6 +55,7 @@ Constants.paths = {
|
|
|
55
55
|
CONFIG_FILE,
|
|
56
56
|
// Optional dashboard password: scrypt hash + session signing secret (0600).
|
|
57
57
|
UI_AUTH_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-ui-auth.json"),
|
|
58
|
+
HOST_PASSWORDS_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-host-passwords.json"),
|
|
58
59
|
// Remote access. Sender: public hostname config, invites + peers (0600). Receiver: remotes (0600).
|
|
59
60
|
HUB_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-hub.json"),
|
|
60
61
|
LINKS_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-links.json"),
|
package/dist/dashboard/api.js
CHANGED
|
@@ -419,7 +419,17 @@ async function route(req, url, ctx) {
|
|
|
419
419
|
updated = await hosts_1.default.setAllow({ host, allow: body.allow });
|
|
420
420
|
logger_1.default.info(`✏️ dashboard: ${updated.host} allows ${updated.entry.allow?.join(", ") || "no internal addresses"}`);
|
|
421
421
|
}
|
|
422
|
-
if (body.
|
|
422
|
+
if (body.protect !== undefined) {
|
|
423
|
+
const protect = body.protect;
|
|
424
|
+
if (protect !== null && (typeof protect !== "object" || Array.isArray(protect)))
|
|
425
|
+
throw new ApiError(400, "protect must be an object or null.");
|
|
426
|
+
updated = protect === null
|
|
427
|
+
? await hosts_1.default.setProtect({ host, off: true })
|
|
428
|
+
: await hosts_1.default.setProtect({ host, password: protect.password, scopes: protect.scopes });
|
|
429
|
+
const scopes = updated.entry.protect?.scopes;
|
|
430
|
+
logger_1.default.info(`🔒 dashboard: ${scopes ? `${updated.host} asks for a password on ${scopes.join(", ")}${protect?.password ? " (new password)" : ""}` : `${updated.host} is no longer password protected`}`);
|
|
431
|
+
}
|
|
432
|
+
if (body.target !== undefined || (tunnel === undefined && body.allow === undefined && body.protect === undefined)) {
|
|
423
433
|
updated = await hosts_1.default.update({
|
|
424
434
|
host,
|
|
425
435
|
target: body.target,
|
package/dist/dashboard/index.js
CHANGED
|
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.handleDashboardRequest = handleDashboardRequest;
|
|
40
40
|
const crypto_1 = __importDefault(require("crypto"));
|
|
41
|
+
const host_auth_1 = __importDefault(require("../lib/host-auth"));
|
|
41
42
|
const page_1 = require("./page");
|
|
42
43
|
const api_1 = require("./api");
|
|
43
44
|
const request_1 = require("../proxy/request");
|
|
@@ -93,6 +94,7 @@ async function buildHosts(ctx) {
|
|
|
93
94
|
cors: !!entry.cors,
|
|
94
95
|
allow: entry.allow ?? [],
|
|
95
96
|
tunnelDomain: entry.tunnelDomain ?? null,
|
|
97
|
+
protect: entry.protect ? { scopes: entry.protect.scopes, updatedAt: host_auth_1.default.updatedAt(host) ?? null } : null,
|
|
96
98
|
createdAt: entry.createdAt,
|
|
97
99
|
updatedAt: entry.updatedAt,
|
|
98
100
|
urls: hostUrls(host, httpPort, httpsPort),
|