diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 77f00aa2..de64a774 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -52,6 +52,21 @@ var criticalRoutes = []route{ {Method: "POST", Path: "/api/v1/accounts/:account_id/actions/contact_merge", Controller: "api/v1/accounts/actions/contact_merges#create", Source: "routes.rb:55"}, {Method: "PATCH", Path: "/api/v1/accounts/:account_id/onboarding", Controller: "api/v1/accounts/onboardings#update", Source: "routes.rb:58"}, + {Method: "GET", Path: "/api/v1/profile", Controller: "api/v1/profiles#show", Source: "routes.rb:421"}, + {Method: "PATCH", Path: "/api/v1/profile", Controller: "api/v1/profiles#update", Source: "routes.rb:421"}, + {Method: "PUT", Path: "/api/v1/profile", Controller: "api/v1/profiles#update", Source: "routes.rb:421"}, + {Method: "DELETE", Path: "/api/v1/profile/avatar", Controller: "api/v1/profiles#avatar", Source: "routes.rb:422"}, + {Method: "POST", Path: "/api/v1/profile/availability", Controller: "api/v1/profiles#availability", Source: "routes.rb:424"}, + {Method: "POST", Path: "/api/v1/profile/auto_offline", Controller: "api/v1/profiles#auto_offline", Source: "routes.rb:425"}, + {Method: "PUT", Path: "/api/v1/profile/set_active_account", Controller: "api/v1/profiles#set_active_account", Source: "routes.rb:426"}, + {Method: "POST", Path: "/api/v1/profile/resend_confirmation", Controller: "api/v1/profiles#resend_confirmation", Source: "routes.rb:427"}, + {Method: "POST", Path: "/api/v1/profile/reset_access_token", Controller: "api/v1/profiles#reset_access_token", Source: "routes.rb:428"}, + {Method: "GET", Path: "/api/v1/profile/mfa", Controller: "api/v1/profile/mfa#show", Source: "routes.rb:433"}, + {Method: "POST", Path: "/api/v1/profile/mfa", Controller: "api/v1/profile/mfa#create", Source: "routes.rb:433"}, + {Method: "DELETE", Path: "/api/v1/profile/mfa", Controller: "api/v1/profile/mfa#destroy", Source: "routes.rb:433"}, + {Method: "POST", Path: "/api/v1/profile/mfa/verify", Controller: "api/v1/profile/mfa#verify", Source: "routes.rb:434"}, + {Method: "POST", Path: "/api/v1/profile/mfa/backup_codes", Controller: "api/v1/profile/mfa#backup_codes", Source: "routes.rb:435"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/agents", Controller: "api/v1/accounts/agents#index", Source: "routes.rb:59"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/agents", Controller: "api/v1/accounts/agents#create", Source: "routes.rb:59"}, {Method: "PUT", Path: "/api/v1/accounts/:account_id/agents/:agent_id", Controller: "api/v1/accounts/agents#update", Source: "routes.rb:59"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 0202eb12..cd7ae1a3 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,16 +49,16 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 Facebook callbacks route/payload parity checkpoint, prepared as `feat(channels): align facebook callbacks`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(channels): align facebook callbacks`. -- Latest documentation/tooling checkpoint: this tracker update for P3.38 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. +- Current tracking checkpoint: 2026-06-06 profile MFA route/payload parity checkpoint, prepared as `feat(profile): align mfa payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(profile): align mfa payloads`. +- Latest documentation/tooling checkpoint: this tracker update for P3.39 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: Facebook callback routes from `reference/chatwoot/config/routes.rb:106-111`, `Api::V1::Accounts::CallbacksController`, callback Jbuilder views/specs, and reused dashboard `fbChannel.js`/`endPoints.js` are implemented. GoChat now registers account-level `callbacks/register_facebook_page`, `callbacks/facebook_pages`, `callbacks/facebook_pages.json`, and `callbacks/reauthorize_page`; exchanges short-lived Facebook login tokens through a fakeable provider boundary; returns Chatwoot-shaped page discovery payloads with `exists`; creates Facebook inboxes from callback payloads; persists `user_access_token`; reauthorizes matching Facebook page inboxes; refreshes page token/channel config; and preserves the existing channel-specific compatibility routes. Account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: profile MFA routes from `reference/chatwoot/config/routes.rb:421-435`, `Api::V1::ProfilesController`, `Api::V1::Profile::MfaController`, MFA Jbuilder views, reused dashboard `mfa.js`, and profile MFA settings Vue flow are implemented for the reused frontend. GoChat now registers no-trailing and trailing `profile/mfa` route variants, tracks the profile route family, returns raw Chatwoot-shaped MFA status/create/verify/destroy/backup-code payloads, persists pending setup secrets before verification, returns backup codes after verification, stores backup code hashes in user custom attributes, validates password plus OTP/backup code for disable, and preserves the older `/api/v1/auth/mfa/*` compatibility endpoints. Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. - `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox. -- Route dump succeeds with `963` registered routes after Facebook callback route tracking. +- Route dump succeeds with `967` registered routes after profile MFA route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 425 Chatwoot routes: 407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing. The 18 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, WhatsApp call `:id` route names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. +- Tracked frontend-critical route audit covers 439 Chatwoot routes: 421 exact, 0 method-compatible, 18 parameter-compatible, 0 missing. The 18 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, WhatsApp call `:id` route names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -140,6 +140,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.39 profile MFA route/payload parity | Implemented for reused dashboard profile MFA settings: `GET/PATCH/PUT /api/v1/profile`, profile action routes, and no-trailing `GET/POST/DELETE /api/v1/profile/mfa` plus `POST /profile/mfa/verify` and `POST /profile/mfa/backup_codes` are registered and tracked from `routes.rb:421-435`; profile MFA status returns raw `{ feature_available, enabled, backup_codes_generated }`; setup returns raw `{ provisioning_url, secret }` while persisting a pending TOTP secret; verify accepts frontend `otp_code`, enables MFA, and returns raw `{ enabled: true, backup_codes }`; backup-code regeneration validates the active OTP; destroy validates the current password plus OTP/backup-code and returns raw `{ enabled: false }`; legacy `/api/v1/auth/mfa/*` routes remain compatibility endpoints. | Keep in Review; reopen only if live profile security settings smoke exposes MFA feature-flag gating drift, backup-code storage shape drift, Devise password validation drift, or profile serializer fields beyond the inspected controller/Jbuilder/frontend contract. | Focused MFA handler/router/route-parity tests, route dump/parity regeneration (`967` routes; `421 exact`, `18 parameter-compatible`, `0 missing out of 439`), full `go test ./...`, and `git diff --check` must pass. | | 0 | P3.38 Facebook callbacks route/payload parity | Implemented for reused dashboard Facebook login and reauthorization flows: account-level `GET/POST /api/v1/accounts/:account_id/callbacks/register_facebook_page`, `POST /callbacks/facebook_pages`, frontend `.json` alias, and `POST /callbacks/reauthorize_page` are registered and tracked from `routes.rb:106-111`; page discovery exchanges `omniauth_token` for a long-lived user token through a fakeable Facebook provider boundary and returns `{ data: { page_details, user_access_token } }` with Chatwoot `exists` flags; register creates a Facebook inbox/channel from callback payloads and returns the raw callback Jbuilder fields; reauthorize validates account-scoped Facebook inboxes, refreshes the matching page token, clears `reauthorization_required`, updates inbox channel config, and returns `{ data: inbox }`. | Keep in Review; reopen only if live Facebook login smoke exposes Koala pagination/body drift, avatar import side-effect requirements, Facebook page subscription setup drift, or inbox serializer fields beyond the inspected callbacks controller/Jbuilder/frontend contract. | Focused Facebook callback handler tests, router/route-parity tests, route dump/parity regeneration (`963` routes; `407 exact`, `18 parameter-compatible`, `0 missing out of 425`), full `go test ./...`, and `git diff --check` must pass. | | 0 | P3.37 account WhatsApp calls API parity | Implemented for reused dashboard WhatsApp WebRTC call controls: account-level `GET /api/v1/accounts/:account_id/whatsapp_calls/:id`, `POST /whatsapp_calls/initiate`, `POST /whatsapp_calls/:id/accept`, `reject`, `terminate`, and `upload_recording` are registered and tracked from `routes.rb:237-245`; initiate validates enabled WhatsApp Cloud calling, `sdp_offer`, and contact phone; outbound calls persist as Chatwoot `calls` with linked `voice_call` messages and return `{ status: "calling", call_id }`; accept/reject/terminate update call/message/conversation status through a fakeable Meta provider boundary; permission-request and upload-recording idempotency responses match the inspected controller/spec contract. | Keep in Review; reopen only if live WhatsApp calling smoke exposes Meta call action body drift, ActionCable broadcast requirements beyond persisted state, permission-template throttling drift, recording storage URL drift, or call bubble serializer drift beyond the inspected enterprise controller/service/Jbuilder/frontend contract. | Focused WhatsApp call service/handler tests, router/route-parity tests, route dump/parity regeneration (`958` routes; `403 exact`, `18 parameter-compatible`, `0 missing out of 421`), full `go test ./...`, and `git diff --check` must pass. | | 0 | P3.36 account onboarding route/payload parity | Implemented for reused dashboard onboarding: `PATCH /api/v1/accounts/:account_id/onboarding` is registered and tracked from `routes.rb:58`, administrator-gated like Chatwoot `OnboardingsController#update`, accepts raw `name`, `locale`, `website`, `industry`, `company_size`, `timezone`, `referral_source`, and `user_role`, persists account `custom_attributes` through `accounts.custom_attributes`, clears `onboarding_step` when current account details are finalized, and returns the raw account serializer with selected custom-attribute fields. | Keep in Review; reopen only if live onboarding smoke exposes additional account serializer fields, empty-string/null strong-param drift, admin policy drift, or onboarding step behavior beyond the inspected controller/Jbuilder/frontend contract. | Focused Account handler/service tests, router/route-parity tests, route dump/parity regeneration (`952` routes; `402 exact`, `13 parameter-compatible`, `0 missing out of 415`), full `go test ./...`, and `git diff --check` must pass. | @@ -157,7 +158,7 @@ This table is the shortest authoritative handoff view. If an older lower section | 0 | P3.24 Slack integration parity | Implemented for reused dashboard Slack settings flow: no-trailing and trailing singleton routes are registered for create/update/delete, `PUT` and `PATCH` update both work, create accepts frontend `code` and exchanges it for a Slack access token, hooks are persisted with `app_id: slack` and disabled status, update accepts frontend `reference_id`, fetches real private/public Slack channels with pagination, joins public channels, persists `reference_id/settings.channel_name/status`, create/update return raw Chatwoot app payloads with hooks, list-all returns raw channel arrays, invalid channels return Chatwoot's `422 { error }`, and delete returns empty `200 OK`. | Keep in Review; reopen only if live Slack OAuth/channel smoke exposes OAuth redirect, provider error, app serializer, or Slack channel pagination drift beyond the inspected controller/builder/spec/frontend contract. | Focused Slack handler/service tests, route dump/parity regeneration, sandbox focused `go test`, escalated full `go test ./...`, and `git diff --check` passed. | | 0 | P3.23 nested contact inbox creation API | Implemented for Chatwoot nested contact inbox creation: raw JSON/form/query params are accepted, contact and inbox are account-scoped, missing source IDs are generated through Chatwoot channel rules, duplicate contact+inbox+source rows are returned idempotently, `hmac_verified` is persisted on creation, and the response is raw `{ source_id, inbox }` rather than the local model/envelope. | Keep in Review; reopen only if live CRM/new-conversation smoke exposes inbox access-policy, unsupported channel, or serializer drift beyond the inspected controller/builder/Jbuilder contract. | Focused nested ContactInbox handler/service/repository tests, route parity check, full `go test ./...`, and `git diff --check` passed. | | 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. | -| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 425-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account Facebook callback routes from `routes.rb:106-111`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, account WhatsApp call routes from `routes.rb:237-245`, account onboarding route from `routes.rb:58`, API v2 year-in-review route from `routes.rb:505`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers plus current-user/current-account mutation scoping, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, account onboarding custom-attribute payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account Facebook callback page discovery/register/reauthorization behavior, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, account WhatsApp call lifecycle/recording behavior, API v2 year-in-review raw/cache/aggregate behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.38 Facebook callbacks or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 439-route critical set; profile/profile-MFA routes from `routes.rb:421-435`, dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account Facebook callback routes from `routes.rb:106-111`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, account WhatsApp call routes from `routes.rb:237-245`, account onboarding route from `routes.rb:58`, API v2 year-in-review route from `routes.rb:505`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Profile MFA setup/verify/backup/disable raw payload behavior, notification list/action serializers plus current-user/current-account mutation scoping, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, account onboarding custom-attribute payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account Facebook callback page discovery/register/reauthorization behavior, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, account WhatsApp call lifecycle/recording behavior, API v2 year-in-review raw/cache/aggregate behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.39 profile MFA or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. | | 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 5 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | @@ -184,6 +185,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | +| P3.39 profile MFA route/payload parity | `internal/auth/mfa.go`, `internal/handler/api/v1/mfa_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, profile MFA handler/router tests | `reference/chatwoot/config/routes.rb:421-435`, `reference/chatwoot/app/controllers/api/v1/profiles_controller.rb`, `reference/chatwoot/app/controllers/api/v1/profile/mfa_controller.rb`, `reference/chatwoot/app/views/api/v1/profile/mfa/*.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/mfa.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue` | Done. Profile MFA now matches the reused dashboard route and payload family: no-trailing and trailing profile MFA routes are registered; route parity tracks profile show/update/action plus MFA routes; status/create/verify/destroy/backup-code responses are raw Chatwoot shapes; setup secrets are persisted before verification; frontend `otp_code` enables MFA and returns backup codes; backup-code hashes are stored in user custom attributes; disable validates password plus OTP/backup-code. | Review by `feat(profile): align mfa payloads`; focused MFA handler tests cover raw status, setup, verify, backup-code regeneration, and disable payloads; route artifacts cover profile routes; full `go test ./...` and `git diff --check` must pass. | | P3.38 Facebook callbacks route/payload parity | `internal/handler/api/v1/facebook_channel_handler.go`, `internal/channel/facebook/provider.go`, `internal/model/channel/facebook.go`, `internal/service/inbox_service.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000035_align_facebook_callback_fields.*`, Facebook callback handler/router tests | `reference/chatwoot/config/routes.rb:106-111`, `reference/chatwoot/app/controllers/api/v1/accounts/callbacks_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/callbacks/*.json.jbuilder`, `reference/chatwoot/spec/controllers/api/v1/accounts/callbacks_controller_spec.rb`, `reference/chatwoot/app/javascript/dashboard/api/channel/fbChannel.js`, `reference/chatwoot/app/javascript/dashboard/api/endPoints.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/inboxes.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue` | Done. Facebook callbacks now match the reused dashboard route family: register page, page discovery, `.json` page discovery alias, and reauthorize routes are tracked and registered; callback payloads accept raw frontend params; short-lived login tokens are exchanged through a fakeable provider boundary; page discovery marks existing account pages; register creates Facebook inbox/channel rows and stores user/page tokens; reauthorize refreshes the matching page token and inbox channel config, clears `reauthorization_required`, and returns `{ data: inbox }`. | Review by `feat(channels): align facebook callbacks`; focused handler tests cover register, page discovery `.json`, successful reauthorization, and missing-page `422`; route artifacts cover the four Chatwoot callback routes; full `go test ./...` and `git diff --check` must pass. | | P3.37 account WhatsApp calls API parity | `internal/model/call.go`, `internal/service/whatsapp_call_service.go`, `internal/handler/api/v1/whatsapp_call_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000034_align_calls_whatsapp_fields.*`, WhatsApp call service/handler/router tests | `reference/chatwoot/config/routes.rb:237-245`, `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb`, `reference/chatwoot/enterprise/app/views/api/v1/models/_whatsapp_call.json.jbuilder`, `reference/chatwoot/enterprise/app/views/api/v1/accounts/whatsapp_calls/*.json.jbuilder`, `reference/chatwoot/enterprise/app/services/whatsapp/call_service.rb`, `reference/chatwoot/enterprise/app/services/voice/call_message_builder.rb`, `reference/chatwoot/enterprise/app/models/call.rb`, `reference/chatwoot/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb`, `reference/chatwoot/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js` | Done. Account WhatsApp calls now match the reused dashboard route family: account-level show/initiate/accept/reject/terminate/upload-recording routes are tracked and registered; initiate resolves conversations by display ID, validates WhatsApp Cloud calling and contact phone, calls a fakeable Meta provider boundary, creates outbound `calls` rows and linked `voice_call` messages, and returns `{ status, call_id }`; accept/reject/terminate update call/message/conversation status; permission requests record conversation attributes; recording upload is idempotent over audio attachments. | Review by `feat(channels): align whatsapp calls api`; focused service tests cover outbound creation, permission request, accept/terminate/recording, guard errors, and no-answer termination; handler tests cover account route payloads; route artifacts cover the six enterprise routes; full `go test ./...` and `git diff --check` must pass. | | P3.36 account onboarding route/payload parity | `internal/model/account.go`, `internal/service/account_service.go`, `internal/handler/api/v1/account_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000033_add_accounts_custom_attributes.*`, account handler/service/router tests | `reference/chatwoot/config/routes.rb:58`, `reference/chatwoot/app/controllers/api/v1/accounts/onboardings_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/update.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_account.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/onboarding.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/accounts.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue` | Done. Account onboarding update now matches the reused dashboard path: `PATCH /api/v1/accounts/:account_id/onboarding` is tracked and registered with administrator authorization; the handler accepts the raw frontend onboarding payload; the service merges permitted onboarding attributes into persisted account `custom_attributes`, mirrors timezone for existing account consumers, clears `onboarding_step` only when finalizing account details, and the account serializer emits the Chatwoot selected custom-attribute fields while preserving subscription/deletion placeholders. | Review by `feat(accounts): align onboarding update`; focused account handler tests cover finalizing and non-finalizing onboarding merges plus raw account response shape; service/router/route-parity tests and route artifacts cover the new account route; full `go test ./...` and `git diff --check` must pass. | @@ -260,6 +262,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(profile): align mfa payloads` | Advances P3.39 profile MFA route/payload parity by matching Chatwoot `Api::V1::ProfilesController`, `Api::V1::Profile::MfaController`, MFA Jbuilder views, routes `421-435`, and reused dashboard `mfa.js`/profile MFA settings flow. GoChat now exposes no-trailing profile MFA routes, tracks profile route parity, returns raw MFA status/create/verify/destroy/backup-code payloads, persists pending setup secrets, verifies frontend `otp_code`, returns and stores backup-code hashes, and validates password plus OTP/backup-code for disable while preserving legacy `/auth/mfa` compatibility endpoints. | `go test ./internal/auth ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'MFA\|Profile\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 967`; tracked route parity is `421 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 439`. | P3.39 moves to Review for current profile MFA evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(channels): align facebook callbacks` | Advances P3.38 Facebook callbacks route/payload parity by matching Chatwoot `Api::V1::Accounts::CallbacksController`, callback Jbuilder views/specs, routes `106-111`, and reused dashboard `fbChannel.js`/`endPoints.js`/Facebook reauthorize store flow. GoChat now exposes account-level register/page-discovery/reauthorize callback routes, supports the frontend `.json` page-discovery alias, exchanges short-lived login tokens through a fakeable provider boundary, marks existing Facebook pages, creates Facebook inboxes from callback payloads, persists `user_access_token`, refreshes page tokens and inbox channel config on reauthorize, and returns Chatwoot-shaped raw callback payloads. | `go test ./internal/handler/api/v1 ./internal/channel/facebook ./internal/router ./cmd/route_parity -run 'Facebook\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 963`; tracked route parity is `407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425`. | P3.38 moves to Review for current Facebook callback evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(channels): align whatsapp calls api` | Advances P3.37 account WhatsApp calls API parity by matching Chatwoot enterprise `Api::V1::Accounts::WhatsappCallsController`, `Whatsapp::CallService`, `Voice::CallMessageBuilder`, `Call`, WhatsApp call Jbuilder views/specs, route `237-245`, and reused dashboard `whatsappCallsAPI.js`. GoChat now exposes account-level show/initiate/accept/reject/terminate/upload-recording routes, persists WhatsApp calls through the `calls` model boundary, creates linked `voice_call` messages, updates call/message/conversation status for lifecycle actions, records permission-request attributes, and makes recording upload idempotent. | `go test ./internal/service ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'WhatsAppCall\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 958`; tracked route parity is `403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421`. | P3.37 moves to Review for current WhatsApp calls evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(accounts): align onboarding update` | Advances P3.36 account onboarding route/payload parity by matching Chatwoot `Api::V1::Accounts::OnboardingsController#update`, account update/model Jbuilder payloads, route `58`, and reused dashboard onboarding API/store/Vue flow. GoChat now persists `accounts.custom_attributes`, exposes administrator-gated `PATCH /api/v1/accounts/:account_id/onboarding`, accepts the raw onboarding payload, merges permitted custom attributes, clears `onboarding_step` when finalizing account details, and returns the raw account serializer shape. | `go test ./internal/handler/api/v1 -run AccountHandler -count=1`; `go test ./internal/service -run AccountService -count=1`; `go test ./cmd/route_parity ./internal/router -run 'RouteParity\|RegisterRoutes' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 952`; tracked route parity is `402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 415`. | P3.36 moves to Review for current onboarding/account serializer evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2279,6 +2282,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.39 profile MFA checkpoint prepared as `feat(profile): align mfa payloads`; audited Chatwoot profile routes `421-435`, `ProfilesController`, `Profile::MfaController`, MFA Jbuilder views, reused dashboard `api/mfa.js`, and profile MFA settings Vue flow. GoChat now registers exact no-trailing profile MFA routes plus trailing aliases, tracks profile show/update/action/MFA routes in route parity, returns raw status/create/verify/destroy/backup-code payloads, persists pending TOTP setup secrets, verifies frontend `otp_code`, returns generated backup codes after verification, stores hashed backup codes in user custom attributes, and validates password plus OTP/backup-code for disable while keeping legacy `/api/v1/auth/mfa/*` endpoints. Focused MFA handler/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 967` and tracked route parity is `421 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 439`. - 2026-06-06: P3.38 Facebook callbacks checkpoint prepared as `feat(channels): align facebook callbacks`; audited Chatwoot account callback routes `106-111`, `CallbacksController`, callback Jbuilder views, controller specs, reused dashboard `fbChannel.js`, `endPoints.js`, inbox store reauthorization action, and Facebook reauthorize screen. GoChat now registers account-level Facebook callback register/page-discovery/reauthorize routes plus the frontend `.json` page-discovery alias, exchanges login tokens through a fakeable Facebook provider boundary, returns page discovery payloads with `exists`, creates callback Facebook inbox/channel records with `user_access_token`, refreshes matching page tokens and inbox channel config during reauthorization, and returns Chatwoot-shaped callback payloads. Focused Facebook callback handler/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 963` and tracked route parity is `407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425`. - 2026-06-06: P3.37 account WhatsApp calls checkpoint prepared as `feat(channels): align whatsapp calls api`; audited Chatwoot enterprise WhatsApp call routes `237-245`, `WhatsappCallsController`, WhatsApp call Jbuilder views, `Whatsapp::CallService`, `Voice::CallMessageBuilder`, `Call`, controller specs, and reused dashboard `whatsappCallsAPI.js`. GoChat now registers account-level WhatsApp call show/initiate/accept/reject/terminate/upload-recording routes, persists calls through the `calls` model, creates linked `voice_call` messages for outbound calls, updates message/conversation call status through lifecycle actions, records permission-request conversation attributes, and returns idempotent recording upload status. Focused WhatsApp call service/handler/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 958` and tracked route parity is `403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421`. - 2026-06-06: P3.36 account onboarding checkpoint prepared as `feat(accounts): align onboarding update`; audited Chatwoot account onboarding route `58`, `Api::V1::Accounts::OnboardingsController#update`, account update/model Jbuilder views, and reused dashboard `api/onboarding.js`, account store `finishOnboarding`, and onboarding Vue payload. GoChat now registers administrator-gated `PATCH /api/v1/accounts/:account_id/onboarding`, adds persisted `accounts.custom_attributes`, accepts the raw frontend payload, merges permitted onboarding custom attributes, clears `onboarding_step` when finalizing account details, and returns the raw account serializer with Chatwoot selected custom-attribute fields. Focused account handler/service tests, router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 952` and tracked route parity is `402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 415`. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 545997f3..1760dc68 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -111,6 +111,7 @@ 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/push_subscriptions/:id DELETE /api/v1/widget/labels/:label_id @@ -402,6 +403,7 @@ 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/push_subscriptions GET /api/v1/saml/login @@ -554,6 +556,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/profile PATCH /api/v1/widget/contact PATCH /api/v1/widget/contact/set_user PATCH /api/v1/widget/messages/:message_id @@ -803,6 +806,7 @@ 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 @@ -961,4 +965,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: 963 +TOTAL: 967 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 5c145ba1..933b4d3a 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/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: 407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425 tracked critical routes. +Summary: 421 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 439 tracked critical routes. ## Missing Critical Routes @@ -99,6 +99,8 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#destroy` | `routes.rb:299` | exact | | 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/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | 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 | @@ -215,6 +217,8 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#show` | `routes.rb:296` | exact | | GET | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#index` | `routes.rb:297` | exact | | 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/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/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 | | GET | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#index` | `routes.rb:448` | exact | @@ -288,6 +292,7 @@ These routes exist with equivalent method and path shape but different parameter | PATCH | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#update` | `routes.rb:296` | exact | | PATCH | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#update` | `routes.rb:300` | exact | | PATCH | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#update` | `routes.rb:342` | exact | +| PATCH | `/api/v1/profile` | `/api/v1/profile` | `api/v1/profiles#update` | `routes.rb:421` | exact | | PATCH | `/api/v1/widget/contact/set_user` | `/api/v1/widget/contact/set_user` | `api/v1/widget/contact#set_user` | `routes.rb:461` | exact | | POST | `/api/v1/accounts/` | `/api/v1/accounts/` | `api/v1/accounts#create` | `routes.rb:47` | exact | | POST | `/api/v1/accounts/:account_id/actions/contact_merge` | `/api/v1/accounts/:account_id/actions/contact_merge` | `api/v1/accounts/actions/contact_merges#create` | `routes.rb:55` | exact | @@ -395,6 +400,13 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/accounts/:account_id/whatsapp/authorization` | `/api/v1/accounts/:account_id/whatsapp/authorization` | `api/v1/accounts/whatsapp/authorizations#create` | `routes.rb:339` | exact | | POST | `/api/v1/accounts/:account_id/whatsapp_calls/initiate` | `/api/v1/accounts/:account_id/whatsapp_calls/initiate` | `api/v1/accounts/whatsapp_calls#initiate` | `routes.rb:245` | exact | | 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 | | POST | `/api/v1/widget/contact/destroy_custom_attributes` | `/api/v1/widget/contact/destroy_custom_attributes` | `api/v1/widget/contact#destroy_custom_attributes` | `routes.rb:460` | exact | | POST | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#create` | `routes.rb:448` | exact | @@ -454,6 +466,8 @@ These routes exist with equivalent method and path shape but different parameter | PUT | `/api/v1/accounts/:account_id/sla_policies/:id` | `/api/v1/accounts/:account_id/sla_policies/:id` | `api/v1/accounts/sla_policies#update` | `routes.rb:121` | exact | | PUT | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#update` | `routes.rb:296` | exact | | PUT | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#update` | `routes.rb:342` | exact | +| PUT | `/api/v1/profile` | `/api/v1/profile` | `api/v1/profiles#update` | `routes.rb:421` | exact | +| PUT | `/api/v1/profile/set_active_account` | `/api/v1/profile/set_active_account` | `api/v1/profiles#set_active_account` | `routes.rb:426` | exact | | PUT | `/api/v1/widget/contact` | `/api/v1/widget/contact` | `api/v1/widget/contact#update` | `routes.rb:458` | exact | | PUT | `/api/v1/widget/messages/:message_id` | `/api/v1/widget/messages/:message_id` | `api/v1/widget/messages#update` | `routes.rb:447` | exact | | PUT | `/public/api/v1/csat_survey/:id` | `/public/api/v1/csat_survey/:id` | `public/api/v1/csat_survey#update` | `routes.rb:585` | exact | diff --git a/internal/auth/mfa.go b/internal/auth/mfa.go index c64f8ba8..dd0ad918 100644 --- a/internal/auth/mfa.go +++ b/internal/auth/mfa.go @@ -6,15 +6,19 @@ import ( "crypto/sha1" "encoding/base32" "encoding/binary" + "encoding/json" "fmt" "math" "strings" "time" "github.com/gochat/gochat/internal/model" + pkgcrypto "github.com/gochat/gochat/pkg/crypto" "gorm.io/gorm" ) +const mfaBackupCodesAttribute = "mfa_backup_code_hashes" + // Reference: P2E §1.5 — MFA (TOTP) support // Implements time-based one-time password (TOTP) per RFC 6238. // Corresponds to Chatwoot enterprise TwoFactorAuthController pattern. @@ -99,6 +103,46 @@ func (s *MFAService) EnableTOTP(userID uint, secret string) error { return nil } +// BeginTOTPSetup creates and stores a pending TOTP secret for Chatwoot's +// profile MFA setup flow. The user is activated only after VerifyAndActivateTOTP. +func (s *MFAService) BeginTOTPSetup(userID uint) (string, string, error) { + secret, uri, err := s.GenerateTOTPSecret(userID) + if err != nil { + return "", "", err + } + + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return "", "", fmt.Errorf("user not found: %w", err) + } + user.TOTPSecret = secret + user.TOTPEnabled = false + if err := s.db.Save(&user).Error; err != nil { + return "", "", fmt.Errorf("failed to store pending totp secret: %w", err) + } + return secret, uri, nil +} + +// VerifyAndActivateTOTP validates the pending profile MFA code, enables MFA, +// and returns the one-time backup codes expected by Chatwoot's verify response. +func (s *MFAService) VerifyAndActivateTOTP(userID uint, code string) ([]string, error) { + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + if user.TOTPSecret == "" { + return nil, fmt.Errorf("mfa setup not started for user") + } + if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) { + return nil, fmt.Errorf("invalid totp code") + } + user.TOTPEnabled = true + if err := s.db.Save(&user).Error; err != nil { + return nil, fmt.Errorf("failed to enable totp: %w", err) + } + return s.GenerateBackupCodes(userID) +} + // VerifyTOTPCode validates a TOTP code against the user's stored secret. // Uses a 1-period window (±30 seconds) to account for clock drift per RFC 6238. func (s *MFAService) VerifyTOTPCode(userID uint, code string) (bool, error) { @@ -142,6 +186,35 @@ func (s *MFAService) DisableTOTP(userID uint, code string) error { return nil } +// DisableTOTPWithPassword mirrors Chatwoot profile MFA destroy: the current +// password and either an OTP code or a backup code must be provided. +func (s *MFAService) DisableTOTPWithPassword(userID uint, password, code, backupCode string) error { + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return fmt.Errorf("user not found: %w", err) + } + if !user.TOTPEnabled || user.TOTPSecret == "" { + return fmt.Errorf("mfa not enabled for user") + } + if !pkgcrypto.CheckPassword(password, user.PasswordDigest) && !pkgcrypto.CheckPassword(password, user.Password) { + return fmt.Errorf("invalid credentials") + } + if backupCode != "" { + if err := s.consumeBackupCode(&user, backupCode); err != nil { + return err + } + } else if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) { + return fmt.Errorf("invalid totp code") + } + + user.TOTPSecret = "" + user.TOTPEnabled = false + if err := s.db.Save(&user).Error; err != nil { + return fmt.Errorf("failed to disable totp: %w", err) + } + return nil +} + // IsMFAEnabled checks whether MFA is enabled for a user. func (s *MFAService) IsMFAEnabled(userID uint) (bool, error) { var user model.User @@ -151,6 +224,16 @@ func (s *MFAService) IsMFAEnabled(userID uint) (bool, error) { return user.TOTPEnabled, nil } +// BackupCodesGenerated reports whether the user currently has MFA backup codes. +func (s *MFAService) BackupCodesGenerated(userID uint) (bool, error) { + var user model.User + if err := s.db.Select("custom_attributes").First(&user, userID).Error; err != nil { + return false, fmt.Errorf("user not found: %w", err) + } + codes := backupCodeHashes(user.CustomAttributes) + return len(codes) > 0, nil +} + // validateTOTP validates a TOTP code against a secret using the given config. // Allows ±1 period window for clock drift tolerance. func validateTOTP(secret string, code string, cfg TOTPConfig) bool { @@ -192,7 +275,7 @@ func generateTOTP(secret string, timeCounter int64, cfg TOTPConfig) string { truncated := (int32(hash[offset]&0x7f) << 24) | (int32(hash[offset+1]&0xff) << 16) | (int32(hash[offset+2]&0xff) << 8) | - (int32(hash[offset+3]&0xff)) + (int32(hash[offset+3] & 0xff)) // Modulo 10^digits mod := int32(math.Pow10(cfg.Digits)) @@ -217,15 +300,83 @@ func init() { // GenerateBackupCodes creates a set of one-time backup codes for MFA recovery. // Reference: Chatwoot MfaController#backup_codes func (s *MFAService) GenerateBackupCodes(userID uint) ([]string, error) { + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } var codes []string + var hashes []string for i := 0; i < 10; i++ { code := cryptoRandomString(8) codes = append(codes, code) + hash, err := pkgcrypto.HashPassword(code) + if err != nil { + return nil, fmt.Errorf("failed to hash backup code: %w", err) + } + hashes = append(hashes, hash) + } + attrs := customAttributesMap(user.CustomAttributes) + attrs[mfaBackupCodesAttribute] = hashes + encoded, err := json.Marshal(attrs) + if err != nil { + return nil, fmt.Errorf("failed to encode backup codes: %w", err) + } + user.CustomAttributes = encoded + if err := s.db.Save(&user).Error; err != nil { + return nil, fmt.Errorf("failed to store backup codes: %w", err) } - // TODO: store hashed backup codes in DB for later verification return codes, nil } +func (s *MFAService) consumeBackupCode(user *model.User, code string) error { + hashes := backupCodeHashes(user.CustomAttributes) + for i, hash := range hashes { + if pkgcrypto.CheckPassword(code, hash) { + hashes = append(hashes[:i], hashes[i+1:]...) + attrs := customAttributesMap(user.CustomAttributes) + attrs[mfaBackupCodesAttribute] = hashes + encoded, err := json.Marshal(attrs) + if err != nil { + return fmt.Errorf("failed to encode backup codes: %w", err) + } + user.CustomAttributes = encoded + if err := s.db.Save(user).Error; err != nil { + return fmt.Errorf("failed to consume backup code: %w", err) + } + return nil + } + } + return fmt.Errorf("invalid backup code") +} + +func backupCodeHashes(raw []byte) []string { + attrs := customAttributesMap(raw) + value, ok := attrs[mfaBackupCodesAttribute] + if !ok { + return nil + } + items, ok := value.([]any) + if !ok { + return nil + } + hashes := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok && text != "" { + hashes = append(hashes, text) + } + } + return hashes +} + +func customAttributesMap(raw []byte) map[string]any { + attrs := map[string]any{} + if len(raw) == 0 { + return attrs + } + _ = json.Unmarshal(raw, &attrs) + return attrs +} + // cryptoRandomString generates a random alphanumeric string of given length. func cryptoRandomString(length int) string { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -236,4 +387,4 @@ func cryptoRandomString(length int) string { b[i] = charset[int(buf[0])%len(charset)] } return string(b) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/mfa_handler.go b/internal/handler/api/v1/mfa_handler.go index 91731440..c03e83f6 100644 --- a/internal/handler/api/v1/mfa_handler.go +++ b/internal/handler/api/v1/mfa_handler.go @@ -52,6 +52,17 @@ type DisableMFARequest struct { TOTPCode string `json:"totp_code" binding:"required"` // current TOTP code for verification } +type profileMFAVerifyRequest struct { + OTPCode string `json:"otp_code"` + TOTPCode string `json:"totp_code"` +} + +type profileMFADisableRequest struct { + Password string `json:"password"` + OTPCode string `json:"otp_code"` + BackupCode string `json:"backup_code"` +} + // --- Handlers --- // EnableMFA initiates MFA setup: generates a TOTP secret and QR URI. @@ -187,6 +198,117 @@ func RegisterMFARoutes(rg *gin.RouterGroup, handler *MFAHandler) { } } +// ProfileMFAStatus matches Chatwoot Profile::MfaController#show. +func (h *MFAHandler) ProfileMFAStatus(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + enabled, err := h.mfaService.IsMFAEnabled(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + backupCodesGenerated, err := h.mfaService.BackupCodesGenerated(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "feature_available": true, + "enabled": enabled, + "backup_codes_generated": backupCodesGenerated, + }) +} + +// ProfileEnableMFA matches Chatwoot Profile::MfaController#create. +func (h *MFAHandler) ProfileEnableMFA(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + enabled, err := h.mfaService.IsMFAEnabled(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if enabled { + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "MFA is already enabled"}) + return + } + secret, uri, err := h.mfaService.BeginTOTPSetup(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"provisioning_url": uri, "secret": secret}) +} + +// ProfileVerifyMFA matches Chatwoot Profile::MfaController#verify. +func (h *MFAHandler) ProfileVerifyMFA(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + var req profileMFAVerifyRequest + _ = c.ShouldBindJSON(&req) + code := req.OTPCode + if code == "" { + code = req.TOTPCode + } + backupCodes, err := h.mfaService.VerifyAndActivateTOTP(userID, code) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"enabled": true, "backup_codes": backupCodes}) +} + +// ProfileDisableMFA matches Chatwoot Profile::MfaController#destroy. +func (h *MFAHandler) ProfileDisableMFA(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + var req profileMFADisableRequest + _ = c.ShouldBindJSON(&req) + if err := h.mfaService.DisableTOTPWithPassword(userID, req.Password, req.OTPCode, req.BackupCode); err != nil { + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"enabled": false}) +} + +// ProfileBackupCodes matches Chatwoot Profile::MfaController#backup_codes. +func (h *MFAHandler) ProfileBackupCodes(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + var req profileMFAVerifyRequest + _ = c.ShouldBindJSON(&req) + code := req.OTPCode + if code == "" { + code = req.TOTPCode + } + valid, err := h.mfaService.VerifyTOTPCode(userID, code) + if err != nil || !valid { + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid totp code"}) + return + } + codes, err := h.mfaService.GenerateBackupCodes(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"backup_codes": codes}) +} + // BackupCodes generates one-time MFA backup codes. // POST /api/v1/profile/mfa/backup_codes or /api/v1/auth/mfa/backup_codes // Reference: Chatwoot MfaController#backup_codes diff --git a/internal/handler/api/v1/mfa_handler_test.go b/internal/handler/api/v1/mfa_handler_test.go index 951c7983..7ba75bf8 100644 --- a/internal/handler/api/v1/mfa_handler_test.go +++ b/internal/handler/api/v1/mfa_handler_test.go @@ -24,6 +24,7 @@ import ( "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" + pkgcrypto "github.com/gochat/gochat/pkg/crypto" "github.com/gochat/gochat/pkg/response" ) @@ -107,6 +108,11 @@ func (s *MFAHandlerTestSuite) setupRouter(userID uint) { mfaGroup.POST("/verify", s.handler.VerifyMFA) mfaGroup.POST("/disable", s.handler.DisableMFA) } + r.GET("/api/v1/profile/mfa", s.handler.ProfileMFAStatus) + r.POST("/api/v1/profile/mfa", s.handler.ProfileEnableMFA) + r.DELETE("/api/v1/profile/mfa", s.handler.ProfileDisableMFA) + r.POST("/api/v1/profile/mfa/verify", s.handler.ProfileVerifyMFA) + r.POST("/api/v1/profile/mfa/backup_codes", s.handler.ProfileBackupCodes) s.router = r } @@ -213,7 +219,7 @@ func generateTOTPFromKey(key []byte, timeCounter int64, cfg auth.TOTPConfig) str truncated := (int32(hash[offset]&0x7f) << 24) | (int32(hash[offset+1]&0xff) << 16) | (int32(hash[offset+2]&0xff) << 8) | - (int32(hash[offset+3]&0xff)) + (int32(hash[offset+3] & 0xff)) // Modulo 10^digits mod := int32(math.Pow10(cfg.Digits)) @@ -660,5 +666,58 @@ func (s *MFAHandlerTestSuite) TestStatus_Unauthorized_NoUserID() { s.Equal(response.ErrUnauthorized, respStruct.Error.Code) } +func (s *MFAHandlerTestSuite) TestProfileMFA_StatusUsesChatwootRawPayload() { + w := s.doRequest(http.MethodGet, "/api/v1/profile/mfa", "") + s.Equal(http.StatusOK, w.Code) + + var payload map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Equal(true, payload["feature_available"]) + s.Equal(false, payload["enabled"]) + s.Equal(false, payload["backup_codes_generated"]) + s.NotContains(payload, "data") +} + +func (s *MFAHandlerTestSuite) TestProfileMFA_EnableVerifyBackupAndDisableUseFrontendPayloads() { + passwordHash, err := pkgcrypto.HashPassword("current-password") + s.Require().NoError(err) + s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{ + "password": passwordHash, + "password_digest": passwordHash, + }).Error) + + enableRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa", "") + s.Equal(http.StatusOK, enableRec.Code) + var enablePayload map[string]string + s.Require().NoError(json.Unmarshal(enableRec.Body.Bytes(), &enablePayload)) + s.NotEmpty(enablePayload["secret"]) + s.Contains(enablePayload["provisioning_url"], "otpauth://totp/") + + code := s.generateValidTOTPCode(enablePayload["secret"]) + verifyRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/verify", jsonBody(map[string]interface{}{"otp_code": code})) + s.Equal(http.StatusOK, verifyRec.Code) + var verifyPayload struct { + Enabled bool `json:"enabled"` + BackupCodes []string `json:"backup_codes"` + } + s.Require().NoError(json.Unmarshal(verifyRec.Body.Bytes(), &verifyPayload)) + s.True(verifyPayload.Enabled) + s.Len(verifyPayload.BackupCodes, 10) + + backupRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/backup_codes", jsonBody(map[string]interface{}{"otp_code": code})) + s.Equal(http.StatusOK, backupRec.Code) + var backupPayload struct { + BackupCodes []string `json:"backup_codes"` + } + s.Require().NoError(json.Unmarshal(backupRec.Body.Bytes(), &backupPayload)) + s.Len(backupPayload.BackupCodes, 10) + + disableRec := s.doRequest(http.MethodDelete, "/api/v1/profile/mfa", jsonBody(map[string]interface{}{"password": "current-password", "otp_code": code})) + s.Equal(http.StatusOK, disableRec.Code) + var disablePayload map[string]bool + s.Require().NoError(json.Unmarshal(disableRec.Body.Bytes(), &disablePayload)) + s.False(disablePayload["enabled"]) +} + // Ensure unused import warning doesn't cause issues -var _ = assert.Equal \ No newline at end of file +var _ = assert.Equal diff --git a/internal/router/router.go b/internal/router/router.go index ffa4b673..e0299822 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -569,6 +569,7 @@ func RegisterRoutes( func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Profile routes — not scoped to account g.GET("/profile", h.Profile.Get) + g.PATCH("/profile", h.Profile.Update) g.PUT("/profile", h.Profile.Update) g.PUT("/profile/avatar", h.Profile.UpdateAvatar) g.DELETE("/profile/avatar", h.Profile.DeleteAvatar) @@ -581,13 +582,16 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // MFA routes under profile scope (Chatwoot: scope module: 'profile' do resource :mfa) // GET /profile/mfa — show status, POST /profile/mfa — create (enable), DELETE /profile/mfa — destroy (disable) // POST /profile/mfa/verify — verify TOTP code, POST /profile/mfa/backup_codes — generate backup codes + g.GET("/profile/mfa", h.MFA.ProfileMFAStatus) + g.POST("/profile/mfa", h.MFA.ProfileEnableMFA) + g.DELETE("/profile/mfa", h.MFA.ProfileDisableMFA) profileMfa := g.Group("/profile/mfa") { - profileMfa.GET("/", h.MFA.MFAStatus) - profileMfa.POST("/", h.MFA.EnableMFA) - profileMfa.DELETE("/", h.MFA.DisableMFA) - profileMfa.POST("/verify", h.MFA.VerifyMFA) - profileMfa.POST("/backup_codes", h.MFA.BackupCodes) + profileMfa.GET("/", h.MFA.ProfileMFAStatus) + profileMfa.POST("/", h.MFA.ProfileEnableMFA) + profileMfa.DELETE("/", h.MFA.ProfileDisableMFA) + profileMfa.POST("/verify", h.MFA.ProfileVerifyMFA) + profileMfa.POST("/backup_codes", h.MFA.ProfileBackupCodes) } // Notification routes — user-scoped, not account-scoped