diff --git a/README.md b/README.md
index 2bdd054e..4bc47e79 100644
--- a/README.md
+++ b/README.md
@@ -488,6 +488,38 @@ bun run tauri:build # builds the app — needs Rust, the Tauri CLI and webvie
---
+## Security controls
+
+**Bundle integrity.** Every bundle ships with a SHA-384 hash in `public/dx-maps/manifest.json`
+(`bundles[].integrity`), and every copy-paste script snippet (`profiles[].embed.script`) already
+uses it:
+
+```html
+
+```
+
+If the file on the host is changed by anyone, even by one byte, the browser refuses to run it.
+`crossorigin="anonymous"` is required for the check to work across sites, so the host must send
+`Access-Control-Allow-Origin: *`. Copy the snippet again after each release, because the hash changes
+with the bundle. `bun run check:sri` keeps the manifest and the built pages in step.
+
+
+The backend in the cloud container (v1, experimental)
+
+- **Locked by default.** A new PocketBase collection starts with admin-only rules; you open it
+ deliberately, collection by collection.
+- **Dashboards hidden.** The production instance exposes only its `/api/`; its admin dashboard is
+ blocked at the proxy. The development dashboard is open only to the admin group.
+- **No server-side code.** PocketBase's JavaScript hooks are switched off in both instances.
+- **Not built yet:** per-plugin locks between plugin authors (who may edit which plugin's backend)
+ and isolated edge functions. Until they exist, everyone with dev access is trusted with every
+ plugin's backend.
+
+
+
+---
+
## Languages
**Every map speaks 11 languages** — add `?lang=` to a map's page address, or, when a map is embedded in
diff --git a/deployment/cloud/Dockerfile b/deployment/cloud/Dockerfile
index 651c1a81..519b6744 100644
--- a/deployment/cloud/Dockerfile
+++ b/deployment/cloud/Dockerfile
@@ -7,6 +7,9 @@ FROM oven/bun:1-debian
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates curl unzip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=oauth2proxy /bin/oauth2-proxy /usr/local/bin/oauth2-proxy
+# Go, to build Rudder's local engine (backend/rudder-engine, go 1.22) when RUDDER_ENGINE=1.
+COPY --from=golang:1.22-bookworm /usr/local/go /usr/local/go
+ENV PATH="/usr/local/go/bin:${PATH}"
# PocketBase, pinned and checked: the backend for plugins (a dev and a prod instance, see entry.sh).
ARG PB_VERSION=0.40.4
ARG PB_SHA256=9042ec818570e79c3628dadcd0a756c1496d9e1173918ec409d133c02f82e5fa
diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md
index 6b6ea35b..e5f0c1d3 100644
--- a/deployment/cloud/README.md
+++ b/deployment/cloud/README.md
@@ -67,7 +67,9 @@ there. Only then does it swap the new build in.
| `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | PocketBase's superuser, set on start in both instances |
| `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https:///api-prod`) |
| `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) |
+| `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its model, ~742 MB, downloads once to `RUDDER_HOME`, default `/data/rudder`). Give the container at least 2 vCPUs, or transcription falls behind speech |
+| `RUDDER_ALLOW_HOST` | the public host the engine accepts (it refuses others); defaults to the host in `OIDC_REDIRECT_URL` |
Mount a persistent volume at **`/data`** (PocketBase data and published builds). Memory measured on
the running container: about 0.45 GB idle, up to about 1.5 GB more during a build, so give it
-**2.5 GB**.
+**2.5 GB** (**3.5 GB** with `RUDDER_ENGINE=1`).
diff --git a/deployment/cloud/entry.sh b/deployment/cloud/entry.sh
index ead83270..7f2e64ec 100755
--- a/deployment/cloud/entry.sh
+++ b/deployment/cloud/entry.sh
@@ -15,8 +15,14 @@ bun install --frozen-lockfile || bun install
if git fetch -q --depth 1 origin "$BRANCH"; then
NEW=$(git rev-parse FETCH_HEAD)
if [ "$OLD" != "$NEW" ]; then
+ CHANGED=$(git diff --name-only "$OLD" "$NEW" 2>/dev/null)
git reset -q --hard FETCH_HEAD
echo "$(date -u +%FT%TZ) PULLED ${OLD:0:8} -> ${NEW:0:8}" >> /tmp/pull.log
+ # the engine binary is untracked, so rebuild it when its source changed (next start picks it up)
+ if [ "${RUDDER_ENGINE:-0}" = 1 ] && echo "$CHANGED" | grep -q '^backend/rudder-engine/'; then
+ (cd backend/rudder-engine && go build -o ../bin/rudder-engine .) >> /tmp/pull.log 2>&1 \
+ && echo "$(date -u +%FT%TZ) rudder-engine rebuilt" >> /tmp/pull.log
+ fi
fi
fi
done
@@ -24,6 +30,22 @@ bun install --frozen-lockfile || bun install
CONFIG=/app/repo/deployment/cloud/vite.cloud.config.mjs
+# Rudder (optional, RUDDER_ENGINE=1): its voice-to-text engine runs here, fed by the BROWSER's
+# microphone (no server mic). The runtime and the pinned model (~742 MB) go to RUDDER_HOME, which should
+# be on the persistent volume so they download once. Vite's backend plugin starts the engine and serves
+# /api/rudder, which stays behind the sign-in (it is not a public route). About 1 GB resident.
+if [ "${RUDDER_ENGINE:-0}" = 1 ]; then
+ export RUDDER_HOME="${RUDDER_HOME:-/data/rudder}" RUDDER_AUDIO=browser
+ # The engine refuses a non-loopback Host header, and Vite's proxy passes the public host through:
+ # allow the public host, defaulting to the one in OIDC_REDIRECT_URL.
+ if [ -z "${RUDDER_ALLOW_HOST:-}" ] && [ -n "${OIDC_REDIRECT_URL:-}" ]; then
+ RUDDER_ALLOW_HOST=$(printf '%s' "$OIDC_REDIRECT_URL" | sed -E 's#^[a-z]+://([^/:]+).*#\1#')
+ fi
+ export RUDDER_ALLOW_HOST="${RUDDER_ALLOW_HOST:-}"
+ bash /app/repo/backend/rudder-engine/setup-runtime.sh \
+ || echo "[rudder] setup failed: the widget will report the engine offline"
+fi
+
if [ -z "${OIDC_ISSUER:-}" ]; then
# Open, like a laptop: no sign-in.
export VITE_HOST=0.0.0.0 VITE_PORT="$PORT"
@@ -53,8 +75,11 @@ for inst in dev prod; do
&& echo "[pb-$inst] superuser set" || echo "[pb-$inst] superuser upsert failed"
fi
done
-pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" &
-pocketbase serve --http 127.0.0.1:8091 --dir /data/pb-prod --migrationsDir "$MIG" &
+# Server-side JS hooks OFF: point --hooksDir at a path that never exists, so a pb_hooks/ folder
+# added to the repo can't run code in either instance (v1 has no per-plugin isolation for hooks).
+NOHOOKS=/var/empty/pb_hooks_disabled
+pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$NOHOOKS" &
+pocketbase serve --http 127.0.0.1:8091 --dir /data/pb-prod --migrationsDir "$MIG" --hooksDir "$NOHOOKS" &
bun /app/repo/deployment/cloud/admin.mjs &
VITE_HOST=127.0.0.1 VITE_PORT=5174 bun x vite --config "$CONFIG" &