From 6fcdc74843e8f66cab527ad1c0db295176a4cc65 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Mon, 17 Aug 2026 21:20:45 +0200 Subject: [PATCH] fix --- .gitignore | 6 + apps/api/cmd/migrator/postimport.go | 4 +- apps/api/cmd/migrator/postimport_test.go | 4 +- apps/api/internal/auth/errors.go | 3 +- apps/api/internal/auth/ownership_test.go | 15 + apps/api/internal/catalog/service.go | 4 +- apps/api/internal/company/lang_content.go | 15 + .../api/internal/company/lang_content_test.go | 30 + .../httpapi/accept_invite_url_test.go | 2 +- apps/api/internal/httpapi/catalog_handlers.go | 26 + .../httpapi/catalog_handlers_seo_test.go | 76 ++ .../internal/httpapi/catalog_v1_handlers.go | 17 + .../httpapi/catalog_v1_handlers_test.go | 18 +- .../internal/httpapi/company_invite_test.go | 2 +- apps/api/internal/httpapi/health_test.go | 29 + .../internal/httpapi/processing_handlers.go | 1 + .../internal/httpapi/products_v1_handlers.go | 78 ++ .../httpapi/products_v1_handlers_test.go | 47 +- apps/api/internal/httpapi/v1.go | 9 +- apps/api/internal/httpapi/v1_auth_test.go | 2 +- .../internal/httpapi/v1_csrf_tenant_test.go | 6 +- .../v1_domain_crud_integration_test.go | 33 +- apps/api/internal/httpapi/v1_feeds_test.go | 2 +- apps/api/internal/httpapi/v1_openapi.go | 897 +++++++++--------- apps/api/internal/httpapi/v1_openapi_test.go | 166 +++- .../internal/httpapi/v1_process_handlers.go | 99 +- .../httpapi/v1_process_handlers_test.go | 215 ++++- apps/api/internal/mail/mailer.go | 25 +- apps/api/internal/mail/mailer_test.go | 33 +- apps/api/internal/metrics/metrics.go | 19 +- apps/api/internal/metrics/metrics_test.go | 14 + apps/api/internal/processing/sanitize.go | 101 +- apps/api/internal/processing/sanitize_test.go | 65 +- .../internal/processing/title_spacing_test.go | 11 +- apps/api/internal/processing/v1_item_json.go | 66 +- apps/api/internal/processing/v1_legacy.go | 101 +- .../api/internal/processing/v1_legacy_test.go | 61 ++ .../internal/processing/v1_process_item.go | 14 +- .../processing/v1_process_item_test.go | 132 ++- apps/web/scripts/fill-phrase-gaps.mjs | 36 +- apps/web/scripts/locale-extra-es.mjs | 4 +- apps/web/scripts/locale-extra-rest.mjs | 32 +- apps/web/scripts/phrase-map.json | 40 +- apps/web/src/hooks.server.ts | 21 +- apps/web/src/lib/analytics.ts | 5 +- apps/web/src/lib/assistant/assistant.test.ts | 17 +- apps/web/src/lib/assistant/engine.ts | 2 +- apps/web/src/lib/assistant/index.ts | 1 + apps/web/src/lib/assistant/intents.ts | 1 + apps/web/src/lib/assistant/match.ts | 8 +- apps/web/src/lib/assistant/state.svelte.ts | 5 +- apps/web/src/lib/assistant/types.ts | 2 + apps/web/src/lib/auth-link-token.test.ts | 69 ++ apps/web/src/lib/auth-link-token.ts | 73 ++ .../web/src/lib/categories/prompt-sections.ts | 10 +- .../src/lib/components/UserSwitcher.svelte | 1 - .../components/pricing/PricingSection.svelte | 12 +- apps/web/src/lib/docs-guide/resolve.ts | 39 + apps/web/src/lib/docs-guide/tree.ts | 52 +- apps/web/src/lib/docs/rapi-doc-auth.ts | 33 + apps/web/src/lib/i18n/messages/de.ts | 44 +- apps/web/src/lib/i18n/messages/en.ts | 65 +- apps/web/src/lib/i18n/messages/es.ts | 47 +- apps/web/src/lib/i18n/messages/fr.ts | 47 +- apps/web/src/lib/i18n/messages/it.ts | 53 +- apps/web/src/lib/i18n/messages/ja.ts | 47 +- apps/web/src/lib/i18n/messages/nl.ts | 53 +- apps/web/src/lib/i18n/messages/pl.ts | 47 +- apps/web/src/lib/i18n/messages/pt.ts | 47 +- apps/web/src/lib/merchant-copy.test.ts | 80 ++ .../lib/server/require-platform-admin.test.ts | 71 +- .../src/lib/server/require-platform-admin.ts | 67 +- apps/web/src/lib/staff-access.test.ts | 23 +- apps/web/src/lib/staff-access.ts | 9 + .../web/src/routes/accept-invite/+page.svelte | 51 +- apps/web/src/routes/admin/+layout.server.ts | 46 + apps/web/src/routes/admin/+page.svelte | 28 +- .../src/routes/admin/bootstrap/+page.svelte | 49 +- .../web/src/routes/admin/logs/+page.server.ts | 7 + apps/web/src/routes/admin/users/+page.svelte | 2 +- .../web/src/routes/contact-sales/+page.svelte | 22 +- apps/web/src/routes/docs/+page.svelte | 9 +- .../src/routes/integrations/ai/+page.svelte | 4 +- apps/web/src/routes/login/+page.svelte | 6 - apps/web/src/routes/pricing/+page.svelte | 11 +- .../src/routes/reset-password/+page.svelte | 25 +- apps/web/src/routes/settings/+page.svelte | 2 +- apps/web/src/routes/unsubscribe/+page.svelte | 12 +- .../static/.audit/00-post-login-mobile.png | Bin 113578 -> 0 bytes .../.audit/00-post-login-mobile.report.json | 72 -- .../static/.audit/00-post-login-tablet.png | Bin 192576 -> 0 bytes .../.audit/00-post-login-tablet.report.json | 72 -- apps/web/static/.audit/00b-login-tablet.png | Bin 192576 -> 0 bytes .../.audit/00b-login-tablet.report.json | 72 -- .../web/static/.audit/01-dashboard-mobile.png | Bin 113578 -> 0 bytes .../.audit/01-dashboard-mobile.report.json | 444 --------- apps/web/static/.audit/02-login-mobile.png | Bin 86339 -> 0 bytes .../static/.audit/02-login-mobile.report.json | 46 - apps/web/static/.audit/02-login-tablet.png | Bin 125753 -> 0 bytes .../static/.audit/02-login-tablet.report.json | 46 - .../web/static/.audit/03-dashboard-tablet.png | Bin 192576 -> 0 bytes .../.audit/03-dashboard-tablet.report.json | 492 ---------- apps/web/static/.audit/04-settings-mobile.png | Bin 93609 -> 0 bytes .../.audit/04-settings-mobile.report.json | 340 ------- apps/web/static/.audit/04-settings-tablet.png | Bin 126312 -> 0 bytes .../.audit/04-settings-tablet.report.json | 408 -------- apps/web/static/.audit/05-billing-mobile.png | Bin 105022 -> 0 bytes .../.audit/05-billing-mobile.report.json | 268 ------ apps/web/static/.audit/05-billing-tablet.png | Bin 153911 -> 0 bytes .../.audit/05-billing-tablet.report.json | 336 ------- apps/web/static/.audit/06-support-mobile.png | Bin 95028 -> 0 bytes .../.audit/06-support-mobile.report.json | 312 ------ apps/web/static/.audit/06-support-tablet.png | Bin 115453 -> 0 bytes .../.audit/06-support-tablet.report.json | 380 -------- .../static/.audit/07-support-new-mobile.png | Bin 98809 -> 0 bytes .../.audit/07-support-new-mobile.report.json | 346 ------- .../static/.audit/07-support-new-tablet.png | Bin 137273 -> 0 bytes .../.audit/07-support-new-tablet.report.json | 414 -------- .../static/.audit/10-admin-support-tablet.png | Bin 55539 -> 0 bytes .../10-admin-support-tablet.report.json | 136 --- .../static/.audit/11-admin-users-tablet.png | Bin 66078 -> 0 bytes .../.audit/11-admin-users-tablet.report.json | 136 --- .../.audit/12-admin-settings-tablet.png | Bin 52217 -> 0 bytes .../12-admin-settings-tablet.report.json | 122 --- apps/web/static/.audit/13-nav-open-mobile.png | Bin 113578 -> 0 bytes .../.audit/13-nav-open-mobile.report.json | 424 --------- apps/web/static/.audit/13-nav-open-tablet.png | Bin 59447 -> 0 bytes .../.audit/13-nav-open-tablet.report.json | 206 ---- .../web/static/.audit/audit-login-mobile.webp | Bin 31332 -> 0 bytes apps/web/static/.audit/capture_audit.py | 185 ---- apps/web/static/.audit/login-actions.json | 9 - apps/web/static/.audit/probe-consent.png | Bin 119333 -> 0 bytes .../static/.audit/probe-consent.report.json | 304 ------ .../static/.audit/r00-logged-in-mobile.png | Bin 113955 -> 0 bytes .../.audit/r00-logged-in-mobile.report.json | 86 -- .../static/.audit/r00-logged-in-tablet.png | Bin 181744 -> 0 bytes .../.audit/r00-logged-in-tablet.report.json | 86 -- apps/web/static/.audit/r00-mobile.png | Bin 113955 -> 0 bytes apps/web/static/.audit/r00-mobile.report.json | 86 -- apps/web/static/.audit/r02-login-mobile.png | Bin 84435 -> 0 bytes .../.audit/r02-login-mobile.report.json | 64 -- .../static/.audit/r03-dashboard-mobile.png | Bin 148149 -> 0 bytes .../.audit/r03-dashboard-mobile.report.json | 63 -- .../web/static/.audit/r04-settings-mobile.png | Bin 130236 -> 0 bytes .../.audit/r04-settings-mobile.report.json | 63 -- apps/web/static/.audit/r05-billing-mobile.png | Bin 141180 -> 0 bytes .../.audit/r05-billing-mobile.report.json | 63 -- apps/web/static/.audit/r06-support-mobile.png | Bin 119763 -> 0 bytes .../.audit/r06-support-mobile.report.json | 63 -- .../static/.audit/r07-support-new-mobile.png | Bin 133217 -> 0 bytes .../.audit/r07-support-new-mobile.report.json | 63 -- .../web/static/.audit/r13-nav-open-mobile.png | Bin 133294 -> 0 bytes .../.audit/r13-nav-open-mobile.report.json | 67 -- apps/web/static/.audit/r14-fab-mobile.png | Bin 148149 -> 0 bytes .../static/.audit/r14-fab-mobile.report.json | 64 -- apps/web/static/.audit/recapture.py | 214 ----- apps/web/static/robots.txt | 2 +- 157 files changed, 2895 insertions(+), 7544 deletions(-) create mode 100644 apps/api/internal/httpapi/catalog_handlers_seo_test.go create mode 100644 apps/web/src/lib/auth-link-token.test.ts create mode 100644 apps/web/src/lib/auth-link-token.ts create mode 100644 apps/web/src/lib/merchant-copy.test.ts create mode 100644 apps/web/src/routes/admin/+layout.server.ts create mode 100644 apps/web/src/routes/admin/logs/+page.server.ts delete mode 100644 apps/web/static/.audit/00-post-login-mobile.png delete mode 100644 apps/web/static/.audit/00-post-login-mobile.report.json delete mode 100644 apps/web/static/.audit/00-post-login-tablet.png delete mode 100644 apps/web/static/.audit/00-post-login-tablet.report.json delete mode 100644 apps/web/static/.audit/00b-login-tablet.png delete mode 100644 apps/web/static/.audit/00b-login-tablet.report.json delete mode 100644 apps/web/static/.audit/01-dashboard-mobile.png delete mode 100644 apps/web/static/.audit/01-dashboard-mobile.report.json delete mode 100644 apps/web/static/.audit/02-login-mobile.png delete mode 100644 apps/web/static/.audit/02-login-mobile.report.json delete mode 100644 apps/web/static/.audit/02-login-tablet.png delete mode 100644 apps/web/static/.audit/02-login-tablet.report.json delete mode 100644 apps/web/static/.audit/03-dashboard-tablet.png delete mode 100644 apps/web/static/.audit/03-dashboard-tablet.report.json delete mode 100644 apps/web/static/.audit/04-settings-mobile.png delete mode 100644 apps/web/static/.audit/04-settings-mobile.report.json delete mode 100644 apps/web/static/.audit/04-settings-tablet.png delete mode 100644 apps/web/static/.audit/04-settings-tablet.report.json delete mode 100644 apps/web/static/.audit/05-billing-mobile.png delete mode 100644 apps/web/static/.audit/05-billing-mobile.report.json delete mode 100644 apps/web/static/.audit/05-billing-tablet.png delete mode 100644 apps/web/static/.audit/05-billing-tablet.report.json delete mode 100644 apps/web/static/.audit/06-support-mobile.png delete mode 100644 apps/web/static/.audit/06-support-mobile.report.json delete mode 100644 apps/web/static/.audit/06-support-tablet.png delete mode 100644 apps/web/static/.audit/06-support-tablet.report.json delete mode 100644 apps/web/static/.audit/07-support-new-mobile.png delete mode 100644 apps/web/static/.audit/07-support-new-mobile.report.json delete mode 100644 apps/web/static/.audit/07-support-new-tablet.png delete mode 100644 apps/web/static/.audit/07-support-new-tablet.report.json delete mode 100644 apps/web/static/.audit/10-admin-support-tablet.png delete mode 100644 apps/web/static/.audit/10-admin-support-tablet.report.json delete mode 100644 apps/web/static/.audit/11-admin-users-tablet.png delete mode 100644 apps/web/static/.audit/11-admin-users-tablet.report.json delete mode 100644 apps/web/static/.audit/12-admin-settings-tablet.png delete mode 100644 apps/web/static/.audit/12-admin-settings-tablet.report.json delete mode 100644 apps/web/static/.audit/13-nav-open-mobile.png delete mode 100644 apps/web/static/.audit/13-nav-open-mobile.report.json delete mode 100644 apps/web/static/.audit/13-nav-open-tablet.png delete mode 100644 apps/web/static/.audit/13-nav-open-tablet.report.json delete mode 100644 apps/web/static/.audit/audit-login-mobile.webp delete mode 100644 apps/web/static/.audit/capture_audit.py delete mode 100644 apps/web/static/.audit/login-actions.json delete mode 100644 apps/web/static/.audit/probe-consent.png delete mode 100644 apps/web/static/.audit/probe-consent.report.json delete mode 100644 apps/web/static/.audit/r00-logged-in-mobile.png delete mode 100644 apps/web/static/.audit/r00-logged-in-mobile.report.json delete mode 100644 apps/web/static/.audit/r00-logged-in-tablet.png delete mode 100644 apps/web/static/.audit/r00-logged-in-tablet.report.json delete mode 100644 apps/web/static/.audit/r00-mobile.png delete mode 100644 apps/web/static/.audit/r00-mobile.report.json delete mode 100644 apps/web/static/.audit/r02-login-mobile.png delete mode 100644 apps/web/static/.audit/r02-login-mobile.report.json delete mode 100644 apps/web/static/.audit/r03-dashboard-mobile.png delete mode 100644 apps/web/static/.audit/r03-dashboard-mobile.report.json delete mode 100644 apps/web/static/.audit/r04-settings-mobile.png delete mode 100644 apps/web/static/.audit/r04-settings-mobile.report.json delete mode 100644 apps/web/static/.audit/r05-billing-mobile.png delete mode 100644 apps/web/static/.audit/r05-billing-mobile.report.json delete mode 100644 apps/web/static/.audit/r06-support-mobile.png delete mode 100644 apps/web/static/.audit/r06-support-mobile.report.json delete mode 100644 apps/web/static/.audit/r07-support-new-mobile.png delete mode 100644 apps/web/static/.audit/r07-support-new-mobile.report.json delete mode 100644 apps/web/static/.audit/r13-nav-open-mobile.png delete mode 100644 apps/web/static/.audit/r13-nav-open-mobile.report.json delete mode 100644 apps/web/static/.audit/r14-fab-mobile.png delete mode 100644 apps/web/static/.audit/r14-fab-mobile.report.json delete mode 100644 apps/web/static/.audit/recapture.py diff --git a/.gitignore b/.gitignore index 124b7db..43f7886 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,9 @@ scripts/_layout_snip.js scripts/write_001.py apps/web/scripts/_* apps/web/scripts/tr-* + +# Never ship local browser-audit dumps as public static +apps/web/static/.audit/ + +# codehelper (generated local — do not commit) +.zed/ diff --git a/apps/api/cmd/migrator/postimport.go b/apps/api/cmd/migrator/postimport.go index 37a00c3..b9cbaf8 100644 --- a/apps/api/cmd/migrator/postimport.go +++ b/apps/api/cmd/migrator/postimport.go @@ -4,13 +4,13 @@ import ( "context" "fmt" "log" - "net/url" "os" "path/filepath" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" + "github.com/descrybe/descrybe-v2/apps/api/internal/mail" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) @@ -37,7 +37,7 @@ func webOrigin() string { } func setPasswordInviteURL(token string) string { - return webOrigin() + "/accept-invite?token=" + url.QueryEscape(token) + return mail.AcceptInviteURL(webOrigin(), token) } // prepareSetPasswordHooks creates invites for active users with must_set_password=true. diff --git a/apps/api/cmd/migrator/postimport_test.go b/apps/api/cmd/migrator/postimport_test.go index 1c0ba87..c190007 100644 --- a/apps/api/cmd/migrator/postimport_test.go +++ b/apps/api/cmd/migrator/postimport_test.go @@ -10,7 +10,7 @@ import ( func TestSetPasswordInviteURL(t *testing.T) { t.Setenv("WEB_ORIGIN", "https://app.example.com/") got := setPasswordInviteURL("tok+1") - wantPrefix := "https://app.example.com/accept-invite?token=" + wantPrefix := "https://app.example.com/accept-invite#token=" if !strings.HasPrefix(got, wantPrefix) { t.Fatalf("got %q", got) } @@ -31,4 +31,4 @@ func TestWebOriginDefault(t *testing.T) { if webOrigin() != "http://localhost:5174" { t.Fatalf("default origin") } -} \ No newline at end of file +} diff --git a/apps/api/internal/auth/errors.go b/apps/api/internal/auth/errors.go index b4111fc..3229385 100644 --- a/apps/api/internal/auth/errors.go +++ b/apps/api/internal/auth/errors.go @@ -20,6 +20,8 @@ func ClientError(err error) (msg string, ok bool) { switch { case err == nil: return "", false + case errors.Is(err, ErrSyntheticEmail): + return "this email cannot receive invites", true case errors.Is(err, ErrRegisterFieldsRequired), errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrPasswordAlreadySet), @@ -32,7 +34,6 @@ func ClientError(err error) (msg string, ok bool) { errors.Is(err, ErrInviteNotFound), errors.Is(err, ErrTokenInvalid), errors.Is(err, ErrEmailRequired), - errors.Is(err, ErrSyntheticEmail), errors.Is(err, ErrNotEligibleSetPassword), errors.Is(err, ErrEmailMismatch), errors.Is(err, ErrNotCompanyOwner), diff --git a/apps/api/internal/auth/ownership_test.go b/apps/api/internal/auth/ownership_test.go index 5db633d..337af93 100644 --- a/apps/api/internal/auth/ownership_test.go +++ b/apps/api/internal/auth/ownership_test.go @@ -1,6 +1,7 @@ package auth import ( + "strings" "testing" ) @@ -18,3 +19,17 @@ func TestOwnershipErrorClientFacing(t *testing.T) { } } } + +func TestClientError_syntheticEmailPublicString(t *testing.T) { + t.Parallel() + msg, ok := ClientError(ErrSyntheticEmail) + if !ok { + t.Fatal("ErrSyntheticEmail should be a client error") + } + if msg != "this email cannot receive invites" { + t.Fatalf("public msg=%q", msg) + } + if strings.Contains(msg, "synthetic") || strings.Contains(msg, "migration") { + t.Fatalf("internal wording leaked: %q", msg) + } +} diff --git a/apps/api/internal/catalog/service.go b/apps/api/internal/catalog/service.go index 59d0106..f194bc5 100644 --- a/apps/api/internal/catalog/service.go +++ b/apps/api/internal/catalog/service.go @@ -1265,7 +1265,7 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU item["content_language"] = primary item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID) if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil { - item["localized_content"] = loc + item["localized_content"] = loc.Public() } return item, nil } @@ -1328,7 +1328,7 @@ func (s *Service) GetRawProduct(ctx context.Context, companyID, id uuid.UUID) (m item["content_language"] = primary item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID) if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil { - item["localized_content"] = loc + item["localized_content"] = loc.Public() } return item, nil } diff --git a/apps/api/internal/company/lang_content.go b/apps/api/internal/company/lang_content.go index f9d2cc4..6fb279e 100644 --- a/apps/api/internal/company/lang_content.go +++ b/apps/api/internal/company/lang_content.go @@ -31,6 +31,21 @@ type LocalizedFields struct { // LocalizedContent is language-code → per-language product output fields. type LocalizedContent map[string]LocalizedFields +// Public returns a copy safe for API/UI clients. Drops enhance_input_hash +// (pipeline cache key) so GET product payloads cannot leak job internals. +// UpdateProcessedProduct merges from DB, so omitting the hash on GET is safe. +func (c LocalizedContent) Public() LocalizedContent { + if len(c) == 0 { + return c + } + out := make(LocalizedContent, len(c)) + for lang, fields := range c { + fields.EnhanceInputHash = "" + out[lang] = fields + } + return out +} + // SanitizeLangPromptMap validates language codes, sanitizes prompts, and drops empties. // Accepts LangPromptAny ("*") as a shared any-language prompt key. func SanitizeLangPromptMap(in map[string]string, maxRunes int) (LangPromptMap, error) { diff --git a/apps/api/internal/company/lang_content_test.go b/apps/api/internal/company/lang_content_test.go index 302db4e..5ca6512 100644 --- a/apps/api/internal/company/lang_content_test.go +++ b/apps/api/internal/company/lang_content_test.go @@ -2,6 +2,7 @@ package company import ( "encoding/json" + "strings" "testing" ) @@ -101,3 +102,32 @@ func TestLocalizedContentRoundTrip(t *testing.T) { t.Fatalf("got %#v", f) } } + +func TestLocalizedContentPublicOmitsEnhanceHash(t *testing.T) { + t.Parallel() + c := LocalizedContent{ + "sl": { + ProcessedName: "Naslov", + ProcessedDescription: "Opis", + MetaTitle: "Meta", + EnhanceInputHash: "deadbeef", + }, + } + pub := c.Public() + if pub["sl"].EnhanceInputHash != "" { + t.Fatalf("public hash leaked: %#v", pub["sl"]) + } + if pub["sl"].ProcessedName != "Naslov" || pub["sl"].MetaTitle != "Meta" { + t.Fatalf("public stripped too much: %#v", pub["sl"]) + } + if c["sl"].EnhanceInputHash != "deadbeef" { + t.Fatalf("Public must not mutate storage copy: %#v", c["sl"]) + } + b, err := json.Marshal(pub) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "enhance_input_hash") || strings.Contains(string(b), "deadbeef") { + t.Fatalf("hash in JSON: %s", b) + } +} diff --git a/apps/api/internal/httpapi/accept_invite_url_test.go b/apps/api/internal/httpapi/accept_invite_url_test.go index bcce610..d9c4db7 100644 --- a/apps/api/internal/httpapi/accept_invite_url_test.go +++ b/apps/api/internal/httpapi/accept_invite_url_test.go @@ -9,7 +9,7 @@ import ( func TestAcceptInviteURL(t *testing.T) { t.Parallel() got := mail.AcceptInviteURL("http://localhost:28472/", "abc123") - want := "http://localhost:28472/accept-invite?token=abc123" + want := "http://localhost:28472/accept-invite#token=abc123" if got != want { t.Fatalf("AcceptInviteURL = %q want %q", got, want) } diff --git a/apps/api/internal/httpapi/catalog_handlers.go b/apps/api/internal/httpapi/catalog_handlers.go index f1ed5e7..094a541 100644 --- a/apps/api/internal/httpapi/catalog_handlers.go +++ b/apps/api/internal/httpapi/catalog_handlers.go @@ -7,6 +7,7 @@ import ( "github.com/descrybe/descrybe-v2/apps/api/internal/catalog" "github.com/descrybe/descrybe-v2/apps/api/internal/company" + "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -403,6 +404,7 @@ func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) { if detailed { attachProductQuality(items) } + stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), items...) resp := map[string]any{"products": items, "total": total, "kind": "processed", "limit": limit, "offset": offset, "detailed": detailed} if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" { if nextCursor != "" { @@ -438,6 +440,7 @@ func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) { return } } + stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item) JSON(w, http.StatusOK, item) } @@ -458,5 +461,28 @@ func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) { ClientOrLog(w, http.StatusBadRequest, "could not update product", err, catalog.ClientError) return } + stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item) JSON(w, http.StatusOK, item) } + +// stripCatalogSEOMetaIfOmitted drops meta_title / meta_description from dashboard +// product payloads when omit is true (same detection as V1 process / CompanyOmitsSEOMeta). +func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) { + if !omit { + return + } + for _, item := range items { + if item == nil { + continue + } + delete(item, "meta_title") + delete(item, "meta_description") + if loc, ok := item["localized_content"].(company.LocalizedContent); ok { + for lang, fields := range loc { + fields.MetaTitle = "" + fields.MetaDescription = "" + loc[lang] = fields + } + } + } +} diff --git a/apps/api/internal/httpapi/catalog_handlers_seo_test.go b/apps/api/internal/httpapi/catalog_handlers_seo_test.go new file mode 100644 index 0000000..825c106 --- /dev/null +++ b/apps/api/internal/httpapi/catalog_handlers_seo_test.go @@ -0,0 +1,76 @@ +package httpapi + +import ( + "testing" + + "github.com/descrybe/descrybe-v2/apps/api/internal/company" + "github.com/descrybe/descrybe-v2/apps/api/internal/processing" + "github.com/google/uuid" +) + +func catalogSEOItem() map[string]any { + return map[string]any{ + "name": "Widget", + "meta_title": "T", + "meta_description": "D", + "localized_content": company.LocalizedContent{ + "sl": {ProcessedName: "N", MetaTitle: "LT", MetaDescription: "LD", EnhanceInputHash: "abc"}, + }, + } +} + +func assertCatalogSEOOmitted(t *testing.T, item map[string]any) { + t.Helper() + if _, ok := item["meta_title"]; ok { + t.Fatalf("must omit meta_title: %v", item) + } + if _, ok := item["meta_description"]; ok { + t.Fatalf("must omit meta_description: %v", item) + } + loc := item["localized_content"].(company.LocalizedContent) + if loc["sl"].MetaTitle != "" || loc["sl"].MetaDescription != "" { + t.Fatalf("localized meta leaked: %#v", loc["sl"]) + } + if loc["sl"].ProcessedName != "N" { + t.Fatalf("stripped too much: %#v", loc["sl"]) + } +} + +func TestStripCatalogSEOMetaIfOmitted(t *testing.T) { + t.Parallel() + a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790") + other := uuid.MustParse("11111111-1111-1111-1111-111111111111") + + item := catalogSEOItem() + stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(other, "", "Acme", false), item) + if item["meta_title"] != "T" { + t.Fatalf("ordinary company must keep meta: %v", item["meta_title"]) + } + + stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(a1, "", "", false), item) + assertCatalogSEOOmitted(t, item) +} + +func TestStripCatalogSEOMetaIfOmitted_demoByName(t *testing.T) { + t.Parallel() + other := uuid.MustParse("11111111-1111-1111-1111-111111111111") + omit := processing.CompanyOmitsSEOMetaLookup(other, "", " platform demo ", false) + if !omit { + t.Fatal("Platform Demo by name must omit SEO meta") + } + item := catalogSEOItem() + stripCatalogSEOMetaIfOmitted(omit, item) + assertCatalogSEOOmitted(t, item) +} + +func TestStripCatalogSEOMetaIfOmitted_a1PromptMarkers(t *testing.T) { + t.Parallel() + other := uuid.MustParse("11111111-1111-1111-1111-111111111111") + omit := processing.CompanyOmitsSEOMetaLookup(other, "", "Acme", true) + if !omit { + t.Fatal("A1-style prompt markers must omit SEO meta") + } + item := catalogSEOItem() + stripCatalogSEOMetaIfOmitted(omit, item) + assertCatalogSEOOmitted(t, item) +} diff --git a/apps/api/internal/httpapi/catalog_v1_handlers.go b/apps/api/internal/httpapi/catalog_v1_handlers.go index a495d67..ebadaf7 100644 --- a/apps/api/internal/httpapi/catalog_v1_handlers.go +++ b/apps/api/internal/httpapi/catalog_v1_handlers.go @@ -24,6 +24,23 @@ func presentV1Category(item map[string]any) map[string]any { } } +// handleV1GetCategory serves GET /api/v1/categories/{id} as the public list DTO +// (no prompts, formulas, or has_prompt). Dashboard GET stays on handleGetCategory. +func (s *Server) handleV1GetCategory(w http.ResponseWriter, r *http.Request) { + cid, _ := CompanyIDFromContext(r.Context()) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + Error(w, http.StatusBadRequest, "invalid id") + return + } + item, err := s.Catalog.GetCategory(r.Context(), cid, id) + if err != nil { + Error(w, http.StatusNotFound, "not found") + return + } + v1OK(w, http.StatusOK, presentV1Category(item), nil) +} + func presentV1Attribute(item map[string]any) map[string]any { out := map[string]any{ "id": item["id"], diff --git a/apps/api/internal/httpapi/catalog_v1_handlers_test.go b/apps/api/internal/httpapi/catalog_v1_handlers_test.go index 6f45acc..5f90681 100644 --- a/apps/api/internal/httpapi/catalog_v1_handlers_test.go +++ b/apps/api/internal/httpapi/catalog_v1_handlers_test.go @@ -17,6 +17,9 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) { cat := presentV1Category(map[string]any{ "id": id, "unique_id": "electronics", "name": "Electronics", "created_at": ts, "updated_at": ts, + "prompt": "--- Title --- secret", "prompts": map[string]string{"en": "x"}, + "has_prompt": true, "title_template": []any{"--- Title ---"}, + "description_template": []any{"x"}, }) if cat["unique_id"] != "electronics" || cat["name"] != "Electronics" { t.Fatalf("category=%v", cat) @@ -24,6 +27,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) { if cat["created_at"] != "2026-07-01T08:00:00Z" { t.Fatalf("created_at=%v", cat["created_at"]) } + for _, leak := range []string{"prompt", "prompts", "has_prompt", "title_template", "description_template"} { + if _, ok := cat[leak]; ok { + t.Fatalf("public category leaked %q: %v", leak, cat) + } + } attr := presentV1Attribute(map[string]any{ "id": id, "attribute_key": "color", "name": "Color", "value_type": "string", @@ -41,11 +49,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) { func TestV1OpenAPICategoriesAttributesLegacyContract(t *testing.T) { body := string(v1OpenAPIYAML) needles := []string{ - "LegacyCategoriesResponse", - "LegacyAttributesResponse", - "LegacyCategoryCreateResponse", - "LegacyAttributeCreateResponse", - "LegacySuccessMessage", + "CategoriesEnvelope", + "AttributesEnvelope", + "CategoryCreateEnvelope", + "AttributeCreateEnvelope", + "SuccessMessage", "category_unique_id", "attribute_key", "parent_id", diff --git a/apps/api/internal/httpapi/company_invite_test.go b/apps/api/internal/httpapi/company_invite_test.go index 761db9e..be01c2b 100644 --- a/apps/api/internal/httpapi/company_invite_test.go +++ b/apps/api/internal/httpapi/company_invite_test.go @@ -10,7 +10,7 @@ import ( func TestInviteAcceptURLAlwaysShareable(t *testing.T) { t.Parallel() url := mail.AcceptInviteURL("https://app.example.com/", "tok_abc") - want := "https://app.example.com/accept-invite?token=tok_abc" + want := "https://app.example.com/accept-invite#token=tok_abc" if url != want { t.Fatalf("AcceptInviteURL = %q want %q", url, want) } diff --git a/apps/api/internal/httpapi/health_test.go b/apps/api/internal/httpapi/health_test.go index 62f2a4a..ff76469 100644 --- a/apps/api/internal/httpapi/health_test.go +++ b/apps/api/internal/httpapi/health_test.go @@ -413,6 +413,35 @@ func TestRouterMetricsHiddenInProductionForRemote(t *testing.T) { } } +func TestRouterMetricsHiddenInProductionForSpoofedLoopbackXFF(t *testing.T) { + t.Parallel() + s := testAPIServer() + s.Config.AppEnv = "production" + s.Config.MetricsPublic = false + s.Config.TrustedProxies = []string{"10.0.0.0/8"} + h := s.Router() + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + req.RemoteAddr = "10.0.0.5:443" + req.Header.Set("X-Forwarded-For", "127.0.0.1") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("trusted proxy spoofed loopback XFF status=%d want 404", rec.Code) + } + + s.Config.MetricsPublic = true + hPub := s.Router() + reqPub := httptest.NewRequest(http.MethodGet, "/metrics", nil) + reqPub.RemoteAddr = "10.0.0.5:443" + reqPub.Header.Set("X-Forwarded-For", "127.0.0.1") + recPub := httptest.NewRecorder() + hPub.ServeHTTP(recPub, reqPub) + if recPub.Code != http.StatusOK { + t.Fatalf("METRICS_PUBLIC trusted proxy XFF status=%d", recPub.Code) + } +} + func assertGateBody(t *testing.T, rec *httptest.ResponseRecorder, errorCode string, maintenance, readOnly bool) { t.Helper() var body map[string]any diff --git a/apps/api/internal/httpapi/processing_handlers.go b/apps/api/internal/httpapi/processing_handlers.go index d37b04e..858968a 100644 --- a/apps/api/internal/httpapi/processing_handlers.go +++ b/apps/api/internal/httpapi/processing_handlers.go @@ -119,6 +119,7 @@ func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request) Error(w, http.StatusInternalServerError, "load failed") return } + items = processing.ApplyV1SEOMetaPolicy(cid, items) JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true)) return } diff --git a/apps/api/internal/httpapi/products_v1_handlers.go b/apps/api/internal/httpapi/products_v1_handlers.go index 5a6bf08..0093320 100644 --- a/apps/api/internal/httpapi/products_v1_handlers.go +++ b/apps/api/internal/httpapi/products_v1_handlers.go @@ -1,6 +1,7 @@ package httpapi import ( + "errors" "net/http" "strconv" "strings" @@ -8,6 +9,10 @@ import ( "github.com/descrybe/descrybe-v2/apps/api/internal/catalog" "github.com/descrybe/descrybe-v2/apps/api/internal/marketing" + "github.com/descrybe/descrybe-v2/apps/api/internal/processing" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) const ( @@ -88,6 +93,49 @@ func presentV1Product(item map[string]any) map[string]any { } } +func presentV1ProductDetail(item map[string]any) map[string]any { + out := presentV1Product(item) + desc := firstNonEmpty(asMapString(item["processed_description"]), asMapString(item["description"])) + if desc == "" { + out["description"] = nil + } else { + out["description"] = desc + } + out["attributes"] = firstPublicAttributes(item) + mapped, _ := item["mapped_data"].(map[string]any) + main, more := catalog.ExtractProductImages(mapped, nil) + if main == "" { + out["main_image"] = nil + } else { + out["main_image"] = main + } + if len(more) > 0 { + out["more_images"] = more + } else { + out["more_images"] = nil + } + if eprel, ok := item["eprel"]; ok { + out["eprel"] = eprel + } else { + out["eprel"] = nil + } + return out +} + +func firstPublicAttributes(item map[string]any) any { + for _, key := range []string{"processed_attributes", "attributes"} { + v := item[key] + if v == nil { + continue + } + if m, ok := v.(map[string]any); ok && len(m) == 0 { + continue + } + return v + } + return map[string]any{} +} + func nullIfEmptyAny(v any) any { if v == nil { return nil @@ -150,6 +198,36 @@ func (s *Server) handleV1ListProducts(w http.ResponseWriter, r *http.Request) { v1OK(w, http.StatusOK, data, v1ProductListMeta(page, limit, total)) } +// handleV1GetProduct serves GET /api/v1/products/{id} as a public DTO aligned with +// process items (name, description, attributes, images, eprel). Dashboard GET stays +// on handleGetProduct. +func (s *Server) handleV1GetProduct(w http.ResponseWriter, r *http.Request) { + cid, _ := CompanyIDFromContext(r.Context()) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + Error(w, http.StatusBadRequest, "invalid id") + return + } + item, err := s.Catalog.GetProcessedProduct(r.Context(), cid, id) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + Error(w, http.StatusInternalServerError, "lookup failed") + return + } + item, err = s.Catalog.GetRawProduct(r.Context(), cid, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + Error(w, http.StatusNotFound, "not found") + return + } + Error(w, http.StatusInternalServerError, "lookup failed") + return + } + } + stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item) + v1OK(w, http.StatusOK, presentV1ProductDetail(item), nil) +} + // handleV1ListProductQuality serves GET /api/v1/products/quality with the legacy // { data, meta } envelope (quality rows + page/limit/total). func (s *Server) handleV1ListProductQuality(w http.ResponseWriter, r *http.Request) { diff --git a/apps/api/internal/httpapi/products_v1_handlers_test.go b/apps/api/internal/httpapi/products_v1_handlers_test.go index 625775f..d7f3e7b 100644 --- a/apps/api/internal/httpapi/products_v1_handlers_test.go +++ b/apps/api/internal/httpapi/products_v1_handlers_test.go @@ -56,6 +56,51 @@ func TestPresentV1ProductFields(t *testing.T) { } } +func TestPresentV1ProductDetailOmitsMappedData(t *testing.T) { + ts := time.Date(2026, 8, 1, 10, 15, 0, 0, time.UTC) + row := map[string]any{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "product_id": "SKU-1001", + "name": "Wireless earbuds", + "processed_name": "Acme Wireless Earbuds", + "category": "electronics/audio", + "status": "completed", + "feed_id": "22222222-2222-2222-2222-222222222222", + "description": "raw desc", + "processed_description": "A detailed product description that is long enough for scoring.", + "attributes": map[string]any{"color": "Black"}, + "processed_attributes": map[string]any{"color": "Midnight"}, + "mapped_data": map[string]any{"image": "https://example.com/earbuds.jpg", "main_image": "https://example.com/main.jpg"}, + "has_processed_name": true, + "has_eprel": false, + "eprel": nil, + "created_at": ts, + "updated_at": ts, + } + out := presentV1ProductDetail(row) + for _, key := range []string{"name", "description", "attributes", "main_image", "more_images", "eprel"} { + if _, ok := out[key]; !ok { + t.Fatalf("missing field %q", key) + } + } + for _, leak := range []string{ + "mapped_data", "has_processed_name", "has_processed_description", + "has_attributes", "has_processed_attributes", "has_eprel", + "processed_name", "processed_description", "processed_attributes", + } { + if _, ok := out[leak]; ok { + t.Fatalf("unexpected internal field %q", leak) + } + } + if out["description"] != "raw desc" && out["description"] != "A detailed product description that is long enough for scoring." { + t.Fatalf("description=%v", out["description"]) + } + attrs, _ := out["attributes"].(map[string]any) + if attrs["color"] != "Midnight" { + t.Fatalf("attributes=%v", out["attributes"]) + } +} + func TestV1ProductStatusAll(t *testing.T) { if got := v1ProductStatus("all"); got != "" { t.Fatalf("all -> %q want empty", got) @@ -99,7 +144,7 @@ func TestV1OpenAPIIncludesLegacyProductsEnvelope(t *testing.T) { "name: sortBy", "name: feedId", "totalPages", - "LegacyLimit", + "PublicLimit", "required: [data, meta]", } { if !strings.Contains(body, needle) { diff --git a/apps/api/internal/httpapi/v1.go b/apps/api/internal/httpapi/v1.go index 4ec0af6..d53f55c 100644 --- a/apps/api/internal/httpapi/v1.go +++ b/apps/api/internal/httpapi/v1.go @@ -8,7 +8,6 @@ import ( "net/http" "strings" - "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) @@ -66,7 +65,7 @@ func (s *Server) mountV1(r chi.Router) { // Not an alias of POST/GET /process (flat ProcessingJob). r.Post("/products/process", s.handleV1StartProcess) r.Get("/products/process/{id}", s.handleV1GetProcess) - r.Get("/products/{id}", s.handleGetProduct) + r.Get("/products/{id}", s.handleV1GetProduct) r.Patch("/products/{id}", s.handleUpdateProduct) // Content calendar — separate from email /api/campaigns (session UI). @@ -79,7 +78,7 @@ func (s *Server) mountV1(r chi.Router) { r.Get("/categories", s.handleV1ListCategories) r.Post("/categories", s.handleV1CreateCategory) r.Post("/categories/create", s.handleV1CreateCategory) // legacy alias - r.Get("/categories/{id}", s.handleGetCategory) + r.Get("/categories/{id}", s.handleV1GetCategory) r.Patch("/categories/{id}", s.handleUpdateCategory) r.Delete("/categories/{id}", s.handleV1DeleteCategory) @@ -113,7 +112,7 @@ func (s *Server) mountV1(r chi.Router) { // Dashboard-style jobs (flat JSON / 202). Prefer /products/process for legacy integrations. r.Post("/process", s.handleStartProcessingJob) r.Get("/process", s.handleV1ListProcessJobs) - r.Get("/process/{id}", s.handleGetProcessingJob) + r.Get("/process/{id}", s.handleV1GetProcessJob) r.Post("/process/{id}/cancel", s.handleCancelProcessingJob) r.Post("/process/{id}/terminate", s.handleCancelProcessingJob) r.Post("/process/{id}/retry", s.handleRetryProcessingJob) @@ -133,7 +132,7 @@ func (s *Server) handleV1ListProcessJobs(w http.ResponseWriter, r *http.Request) Error(w, http.StatusInternalServerError, "list failed") return } - JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit}) + JSON(w, http.StatusOK, map[string]any{"jobs": presentV1JobList(items), "limit": limit}) } func (s *Server) handleV1OpenAPI(w http.ResponseWriter, r *http.Request) { diff --git a/apps/api/internal/httpapi/v1_auth_test.go b/apps/api/internal/httpapi/v1_auth_test.go index 3e47973..2cb7cbd 100644 --- a/apps/api/internal/httpapi/v1_auth_test.go +++ b/apps/api/internal/httpapi/v1_auth_test.go @@ -118,7 +118,7 @@ func TestV1OpenAPIDocumentsPublicAPIAuth(t *testing.T) { "Retry-After", "rate limit exceeded", "code: unauthorized", - "LegacyAPIError", + "CodedAPIError", "security: []", } { if !strings.Contains(body, want) { diff --git a/apps/api/internal/httpapi/v1_csrf_tenant_test.go b/apps/api/internal/httpapi/v1_csrf_tenant_test.go index 409616d..3172d0f 100644 --- a/apps/api/internal/httpapi/v1_csrf_tenant_test.go +++ b/apps/api/internal/httpapi/v1_csrf_tenant_test.go @@ -154,9 +154,9 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) { "raw_product_ids", "items[].ean", "process_id", - "LegacyStartProcessByEAN", + "StartProcessByEAN", "StartProcessByRawIDs", - "LegacyProcessCompleted", + "ProcessCompletedExample", "X-API-Key", "https://descrybe.io/api/v1", "BearerAuth", @@ -185,7 +185,7 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) { "CSRFHeader", "code: unauthorized", "message: Unauthorized", - "legacy envelope", + "coded envelope", "/process/{id}/retry:", } { if !strings.Contains(body, needle) { diff --git a/apps/api/internal/httpapi/v1_domain_crud_integration_test.go b/apps/api/internal/httpapi/v1_domain_crud_integration_test.go index 72a9635..9466de1 100644 --- a/apps/api/internal/httpapi/v1_domain_crud_integration_test.go +++ b/apps/api/internal/httpapi/v1_domain_crud_integration_test.go @@ -123,12 +123,18 @@ func TestV1DomainResourceCRUD(t *testing.T) { t.Fatalf("get category status=%d body=%s", rec.Code, rec.Body.String()) } gotCat := decode(t, rec) - if _, hasData := gotCat["data"]; hasData { - t.Fatalf("GET /categories/{uuid} should be flat dashboard JSON, got envelope: %v", gotCat) - } - if gotCat["unique_id"] != catUnique { + gotCatData, _ := gotCat["data"].(map[string]any) + if gotCatData["unique_id"] != catUnique { t.Fatalf("get category=%v", gotCat) } + for _, leak := range []string{"prompt", "prompts", "has_prompt", "title_template", "description_template"} { + if _, ok := gotCatData[leak]; ok { + t.Fatalf("v1 GET category leaked %q: %v", leak, gotCatData) + } + if _, ok := gotCat[leak]; ok { + t.Fatalf("v1 GET category envelope leaked %q: %v", leak, gotCat) + } + } rec = do(http.MethodPatch, "/api/v1/categories/"+catUUID.String(), `{"name":"Electronics & Audio"}`) @@ -323,12 +329,15 @@ func TestV1DomainResourceCRUD(t *testing.T) { t.Fatalf("get product status=%d body=%s", rec.Code, rec.Body.String()) } gotProd := decode(t, rec) - if _, hasData := gotProd["data"]; hasData { - t.Fatalf("GET /products/{id} should be flat JSON, got envelope: %v", gotProd) - } - if fmt.Sprint(gotProd["product_id"]) != "SKU-1001" { + gotProdData, _ := gotProd["data"].(map[string]any) + if fmt.Sprint(gotProdData["product_id"]) != "SKU-1001" { t.Fatalf("get product=%v", gotProd) } + for _, leak := range []string{"mapped_data", "has_processed_name", "has_eprel", "processed_name"} { + if _, ok := gotProdData[leak]; ok { + t.Fatalf("v1 GET product leaked %q: %v", leak, gotProdData) + } + } rec = do(http.MethodPatch, "/api/v1/products/"+productID.String(), `{"processed_name":"Acme Wireless Earbuds ANC Midnight","status":"completed"}`) @@ -427,7 +436,7 @@ func mountV1DomainTestRouter(s *Server) http.Handler { r.Route("/api/v1", func(r chi.Router) { r.Get("/products", s.handleV1ListProducts) r.Get("/products/quality", s.handleV1ListProductQuality) - r.Get("/products/{id}", s.handleGetProduct) + r.Get("/products/{id}", s.handleV1GetProduct) r.Patch("/products/{id}", s.handleUpdateProduct) r.Get("/marketing/calendar", s.handleGetMarketingCalendar) @@ -438,7 +447,7 @@ func mountV1DomainTestRouter(s *Server) http.Handler { r.Get("/categories", s.handleV1ListCategories) r.Post("/categories", s.handleV1CreateCategory) r.Post("/categories/create", s.handleV1CreateCategory) - r.Get("/categories/{id}", s.handleGetCategory) + r.Get("/categories/{id}", s.handleV1GetCategory) r.Patch("/categories/{id}", s.handleUpdateCategory) r.Delete("/categories/{id}", s.handleV1DeleteCategory) @@ -480,9 +489,9 @@ func TestV1OpenAPIDocumentsDomainCRUDSurface(t *testing.T) { "/marketing/calendar:", "/products:", "/feeds/{id}/sync-process-sample:", - "Flat category JSON", + "no prompts or formulas", "flat PresentFeed", - "flat ProcessedProduct", + "without mapped_data", "CategoryDetail", "FeedDeleted", "FeedMappings", diff --git a/apps/api/internal/httpapi/v1_feeds_test.go b/apps/api/internal/httpapi/v1_feeds_test.go index ff0f42b..8e4dd8b 100644 --- a/apps/api/internal/httpapi/v1_feeds_test.go +++ b/apps/api/internal/httpapi/v1_feeds_test.go @@ -9,7 +9,7 @@ func TestV1OpenAPIFeedsLegacyContract(t *testing.T) { t.Parallel() body := string(v1OpenAPIYAML) for _, needle := range []string{ - "Legacy public contract — { data: Feed[], meta: { page, limit, total } }", + "Paged list — { data: Feed[], meta: { page, limit, total } }", "required: [name, item_path]", "jobId:", "FeedSyncResponse", diff --git a/apps/api/internal/httpapi/v1_openapi.go b/apps/api/internal/httpapi/v1_openapi.go index 8219bbc..8d7d5da 100644 --- a/apps/api/internal/httpapi/v1_openapi.go +++ b/apps/api/internal/httpapi/v1_openapi.go @@ -19,7 +19,8 @@ info: ## Authentication - All /api/v1 operations require a company API key except: + All /api/v1 operations require a company API key (Bearer or X-API-Key) except + GET /health and GET /openapi.yaml. Document-level security is BearerAuth OR ApiKeyAuth (same key value). Do not send dashboard session cookies or CSRF tokens to /api/v1. @@ -48,16 +49,16 @@ info: API keys from the previous Descrybe platform were not migrated. After cutover, integrations must create a new dk_ key in Settings -> API keys (or Use my API key on /docs). Pre-cutover secrets return the same HTTP 401 - Unauthorized as unknown keys — there is no separate “legacy key” error. + Unauthorized as unknown keys — there is no separate key-type error. ### 401 Unauthorized Missing, empty, unknown, revoked, or non-migrated (pre-cutover) keys return - HTTP 401 from RequireAPIKey with the legacy coded envelope: + HTTP 401 from RequireAPIKey with the coded envelope: { "error": { "code": "unauthorized", "message": "Unauthorized" } } - See components.responses.Unauthorized (schema LegacyAPIError). Reissue via + See components.responses.Unauthorized (schema CodedAPIError). Reissue via Settings -> API keys (/settings?tab=api-keys). ### 403 Forbidden @@ -98,11 +99,11 @@ info: Two separate surfaces — do not mix bodies or response envelopes: - 1. **Legacy public process (source of truth for integrations)** + 1. **Public process (source of truth for integrations)** - POST /products/process with body items[].ean - GET /products/process/{id} - Envelope: HTTP 200 { data: { process_id, … } } (and completed items[]) - - Matches legacy Descrybe /api/v1/products/process + - Matches POST /api/v1/products/process - Handler also accepts raw_product_ids as an alternate body on this path - Plan gates (credits / product limit / AI / EPREL / feature flags) run before EnsureRaw catalog writes on the items[].ean path; blocked starts return HTTP 402 @@ -123,7 +124,7 @@ info: Display name is items[].name (title omitted when identical). Note: Dashboard JSON under /api/* uses session cookies + CSRF and is separate - from this public API-key surface. Other legacy path aliases + from this public API-key surface. Other path aliases (/categories/create, /attributes/create, /campaigns) appear next to canonical paths. servers: - url: https://descrybe.io/api/v1 @@ -143,7 +144,7 @@ tags: - name: Feeds - name: Export feeds - name: Campaigns - description: Seasonal content calendar (legacy /campaigns aliases) + description: Seasonal content calendar (/campaigns aliases) - name: Processing - name: Team description: Dashboard session routes under /api (not API-key) @@ -217,14 +218,14 @@ paths: - Products summary: List products description: | - Legacy-compatible public list. Envelope is { data, meta } (not flat products/offset). - Query params match legacy: page, limit (default 25), status, search, sortBy, sortOrder, feedId. + Public product list. Envelope is { data, meta } (not flat products/offset). + Query params: page, limit (default 25), status, search, sortBy, sortOrder, feedId. Each row matches PresentProduct (id = processed_products.id, raw_product_id = raw_products.id, product_id, name, category, status, feed_id, quality_score, quality_grade, created_at, updated_at). For POST /products/process or POST /process dual-mode bodies, pass raw_product_id — not id. parameters: - $ref: "#/components/parameters/Page" - - $ref: "#/components/parameters/LegacyLimit" + - $ref: "#/components/parameters/PublicLimit" - in: query name: status schema: @@ -240,7 +241,7 @@ paths: default: all description: | Filter by product status. all (default) returns every status. needs_review also matches - legacy status processed (pre-P0-8 AI review queue). Use completed after Accept enrichment. + status processed (pre-P0-8 AI review queue). Use completed after Accept enrichment. - in: query name: search schema: @@ -309,7 +310,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -319,19 +320,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_error message: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -342,11 +343,11 @@ paths: - Products summary: List product quality scores description: | - Legacy-compatible quality listing with { data, meta }. Defaults status=completed. + Quality listing with { data, meta }. Defaults status=completed. Optional min_score filters rows after scoring. parameters: - $ref: "#/components/parameters/Page" - - $ref: "#/components/parameters/LegacyLimit" + - $ref: "#/components/parameters/PublicLimit" - in: query name: status schema: @@ -415,7 +416,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -425,19 +426,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_error message: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -530,12 +531,12 @@ paths: error: could not reset products '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -543,19 +544,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -569,21 +570,26 @@ paths: - Products summary: Get processed product description: | - Shared dashboard handler — flat ProcessedProduct JSON (not a legacy { data } envelope). - List endpoints use { data, meta }. + Public GET returns a data envelope without mapped_data, has_* flags, or processed_* internals. + Public fields match process items: name, description, attributes, images, eprel. + List endpoints use data plus meta. responses: '200': - description: Single processed product as a flat object (no data wrapper). Returned when GetProcessedProduct - finds the id for this company. + description: "Single processed product as a data envelope (no mapped_data)." content: application/json: schema: - $ref: "#/components/schemas/ProcessedProduct" + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/PresentProduct" example: - id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - product_id: SKU-1001 - name: Wireless earbuds - status: completed + data: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + product_id: SKU-1001 + name: Wireless earbuds + status: completed '400': description: Path id is not a UUID. content: @@ -602,12 +608,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -633,19 +639,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -655,7 +661,7 @@ paths: tags: - Products summary: Update processed product - description: "Flat ProcessedProduct JSON (not a legacy { data } envelope)." + description: "Flat ProcessedProduct JSON (not a { data } envelope)." requestBody: content: application/json: @@ -665,7 +671,7 @@ paths: properties: processed_name: type: string - description: Enriched display title after AI/manual edit + description: Enriched display title processed_description: type: string description: Enriched long description text @@ -719,12 +725,12 @@ paths: error: could not update product '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -758,19 +764,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -780,28 +786,28 @@ paths: post: tags: - Processing - summary: Start processing by EAN (legacy public contract) + summary: Start processing by EAN description: | - Legacy Descrybe public API (handleV1StartProcess). Upserts raw products from + Public process API (handleV1StartProcess). Upserts raw products from items[].ean (GTIN), enqueues one processing job, returns HTTP 200 with a data envelope. Primary body: items[].ean. Alternate body on the same handler: raw_product_ids (raw_products.id UUID list — not GET /products data[].id). Prefer items for public integrations. On items[].ean, assertV1ProcessGates runs before EnsureRaw so plan/credit - failures cannot spam catalog writes (HTTP 402 legacy coded envelope). + failures cannot spam catalog writes (HTTP 402 coded envelope). Not an alias of POST /process (flat ProcessingJob / 202). Do not mix envelopes. requestBody: - $ref: "#/components/requestBodies/LegacyStartProcessByEAN" + $ref: "#/components/requestBodies/StartProcessByEAN" responses: '200': - description: "Legacy process job accepted. Always HTTP 200 (not 202) with data.process_id when\ + description: "Process job accepted. Always HTTP 200 (not 202) with data.process_id when\ \ enqueue succeeds. Prefer items[].ean; raw_product_ids alternate body is accepted on the\ \ same path." content: application/json: schema: - $ref: "#/components/schemas/LegacyProcessStartEnvelope" + $ref: "#/components/schemas/ProcessStartEnvelope" example: data: process_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa @@ -813,7 +819,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -821,13 +827,13 @@ paths: '402': description: | Plan gate blocked starting processing (billing credits/limits/AI/EPREL/feature flags). - v1ErrFromProcessing maps these to HTTP 402 with a coded legacy envelope. + v1ErrFromProcessing maps these to HTTP 402 with a coded envelope. Codes: insufficient_credits, product_limit, ai_requires_upgrade, eprel_requires_upgrade, plan_gate (message feature_disabled when the platform feature flag is off). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" examples: insufficient_credits: summary: Credits @@ -843,23 +849,23 @@ paths: message: feature_disabled "429": { $ref: "#/components/responses/TooManyRequests" } '500': - description: Enqueue or unexpected internal failure starting the legacy job. + description: Enqueue or unexpected internal failure starting the job. content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_server_error message: Internal server error '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -867,19 +873,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -889,17 +895,17 @@ paths: get: tags: - Processing - summary: Processing job status by process_id (legacy public contract) + summary: Processing job status by process_id description: | - Poll legacy job status. Path param is process_id from POST /products/process. + Poll job status. Path param is process_id from POST /products/process. Completed jobs return items[] (EAN-keyed enrichment). In-progress and failed jobs omit items. Not the same shape as GET /process/{id} (flat ProcessingJob). On COMPLETED items, name is the product display name (title is omitted when identical). Internal UUIDs (id / processed_product_id / raw_product_id) are - omitted — use GET /products for those. A1 cohort / Platform Demo / A1-prompt - companies omit meta_title and meta_description even if stored. + omitted — use GET /products for those. Optional meta_title and + meta_description may be omitted depending on company configuration. parameters: - name: id in: path @@ -911,15 +917,15 @@ paths: process_id returned by POST /products/process. UUID format. Required. responses: '200': - description: "Legacy job poll. Returned when the job exists for this company. status is uppercase;\ + description: "Job poll. Returned when the job exists for this company. status is uppercase;\ \ items[] appear when status is COMPLETED." content: application/json: schema: - $ref: "#/components/schemas/LegacyProcessStatusEnvelope" + $ref: "#/components/schemas/ProcessStatusEnvelope" examples: completed: - summary: Completed A1-shaped item + summary: Completed item value: data: status: COMPLETED @@ -958,7 +964,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -968,7 +974,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: not_found @@ -978,19 +984,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_server_error message: Internal server error '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1008,19 +1014,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1031,7 +1037,7 @@ paths: tags: - Categories summary: List categories - description: "Legacy public contract — { data, meta } with page/limit pagination." + description: "Paged list — { data, meta } with page/limit pagination." parameters: - name: page in: query @@ -1070,7 +1076,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyCategoriesResponse" + $ref: "#/components/schemas/CategoriesEnvelope" example: data: - id: 33333333-3333-3333-3333-333333333333 @@ -1093,12 +1099,12 @@ paths: error: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1116,7 +1122,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyCategoryCreateResponse" + $ref: "#/components/schemas/CategoryCreateEnvelope" example: data: id: 33333333-3333-3333-3333-333333333333 @@ -1133,12 +1139,12 @@ paths: error: could not create category '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1154,19 +1160,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1176,7 +1182,7 @@ paths: post: tags: - Categories - summary: Create category (legacy alias) + summary: Create category (alias of POST /categories) description: "Alias of POST /categories. Same body and { data } response." requestBody: $ref: "#/components/requestBodies/CreateCategory" @@ -1186,7 +1192,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyCategoryCreateResponse" + $ref: "#/components/schemas/CategoryCreateEnvelope" example: data: id: 33333333-3333-3333-3333-333333333333 @@ -1203,12 +1209,12 @@ paths: error: could not create category '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1224,19 +1230,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1256,20 +1262,25 @@ paths: tags: - Categories summary: Get category by UUID - description: "Flat category JSON (not a legacy { data } envelope). Path id must be the category\ - \ UUID." + description: | + Public GET returns a data envelope of EnvelopeCategory (no prompts or formulas). Path id must be the category UUID. + List endpoints use data plus meta; this GET omits meta. responses: '200': - description: Category detail (flat CategoryDetail) when found. Path id is typically unique_id - for v1 delete; shared get handler accepts the mounted id param. + description: Category detail as a data envelope of EnvelopeCategory when found. content: application/json: schema: - $ref: "#/components/schemas/CategoryDetail" + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/EnvelopeCategory" example: - id: 33333333-3333-3333-3333-333333333333 - unique_id: electronics - name: Electronics + data: + id: 33333333-3333-3333-3333-333333333333 + unique_id: electronics + name: Electronics '400': description: Invalid category id. content: @@ -1296,12 +1307,12 @@ paths: error: get failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1319,19 +1330,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1341,7 +1352,7 @@ paths: tags: - Categories summary: Update category by UUID - description: "Flat updated category JSON (not a legacy { data } envelope)." + description: "Flat updated category JSON (not a { data } envelope)." requestBody: content: application/json: @@ -1405,12 +1416,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1436,19 +1447,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1458,7 +1469,7 @@ paths: tags: - Categories summary: Delete category by unique_id - description: Path id is the category unique_id (legacy public contract). + description: Path id is the category unique_id. responses: '200': description: Category deleted by unique_id path param. Returned when DeleteCategoryByUniqueID @@ -1466,7 +1477,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacySuccessMessage" + $ref: "#/components/schemas/SuccessMessage" example: data: message: Category deleted successfully @@ -1496,12 +1507,12 @@ paths: error: delete failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1519,19 +1530,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1542,7 +1553,7 @@ paths: tags: - Attributes summary: List attributes - description: "Legacy public contract — { data, meta }." + description: "Paged list — { data, meta }." parameters: - name: page in: query @@ -1608,7 +1619,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAttributesResponse" + $ref: "#/components/schemas/AttributesEnvelope" example: data: - id: 44444444-4444-4444-4444-444444444444 @@ -1635,12 +1646,12 @@ paths: error: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1660,7 +1671,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAttributeCreateResponse" + $ref: "#/components/schemas/AttributeCreateEnvelope" example: data: id: 44444444-4444-4444-4444-444444444444 @@ -1694,12 +1705,12 @@ paths: error: could not create attribute '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1707,19 +1718,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1729,7 +1740,7 @@ paths: post: tags: - Attributes - summary: Create attribute (legacy alias) + summary: Create attribute (alias of POST /attributes) description: Alias of POST /attributes. requestBody: $ref: "#/components/requestBodies/CreateAttribute" @@ -1740,7 +1751,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAttributeCreateResponse" + $ref: "#/components/schemas/AttributeCreateEnvelope" example: data: id: 44444444-4444-4444-4444-444444444444 @@ -1774,12 +1785,12 @@ paths: error: could not create attribute '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1787,19 +1798,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1812,7 +1823,7 @@ paths: tags: - Attributes summary: Update attribute - description: Flat attribute JSON (attribute_key / value_type fields — not legacy key/type aliases). + description: Flat attribute JSON (attribute_key / value_type fields — not key/type aliases). requestBody: content: application/json: @@ -1869,12 +1880,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1900,19 +1911,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -1929,7 +1940,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacySuccessMessage" + $ref: "#/components/schemas/SuccessMessage" example: data: message: Attribute deleted successfully @@ -1959,12 +1970,12 @@ paths: error: delete failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -1982,19 +1993,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2006,8 +2017,8 @@ paths: - Feeds summary: List input feeds description: | - Legacy public contract — { data: Feed[], meta: { page, limit, total } }. - Accepts page+limit (legacy defaults page=1, limit=25, max 100) or limit+offset. + Paged list — { data: Feed[], meta: { page, limit, total } }. + Accepts page+limit (defaults page=1, limit=25, max 100) or limit+offset. Each feed includes presentFeed fields plus dual-support v2 keys (feed_type, sync_interval_minutes, options). parameters: - name: page @@ -2017,7 +2028,7 @@ paths: minimum: 1 default: 1 description: | - 1-based page index for legacy page+limit mode. Integer, default 1, minimum 1. + 1-based page index for page+limit mode. Integer, default 1, minimum 1. - name: limit in: query schema: @@ -2069,19 +2080,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_error message: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2091,7 +2102,7 @@ paths: - Feeds summary: Create feed description: | - Legacy body requires name + item_path (url optional). Dual-support also accepts + Body requires name + item_path (url optional). Dual-support also accepts feed_type, sync_interval_minutes, sync_frequency (hours), and multipart file uploads. requestBody: content: @@ -2129,7 +2140,7 @@ paths: type: integer minimum: 1 description: | - Legacy interval in hours. Converted to minutes when sync_interval_minutes is unset. + Interval in hours. Converted to minutes when sync_interval_minutes is unset. is_active: type: boolean description: | @@ -2162,7 +2173,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -2172,19 +2183,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_error message: could not create feed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2192,19 +2203,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2217,7 +2228,7 @@ paths: tags: - Feeds summary: Get feed - description: "Legacy envelope — { data: Feed }." + description: "Envelope — { data: Feed }." responses: '200': description: Single feed in data envelope when Feeds.Get succeeds for this company. @@ -2237,7 +2248,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -2247,7 +2258,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: not_found @@ -2257,19 +2268,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_error message: get failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2287,19 +2298,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2347,7 +2358,7 @@ paths: type: integer minimum: 1 description: | - Legacy interval in hours. Converted to minutes when sync_interval_minutes is unset. + Interval in hours. Converted to minutes when sync_interval_minutes is unset. example: name: Nordic Webshop Google Merchant XML (EU) sync_interval_minutes: 120 @@ -2381,12 +2392,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2412,19 +2423,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2434,7 +2445,7 @@ paths: tags: - Feeds summary: Delete feed - description: "Company-admin only. Flat { id, deleted: true } (not a legacy message envelope)." + description: "Company-admin only. Flat { id, deleted: true } (not a message envelope)." responses: '200': description: Feed deleted. Flat FeedDeleted when handleDeleteFeed succeeds. Requires company-admin @@ -2480,12 +2491,12 @@ paths: error: delete failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2493,19 +2504,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2517,7 +2528,7 @@ paths: - Feeds summary: Trigger feed sync description: | - Legacy contract — HTTP 200 { data: { jobId } } (not 202). Enqueues a durable + Contract — HTTP 200 { data: { jobId } } (not 202). Enqueues a durable feed_sync_jobs row and wakes the worker via NOTIFY; the API process does not run sync work. Dual-support also returns job_id. Dashboard POST /api/feeds/{id}/sync uses HTTP 202 with a flat job object — do not mix envelopes. @@ -2539,7 +2550,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -2549,7 +2560,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: not_found @@ -2567,19 +2578,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid id - legacy: + coded: summary: v1Err coded value: error: @@ -2590,19 +2601,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_error message: Failed to create sync job '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2662,12 +2673,12 @@ paths: error: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2685,19 +2696,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2762,12 +2773,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2793,19 +2804,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2870,12 +2881,12 @@ paths: error: extract failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -2893,19 +2904,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -2997,12 +3008,12 @@ paths: error: sample failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3020,19 +3031,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3045,8 +3056,8 @@ paths: - Export feeds summary: List export feeds description: | - Legacy envelope — { data: ExportFeed[], meta: { page, limit, total } }. - Accepts page+limit (legacy defaults page=1, limit=25, max 100) or limit+offset. + Envelope — { data: ExportFeed[], meta: { page, limit, total } }. + Accepts page+limit (defaults page=1, limit=25, max 100) or limit+offset. parameters: - name: page in: query @@ -3055,8 +3066,8 @@ paths: default: 1 minimum: 1 description: | - 1-based page index for legacy page+limit mode. Integer, default 1, minimum 1. - - $ref: "#/components/parameters/LegacyLimit" + 1-based page index for page+limit mode. Integer, default 1, minimum 1. + - $ref: "#/components/parameters/PublicLimit" - $ref: "#/components/parameters/Offset" responses: '200': @@ -3095,12 +3106,12 @@ paths: error: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3109,7 +3120,7 @@ paths: tags: - Export feeds summary: Create export feed - description: "Legacy envelope — { data: ExportFeed }." + description: "Envelope — { data: ExportFeed }." requestBody: content: application/json: @@ -3138,7 +3149,7 @@ paths: Export template document (root/item/mappings). Structure depends on format. structure: type: object - description: Legacy alias for template — same object shape + description: Alias for template — same object shape mappings: type: object description: Field mapping object (source → target). May also live under template. @@ -3185,19 +3196,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error message: "Missing required fields: name, format" '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3213,19 +3224,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3277,12 +3288,12 @@ paths: error: get failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3300,19 +3311,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3377,12 +3388,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3408,19 +3419,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3430,7 +3441,7 @@ paths: tags: - Export feeds summary: Delete export feed - description: "Company-admin only. Flat { status: ok } (not LegacySuccessMessage)." + description: "Company-admin only. Flat { status: ok } (not SuccessMessage)." responses: '200': description: Export feed deleted (admin capability; API keys pass). Flat deleted marker. @@ -3475,12 +3486,12 @@ paths: error: delete failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3488,19 +3499,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3567,12 +3578,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3598,19 +3609,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3647,7 +3658,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" '403': description: Admin required. content: @@ -3666,7 +3677,7 @@ paths: - Export feeds summary: Generate export feed file description: | - Legacy envelope — { data: { generated, format, filePath, downloadUrl, ... } }. + Envelope — { data: { generated, format, filePath, downloadUrl, ... } }. Content is streamed live via public download URL (not persisted to disk). parameters: - $ref: "#/components/parameters/ID" @@ -3700,7 +3711,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: generation_failed @@ -3710,7 +3721,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: not_found @@ -3720,19 +3731,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: generation_failed message: Failed to generate export feed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3750,19 +3761,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3836,12 +3847,12 @@ paths: error: export failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3859,19 +3870,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -3882,9 +3893,9 @@ paths: get: tags: - Campaigns - summary: List seasonal campaign presets (legacy alias) + summary: List seasonal campaign presets (alias of GET /marketing/calendar) description: | - Alias of GET /marketing/calendar. Returns legacy envelope + Alias of GET /marketing/calendar. Returns the same envelope { data: { year, presets, prepared } }. Not email campaigns. parameters: - name: year @@ -3898,7 +3909,7 @@ paths: Calendar year for seasonal presets (e.g. 2026). Integer. When omitted, server uses the current year. responses: '200': - description: Legacy alias of marketing calendar. data envelope around calendar payload (handleV1ListCampaigns + description: Alias of marketing calendar. data envelope around calendar payload (handleV1ListCampaigns → v1OK). content: application/json: @@ -3914,7 +3925,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error @@ -3924,19 +3935,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: internal_error message: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -3945,9 +3956,9 @@ paths: post: tags: - Campaigns - summary: Prepare seasonal campaign export (legacy alias) + summary: Prepare seasonal campaign export (alias of POST /marketing/calendar/prepare) description: | - Alias of POST /marketing/calendar/prepare. Legacy envelope + Alias of POST /marketing/calendar/prepare. Envelope { data: { preset_id, name, export_feed_id, created, ... } }. requestBody: content: @@ -4014,19 +4025,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: validation_error message: invalid json '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4042,19 +4053,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -4065,7 +4076,7 @@ paths: tags: - Campaigns summary: List seasonal campaign presets - description: Canonical path (flat JSON). Prefer /campaigns for legacy clients. + description: Canonical path (flat JSON). /campaigns is an alias of this path. parameters: - name: year in: query @@ -4106,12 +4117,12 @@ paths: error: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4121,7 +4132,7 @@ paths: tags: - Campaigns summary: Prepare seasonal campaign export - description: Canonical path (flat JSON). Prefer /campaigns/prepare for legacy clients. + description: Canonical path (flat JSON). /campaigns/prepare is an alias of this path. requestBody: content: application/json: @@ -4189,12 +4200,12 @@ paths: error: invalid json '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4210,19 +4221,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -4235,13 +4246,13 @@ paths: summary: List processing jobs (raw_product_ids surface) description: | Flat job list for the /process + raw_product_ids contract. - Separate from legacy GET /products/process/{id}. + Separate from GET /products/process/{id}. parameters: - $ref: "#/components/parameters/Limit" responses: '200': description: "Recent dashboard-style jobs. Flat object with jobs[] and limit (handleV1ListProcessJobs\ - \ — not legacy data envelope)." + \ — not a data envelope)." content: application/json: schema: @@ -4273,12 +4284,12 @@ paths: error: list failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4293,8 +4304,8 @@ paths: Flat JSON body/response (no data wrapper); HTTP 202. Pass raw_products.id only (GET /products data[].raw_product_id), never PresentProduct.id. - Separate from legacy POST /products/process (items[].ean → 200 { data }). - Plan gates return HTTP 402 PlanGateError (not the legacy coded envelope). + Separate from POST /products/process (items[].ean → 200 { data }). + Plan gates return HTTP 402 PlanGateError (not the coded envelope). requestBody: $ref: "#/components/requestBodies/StartProcessByRawIDs" responses: @@ -4320,7 +4331,7 @@ paths: error: could not start processing job '402': description: Plan gate blocked start. Special shape from handleStartProcessingJob (error string - + code + upgrade_url) — not the legacy coded envelope. + + code + upgrade_url) — not the coded envelope. content: application/json: schema: @@ -4340,12 +4351,12 @@ paths: error: enqueue failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4353,19 +4364,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -4377,9 +4388,8 @@ paths: - Processing summary: Processing job status (raw_product_ids surface) description: | - Flat ProcessingJob JSON. Not the legacy { data: { process_id, items } } envelope. - When status is completed (finished), response is additively enriched with items[] - and total_items (same processed product projection as GET /products/process/{id}). + Public processing job JSON: status, counts, timestamps, processing_type. + Omits current_step, step_progress, and company_id. Completed jobs still include items[]. parameters: - $ref: "#/components/parameters/ID" responses: @@ -4398,7 +4408,6 @@ paths: total_products: 25 processed_products: 8 processing_type: full - current_step: title completed: summary: Finished with processed products value: @@ -4409,9 +4418,9 @@ paths: processing_type: full items: - ean: '0123456789012' - id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb status: processed - title: Acme Wireless Earbuds ANC Black + name: Acme Wireless Earbuds ANC Black + category: Headphones total_items: 1 '400': description: Path id is not a UUID. @@ -4431,12 +4440,12 @@ paths: error: not found '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4462,19 +4471,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -4508,12 +4517,12 @@ paths: error: could not cancel job '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4547,19 +4556,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -4570,7 +4579,7 @@ paths: tags: - Processing summary: Terminate processing job (alias of cancel) - description: "Pixel/legacy naming alias of POST /process/{id}/cancel." + description: "Naming alias of POST /process/{id}/cancel." parameters: - $ref: "#/components/parameters/ID" responses: @@ -4594,12 +4603,12 @@ paths: error: could not cancel job '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4633,19 +4642,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -4688,12 +4697,12 @@ paths: error: enqueue failed '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4719,19 +4728,19 @@ paths: '422': description: Validation failed. Current public v1 handlers emit these cases as HTTP 400 (flat or coded). 422 is documented for clients that expect an explicit validation status; body matches - FlatAPIError or LegacyAPIError. + FlatAPIError or CodedAPIError. content: application/json: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error() value: error: invalid json - legacy: + coded: summary: v1Err coded value: error: @@ -4831,12 +4840,12 @@ paths: example: cannot demote the last admin '401': description: Missing or invalid API key. RequireAPIKey returns this when Authorization Bearer - and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Legacy - coded envelope (not a flat string). + and X-API-Key are both absent/empty or AuthenticateAPIKey returns ErrInvalidAPIKey. Coded + envelope (not a flat string). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -4865,7 +4874,7 @@ paths: description: | Platform admin only. Prefers durable invite reissue (ReissueSetPasswordInvite); falls back to HMAC set-password tokens when the user has no active membership. - Skips synthetic …@legacy.local emails. Rate-limited per admin. + Skips synthetic placeholder emails. Rate-limited per admin. When SMTP is disabled and a single user_id is provided, the response may include a one-time token for local/staging link copy (never logs email/token). Dashboard surface under /api (not the public /api/v1 API-key base). @@ -5051,8 +5060,8 @@ components: maximum: 200 minimum: 1 description: | - Page size for non-legacy list endpoints. Integer, default 50, minimum 1, maximum 200. - LegacyLimit: + Page size for other list endpoints. Integer, default 50, minimum 1, maximum 200. + PublicLimit: in: query name: limit schema: @@ -5061,7 +5070,7 @@ components: maximum: 100 minimum: 1 description: | - Page size for legacy public list endpoints. Integer, default 25, minimum 1, maximum 100. + Page size for public list endpoints. Integer, default 25, minimum 1, maximum 100. Page: in: query name: page @@ -5092,10 +5101,10 @@ components: format: uuid description: Keyset bookmark by product id; cursor wins when both are set requestBodies: - LegacyStartProcessByEAN: + StartProcessByEAN: required: true description: | - Prefer items[].ean (legacy public). Same handler also accepts raw_product_ids + Prefer items[].ean. Same handler also accepts raw_product_ids when items is omitted. Sending neither returns validation_error. raw_product_ids must be raw_products.id values (see GET /products data[].raw_product_id). Do not pass PresentProduct.id / processed_products.id. @@ -5107,7 +5116,7 @@ components: items: type: array minItems: 1 - description: Primary legacy body — required unless raw_product_ids is set + description: Primary body — required unless raw_product_ids is set items: type: object required: @@ -5184,7 +5193,7 @@ components: Not PresentProduct.id. Use GET /products data[].raw_product_id. processing_type: description: | - full (default); legacy steps category|title|description|attributes (string or + full (default); named steps category|title|description|attributes (string or array of those steps); dual-mode also accepts v2 dashboard types such as normalize_only, enhance_only, attributes_only, eprel_only, categorize_only (ParseV1ProcessingType). Alias processingType accepted when values match. @@ -5230,7 +5239,7 @@ components: description: Dashboard fine-grained steps; used when processing_type omitted examples: by_ean: - summary: "Legacy items[].ean (preferred)" + summary: "items[].ean (preferred)" value: items: - ean: '4548736132174' @@ -5273,7 +5282,7 @@ components: processing_type: type: string description: | - full (default) or a legacy/dashboard step (category, title, description, + full (default) or a named dashboard step (category, title, description, attributes, normalize_only, enhance_only, …). default: full example: full @@ -5315,7 +5324,7 @@ components: parent_id: type: string nullable: true - description: Legacy alias of parent_unique_id + description: Alias of parent_unique_id description: type: string nullable: true @@ -5584,7 +5593,7 @@ components: type: integer offset: type: integer - LegacyPaginationMeta: + EnvelopePaginationMeta: type: object required: - page @@ -5599,7 +5608,7 @@ components: type: integer totalPages: type: integer - LegacyCategoriesResponse: + CategoriesEnvelope: type: object required: - data @@ -5608,10 +5617,10 @@ components: data: type: array items: - $ref: "#/components/schemas/LegacyCategory" + $ref: "#/components/schemas/EnvelopeCategory" meta: - $ref: "#/components/schemas/LegacyPaginationMeta" - LegacyCategory: + $ref: "#/components/schemas/EnvelopePaginationMeta" + EnvelopeCategory: type: object properties: id: @@ -5629,7 +5638,7 @@ components: format: date-time CategoryDetail: type: object - description: "Flat category row from GET/PATCH /categories/{id} (not a legacy data envelope)." + description: "Flat category row from GET/PATCH /categories/{id} (not a data envelope)." properties: id: type: string @@ -5663,7 +5672,7 @@ components: updated_at: type: string format: date-time - LegacyCategoryCreateResponse: + CategoryCreateEnvelope: type: object required: - data @@ -5682,7 +5691,7 @@ components: type: string name: type: string - LegacyAttributesResponse: + AttributesEnvelope: type: object required: - data @@ -5691,10 +5700,10 @@ components: data: type: array items: - $ref: "#/components/schemas/LegacyAttribute" + $ref: "#/components/schemas/EnvelopeAttribute" meta: - $ref: "#/components/schemas/LegacyPaginationMeta" - LegacyAttribute: + $ref: "#/components/schemas/EnvelopePaginationMeta" + EnvelopeAttribute: type: object properties: id: @@ -5721,7 +5730,7 @@ components: format: date-time AttributeDetail: type: object - description: "Flat attribute row from PATCH /attributes/{id} (not a legacy data envelope)." + description: "Flat attribute row from PATCH /attributes/{id} (not a data envelope)." properties: id: type: string @@ -5748,7 +5757,7 @@ components: updated_at: type: string format: date-time - LegacyAttributeCreateResponse: + AttributeCreateEnvelope: type: object required: - data @@ -5772,7 +5781,7 @@ components: type: string required: type: boolean - LegacySuccessMessage: + SuccessMessage: type: object required: - data @@ -6126,7 +6135,7 @@ components: type: array description: Present when status is completed — processed product payload (additive) items: - $ref: "#/components/schemas/LegacyProcessItem" + $ref: "#/components/schemas/ProcessItem" total_items: type: integer description: Present with items when status is completed @@ -6171,7 +6180,7 @@ components: properties: error: type: string - LegacyAPIError: + CodedAPIError: type: object required: - error @@ -6189,7 +6198,7 @@ components: type: string requestId: type: string - LegacyProcessStartEnvelope: + ProcessStartEnvelope: type: object required: - data @@ -6236,7 +6245,7 @@ components: items: type: string description: Per-item upsert failures when some EANs still queued - LegacyProcessStatusEnvelope: + ProcessStatusEnvelope: type: object required: - data @@ -6265,7 +6274,7 @@ components: type: array description: Present when status is COMPLETED items: - $ref: "#/components/schemas/LegacyProcessItem" + $ref: "#/components/schemas/ProcessItem" total_items: type: integer processed_at: @@ -6283,15 +6292,14 @@ components: type: string format: date-time nullable: true - LegacyProcessItem: + ProcessItem: type: object description: | - One COMPLETED legacy process line (A1 / public contract). Successful items + One COMPLETED process line (public contract). Successful items expose category as the human-readable display name (category_id holds - categories.unique_id), a description string that may include category formula + categories.unique_id), a description string that may include section HTML (h1/h2/h3/h4, p, ul — never a JSON array), optional SEO meta_title / - meta_description (plain text; omitted for A1 cohort, Platform Demo, and any - company with A1-style category role-section prompts — even if stored in DB), + meta_description (plain text; some companies omit these fields), optional eprel object or null, clean attributes, and images. Product display name is name (primary). title is omitted when identical to name. Internal UUIDs (id, processed_product_id, raw_product_id) are omitted from this @@ -6331,31 +6339,26 @@ components: type: string nullable: true description: | - Optional legacy alias of name. Omitted when identical to name. + Optional alias of name. Omitted when identical to name. meta_title: type: string nullable: true description: | - SEO title. Filled from processing meta or synthesized from title / category - when empty so successful items are not left with null meta. - Omitted for A1 cohort, Platform Demo, and companies with A1-style category - prompts (SEO meta is not used). + Optional SEO title. When present, filled from processing meta or + synthesized from name / category. Some companies omit this field. meta_description: type: string nullable: true description: | - SEO description (word-safe truncate). Distinct from body description when - possible; synthesized from plain description when DB meta is empty. - Omitted for A1 cohort, Platform Demo, and companies with A1-style category - prompts (SEO meta is not used). + Optional SEO description (word-safe truncate). Distinct from body + description when possible. Some companies omit this field. description: type: string nullable: true description: | Product body description as a string — never a one-element JSON array. - When the category description_template requires multi-section markup, - this field retains formula HTML tags (h1/h2/h3/h4, p, ul). Feed-only - plain text remains plain; meta_description stays plain SEO text. + May include section HTML tags (h1/h2/h3/h4, p, ul). Plain-text + descriptions remain plain. Optional meta_description stays plain SEO text. attributes: type: object additionalProperties: true @@ -6460,8 +6463,8 @@ components: status: pending started_at: '2026-08-04T10:00:00Z' created_at: '2026-08-04T09:59:50Z' - LegacyProcessCompleted: - summary: "Completed legacy poll with A1-shaped item (unique_id category, plain description, meta, eprel)" + ProcessCompletedExample: + summary: "Completed poll item with category, description, and eprel" value: data: status: COMPLETED @@ -6494,8 +6497,8 @@ components: energy_scale: A-G total_items: 1 processed_at: '2026-08-04T10:04:12Z' - LegacyProcessInProgress: - summary: In-progress legacy poll + ProcessInProgressExample: + summary: In-progress poll value: data: status: PROCESSING @@ -6503,8 +6506,8 @@ components: processing_type: full created_at: '2026-08-04T09:59:50Z' started_at: '2026-08-04T10:00:00Z' - LegacyProcessFailed: - summary: Failed legacy poll + ProcessFailedExample: + summary: Failed poll value: data: status: FAILED @@ -6513,11 +6516,11 @@ components: error: Processing failed responses: Unauthorized: - description: Missing or invalid API key (RequireAPIKey / CodedError legacy envelope). + description: Missing or invalid API key (RequireAPIKey / CodedError envelope). content: application/json: schema: - $ref: "#/components/schemas/LegacyAPIError" + $ref: "#/components/schemas/CodedAPIError" example: error: code: unauthorized @@ -6538,14 +6541,14 @@ components: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Dashboard-shared handlers value: error: not found - legacy: - summary: Legacy v1Err helpers + coded: + summary: Coded v1Err helpers value: error: code: not_found @@ -6557,14 +6560,14 @@ components: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error helper value: error: invalid json - legacy: - summary: Legacy v1Err validation + coded: + summary: Coded v1Err validation value: error: code: validation_error @@ -6577,14 +6580,14 @@ components: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat validation value: error: invalid id - legacy: - summary: Legacy coded validation + coded: + summary: Coded validation value: error: code: validation_error @@ -6610,13 +6613,13 @@ components: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Middleware limiter value: error: rate limit exceeded - legacy: + coded: summary: Processing StartLimiter value: error: @@ -6629,14 +6632,14 @@ components: schema: oneOf: - $ref: "#/components/schemas/FlatAPIError" - - $ref: "#/components/schemas/LegacyAPIError" + - $ref: "#/components/schemas/CodedAPIError" examples: flat: summary: Flat Error helper value: error: list failed - legacy: - summary: Legacy coded internal error + coded: + summary: Coded internal error value: error: code: internal_error diff --git a/apps/api/internal/httpapi/v1_openapi_test.go b/apps/api/internal/httpapi/v1_openapi_test.go index 578a734..7aae363 100644 --- a/apps/api/internal/httpapi/v1_openapi_test.go +++ b/apps/api/internal/httpapi/v1_openapi_test.go @@ -415,25 +415,25 @@ func TestV1OpenAPIYAMLProcessDualIDsAndGatesDocumentsContracts(t *testing.T) { } } -// TestV1OpenAPIYAMLLegacyProcessItemEprelShape locks LegacyProcessItem.eprel +// TestV1OpenAPIYAMLProcessItemEprelShape locks ProcessItem.eprel // to the live nested object from eprel.MergeInto / extractEPRELFromAttrs. -func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) { +func TestV1OpenAPIYAMLProcessItemEprelShape(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) comps, _ := root["components"].(map[string]any) schemas, _ := comps["schemas"].(map[string]any) - item, _ := schemas["LegacyProcessItem"].(map[string]any) + item, _ := schemas["ProcessItem"].(map[string]any) itemProps, _ := item["properties"].(map[string]any) eprel, _ := itemProps["eprel"].(map[string]any) eprelProps, _ := eprel["properties"].(map[string]any) if eprelProps == nil { - t.Fatal("LegacyProcessItem.eprel.properties missing") + t.Fatal("ProcessItem.eprel.properties missing") } want := []string{"id", "label", "pdf", "energy_class", "energy_scale"} for _, key := range want { if _, ok := eprelProps[key]; !ok { - t.Fatalf("LegacyProcessItem.eprel missing property %q (live shape)", key) + t.Fatalf("ProcessItem.eprel missing property %q (live shape)", key) } } if len(eprelProps) != len(want) { @@ -441,7 +441,161 @@ func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) { for k := range eprelProps { keys = append(keys, k) } - t.Fatalf("LegacyProcessItem.eprel properties = %v, want exactly %v", keys, want) + t.Fatalf("ProcessItem.eprel properties = %v, want exactly %v", keys, want) + } +} + +func TestV1OpenAPIYAMLNoLegacyWording(t *testing.T) { + t.Parallel() + assertOpenAPIYAMLOmitsToken(t, "legacy") +} + +func TestV1OpenAPIYAMLNoA1Wording(t *testing.T) { + t.Parallel() + // Case-sensitive: lowercase "a1" appears in UUIDs and must stay allowed. + assertOpenAPIYAMLOmitsToken(t, "A1") +} + +func TestV1OpenAPIYAMLOmitsInternalPromptWording(t *testing.T) { + t.Parallel() + for _, token := range []string{ + "role-section prompts", + "plain description, meta", + "after AI/manual edit", + } { + assertOpenAPIYAMLOmitsToken(t, token) + } +} + +func TestV1OpenAPIYAMLProcessItemCompletedExamplesOmitLeakyShape(t *testing.T) { + t.Parallel() + root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) + leaky := []string{"id", "title", "meta_title", "meta_description"} + assertProcessItems := func(where string, items []any) { + t.Helper() + if len(items) == 0 { + t.Fatalf("%s: expected completed ProcessItem example", where) + } + for i, raw := range items { + item, _ := raw.(map[string]any) + if item == nil { + t.Fatalf("%s[%d]: not an object", where, i) + } + if _, ok := item["ean"]; !ok { + t.Fatalf("%s[%d]: missing ean", where, i) + } + for _, k := range leaky { + if _, ok := item[k]; ok { + t.Fatalf("%s[%d] must omit %q (do not teach leaky ProcessItem shape)", where, i, k) + } + } + } + } + + comps, _ := root["components"].(map[string]any) + examples, _ := comps["examples"].(map[string]any) + completed, _ := examples["ProcessCompletedExample"].(map[string]any) + val, _ := completed["value"].(map[string]any) + data, _ := val["data"].(map[string]any) + items, _ := data["items"].([]any) + assertProcessItems("ProcessCompletedExample", items) + + paths, _ := root["paths"].(map[string]any) + productsProcess := openAPIJSONExamples(t, paths, "/products/process/{id}", "get", "200") + prodCompleted, _ := productsProcess["completed"].(map[string]any) + prodVal, _ := prodCompleted["value"].(map[string]any) + prodData, _ := prodVal["data"].(map[string]any) + prodItems, _ := prodData["items"].([]any) + assertProcessItems("GET /products/process/{id} completed", prodItems) + + legacyProcess := openAPIJSONExamples(t, paths, "/process/{id}", "get", "200") + legacyCompleted, _ := legacyProcess["completed"].(map[string]any) + legacyVal, _ := legacyCompleted["value"].(map[string]any) + legacyItems, _ := legacyVal["items"].([]any) + assertProcessItems("GET /process/{id} completed", legacyItems) +} + +func openAPIJSONExamples(t *testing.T, paths map[string]any, path, method, status string) map[string]any { + t.Helper() + p, _ := paths[path].(map[string]any) + op, _ := p[method].(map[string]any) + resps, _ := op["responses"].(map[string]any) + resp, _ := resps[status].(map[string]any) + content, _ := resp["content"].(map[string]any) + appJSON, _ := content["application/json"].(map[string]any) + examples, _ := appJSON["examples"].(map[string]any) + if examples == nil { + t.Fatalf("%s %s %s: missing application/json examples", method, path, status) + } + return examples +} + +func assertOpenAPIYAMLOmitsToken(t *testing.T, token string) { + t.Helper() + doc := string(v1OpenAPIYAML) + if token == "legacy" { + doc = strings.ToLower(doc) + } + if i := strings.Index(doc, token); i >= 0 { + start := i - 40 + if start < 0 { + start = 0 + } + end := i + 40 + if end > len(doc) { + end = len(doc) + } + t.Fatalf("public OpenAPI YAML must not mention %q (near %q)", token, doc[start:end]) + } +} + +func TestV1OpenAPIYAMLEmptySecurityOnlyOnPublicProbes(t *testing.T) { + t.Parallel() + + root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) + sec, _ := root["security"].([]any) + if len(sec) == 0 { + t.Fatal("document-level security must require API key schemes") + } + var hasBearer, hasAPIKey bool + for _, item := range sec { + m, _ := item.(map[string]any) + if _, ok := m["BearerAuth"]; ok { + hasBearer = true + } + if _, ok := m["ApiKeyAuth"]; ok { + hasAPIKey = true + } + } + if !hasBearer || !hasAPIKey { + t.Fatal("document-level security must include BearerAuth and ApiKeyAuth") + } + + paths, _ := root["paths"].(map[string]any) + methods := []string{"get", "post", "put", "patch", "delete"} + var bad []string + for p, raw := range paths { + item, _ := raw.(map[string]any) + for _, m := range methods { + op, _ := item[m].(map[string]any) + if op == nil { + continue + } + rawSec, ok := op["security"] + if !ok { + continue + } + arr, _ := rawSec.([]any) + if len(arr) != 0 { + continue + } + if p != "/health" && p != "/openapi.yaml" { + bad = append(bad, m+" "+p) + } + } + } + if len(bad) > 0 { + t.Fatalf("empty security (no API key) on non-probe operations: %v", bad) } } diff --git a/apps/api/internal/httpapi/v1_process_handlers.go b/apps/api/internal/httpapi/v1_process_handlers.go index c4a08a2..8ea029e 100644 --- a/apps/api/internal/httpapi/v1_process_handlers.go +++ b/apps/api/internal/httpapi/v1_process_handlers.go @@ -227,6 +227,7 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) { v1Err(w, http.StatusInternalServerError, "internal_server_error", "Internal server error") return } + items = processing.ApplyV1SEOMetaPolicy(cid, items) data := map[string]any{ "status": status, "process_id": job.ID.String(), @@ -245,8 +246,9 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) { v1OK(w, http.StatusOK, data, nil) case "FAILED": errMsg := "Processing failed" - if job.Error != nil && *job.Error != "" { - errMsg = *job.Error + if job.Error != nil && strings.TrimSpace(*job.Error) != "" { + log.Printf("httpapi: v1 process job %s failed: %s", job.ID, redactForLog(*job.Error)) + errMsg = publicV1JobError(*job.Error) } v1OK(w, http.StatusOK, map[string]any{ "status": status, @@ -269,6 +271,99 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) { } } +func publicV1JobError(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "Processing failed" + } + out := processing.PublicV1Error(raw) + if strings.TrimSpace(out) == "" { + return "Processing failed" + } + return out +} + +func presentV1Job(job processing.Job) map[string]any { + out := map[string]any{ + "id": job.ID, + "status": job.Status, + "total_products": job.TotalProducts, + "processed_products": job.ProcessedProducts, + "processing_type": processing.ProcessingTypeForAPIResponse(job.ProcessingType), + "created_at": formatV1Timestamp(job.CreatedAt), + } + if job.StartedAt != nil { + out["started_at"] = formatV1Timestamp(*job.StartedAt) + } + if job.CompletedAt != nil { + out["completed_at"] = formatV1Timestamp(*job.CompletedAt) + } + if job.Error != nil && strings.TrimSpace(*job.Error) != "" { + out["error"] = publicV1JobError(*job.Error) + } + return out +} + +func presentV1JobList(jobs []processing.Job) []any { + if len(jobs) == 0 { + return []any{} + } + formatted := processing.FormatListJobsResponse(jobs) + out := make([]any, len(formatted)) + for i, row := range formatted { + base := presentV1Job(jobs[i]) + raw, err := json.Marshal(row) + if err != nil { + out[i] = base + continue + } + var extra map[string]any + if err := json.Unmarshal(raw, &extra); err != nil { + out[i] = base + continue + } + for _, k := range []string{"sibling_job_ids", "job_count", "total_products_queued"} { + if v, ok := extra[k]; ok { + base[k] = v + } + } + out[i] = base + } + return out +} + +// handleV1GetProcessJob serves GET /api/v1/process/{id} with a public job DTO +// (no current_step, step notes, or company_id). Dashboard GET stays on handleGetProcessingJob. +func (s *Server) handleV1GetProcessJob(w http.ResponseWriter, r *http.Request) { + cid, ok := CompanyIDFromContext(r.Context()) + if !ok || cid == uuid.Nil { + Error(w, http.StatusUnauthorized, "unauthorized") + return + } + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + Error(w, http.StatusBadRequest, "invalid id") + return + } + job, err := s.getV1ProcessJob(r.Context(), cid, id) + if err != nil { + Error(w, http.StatusNotFound, "not found") + return + } + out := presentV1Job(job) + if processing.JobStatusIncludesProducts(job.Status) { + items, loadErr := s.loadV1ProcessJobItems(r.Context(), cid, id, job.ProcessingType) + if loadErr != nil { + Error(w, http.StatusInternalServerError, "load failed") + return + } + items = processing.ApplyV1SEOMetaPolicy(cid, items) + out["items"] = items + out["total_items"] = len(items) + } + JSON(w, http.StatusOK, out) +} + func (s *Server) ensureRawV1Items(ctx context.Context, companyID uuid.UUID, items []catalog.V1ProcessItem) ([]uuid.UUID, []catalog.EnsureRawResult, []string, error) { if s != nil && s.testEnsureRawV1Items != nil { return s.testEnsureRawV1Items(ctx, companyID, items) diff --git a/apps/api/internal/httpapi/v1_process_handlers_test.go b/apps/api/internal/httpapi/v1_process_handlers_test.go index 2a072a9..554113b 100644 --- a/apps/api/internal/httpapi/v1_process_handlers_test.go +++ b/apps/api/internal/httpapi/v1_process_handlers_test.go @@ -337,7 +337,6 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) { t.Parallel() cid := uuid.MustParse("11111111-1111-1111-1111-111111111111") jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - productID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" s := &Server{ testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) { if companyID != cid || id != jobID { @@ -357,7 +356,7 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) { } return []processing.V1ProcessJobItem{{ "ean": "0123456789012", - "id": productID, + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "status": "processed", "title": "Acme Widget", }}, nil @@ -393,9 +392,12 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) { if !ok { t.Fatalf("item=%T", items[0]) } - if item["status"] != "processed" || item["id"] != productID || item["ean"] != "0123456789012" { + if item["status"] != "processed" || item["ean"] != "0123456789012" { t.Fatalf("item=%v", item) } + if _, ok := item["id"]; ok { + t.Fatalf("id must be omitted from public process items: %v", item) + } if _, ok := body.Data["processed_at"]; !ok { t.Fatalf("missing processed_at: %v", body.Data) } @@ -447,6 +449,90 @@ func TestHandleGetProcessingJobCompletedIncludesItems(t *testing.T) { } } +func TestHandleV1GetProcessJobOmitsAIEnhance(t *testing.T) { + t.Parallel() + cid := uuid.MustParse("11111111-1111-1111-1111-111111111111") + jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + note := "GPT: rewrite title with --- Title --- markers" + errMsg := "openai: invalid api-key sk-live-secret" + s := &Server{ + testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) { + return processing.Job{ + ID: jobID, + CompanyID: companyID, + Status: "processing", + ProcessingType: "full", + CurrentStep: "ai_enhance", + StepProgress: []processing.StepProgress{{ + Step: "ai_enhance", Status: "running", Note: note, + }}, + Error: &errMsg, + TotalProducts: 2, + ProcessedProducts: 1, + }, nil + }, + } + ctx := context.WithValue(context.Background(), ctxCompanyID, cid) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", jobID.String()) + req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) + s.handleV1GetProcessJob(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + raw := rec.Body.String() + if strings.Contains(raw, "ai_enhance") { + t.Fatalf("public process poll leaked ai_enhance: %s", raw) + } + if strings.Contains(raw, "GPT:") || strings.Contains(raw, "--- Title ---") { + t.Fatalf("public process poll leaked step notes: %s", raw) + } + if strings.Contains(raw, "current_step") || strings.Contains(raw, "step_progress") || strings.Contains(raw, "company_id") { + t.Fatalf("public process poll leaked internals: %s", raw) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["status"] != "processing" { + t.Fatalf("status=%v", body["status"]) + } + if body["total_products"].(float64) != 2 || body["processed_products"].(float64) != 1 { + t.Fatalf("counts=%v", body) + } +} + +func TestHandleGetProcessingJobKeepsCurrentStep(t *testing.T) { + t.Parallel() + cid := uuid.MustParse("11111111-1111-1111-1111-111111111111") + jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + s := &Server{ + testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) { + return processing.Job{ + ID: jobID, + CompanyID: companyID, + Status: "processing", + CurrentStep: "ai_enhance", + }, nil + }, + } + ctx := context.WithValue(context.Background(), ctxCompanyID, cid) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/processing/jobs/"+jobID.String(), nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", jobID.String()) + req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) + s.handleGetProcessingJob(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "ai_enhance") { + t.Fatalf("dashboard GET should keep current_step: %s", rec.Body.String()) + } +} + func TestHandleV1ListProcessJobsUnauthorizedWithoutCompany(t *testing.T) { t.Parallel() s := &Server{} @@ -500,3 +586,126 @@ func TestAssertV1ProcessGatesNilBilling(t *testing.T) { t.Fatalf("nil billing must no-op: %v", err) } } + +func processJobWithSEOMetaItems(cid, jobID uuid.UUID) *Server { + return &Server{ + testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) { + return processing.Job{ + ID: jobID, + CompanyID: companyID, + Status: "completed", + ProcessingType: "full", + TotalProducts: 1, + }, nil + }, + testLoadV1ProcessJobItems: func(context.Context, uuid.UUID, uuid.UUID, string) ([]processing.V1ProcessJobItem, error) { + return []processing.V1ProcessJobItem{{ + "ean": "0123456789012", + "status": "processed", + "name": "Acme Widget", + "meta_title": "Acme | Widget", + "meta_description": "A widget for tests", + }}, nil + }, + } +} + +func getProcessJSONItem(t *testing.T, rec *httptest.ResponseRecorder, wrapped bool) map[string]any { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + data := raw + if wrapped { + var ok bool + data, ok = raw["data"].(map[string]any) + if !ok { + t.Fatalf("data=%T body=%s", raw["data"], rec.Body.String()) + } + } + items, ok := data["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("items=%v", data["items"]) + } + item, ok := items[0].(map[string]any) + if !ok { + t.Fatalf("item=%T", items[0]) + } + return item +} + +func TestA1ProcessEndpointsOmitSEOMeta(t *testing.T) { + t.Parallel() + cid := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790") + jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + s := processJobWithSEOMetaItems(cid, jobID) + ctx := context.WithValue(context.Background(), ctxCompanyID, cid) + + legacyRec := httptest.NewRecorder() + legacyReq := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", jobID.String()) + legacyReq = legacyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) + s.handleV1GetProcess(legacyRec, legacyReq) + legacyItem := getProcessJSONItem(t, legacyRec, true) + if _, ok := legacyItem["meta_title"]; ok { + t.Fatalf("GET /products/process/{id} A1 meta_title=%v", legacyItem["meta_title"]) + } + if _, ok := legacyItem["meta_description"]; ok { + t.Fatalf("GET /products/process/{id} A1 meta_description=%v", legacyItem["meta_description"]) + } + + jobRec := httptest.NewRecorder() + jobReq := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil) + jrctx := chi.NewRouteContext() + jrctx.URLParams.Add("id", jobID.String()) + jobReq = jobReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, jrctx)) + s.handleGetProcessingJob(jobRec, jobReq) + jobItem := getProcessJSONItem(t, jobRec, false) + if _, ok := jobItem["meta_title"]; ok { + t.Fatalf("GET /process/{id} A1 meta_title=%v", jobItem["meta_title"]) + } + if _, ok := jobItem["meta_description"]; ok { + t.Fatalf("GET /process/{id} A1 meta_description=%v", jobItem["meta_description"]) + } +} + +func TestNonA1ProcessEndpointsKeepSEOMeta(t *testing.T) { + t.Parallel() + cid := uuid.MustParse("11111111-1111-1111-1111-111111111111") + jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + s := processJobWithSEOMetaItems(cid, jobID) + ctx := context.WithValue(context.Background(), ctxCompanyID, cid) + + legacyRec := httptest.NewRecorder() + legacyReq := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", jobID.String()) + legacyReq = legacyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) + s.handleV1GetProcess(legacyRec, legacyReq) + legacyItem := getProcessJSONItem(t, legacyRec, true) + if legacyItem["meta_title"] != "Acme | Widget" { + t.Fatalf("non-A1 GET /products/process/{id} meta_title=%v", legacyItem["meta_title"]) + } + if legacyItem["meta_description"] != "A widget for tests" { + t.Fatalf("non-A1 GET /products/process/{id} meta_description=%v", legacyItem["meta_description"]) + } + + jobRec := httptest.NewRecorder() + jobReq := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil) + jrctx := chi.NewRouteContext() + jrctx.URLParams.Add("id", jobID.String()) + jobReq = jobReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, jrctx)) + s.handleGetProcessingJob(jobRec, jobReq) + jobItem := getProcessJSONItem(t, jobRec, false) + if jobItem["meta_title"] != "Acme | Widget" { + t.Fatalf("non-A1 GET /process/{id} meta_title=%v", jobItem["meta_title"]) + } + if jobItem["meta_description"] != "A widget for tests" { + t.Fatalf("non-A1 GET /process/{id} meta_description=%v", jobItem["meta_description"]) + } +} diff --git a/apps/api/internal/mail/mailer.go b/apps/api/internal/mail/mailer.go index ad5a83d..5a31b92 100644 --- a/apps/api/internal/mail/mailer.go +++ b/apps/api/internal/mail/mailer.go @@ -2,6 +2,7 @@ package mail import ( "fmt" + "html" "log" "net" "net/smtp" @@ -109,31 +110,33 @@ func hasHeaderBreak(v string) bool { func InviteMessage(webOrigin, email, token, companyName string) Message { link := AcceptInviteURL(webOrigin, token) text := fmt.Sprintf("You have been invited to %s on Descrybe.\n\nAccept: %s\n", companyName, link) - html := fmt.Sprintf( + htmlBody := fmt.Sprintf( `

