bunker-admin 5a2c54dabf feat(jsn-bridge): identity / donation / social-link / Rocket.Chat bridges
Four bearer-secret-gated server-to-server endpoints that mirror JSN
supporters into cmlite. All share a constant-time `requireBridgeSecret`
(timingSafeEqual on `Bearer ${JSN_BRIDGE_SECRET}`) and per-route
Redis-backed rate limiting.

- POST /api/auth/bridge/users — identity provisioning, idempotent on email.
  Tags users with `permissions._jsnProvenance` (jsn-funnel | jsn-donate |
  jsn-organize | jsn-organic) and createdVia=JSN_BRIDGE. On new-user
  creation, fire-and-forget RC account provisioning via the existing
  rocketchatClient (createUser with throwaway password; SSO uses admin-side
  createUserToken). Stores RC user id on User.rocketChatUserId.

- POST /api/payments/bridge/record — donation mirror, idempotent on
  stripePaymentIntentId. Creates Invoice (type='donation', status='paid')
  + Payment in one transaction. 404s if no cmlite user for the email
  (JSN provisions first).

- POST /api/auth/bridge/social-links — handle sync, mirror semantics
  keyed on (userId, platform). Mirror per the existing handoff spec at
  docs/docs/handoff/cmlite-bridge.md on the JSN side.

- POST /api/rocketchat/bridge/auth — returns RC SSO token by looking up
  User.rocketChatUserId and calling rocketchatClient.createUserToken.
  Returns {enabled: false} when chat is off or the user has no RC account.

env.ts gains JSN_BRIDGE_SECRET (z.string().min(32).optional()).

Prisma migrations:
- add_jsn_bridge_to_user_created_via — enum value JSN_BRIDGE
- extend_social_platforms_and_unique — facebook/threads/bluesky/linkedin +
  composite unique (user_id, platform)
- add_user_rocketchat_user_id — User.rocketChatUserId column

JSN-side spec lives in the JSN repo at
docs/docs/integrations/changemaker.md.

Bunker Admin
2026-05-25 19:06:07 -06:00

303 lines
13 KiB
TypeScript

