Files
openwork/packaging/docker/docker-compose.den-dev.yml
ben 0002a8e030 fix(den-api): surface invite email failures instead of silently dropping (#1483)
* fix(den-api): surface invitation email send failures instead of swallowing

Loops failures in sendDenOrganizationInvitationEmail and
sendDenVerificationEmail were being caught and logged at warn level,
so the HTTP handlers still returned 201 'Invitation created' even when
no email ever left the process. Ben observed this with two live
invitations: the DB row was pending, the UI showed it, but one of two
recipients never received the email and clicking resend re-ran the same
silent-failure path.

Root cause (from an explore audit):
- email.ts:129,132 swallowed non-2xx Loops responses and fetch throws.
- invitations.ts awaited the send and unconditionally returned 2xx.
- There is no 'skip email if user already exists' branch anywhere;
  the Slack hypothesis was wrong. The failure mode is provider-side and
  was invisible because of the swallow.

Changes:
- Introduce DenEmailSendError with a stable reason tagged union
  (loops_not_configured | loops_rejected | loops_network).
- sendDenOrganizationInvitationEmail and sendDenVerificationEmail now
  throw DenEmailSendError on failure. Dev-mode short-circuit is
  preserved (still logs the payload and returns cleanly).
- POST /v1/orgs/:orgId/invitations catches DenEmailSendError, logs via
  console.error with a stable [auth][invite_email_failed] prefix
  (greppable across deployments), and returns 502
  invitation_email_failed with a human-readable message and the
  invitationId so the UI can correlate and offer a retry. The row is
  left pending intentionally so the next submit becomes a real resend.
- Document the 502 response in the OpenAPI describeRoute.

Operator note: if LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL is unset the
signup OTP endpoint will now return a real error instead of silently
stranding the user at the OTP screen forever. This is intentional; the
previous behavior was a latent signup-breaking bug.

* fix(den-api): tolerate missing apps/desktop/package.json in Docker build

PR #1476 introduced a build step that reads apps/desktop/package.json to
bake in a default latest-app-version, but packaging/docker/Dockerfile.den
does not ship the Tauri desktop sources. As a result, the den-dev Docker
stack fails to build after the PR landed. Gracefully fall back to 0.0.0
(matching the runtime default) when the file is absent, and allow a
DEN_API_LATEST_APP_VERSION env override so deployers can still pin a
real value.

* test(den-api): add smoke script for invite email failure paths

scripts/smoke-email-failures.mjs exercises the DenEmailSendError paths
against the built dist/ of den-api. Ships with instructions so a
reviewer can rerun it inside the docker-compose den-dev container with
a single command.

Also parameterises OPENWORK_DEV_MODE in the den compose service so the
failure paths can be reached from outside the container when needed
(defaults to 1; override with OPENWORK_DEV_MODE=0 at compose time).
2026-04-17 17:06:52 -07:00

150 lines
6.0 KiB
YAML

# docker-compose.den-dev.yml — Den local testability stack
#
# Usage (from repo root):
# docker compose -f packaging/docker/docker-compose.den-dev.yml up
#
# Then open the printed web UI or Den demo URL.
#
# Env overrides (optional, via export or .env):
# DEN_API_PORT — host port to map to Den control plane :8788
# DEN_WEB_PORT — host port to map to the cloud web app :3005
# DEN_WORKER_PROXY_PORT — host port to map to the worker proxy :8789
# DEN_MYSQL_PORT — host port to map to MySQL :3306
# DEN_BETTER_AUTH_SECRET — Better Auth secret (auto-generated by den-dev-up.sh)
# DEN_DB_ENCRYPTION_KEY — dev-only DB encryption key for encrypted columns
# — defaults to a premade local key for Docker smoke tests
# — generate a replacement with: openssl rand -base64 128
# DEN_PUBLIC_HOST — browser-facing host/IP for LAN access (set by den-dev-up.sh)
# DEN_BETTER_AUTH_URL — browser-facing auth origin (default: http://<DEN_PUBLIC_HOST>:<DEN_WEB_PORT>)
# DEN_BETTER_AUTH_TRUSTED_ORIGINS — Better Auth trusted origins (defaults to DEN_CORS_ORIGINS)
# DEN_CORS_ORIGINS — comma-separated trusted origins for Better Auth + CORS
# DEN_PROVISIONER_MODE — stub, render, or daytona (default: stub)
# DEN_WORKER_URL_TEMPLATE — worker URL template used by stub provisioning
# DAYTONA_API_URL / DAYTONA_API_KEY / DAYTONA_TARGET / DAYTONA_SNAPSHOT
# — optional Daytona passthrough vars when DEN_PROVISIONER_MODE=daytona
# POLAR_FEATURE_GATE_ENABLED / POLAR_API_BASE / POLAR_ACCESS_TOKEN
# POLAR_PRODUCT_ID / POLAR_BENEFIT_ID / POLAR_SUCCESS_URL / POLAR_RETURN_URL
# — optional Polar passthrough vars for billing/paywall testing
x-shared: &shared
restart: unless-stopped
services:
mysql:
image: mysql:8.4
command:
- --performance_schema=OFF
- --innodb-buffer-pool-size=64M
- --innodb-log-buffer-size=8M
- --tmp-table-size=16M
- --max-heap-table-size=16M
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: openwork_den
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -ppassword --silent"]
interval: 5s
timeout: 5s
retries: 30
start_period: 10s
ports:
- "${DEN_MYSQL_PORT:-3306}:3306"
volumes:
- den-mysql-data:/var/lib/mysql
den:
<<: *shared
build:
context: ../../
dockerfile: packaging/docker/Dockerfile.den
depends_on:
mysql:
condition: service_healthy
ports:
- "${DEN_API_PORT:-8788}:8788"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8788/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 5s
retries: 30
start_period: 120s
environment:
CI: "true"
OPENWORK_DEV_MODE: ${OPENWORK_DEV_MODE:-1}
DATABASE_URL: mysql://root:password@mysql:3306/openwork_den
BETTER_AUTH_SECRET: ${DEN_BETTER_AUTH_SECRET:-dev-den-local-auth-secret-please-override-1234567890}
DEN_DB_ENCRYPTION_KEY: ${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890}
BETTER_AUTH_URL: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
DEN_BETTER_AUTH_TRUSTED_ORIGINS: ${DEN_BETTER_AUTH_TRUSTED_ORIGINS:-}
PORT: "8788"
CORS_ORIGINS: ${DEN_CORS_ORIGINS:-http://localhost:3005,http://127.0.0.1:3005,http://0.0.0.0:3005,http://localhost:8788,http://127.0.0.1:8788}
PROVISIONER_MODE: ${DEN_PROVISIONER_MODE:-stub}
WORKER_URL_TEMPLATE: ${DEN_WORKER_URL_TEMPLATE:-}
POLAR_FEATURE_GATE_ENABLED: ${POLAR_FEATURE_GATE_ENABLED:-false}
POLAR_API_BASE: ${POLAR_API_BASE:-}
POLAR_ACCESS_TOKEN: ${POLAR_ACCESS_TOKEN:-}
POLAR_PRODUCT_ID: ${POLAR_PRODUCT_ID:-}
POLAR_BENEFIT_ID: ${POLAR_BENEFIT_ID:-}
POLAR_SUCCESS_URL: ${POLAR_SUCCESS_URL:-}
POLAR_RETURN_URL: ${POLAR_RETURN_URL:-}
DAYTONA_API_URL: ${DAYTONA_API_URL:-}
DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
DAYTONA_TARGET: ${DAYTONA_TARGET:-}
DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-}
DAYTONA_WORKER_PROXY_BASE_URL: ${DEN_DAYTONA_WORKER_PROXY_BASE_URL:-http://localhost:8789}
worker-proxy:
<<: *shared
build:
context: ../../
dockerfile: packaging/docker/Dockerfile.den-worker-proxy
depends_on:
mysql:
condition: service_healthy
ports:
- "${DEN_WORKER_PROXY_PORT:-8789}:8789"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8789/unknown').then((res)=>process.exit([404,502].includes(res.status)?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 5s
retries: 30
start_period: 90s
environment:
CI: "true"
DATABASE_URL: mysql://root:password@mysql:3306/openwork_den
PORT: "8789"
OPENWORK_DAYTONA_ENV_PATH: ${OPENWORK_DAYTONA_ENV_PATH:-}
DAYTONA_API_URL: ${DAYTONA_API_URL:-}
DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
DAYTONA_TARGET: ${DAYTONA_TARGET:-}
DAYTONA_OPENWORK_PORT: ${DAYTONA_OPENWORK_PORT:-8787}
DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS: ${DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS:-86400}
web:
<<: *shared
build:
context: ../../
dockerfile: packaging/docker/Dockerfile.den-web
command: ["sh", "-lc", "npm run build && npm run start"]
depends_on:
den:
condition: service_healthy
ports:
- "${DEN_WEB_PORT:-3005}:3005"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3005/api/den/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 10s
retries: 30
start_period: 180s
environment:
CI: "true"
OPENWORK_DEV_MODE: "1"
DEN_API_BASE: http://den:8788
DEN_AUTH_FALLBACK_BASE: http://den:8788
DEN_AUTH_ORIGIN: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
NEXT_PUBLIC_OPENWORK_AUTH_CALLBACK_URL: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
volumes:
den-mysql-data: