diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 472e20e..d7abdc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,9 @@ name: CI -# src/api is generated from LensBridgeBackend/openapi.yaml. The kiosk reads one -# endpoint, but it runs unattended on a wall -- a silently changed payload shape -# shows up as a blank board, so the contract is checked here instead. +# src/api/schema.d.ts is generated from LensBridgeBackend/openapi.yaml. The kiosk +# reads one payload shape (MusallahBoardPayload, served by its agent), but it +# runs unattended on a wall -- a silently changed payload shape shows up as a +# blank board, so the contract is checked here instead. on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..96357be --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +name: Release + +# Publishes the board app for the device agents (agent/docs/architecture.md, +# sections 4, 9.4 and 15). Pushing a tag vX.Y.Z builds the SPA, signs it into +# musallahboard-app-X.Y.Z.mbu with the release key, and attaches that plus +# app-channel.json to the GitHub release. Boards poll +# releases/latest/download/app-channel.json, so publishing the release is what +# rolls the fleet forward; the package signature, not this workflow, is what +# makes that safe. + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + # The channel file advertises package.json's version under this tag's + # download URL. A tag that disagrees would publish a pointer to a file + # that is not there. + - name: Check the tag matches package.json + run: | + version="$(node -p "require('./package.json').version")" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "tag ${GITHUB_REF_NAME} does not match package.json version ${version}" >&2 + exit 1 + fi + + - run: npm test + + - name: Build and sign the app package + env: + MB_RELEASE_SIGNING_KEY: ${{ secrets.MB_RELEASE_SIGNING_KEY }} + run: npm run package + + - uses: softprops/action-gh-release@v2 + with: + files: | + release/*.mbu + release/app-channel.json diff --git a/.gitignore b/.gitignore index e37b285..00466d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/* dist/* +# npm run package: signed board app packages and the channel pointer +release/ .env .DS_Store .vscode/* @@ -7,10 +9,6 @@ logs/* npm-debug.log* new/* -# wrangler files -.wrangler -.dev.vars* -!.dev.vars.example !.env.example # design-sync: staged converter scripts, build output, and machine state. diff --git a/PLAN.md b/PLAN.md index c9f7f97..cc4d64e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,7 @@ # MusallahBoard Production Plan +> Superseded: the board app is no longer hosted; it runs only served by the device agent (see README and agent/docs/architecture.md). + Path from the current PoC (hosted Vite site + `setup.sh`-hardened Pi kiosk) to a production fleet with per-device identity, remote control from the admin portal, granular per-board configuration, and offline resilience. @@ -90,6 +92,13 @@ elevated privileges, so they don't touch the agent. ### 1. Service worker (offline) +> **Superseded.** Offline operation is no longer a service-worker concern: +> every board is now served from its own disk by the device agent, with +> signed content and app packages (local-first, no online/offline mode). See +> `docs/architecture.md` in the [agent repo](https://github.com/LensBridge/agent). +> The notes below, and the related non-goals and tech choices in this plan, +> describe the earlier approach. + In the existing Vite app, using `vite-plugin-pwa` (Workbox under the hood). - Precache app shell (HTML/CSS/JS/fonts/logo). diff --git a/README.md b/README.md index 9e88b11..4d65437 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,44 @@ curl -fsSL https://raw.githubusercontent.com/LensBridge/agent/main/setup.sh | su This will install the agent and all of its dependencies, setup the configuration for the window manager, and configure the system. Upon reboot, the board will display a "Waiting for Enrollment" screen until you enroll it in your Minbar tenancy. +## Running on a board + +Every board runs this app from its own disk. The [device agent](https://github.com/LensBridge/agent) serves it at `http://127.0.0.1:8080/` together with the day's content, and the kiosk always loads that address, online or not. The network only changes how fresh the content is. The agent's `docs/architecture.md` is the contract for all of this. + +The page is same-origin with the agent: it fetches today's payload from `/api/musallah/payload`, learns its device id from `/api/local/status`, and `/api/local/events` tells it when new content (re-fetch in place) or a new app release (reload) is installed. With no content installed yet it shows a "Waiting for content" screen saying whether it is downloading or needs a USB stick or a laptop on its ethernet port. + +Alt+Shift+F shows diagnostics: app and agent versions, installed content and sync status. + +### Developing + +`npm run dev` serves the app on port 3000 and proxies `/api` (including the `/api/local/events` stream) and `/media` to a device agent, `http://127.0.0.1:8080` by default. To develop against a real board's content, forward its agent port first: + +```bash +ssh -L 8080:127.0.0.1:8080 +npm run dev +``` + +Set `VITE_DEV_AGENT` to proxy to an agent somewhere else instead (`VITE_DEV_AGENT=http://127.0.0.1:18080 npm run dev`). + +`npm test` runs the unit tests and `npm run typecheck` checks the API layer. The payload types in `src/api/schema.d.ts` are generated from the backend's `openapi.yaml` (`MusallahBoardPayload`); regenerate them with `npm run api:generate`, with LensBridgeBackend checked out beside this repository or `OPENAPI_SPEC` pointing at the spec. + +### Releasing the board app + +Boards only install app packages (`.mbu`) signed with the release key, so the key is created once and kept out of the repo: + +```bash +# in the agent repo +mbpack keygen +# or with Node alone: prints the seed (keep secret) and the public key (give to the agent) +node -e "const c=require('crypto');const s=c.randomBytes(32);const k=c.createPrivateKey({key:Buffer.concat([Buffer.from('302e020100300506032b657004220420','hex'),s]),format:'der',type:'pkcs8'});const p=c.createPublicKey(k).export({format:'der',type:'spki'}).subarray(-32);console.log('seed: ',s.toString('base64'));console.log('public:',p.toString('base64'));console.log('keyId: ',c.createHash('sha256').update(p).digest().subarray(0,8).toString('hex'))" +``` + +Store the seed as the repository secret `MB_RELEASE_SIGNING_KEY`, and compile the public key into the agent (or add it with `musallahboard-agent trust add release `). + +To release, bump `version` in `package.json` (`MAJOR.MINOR.PATCH`), commit, and push a matching tag (`v2.1.0`). The `Release` workflow tests, builds and signs `musallahboard-app-.mbu`, and attaches it and `app-channel.json` to the GitHub release; online boards pick it up from the channel within a few hours, offline ones from a USB stick or `mbpush`. + +Locally, `MB_RELEASE_SIGNING_KEY= npm run package` writes both files to `release/`. `MB_RELEASE_BASE_URL` changes where `app-channel.json` says the package can be downloaded. + ## Epilogue This README is vastly incomplete due to me focusing on development. Docs and setup instructions will lag behind actual development work for another ~month-ish or so. Rest assured, I do plan on writing high-quality docs from scratch (no AI :P) once the system is in a more stable state. For now, please feel free to open an issue if you have any questions or need help with setup. I will do my best to respond in a timely manner. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3f457ff..1be83ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,11 @@ "@fontsource/cinzel": "^5.3.0", "@fontsource/montserrat": "^5.3.0", "adhan": "^4.4.6", - "openapi-fetch": "^0.17.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.41.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^5.2.0", @@ -26,8 +24,7 @@ "openapi-typescript": "^7.13.0", "typescript": "^5.9.3", "vite": "^8.2.0", - "vitest": "^4.1.10", - "wrangler": "^4.101.0" + "vitest": "^4.1.10" } }, "node_modules/@asamuzakjp/css-color": { @@ -112,7 +109,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -387,163 +383,6 @@ "specificity": "bin/cli.js" } }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/vite-plugin": { - "version": "1.54.11", - "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.54.11.tgz", - "integrity": "sha512-TJoTX1MvKXijrLVciUc66xR0Ss199au/9uiTP4H0QDNrUYpTHUU5V+X3opt0IU9f41MXStuy0oPkrZqGM0LRww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cloudflare/unenv-preset": "2.16.1", - "miniflare": "5.20260916.0-alpha", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260916.1", - "wrangler": "4.133.0", - "ws": "8.21.0" - }, - "bin": { - "cf-vite": "bin/cf-vite" - }, - "peerDependencies": { - "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", - "wrangler": "^4.133.0" - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260916.1.tgz", - "integrity": "sha512-h/xA7foQncooOpO3HF4IaCR/8NAy1qN5SjL0+nMTT8FgJyYE/SAGiLgSCusXc15yh7upibaYFrjHK5928nFIEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260916.1.tgz", - "integrity": "sha512-triQBeZCJkCAbugMK/2/K3sqY1neUPXMDbWtoGqpKmzWXi4C2VW2Z+VSobQwJCHluP9wu0qP3O8G0+YNmsJCEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260916.1.tgz", - "integrity": "sha512-a4gRHOI2Iv9wRbFkSGqcDFyfgo4pthTKeBUkdKhL7fxyNUghPNs9rHw+d2Q9aZyeOW2TDa2FI7ELg09eDJOPxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260916.1.tgz", - "integrity": "sha512-qWIZZtd1ATaRI53hKBTxpLKGQJcwOwpPsqfIUaRv8ouq2yJkzJxlG9hReacOjpSh1br3edPxysNa5KBOkqpn9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260916.1.tgz", - "integrity": "sha512-Fa14o+wq2QI4/Lsez609sNmoS9dRliSZwgmDdLLG0wZzmf3s7n0Xs2ZDAvaO2iZgH8WEk+hBfHuOeWM0P7LF0Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -632,7 +471,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -681,22 +519,10 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -710,6 +536,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -727,6 +554,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -744,6 +572,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -761,6 +590,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -778,6 +608,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -795,6 +626,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -812,6 +644,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -829,6 +662,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -846,6 +680,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -863,6 +698,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -880,6 +716,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -897,6 +734,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -914,6 +752,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -931,6 +770,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -948,6 +788,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -965,6 +806,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -982,6 +824,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -999,6 +842,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1016,6 +860,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1033,6 +878,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1050,648 +896,126 @@ "os": [ "openbsd" ], + "peer": true, "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@fontsource/amiri": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/amiri/-/amiri-5.3.0.tgz", - "integrity": "sha512-i4SWJnVIphj8556EdVzNrEG/Qu82k6JXjXTtSUIohx3tHmo2GHw1Kv60TWo+xjk7Yf1Etb2Gskh0lUFclTKyUw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/cinzel": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz", - "integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/montserrat": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz", - "integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "node": ">=18" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, + "os": [ + "openharmony" + ], + "peer": true, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "wasm32" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, + "os": [ + "sunos" + ], + "peer": true, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", - "cpu": [ - "x64" - ], + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">=20.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource/amiri": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/amiri/-/amiri-5.3.0.tgz", + "integrity": "sha512-i4SWJnVIphj8556EdVzNrEG/Qu82k6JXjXTtSUIohx3tHmo2GHw1Kv60TWo+xjk7Yf1Etb2Gskh0lUFclTKyUw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/cinzel": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz", + "integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/montserrat": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz", + "integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==", + "license": "OFL-1.1", "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1754,35 +1078,6 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -2074,26 +1369,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.24", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", - "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2107,7 +1382,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2467,13 +1741,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -2504,7 +1771,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -2571,20 +1837,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2685,16 +1937,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2709,6 +1951,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "optional": true, "peer": true, "bin": { "esbuild": "bin/esbuild" @@ -2758,6 +2001,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -3012,16 +2256,6 @@ "node": ">=6" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -3332,24 +2566,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/miniflare": { - "version": "5.20260916.0-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260916.0-alpha.tgz", - "integrity": "sha512-rffIUYE5tPwhvUtVYZ4LEGaQ2XkXdBsq/flJOKODumR3gbBgHFyqPkobQEB7Ocqesz5wSUhpV8C2R57JYOsyhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.35.4", - "undici": "7.29.0", - "workerd": "1.20260916.1", - "ws": "8.21.0", - "youch": "4.1.0-beta.10" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -3413,15 +2629,6 @@ "node": ">=12.20.0" } }, - "node_modules/openapi-fetch": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", - "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", - "license": "MIT", - "dependencies": { - "openapi-typescript-helpers": "^0.1.0" - } - }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", @@ -3443,12 +2650,6 @@ "typescript": "^5.x" } }, - "node_modules/openapi-typescript-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", - "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", - "license": "MIT" - }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -3480,13 +2681,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3507,7 +2701,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3593,7 +2786,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3606,7 +2798,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3714,69 +2905,6 @@ "semver": "bin/semver.js" } }, - "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3918,14 +3046,6 @@ "node": ">=20" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -3945,7 +3065,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3964,17 +3083,6 @@ "node": ">=20.18.1" } }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "pathe": "^2.0.3" - } - }, "node_modules/update-browserslist-db": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", @@ -4019,7 +3127,6 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -4247,85 +3354,6 @@ "node": ">=8" } }, - "node_modules/workerd": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260916.1.tgz", - "integrity": "sha512-yGPvK1Tg6l80QNp7uIs3fAlWBmw+ftgn3QK4uqH3bHoS+PeJNARjm6HoWGmkYl1zaqFO0iR31sdwnFKHoHOhUQ==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260916.1", - "@cloudflare/workerd-darwin-arm64": "1.20260916.1", - "@cloudflare/workerd-linux-64": "1.20260916.1", - "@cloudflare/workerd-linux-arm64": "1.20260916.1", - "@cloudflare/workerd-windows-64": "1.20260916.1" - } - }, - "node_modules/wrangler": { - "version": "4.133.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.133.0.tgz", - "integrity": "sha512-Iv+feMJ3CzBvWcJM1OjtRL/0ujr1Oqmg6H/356hJrnqf8AEkazhUVqjgcRer5HGhuc9KhrsyxUpKTRMnMeUmeA==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.28.1", - "miniflare": "5.20260916.0-alpha", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260916.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^5.20260916.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4366,31 +3394,6 @@ "engines": { "node": ">=12" } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } } } } diff --git a/package.json b/package.json index 89b79e4..bb35275 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "vite --host", "build": "npm run typecheck && vite build", "preview": "vite preview --host", - "deploy": "npm run build && wrangler deploy", + "package": "npm run build && node scripts/package-mbu.mjs", "api:generate": "node scripts/generate-api.mjs", "test": "vitest run", "test:watch": "vitest", @@ -18,13 +18,11 @@ "@fontsource/cinzel": "^5.3.0", "@fontsource/montserrat": "^5.3.0", "adhan": "^4.4.6", - "openapi-fetch": "^0.17.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.41.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^5.2.0", @@ -32,7 +30,6 @@ "openapi-typescript": "^7.13.0", "typescript": "^5.9.3", "vite": "^8.2.0", - "vitest": "^4.1.10", - "wrangler": "^4.101.0" + "vitest": "^4.1.10" } } diff --git a/scripts/package-mbu.mjs b/scripts/package-mbu.mjs new file mode 100644 index 0000000..a6d5d83 --- /dev/null +++ b/scripts/package-mbu.mjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node +/** + * ===================================================== + * Board app package (.mbu) builder + * ===================================================== + * Turns the Vite build into a signed `app` package the + * device agent will install, plus the release channel + * pointer boards poll (agent/docs/architecture.md, + * sections 4, 9.4 and 15): + * + * release/musallahboard-app-.mbu + * release/app-channel.json + * {"version","url","sha256","bytes"} + * + * Run after `vite build` (`npm run package` does both). + * Dependency-free on purpose: this script holds the + * release key in CI, so it is small enough to read in one + * sitting and pulls nothing from npm. It produces the + * same format as `mbpack app` in the agent repo. + * + * Environment: + * MB_RELEASE_SIGNING_KEY base64 of the 32-byte Ed25519 + * seed. Required unless + * --unsigned (tests only: the + * agent refuses such a package). + * MB_RELEASE_BASE_URL where the .mbu is downloaded + * from; default the GitHub + * release for v. + * + * Options: + * --dir build output (default: dist) + * --out-dir default: release + * --unsigned write without mbu.sig + * ===================================================== + */ + +import { + createHash, createPrivateKey, createPublicKey, sign as edSign, +} from 'node:crypto'; +import { + existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync, +} from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { crc32, deflateRawSync } from 'node:zlib'; + +// --------------------------------------------------------------------------- +// Contract constants (architecture doc, section 4) +// --------------------------------------------------------------------------- + +/** The local API version (section 7) this build needs from the agent. */ +export const LOCAL_API = 2; + +const SIGNING_PREFIX = Buffer.from('musallahboard-mbu-v2\n', 'ascii'); + +const SEGMENT_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/; +const MAX_SEGMENTS = 8; +const MAX_NAME_BYTES = 200; +const MAX_FILES = 20000; +const MAX_TOTAL_FILE_BYTES = 1024 * 1024 * 1024; // sum of files[].bytes +const MAX_PACKAGE_BYTES = 512 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 1024 * 1024; + +// --------------------------------------------------------------------------- +// Entry names +// --------------------------------------------------------------------------- + +/** + * Throw unless `name` is a valid package entry name: slash-separated segments + * matching SEGMENT_RE (so no empty, `.`, `..` or hidden segments), at most 8 + * segments and 200 bytes, no backslash, no leading slash. The agent rejects the + * whole package over one bad name, so this fails here, loudly, instead. + * @param {string} name + */ +export function checkEntryName(name) { + const bad = (why) => { throw new Error(`invalid package entry name ${JSON.stringify(name)}: ${why}`); }; + if (typeof name !== 'string' || name === '') bad('empty'); + if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) bad(`longer than ${MAX_NAME_BYTES} bytes`); + if (name.includes('\\')) bad('contains a backslash'); + if (name.startsWith('/')) bad('leading slash'); + const segments = name.split('/'); + if (segments.length > MAX_SEGMENTS) bad(`more than ${MAX_SEGMENTS} segments`); + for (const s of segments) { + if (!SEGMENT_RE.test(s)) bad(`segment ${JSON.stringify(s)} must match ${SEGMENT_RE}`); + } +} + +// --------------------------------------------------------------------------- +// Build output +// --------------------------------------------------------------------------- + +/** + * Every regular file under `dir`, as sorted slash-separated paths relative to + * it. Symlinks and other special files are an + * error: the package may only carry regular files, and following a link could + * pull in something from outside the build. + * @param {string} dir + * @returns {string[]} + */ +export function listBuildFiles(dir) { + const out = []; + const walk = (d) => { + for (const ent of readdirSync(d, { withFileTypes: true })) { + const full = join(d, ent.name); + const rel = relative(dir, full).split(sep).join('/'); + if (ent.isDirectory()) walk(full); + else if (ent.isFile()) out.push(rel); + else throw new Error(`${rel}: not a regular file (symlink or special file)`); + } + }; + walk(dir); + // Byte order, so the manifest is the same on every platform and locale. + return out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +const sha256hex = (buf) => createHash('sha256').update(buf).digest('hex'); + +/** RFC 3339 UTC, second precision: `2026-09-24T14:02:11Z`. */ +export function rfc3339Seconds(date) { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** + * Build the `app` manifest for a set of files. + * @param {{ version: string, createdAt: string, files: {path:string, data:Buffer}[] }} args + * @returns {object} + */ +export function buildAppManifest({ version, createdAt, files }) { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`version ${JSON.stringify(version)} must be MAJOR.MINOR.PATCH (digits only)`); + } + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(createdAt)) { + throw new Error(`createdAt ${JSON.stringify(createdAt)} must be RFC 3339 UTC with second precision`); + } + if (files.length > MAX_FILES) throw new Error(`${files.length} files; the limit is ${MAX_FILES}`); + const seen = new Set(); + let total = 0; + for (const f of files) { + checkEntryName(f.path); + if (f.path === 'mbu.json' || f.path === 'mbu.sig') { + throw new Error(`${f.path}: reserved for the package manifest`); + } + if (seen.has(f.path)) throw new Error(`${f.path}: listed twice`); + seen.add(f.path); + total += f.data.length; + } + if (total > MAX_TOTAL_FILE_BYTES) throw new Error(`build is ${total} bytes; the limit is ${MAX_TOTAL_FILE_BYTES}`); + if (!seen.has('index.html')) throw new Error('index.html is not in the build output'); + + return { + format: 'mbu', + formatVersion: 2, + type: 'app', + createdAt, + version, + files: files.map((f) => ({ path: f.path, sha256: sha256hex(f.data), bytes: f.data.length })), + app: { localApi: LOCAL_API }, + }; +} + +// --------------------------------------------------------------------------- +// Signing +// --------------------------------------------------------------------------- + +// PKCS#8 wrapper for a raw Ed25519 seed (RFC 8410): SEQUENCE { version 0, +// AlgorithmIdentifier { 1.3.101.112 }, OCTET STRING { OCTET STRING seed } }. +const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); + +/** + * @param {string} seedB64 base64 of the 32-byte seed, as the architecture doc + * stores private keys (section 4.2) + */ +export function releaseKeyFromSeed(seedB64) { + const seed = Buffer.from(String(seedB64 ?? '').trim(), 'base64'); + if (seed.length !== 32) { + throw new Error(`signing key must be the base64 of a 32-byte Ed25519 seed (got ${seed.length} bytes)`); + } + const privateKey = createPrivateKey({ + key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), + format: 'der', + type: 'pkcs8', + }); + // The raw public key is the tail of its SPKI encoding. + const publicRaw = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(-32); + const keyId = createHash('sha256').update(publicRaw).digest().subarray(0, 8).toString('hex'); + return { privateKey, publicRaw: Buffer.from(publicRaw), keyId }; +} + +/** The exact bytes an Ed25519 signature covers: domain prefix + mbu.json. */ +export function signedMessage(manifestBytes) { + return Buffer.concat([SIGNING_PREFIX, manifestBytes]); +} + +/** + * The mbu.sig document for `manifestBytes`. + * @param {Buffer} manifestBytes exactly the bytes stored as mbu.json + * @param {ReturnType} key + */ +export function signManifest(manifestBytes, key) { + const sig = edSign(null, signedMessage(manifestBytes), key.privateKey); + return { signatures: [{ keyId: key.keyId, sig: sig.toString('base64') }] }; +} + +// --------------------------------------------------------------------------- +// Zip writer +// --------------------------------------------------------------------------- + +const U32_MAX = 0xffffffff; + +/** MS-DOS date and time fields for `date` (UTC; the format has no zone). */ +function dosDateTime(date) { + const y = Math.max(1980, date.getUTCFullYear()); + return { + time: (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | (date.getUTCSeconds() >> 1), + date: ((y - 1980) << 9) | ((date.getUTCMonth() + 1) << 5) | date.getUTCDate(), + }; +} + +/** + * Write a zip (PKWARE APPNOTE, no zip64) of `entries`, in order. Each entry is + * deflated when that makes it smaller and stored otherwise. No directory + * entries: the agent's reader wants files only, and names imply their + * directories. Deterministic: the same entries and `modified` give the same + * bytes, so a rebuild of a tag is byte-identical. + * + * Refuses anything that would need zip64 (an entry or the archive past 4 GiB, + * more than 65535 entries) rather than writing a truncated header. + * + * @param {{name: string, data: Buffer}[]} entries + * @param {Date} modified timestamp recorded on every entry + * @returns {Buffer} + */ +export function createZip(entries, modified = new Date()) { + if (entries.length > 0xffff) throw new Error('too many entries for a zip without zip64'); + const { time, date } = dosDateTime(modified); + const locals = []; + const centrals = []; + let offset = 0; + + for (const { name, data } of entries) { + const nameBytes = Buffer.from(name, 'utf8'); + if (data.length > U32_MAX) throw new Error(`${name}: larger than 4 GiB (zip64 is not supported)`); + const crc = crc32(data) >>> 0; + const deflated = deflateRawSync(data, { level: 9 }); + const useDeflate = deflated.length < data.length; + const body = useDeflate ? deflated : data; + const method = useDeflate ? 8 : 0; + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); // local file header signature + local.writeUInt16LE(20, 4); // version needed: 2.0 (deflate) + local.writeUInt16LE(0x0800, 6); // flags: UTF-8 names + local.writeUInt16LE(method, 8); + local.writeUInt16LE(time, 10); + local.writeUInt16LE(date, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(body.length, 18); // compressed size + local.writeUInt32LE(data.length, 22); // uncompressed size + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); // extra field length + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); // central directory header signature + central.writeUInt16LE(0x0314, 4); // made by: Unix, spec 2.0 + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(time, 12); + central.writeUInt16LE(date, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt16LE(0, 30); // extra field length + central.writeUInt16LE(0, 32); // comment length + central.writeUInt16LE(0, 34); // disk number start + central.writeUInt16LE(0, 36); // internal attributes + central.writeUInt32LE(((0o100644 << 16) >>> 0), 38); // regular file, rw-r--r-- + central.writeUInt32LE(offset, 42); // local header offset + + locals.push(local, nameBytes, body); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + body.length; + if (offset > U32_MAX) throw new Error('archive larger than 4 GiB (zip64 is not supported)'); + } + + const cdSize = centrals.reduce((n, b) => n + b.length, 0); + if (offset + cdSize + 22 > U32_MAX) throw new Error('archive larger than 4 GiB (zip64 is not supported)'); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); // end of central directory signature + end.writeUInt16LE(0, 4); // this disk + end.writeUInt16LE(0, 6); // disk with the central directory + end.writeUInt16LE(entries.length, 8); // entries on this disk + end.writeUInt16LE(entries.length, 10); // entries in total + end.writeUInt32LE(cdSize, 12); + end.writeUInt32LE(offset, 16); // central directory offset + end.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([...locals, ...centrals, end]); +} + +// --------------------------------------------------------------------------- +// Package +// --------------------------------------------------------------------------- + +/** + * Build a complete app package in memory. + * @param {{ version: string, createdAt: string, files: {path:string, data:Buffer}[], + * key: ReturnType|null }} args + * `key` null writes an unsigned package (no mbu.sig). + * @returns {{ zip: Buffer, manifest: object, manifestBytes: Buffer, sig: object|null }} + */ +export function buildAppPackage({ version, createdAt, files, key }) { + const manifest = buildAppManifest({ version, createdAt, files }); + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + if (manifestBytes.length > MAX_MANIFEST_BYTES) throw new Error('mbu.json is larger than 1 MiB'); + const sig = key ? signManifest(manifestBytes, key) : null; + + const entries = [{ name: 'mbu.json', data: manifestBytes }]; + if (sig) entries.push({ name: 'mbu.sig', data: Buffer.from(`${JSON.stringify(sig)}\n`, 'utf8') }); + for (const f of files) entries.push({ name: f.path, data: f.data }); + + const zip = createZip(entries, new Date(createdAt)); + if (zip.length > MAX_PACKAGE_BYTES) throw new Error(`package is ${zip.length} bytes; the limit is ${MAX_PACKAGE_BYTES}`); + return { zip, manifest, manifestBytes, sig }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const opts = { dir: null, outDir: 'release', unsigned: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--unsigned') opts.unsigned = true; + else if (a === '--dir') opts.dir = argv[++i]; + else if (a === '--out-dir') opts.outDir = argv[++i]; + else throw new Error(`unknown argument ${a}`); + } + return opts; +} + +function main() { + const root = resolve(fileURLToPath(new URL('..', import.meta.url))); + const opts = parseArgs(process.argv.slice(2)); + const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + + const seed = process.env.MB_RELEASE_SIGNING_KEY; + let key = null; + if (seed && seed.trim()) key = releaseKeyFromSeed(seed); + else if (!opts.unsigned) { + throw new Error( + 'MB_RELEASE_SIGNING_KEY is not set. Boards refuse unsigned packages; pass --unsigned ' + + 'only to test the packer.' + ); + } + + const dir = resolve(opts.dir ?? join(root, 'dist')); + if (!existsSync(join(dir, 'index.html')) || !lstatSync(dir).isDirectory()) { + throw new Error(`${dir} does not hold a build (no index.html). Run vite build first.`); + } + const files = listBuildFiles(dir).map((path) => ({ path, data: readFileSync(join(dir, ...path.split('/'))) })); + const createdAt = rfc3339Seconds(new Date()); + const { zip, manifest } = buildAppPackage({ version, createdAt, files, key }); + + const outDir = resolve(opts.outDir); + mkdirSync(outDir, { recursive: true }); + const name = `musallahboard-app-${version}.mbu`; + writeFileSync(join(outDir, name), zip); + + const base = (process.env.MB_RELEASE_BASE_URL || + `https://github.com/LensBridge/MusallahBoard/releases/download/v${version}`).replace(/\/+$/, ''); + const channel = { version, url: `${base}/${name}`, sha256: sha256hex(zip), bytes: zip.length }; + writeFileSync(join(outDir, 'app-channel.json'), `${JSON.stringify(channel, null, 2)}\n`); + + console.log(`build: ${relative(root, dir) || '.'} (${manifest.files.length} files)`); + console.log(`package: ${join(relative(root, outDir) || '.', name)} (${zip.length} bytes, sha256 ${channel.sha256})`); + console.log(key ? `signed: keyId ${key.keyId}` : 'UNSIGNED: boards will refuse this package'); + console.log(`channel: ${channel.url}`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (e) { + console.error(`package-mbu: ${e.message}`); + process.exit(1); + } +} diff --git a/scripts/package-mbu.test.mjs b/scripts/package-mbu.test.mjs new file mode 100644 index 0000000..6b1af9b --- /dev/null +++ b/scripts/package-mbu.test.mjs @@ -0,0 +1,207 @@ +// @vitest-environment node +// +// The packer is the one piece of this repo that talks to the agent's verifier +// rather than to a browser, so its tests read the output the way the agent +// does: a from-scratch zip reader below (not the writer's own bookkeeping), and +// crypto.verify against the public key alone. +import { createHash, createPublicKey, verify, randomBytes } from 'node:crypto'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { crc32, inflateRawSync } from 'node:zlib'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildAppManifest, buildAppPackage, checkEntryName, createZip, listBuildFiles, + releaseKeyFromSeed, rfc3339Seconds, signManifest, signedMessage, +} from './package-mbu.mjs'; + +/** Minimal zip reader: EOCD, then the central directory, then each local header. */ +function readZip(buf) { + const eocd = buf.length - 22; + expect(buf.readUInt32LE(eocd)).toBe(0x06054b50); + const count = buf.readUInt16LE(eocd + 10); + const cdSize = buf.readUInt32LE(eocd + 12); + let p = buf.readUInt32LE(eocd + 16); + expect(p + cdSize).toBe(eocd); + + const out = []; + for (let i = 0; i < count; i++) { + expect(buf.readUInt32LE(p)).toBe(0x02014b50); + const method = buf.readUInt16LE(p + 10); + const crc = buf.readUInt32LE(p + 16); + const csize = buf.readUInt32LE(p + 20); + const usize = buf.readUInt32LE(p + 24); + const nlen = buf.readUInt16LE(p + 28); + const xlen = buf.readUInt16LE(p + 30); + const clen = buf.readUInt16LE(p + 32); + const mode = buf.readUInt32LE(p + 38) >>> 16; + const local = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nlen).toString('utf8'); + p += 46 + nlen + xlen + clen; + + expect(buf.readUInt32LE(local)).toBe(0x04034b50); + expect(buf.readUInt32LE(local + 14)).toBe(crc); + expect(buf.subarray(local + 30, local + 30 + buf.readUInt16LE(local + 26)).toString('utf8')).toBe(name); + const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + const body = buf.subarray(start, start + csize); + const data = method === 8 ? inflateRawSync(body) : Buffer.from(body); + expect([0, 8]).toContain(method); + expect(data.length).toBe(usize); + expect(crc32(data) >>> 0).toBe(crc); + out.push({ name, method, mode, data }); + } + return out; +} + +const SEED_1_TO_32 = Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)).toString('base64'); + +describe('createZip', () => { + it('round-trips stored and deflated entries with correct CRCs', () => { + const entries = [ + { name: 'mbu.json', data: Buffer.from('{"a":1}\n') }, + { name: 'index.html', data: Buffer.from(''.repeat(200)) }, // compresses + { name: 'assets/x.bin', data: randomBytes(300) }, // does not + { name: 'empty.txt', data: Buffer.alloc(0) }, + ]; + const got = readZip(createZip(entries, new Date('2026-09-24T14:02:11Z'))); + expect(got.map((e) => e.name)).toEqual(entries.map((e) => e.name)); + got.forEach((e, i) => expect(e.data.equals(entries[i].data)).toBe(true)); + expect(got[1].method).toBe(8); + expect(got[2].method).toBe(0); + expect(got.every((e) => e.mode === 0o100644)).toBe(true); + }); + + it('is deterministic for the same input and timestamp', () => { + const entries = [{ name: 'a', data: Buffer.from('hello') }]; + const at = new Date('2026-01-01T00:00:00Z'); + expect(createZip(entries, at).equals(createZip(entries, at))).toBe(true); + }); +}); + +describe('signing', () => { + it('matches the Go verifier test vector for seed 0x01..0x20', () => { + expect(releaseKeyFromSeed(SEED_1_TO_32).keyId).toBe('65b60673d6ed884b'); + }); + + it('signs prefix + exact mbu.json bytes, verifiable from the raw public key', () => { + const key = releaseKeyFromSeed(SEED_1_TO_32); + const bytes = Buffer.from('{"format":"mbu"}\n'); + const { signatures } = signManifest(bytes, key); + expect(signatures).toHaveLength(1); + expect(signatures[0].keyId).toBe(key.keyId); + const sig = Buffer.from(signatures[0].sig, 'base64'); + expect(sig.length).toBe(64); + + // Rebuild the public key from its raw 32 bytes, as a board's trust store has it. + const pub = createPublicKey({ + key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), key.publicRaw]), + format: 'der', + type: 'spki', + }); + const msg = Buffer.concat([Buffer.from('musallahboard-mbu-v2\n'), bytes]); + expect(signedMessage(bytes).equals(msg)).toBe(true); + expect(verify(null, msg, pub, sig)).toBe(true); + expect(verify(null, bytes, pub, sig)).toBe(false); // prefix is part of it + }); + + it('rejects a seed that is not 32 bytes', () => { + expect(() => releaseKeyFromSeed(Buffer.alloc(31).toString('base64'))).toThrow(/32-byte/); + expect(() => releaseKeyFromSeed('')).toThrow(/32-byte/); + }); +}); + +describe('entry names', () => { + it.each(['index.html', 'assets/index-BHAz-QHd.js', '_x/a.b', 'a/b/c/d/e/f/g/h'])('accepts %s', (n) => { + expect(() => checkEntryName(n)).not.toThrow(); + }); + it.each([ + '', '/index.html', 'a//b', './a', 'a/../b', '.assetsignore', 'assets/.hidden', + 'a\\b', 'a/b/c/d/e/f/g/h/i', '-x', 'a b', `${'a'.repeat(201)}`, + ])('rejects %j', (n) => { + expect(() => checkEntryName(n)).toThrow(/invalid package entry name/); + }); +}); + +describe('manifest and package', () => { + const files = [ + { path: 'assets/app.js', data: Buffer.from('console.log(1)') }, + { path: 'index.html', data: Buffer.from('') }, + ]; + + it('builds an app manifest per section 4.1', () => { + const m = buildAppManifest({ version: '2.1.0', createdAt: '2026-09-24T14:02:11Z', files }); + expect(m).toMatchObject({ + format: 'mbu', formatVersion: 2, type: 'app', version: '2.1.0', + createdAt: '2026-09-24T14:02:11Z', app: { localApi: 2 }, + }); + expect(m.files.map((f) => f.path)).toEqual(['assets/app.js', 'index.html']); + expect(m.files[1]).toEqual({ + path: 'index.html', + sha256: createHash('sha256').update('').digest('hex'), + bytes: 15, + }); + }); + + it('refuses bad versions, timestamps, missing index.html and bad names', () => { + const at = '2026-09-24T14:02:11Z'; + expect(() => buildAppManifest({ version: '2.1', createdAt: at, files })).toThrow(/MAJOR/); + expect(() => buildAppManifest({ version: 'v2.1.0', createdAt: at, files })).toThrow(/MAJOR/); + expect(() => buildAppManifest({ version: '2.1.0', createdAt: '2026-09-24T14:02:11.123Z', files })).toThrow(/second/); + expect(() => buildAppManifest({ version: '2.1.0', createdAt: at, files: files.slice(0, 1) })).toThrow(/index.html/); + expect(() => buildAppManifest({ + version: '2.1.0', createdAt: at, files: [...files, { path: '.env', data: Buffer.alloc(1) }], + })).toThrow(/invalid package entry name/); + expect(() => buildAppManifest({ + version: '2.1.0', createdAt: at, files: [...files, { path: 'mbu.sig', data: Buffer.alloc(1) }], + })).toThrow(/reserved/); + }); + + it('writes mbu.json, mbu.sig and every file, with the signature over the stored mbu.json', () => { + const key = releaseKeyFromSeed(SEED_1_TO_32); + const { zip, manifestBytes } = buildAppPackage({ + version: '2.1.0', createdAt: '2026-09-24T14:02:11Z', files, key, + }); + const got = readZip(zip); + expect(got.map((e) => e.name)).toEqual(['mbu.json', 'mbu.sig', 'assets/app.js', 'index.html']); + expect(got.some((e) => e.name.endsWith('/'))).toBe(false); // no directory entries + expect(got[0].data.equals(manifestBytes)).toBe(true); + + const sig = JSON.parse(got[1].data.toString('utf8')); + const pub = createPublicKey({ + key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), key.publicRaw]), + format: 'der', + type: 'spki', + }); + expect(verify(null, signedMessage(got[0].data), pub, Buffer.from(sig.signatures[0].sig, 'base64'))).toBe(true); + }); + + it('leaves mbu.sig out of an unsigned package', () => { + const { zip } = buildAppPackage({ version: '2.1.0', createdAt: '2026-09-24T14:02:11Z', files, key: null }); + expect(readZip(zip).map((e) => e.name)).toEqual(['mbu.json', 'assets/app.js', 'index.html']); + }); + + it('formats createdAt at second precision in UTC', () => { + expect(rfc3339Seconds(new Date('2026-09-24T14:02:11.987Z'))).toBe('2026-09-24T14:02:11Z'); + }); +}); + +describe('listBuildFiles', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('lists regular files sorted', () => { + dir = mkdtempSync(join(tmpdir(), 'mbu-build-')); + mkdirSync(join(dir, 'assets')); + writeFileSync(join(dir, 'index.html'), 'x'); + writeFileSync(join(dir, 'assets', 'b.js'), 'x'); + writeFileSync(join(dir, 'assets', 'a.css'), 'x'); + expect(listBuildFiles(dir)).toEqual(['assets/a.css', 'assets/b.js', 'index.html']); + }); + + it('refuses symlinks', () => { + dir = mkdtempSync(join(tmpdir(), 'mbu-build-')); + writeFileSync(join(dir, 'index.html'), 'x'); + symlinkSync('/etc/hostname', join(dir, 'link')); + expect(() => listBuildFiles(dir)).toThrow(/not a regular file/); + }); +}); diff --git a/src/App.jsx b/src/App.jsx index 2f187c0..c8b3099 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,9 +1,11 @@ // MusallahBoard — root orchestrator. -// Setup gate → fetch payload (weather rides along) + prayer → compose the -// design data shape → drive the slideshow built from the frame-builder registry. +// Served by the device agent at 127.0.0.1:8080: fetch the payload (weather +// rides along) + prayer → compose the design data shape → drive the slideshow +// built from the frame-builder registry. import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { - getBoardPayload, getPrayerData, connectRefreshSocket, getLocalStatus, + getBoardPayload, getPrayerData, getLocalStatus, connectLocalEvents, + isNoContentError, isValidDeviceId, } from './api/index.js'; import { prefetchPayloadImages } from './utils/prefetch.js'; import { @@ -13,10 +15,8 @@ import { classifyPrayers } from './utils/prayers.js'; import { buildSlideshow } from './frames/registry.js'; import { resolveTheme, DEFAULT_THEME } from './themes/registry.js'; import './frames/builders.jsx'; // registers default builders -import { - getSetupConfig, applyCursorPreference, isValidDeviceId, - setDeviceId as saveDeviceId, readBoardMode, -} from './utils/cookies.js'; +import { applyCursorPreference } from './utils/cookies.js'; +import { APP_VERSION } from './version.js'; import SetupModal from './components/SetupModal.jsx'; import DebugMenu from './components/DebugMenu.jsx'; import TopBar from './components/TopBar.jsx'; @@ -24,10 +24,7 @@ import PrayerRail from './components/PrayerRail.jsx'; import Ticker from './components/Ticker.jsx'; const PAYLOAD_REFRESH_MS = 10 * 60 * 1000; - -// 'offline' when the agent serves this page from a content bundle (see -// agent/docs/offline.md). Fixed for the life of the page. -const MODE = readBoardMode(); +const LOCAL_STATUS_REFRESH_MS = 60 * 1000; // Presentation overrides driven by the debug drawer (Alt+Shift+D). `null` on a // tri-state field means "don't override, use the board's own logic". Held in @@ -52,29 +49,80 @@ function ScaledStage({ children }) { ); } -function Status({ title, detail, error }) { +// `large` is for screens that someone has to act on from across the room (the +// waiting-for-content screen); `hint` is a second, plainer line under detail. +function Status({ title, detail, error, hint, large = false }) { return ( -
+