You have been invited to %s on Descrybe.

Accept invite

`, - companyName, link, + html.EscapeString(companyName), html.EscapeString(link), ) - return Message{To: email, Subject: "You are invited to Descrybe", Text: text, HTML: html} + return Message{To: email, Subject: "You are invited to Descrybe", Text: text, HTML: htmlBody} } // AcceptInviteURL builds the durable invite / set-password accept link (hashed invite tokens). +// Token is placed in the URL fragment so it is not sent on the page GET (Referer/access logs). func AcceptInviteURL(webOrigin, token string) string { - return strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token + return strings.TrimRight(webOrigin, "/") + "/accept-invite#token=" + token } // SetPasswordURL builds the HMAC set-password accept-invite link. +// Token is placed in the URL fragment so it is not sent on the page GET (Referer/access logs). func SetPasswordURL(webOrigin, token string) string { - return strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token + "&mode=set-password" + return strings.TrimRight(webOrigin, "/") + "/accept-invite#token=" + token + "&mode=set-password" } func SetPasswordMessage(webOrigin, email, token string) Message { link := SetPasswordURL(webOrigin, token) text := fmt.Sprintf("Set your Descrybe password:\n\n%s\n\nThis link expires in 72 hours.\n", link) - html := fmt.Sprintf( + htmlBody := fmt.Sprintf( `

