feat(profile): expose hmac identifier

This commit is contained in:
2026-06-06 11:36:00 +08:00
parent 9c2a396d69
commit fbc0bb833d
4 changed files with 75 additions and 14 deletions
+6 -4
View File
@@ -49,10 +49,10 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(platform): align user payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(platform): align user payloads`.
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(profile): expose hmac identifier`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(profile): expose hmac identifier`.
- Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: record placeholder smoke audit`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: P3.2 platform users drift from `Platform::Api::V1::UsersController`, `platform/api/v1/models/_user.json.jbuilder`, and `users/token.json.jbuilder` is implemented. Platform user show/create/update now return raw Chatwoot user payloads, create reuses existing users by email and idempotently grants platform-app permission, access-token and login/token payloads match the reference shape, custom attributes merge on update, and delete returns empty `200 OK`. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active implementation slice is B12 live smoke finding, remaining P3.2 invitations drift from fresh reference evidence, or another route/controller/serializer drift opened from current `reference/chatwoot` inspection.
- Worktree status at this implementation checkpoint: P3.1 profile `_user` serializer drift from `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `user_spec.rb`, and reused frontend `scriptHelpers.js` is implemented. Profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is absent and expose the Chatwoot-compatible SHA-256 HMAC of the user email when the installation config is present. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active implementation slice is B12 live smoke finding, remaining P3.2 invitations drift from fresh reference evidence, or another route/controller/serializer drift opened from current `reference/chatwoot` inspection.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 905` after removing the non-Chatwoot `GET /platform/api/v1/users/:id/token` route and keeping the Chatwoot `GET /platform/api/v1/users/:id/login` route.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -209,6 +209,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(profile): expose hmac identifier` | Advances P3.1 profile `_user` serializer parity by matching Chatwoot `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `spec/models/user_spec.rb`, and reused frontend `scriptHelpers.js`. GoChat profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is not configured and include the SHA-256 HMAC of the user email keyed by that installation config when present. | `go test ./internal/handler/api/v1 -run 'ProfileHandlerSuite/TestGet' -count=1`; `go test ./internal/service -run Profile -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | Profile `_user` HMAC field moves to Review for current reference evidence; continue remaining P3.2 invitations drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. |
| `feat(platform): align user payloads` | Advances P3.2 platform user parity by matching Chatwoot `Platform::Api::V1::UsersController`, `app/views/platform/api/v1/models/_user.json.jbuilder`, `users/token.json.jbuilder`, and platform routes. GoChat now returns raw user/token/login payloads instead of local envelopes, creates or reuses users by email with confirmed active status, idempotently grants platform-app permission, generates/reuses personal access tokens, merges `custom_attributes` on update, returns empty `200 OK` deletes, registers `GET /platform/api/v1/users/:id/login`, and removes the non-reference `GET /platform/api/v1/users/:id/token` route from the dump. | `go test ./internal/handler/api/v1 -run PlatformUser -count=1`; `go test ./internal/service -run PlatformUser -count=1`; `go test ./internal/router -run RegisterRoutes -count=1`; `go test ./cmd/route_parity -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: 905`; tracked route parity remains `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. | Platform users move to Review for current reference evidence; continue remaining P3.2 invitations drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. |
| `feat(agents): align serializer fields` | Advances P3.2 agents serializer parity by matching Chatwoot `app/views/api/v1/models/_agent.json.jbuilder`. GoChat account agent list/create/show/update payloads now return only `id`, `account_id`, `availability_status`, `auto_offline`, `confirmed`, `email`, `provider`, `available_name`, optional `custom_attributes`, `name`, `role`, `thumbnail`, and enterprise `custom_role_id`; local `invited_by` and `account_user_id` fields are no longer emitted. | `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestCreateAgent\|TestAgentHandlerSuite/TestListAfterCreate\|TestAgentHandlerSuite/TestGetAgent' -count=1`; `go test ./internal/handler/api/v1 -run AgentHandler -count=1`; `go test ./internal/service ./internal/repository ./internal/router -run Agent -count=1`; sandboxed full `go test ./...` failed only on local socket/miniredis/httptest restrictions; escalated full `go test ./...` passed; `git diff --check`. | Agent serializer field shape moves to Review for current reference evidence; continue remaining P3.2 account users/invitations drift or B12 live smoke. |
| `docs: record placeholder smoke audit` | Documentation/tooling checkpoint for Phase 6 and B12 readiness. Adds `docs/parity/placeholder_audit.md`, classifies remaining `chatwootParityStub` call sites as public webhook nil-handler guards only, refreshes `docs/parity/frontend_smoke_report.md`, and moves Phase 6 placeholder burn-down to Review for the inspected account/contact/conversation/message/inbox/widget/public critical paths. | `rg -n "chatwootParityStub" internal/handler internal/service internal/router internal/channel`; `rg -n "chatwootParityStub" internal/router/router.go | wc -l`; `bash -n scripts/parity_frontend_smoke.sh`; `scripts/parity_frontend_smoke.sh --check`; `scripts/parity_frontend_smoke.sh --print`; `git diff --check`. | Continue B12 live API/browser/enterprise smoke when the full stack is available; otherwise continue the next evidence-backed P2/P3 serializer/controller drift such as P3.2 account users/invitations. |
@@ -1674,7 +1675,7 @@ Frontend-critical API groups to audit first:
| ID | Area | Scope | Status |
| --- | --- | --- | --- |
| P3.1 | Auth/session/profile | Login, logout, current user, profile, availability, notification settings, Devise password reset, and confirmation. | Done; profile account permission arrays and user notification-settings raw payloads now match Chatwoot role/custom-role semantics and dashboard settings store expectations. |
| P3.1 | Auth/session/profile | Login, logout, current user, profile, availability, notification settings, Devise password reset, and confirmation. | Review; profile account permission arrays, conditional `hmac_identifier`, and user notification-settings raw payloads now match Chatwoot role/custom-role semantics, widget script helper, and dashboard settings store expectations. |
| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member, assignable-agent settings payloads, profile account permission arrays, platform account-user raw/idempotent membership, platform user raw serializer/create-login-token/update-delete semantics, agents `_agent` serializer fields, `order_by_full_name` ordering with Chatwoot no-pagination index behavior, agents bulk invite best-effort behavior, and agent update PATCH/`auto_offline`/validation semantics now have focused Chatwoot-style coverage. |
| P3.3 | Inboxes/channels | Inbox CRUD, assignable agents, avatars, channel config, business hours, widget config. | Review; assignable-agent payload drift closed, broader inbox serializer drift remains evidence-driven. |
| P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review |
@@ -2197,6 +2198,7 @@ Verification milestone gates:
## Progress Log
- 2026-06-06: P3.1 profile HMAC checkpoint prepared as `feat(profile): expose hmac identifier`; audited Chatwoot `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `spec/models/user_spec.rb`, and reused frontend `scriptHelpers.js`. Profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is absent and expose `OpenSSL::HMAC.hexdigest('sha256', key, email)` compatible values when the installation config exists, letting the reused widget script helper send `identifier_hash` without adapter code. Focused profile handler tests, service compile test, full `go test ./...`, and `git diff --check` passed. Continue remaining P3.2 invitations drift or B12 live smoke.
- 2026-06-06: P3.2 platform user checkpoint prepared as `feat(platform): align user payloads`; audited Chatwoot `Platform::Api::V1::UsersController`, `platform/api/v1/models/_user.json.jbuilder`, `users/token.json.jbuilder`, and platform user routes. Platform user show/create/update now return raw Chatwoot user payloads, create reuses existing users by email and creates the platform-app permissible idempotently, access tokens are generated/reused for user payloads, `GET /users/:id/login` returns a Chatwoot-shaped URL payload, `POST /users/:id/token` returns `{ access_token, expiry, user }`, update merges `custom_attributes`, and destroy returns empty `200 OK`. The stale non-reference `GET /platform/api/v1/users/:id/token` route was removed from the dump, leaving route dump `TOTAL: 905` and tracked route parity at `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. Focused platform handler/service/router tests, route parity test/generation, full `go test ./...`, and `git diff --check` passed; continue remaining P3.2 invitations drift or B12 live smoke.
- 2026-06-06: P3.5 company avatar form/pagination checkpoint prepared as `feat(companies): align avatar form payloads`; audited Chatwoot enterprise `CompaniesController`, company Jbuilder views, reused dashboard `api/companies.js`, and Pinia `stores/companies.js`. Company create/update now accepts nested multipart `company[...]` form payloads generated when the frontend includes an avatar, maps `company[avatar]` into the existing serialized `avatar_url` boundary, preserves nested multipart `company[custom_attributes][...]`, and makes company list/search/contact list/search use Chatwoot's fixed `RESULTS_PER_PAGE = 25` instead of local `per_page` overrides. Focused company handler tests, focused company service tests, full `go test ./...`, and `git diff --check` passed; no route artifacts changed.
- 2026-06-06: P3.11 account integrations apps/hooks checkpoint prepared as `feat(integrations): align app hook payloads`; audited Chatwoot `routes.rb:345-348`, `Integrations::AppsController`, `Integrations::HooksController`, app/hook Jbuilder views, `Integrations::App`, `Integrations::Hook`, `config/integration/apps.yml`, and reused dashboard `api/integrations.js`, `store/modules/integrations.js`, and settings integration components. Account integration apps/hooks now return Chatwoot `{ payload: [...] }` app index payloads and raw app/hook objects, accept raw frontend hook bodies plus nested `{ hook: ... }`, register frontend no-trailing-slash app/hook routes and hook `PATCH`, persist `app_id/reference_id`, scope hook show/update/delete by account, serialize boolean `status`, account/inbox `hook_type`, `settings`, `inbox`, app `hooks`, settings schemas, and visible properties, seed additional reference app catalog rows, and return empty `200 OK` delete. Focused integration hook handler/service tests, migration test, route parity test, router focused test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 869` and tracked route parity is `321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328`.
+1 -1
View File
@@ -645,7 +645,7 @@ func Bootstrap(env string) (*App, error) {
// Team + Profile services (P5 — Teams + Team Members + User Profiles)
teamService := service.NewTeamService(teamRepo, teamMemberRepo, db)
profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo)
profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
// Campaign + AutoAssignment services
campaignInternalSvc := campaign.NewCampaignService(db)
@@ -2,6 +2,9 @@ package v1
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"mime/multipart"
@@ -58,6 +61,7 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
&model.AccountUser{},
&model.CustomRole{},
&model.AccessToken{},
&model.InstallationConfig{},
))
s.db = db
@@ -94,7 +98,8 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
userRepo := repository.NewUserRepo(db)
accountUserRepo := repository.NewAccountUserRepo(db)
accessTokenRepo := repository.NewAccessTokenRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo)
installationConfigRepo := repository.NewInstallationConfigRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
s.handler = NewProfileHandler(profileSvc)
// Build router with profile routes and auth middleware
@@ -148,6 +153,7 @@ func (s *ProfileHandlerTestSuite) SetupTest() {
})
s.db.Unscoped().Where("account_id = ?", s.accountID).Delete(&model.CustomRole{})
s.db.Unscoped().Where("owner_type = ? AND owner_id = ?", model.AccessTokenOwnerTypeUser, s.userID).Delete(&model.AccessToken{})
s.db.Unscoped().Where("name = ?", "CHATWOOT_INBOX_HMAC_KEY").Delete(&model.InstallationConfig{})
s.Require().NoError(s.db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: s.userID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
}
@@ -182,6 +188,7 @@ func (s *ProfileHandlerTestSuite) TestGet_Success() {
assert.Equal(s.T(), "Profile Display", dataMap["available_name"])
assert.Equal(s.T(), "Regards", dataMap["message_signature"])
assert.Equal(s.T(), "pubsub-profile-user", dataMap["pubsub_token"])
assert.NotContains(s.T(), dataMap, "hmac_identifier")
assert.Equal(s.T(), "administrator", dataMap["role"])
accounts, ok := dataMap["accounts"].([]interface{})
assert.True(s.T(), ok)
@@ -221,6 +228,22 @@ func (s *ProfileHandlerTestSuite) TestGet_CustomRolePermissions() {
assert.Equal(s.T(), []interface{}{"conversation_manage", "contact_manage"}, customRole["permissions"])
}
func (s *ProfileHandlerTestSuite) TestGet_HMACIdentifierWhenConfigured() {
secret := "random_secret_key"
s.Require().NoError(s.db.Create(&model.InstallationConfig{Name: "CHATWOOT_INBOX_HMAC_KEY", Value: secret}).Error)
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload := s.decodeProfileBody(w)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte("profile@example.com"))
expected := hex.EncodeToString(mac.Sum(nil))
assert.Equal(s.T(), expected, payload["hmac_identifier"])
}
func (s *ProfileHandlerTestSuite) TestGet_Unauthorized() {
// Create router without auth middleware — user_id will be 0
r := gin.New()
+44 -8
View File
@@ -2,11 +2,16 @@ package service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -18,18 +23,24 @@ import (
// ProfileService implements business logic for user profile operations.
// Reference: Chatwoot app/controllers/api/v1/profile_controller.rb
type ProfileService struct {
userRepo *repository.UserRepo
accountUserRepo *repository.AccountUserRepo
accessTokenRepo *repository.AccessTokenRepo
userRepo *repository.UserRepo
accountUserRepo *repository.AccountUserRepo
accessTokenRepo *repository.AccessTokenRepo
installationConfigRepo *repository.InstallationConfigRepo
}
// NewProfileService creates a new Profile service.
func NewProfileService(userRepo *repository.UserRepo, accountUserRepo *repository.AccountUserRepo, accessTokenRepo ...*repository.AccessTokenRepo) *ProfileService {
var tokenRepo *repository.AccessTokenRepo
if len(accessTokenRepo) > 0 {
tokenRepo = accessTokenRepo[0]
func NewProfileService(userRepo *repository.UserRepo, accountUserRepo *repository.AccountUserRepo, extras ...any) *ProfileService {
svc := &ProfileService{userRepo: userRepo, accountUserRepo: accountUserRepo}
for _, extra := range extras {
switch repo := extra.(type) {
case *repository.AccessTokenRepo:
svc.accessTokenRepo = repo
case *repository.InstallationConfigRepo:
svc.installationConfigRepo = repo
}
}
return &ProfileService{userRepo: userRepo, accountUserRepo: accountUserRepo, accessTokenRepo: tokenRepo}
return svc
}
// ProfileUserResponse matches Chatwoot app/views/api/v1/models/_user.json.jbuilder.
@@ -42,6 +53,7 @@ type ProfileUserResponse struct {
DisplayName string `json:"display_name"`
MessageSignature string `json:"message_signature"`
Email string `json:"email"`
HMACIdentifier string `json:"hmac_identifier,omitempty"`
ID uint `json:"id"`
InviterID *uint `json:"inviter_id"`
Name string `json:"name"`
@@ -341,6 +353,10 @@ func (s *ProfileService) serializeUser(ctx context.Context, user *model.User, ac
if err != nil {
return nil, err
}
hmacIdentifier, err := s.hmacIdentifier(ctx, user.Email)
if err != nil {
return nil, err
}
displayName := user.DisplayName
availableName := user.Name
@@ -375,6 +391,7 @@ func (s *ProfileService) serializeUser(ctx context.Context, user *model.User, ac
DisplayName: displayName,
MessageSignature: user.MessageSignature,
Email: user.Email,
HMACIdentifier: hmacIdentifier,
ID: user.ID,
InviterID: inviterID,
Name: user.Name,
@@ -389,6 +406,25 @@ func (s *ProfileService) serializeUser(ctx context.Context, user *model.User, ac
}, nil
}
func (s *ProfileService) hmacIdentifier(ctx context.Context, email string) (string, error) {
if s.installationConfigRepo == nil {
return "", nil
}
cfg, err := s.installationConfigRepo.FindByName(ctx, "CHATWOOT_INBOX_HMAC_KEY")
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
if cfg.Value == "" {
return "", nil
}
mac := hmac.New(sha256.New, []byte(cfg.Value))
_, _ = mac.Write([]byte(email))
return hex.EncodeToString(mac.Sum(nil)), nil
}
func selectActiveAccountUser(accountUsers []model.AccountUser, accountID uint) *model.AccountUser {
if len(accountUsers) == 0 {
return nil