{title}

{detail}

{error &&
{error}
} + {hint &&
{hint}
}
); } +const SERVICE_PORT_URL = 'http://10.77.0.1/'; + /** - * "Content last updated …" note for an offline board whose bundle has run + * Agent up, but no content package installed yet. What an + * operator can do about it depends on whether the agent is trying to sync: + * + * - sync on and no error yet: it is downloading; nothing to do but wait. + * - sync on and failing: say why (sync.lastError), and offer the offline + * routes too, since a board with no internet also lands here (its sync + * attempts fail fast with no route, section 11 of the architecture doc). + * - sync off, or no status at all: only the offline routes can help. + */ +function WaitingForContent({ status }) { + const sync = status?.sync; + const offline = + `Plug in a USB stick with a MusallahBoard update, or connect a laptop or phone ` + + `to the board's ethernet port and open ${SERVICE_PORT_URL}`; + if (sync?.enabled && !sync.lastError) { + return ( + + ); + } + if (sync?.enabled) { + return ( + + ); + } + return ( + + ); +} + +/** + * "Content last updated …" note for a board whose installed content has run * out. Past lastDay the agent keeps serving lastDay's payload, so the board - * still looks alive — this is the one visible hint that nobody has pushed new - * content. Dated by when the bundle was generated, in the bundle's zone. + * still looks alive, and this is the one visible hint that nobody has sent new + * content. Dated by when the package was made, in the content's zone. */ function StaleNote({ status }) { if (!(status?.staleDays > 0)) return null; - const b = status.bundle; + const b = status?.content; let when = b?.lastDay ?? ''; - const at = b?.generatedAt ? new Date(b.generatedAt) : null; + const createdAt = b?.createdAt; + const at = createdAt ? new Date(createdAt) : null; if (at && !Number.isNaN(at.getTime())) { try { when = new Intl.DateTimeFormat('en-US', { @@ -96,20 +144,22 @@ function gregorian(now, timezone) { } export default function App() { - // Identity is settled by resolveDeviceId() in main.jsx before the first - // render, so the cookie is already authoritative here — no reconciliation - // effect, and no unpaired flash on a board the agent provisioned. - const [deviceId, setDeviceIdState] = useState(() => getSetupConfig().deviceId); + // The agent says who we are in /api/local/status. Until that answers the id + // is simply unknown; it is for diagnostics only, since the agent serves just + // its own board and the payload is fetched without it. + const [deviceId, setDeviceId] = useState(null); const [payload, setPayload] = useState(null); const [prayerInfo, setPrayerInfo] = useState(null); // { prayers, hijri } const [error, setError] = useState(null); + // The last payload fetch said the agent has no content yet. + const [noContent, setNoContent] = useState(false); const [lastPayloadAt, setLastPayloadAt] = useState(null); - // Offline mode only: the agent's /api/local/status, refreshed with the payload. + // The agent's /api/local/status, refreshed with the payload and every + // minute (see applyLocalStatus below). const [localStatus, setLocalStatus] = useState(null); const [realNow, setRealNow] = useState(new Date()); const [slideIdx, setSlideIdx] = useState(0); - // Setup modal force-opened by the operator hotkey. It is a diagnostics - // panel now — never a gate. An unpaired board waits, it does not prompt. + // Diagnostics panel, opened by the operator hotkey. Never a gate. const [setupOpen, setSetupOpen] = useState(false); const [debugOpen, setDebugOpen] = useState(false); const [debug, setDebug] = useState(NO_DEBUG); @@ -123,8 +173,6 @@ export default function App() { [realNow, debug.timeOffsetMs] ); - const needsSetup = !isValidDeviceId(deviceId); - // Force the cursor visible while either operator panel is open, otherwise // an operator on a touchscreen-less board can't aim at its own fields. Both // panels share one effect: as two, closing either would re-hide the cursor @@ -132,20 +180,7 @@ export default function App() { useEffect(() => { if (setupOpen || debugOpen) document.body.classList.remove('cursor-hidden'); else applyCursorPreference(); - }, [needsSetup, setupOpen, debugOpen]); - - // While unpaired, watch for the cookie appearing. The agent's normal path is - // to rewrite /etc/musallahboard/kiosk-url and let the systemd .path watcher - // bounce the kiosk, but it can also provision in-place over CDP — this is - // what makes that land without a reload. - useEffect(() => { - if (!needsSetup) return; - const id = setInterval(() => { - const current = getSetupConfig().deviceId; - if (isValidDeviceId(current)) setDeviceIdState(current); - }, 2000); - return () => clearInterval(id); - }, [needsSetup]); + }, [setupOpen, debugOpen]); // Operator hotkeys: Alt+Shift+F opens setup, Alt+Shift+D the debug drawer. // Matched on e.code too because Alt+Shift on some layouts yields a dead key @@ -171,22 +206,19 @@ export default function App() { return () => clearInterval(id); }, []); - // Reads identity from the cookie at call time rather than from the closure: - // the agent provisions and refreshes in a single evaluated expression, so - // setDeviceId() and refresh() run in the same tick — before React has - // re-rendered with the new id. Closing over `deviceId` would refetch with - // the old one (or refuse as unpaired) on exactly the call that matters. + // No device id is needed: the agent serves only its own board. The result + // is what window.MusallahBoard.refresh() resolves to for the agent. const loadPayload = useCallback(async () => { - const id = getSetupConfig().deviceId; - if (!isValidDeviceId(id)) return { ok: false, reason: 'unpaired' }; - // Alongside the payload, not after it: when the agent has no bundle the - // payload 503s, and that is exactly when the diagnostics need the status. - if (MODE === 'offline') getLocalStatus().then(setLocalStatus); + // Alongside the payload, not after it: when the agent has no content the + // payload 503s, and that is exactly when the waiting screen and the + // diagnostics need the status. + getLocalStatus().then((s) => applyLocalStatusRef.current(s)); try { - const p = await getBoardPayload(id); + const p = await getBoardPayload(); prefetchPayloadImages(p); setPayload(p); setError(null); + setNoContent(false); setLastPayloadAt(Date.now()); // Computed locally (adhan + Intl), recomputed on every payload load — // which is also what rolls the times over after midnight. @@ -195,15 +227,17 @@ export default function App() { } catch (e) { console.error('prayer computation failed', e); } - return { ok: true, deviceId: id, at: new Date().toISOString() }; + return { ok: true, at: new Date().toISOString() }; } catch (e) { - console.error('payload fetch failed', e); + // Not an error on a board that has simply never been given content: it + // gets its own screen instead of "Reconnecting…". + const empty = isNoContentError(e); + if (!empty) console.error('payload fetch failed', e); + setNoContent(empty); setError(e?.message || 'Unknown error'); - return { ok: false, deviceId: id, error: e?.message || 'Unknown error' }; + return { ok: false, error: e?.message || 'Unknown error' }; } - // deviceId is not read here, but a change to it must restart the polling - // effect below so a freshly-paired board fetches immediately. - }, [deviceId]); + }, []); useEffect(() => { loadPayload(); @@ -211,6 +245,48 @@ export default function App() { return () => clearInterval(id); }, [loadPayload]); + // --------------------------------------------------------------------- + // Agent status. Polled every minute (and with every payload load) + // so the waiting screen, the staleness note and the diagnostics stay current + // without depending on the event stream. It is also the backstop for events + // missed while the stream was down: + // - content appearing while we sit on the waiting screen loads it at once + // rather than at the next 10-minute payload poll; + // - a change of installed app version between two polls reloads the page. + // Compared poll to poll, never against APP_VERSION: a package whose + // signed version differs from the package.json it was built from would + // otherwise reload the board forever. + // Held in a ref so loadPayload (above) and the interval always call the + // version that sees current state. + // --------------------------------------------------------------------- + const seenAppVersion = useRef(undefined); + // + // Only the minute poll may trigger the content catch-up. loadPayload fetches + // the status too, and if it could also call back into loadPayload, an agent + // whose status and payload disagree would have the two chase each other. + const applyLocalStatus = (s, { fromPoll = false } = {}) => { + setLocalStatus(s); + if (!s) return; + if (isValidDeviceId(s.deviceId)) setDeviceId(s.deviceId.trim()); + const appVersion = s.app?.version ?? null; + if (seenAppVersion.current === undefined) seenAppVersion.current = appVersion; + else if (appVersion && appVersion !== seenAppVersion.current) { + window.location.reload(); + return; + } + if (fromPoll && noContent && s?.content) refreshRef.current(); + }; + const applyLocalStatusRef = useRef(applyLocalStatus); + useEffect(() => { applyLocalStatusRef.current = applyLocalStatus; }); + + useEffect(() => { + const id = setInterval( + () => getLocalStatus().then((s) => applyLocalStatusRef.current(s, { fromPoll: true })), + LOCAL_STATUS_REFRESH_MS + ); + return () => clearInterval(id); + }, []); + const tz = payload?.deviceConfig?.location?.timezone; // Compose the design data shape. @@ -272,15 +348,14 @@ export default function App() { // --------------------------------------------------------------------- // Agent-facing surface. // - // The device agent drives this page over the DevTools Protocol. Two paths - // provision a board, both implemented: - // • navigate to ?deviceId= (kiosk-url + .path watcher) - // • Runtime.evaluate: window.MusallahBoard.setDeviceId('') + // The device agent drives this page over the DevTools Protocol: its + // config.refresh command calls window.MusallahBoard.refresh(), and its + // telemetry reads getStatus(). // // The globals must always see current values, but they are registered once // so the agent never races a re-render and finds them missing. Hence the // latest-value refs: registering them in an effect that closed over the - // first render would refetch with a stale deviceId forever. + // first render would report stale state forever. // --------------------------------------------------------------------- const refreshRef = useRef(loadPayload); const statusRef = useRef(null); @@ -289,9 +364,10 @@ export default function App() { useEffect(() => { statusRef.current = () => ({ deviceId: deviceId ?? null, - paired: !needsSetup, - mode: MODE, - bundle: localStatus?.bundle ?? null, + paired: isValidDeviceId(deviceId), + appVersion: APP_VERSION, + content: localStatus?.content ?? null, + noContent, slideKey: slides[slideIdx]?.key ?? null, slideIndex: slides.length ? slideIdx : null, slideCount: slides.length, @@ -302,17 +378,6 @@ export default function App() { useEffect(() => { window.MusallahBoard = { - /** Provision (or re-point) this board. Rejects anything but a UUID. */ - setDeviceId(id, { reload = true } = {}) { - const next = String(id ?? '').trim(); - if (!isValidDeviceId(next)) { - throw new Error(`setDeviceId: not a UUID: ${JSON.stringify(id)}`); - } - saveDeviceId(next); - if (reload) window.location.reload(); - else setDeviceIdState(next); - return next; - }, /** Re-fetch the payload and repaint in place. No reload. */ refresh: () => refreshRef.current(), /** What this board thinks it is and what is currently on screen. */ @@ -324,28 +389,20 @@ export default function App() { /** Apply presentation overrides without the drawer. Partial patch. */ setDebug: (patch) => setDebug((d) => ({ ...d, ...(patch || {}) })), resetDebug: () => setDebug(NO_DEBUG), - getConfig: () => getSetupConfig(), - }; - // Alias for agent builds that predate MusallahBoard.refresh(). - window.__refreshBoard = () => window.MusallahBoard.refresh(); - return () => { - delete window.MusallahBoard; - delete window.__refreshBoard; }; + return () => { delete window.MusallahBoard; }; }, []); - // Live content push. Only opened once this board is enrolled: the channel - // carries enrolled devices only, and the backend closes anything that cannot - // name a known device. The cookie poll above re-runs this on enrollment. + // The agent listens on the backend's refresh channel itself and turns it + // into content syncs. The page only hears the outcome, from the agent's event + // stream: new content re-fetches in place, a new app release reloads into it. // Goes through the ref so it always calls the current loader. - // - // Offline there is no backend to push anything; new content arrives as a - // bundle install, which reloads the page or is picked up by the 10-minute - // poll. useEffect(() => { - if (MODE === 'offline' || !isValidDeviceId(deviceId)) return undefined; - return connectRefreshSocket(deviceId, () => refreshRef.current()); - }, [deviceId]); + return connectLocalEvents({ + onContent: () => refreshRef.current(), + onApp: () => window.location.reload(), + }); + }, []); // The drawer is reachable from every render path — a board stuck on the // loading screen is exactly when you want to force a theme or hold the deck. @@ -367,46 +424,26 @@ export default function App() { /> ); - // Unpaired: wait, never prompt. This is a wall-mounted screen with no - // keyboard and a hidden cursor — a form here is a dead end. The agent - // provisions us on its own schedule and the poll above picks it up. - if (needsSetup) { + // Loading / error screen: the operator hotkeys still work, so a stuck board + // can be diagnosed on the spot. + if (!data || !slides.length) { return ( <> - - {renderDebug()} - {setupOpen && ( - setSetupOpen(false)} - onComplete={(id) => { setSetupOpen(false); setDeviceIdState(id); }} + {noContent ? ( + + ) : ( + )} - - ); - } - - // Loading / error screen — still allow the operator hotkey to summon setup - // so a mis-provisioned board can be fixed on the spot. - if (!data || !slides.length) { - return ( - <> - {renderDebug()} {setupOpen && ( setSetupOpen(false)} - onComplete={(id) => { setSetupOpen(false); setDeviceIdState(id); }} + onClose={() => setSetupOpen(false)} /> )} @@ -481,8 +518,7 @@ export default function App() { setSetupOpen(false)} - onComplete={(id) => { setSetupOpen(false); setDeviceIdState(id); }} + onClose={() => setSetupOpen(false)} /> )} diff --git a/src/api/client.js b/src/api/client.js deleted file mode 100644 index 4c79700..0000000 --- a/src/api/client.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * ===================================================== - * API Client - * ===================================================== - * Typed client for the LensBridge backend. - * - * Paths, parameters and response shapes come from - * src/api/schema.d.ts, generated from - * LensBridgeBackend/openapi.yaml. Regenerate with - * `npm run api:generate`; never edit the schema by hand. - * - * What OpenAPI does not provide, and so is kept here: - * per-request timeouts, and ApiError carrying the HTTP - * status so payloadService can decide what to retry. - * ===================================================== - */ - -import createClient from 'openapi-fetch'; - -/** - * @typedef {Object} ApiClientConfig - * @property {string} baseUrl - Base URL prefixed to every request path - * @property {number} timeout - Request timeout in ms - * @property {Record} headers - Default headers - */ - -/** @type {ApiClientConfig} */ -const defaultConfig = { - // Empty => same-origin (dev proxy / reverse proxy in prod). Overridden by - // VITE_API_BASE_URL when the kiosk talks to the backend cross-origin. - baseUrl: import.meta.env.VITE_API_BASE_URL ?? '', - timeout: 30000, - headers: { 'Content-Type': 'application/json' }, -}; - -let globalConfig = { ...defaultConfig }; - -/** Custom API error carrying the HTTP status. */ -export class ApiError extends Error { - /** @param {string} message @param {number} status @param {any} [data] */ - constructor(message, status, data = null) { - super(message); - this.name = 'ApiError'; - this.status = status; - this.data = data; - } -} - -/** - * Applies the configured timeout to every request. - * - * A kiosk left on a dead network will otherwise hang on a fetch forever and - * never reach the retry logic in payloadService. - * @param {Request} request - */ -async function timeoutFetch(request) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), globalConfig.timeout); - try { - return await fetch(request, { signal: controller.signal }); - } catch (error) { - if (error?.name === 'AbortError') throw new ApiError('Request timeout', 408); - throw new ApiError(error?.message || 'Network error', 0); - } finally { - clearTimeout(timeoutId); - } -} - -/** @type {ReturnType>} */ -let client = createClient({ baseUrl: globalConfig.baseUrl, fetch: timeoutFetch }); - -/** @param {Partial} config */ -export function configureApiClient(config) { - globalConfig = { - ...globalConfig, - ...config, - headers: { ...globalConfig.headers, ...config.headers }, - }; - // baseUrl is fixed at construction, so rebuild when it changes. - client = createClient({ baseUrl: globalConfig.baseUrl, fetch: timeoutFetch }); -} - -/** @returns {ApiClientConfig} */ -export function getApiConfig() { - return { ...globalConfig }; -} - -/** The generated, type-checked client. */ -export function getClient() { - return client; -} - -/** - * Turn an openapi-fetch result into a value or an ApiError. - * - * openapi-fetch reports failures as a value; the retry logic in payloadService - * is built around exceptions carrying a status, so convert here rather than - * reshaping every caller. - * - * @template T - * @param {{ data?: T, error?: any, response: Response }} result - * @returns {T} - */ -export function unwrap({ data, error, response }) { - if (error !== undefined || !response.ok) { - throw new ApiError( - error?.message || `Request failed with status ${response.status}`, - response.status, - error ?? null - ); - } - return data; -} diff --git a/src/api/index.js b/src/api/index.js index 3b69158..11d7efe 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,5 +1,6 @@ -export { configureApiClient, getApiConfig, ApiError } from './client.js'; -export { getBoardPayload } from './payloadService.js'; +export { getBoardPayload, isNoContentError, ApiError } from './payloadService.js'; export { getPrayerData } from './prayerService.js'; -export { connectRefreshSocket } from './refreshSocket.js'; -export { getLocalStatus } from './localStatus.js'; +export { + getLocalStatus, isValidDeviceId, +} from './localStatus.js'; +export { connectLocalEvents } from './localEvents.js'; diff --git a/src/api/localEvents.js b/src/api/localEvents.js new file mode 100644 index 0000000..1c6ab2b --- /dev/null +++ b/src/api/localEvents.js @@ -0,0 +1,55 @@ +/** + * ===================================================== + * Local Events + * ===================================================== + * The agent announces installs on GET /api/local/events, + * a Server-Sent Events stream (agent/docs/architecture.md, + * section 7): + * + * event: content data: {"sequence": n} + * new content whose payloads differ from what was + * being served: re-fetch the payload in place. + * event: app data: {"version": "..."} + * a new board app release is installed: reload, so + * the new build is what runs. + * : ping every 25 s, keeps the stream alive. + * + * EventSource reconnects on its own after a dropped + * connection (an agent restart, say), so there is no + * backoff loop here. An event can still be missed while disconnected; the + * payload poll (10 min) and status poll (1 min) in + * App.jsx bound what that costs, and a re-opened stream + * triggers one catch-up refresh. + * ===================================================== + */ + +/** + * @param {{ onContent: () => unknown, onApp: () => unknown }} handlers + * @returns {() => void} disposer: closes the stream. Safe to call twice. + */ +export function connectLocalEvents({ onContent, onApp }) { + if (typeof EventSource === 'undefined') return () => {}; + + const source = new EventSource('/api/local/events'); + let opened = false; + + const run = (fn, what) => { + try { + Promise.resolve(fn()).catch((e) => console.error(`local events: ${what} failed`, e)); + } catch (e) { + console.error(`local events: ${what} threw`, e); + } + }; + + source.addEventListener('open', () => { + // The first open is the page starting up, which loads the payload anyway. + // A later one is a reconnect, and a `content` event may have been missed + // while the stream was down. + if (opened) run(onContent, 'catch-up refresh'); + opened = true; + }); + source.addEventListener('content', () => run(onContent, 'content refresh')); + source.addEventListener('app', () => run(onApp, 'app reload')); + + return () => source.close(); +} diff --git a/src/api/localStatus.js b/src/api/localStatus.js index b29e474..4a62f1d 100644 --- a/src/api/localStatus.js +++ b/src/api/localStatus.js @@ -1,37 +1,58 @@ /** * ===================================================== - * Local Status (offline mode only) + * Local Status * ===================================================== - * In offline mode the device agent serves this page, and - * the payload, from 127.0.0.1 off an installed content - * bundle. GET /api/local/status says which bundle and how - * fresh it is (agent/docs/offline.md, "Local HTTP - * server"). It is the agent's, not LensBridge's, so it is - * not in the OpenAPI schema: a plain same-origin fetch. + * The device agent serves this page, and the payload, + * from 127.0.0.1:8080 off an installed content package. GET /api/local/status says who this board is, + * what is installed and how fresh it is + * (agent/docs/architecture.md, section 7). It is the + * agent's, not LensBridge's, so it is not in the OpenAPI + * schema: a plain same-origin fetch. * - * { mode, deviceId, - * bundle: { firstDay, lastDay, generatedAt, timezone } | null, - * today, servingDay, daysRemaining, staleDays } + * { localApi, agentVersion, deviceId, + * app: { version } | null, + * content: { sequence, createdAt, firstDay, lastDay, + * timezone, source, installedAt } | null, + * today, servingDay, daysRemaining, staleDays, + * sync: { enabled, lastSuccessAt, lastAttemptAt, + * lastError }, + * update: { active } } * ===================================================== */ const TIMEOUT_MS = 5000; +/** + * @typedef {Object} LocalContent + * @property {string} firstDay + * @property {string} lastDay + * @property {string} [timezone] + * @property {number} [sequence] + * @property {string} [createdAt] + * @property {string} [source] sync | usb | upload | cli + * @property {string} [installedAt] + */ + /** * @typedef {Object} LocalStatus - * @property {string} mode + * @property {number} [localApi] + * @property {string} [agentVersion] * @property {string} [deviceId] - * @property {{firstDay:string,lastDay:string,generatedAt:string,timezone:string}|null} bundle + * @property {{version:string}|null} [app] + * @property {LocalContent|null} [content] * @property {string} [today] - * @property {string} [servingDay] - * @property {number} [daysRemaining] - * @property {number} [staleDays] + * @property {string|null} [servingDay] + * @property {number|null} [daysRemaining] + * @property {number|null} [staleDays] + * @property {{enabled:boolean,lastSuccessAt:string|null,lastAttemptAt:string|null,lastError:string|null}} [sync] + * @property {{active:boolean}} [update] + * @property {string} [error] installed content could not be read */ /** - * Fetch the agent's bundle status. Never throws: the status only feeds the - * staleness note and the diagnostics panel, and a board must keep showing its - * payload even if the agent is too old to answer this. + * Fetch the agent's status. Never throws: the status feeds the device id, the + * waiting screen, the staleness note and the diagnostics panel, and a board + * must keep showing its payload even if the agent cannot answer right now. * @returns {Promise} */ export async function getLocalStatus() { @@ -52,3 +73,15 @@ export async function getLocalStatus() { clearTimeout(timeoutId); } } + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Whether the agent's `deviceId` is a real device id (a UUID). An agent that + * is not enrolled yet has none, and the board then reports itself unpaired. + * @param {unknown} value + */ +export function isValidDeviceId(value) { + return typeof value === 'string' && UUID_RE.test(value.trim()); +} diff --git a/src/api/payloadService.js b/src/api/payloadService.js index f9688a0..9027a62 100644 --- a/src/api/payloadService.js +++ b/src/api/payloadService.js @@ -2,44 +2,110 @@ * ===================================================== * Payload Service * ===================================================== - * Fetches GET /api/musallah/payload?deviceId= and - * flattens it into the design data shape. Retries with - * jittered backoff on transient failures so a fleet of - * boards doesn't reconnect in lockstep. + * Fetches today's payload from the device agent that + * serves this page (same-origin GET /api/musallah/payload, + * agent/docs/architecture.md, section 7) and flattens it + * into the design data shape. Retries with jittered + * backoff on transient failures. + * + * The agent serves only its own board, so no deviceId is + * sent, and a 503 "no content installed" is a state, not + * an outage: see isNoContentError(). + * + * The path is the agent's, not the backend's, so it is + * not in the generated `paths`; the body is still the + * backend's MusallahBoardPayload schema component, which + * the agent serves verbatim from its content package. * ===================================================== */ -import { getClient, unwrap, ApiError } from './client.js'; import { normalizePayload } from '../models/index.js'; +/** @typedef {import('./schema').components['schemas']['MusallahBoardPayload']} MusallahBoardPayload */ + +const PAYLOAD_URL = '/api/musallah/payload'; +const TIMEOUT_MS = 30000; const RETRY_DELAYS_MS = [2000, 5000, 12000, 30000]; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +/** Error carrying the HTTP status (0 for a network failure). */ +export class ApiError extends Error { + /** @param {string} message @param {number} status @param {any} [data] */ + constructor(message, status, data = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.data = data; + } +} + +/** + * GET the raw payload once. Times out rather than hanging: a fetch that never + * settles would never reach the retry loop below. + * @returns {Promise} + */ +async function fetchPayload() { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + let res; + try { + res = await fetch(PAYLOAD_URL, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + } catch (error) { + if (error?.name === 'AbortError') throw new ApiError('Request timeout', 408); + throw new ApiError(error?.message || 'Network error', 0); + } finally { + clearTimeout(timeoutId); + } + + // The agent's errors are all {"message": "..."} (section 7). + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new ApiError( + body?.message || `Request failed with status ${res.status}`, + res.status, + body + ); + } + return /** @type {MusallahBoardPayload} */ (body); +} + +/** + * The agent's answer when no content package is installed yet + * (agent/docs/architecture.md, section 7). Retrying it a few seconds later only + * delays the "Waiting for content" screen: content arrives by sync, USB stick + * or upload, and the agent announces it on /api/local/events. + * @param {unknown} error + */ +export function isNoContentError(error) { + return ( + error instanceof ApiError && + error.status === 503 && + /no content/i.test(error.message || '') + ); +} + /** 429 / 5xx / network errors are worth retrying; other 4xx are not. */ function isTransient(error) { if (!(error instanceof ApiError)) return false; + if (isNoContentError(error)) return false; const { status } = error; return status === 429 || status === 0 || status >= 500; } /** - * Fetch and normalize the board payload for a device. - * @param {string} deviceId device UUID (from enrollment / setup) + * Fetch and normalize this board's payload. * @returns {Promise>} */ -export async function getBoardPayload(deviceId) { - if (!deviceId) throw new ApiError('Missing deviceId', 400); - +export async function getBoardPayload() { let lastError = null; for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { try { - const payload = unwrap( - await getClient().GET('/api/musallah/payload', { - params: { query: { deviceId } }, - }) - ); - return normalizePayload(payload); + return normalizePayload(await fetchPayload()); } catch (error) { lastError = error; if (!isTransient(error) || attempt === RETRY_DELAYS_MS.length) break; diff --git a/src/api/payloadService.test.js b/src/api/payloadService.test.js new file mode 100644 index 0000000..44b1d4c --- /dev/null +++ b/src/api/payloadService.test.js @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getBoardPayload, isNoContentError, ApiError } from './payloadService.js'; + +// The payload is a plain same-origin fetch to the agent, not the generated +// client, so its error handling is this module's own. +describe('getBoardPayload', () => { + afterEach(() => vi.unstubAllGlobals()); + + const respond = (status, body) => + vi.fn(async (_url, _init) => new Response(JSON.stringify(body), { + status, headers: { 'Content-Type': 'application/json' }, + })); + + it('fetches the agent payload same-origin, with no device id', async () => { + const fetch = respond(200, { deviceConfig: {}, frames: [] }); + vi.stubGlobal('fetch', fetch); + await getBoardPayload(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][0]).toBe('/api/musallah/payload'); + }); + + it('reports "no content installed" once, without retrying', async () => { + const fetch = respond(503, { message: 'no content installed' }); + vi.stubGlobal('fetch', fetch); + const error = await getBoardPayload().catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect(error.status).toBe(503); + expect(isNoContentError(error)).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not retry a 404', async () => { + const fetch = respond(404, { message: 'not found' }); + vi.stubGlobal('fetch', fetch); + const error = await getBoardPayload().catch((e) => e); + expect(error.status).toBe(404); + expect(isNoContentError(error)).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/api/prayerService.js b/src/api/prayerService.js index 46f8642..fafe28e 100644 --- a/src/api/prayerService.js +++ b/src/api/prayerService.js @@ -8,8 +8,8 @@ * location + calculation method (DeviceConfig.location). * * This used to call the Aladhan API. It is computed - * locally now so a board with no internet (offline mode, - * see agent/docs/offline.md) still shows correct times + * locally now so a board with no internet (served by its agent, + * see agent/docs/architecture.md) still shows correct times * indefinitely — and so an online board has one fewer * third party that can blank its prayer rail. * ===================================================== diff --git a/src/api/refreshSocket.js b/src/api/refreshSocket.js deleted file mode 100644 index 159164b..0000000 --- a/src/api/refreshSocket.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * ===================================================== - * Refresh Socket - * ===================================================== - * Listens on the backend's board-refresh channel and asks the app to re-fetch - * in place. - * - * The backend (`BoardStreamHandler`) pushes JSON: - * - * { type: "refresh", reason: "posters"|"events"|"weekly-content"|"config"|"manual-refresh", - * deviceId: , at: } - * - * Content edits fan out to every board; `reason: "config"` is addressed to one. - * `?deviceId=` is mandatory — the channel carries enrolled devices only and the - * backend closes a connection that cannot name a known, unrevoked one. Older - * backends broadcast the bare string "REFRESH", so the matcher still accepts - * anything containing that word. - * - * We deliberately soft-refresh rather than `location.reload()`: a reload on a - * kiosk means a visible white flash, a lost slide position, and a fresh round - * of font/image fetches on a screen people are looking at. - * ===================================================== - */ - -import { getApiConfig } from './client.js'; - -const BASE_MS = 1000; -const MAX_MS = 60000; - -/** Derive the ws(s):// endpoint from the configured API base. */ -function socketUrl(deviceId) { - const base = getApiConfig().baseUrl || window.location.origin; - const origin = base.replace(/\/$/, '').replace(/^http/, 'ws'); - return `${origin}/api/refresh-musallahboard?deviceId=${encodeURIComponent(deviceId)}`; -} - -/** - * Decide whether a frame is a refresh for us. - * @returns {string|null} the reason, or null to ignore the frame - */ -function refreshReason(text) { - try { - const msg = JSON.parse(text); - if (msg?.type !== 'refresh') return null; - return msg.reason || 'unknown'; - } catch { - // Legacy bare-string broadcast. - return text.toUpperCase().includes('REFRESH') ? 'legacy' : null; - } -} - -/** - * Open the refresh channel. - * - * @param {string} deviceId this board's enrolled device UUID. Required: an - * unenrolled board has nothing to refetch, and the backend would reject it. - * @param {(reason: string) => unknown} onRefresh called when the backend asks - * us to re-fetch - * @returns {() => void} disposer — closes the socket and cancels any pending - * reconnect. Safe to call more than once. - */ -export function connectRefreshSocket(deviceId, onRefresh) { - if (!deviceId) throw new Error('connectRefreshSocket: deviceId is required'); - - let socket = null; - let timer = null; - let attempts = 0; - let closed = false; - - const scheduleReconnect = () => { - if (closed || timer) return; - // Full jitter over an exponential window: a fleet of boards coming back - // from a backend restart must not reconnect in lockstep. - const window_ = Math.min(MAX_MS, BASE_MS * 2 ** attempts); - const delay = Math.floor(Math.random() * window_); - attempts += 1; - timer = setTimeout(() => { - timer = null; - open(); - }, delay); - }; - - function open() { - if (closed) return; - let ws; - try { - ws = new WebSocket(socketUrl(deviceId)); - } catch (e) { - console.warn('refresh socket: could not open', e); - scheduleReconnect(); - return; - } - socket = ws; - - ws.onopen = () => { attempts = 0; }; - - ws.onmessage = (event) => { - const text = typeof event.data === 'string' ? event.data : ''; - const reason = refreshReason(text); - if (!reason) return; - try { - Promise.resolve(onRefresh(reason)).catch((e) => - console.error('refresh socket: refresh failed', e) - ); - } catch (e) { - console.error('refresh socket: refresh threw', e); - } - }; - - // No onerror handler beyond logging: an error is always followed by a - // close, and reconnecting from both would double-schedule. - ws.onerror = () => { /* close handler owns recovery */ }; - ws.onclose = () => { - if (socket === ws) socket = null; - scheduleReconnect(); - }; - } - - open(); - - return () => { - closed = true; - if (timer) { clearTimeout(timer); timer = null; } - if (socket) { - // Drop the handler first so our own close doesn't queue a reconnect. - socket.onclose = null; - socket.close(); - socket = null; - } - }; -} diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index ea1c932..a55b5ce 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -223,6 +223,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/board/devices/{deviceId}/offline-bundle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download a signed content package for a board + * @description A signed .mbu content package (format version 2) holding one fully assembled payload per day, starting today in the device's timezone, plus every poster image those payloads reference. Taken to the board by USB stick, laptop or phone. Requires board:device:read. + */ + get: operations["downloadOfflineBundle"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/board/devices/{deviceId}/revoke": { parameters: { query?: never; @@ -786,6 +806,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/content-bundle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fetch this board's signed content package (online sync) + * @description Device-authenticated with the X-MB-* headers (Ed25519 signature by the enrolled device key over musallahboard-http-v1, method, path, device id, timestamp and the body's SHA-256). Returns a signed .mbu content package starting today in the device's timezone. Media whose SHA-256 is in haveMedia is listed and signed in mbu.json but left out of the zip. + */ + post: operations["downloadContentBundle"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/agent/enroll": { parameters: { query?: never; @@ -797,7 +837,7 @@ export interface paths { put?: never; /** * Exchange a one-time enrollment token for a device identity - * @description Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. + * @description Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. */ post: operations["enrollAgent"]; delete?: never; @@ -806,6 +846,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/signing-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public content signing keys boards should trust + * @description Current key first, then previous keys still valid during a rotation. Empty when the server has no content signing key. Used by `musallahboard-agent trust fetch` on boards enrolled before signed content existed. + */ + get: operations["getSigningKeys"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/weather": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Current weather for this board + * @description Device-authenticated with the X-MB-* headers, like the content bundle; the signed body hash is the SHA-256 of the empty string. `weather` is the OpenWeatherMap current weather JSON exactly as the server last fetched it, or null when the server has none. `fetchedAt` is when the server answered. + */ + get: operations["getAgentWeather"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/change-password": { parameters: { query?: never; @@ -1127,22 +1207,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/musallah/payload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getBoardPayload"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/musallah/weekly-content": { parameters: { query?: never; @@ -1370,10 +1434,21 @@ export interface components { token: string; }; AgentEnrollResponse: { + /** @description Public content signing keys the agent pins at enrollment. Always present; empty when the server has no content key configured. */ + contentSigningKeys: components["schemas"]["SigningKeyView"][]; /** Format: uuid */ deviceId?: string; websocketUrl?: string; }; + AgentWeatherResponse: { + /** + * Format: date-time + * @example 2026-09-24T14:05:00Z + */ + fetchedAt: string; + /** @description OpenWeatherMap current weather JSON, verbatim; null when unavailable */ + weather: components["schemas"]["JsonNode"]; + }; AuditEventDto: { /** @enum {string} */ action?: "APPROVE_UPLOAD" | "UNAPPROVE_UPLOAD" | "DELETE_UPLOAD" | "FEATURE_UPLOAD" | "UNFEATURE_UPLOAD" | "CREATE_EVENT" | "UPDATE_EVENT" | "DELETE_EVENT" | "CREATE_CALENDAR_EVENT" | "UPDATE_CALENDAR_EVENT" | "DELETE_CALENDAR_EVENT" | "CREATE_POSTER" | "UPDATE_POSTER" | "DELETE_POSTER" | "CREATE_SOCIAL" | "UPDATE_SOCIAL" | "DELETE_SOCIAL" | "SAVE_WEEKLY_CONTENT" | "DELETE_WEEKLY_CONTENT" | "UPDATE_BOARD_CONFIG" | "UPDATE_BOARD_TICKER" | "REFRESH_BOARDS" | "ISSUE_ENROLLMENT_TOKEN" | "REVOKE_DEVICE" | "ISSUE_DEVICE_COMMAND" | "PROMOTE_USER" | "DEMOTE_USER" | "DISABLE_USER" | "ENABLE_USER" | "VIEW_AUDIT_LOGS" | "EXPORT_DATA" | "SYSTEM_MAINTENANCE" | "VERIFY_USER" | "UNVERIFY_USER" | "RESET_USER_PASSWORD" | "TRIGGER_PASSWORD_RESET_EMAIL" | "ADD_USER_ROLE" | "REMOVE_USER_ROLE" | "GRANT_PERMISSION" | "REVOKE_PERMISSION" | "ADD_USER" | "REMOVE_USER" | "UPDATE_USER"; @@ -1451,6 +1526,14 @@ export interface components { /** @enum {string} */ status?: "PENDING" | "DELIVERED" | "ACKED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "TIMEOUT" | "REJECTED" | "EXPIRED"; }; + ContentBundleRequest: { + /** + * Format: int32 + * @default 7 + */ + days: number; + haveMedia?: string[]; + }; CreateCalendarEventRequest: { allDay?: boolean; /** @enum {string} */ @@ -1675,6 +1758,7 @@ export interface components { MessageResponse: { message?: string; }; + /** @description Format of each payloads/.json file inside a signed content package (.mbu). Not returned by any endpoint; boards read it from installed packages. weather is always null here: boards fetch current weather from GET /api/agent/weather. */ MusallahBoardPayload: { deviceConfig?: components["schemas"]["DeviceConfig"]; frames?: components["schemas"]["FrameDefinition"][]; @@ -1876,6 +1960,14 @@ export interface components { name?: string; permissions?: string[]; }; + SigningKeyView: { + /** @example 0123456789abcdef */ + keyId: string; + publicKey: string; + }; + SigningKeysResponse: { + content: components["schemas"]["SigningKeyView"][]; + }; SignupRequest: { /** Format: email */ email: string; @@ -2657,6 +2749,87 @@ export interface operations { }; }; }; + downloadOfflineBundle: { + parameters: { + query?: { + /** @description Number of days in the bundle, starting today in the device's timezone */ + days?: number; + }; + header?: never; + path: { + deviceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The package */ + 200: { + headers: { + /** @description attachment; filename="musallahboard-content--.mbu" */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "application/vnd.musallahboard.mbu": string; + }; + }; + /** @description days outside 1-31 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description No device with that id */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Device is revoked */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description A poster image could not be fetched; the message names the poster */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description No content signing key is configured on the server */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; revokeDevice: { parameters: { query?: never; @@ -4155,6 +4328,84 @@ export interface operations { }; }; }; + downloadContentBundle: { + parameters: { + query?: never; + header: { + /** @description Enrolled device id */ + "X-MB-Device-Id": string; + /** @description Unix milliseconds; must be within 5 minutes of the server clock */ + "X-MB-Timestamp": number; + /** @description Base64 Ed25519 signature by the device key */ + "X-MB-Signature": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ContentBundleRequest"]; + }; + }; + responses: { + /** @description The signed content package */ + 200: { + headers: { + /** @description attachment; filename="musallahboard-content--.mbu" */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "application/vnd.musallahboard.mbu": string; + }; + }; + /** @description Malformed body, days outside 1-31, or a bad haveMedia entry */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Missing or bad device signature, clock skew over 5 minutes, or an unknown or revoked device */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description A poster image could not be fetched; the message names the poster */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description No content signing key is configured on the server */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; enrollAgent: { parameters: { query?: never; @@ -4206,6 +4457,80 @@ export interface operations { }; }; }; + getSigningKeys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SigningKeysResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; + getAgentWeather: { + parameters: { + query?: never; + header: { + /** @description Enrolled device id */ + "X-MB-Device-Id": string; + /** @description Unix milliseconds; must be within 5 minutes of the server clock */ + "X-MB-Timestamp": number; + /** @description Base64 Ed25519 signature by the device key */ + "X-MB-Signature": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current weather, or null weather when unavailable */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentWeatherResponse"]; + }; + }; + /** @description Missing or bad device signature, clock skew over 5 minutes, or an unknown or revoked device */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; changePassword: { parameters: { query?: never; @@ -4896,37 +5221,6 @@ export interface operations { }; }; }; - getBoardPayload: { - parameters: { - query: { - deviceId: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MusallahBoardPayload"]; - }; - }; - /** @description Request failed; body carries a human-readable message */ - default: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageResponse"]; - }; - }; - }; - }; getCurrentWeeklyContent: { parameters: { query?: never; diff --git a/src/components/SetupModal.jsx b/src/components/SetupModal.jsx index bfb9fd1..62bd3d2 100644 --- a/src/components/SetupModal.jsx +++ b/src/components/SetupModal.jsx @@ -1,9 +1,7 @@ // Operator diagnostics panel. Opened by Alt+Shift+F or `window.MusallahBoard.openSetup()`. Sibling to DebugMenu.jsx (Alt+Shift+D) import { useState, useEffect } from 'react'; -import { - saveSetupConfig, getSetupConfig, isValidDeviceId, readBoardMode, -} from '../utils/cookies.js'; -import { getApiConfig } from '../api/index.js'; +import { getHideCursor, setHideCursor as saveHideCursor } from '../utils/cookies.js'; +import { APP_VERSION } from '../version.js'; function Row({ label, value }) { return ( @@ -14,55 +12,64 @@ function Row({ label, value }) { ); } -/** Offline-mode rows, from the agent's /api/local/status (may be null). */ -function OfflineRows({ local }) { - const b = local?.bundle; +/** Rows from the agent's /api/local/status (may be null). */ +function LocalRows({ local }) { + if (!local) return ; + const c = local?.content; + const sync = local.sync; let remaining = null; - if (local && b) { + if (c) { remaining = local.staleDays > 0 ? `0 — content ran out ${local.staleDays} day${local.staleDays === 1 ? '' : 's'} ago` : String(local.daysRemaining ?? '—'); } + // The version the agent installed, next to the one actually running: they + // differ for the moment between an app install and the reload it triggers, + // and a board stuck in that state is worth seeing. + const installed = local.app?.version; return ( <> - + + + {c && } + {c && } + + {sync?.lastError && } ); } -export default function SetupModal({ onComplete, onCancel, status, localStatus }) { - const existing = getSetupConfig(); - const [deviceId, setDeviceId] = useState(existing.deviceId || ''); - const [hideCursor, setHideCursor] = useState(existing.hideCursor); - - const trimmed = deviceId.trim(); - const valid = isValidDeviceId(trimmed); - const idDirty = trimmed !== (existing.deviceId || ''); - const cursorDirty = hideCursor !== existing.hideCursor; - const canApply = (idDirty ? valid : true) && (idDirty || cursorDirty); +export default function SetupModal({ onClose, status, localStatus }) { + const savedHideCursor = getHideCursor(); + const [hideCursor, setHideCursor] = useState(savedHideCursor); + const canApply = hideCursor !== savedHideCursor; useEffect(() => { const onKey = (e) => { - if (e.key === 'Escape') { e.preventDefault(); onCancel?.(); } + if (e.key === 'Escape') { e.preventDefault(); onClose?.(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [onCancel]); + }, [onClose]); function submit(e) { e.preventDefault(); if (!canApply) return; - saveSetupConfig({ deviceId: idDirty && valid ? trimmed : null, hideCursor }); - onComplete?.(idDirty && valid ? trimmed : existing.deviceId); + saveHideCursor(hideCursor); + onClose?.(); } - const backend = getApiConfig().baseUrl || `${window.location.origin} (same-origin)`; - const mode = readBoardMode(); - return (
@@ -70,34 +77,15 @@ export default function SetupModal({ onComplete, onCancel, status, localStatus }
UTM MSA · MusallahBoard kiosk
- - - - {mode === 'offline' && } + + + + {status?.error && }
-
- - setDeviceId(e.target.value)} - placeholder="00000000-0000-0000-0000-000000000000" - autoFocus - /> - {idDirty && trimmed && !valid && ( -
Must be a UUID.
- )} -
- Normally set by the device agent. Change this only to re-point a - board by hand. -
-
-
- diff --git a/src/main.jsx b/src/main.jsx index e954609..53f7ad0 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,8 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.jsx'; -import { resolveDeviceId, readBoardMode } from './utils/cookies.js'; -import { configureApiClient } from './api/index.js'; // Fonts are bundled, not loaded from Google Fonts: an offline board has no // route to it, and an online one shouldn't blank its type when Google is slow. // Weights match what styles.css uses; each file carries every subset (Amiri's @@ -21,14 +19,6 @@ import '@fontsource/amiri/400.css'; import '@fontsource/amiri/700.css'; import './styles.css'; -// Settle identity before the first render so a board provisioned via -// `?deviceId=` never paints its unpaired screen. See resolveDeviceId(). -resolveDeviceId(); -// Offline, the agent on 127.0.0.1 serves the payload itself. The build may -// carry VITE_API_BASE_URL for the online deployment; ignore it here, since -// that backend is by definition unreachable. -if (readBoardMode() === 'offline') configureApiClient({ baseUrl: '' }); - ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/src/models/index.js b/src/models/index.js index 3c97451..628bd52 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -13,7 +13,7 @@ // Wire contract (authoritative — from /v3/api-docs) // --------------------------------------------------------------------------- // -// GET /api/musallah/payload?deviceId= -> MusallahBoardPayload +// GET /api/musallah/payload (served by the agent) -> MusallahBoardPayload // { deviceConfig: DeviceConfig, frames: FrameDefinition[] } // // DeviceConfig { diff --git a/src/styles.css b/src/styles.css index 016f909..0b8a1df 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1464,6 +1464,19 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; } margin-top: 18px; font-size: 12px; color: oklch(0.72 0.13 30); word-break: break-word; text-transform: none; letter-spacing: 0.02em; } +.board-status .bs-hint { + margin-top: 28px; font-size: 15px; line-height: 1.6; + color: oklch(0.96 0.01 95); +} +/* Screens someone has to act on from across the room (waiting for content). + Same look as the loading screen, sized for distance rather than a glance + from up close. Not scaled with the stage: this sits outside it, so these are + real pixels on a 1080p panel. */ +.board-status.bs-large .bs-inner { max-width: 1280px; } +.board-status.bs-large h1 { font-size: 72px; margin-bottom: 28px; } +.board-status.bs-large p { font-size: 28px; letter-spacing: 0.12em; } +.board-status.bs-large .bs-err { margin-top: 24px; font-size: 24px; } +.board-status.bs-large .bs-hint { margin-top: 48px; font-size: 32px; } .setup-modal { position: fixed; inset: 0; z-index: 50; @@ -1511,25 +1524,6 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; } overflow-wrap: anywhere; } -.setup-hint { - font-family: var(--f-sans); font-size: 12px; line-height: 1.45; - color: oklch(0.68 0.03 95); -} -.setup-hint-bad { color: oklch(0.72 0.15 30); } - -.setup-field { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; } -.setup-field label { - font-family: var(--f-mono); font-size: 11px; letter-spacing: 0.2em; - text-transform: uppercase; color: oklch(0.85 0.04 95); -} -.setup-field input[type="text"], -.setup-field input[type="password"] { - background: oklch(0.20 0.08 258); - border: 1px solid oklch(0.38 0.07 258); - border-radius: 8px; padding: 12px 14px; - color: oklch(0.96 0.01 95); font-family: var(--f-mono); font-size: 14px; -} -.setup-field input:focus { outline: 2px solid oklch(0.91 0.16 95); } .setup-row { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; } .setup-row label { font-family: var(--f-sans); font-size: 14px; color: oklch(0.85 0.04 95); } .setup-card button { diff --git a/src/utils/cookies.js b/src/utils/cookies.js index 92bcdad..28bb25d 100644 --- a/src/utils/cookies.js +++ b/src/utils/cookies.js @@ -1,7 +1,7 @@ /** - * Cookie helpers + kiosk setup persistence. - * The board identifies itself to the backend by `deviceId` - * (replaces the old per-location `boardLocation`). + * Kiosk preferences, kept in a cookie on the agent's origin (127.0.0.1:8080). + * Only the cursor preference lives here: who the board is comes from the + * agent's /api/local/status, not from anything the page stores. */ export function setCookie(name, value, days = 365) { @@ -21,115 +21,18 @@ export function getCookie(name) { return null; } -/** @returns {{deviceId:string|null, hideCursor:boolean}} */ -export function getSetupConfig() { - const hideRaw = getCookie('hideCursor'); - return { - deviceId: getCookie('deviceId'), - // Kiosks are the common case → default to a hidden cursor. - hideCursor: hideRaw === null ? true : hideRaw === 'true', - }; +/** @returns {boolean} */ +export function getHideCursor() { + const raw = getCookie('hideCursor'); + // Kiosks are the common case → default to a hidden cursor. + return raw === null ? true : raw === 'true'; } -export function saveSetupConfig({ deviceId, hideCursor }) { - if (deviceId) setCookie('deviceId', deviceId); - if (typeof hideCursor === 'boolean') setCookie('hideCursor', String(hideCursor)); -} - -export function isSetupComplete() { - return isValidDeviceId(getCookie('deviceId')); -} - -/** - * Persist just the device id. Used by the headless provisioning path: the - * device agent drives Chromium over the DevTools Protocol and calls - * `window.MusallahBoard.setDeviceId(...)` (or sets the `deviceId` cookie / - * `?deviceId=` query param) so the kiosk boots without any human touching - * the setup screen. - * @param {string} deviceId - */ -export function setDeviceId(deviceId) { - if (deviceId) setCookie('deviceId', String(deviceId).trim()); -} - -/** Read a deviceId provided via the URL (`?deviceId=...`), if any. */ -export function getDeviceIdFromQuery() { - try { - const v = new URLSearchParams(window.location.search).get('deviceId'); - return v ? v.trim() : null; - } catch { - return null; - } -} - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** The backend keys every board read on a UUID, so anything else is garbage. */ -export function isValidDeviceId(value) { - return typeof value === 'string' && UUID_RE.test(value.trim()); -} - -/** - * Settle the board's identity and return it, or null if unpaired. - * - * Must run BEFORE the first React render. The agent provisions a board by - * navigating to `?deviceId=`; if that reconciliation happens - * in an effect it lands after paint, and the board flashes its unpaired screen - * on every healthy cold boot. - * - * A malformed id is dropped rather than persisted — storing it would leave the - * board 404ing against the backend forever with no way to tell why. - */ -export function resolveDeviceId() { - const fromQuery = getDeviceIdFromQuery(); - - if (fromQuery !== null) { - // Strip the param either way: valid ones are now in the cookie, and a bad - // one shouldn't linger in the address bar or survive a reload. - try { - const u = new URL(window.location.href); - u.searchParams.delete('deviceId'); - window.history.replaceState({}, '', u); - } catch { /* non-fatal */ } - - if (isValidDeviceId(fromQuery)) { - setDeviceId(fromQuery); - return fromQuery.trim(); - } - console.warn(`Ignoring malformed deviceId in URL: ${fromQuery}`); - } - - const stored = getCookie('deviceId'); - return isValidDeviceId(stored) ? stored.trim() : null; -} - -let boardMode = null; - -/** - * 'offline' when the page was opened with `?mode=offline`, else 'online'. - * - * The agent sets this in kiosk-url when it serves the board itself from - * 127.0.0.1 off an installed content bundle (agent/docs/offline.md): no refresh - * socket, plus /api/local/status for bundle freshness. Read once and - * remembered — the mode is fixed for the life of the page, and a later - * history.replaceState() must not flip it. Deliberately not a cookie: a board - * switched back to online gets a kiosk-url without the param and must not stay - * offline. - * @returns {'online'|'offline'} - */ -export function readBoardMode() { - if (boardMode === null) { - try { - const v = new URLSearchParams(window.location.search).get('mode'); - boardMode = v && v.trim().toLowerCase() === 'offline' ? 'offline' : 'online'; - } catch { - boardMode = 'online'; - } - } - return boardMode; +/** @param {boolean} hideCursor */ +export function setHideCursor(hideCursor) { + setCookie('hideCursor', String(Boolean(hideCursor))); } export function applyCursorPreference() { - document.body.classList.toggle('cursor-hidden', getSetupConfig().hideCursor); + document.body.classList.toggle('cursor-hidden', getHideCursor()); } diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..65fd096 --- /dev/null +++ b/src/version.js @@ -0,0 +1,9 @@ +/** + * This build's own version, from package.json via Vite's `define` (see + * vite.config.js). Embedded rather than read from the agent so the diagnostics + * can tell "the agent installed 2.1.0" apart from "the page actually running + * is 2.1.0". + */ +/* global __MB_APP_VERSION__ */ +export const APP_VERSION = + typeof __MB_APP_VERSION__ === 'string' ? __MB_APP_VERSION__ : 'dev'; diff --git a/vite.config.js b/vite.config.js index 673d282..54022c4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,36 +1,46 @@ +import { readFileSync } from 'node:fs' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// The kiosk talks to the LensBridge backend. In dev we proxy /api to the -// local Spring app so the browser stays same-origin (no CORS). In prod the -// backend origin is injected via VITE_API_BASE_URL at build time; when unset -// the app falls back to same-origin /api (reverse-proxied in deployment). -const BACKEND = process.env.VITE_DEV_BACKEND || 'http://localhost:8080' +// The board app only ever runs served by the device agent at 127.0.0.1:8080, +// which answers /api (payload, status, SSE events) and /media same-origin. In +// dev we proxy both to an agent so the page stays same-origin there too. Point +// it at a real board with `ssh -L 8080:127.0.0.1:8080 ` (the default), +// or set VITE_DEV_AGENT to another agent. +const AGENT = process.env.VITE_DEV_AGENT || 'http://127.0.0.1:8080' -import { cloudflare } from "@cloudflare/vite-plugin"; +// Baked into the bundle as the build's own version (src/version.js). It is the +// same number scripts/package-mbu.mjs signs into the app package, so the +// diagnostics can show which build is really running. +const { version: APP_VERSION } = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf8') +) export default defineConfig({ - plugins: [cloudflare(), react()], + plugins: [react()], + define: { + __MB_APP_VERSION__: JSON.stringify(APP_VERSION), + }, server: { host: '0.0.0.0', port: 3000, open: true, proxy: { - // ws: the refresh channel upgrades under /api too, and without this the - // upgrade isn't forwarded — the socket reconnect-loops silently in dev - // while every HTTP call works fine. - '/api': { target: BACKEND, changeOrigin: true, ws: true }, + // /api/local/events is Server-Sent Events: a plain long-lived HTTP + // response, which the proxy streams through as it arrives. + '/api': { target: AGENT, changeOrigin: true }, + '/media': { target: AGENT, changeOrigin: true }, }, }, preview: { host: '0.0.0.0', port: 3000, }, - // Vitest reads this config, so the React plugin and the aliases above apply - // to tests too — a component test compiles exactly like the bundle does. + // Vitest reads this config, so the React plugin applies to tests too: a + // component test compiles exactly like the bundle does. test: { environment: 'jsdom', globals: true, - include: ['src/**/*.test.{js,jsx}'], + include: ['src/**/*.test.{js,jsx}', 'scripts/**/*.test.mjs'], }, }) diff --git a/wrangler.jsonc b/wrangler.jsonc deleted file mode 100644 index 273e610..0000000 --- a/wrangler.jsonc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "musallahboard", - "compatibility_date": "2026-06-17", - "observability": { - "enabled": true - }, - "assets": { - "directory": "./dist", - "not_found_handling": "single-page-application" - }, - "compatibility_flags": [ - "nodejs_compat" - ] -}