Set your Descrybe password:

Set password

This link expires in 72 hours.

`, - link, + html.EscapeString(link), ) - return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: html} + return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: htmlBody} } // MigratedSetPasswordMessage uses migrator invite tokens (accept-invite flow). @@ -143,11 +146,11 @@ func MigratedSetPasswordMessage(webOrigin, email, token string) Message { "Your Descrybe account was migrated. Set your password here:\n\n%s\n\nIf you did not expect this email, ignore it.\n", link, ) - html := fmt.Sprintf( + htmlBody := fmt.Sprintf( `

Your Descrybe account was migrated.

Set your password

If you did not expect this email, ignore it.

`, - link, + html.EscapeString(link), ) - return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: html} + return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: htmlBody} } // ResetPasswordURL builds the self-serve forgot-password reset link. diff --git a/apps/api/internal/mail/mailer_test.go b/apps/api/internal/mail/mailer_test.go index 805cc8f..d47c567 100644 --- a/apps/api/internal/mail/mailer_test.go +++ b/apps/api/internal/mail/mailer_test.go @@ -118,9 +118,32 @@ func TestNewDynamicResolvesPerCall(t *testing.T) { } } +func TestInviteAndSetPasswordMessagesEscapeHTML(t *testing.T) { + name := `Acme & Co` + msg := InviteMessage("https://app.example", "a@b.c", "tok", name) + if strings.Contains(msg.HTML, " - {#if done} + {#if !hydrated} + + {i18n.t("auth.invite.title")} + {i18n.t("auth.invite.description")} + + +

{i18n.t("auth.invite.checking")}

+
+ {:else if done} {mode === "set-password" @@ -377,9 +391,6 @@

{i18n.t("auth.invite.expiredFooter")} - {i18n.t("auth.invite.adminUsersLink")}.

{/if} diff --git a/apps/web/src/routes/admin/+layout.server.ts b/apps/web/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..b8d0537 --- /dev/null +++ b/apps/web/src/routes/admin/+layout.server.ts @@ -0,0 +1,46 @@ +import { error, redirect } from "@sveltejs/kit"; +import type { LayoutServerLoad } from "./$types"; +import { + adminLoginRedirect, + fetchMeStaff, + isAdminSupportPath, + isFullPlatformAdmin, + isSupportDesk +} from "$lib/server/require-platform-admin"; + +/** + * SSR gate for /admin/* pages (not +server.ts endpoints). + * Unauthenticated → login?next=. Non-staff → /dashboard (no admin chrome). + * /admin/support* → support desk. All other admin pages (including logs + bootstrap) → full platform admin. + */ +export const load: LayoutServerLoad = async (event) => { + const result = await fetchMeStaff(event); + if (!result.ok) { + if (result.status === 401) { + redirect(303, adminLoginRedirect(event.url.pathname, event.url.search)); + } + error(result.status, "Forbidden"); + } + + const me = result.me; + const path = event.url.pathname; + const supportPath = isAdminSupportPath(path); + const fullAdmin = isFullPlatformAdmin(me); + const supportDesk = isSupportDesk(me); + + if (supportPath) { + if (!supportDesk) { + redirect(303, "/dashboard"); + } + return { staff: { full_admin: fullAdmin, support_desk: true } }; + } + + if (!fullAdmin) { + if (supportDesk) { + redirect(303, "/admin/support"); + } + redirect(303, "/dashboard"); + } + + return { staff: { full_admin: true, support_desk: supportDesk } }; +}; diff --git a/apps/web/src/routes/admin/+page.svelte b/apps/web/src/routes/admin/+page.svelte index 858d356..6e3b1e9 100644 --- a/apps/web/src/routes/admin/+page.svelte +++ b/apps/web/src/routes/admin/+page.svelte @@ -264,18 +264,18 @@ }); - - {#if loading} - - {:else if accessDenied} - - {:else if error} - - {:else} +{#if loading} + +{:else if accessDenied} + +{:else if error} + +{:else} + {#if summary}
@@ -517,5 +517,5 @@
{/if} - {/if} -
+
+{/if} diff --git a/apps/web/src/routes/admin/bootstrap/+page.svelte b/apps/web/src/routes/admin/bootstrap/+page.svelte index 8e91506..7fd34d2 100644 --- a/apps/web/src/routes/admin/bootstrap/+page.svelte +++ b/apps/web/src/routes/admin/bootstrap/+page.svelte @@ -1,7 +1,8 @@ - - {#if loading} - - {:else if accessDenied} - - {:else} +{#if loading} + +{:else if accessDenied} + +{:else} + {i18n.t("admin.bootstrap.statusTitle")} @@ -58,11 +59,11 @@ - {#if me?.user?.is_platform_admin} + {#if isFullPlatformAdmin(me)}

{i18n.t("admin.bootstrap.alreadyAdmin")}

- {i18n.t("admin.bootstrap.signedInAs", { email: me.user.email })} + {i18n.t("admin.bootstrap.signedInAs", { email: me?.user?.email ?? "" })}

{:else if error} @@ -81,7 +82,7 @@ {/if}
- {#if me?.user?.is_platform_admin} + {#if isFullPlatformAdmin(me)} {:else}