Skip to content

Security Guide — @bloomsparkagency/core

Covers PRD §14. Last updated: 2026-05-21. References issue #162.


1. Content Security Policy (CSP) for Consumers

Add the following directives to your application's Content-Security-Policy header. Adjust hostnames to match your actual tile-serving infrastructure.

http
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

Backendconnect-src addition
Cloudflare R2https://*.r2.cloudflarestorage.com
AWS S3 (direct)https://*.s3.amazonaws.com or your bucket URL
AWS S3 + CloudFronthttps://*.cloudfront.net (or your custom domain)
Self-hosted pmtiles-servehttps://your-pmtiles-host
RTLS WebSocketwss://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 involve eval() or dynamic Function construction.
  • 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(), no new Function(), no setTimeout(string).
  • Terra Draw (editor) uses no code-generation patterns.
  • The CI bundle scan (see §6) verifies this on every pull request by grepping dist/ for eval(, new Function(, and setTimeout(string and 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.

typescript
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.

typescript
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.

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.

typescript
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:

tsx
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-lockfile inside GitHub Actions jobs.
  • Developer machines: set in the shell environment or .npmrc for local pnpm install.
  • Release workflow: pnpm publish inside release.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:

PatternRationale
NODE_AUTH_TOKENGitHub 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:

PatternRationale
eval(Direct eval call
new Function(Dynamic function constructor
setTimeout(stringString-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=high gates every PR (see security-audit CI job).
  • npm provenance is enabled in release.yml via permissions: id-token: write. Each published .tgz is linked to the specific commit and workflow run, allowing consumers to verify the package was built from this repository using npm audit signatures.

Released under commercial licensing.