fixed issues w/ accessing website on other computers
This commit is contained in:
+27
-24
@@ -25,6 +25,28 @@ URL; browsers will block the JSON module requests. To use another port:
|
||||
PORT=8080 node dashboard/server.mjs
|
||||
```
|
||||
|
||||
## Run a live demo on the same network
|
||||
|
||||
From the source-repository root, run:
|
||||
|
||||
```bash
|
||||
node dashboard/server.mjs --lan
|
||||
```
|
||||
|
||||
Stop any older dashboard process with `Ctrl-C` before starting. Or, from this
|
||||
directory, run `npm run start:lan`. The server prints a **This computer** URL
|
||||
and one or more interface-labeled **Other devices** URLs. Open the URL for the
|
||||
shared Wi-Fi or Ethernet interface on the second computer; `0.0.0.0` is a bind
|
||||
address, not the address to put in a browser. Confirm the in-app status reads
|
||||
**Validated sample aggregates**, then stop the server with `Ctrl-C` after the
|
||||
demo.
|
||||
|
||||
All data-contract and checksum validation remains enabled over LAN HTTP. When a
|
||||
remote browser does not expose the SubtleCrypto digest API to the plain-HTTP
|
||||
page, the dashboard uses its dependency-free SHA-256 implementation and still
|
||||
rejects changed assets. The checks validate bundle consistency, not the
|
||||
authenticity of a plain-HTTP connection, so use a trusted demo network.
|
||||
|
||||
Run the dependency-free contract checks with:
|
||||
|
||||
```bash
|
||||
@@ -68,28 +90,9 @@ operational connection, or row-level prediction output. VINs, plates, ZIPs,
|
||||
stations, technician identifiers, raw source JSON, credentials, and operational
|
||||
records must never enter this directory.
|
||||
|
||||
## Publish through Bolt
|
||||
## Public hosting is not required
|
||||
|
||||
Use a separate static Bolt project backed by a dashboard-only repository. Do
|
||||
not import this source repository into Bolt.
|
||||
|
||||
1. Run `npm test` from this directory and confirm every contract check passes.
|
||||
2. Create a clean dashboard-only repository or Bolt project.
|
||||
3. Copy only the *contents* of `dashboard/` into that project, so `index.html`
|
||||
is at the project root.
|
||||
4. Confirm that private `data/`, `artifacts/`, `models/`, `.env` files, SQL,
|
||||
notebooks, and database tooling are absent.
|
||||
5. Preview the project. There is no install or build step; if Bolt requests a
|
||||
preview command, use `node server.mjs` with `HOST=0.0.0.0` and let Bolt
|
||||
provide `PORT`.
|
||||
6. Verify all four views, the development-sample messaging on every view, and
|
||||
that the in-app status reads **Validated sample aggregates**.
|
||||
7. Use **Publish** only after the aggregate bundle completes privacy review.
|
||||
Bolt hosting is the default; Netlify can instead be selected before the
|
||||
first publish if desired.
|
||||
|
||||
See Bolt's official documentation for [Git
|
||||
integration](https://support.bolt.new/integrations/git) and [publishing with
|
||||
Netlify](https://support.bolt.new/integrations/netlify).
|
||||
|
||||
No publishing action is performed by this repository.
|
||||
The current delivery path is the same-network live demo. No Bolt project,
|
||||
public URL, dependency install, or build step is needed. If public hosting is
|
||||
ever reconsidered, publish only a separately reviewed copy of this dashboard
|
||||
directory—never the private source repository.
|
||||
|
||||
+100
-1
@@ -10,6 +10,34 @@ const ENVELOPE_KEYS = Object.freeze([
|
||||
"population_estimate_allowed",
|
||||
"schema_version",
|
||||
]);
|
||||
const SHA256_INITIAL_STATE = Object.freeze([
|
||||
0x6a09e667,
|
||||
0xbb67ae85,
|
||||
0x3c6ef372,
|
||||
0xa54ff53a,
|
||||
0x510e527f,
|
||||
0x9b05688c,
|
||||
0x1f83d9ab,
|
||||
0x5be0cd19,
|
||||
]);
|
||||
const SHA256_ROUND_CONSTANTS = Object.freeze([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
|
||||
export const REQUIRED_ASSETS = Object.freeze({
|
||||
manifest: "data_manifest.json",
|
||||
@@ -541,9 +569,80 @@ function parseJsonBytes(bytes, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function rotateRight(value, shift) {
|
||||
return ((value >>> shift) | (value << (32 - shift))) >>> 0;
|
||||
}
|
||||
|
||||
function sha256HexFallback(bytes) {
|
||||
const input = new Uint8Array(bytes);
|
||||
const bitLength = input.byteLength * 8;
|
||||
if (!Number.isSafeInteger(bitLength)) {
|
||||
throw new DataContractError("Dashboard asset integrity verification failed.");
|
||||
}
|
||||
|
||||
const paddedLength = Math.ceil((input.byteLength + 9) / 64) * 64;
|
||||
const padded = new Uint8Array(paddedLength);
|
||||
padded.set(input);
|
||||
padded[input.byteLength] = 0x80;
|
||||
|
||||
const paddedView = new DataView(padded.buffer);
|
||||
paddedView.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false);
|
||||
paddedView.setUint32(paddedLength - 4, bitLength >>> 0, false);
|
||||
|
||||
const state = [...SHA256_INITIAL_STATE];
|
||||
const words = new Uint32Array(64);
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
words[index] = paddedView.getUint32(offset + index * 4, false);
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const sigma0 =
|
||||
rotateRight(words[index - 15], 7) ^
|
||||
rotateRight(words[index - 15], 18) ^
|
||||
(words[index - 15] >>> 3);
|
||||
const sigma1 =
|
||||
rotateRight(words[index - 2], 17) ^
|
||||
rotateRight(words[index - 2], 19) ^
|
||||
(words[index - 2] >>> 10);
|
||||
words[index] =
|
||||
(words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
|
||||
}
|
||||
|
||||
let [a, b, c, d, e, f, g, h] = state;
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
||||
const choose = (e & f) ^ (~e & g);
|
||||
const temporary1 =
|
||||
(h + sum1 + choose + SHA256_ROUND_CONSTANTS[index] + words[index]) >>> 0;
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temporary2 = (sum0 + majority) >>> 0;
|
||||
h = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + temporary1) >>> 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (temporary1 + temporary2) >>> 0;
|
||||
}
|
||||
|
||||
state[0] = (state[0] + a) >>> 0;
|
||||
state[1] = (state[1] + b) >>> 0;
|
||||
state[2] = (state[2] + c) >>> 0;
|
||||
state[3] = (state[3] + d) >>> 0;
|
||||
state[4] = (state[4] + e) >>> 0;
|
||||
state[5] = (state[5] + f) >>> 0;
|
||||
state[6] = (state[6] + g) >>> 0;
|
||||
state[7] = (state[7] + h) >>> 0;
|
||||
}
|
||||
|
||||
return state.map((word) => word.toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes, cryptoImplementation) {
|
||||
if (!cryptoImplementation?.subtle || typeof cryptoImplementation.subtle.digest !== "function") {
|
||||
throw new DataContractError("This browser cannot verify dashboard asset integrity.");
|
||||
return sha256HexFallback(bytes);
|
||||
}
|
||||
let digest;
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"description": "Dependency-free static dashboard for approved Utah Vehicle Health aggregates.",
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"start:lan": "node server.mjs --lan",
|
||||
"test": "node --test tests/contract.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+43
-3
@@ -1,10 +1,12 @@
|
||||
import { createReadStream, realpathSync, statSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = realpathSync(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const HOST = process.env.HOST || "127.0.0.1";
|
||||
const LAN_MODE = process.argv.includes("--lan");
|
||||
const HOST = LAN_MODE ? "0.0.0.0" : process.env.HOST || "127.0.0.1";
|
||||
const PORT = Number.parseInt(process.env.PORT || "4173", 10);
|
||||
const TYPES = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
@@ -50,6 +52,28 @@ function safeFile(requestUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
function displayUrls(host, port) {
|
||||
if (host !== "0.0.0.0") {
|
||||
const displayHost = host === "::" ? "::1" : host;
|
||||
const urlHost = displayHost.includes(":") ? `[${displayHost}]` : displayHost;
|
||||
return [{ label: "Open", url: `http://${urlHost}:${port}` }];
|
||||
}
|
||||
|
||||
const entries = [{ label: "This computer", url: `http://127.0.0.1:${port}` }];
|
||||
const seen = new Set();
|
||||
for (const [interfaceName, addresses] of Object.entries(networkInterfaces())) {
|
||||
for (const address of addresses || []) {
|
||||
if (address.family !== "IPv4" || address.internal || seen.has(address.address)) continue;
|
||||
seen.add(address.address);
|
||||
entries.push({
|
||||
label: `Other devices (${interfaceName})`,
|
||||
url: `http://${address.address}:${port}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
if (!request.url || !["GET", "HEAD"].includes(request.method || "")) {
|
||||
response.writeHead(405, { Allow: "GET, HEAD" });
|
||||
@@ -66,7 +90,7 @@ const server = createServer((request, response) => {
|
||||
const extension = path.extname(file).toLowerCase();
|
||||
response.writeHead(200, {
|
||||
"Content-Type": TYPES.get(extension) || "application/octet-stream",
|
||||
"Cache-Control": extension === ".json" ? "no-store" : "public, max-age=300",
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
});
|
||||
@@ -77,6 +101,22 @@ const server = createServer((request, response) => {
|
||||
createReadStream(file).on("error", () => response.destroy()).pipe(response);
|
||||
});
|
||||
|
||||
server.on("error", (error) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
process.stderr.write(
|
||||
`Dashboard port ${PORT} is already in use. Stop the existing server or set a different PORT.\n`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`Dashboard server could not start (${error.code || "unknown error"}).\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
process.stdout.write(`Utah Vehicle Health dashboard: http://${HOST}:${PORT}\n`);
|
||||
const address = server.address();
|
||||
const actualPort = typeof address === "object" && address ? address.port : PORT;
|
||||
const urls = displayUrls(HOST, actualPort)
|
||||
.map(({ label, url }) => ` ${label}: ${url}`)
|
||||
.join("\n");
|
||||
process.stdout.write(`Utah Vehicle Health dashboard:\n${urls}\n`);
|
||||
});
|
||||
|
||||
@@ -214,6 +214,32 @@ test("browser loader verifies every raw asset digest before rendering", async ()
|
||||
);
|
||||
});
|
||||
|
||||
test("LAN HTTP loader verifies asset digests without Web Crypto", async () => {
|
||||
const validated = await loadDashboardData({
|
||||
basePath: "http://dashboard.test/public/data/",
|
||||
fetchImplementation: publicDataFetch(),
|
||||
cryptoImplementation: null,
|
||||
});
|
||||
assert.equal(validated.manifest.schema_version, SCHEMA_VERSION);
|
||||
|
||||
const changedOverview = Buffer.concat([
|
||||
readFileSync(path.join(PUBLIC_DATA, REQUIRED_ASSETS.overview)),
|
||||
Buffer.from("\n"),
|
||||
]);
|
||||
await assert.rejects(
|
||||
loadDashboardData({
|
||||
basePath: "http://dashboard.test/public/data/",
|
||||
fetchImplementation: publicDataFetch(
|
||||
new Map([[REQUIRED_ASSETS.overview, changedOverview]]),
|
||||
),
|
||||
cryptoImplementation: null,
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof DataContractError &&
|
||||
/Integrity verification failed for overview_period_county\.json/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test("sha256 manifest covers and matches every approved data asset", () => {
|
||||
const manifest = json(REQUIRED_ASSETS.shaManifest);
|
||||
const expectedNames = Object.entries(REQUIRED_ASSETS)
|
||||
|
||||
Reference in New Issue
Block a user