Security Guide — @bloomsparkagency/core
Covers PRD §14. Last updated: 2026-05-21. References issue #162.
1. Content Security Policy (CSP) for Consumers
1.1 Recommended CSP directives
Add the following directives to your application's Content-Security-Policy header. Adjust hostnames to match your actual tile-serving infrastructure.
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
worker-src 'self' blob:;
connect-src
'self'
https://*.r2.cloudflarestorage.com
https://*.s3.amazonaws.com
https://*.cloudfront.net
wss://your-rtls-host;
img-src 'self' data: blob:;
font-src 'self';1.2 Per-backend breakdown
| Backend | connect-src addition |
|---|---|
| Cloudflare R2 | https://*.r2.cloudflarestorage.com |
| AWS S3 (direct) | https://*.s3.amazonaws.com or your bucket URL |
| AWS S3 + CloudFront | https://*.cloudfront.net (or your custom domain) |
| Self-hosted pmtiles-serve | https://your-pmtiles-host |
| RTLS WebSocket | wss://your-rtls-host |
1.3 worker-src 'self' blob:
The library spawns Web Workers for IMDF parsing, navigation graph computation, Kalman filtering, RTLS network I/O, and PMTiles range batching (all via Comlink). Workers are created from blob: URLs in bundled form. blob: in worker-src is therefore required; 'self' covers the fallback path used in some bundler configurations.
1.4 'unsafe-eval' is NOT required
'unsafe-eval' is not needed and must not be added. The reasons:
- MapLibre GL JS uses WebGL shaders that are compiled via the native
WebGLRenderingContext.compileShader()API — this does not involveeval()or dynamicFunctionconstruction. - deck.gl likewise compiles all GLSL at build time via
luma.gl's static shader strings. No runtime code generation. - PMTiles parsing, navigation graph, and Kalman filter are all implemented as pure TypeScript compiled to static ES modules — no
eval(), nonew Function(), nosetTimeout(string). - Terra Draw (editor) uses no code-generation patterns.
- The CI bundle scan (see §6) verifies this on every pull request by grepping
dist/foreval(,new Function(, andsetTimeout(stringand failing the build if any match is found.
2. MQTT / WebSocket Authentication Patterns
mTLS is impossible in the browser. The library supports three auth patterns for RTLS adapters that connect over MQTT/WebSocket.
2a. JWT-Bearer with OAuth2 Device-Code Refresh
Use when your RTLS broker accepts Authorization: Bearer <jwt> in the WebSocket upgrade request or MQTT CONNECT packet.
import { MqttAdapter } from '@bloomsparkagency/rtls';
async function getAccessToken(): Promise<string> {
// OAuth2 device-code flow — exchange refresh token for new access token
const response = await fetch('https://auth.example.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
refresh_token: sessionStorage.getItem('rtls_refresh_token') ?? '',
}),
});
const { access_token, refresh_token } = await response.json();
sessionStorage.setItem('rtls_refresh_token', refresh_token);
return access_token;
}
// Adapter receives a token factory; it calls it on connect and on 401/disconnect
const adapter = new MqttAdapter({
brokerUrl: 'wss://rtls.example.com/mqtt',
getToken: getAccessToken,
// Re-authenticate 60 s before token expiry (jwtExpiresIn - 60)
tokenRefreshLeadSecs: 60,
});Tokens are held in memory only (or sessionStorage for the refresh token). They are never embedded in source code or config files committed to the repository.
2b. AWS IoT Core — Signed WebSocket URL (SigV4)
Use when connecting to an AWS IoT Core endpoint. AWS IoT does not support plain Authorization headers on WebSocket upgrades; instead you sign the WebSocket URL using SigV4 query-string parameters.
import { MqttAdapter } from '@bloomsparkagency/rtls';
import { SignatureV4 } from '@smithy/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-browser';
async function signIoTWebSocketUrl(
endpoint: string, // e.g. "xxxxxxxx-ats.iot.us-east-1.amazonaws.com"
region: string,
credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string },
): Promise<string> {
const signer = new SignatureV4({
credentials,
region,
service: 'iotdevicegateway',
sha256: Sha256,
});
const url = new URL(`wss://${endpoint}/mqtt`);
const signed = await signer.presign(
{
method: 'GET',
protocol: 'wss:',
hostname: endpoint,
path: '/mqtt',
query: {},
headers: { host: endpoint },
},
{ expiresIn: 300 }, // 5-minute signed URL
);
// Reconstruct signed WebSocket URL from presigned request
const params = new URLSearchParams(signed.query as Record<string, string>);
return `wss://${endpoint}/mqtt?${params.toString()}`;
}
// Usage: regenerate the signed URL before it expires (< 5 min)
const adapter = new MqttAdapter({
brokerUrl: () => signIoTWebSocketUrl(
import.meta.env.VITE_IOT_ENDPOINT,
import.meta.env.VITE_AWS_REGION,
await fetchTemporaryCredentials(), // e.g. via AWS STS AssumeRoleWithWebIdentity
),
});AWS temporary credentials (from Cognito Identity Pool or STS) must be obtained server-side or via a Cognito-backed identity pool. Long-lived AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY values must never appear in frontend bundles.
2c. Reverse-Proxy with Cookie-Based Session
Use when your organisation runs an MQTT-over-WebSocket broker behind an application proxy (nginx, Caddy, AWS ALB) that handles authentication via HTTP session cookies. The browser WebSocket upgrade carries the session cookie automatically; the proxy validates it before forwarding to the broker.
import { MqttAdapter } from '@bloomsparkagency/rtls';
// No credentials in code — the browser sends the HttpOnly session cookie
// automatically on same-origin (or credentialed cross-origin) WebSocket upgrades.
const adapter = new MqttAdapter({
brokerUrl: 'wss://rtls.example.com/mqtt', // proxied endpoint
// No getToken — auth is handled by the proxy via the session cookie
});
// Nginx reverse-proxy config (reference):
// location /mqtt {
// auth_request /auth/validate; # validates session cookie
// proxy_pass http://mosquitto:1883;
// proxy_http_version 1.1;
// proxy_set_header Upgrade $http_upgrade;
// proxy_set_header Connection "upgrade";
// }Prerequisite: the WebSocket URL must be same-origin or the server must include Access-Control-Allow-Credentials: true with a specific (non-wildcard) Access-Control-Allow-Origin.
3. Telemetry
No telemetry ships by default.
The library contains no Sentry SDK, no analytics beacon, no usage-ping, and no external logging endpoint. Network requests are limited to:
- PMTiles range requests to the customer-configured tile URL.
- RTLS WebSocket/MQTT connections to the customer-configured broker.
Customers who want error reporting can wire their own error boundary:
import { SpatialProvider } from '@bloomsparkagency/core';
import * as Sentry from '@sentry/react';
<SpatialProvider
onError={(err, info) => {
Sentry.captureException(err, { extra: info });
}}
>
{/* ... */}
</SpatialProvider>onError is the only error-reporting hook; it is opt-in and the callback is fully under the consumer's control.
4. GitHub Packages Token — Build-Time Only
NODE_AUTH_TOKEN (backed by GITHUB_TOKEN or a fine-grained PAT with read:packages scope) is used exclusively at:
- CI install step:
pnpm install --frozen-lockfileinside GitHub Actions jobs. - Developer machines: set in the shell environment or
.npmrcfor localpnpm install. - Release workflow:
pnpm publishinsiderelease.yml.
The token is never embedded in published bundles. The build pipeline (tsup) compiles TypeScript source to ES modules and CJS; no authentication material appears in the output. The CI bundle credential scan (§6) enforces this assertion on every pull request.
Consumers only need a token to install the package. Once installed, the published .js / .d.ts files contain no credential references.
5. IMDF Data Residency
Floor-plan IMDF data is sensitive (escape routes, executive offices, security checkpoints). The library enforces the following:
- IMDF parsing runs entirely in a Web Worker in-browser. No floor-plan data is transmitted to any third-party service.
- IMDF export (Apple Indoor Maps Program profile, Microsoft Places profile) produces a local download; no upload occurs.
- Customer-hosted PMTiles preserve data residency — tiles are fetched directly from the customer's storage bucket, not proxied through any BloomSpark infrastructure.
6. CI Bundle Scans (§14 enforcement)
Two automated checks run on every pull request as part of the bundle-scan CI job (see .github/workflows/ci.yml):
6.1 Credential scan
Scans all packages/*/dist/ output for patterns that would indicate an embedded credential:
| Pattern | Rationale |
|---|---|
NODE_AUTH_TOKEN | GitHub Packages install token |
ghp_ | Classic GitHub personal access token |
github_pat_ | Fine-grained GitHub personal access token |
Bearer [A-Za-z0-9] | Bearer auth header value |
AKIA[0-9A-Z]{16} | AWS access key ID |
sk-[a-zA-Z0-9]{20,} | OpenAI-style API key |
CI fails immediately if any pattern is matched.
6.2 No-eval check
Scans all packages/*/dist/ output for dynamic code execution patterns that would require 'unsafe-eval' in CSP:
| Pattern | Rationale |
|---|---|
eval( | Direct eval call |
new Function( | Dynamic function constructor |
setTimeout(string | String-form setTimeout (treated as eval) |
CI fails if any pattern is matched. This provides a continuous guarantee that 'unsafe-eval' is not required (§1.4).
7. Supply Chain
- Renovate auto-updates dependencies;
pnpm audit --audit-level=highgates every PR (seesecurity-auditCI job). - npm provenance is enabled in
release.ymlviapermissions: id-token: write. Each published.tgzis linked to the specific commit and workflow run, allowing consumers to verify the package was built from this repository usingnpm audit signatures.
