diff --git a/.env.example b/.env.example index cf804f8d..8f2e1443 100644 --- a/.env.example +++ b/.env.example @@ -7,10 +7,13 @@ GOCHAT_SERVER_MODE=release GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.CHANGE_ME.example.com GOCHAT_SERVER_TRUSTED_PROXIES=10.0.0.0/8 GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME@db.CHANGE_ME.example.com:5432/gochat_production?sslmode=verify-full -GOCHAT_REDIS_DSN=rediss://:CHANGE_ME@redis.CHANGE_ME.example.com:6380/0 +GOCHAT_REDIS_DSN=redis://redis:6379/1 -# Release mode requires external PostgreSQL/Redis endpoints with verified TLS. -# Set a complete GOCHAT_DATABASE_DSN with sslmode=verify-ca or verify-full. +# Release mode requires external PostgreSQL and Redis endpoints. PostgreSQL must +# set exactly one sslmode: use disable only on a trusted private network, or use +# verify-ca/verify-full with the certificate settings below. +# Redis may omit authentication only on a trusted private Docker network. Use +# rediss:// with authentication when traffic crosses an untrusted network. # Compose passes this value unchanged to every database client. Keep these fixed # container paths in the DSN and point the host variables at untracked files. # GOCHAT_DATABASE_DSN='postgres://user:pass@db.example.com:5432/gochat?sslmode=verify-full&sslrootcert=/run/secrets/external-db-ca.crt&sslcert=/run/secrets/external-db-client.crt&sslkey=/run/secrets/external-db-client.key' diff --git a/.gitignore b/.gitignore index 3ce5d445..011bdadf 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ log/ # Storage storage/ /backend/uploads/ +/deploy/docker/data/ # Dependencies vendor/ diff --git a/backend/cmd/route_parity/main.go b/backend/cmd/route_parity/main.go index ed2d9948..1eda7852 100644 --- a/backend/cmd/route_parity/main.go +++ b/backend/cmd/route_parity/main.go @@ -23,6 +23,7 @@ type sourceDecl struct { } var criticalRoutes = []route{ + {Method: "GET", Path: "/", Controller: "dashboard#index", Source: "routes.rb:17"}, {Method: "GET", Path: "/app", Controller: "dashboard#index", Source: "routes.rb:19"}, {Method: "GET", Path: "/app/*params", Controller: "dashboard#index", Source: "routes.rb:20"}, {Method: "GET", Path: "/.well-known/assetlinks.json", Controller: "android_app#assetlinks", Source: "routes.rb:657"}, diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2f086586..fa022219 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -276,7 +276,7 @@ func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) { assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes") } -func TestValidate_ReleaseDatabaseTLS(t *testing.T) { +func TestValidate_ReleaseDatabaseTransport(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.acme.test"}}}, Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, @@ -292,7 +292,7 @@ func TestValidate_ReleaseDatabaseTLS(t *testing.T) { dsn string wantErr bool }{ - {"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", true}, + {"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", false}, {"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true}, {"external duplicate downgrade", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable&sslmode=verify-full", true}, {"external duplicate allowed", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full&sslmode=verify-full", true}, @@ -329,13 +329,12 @@ func TestValidateProductionDatabaseDSN_RejectsHostMatrix(t *testing.T) { require.NoError(t, scanner.Err()) } -func TestProductionDatabaseTLSRunbookContract(t *testing.T) { +func TestProductionDatabaseTransportRunbookContract(t *testing.T) { runbook, err := os.ReadFile("../../../docs/ops/02-production-operations.md") require.NoError(t, err) runbookText := string(runbook) - assert.Equal(t, 1, strings.Count(runbookText, "`sslmode=verify-ca|verify-full`")) - assert.Equal(t, 1, strings.Count(runbookText, "sslmode=")) - assert.NotContains(t, runbookText, "sslmode=disable") + assert.Contains(t, runbookText, "use `disable` only on a trusted private network") + assert.Contains(t, runbookText, "`verify-ca`/`verify-full`") assert.NotContains(t, runbookText, "sslmode=require") } diff --git a/backend/internal/config/security_hardening_test.go b/backend/internal/config/security_hardening_test.go index b53581e3..c5e692f7 100644 --- a/backend/internal/config/security_hardening_test.go +++ b/backend/internal/config/security_hardening_test.go @@ -28,8 +28,7 @@ func TestReleaseTransportAndCORSValidationFailsClosed(t *testing.T) { mutate func(*Config) message string }{ - {"database certificate not verified", func(c *Config) { c.Database.DSN = "postgres://user:pass@db.acme.test/gochat?sslmode=require" }, "verify-full or verify-ca"}, - {"plain redis", func(c *Config) { c.Redis.DSN = "redis://:redis-secret@redis.acme.test:6379" }, "must use rediss"}, + {"database TLS without identity verification", func(c *Config) { c.Database.DSN = "postgres://user:pass@db.acme.test/gochat?sslmode=require" }, "sslmode=disable, verify-full or verify-ca"}, {"redis certificate not verified", func(c *Config) { c.Redis.DSN = "rediss://:redis-secret@redis.acme.test:6379?insecure_skip_verify=true" }, "cannot be disabled"}, {"placeholder origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"https://example.com"} }, "not deployable"}, {"non HTTPS origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"http://chat.acme.test"} }, "exact HTTPS origin"}, @@ -45,6 +44,18 @@ func TestReleaseTransportAndCORSValidationFailsClosed(t *testing.T) { } } +func TestReleaseAllowsPasswordProtectedRedisWithoutTLS(t *testing.T) { + cfg := validReleaseConfig() + cfg.Redis.DSN = "redis://:redis-secret@redis.acme.test:6379" + require.NoError(t, Validate(cfg)) +} + +func TestReleaseAllowsPrivateRedisWithoutCredentials(t *testing.T) { + cfg := validReleaseConfig() + cfg.Redis.DSN = "redis://redis:6379/1" + require.NoError(t, Validate(cfg)) +} + func TestSecuritySettingsLoadFromEnvironment(t *testing.T) { workingDir, err := os.Getwd() require.NoError(t, err) diff --git a/backend/internal/config/validator.go b/backend/internal/config/validator.go index 4aed0387..add28da7 100644 --- a/backend/internal/config/validator.go +++ b/backend/internal/config/validator.go @@ -128,16 +128,12 @@ func Validate(cfg *Config) error { if err := ValidateProductionDatabaseDSN(cfg.Database.DSN); err != nil { return err } - if redisURL.User == nil { - return fmt.Errorf("production Redis credentials are required") + if redisURL.User != nil { + if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) { + return fmt.Errorf("production Redis password is required and must not contain placeholders") + } } - if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) { - return fmt.Errorf("production Redis password is required and must not contain placeholders") - } - if redisURL.Scheme != "rediss" { - return fmt.Errorf("production Redis DSN must use rediss with certificate verification") - } - if strings.EqualFold(redisURL.Query().Get("insecure_skip_verify"), "true") { + if redisURL.Scheme == "rediss" && strings.EqualFold(redisURL.Query().Get("insecure_skip_verify"), "true") { return fmt.Errorf("production Redis TLS certificate verification cannot be disabled") } if len(cfg.Server.CORS.AllowedOrigins) == 0 { @@ -205,8 +201,8 @@ func ValidateProductionDatabaseDSN(dsn string) error { if len(modes) != 1 { return fmt.Errorf("production database DSN must contain exactly one sslmode") } - if modes[0] != "verify-ca" && modes[0] != "verify-full" { - return fmt.Errorf("production database DSN must use sslmode=verify-full or verify-ca") + if modes[0] != "disable" && modes[0] != "verify-ca" && modes[0] != "verify-full" { + return fmt.Errorf("production database DSN must use sslmode=disable, verify-full or verify-ca") } fixedPaths := map[string]string{ diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 37fc9d2c..a969ba6a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -219,8 +219,9 @@ func RegisterRoutes( engine.GET("/tiktok/callback", tiktokChannelCallback(db)) // Dashboard shell routes used by Chatwoot mailer and push deep links. - // Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index. + // Reference: Chatwoot routes.rb root, `get '/app'`, `get '/app/*params'` -> DashboardController#index. engine.GET("/runtime-config.js", dashboardRuntimeConfig) + engine.GET("/", dashboardIndex) engine.GET("/app", dashboardIndex) engine.GET("/app/*params", dashboardIndex) engine.NoRoute(dashboardStatic) @@ -2266,10 +2267,6 @@ func dashboardRuntimeConfig(c *gin.Context) { } func dashboardStatic(c *gin.Context) { - if c.Request.URL.Path == "/" { - c.Status(http.StatusNotFound) - return - } http.FileServer(http.Dir(frontendDistDir())).ServeHTTP(c.Writer, c.Request) } diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 0be4864c..14f799ca 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -53,6 +53,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { } expected := []string{ + "GET /", "GET /.well-known/assetlinks.json", "GET /.well-known/apple-app-site-association", "GET /.well-known/microsoft-identity-association.json", diff --git a/backend/scripts/db_backup_test.sh b/backend/scripts/db_backup_test.sh index 87a1387d..91e4b048 100755 --- a/backend/scripts/db_backup_test.sh +++ b/backend/scripts/db_backup_test.sh @@ -116,11 +116,8 @@ exec /usr/bin/install "$@" EOF chmod +x "$tmp/bin/psql" "$tmp/bin/pg_dump" "$tmp/bin/pg_restore" "$tmp/bin/tar" "$tmp/bin/install" -if GOCHAT_DATABASE_DSN='postgres://test@db.example.test/test?sslmode=disable' "$tmp/scripts/database_client_entrypoint.sh" --check >"$tmp/rejected" 2>&1; then - echo 'backup gate accepted sslmode=disable' >&2 - exit 1 -fi -grep -F 'sslmode must be verify-ca or verify-full' "$tmp/rejected" >/dev/null +GOCHAT_DATABASE_DSN='postgres://test@db.example.test/test?sslmode=disable' \ + "$tmp/scripts/database_client_entrypoint.sh" --check while IFS='|' read -r name dsn; do if GOCHAT_DATABASE_DSN=$dsn "$tmp/scripts/database_client_entrypoint.sh" --check >"$tmp/rejected" 2>&1; then echo "backup gate accepted $name" >&2 diff --git a/deploy/docker/database_client_entrypoint.sh b/deploy/docker/database_client_entrypoint.sh index 42d18bac..c17f5ce1 100755 --- a/deploy/docker/database_client_entrypoint.sh +++ b/deploy/docker/database_client_entrypoint.sh @@ -91,8 +91,8 @@ validate_production_database_dsn() { echo "$database_dsn_label must contain exactly one sslmode" >&2 return 1 fi - if [ "$sslmode" != verify-ca ] && [ "$sslmode" != verify-full ]; then - echo "$database_dsn_label sslmode must be verify-ca or verify-full" >&2 + if [ "$sslmode" != disable ] && [ "$sslmode" != verify-ca ] && [ "$sslmode" != verify-full ]; then + echo "$database_dsn_label sslmode must be disable, verify-ca or verify-full" >&2 return 1 fi diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index 4776bce1..d96c6e1e 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -42,10 +42,10 @@ x-gochat-environment: &gochat-environment GOCHAT_SERVER_PORT: 3000 GOCHAT_SERVER_MODE: release GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: ${GOCHAT_SERVER_CORS_ALLOWED_ORIGINS:?set production CORS origins} - GOCHAT_DATABASE_DSN: &gochat-database-dsn ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with sslmode=verify-ca or verify-full} + GOCHAT_DATABASE_DSN: &gochat-database-dsn ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with an explicit sslmode} GOCHAT_DATABASE_RUN_MIGRATIONS: "false" GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations - GOCHAT_REDIS_DSN: ${GOCHAT_REDIS_DSN:?set an external Redis rediss:// DSN} + GOCHAT_REDIS_DSN: ${GOCHAT_REDIS_DSN:?set an external Redis DSN} GOCHAT_SEARCH_ENGINE: meilisearch GOCHAT_SEARCH_HOST: http://meilisearch:7700 GOCHAT_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY} @@ -68,7 +68,7 @@ services: MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY} MEILI_NO_ANALYTICS: "true" volumes: - - meili_data:/meili_data + - ./data/meilisearch:/meili_data healthcheck: test: ["CMD", "wget", "--no-verbose", "--spider", "http://127.0.0.1:7700/health"] interval: 5s @@ -90,7 +90,7 @@ services: ports: - "127.0.0.1:${GOCHAT_PORT:-3000}:3000" volumes: - - gochat_storage:/app/storage + - ./data/storage:/app/storage - *database-client-entrypoint - *postgres-tls-ca - *postgres-tls-client-cert @@ -129,7 +129,7 @@ services: <<: *gochat-environment GOCHAT_DATABASE_RUN_MIGRATIONS: "false" volumes: - - gochat_storage:/app/storage + - ./data/storage:/app/storage - *database-client-entrypoint - *postgres-tls-ca - *postgres-tls-client-cert @@ -177,16 +177,16 @@ services: GOCHAT_BACKUP_METRICS_FILE: /metrics/gochat_backup.prom GOCHAT_VERSION: ${GOCHAT_IMAGE_REF} volumes: - - gochat_storage:/source/storage:ro - - shangwutong_backups:/source/connector:ro - - ${GOCHAT_BACKUP_DIR:-./backups/local}:/backup/local + - ./data/storage:/source/storage:ro + - ./data/connector-backups:/source/connector:ro + - ${GOCHAT_BACKUP_DIR:-./data/backups/local}:/backup/local - type: bind source: ${GOCHAT_BACKUP_OFFSITE_DIR:?set an existing external off-site mount point} target: /backup/offsite bind: create_host_path: false - ${GOCHAT_BACKUP_PASSPHRASE_FILE:-./.secrets/backup-passphrase}:/run/secrets/backup-passphrase:ro - - backup_metrics:/metrics + - ./data/backup-metrics:/metrics - *database-client-entrypoint - *postgres-tls-ca - *postgres-tls-client-cert @@ -207,8 +207,8 @@ services: GOCHAT_CONNECTOR_DB_PATH: /restore/connector/connector.db GOCHAT_BACKUP_PASSPHRASE_FILE: /run/secrets/backup-passphrase volumes: - - gochat_storage:/restore/storage - - shangwutong_data:/restore/connector + - ./data/storage:/restore/storage + - ./data/connector:/restore/connector - type: bind source: ${GOCHAT_BACKUP_OFFSITE_DIR:?set an existing external off-site mount point} target: /backup/offsite @@ -236,8 +236,8 @@ services: SWT_OUTBOUND_WORKERS: ${SWT_OUTBOUND_WORKERS:-8} SWT_SHUTDOWN_TIMEOUT: ${SWT_SHUTDOWN_TIMEOUT:-30s} volumes: - - shangwutong_data:/data - - shangwutong_backups:/backup + - ./data/connector:/data + - ./data/connector-backups:/backup healthcheck: test: ["CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:9100/readyz"] interval: 30s @@ -261,8 +261,8 @@ services: - "127.0.0.1:24224:24224" volumes: - ../fluentd/fluent.conf:/fluentd/etc/fluent.conf:ro - - fluentd_logs:/fluentd/log - - fluentd_buffer:/fluentd/buffer + - ./data/fluentd/log:/fluentd/log + - ./data/fluentd/buffer:/fluentd/buffer postgres-exporter: image: quay.io/prometheuscommunity/postgres-exporter:v0.17.1@sha256:38606faa38c54787525fb0ff2fd6b41b4cfb75d455c1df294927c5f611699b17 @@ -284,7 +284,7 @@ services: image: oliver006/redis_exporter:v1.72.1@sha256:f90cae1e7ecc6ac223d04bdb0c95e084918baced9f81f08c3d01c2f11bff72bf restart: always environment: - REDIS_ADDR: ${GOCHAT_REDIS_DSN:?set an external Redis rediss:// DSN} + REDIS_ADDR: ${GOCHAT_REDIS_DSN:?set an external Redis DSN} logging: *gochat-logging blackbox-exporter: @@ -300,7 +300,7 @@ services: restart: always command: ["--collector.disable-defaults", "--collector.textfile", "--collector.textfile.directory=/var/lib/node_exporter/textfile_collector"] volumes: - - backup_metrics:/var/lib/node_exporter/textfile_collector:ro + - ./data/backup-metrics:/var/lib/node_exporter/textfile_collector:ro logging: *gochat-logging cadvisor: @@ -331,7 +331,7 @@ services: read_only: true bind: create_host_path: false - - alertmanager_data:/alertmanager + - ./data/alertmanager:/alertmanager logging: *gochat-logging prometheus: @@ -350,16 +350,5 @@ services: volumes: - ../prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ../../backend/configs/prometheus_alerts.yml:/etc/prometheus/rules/gochat.yml:ro - - prometheus_data:/prometheus + - ./data/prometheus:/prometheus logging: *gochat-logging - -volumes: - meili_data: - gochat_storage: - shangwutong_data: - shangwutong_backups: - backup_metrics: - prometheus_data: - alertmanager_data: - fluentd_logs: - fluentd_buffer: diff --git a/deploy/docker/preflight_test.sh b/deploy/docker/preflight_test.sh index 431b8bcc..d6264469 100755 --- a/deploy/docker/preflight_test.sh +++ b/deploy/docker/preflight_test.sh @@ -105,9 +105,10 @@ expect_failure 'a mutable production image' 'must be pinned to a sha256 digest' unset TEST_MUTABLE_IMAGE export GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=disable' -expect_failure 'an external database without verified TLS' 'sslmode must be verify-ca or verify-full' +run_preflight +grep -F 'production preflight passed' "$tmp/output" >/dev/null GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=require' -expect_failure 'an external database without certificate verification' 'sslmode must be verify-ca or verify-full' +expect_failure 'an external database without certificate verification' 'sslmode must be disable, verify-ca or verify-full' GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=disable&sslmode=verify-full' expect_failure 'duplicate sslmode parameters with a disabling first value' 'must contain exactly one sslmode' GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=verify-full&sslmode=verify-full' diff --git a/docs/ops/01-rolling-upgrade.md b/docs/ops/01-rolling-upgrade.md index 39d3f528..0e6110e5 100644 --- a/docs/ops/01-rolling-upgrade.md +++ b/docs/ops/01-rolling-upgrade.md @@ -10,9 +10,9 @@ Production web and worker processes never migrate on startup. The Compose file also requires `GOCHAT_IMAGE_REF` to be an immutable `image@sha256:digest`; tags such as `latest` are not an acceptable rollback record. -The bundled `gochat_storage` volume is shared and durable for replicas on one -Docker host. Multi-host replicas must provision that volume with a shared -volume driver/filesystem; never use separate node-local volumes. +Persistent files live under `deploy/docker/data/` beside the production Compose +file. Multi-host replicas must mount shared storage at that path; never use +separate node-local attachment directories. ## Recovery objectives @@ -40,7 +40,7 @@ export GOCHAT_CONNECTOR_BACKUP_NAME="connector-$(date -u +%Y%m%dT%H%M%SZ).db" ``` Backup/restore are audited one-shot containers and run as root only to read or -rebuild Docker volumes; web and worker remain non-root. +rebuild bind-mounted data directories; web and worker remain non-root. For an external database using certificate files, keep the three DSN paths fixed as documented in `.env.example`. Set the host files' group to `GOCHAT_DATABASE_TLS_GID` and grant that group read permission (`0640` for the @@ -67,7 +67,8 @@ off-site copy exits non-zero and must alert. ## Clean-environment restore rehearsal -Use an isolated host/project with empty volumes and an empty PostgreSQL database. +Use an isolated host/project with an empty `deploy/docker/data/` directory and +an empty PostgreSQL database. Do not point this procedure at the live project. ```bash diff --git a/docs/ops/02-production-operations.md b/docs/ops/02-production-operations.md index 0be14e3c..8d169bff 100644 --- a/docs/ops/02-production-operations.md +++ b/docs/ops/02-production-operations.md @@ -5,10 +5,10 @@ All commands run from the repository root. Set `COMPOSE='docker compose -f deplo ## Install and preflight - **Owner:** release engineer; SRE on-call approves the target host. -- **Prerequisites:** Docker Compose 2.26+, immutable GoChat/Connector digests, external backup mount, untracked backup passphrase and Alertmanager HTTPS webhook files. For external PostgreSQL, set one complete `GOCHAT_DATABASE_DSN` with exactly one `sslmode=verify-ca|verify-full`. When using a private CA or client certificate, keep the DSN paths fixed at `/run/secrets/external-db-ca.crt`, `/run/secrets/external-db-client.crt`, and `/run/secrets/external-db-client.key`; set the three corresponding untracked host-file variables plus `GOCHAT_DATABASE_TLS_GID`, assign that group to the files, and grant group read permission (`0640` for the key). Compose mounts the files read-only and adds the group to every database client. +- **Prerequisites:** Docker Compose 2.26+, immutable GoChat/Connector digests, external backup mount, untracked backup passphrase and Alertmanager HTTPS webhook files. Create the required `deploy/docker/data/` subdirectories before startup. For external PostgreSQL, set one complete `GOCHAT_DATABASE_DSN` with exactly one `sslmode`: use `disable` only on a trusted private network, or use `verify-ca`/`verify-full` for TLS. Redis may use unauthenticated `redis://` only on a trusted private Docker network; use authenticated `rediss://` when traffic crosses an untrusted network. When PostgreSQL uses a private CA or client certificate, keep the DSN paths fixed at `/run/secrets/external-db-ca.crt`, `/run/secrets/external-db-client.crt`, and `/run/secrets/external-db-client.key`; set the three corresponding untracked host-file variables plus `GOCHAT_DATABASE_TLS_GID`, assign that group to the files, and grant group read permission (`0640` for the key). Compose mounts the files read-only and adds the group to every database client. - **Commands:** `deploy/docker/preflight.sh .env`; then `$COMPOSE config --quiet`; then `$COMPOSE --profile ops run --rm migrate`; finally `$COMPOSE up -d --wait`. - **Success / failure:** success means preflight exits 0, every non-profile service is healthy/running, and rendered `gochat`, `migrate`, `backup`, `restore`, and `postgres-exporter` configuration uses the same database DSN; any mutable image, missing secret file, DSN mismatch, migration error, or unhealthy service is failure. -- **Rollback:** `$COMPOSE down` leaves named data volumes intact; if migration ran, follow `01-rolling-upgrade.md` and restore the preflight backup instead of running schema down. +- **Rollback:** `$COMPOSE down` leaves `deploy/docker/data/` intact; if migration ran, follow `01-rolling-upgrade.md` and restore the preflight backup instead of running schema down. - **Drill cadence:** before every release and quarterly on a clean host. ## TLS and reverse proxy diff --git a/docs/parity/chatwoot-routes-static.md b/docs/parity/chatwoot-routes-static.md index 850fabc9..edbe3a37 100644 --- a/docs/parity/chatwoot-routes-static.md +++ b/docs/parity/chatwoot-routes-static.md @@ -1,6 +1,6 @@ # Chatwoot Route Source Declarations -Source: `docs/chatwoot/config/routes.rb` +Source: `../docs/chatwoot/config/routes.rb` Ruby is unavailable in the current workspace, so this file records static route DSL declarations with source lines. It is a repeatable fallback until `bin/rails routes` can run. diff --git a/docs/parity/gochat-routes.txt b/docs/parity/gochat-routes.txt index 81b5074e..97af8c2d 100644 --- a/docs/parity/gochat-routes.txt +++ b/docs/parity/gochat-routes.txt @@ -21,11 +21,13 @@ DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/documents/:document_id DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes/:inbox_id DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:scenario_id +DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/skills/:skill_id DELETE /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id DELETE /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id DELETE /api/v1/accounts/:account_id/captain/custom_tools/:tool_id DELETE /api/v1/accounts/:account_id/captain/documents/:document_id DELETE /api/v1/accounts/:account_id/captain/scenarios/:scenario_id +DELETE /api/v1/accounts/:account_id/captain/skills/:skill_id DELETE /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id DELETE /api/v1/accounts/:account_id/companies/:company_id DELETE /api/v1/accounts/:account_id/companies/:company_id/avatar @@ -38,6 +40,7 @@ DELETE /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes DELETE /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes/:attribute_name DELETE /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id DELETE /api/v1/accounts/:account_id/conversations/:conversation_id +DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/ai_takeover DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes/:attribute_name DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id @@ -96,8 +99,6 @@ DELETE /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id DELETE /api/v1/accounts/:account_id/portals/:portal_id/folders/:folder_id DELETE /api/v1/accounts/:account_id/portals/:portal_id/logo DELETE /api/v1/accounts/:account_id/portals/:portal_id/members/:member_id -DELETE /api/v1/accounts/:account_id/saml_settings -DELETE /api/v1/accounts/:account_id/saml_settings/:id DELETE /api/v1/accounts/:account_id/sla_policies/:id DELETE /api/v1/accounts/:account_id/sla_policies/:id/inboxes/:inbox_id DELETE /api/v1/accounts/:account_id/tags/:tag_id @@ -113,8 +114,6 @@ DELETE /api/v1/notification_subscriptions/:identifier DELETE /api/v1/notifications/:id DELETE /api/v1/notifications/destroy_all DELETE /api/v1/profile/avatar -DELETE /api/v1/profile/mfa -DELETE /api/v1/profile/mfa/ DELETE /api/v1/profile/sessions/:id DELETE /api/v1/push_subscriptions/:id DELETE /api/v1/widget/labels/:label_id @@ -128,6 +127,7 @@ DELETE /platform/api/v1/apps/:id/permissibles/:permissible_id DELETE /platform/api/v1/banners/:id DELETE /platform/api/v1/installation_configs/:id DELETE /platform/api/v1/users/:id +GET / GET /.well-known/apple-app-site-association GET /.well-known/assetlinks.json GET /.well-known/cf-custom-hostname-challenge/:id @@ -178,6 +178,7 @@ GET /api/v1/accounts/:account_id/canned_responses GET /api/v1/accounts/:account_id/canned_responses/ GET /api/v1/accounts/:account_id/canned_responses/:id GET /api/v1/accounts/:account_id/canned_responses/search +GET /api/v1/accounts/:account_id/captain/assistant_responses GET /api/v1/accounts/:account_id/captain/assistant_responses/ GET /api/v1/accounts/:account_id/captain/assistant_responses/:response_id GET /api/v1/accounts/:account_id/captain/assistants @@ -210,6 +211,8 @@ GET /api/v1/accounts/:account_id/captain/preferences GET /api/v1/accounts/:account_id/captain/preferences/ GET /api/v1/accounts/:account_id/captain/scenarios/ GET /api/v1/accounts/:account_id/captain/scenarios/:scenario_id +GET /api/v1/accounts/:account_id/captain/skills +GET /api/v1/accounts/:account_id/captain/skills/:skill_id GET /api/v1/accounts/:account_id/captain/tasks/follow_up GET /api/v1/accounts/:account_id/captain/tasks/label_suggestion GET /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id @@ -265,8 +268,10 @@ GET /api/v1/accounts/:account_id/csat_survey_responses GET /api/v1/accounts/:account_id/csat_survey_responses/ GET /api/v1/accounts/:account_id/csat_survey_responses/download GET /api/v1/accounts/:account_id/csat_survey_responses/metrics +GET /api/v1/accounts/:account_id/custom_attribute_definitions GET /api/v1/accounts/:account_id/custom_attribute_definitions/ GET /api/v1/accounts/:account_id/custom_attribute_definitions/:id +GET /api/v1/accounts/:account_id/custom_filters GET /api/v1/accounts/:account_id/custom_filters/ GET /api/v1/accounts/:account_id/custom_filters/:id GET /api/v1/accounts/:account_id/custom_roles @@ -387,8 +392,6 @@ GET /api/v1/accounts/:account_id/reports/labels GET /api/v1/accounts/:account_id/reports/outgoing_messages_count GET /api/v1/accounts/:account_id/reports/summary GET /api/v1/accounts/:account_id/reports/teams -GET /api/v1/accounts/:account_id/saml_settings -GET /api/v1/accounts/:account_id/saml_settings/:id GET /api/v1/accounts/:account_id/search GET /api/v1/accounts/:account_id/search/articles GET /api/v1/accounts/:account_id/search/contacts @@ -423,9 +426,8 @@ GET /api/v1/accounts/:account_id/webhooks/:webhook_id GET /api/v1/accounts/:account_id/whatsapp_calls/:id GET /api/v1/accounts/all GET /api/v1/auth/confirm_email -GET /api/v1/auth/mfa/status -GET /api/v1/auth/oauth/authorize -GET /api/v1/ldap/config +GET /api/v1/connector/shangwutong/inboxes +GET /api/v1/connector/shangwutong/inboxes/:inbox_id GET /api/v1/notifications GET /api/v1/notifications/unread_count GET /api/v1/oidc/authorize @@ -433,17 +435,13 @@ GET /api/v1/oidc/callback GET /api/v1/oidc/config GET /api/v1/oidc/discovery GET /api/v1/profile -GET /api/v1/profile/mfa -GET /api/v1/profile/mfa/ GET /api/v1/profile/sessions GET /api/v1/push_subscriptions -GET /api/v1/saml/login -GET /api/v1/saml/metadata -GET /api/v1/saml/slo GET /api/v1/sso_sessions/:session_id GET /api/v1/sso_sessions/user/:user_id GET /api/v1/sso_sessions/user/:user_id/count GET /api/v1/widget/campaigns +GET /api/v1/widget/config GET /api/v1/widget/contact GET /api/v1/widget/conversations GET /api/v1/widget/conversations/toggle_status @@ -493,7 +491,6 @@ GET /hc/:slug/:locale/categories/:category_slug/articles.json GET /hc/:slug/:locale/search GET /hc/:slug/articles/:article_slug GET /hc/:slug/sitemap.xml -GET /health GET /instagram/callback GET /linear/callback GET /microsoft/callback @@ -526,10 +523,10 @@ GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages +GET /runtime-config.js GET /shopify/callback GET /tiktok/callback GET /twitter/callback -GET /webhooks/fake/:identifier GET /webhooks/instagram GET /webhooks/tiktok/:business_id GET /webhooks/twitter @@ -601,6 +598,7 @@ PATCH /api/v1/accounts/:account_id/teams/:team_id PATCH /api/v1/accounts/:account_id/teams/:team_id/team_members PATCH /api/v1/accounts/:account_id/teams/:team_id/team_members/ PATCH /api/v1/accounts/:account_id/webhooks/:webhook_id +PATCH /api/v1/connector/shangwutong/inboxes/:inbox_id/contacts/:source_id PATCH /api/v1/profile PATCH /api/v1/widget/contact PATCH /api/v1/widget/contact/set_user @@ -653,6 +651,7 @@ POST /api/v1/accounts/:account_id/campaigns/:campaign_id/start POST /api/v1/accounts/:account_id/campaigns/:campaign_id/stop POST /api/v1/accounts/:account_id/canned_responses POST /api/v1/accounts/:account_id/canned_responses/ +POST /api/v1/accounts/:account_id/captain/assistant_responses POST /api/v1/accounts/:account_id/captain/assistant_responses/ POST /api/v1/accounts/:account_id/captain/assistant_responses/process POST /api/v1/accounts/:account_id/captain/assistants @@ -661,6 +660,7 @@ POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/documents/ POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/playground POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/ +POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/skills/:skill_id POST /api/v1/accounts/:account_id/captain/auto_reply_rules POST /api/v1/accounts/:account_id/captain/auto_reply_rules/ POST /api/v1/accounts/:account_id/captain/auto_reply_rules/evaluate @@ -686,6 +686,7 @@ POST /api/v1/accounts/:account_id/captain/message_reports POST /api/v1/accounts/:account_id/captain/rag/index/:response_id POST /api/v1/accounts/:account_id/captain/rag/query POST /api/v1/accounts/:account_id/captain/scenarios/ +POST /api/v1/accounts/:account_id/captain/skills POST /api/v1/accounts/:account_id/captain/tasks/follow_up POST /api/v1/accounts/:account_id/captain/tasks/label_suggestion POST /api/v1/accounts/:account_id/captain/tasks/reply_suggestion @@ -720,6 +721,7 @@ POST /api/v1/accounts/:account_id/contacts/import POST /api/v1/accounts/:account_id/contacts/merge POST /api/v1/accounts/:account_id/conversations POST /api/v1/accounts/:account_id/conversations/ +POST /api/v1/accounts/:account_id/conversations/:conversation_id/ai_takeover POST /api/v1/accounts/:account_id/conversations/:conversation_id/assign POST /api/v1/accounts/:account_id/conversations/:conversation_id/assignments POST /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes @@ -744,7 +746,9 @@ POST /api/v1/accounts/:account_id/conversations/:conversation_id/update_last_see POST /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/ POST /api/v1/accounts/:account_id/conversations/filter POST /api/v1/accounts/:account_id/csat_survey_responses/:id/update_review_notes +POST /api/v1/accounts/:account_id/custom_attribute_definitions POST /api/v1/accounts/:account_id/custom_attribute_definitions/ +POST /api/v1/accounts/:account_id/custom_filters POST /api/v1/accounts/:account_id/custom_filters/ POST /api/v1/accounts/:account_id/custom_roles POST /api/v1/accounts/:account_id/custom_roles/ @@ -829,9 +833,6 @@ POST /api/v1/accounts/:account_id/portals/:portal_id/categories/reorder POST /api/v1/accounts/:account_id/portals/:portal_id/folders/ POST /api/v1/accounts/:account_id/portals/:portal_id/members/ POST /api/v1/accounts/:account_id/portals/:portal_id/send_instructions -POST /api/v1/accounts/:account_id/saml_settings -POST /api/v1/accounts/:account_id/saml_settings/ -POST /api/v1/accounts/:account_id/saml_settings/:id/toggle_active POST /api/v1/accounts/:account_id/sla_policies POST /api/v1/accounts/:account_id/sla_policies/:id/inboxes POST /api/v1/accounts/:account_id/tags/ @@ -856,32 +857,19 @@ POST /api/v1/accounts/:account_id/whatsapp_calls/:id/terminate POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording POST /api/v1/accounts/:account_id/whatsapp_calls/initiate POST /api/v1/auth/login -POST /api/v1/auth/login/mfa -POST /api/v1/auth/mfa/backup_codes -POST /api/v1/auth/mfa/disable -POST /api/v1/auth/mfa/enable -POST /api/v1/auth/mfa/verify -POST /api/v1/auth/oauth/callback POST /api/v1/auth/refresh POST /api/v1/auth/reset_password POST /api/v1/auth/switch_account -POST /api/v1/ldap/login -POST /api/v1/ldap/test +POST /api/v1/auth/ws_ticket POST /api/v1/notification_subscriptions POST /api/v1/notifications/:id/snooze POST /api/v1/notifications/:id/unread POST /api/v1/notifications/read_all POST /api/v1/profile/auto_offline POST /api/v1/profile/availability -POST /api/v1/profile/mfa -POST /api/v1/profile/mfa/ -POST /api/v1/profile/mfa/backup_codes -POST /api/v1/profile/mfa/verify POST /api/v1/profile/resend_confirmation POST /api/v1/profile/reset_access_token POST /api/v1/push_subscriptions -POST /api/v1/saml/acs -POST /api/v1/saml/slo POST /api/v1/sso_sessions/:session_id/terminate POST /api/v1/sso_sessions/user/:user_id/terminate_all POST /api/v1/widget/config @@ -942,7 +930,6 @@ POST /twilio/voice/call/:phone POST /twilio/voice/conference_status/:phone POST /twilio/voice/recording_status/:phone POST /twilio/voice/status/:phone -POST /webhooks/fake/:identifier POST /webhooks/instagram POST /webhooks/line/:line_channel_id POST /webhooks/shopify @@ -983,6 +970,7 @@ PUT /api/v1/accounts/:account_id/captain/custom_tools/:tool_id PUT /api/v1/accounts/:account_id/captain/preferences PUT /api/v1/accounts/:account_id/captain/preferences/ PUT /api/v1/accounts/:account_id/captain/scenarios/:scenario_id +PUT /api/v1/accounts/:account_id/captain/skills/:skill_id PUT /api/v1/accounts/:account_id/companies/:company_id PUT /api/v1/accounts/:account_id/contacts/:contact_id PUT /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id @@ -1021,8 +1009,6 @@ PUT /api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id PUT /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id PUT /api/v1/accounts/:account_id/portals/:portal_id/folders/:folder_id PUT /api/v1/accounts/:account_id/portals/:portal_id/members/:member_id -PUT /api/v1/accounts/:account_id/saml_settings -PUT /api/v1/accounts/:account_id/saml_settings/:id PUT /api/v1/accounts/:account_id/settings PUT /api/v1/accounts/:account_id/sla_policies/:id PUT /api/v1/accounts/:account_id/tags/:tag_id @@ -1031,7 +1017,8 @@ PUT /api/v1/accounts/:account_id/web_widgets/offline_messages/:offline_message_i PUT /api/v1/accounts/:account_id/web_widgets/offline_messages/:offline_message_id/dismiss PUT /api/v1/accounts/:account_id/webhooks/:webhook_id PUT /api/v1/auth/reset_password -PUT /api/v1/ldap/config +PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/messages/:message_id/status +PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/status PUT /api/v1/notifications/:id PUT /api/v1/oidc/config PUT /api/v1/profile @@ -1051,4 +1038,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 1053 +TOTAL: 1040 diff --git a/docs/parity/route-parity.md b/docs/parity/route-parity.md index 88900cad..fb5a8640 100644 --- a/docs/parity/route-parity.md +++ b/docs/parity/route-parity.md @@ -2,18 +2,22 @@ Generated from: -- GoChat route dump: `docs/parity/gochat-routes.txt` -- Chatwoot route source: `docs/chatwoot/config/routes.rb` +- GoChat route dump: `../docs/parity/gochat-routes.txt` +- Chatwoot route source: `../docs/chatwoot/config/routes.rb` This report covers tracked frontend-critical Chatwoot 4.15.1 routes from `docs/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 447 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 456 tracked critical routes. +Summary: 443 exact, 0 method-compatible, 9 parameter-compatible, 5 missing out of 457 tracked critical routes. ## Missing Critical Routes | Method | Chatwoot Path | GoChat Match | Controller | Source | Status | | --- | --- | --- | --- | --- | --- | -| - | - | - | - | - | none | +| DELETE | `/api/v1/profile/mfa` | `-` | `api/v1/profile/mfa#destroy` | `routes.rb:433` | missing | +| GET | `/api/v1/profile/mfa` | `-` | `api/v1/profile/mfa#show` | `routes.rb:433` | missing | +| POST | `/api/v1/profile/mfa` | `-` | `api/v1/profile/mfa#create` | `routes.rb:433` | missing | +| POST | `/api/v1/profile/mfa/backup_codes` | `-` | `api/v1/profile/mfa#backup_codes` | `routes.rb:435` | missing | +| POST | `/api/v1/profile/mfa/verify` | `-` | `api/v1/profile/mfa#verify` | `routes.rb:434` | missing | ## Method-Compatible Routes @@ -92,9 +96,9 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#destroy` | `routes.rb:342` | exact | | DELETE | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#destroy` | `routes.rb:440` | exact | | DELETE | `/api/v1/profile/avatar` | `/api/v1/profile/avatar` | `api/v1/profiles#avatar` | `routes.rb:422` | exact | -| DELETE | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#destroy` | `routes.rb:433` | exact | | DELETE | `/api/v1/profile/sessions/:id` | `/api/v1/profile/sessions/:id` | `api/v1/profile/sessions#destroy` | `routes.rb:445` | exact | | DELETE | `/api/v1/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | exact | +| GET | `/` | `/` | `dashboard#index` | `routes.rb:17` | exact | | GET | `/.well-known/apple-app-site-association` | `/.well-known/apple-app-site-association` | `apple_app#site_association` | `routes.rb:658` | exact | | GET | `/.well-known/assetlinks.json` | `/.well-known/assetlinks.json` | `android_app#assetlinks` | `routes.rb:657` | exact | | GET | `/.well-known/cf-custom-hostname-challenge/:id` | `/.well-known/cf-custom-hostname-challenge/:id` | `custom_domains#verify` | `routes.rb:660` | exact | @@ -218,7 +222,6 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/webhooks` | `/api/v1/accounts/:account_id/webhooks` | `api/v1/accounts/webhooks#index` | `routes.rb:342` | exact | | GET | `/api/v1/accounts/:account_id/whatsapp_calls/:id` | `/api/v1/accounts/:account_id/whatsapp_calls/:id` | `api/v1/accounts/whatsapp_calls#show` | `routes.rb:237` | exact | | GET | `/api/v1/profile` | `/api/v1/profile` | `api/v1/profiles#show` | `routes.rb:421` | exact | -| GET | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#show` | `routes.rb:433` | exact | | GET | `/api/v1/profile/sessions` | `/api/v1/profile/sessions` | `api/v1/profile/sessions#index` | `routes.rb:445` | exact | | GET | `/api/v1/widget/campaigns` | `/api/v1/widget/campaigns` | `api/v1/widget/campaigns#index` | `routes.rb:445` | exact | | GET | `/api/v1/widget/contact` | `/api/v1/widget/contact` | `api/v1/widget/contact#show` | `routes.rb:458` | exact | @@ -413,9 +416,6 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#create` | `routes.rb:440` | exact | | POST | `/api/v1/profile/auto_offline` | `/api/v1/profile/auto_offline` | `api/v1/profiles#auto_offline` | `routes.rb:425` | exact | | POST | `/api/v1/profile/availability` | `/api/v1/profile/availability` | `api/v1/profiles#availability` | `routes.rb:424` | exact | -| POST | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#create` | `routes.rb:433` | exact | -| POST | `/api/v1/profile/mfa/backup_codes` | `/api/v1/profile/mfa/backup_codes` | `api/v1/profile/mfa#backup_codes` | `routes.rb:435` | exact | -| POST | `/api/v1/profile/mfa/verify` | `/api/v1/profile/mfa/verify` | `api/v1/profile/mfa#verify` | `routes.rb:434` | exact | | POST | `/api/v1/profile/resend_confirmation` | `/api/v1/profile/resend_confirmation` | `api/v1/profiles#resend_confirmation` | `routes.rb:427` | exact | | POST | `/api/v1/profile/reset_access_token` | `/api/v1/profile/reset_access_token` | `api/v1/profiles#reset_access_token` | `routes.rb:428` | exact | | POST | `/api/v1/widget/config` | `/api/v1/widget/config` | `api/v1/widget/config#create` | `routes.rb:444` | exact |