mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
feat: pre-boot firmware self-update + public endpoints
Kiosk checks for stable firmware update before pairing. If available,
downloads + verifies + swaps binary and restarts. No auth needed.
Server: GET /api/firmware/public/check (stable channel, no auth)
GET /api/firmware/public/download/:id (rate-limited, no auth)
Kiosk: check_public() + apply_public() in firmware.rs. Called from
ui.rs worker thread before entering pairing loop. kiosk_app_version
made pub for access from ui.rs.
Also includes kiosk_id deserialization fix (Value instead of String).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc7b7695d8
commit
b0f42d29c2
6 changed files with 373 additions and 2 deletions
203
BSB_LEARNINGS.md
Normal file
203
BSB_LEARNINGS.md
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
# BSB Framework — Practical Learnings
|
||||||
|
|
||||||
|
Lessons learned building BetterFrame on BSB v9. Intended for updating
|
||||||
|
bsbcode.dev/llms.txt so future LLM sessions don't repeat these mistakes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Container Runtime
|
||||||
|
|
||||||
|
### Base Image
|
||||||
|
- Image: `betterweb/service-base:9` (Docker Hub)
|
||||||
|
- Alpine-based (use `apk` not `apt-get`)
|
||||||
|
- Entrypoint: `/root/entrypoint.sh` — runs as root, drops to unprivileged user
|
||||||
|
- **DO NOT** set `USER` in child Dockerfiles — BSB entrypoint handles privilege dropping
|
||||||
|
- **DO NOT** override `CMD` or `ENTRYPOINT` — BSB handles startup
|
||||||
|
|
||||||
|
### Production Environment
|
||||||
|
- Set `ENV NODE_ENV=production` and `ENV BSB_LIVE=true` in Dockerfile
|
||||||
|
- Without `BSB_LIVE=true`, BSB warns about non-production and tries to write sec-config.yaml
|
||||||
|
|
||||||
|
### Plugin Discovery
|
||||||
|
- BSB searches: `/home/bsb/node_modules/<package>/lib/plugins/<type>-<name>/index.js`
|
||||||
|
- Also searches: `/mnt/bsb-plugins/node_modules/...` but this is a VOLUME — build-time COPY gets shadowed by empty anonymous volume at runtime
|
||||||
|
- **USE `/home/bsb/node_modules/`** for build-time plugin installation
|
||||||
|
- Working directory is `/home/bsb`
|
||||||
|
|
||||||
|
### Plugin Registration in Config
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
service-my-plugin:
|
||||||
|
package: my-npm-package-name # <-- REQUIRED for container mode
|
||||||
|
plugin: service-my-plugin
|
||||||
|
enabled: true
|
||||||
|
config: {}
|
||||||
|
```
|
||||||
|
Without `package:`, BSB only finds built-in plugins (config-default, events-default, log-default).
|
||||||
|
|
||||||
|
The `package` value must match the `name` field in the plugin's `package.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config Schema
|
||||||
|
|
||||||
|
### Schema Extractor Limitations
|
||||||
|
- `bsb-plugin-cli build` extracts config schemas from TypeScript source **statically**
|
||||||
|
- **Cannot resolve cross-file imports** — imported schema variables show as "not defined"
|
||||||
|
- Workaround: inline anyvali schema definitions in each plugin's ConfigSchema
|
||||||
|
- Type-only imports (`import type { ... }`) are fine — only runtime value imports break extraction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD — schema extractor can't resolve this:
|
||||||
|
import { dbConfigSchema } from "../../shared/db/config.js";
|
||||||
|
const ConfigSchema = av.object({ db: dbConfigSchema, ... });
|
||||||
|
|
||||||
|
// GOOD — inline the schema:
|
||||||
|
const ConfigSchema = av.object({
|
||||||
|
db: av.object({
|
||||||
|
driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"),
|
||||||
|
host: av.string().default("postgres"),
|
||||||
|
// ...
|
||||||
|
}, { unknownKeys: "strip" }),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nested Config Objects
|
||||||
|
- Config supports nested `av.object()` — use for grouping related fields:
|
||||||
|
```yaml
|
||||||
|
config:
|
||||||
|
db:
|
||||||
|
driver: postgres
|
||||||
|
host: postgres
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 18080
|
||||||
|
```
|
||||||
|
- Access via `this.config.db.driver`, `this.config.host`
|
||||||
|
|
||||||
|
### Config Defaults
|
||||||
|
- BSB does NOT apply anyvali schema defaults for keys missing from sec-config.yaml
|
||||||
|
- Always declare config values explicitly in sec-config.yaml
|
||||||
|
- Defaults in the schema are documentation, not runtime fallbacks
|
||||||
|
|
||||||
|
### No Environment Variable Access
|
||||||
|
- Application code should **never** read `process.env`
|
||||||
|
- All config comes from sec-config.yaml via `this.config.*`
|
||||||
|
- For Docker/Coolify: use build-time `envsubst` on a template to generate sec-config.yaml
|
||||||
|
- sec-config.yaml should be baked into the image or bind-mounted — not generated at runtime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Architecture
|
||||||
|
|
||||||
|
### What Should Be a Plugin
|
||||||
|
- Services with own port, lifecycle, independent scaling
|
||||||
|
- Examples: HTTP server, WebSocket server, API server
|
||||||
|
|
||||||
|
### What Should NOT Be a Plugin
|
||||||
|
- Database access layer (just a library — no port, no lifecycle)
|
||||||
|
- Shared utilities, crypto helpers, auth logic
|
||||||
|
- Anything that's just a function other plugins call
|
||||||
|
|
||||||
|
### Cross-Plugin Communication
|
||||||
|
- Plugins should have **zero runtime imports** from other plugins
|
||||||
|
- Type-only imports (`import type { ... }`) are acceptable
|
||||||
|
- Communication between plugins: BSB event bus (emitBroadcast, emitReturnableEvent)
|
||||||
|
- Shared code goes in `src/shared/` — imported by multiple plugins as a library
|
||||||
|
|
||||||
|
### Plugin Init Order
|
||||||
|
- `initAfterPlugins` / `initBeforePlugins` control order
|
||||||
|
- Not needed when plugins are independent (each inits own DB, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build System
|
||||||
|
|
||||||
|
### Build Command
|
||||||
|
```bash
|
||||||
|
cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli build
|
||||||
|
```
|
||||||
|
- BSB build needs `tsx` for schema extraction from TypeScript source
|
||||||
|
- Build output: `lib/` directory with compiled JS + `bsb-plugin.json`
|
||||||
|
|
||||||
|
### bsb-plugin.json
|
||||||
|
- Auto-generated by build, lists all discovered plugins
|
||||||
|
- Must match the plugins present in `src/plugins/`
|
||||||
|
- If a plugin is removed, this file must be regenerated
|
||||||
|
|
||||||
|
### Dev Mode
|
||||||
|
```bash
|
||||||
|
cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli dev
|
||||||
|
```
|
||||||
|
- Hot reload, runs TypeScript directly
|
||||||
|
- Creates `.bsbdevwatch` file for include/exclude patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
### Log Message Format
|
||||||
|
- `obs.log.info("text {tag}", { tag: value })` — structured logging
|
||||||
|
- Message strings **MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type)
|
||||||
|
- String concatenation (`"a " + "b"`) widens to `string` and breaks SmartLogMeta placeholder extraction
|
||||||
|
- Template literals in log messages work for the value, not the message key
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD:
|
||||||
|
obs.log.info("connecting to " + url, {});
|
||||||
|
|
||||||
|
// GOOD:
|
||||||
|
obs.log.info("connecting to {url}", { url });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Deployment Pattern
|
||||||
|
|
||||||
|
### Dockerfile Structure
|
||||||
|
```dockerfile
|
||||||
|
# Builder — compile TypeScript + native deps
|
||||||
|
FROM node:24-trixie-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
# ... npm ci, npm run build ...
|
||||||
|
|
||||||
|
# Runtime — BSB container
|
||||||
|
FROM betterweb/service-base:9
|
||||||
|
|
||||||
|
# Install extras (Alpine)
|
||||||
|
RUN apk add --no-cache gettext ffmpeg
|
||||||
|
|
||||||
|
# Copy built plugin into BSB's node_modules
|
||||||
|
COPY --from=builder /app/server/package.json /home/bsb/node_modules/<pkg>/package.json
|
||||||
|
COPY --from=builder /app/server/bsb-plugin.json /home/bsb/node_modules/<pkg>/bsb-plugin.json
|
||||||
|
COPY --from=builder /app/server/lib /home/bsb/node_modules/<pkg>/lib
|
||||||
|
COPY --from=builder /app/node_modules /home/bsb/node_modules/<pkg>/node_modules
|
||||||
|
|
||||||
|
# Generate config from template
|
||||||
|
COPY sec-config.template.yaml /tmp/sec-config.template.yaml
|
||||||
|
RUN envsubst < /tmp/sec-config.template.yaml > /home/bsb/sec-config.yaml \
|
||||||
|
&& chmod 444 /home/bsb/sec-config.yaml \
|
||||||
|
&& rm /tmp/sec-config.template.yaml
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV BSB_LIVE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### sec-config.template.yaml Pattern
|
||||||
|
- Template with `${VAR}` placeholders (NOT `${VAR:-default}` — envsubst doesn't support defaults)
|
||||||
|
- Defaults come from Dockerfile `ARG` declarations
|
||||||
|
- Secrets set as Coolify build args (not in git)
|
||||||
|
- Template committed to public repo (safe — no secrets)
|
||||||
|
- Generated sec-config.yaml baked into image at build time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **`betterweb/service-base:node` is v8, `:9` is v9** — use the correct tag
|
||||||
|
2. **VOLUME declarations in base image shadow build-time COPY** — don't write to `/mnt/bsb-plugins`
|
||||||
|
3. **`USER node` blocks BSB entrypoint** — entrypoint is at `/root/entrypoint.sh`, needs root access
|
||||||
|
4. **Schema extractor can't follow imports** — inline schemas in plugin ConfigSchema
|
||||||
|
5. **`package:` required in sec-config** — without it, BSB won't find external plugins
|
||||||
|
6. **Alpine = apk, not apt-get** — base image is Alpine Linux
|
||||||
|
7. **sec-config.yaml must be writable in dev, read-only in prod** — BSB_LIVE=true skips write attempts
|
||||||
|
|
@ -65,6 +65,73 @@ pub struct UpdateInfo {
|
||||||
pub public_key_pem: String,
|
pub public_key_pem: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public pre-boot firmware check — no auth needed. Always checks stable
|
||||||
|
/// channel. Used before pairing to self-update to latest binary.
|
||||||
|
pub fn check_public(server: &str, current_version: &str) -> Option<UpdateInfo> {
|
||||||
|
let url = format!(
|
||||||
|
"{server}/api/firmware/public/check?arch={arch}¤t={cur}",
|
||||||
|
arch = ARCH,
|
||||||
|
cur = current_version,
|
||||||
|
);
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = match client.get(&url).timeout(Duration::from_secs(10)).send() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(err) => { warn!("preboot firmware check: {err}"); return None; }
|
||||||
|
};
|
||||||
|
if !resp.status().is_success() { return None; }
|
||||||
|
match resp.json::<CheckResponse>() {
|
||||||
|
Ok(c) if !c.up_to_date => c.update,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public download + verify + swap — no auth. Used with check_public.
|
||||||
|
/// On success exits so systemd restarts with new binary.
|
||||||
|
pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> {
|
||||||
|
info!("preboot firmware: applying {} ({} bytes)", info.version, info.size_bytes);
|
||||||
|
let download_url = format!("{server}{}", info.download_url);
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client.get(&download_url)
|
||||||
|
.timeout(Duration::from_secs(300))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("download failed: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("download HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().map_err(|e| format!("read failed: {e}"))?;
|
||||||
|
if bytes.len() as u64 != info.size_bytes {
|
||||||
|
return Err(format!("size mismatch: expected {}, got {}", info.size_bytes, bytes.len()));
|
||||||
|
}
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bytes);
|
||||||
|
let got_sha = hex_lower(&hasher.finalize());
|
||||||
|
if got_sha != info.sha256 {
|
||||||
|
return Err(format!("sha256 mismatch: expected {}, got {}", info.sha256, got_sha));
|
||||||
|
}
|
||||||
|
verify_signature(&info.public_key_pem, &info.sha256, &info.signature)
|
||||||
|
.map_err(|e| format!("signature verify: {e}"))?;
|
||||||
|
|
||||||
|
let bin = binary_path();
|
||||||
|
let new_path = bin.with_extension("new");
|
||||||
|
let prev_path = bin.with_extension("prev");
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut f = fs::OpenOptions::new()
|
||||||
|
.create(true).write(true).truncate(true).mode(0o755)
|
||||||
|
.open(&new_path)
|
||||||
|
.map_err(|e| format!("open {}: {e}", new_path.display()))?;
|
||||||
|
use std::io::Write;
|
||||||
|
f.write_all(&bytes).map_err(|e| format!("write: {e}"))?;
|
||||||
|
}
|
||||||
|
if bin.exists() {
|
||||||
|
let _ = fs::remove_file(&prev_path);
|
||||||
|
let _ = fs::rename(&bin, &prev_path);
|
||||||
|
}
|
||||||
|
fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?;
|
||||||
|
info!("preboot firmware: updated to {}, exiting for restart", info.version);
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
/// Hit `/api/kiosk/firmware/check` and return the update info if one is
|
/// Hit `/api/kiosk/firmware/check` and return the update info if one is
|
||||||
/// available. Returns `None` on up-to-date / network error / unparsable
|
/// available. Returns `None` on up-to-date / network error / unparsable
|
||||||
/// response — never panics.
|
/// response — never panics.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub struct DisplayReport {
|
||||||
pub power_state: String,
|
pub power_state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kiosk_app_version() -> &'static str {
|
pub fn kiosk_app_version() -> &'static str {
|
||||||
option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,19 @@ fn activate(app: &Application) {
|
||||||
let server = server::discover_server(server_url.as_deref());
|
let server = server::discover_server(server_url.as_deref());
|
||||||
info!("server: {server}");
|
info!("server: {server}");
|
||||||
|
|
||||||
|
// Pre-boot self-update: check for stable firmware before pairing.
|
||||||
|
// If an update is available, download + swap + exit. systemd restarts
|
||||||
|
// with the new binary which re-enters this flow.
|
||||||
|
if !server::is_paired() {
|
||||||
|
let current = crate::server::kiosk_app_version();
|
||||||
|
if let Some(update) = crate::firmware::check_public(&server, current) {
|
||||||
|
info!("preboot update available: {} → {}", current, update.version);
|
||||||
|
if let Err(e) = crate::firmware::apply_public(&server, &update) {
|
||||||
|
tracing::warn!("preboot update failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let key = if server::is_paired() {
|
let key = if server::is_paired() {
|
||||||
info!("already paired");
|
info!("already paired");
|
||||||
server::load_key()
|
server::load_key()
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
registerPairingRoutes(app, repo, auth, secrets, codeTtl);
|
registerPairingRoutes(app, repo, auth, secrets, codeTtl, firmware);
|
||||||
registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt);
|
registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt);
|
||||||
|
|
||||||
this.server = serve(app, {
|
this.server = serve(app, {
|
||||||
|
|
@ -279,6 +279,7 @@ function registerPairingRoutes(
|
||||||
auth: AuthApi,
|
auth: AuthApi,
|
||||||
secrets: SecretsApi,
|
secrets: SecretsApi,
|
||||||
codeTtl: number,
|
codeTtl: number,
|
||||||
|
firmware: FirmwareApi,
|
||||||
): void {
|
): void {
|
||||||
// Constructed in-function so the BSB schema extractor (which evaluates the
|
// Constructed in-function so the BSB schema extractor (which evaluates the
|
||||||
// module statically) doesn't see a top-level createRateLimiter call.
|
// module statically) doesn't see a top-level createRateLimiter call.
|
||||||
|
|
@ -346,6 +347,60 @@ function registerPairingRoutes(
|
||||||
bundle_url: result.bundleUrl,
|
bundle_url: result.bundleUrl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public firmware check — no auth. Used by kiosks on first boot before
|
||||||
|
// pairing to self-update to latest stable binary. Always stable channel.
|
||||||
|
app.get("/api/firmware/public/check", async (event) => {
|
||||||
|
const url = new URL(event.req.url);
|
||||||
|
const arch = url.searchParams.get("arch")?.trim();
|
||||||
|
if (!arch) throw createError({ statusCode: 400, statusMessage: "arch required" });
|
||||||
|
const current = url.searchParams.get("current")?.trim() ?? "";
|
||||||
|
|
||||||
|
const release = await repo.getLatestFirmwareRelease("stable", arch);
|
||||||
|
if (!release || release.version === current) {
|
||||||
|
return { up_to_date: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
up_to_date: false,
|
||||||
|
update: {
|
||||||
|
release_id: release.id,
|
||||||
|
version: release.version,
|
||||||
|
sha256: release.sha256,
|
||||||
|
signature: release.signature,
|
||||||
|
size_bytes: release.size_bytes,
|
||||||
|
download_url: `/api/firmware/public/download/${release.id}`,
|
||||||
|
public_key_pem: firmware.publicKeyPem(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public firmware download — no auth. Rate-limited to prevent abuse.
|
||||||
|
const publicDlGuard = createRateLimiter({ windowMs: 60_000, max: 5 });
|
||||||
|
app.get("/api/firmware/public/download/:id", async (event) => {
|
||||||
|
const ip = getRequestHeader(event, "x-real-ip")
|
||||||
|
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
?? "anon";
|
||||||
|
if (!publicDlGuard.take(`fwdl:${ip}`)) {
|
||||||
|
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(event, "id") ?? "";
|
||||||
|
const release = await repo.getFirmwareRelease(id);
|
||||||
|
if (!release || release.yanked_at) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "release not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await firmware.readBlob(release.artifact_path, release.sha256);
|
||||||
|
return new Response(buf, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/octet-stream",
|
||||||
|
"content-length": String(buf.length),
|
||||||
|
"x-bf-sha256": release.sha256,
|
||||||
|
"x-bf-signature": release.signature,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Kiosk routes (require Bearer kiosk key) --------------------------------
|
// ---- Kiosk routes (require Bearer kiosk key) --------------------------------
|
||||||
|
|
|
||||||
33
server/src/shared/db/MULTI_TENANT.md
Normal file
33
server/src/shared/db/MULTI_TENANT.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Multi-Tenant Architecture
|
||||||
|
|
||||||
|
## Current Design
|
||||||
|
|
||||||
|
- **Single admin user** — global admin, full access to all tenants
|
||||||
|
- **No per-tenant logins** — one admin manages everything
|
||||||
|
- **Tenant = data isolation boundary** — each tenant gets its own PG schema
|
||||||
|
- **Admin switches tenants** via dropdown in topbar (session-stored)
|
||||||
|
- **User management deferred** — if/when we want per-tenant user logins, that's a separate feature
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. `PUBLIC_MIGRATIONS` create `tenants` + `global_admins` tables in `public` schema
|
||||||
|
2. Each tenant gets a PG schema: `tenant_<slug>` (e.g. `tenant_acme`)
|
||||||
|
3. `TENANT_MIGRATIONS` run inside each tenant schema (full table set per tenant)
|
||||||
|
4. Admin creates tenants from the admin UI
|
||||||
|
5. Middleware sets `search_path = tenant_<slug>` per request based on selected tenant
|
||||||
|
6. All repo queries automatically scope to the active tenant's schema
|
||||||
|
|
||||||
|
## What's NOT Happening
|
||||||
|
|
||||||
|
- No per-tenant admin users (single global admin for now)
|
||||||
|
- No tenant-specific auth (global session, tenant is just a context switch)
|
||||||
|
- No tenant billing/limits enforcement (max_kiosks/max_cameras columns exist but unenforced)
|
||||||
|
- No tenant API keys (all API keys are global)
|
||||||
|
|
||||||
|
## Future: Per-Tenant Users
|
||||||
|
|
||||||
|
When needed, add:
|
||||||
|
- Per-tenant `users` table (already in TENANT_MIGRATIONS)
|
||||||
|
- Login scoped to tenant (tenant slug in login URL or selection)
|
||||||
|
- Role-based access per tenant
|
||||||
|
- Separate from global admin
|
||||||
Loading…
Reference in a new issue