import dotenv from 'dotenv';
import { z } from 'zod';
dotenv.config();
const envSchema = z.object({
// Server
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(4000),
API_URL: z.string().default('http://localhost:4000'),
ADMIN_URL: z.string().default('http://localhost:3000'),
DOMAIN: z.string().default('cmlite.org'),
// Logging
LOG_DIR: z.string().default('/app/logs'),
// Security
CSP_ENABLED: z.string().default('false'),
// Bunker Ops (Fleet Management)
INSTANCE_LABEL: z.string().default(''),
BUNKER_OPS_ENABLED: z.string().default('false'),
BUNKER_OPS_REMOTE_WRITE_URL: z.string().default(''),
// Database
DATABASE_URL: z.string(),
// Redis
REDIS_URL: z.string().default('redis://redis-changemaker:6379'),
// JWT
JWT_ACCESS_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
JWT_INVITE_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRY: z.string().default('15m'),
// Reduced 2026-04-12 from 7d → 24h. Stolen refresh tokens have a much tighter
// exploitation window now; combined with device-fingerprint binding in
// auth.service.ts, theft is materially harder to monetize.
JWT_REFRESH_EXPIRY: z.string().default('24h'),
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
// Gitea SSO cookie signing secret — MUST be distinct from JWT secrets.
// Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET, which
// meant a JWT leak compromised SSO cookies too. Now required (min 32 chars).
GITEA_SSO_SECRET: z.string().min(32, 'GITEA_SSO_SECRET must be ≥32 chars; generate with: openssl rand -hex 32'),
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
// Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET. Now required.
SERVICE_PASSWORD_SALT: z.string().min(32, 'SERVICE_PASSWORD_SALT must be ≥32 chars; generate with: openssl rand -hex 32'),
// Initial Super Admin (auto-created during database seeding)
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')
.refine(
(val) => val !== 'REQUIRED_STRONG_PASSWORD_CHANGE_THIS',
{ message: 'INITIAL_ADMIN_PASSWORD must be changed from the default placeholder value' },
),
// SMTP
SMTP_HOST: z.string().default('mailhog-changemaker'),
SMTP_PORT: z.coerce.number().default(1025),
SMTP_USER: z.string().default(''),
SMTP_PASS: z.string().default(''),
SMTP_FROM: z.string().default('noreply@cmlite.org'),
SMTP_FROM_NAME: z.string().default('Changemaker Lite'),
EMAIL_TEST_MODE: z.string().default('true'),
TEST_EMAIL_RECIPIENT: z.string().default('admin@cmlite.org'),
// Listmonk
LISTMONK_URL: z.string().default('http://listmonk-app:9000'),
LISTMONK_ADMIN_USER: z.string().default('admin'),
LISTMONK_ADMIN_PASSWORD: z.string().default(''),
LISTMONK_SYNC_ENABLED: z.string().default('false'),
LISTMONK_WEBHOOK_SECRET: z.string().default(''),
LISTMONK_PROXY_PORT: z.coerce.number().default(9002),
// Represent API (Canadian electoral data)
REPRESENT_API_URL: z.string().default('https://represent.opennorth.ca'),
// CORS
CORS_ORIGINS: z.string().default('http://localhost:3000'),
// Rate Limiting
RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000),
RATE_LIMIT_MAX: z.coerce.number().default(500),
// Geocoding
MAPBOX_API_KEY: z.string().optional(),
GEOCODING_RATE_LIMIT_MS: z.coerce.number().default(1100),
GEOCODING_CACHE_ENABLED: z.string().default('true'),
GEOCODING_CACHE_TTL_HOURS: z.coerce.number().default(24),
// Phase 2: Performance & Accuracy
GOOGLE_MAPS_API_KEY: z.string().optional(),
GOOGLE_MAPS_ENABLED: z.string().default('false'),
GEOCODING_PARALLEL_ENABLED: z.string().default('true'),
GEOCODING_BATCH_SIZE: z.coerce.number().default(10),
// Bulk Re-Geocoding (Phase 3)
BULK_GEOCODE_ENABLED: z.string().default('true'),
BULK_GEOCODE_MAX_BATCH: z.coerce.number().default(5000),
// Platform Services (NocoDB, n8n, Gitea)
NOCODB_URL: z.string().default('http://changemaker-v2-nocodb:8080'),
NOCODB_PORT: z.coerce.number().default(8091),
NOCODB_EMBED_PORT: z.coerce.number().default(8881),
N8N_URL: z.string().default('http://n8n-changemaker:5678'),
N8N_PORT: z.coerce.number().default(5678),
N8N_EMBED_PORT: z.coerce.number().default(8882),
GITEA_URL: z.string().default('http://gitea-changemaker:3000'),
GITEA_PORT: z.coerce.number().default(3030),
GITEA_EMBED_PORT: z.coerce.number().default(8883),
// MailHog (email testing UI)
MAILHOG_URL: z.string().default('http://mailhog-changemaker:8025'),
MAILHOG_EMBED_PORT: z.coerce.number().default(8884),
// Mini QR (QR code generator)
MINI_QR_URL: z.string().default('http://mini-qr:8080'),
MINI_QR_PORT: z.coerce.number().default(8089),
MINI_QR_EMBED_PORT: z.coerce.number().default(8885),
// Excalidraw (collaborative whiteboard)
EXCALIDRAW_URL: z.string().default('http://excalidraw-changemaker:80'),
EXCALIDRAW_PORT: z.coerce.number().default(8090),
EXCALIDRAW_EMBED_PORT: z.coerce.number().default(8886),
// Homepage (service dashboard)
HOMEPAGE_URL: z.string().default('http://homepage-changemaker:3000'),
HOMEPAGE_EMBED_PORT: z.coerce.number().default(8887),
// Vaultwarden (password manager)
VAULTWARDEN_URL: z.string().default('http://vaultwarden-changemaker:80'),
VAULTWARDEN_ADMIN_TOKEN: z.string().default(''),
VAULTWARDEN_EMBED_PORT: z.coerce.number().default(8890),
// Rocket.Chat (team chat)
ROCKETCHAT_URL: z.string().default('http://rocketchat-changemaker:3000'),
ROCKETCHAT_ADMIN_USER: z.string().default(''),
ROCKETCHAT_ADMIN_PASSWORD: z.string().default(''),
ROCKETCHAT_EMBED_PORT: z.coerce.number().default(8891),
ENABLE_CHAT: z.string().default('false'),
// Gancio (event management)
GANCIO_URL: z.string().default('http://gancio-changemaker:13120'),
GANCIO_PORT: z.coerce.number().default(8092),
GANCIO_EMBED_PORT: z.coerce.number().default(8892),
GANCIO_ADMIN_USER: z.string().default('admin'),
GANCIO_ADMIN_PASSWORD: z.string().default(''),
GANCIO_SYNC_ENABLED: z.string().default('false'),
// Jitsi Meet (video conferencing)
ENABLE_MEET: z.string().default('false'),
JITSI_APP_ID: z.string().default('changemaker'),
JITSI_APP_SECRET: z.string().default(''),
JITSI_URL: z.string().default('http://jitsi-web-changemaker:80'),
JITSI_EMBED_PORT: z.coerce.number().default(8893),
// Pangolin (tunnel / reverse proxy)
PANGOLIN_API_URL: z.string()
.default('')
.refine(
(url) => !url || url.startsWith('https://'),
{ message: 'PANGOLIN_API_URL must use HTTPS for secure credential transmission' }
),
PANGOLIN_API_KEY: z.string().default(''),
PANGOLIN_ORG_ID: z.string().default(''),
PANGOLIN_SITE_ID: z.string().default(''),
PANGOLIN_ENDPOINT: z.string().default(''),
PANGOLIN_NEWT_ID: z.string().default(''),
PANGOLIN_NEWT_SECRET: z.string().default(''),
// NAR (National Address Register)
NAR_DATA_DIR: z.string().default('/data'),
// Overpass / Area Import
OVERPASS_API_URL: z.string().default('https://overpass-api.de/api/interpreter'),
OVERPASS_MIN_DELAY_MS: z.coerce.number().default(30000),
AREA_IMPORT_MAX_GRID_POINTS: z.coerce.number().default(500),
// Payments (Stripe)
ENABLE_PAYMENTS: z.string().default('false'),
// Media Management
ENABLE_MEDIA_FEATURES: z.string().default('false'),
MEDIA_API_PORT: z.coerce.number().default(4100),
MEDIA_API_PUBLIC_URL: z.string().default('http://media-api:4100'),
MEDIA_ROOT: z.string().default('/media/library'),
MEDIA_UPLOADS: z.string().default('/media/uploads'),
MAX_UPLOAD_SIZE_GB: z.coerce.number().default(10),
// HLS adaptive bitrate transcoding. When false, uploads are not enqueued
// for transcoding (the worker stays registered so PENDING jobs from a
// previous run still process if the flag is flipped back on). MP4 range-
// request streaming continues to work as a fallback for un-transcoded
// videos regardless of this flag.
ENABLE_HLS_TRANSCODE: z.string().default('false'),
// Container Registry (remote — gitea.bnkops.com)
GITEA_REGISTRY: z.string().default('gitea.bnkops.com/admin'),
GITEA_REGISTRY_USER: z.string().default(''),
GITEA_REGISTRY_PASS: z.string().default(''),
GITEA_REGISTRY_API_TOKEN: z.string().default(''), // For release uploads (build-release.sh)
// Gitea Docs Comments (local platform instance)
GITEA_COMMENTS_ENABLED: z.string().default('false'),
GITEA_API_TOKEN: z.string().default(''), // Local Gitea — NOT the remote registry
GITEA_COMMENTS_REPO_OWNER: z.string().default(''),
GITEA_COMMENTS_REPO_NAME: z.string().default('docs-comments'),
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
// Gitea Docs Version History
GITEA_DOCS_REPO: z.string().default('admin/changemaker.lite'),
GITEA_DOCS_PREFIX: z.string().default('mkdocs/docs'),
GITEA_DOCS_BRANCH: z.string().default('v2'),
// Gitea Auto-Setup (password used once to create API token, then cleared)
GITEA_ADMIN_PASSWORD: z.string().default(''),
// SMS Campaigns (Termux Android bridge)
ENABLE_SMS: z.string().default('false'),
// Social, People, Analytics (initial defaults; DB authoritative once admin saves)
ENABLE_SOCIAL: z.string().default('false'),
ENABLE_PEOPLE: z.string().default('false'),
ENABLE_ANALYTICS: z.string().default('false'),
// CCP Agent (remote management)
ENABLE_CCP_AGENT: z.string().default('false'),
CCP_URL: z.string().default(''),
CCP_AGENT_URL: z.string().default(''),
COMPOSE_PROFILES: z.string().default(''),
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),
TERMUX_API_KEY: z.string().default(''),
SMS_DELAY_BETWEEN_MS: z.coerce.number().default(3000),
SMS_MAX_RETRIES: z.coerce.number().default(3),
SMS_RESPONSE_SYNC_INTERVAL_MS: z.coerce.number().default(30000),
SMS_DEVICE_MONITOR_INTERVAL_MS: z.coerce.number().default(30000),
// Docs / Code Server
CODE_SERVER_URL: z.string().default('http://code-server-changemaker:8443'),
CODE_SERVER_PORT: z.coerce.number().default(8888),
MKDOCS_PREVIEW_URL: z.string().default('http://mkdocs-changemaker:8000'),
MKDOCS_PORT: z.coerce.number().default(4003),
MKDOCS_DOCS_PATH: z.string().default('/mkdocs/docs'),
MKDOCS_CONFIG_PATH: z.string().default('/mkdocs/mkdocs.yml'),
MKDOCS_CONTAINER_NAME: z.string().default('mkdocs-changemaker'),
MKDOCS_SITE_SERVER_URL: z.string().default('http://mkdocs-site-server-changemaker:80'),
MKDOCS_SITE_SERVER_PORT: z.coerce.number().default(4004),
// Docker (container status dashboard + service management)
DOCKER_PROXY_URL: z.string().default('http://docker-socket-proxy:2375'),
DOCKER_NETWORK_NAME: z.string().default('changemaker-lite'),
NEWT_CONTAINER_NAME: z.string().default('newt-changemaker'),
NEWT_COMPOSE_SERVICE: z.string().default('newt'),
// Monitoring Services (behind 'monitoring' profile)
PROMETHEUS_URL: z.string().default('http://prometheus-changemaker:9090'),
PROMETHEUS_PORT: z.coerce.number().default(9090),
GRAFANA_URL: z.string().default('http://grafana-changemaker:3000'),
GRAFANA_PORT: z.coerce.number().default(3005),
GRAFANA_EMBED_PORT: z.coerce.number().default(8894),
ALERTMANAGER_URL: z.string().default('http://alertmanager-changemaker:9093'),
ALERTMANAGER_PORT: z.coerce.number().default(9093),
ALERTMANAGER_EMBED_PORT: z.coerce.number().default(8895),
CADVISOR_URL: z.string().default('http://cadvisor-changemaker:8080'),
CADVISOR_PORT: z.coerce.number().default(8086),
NODE_EXPORTER_URL: z.string().default('http://node-exporter-changemaker:9100'),
NODE_EXPORTER_PORT: z.coerce.number().default(9100),
REDIS_EXPORTER_URL: z.string().default('http://redis-exporter-changemaker:9121'),
REDIS_EXPORTER_PORT: z.coerce.number().default(9121),
GOTIFY_URL: z.string().default('http://gotify-changemaker:80'),
GOTIFY_PORT: z.coerce.number().default(8889),
// GeoIP (MaxMind GeoLite2)
MAXMIND_ACCOUNT_ID: z.string().default(''),
MAXMIND_LICENSE_KEY: z.string().default(''),
GEOIP_DB_PATH: z.string().default('/data/geoip/GeoLite2-City.mmdb'),
// JSN -> cmlite bridge shared secret. Must match JSN's CMLITE_BRIDGE_SECRET.
// Required for /api/auth/bridge/users and other /api/*/bridge/* endpoints to
// accept calls. Generate with: openssl rand -hex 32
JSN_BRIDGE_SECRET: z.string().min(32).optional(),
});
export type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
// GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT are now validated as required
// via .min(32) above — no more silent JWT_ACCESS_SECRET fallback. If either is
// missing, the schema check above exits with a clear error.
return result.data;
}
export const env = validateEnv();