Repository navigation
Overview
Phase 5 adds the enterprise surface to Genuflow: outbound webhooks, bulk product import, multi-user brand accounts with RBAC, custom verification domains, and a mega-menu redesign of the marketing nav. Five workstreams, each gated through rust-reviewer with blocking issues fixed before approval.
1. Webhook delivery
Brands subscribe HTTP endpoints to provenance events. Genuflow signs each payload with HMAC-SHA256 and retries on failure with bounded backoff.
Schema
0008_webhook_endpoints.sql—brand_id,url,secret,events TEXT[],active BOOLEAN,created_at. Partial index onactive = TRUE.0009_webhook_deliveries.sql— outbox:webhook_endpoint_id,event_type,payload JSONB,status(pending/delivered/failed),attempts,last_attempted_at,created_at. Backoff is geometric5 × 5^attemptsseconds, computed in SQL viainterval. Capped at three attempts.
Code
db::webhook_endpoints—create,list_by_brand,get_by_id_for_brand,deactivate,list_active_for_event.db::webhook_deliveries—create,list_by_endpoint,list_pending_due(joined with endpoints, filters out deactivated),update_status.db::webhooks::enqueue(brand_id, event_type, payload)resolves matching endpoints and inserts one delivery row each. Canonical event names:product.registered,event.recorded,anchor.confirmed.- New
crates/webhookscrate runs a tokio loop. Concurrency cap of 20 in-flight deliveries viaFuturesUnordered + buffer_unordered(20)— fixes the head-of-line blocking where one slow endpoint would stall every other tenant's deliveries in the same tick. Per-request 10s timeout,rustls-tlsonly. - HMAC signing is panic-free under
deny(warnings)(let Ok ... elseoverHmac::new_from_slice). - 4 unit tests cover signature determinism + sensitivity to body, timestamp, and secret.
API
| Method | Path | Notes |
|---|---|---|
POST |
/v1/webhooks |
Returns the signing secret exactly once. |
GET |
/v1/webhooks |
Secret elided from response. |
DELETE |
/v1/webhooks/:id |
Soft-delete via active = false. |
GET |
/v1/webhooks/:id/deliveries |
Per-endpoint log, capped at 100. |
Producer hooks
Best-effort fan-out on three sites — failures are logged via tracing::warn but never fail the originating request:
routes::products::register→product.registeredroutes::events::record→event.recordedanchor::poller::record_brand_hashes→anchor.confirmed
Frontend
/webhooks dashboard page: register form, accent-tinted secret reveal card (shown once), per-endpoint expandable delivery log with status pills, deactivate flow with confirm. Sidebar gains a Webhooks entry.
2. Bulk product import
Up to 1,000 products per request, with plan-limit enforcement and atomic insert.
Code
db::products::bulk_insert_with_plan_check— single transaction,SELECT FOR UPDATEonsubscriptions(brand_id),COUNT(*), per-rowINSERT, commit.LimitExceededrolls back the whole batch (no partial inserts at the plan boundary).routes::bulk_products::bulk_json— acceptsVec<BulkProductItem>JSON.MAX_BULK_ITEMS = 1000. Per-item validation collects failures intofailed: [{ index, reason }]and never reaches the DB.routes::bulk_products::bulk_csv—axum::extract::Multipart. CSV parsed via thecsvcrate, columnsname(required),external_ref,metadata_json(optional). Unknown columns tolerated. Malformedmetadata_jsonreports the row number withBadRequest.- 5 MB body cap enforced pre-allocation via
.layer(DefaultBodyLimit::max(MAX_CSV_BYTES))on the route — fixes the memory-DoS wherefield.bytes().awaitwould buffer the entire body before the in-handler size check. Axum's 2 MB default was silently clamping the stated 5 MB ceiling; now the explicit layer is the actual cap.
API
| Method | Path | Notes |
|---|---|---|
POST |
/v1/products/bulk |
JSON array, ≤ 1,000 items. |
POST |
/v1/products/bulk/csv |
Multipart, file field, ≤ 5 MB. |
Both return { created: N, products: [...], failed: [{ index, reason }] }. Plan-limit overflow returns 402 with bulk import rejected: N + M > LIMIT.
Operational note
CSV export must escape formula-prefixed cells (=, +, -, @, tab) per OWASP CSV injection guidance — documented in CLAUDE.md (gitignored local instructions) as a forward-looking requirement for any future GET /v1/products/export.csv. Mitigation lives at the serializer boundary, not at input — values stored as-is.
Frontend
Import button on /products opens a modal with two tabs (Paste JSON, Upload CSV), inline example payloads, post-submit result view with prominent created count and a failures table.
3. Multi-user brand accounts and RBAC
Brands can now have many users with three distinct roles.
Schema
0010_user_roles.sql—ALTER users ADD role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner','admin','member')). Back-fills every pre-existing user asowner(one user per brand pre-Phase-5).0011_invites.sql—invites(brand_id, email, role, token UUID UNIQUE DEFAULT gen_random_uuid(), expires_at, accepted_at, created_at). Partial UNIQUE on(brand_id, email) WHERE accepted_at IS NULLso a stale invite can be re-sent after acceptance.
Code
common::UserRole(Owner | Admin | Member),common::Invite,common::User.role.AppError::Forbidden(String)→ HTTP 403.db::users—createnow takes a role parameter. New:list_by_brand,set_role,delete,count_ownersfor the last-owner guard.db::invites—claim_by_token(atomicUPDATE ... RETURNING),get_by_token,list_pending_by_brand,mark_accepted,delete_pending. Both token-bearing functions use#[instrument(skip(db, token), err)]— fixes the leak where the invite secret was logged via the tracing span.middleware::require_sessionextended to injectUserRolealongsidebrand_idandUserId. Role is read fresh from the DB on every session-authenticated request, so role changes take effect on the next request without JWT rollover.middleware::rbac::{OwnerOnly, OwnerOrAdmin}—FromRequestPartsextractors. Authorization fails before the handler body runs.
API
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/v1/team/ |
Session | { members, invites }; invite token elided. |
POST |
/v1/team/invite |
OwnerOrAdmin |
7-day TTL; raw token returned once. 409 on duplicate pending invite. |
DELETE |
/v1/team/invite/:id |
OwnerOrAdmin |
Revoke pending invite. |
DELETE |
/v1/team/:user_id |
OwnerOnly |
Last-owner guard + self-removal guard. |
PATCH |
/v1/team/:user_id/role |
OwnerOnly |
Last-owner guard. |
POST |
/auth/accept-invite/:token |
Public | Atomic claim; sets session cookie. |
Last-owner protection
Both role-change and delete paths enforce the "a brand must always have at least one owner" invariant inside a single transaction:
let mut tx = state.db.begin().await?;
sqlx::query("SELECT id FROM subscriptions WHERE brand_id = $1 FOR UPDATE")...
// re-read target's role inside the tx
if matches!(target.role, Owner) {
let owners = count_owners(...);
if owners <= 1 { return Err(BadRequest(...)); }
}
// delete / update inside the tx
tx.commit().await?;The serialization lock on the subscriptions row closes the race where two concurrent owner-deletes-owner requests would both pass the count check and both succeed, leaving the brand owner-less.
Atomic invite claim
POST /auth/accept-invite/:token no longer does read → check-accepted → check-expired → create-user → mark-accepted. The first four steps collapse into one atomic UPDATE invites SET accepted_at = NOW() WHERE token = $1 AND accepted_at IS NULL AND expires_at > NOW() RETURNING .... An empty result returns a single generic 400 invite is invalid, expired, or already accepted — we do not oracle which of the three failed. The users.email UNIQUE constraint is now a backstop rather than the primary guard.
Frontend
/teamdashboard page: server-fetched member table with role pill or inline role dropdown for owners. Invite form (admin/member only). Pending invites table with revoke. Sidebar gains a Team entry./accept-invite/[token]public page: password + confirm form, ≥12-char minimum, signs the user in on success.
4. Custom verification domain
Brands can serve /verify/... from their own host.
Schema
0012_brand_custom_domain.sql—ALTER brands ADD custom_domain TEXT UNIQUEplus a partial indexWHERE custom_domain IS NOT NULL. Uniqueness is enforced at the database level; the application-layer 409 mapping is purely for UX.
Code
common::Brand.custom_domain: Option<String>.db::brands::set_custom_domainandget_by_custom_domain. Every existing brands query updated to SELECT/RETURN the new column.
API
PUT /v1/settings/domain (session-only, OwnerOnly):
- Body:
{ "custom_domain": string | null }. Null or empty string clears. - Normalisation: trim, strip trailing dot, lowercase.
- Validation enforces a bare hostname (≤ 253 chars, ≥ 1 dot, RFC 1035-shaped labels, no protocol/path/port).
- Reserved-domain blocklist rejects squatting on
genuflow.comand any*.genuflow.comsubdomain (including our advertised CNAME targetverify.genuflow.com),localhostand any*.localhost,*.local,*.internal, and any raw IPv4 literal — fixes the squatting vector where a tenant could claim a Genuflow-branded URL.
Auth surface tweak
GET /auth/me now also returns brand_slug, custom_domain, and role so the dashboard can render owner-only sections without a second fetch.
Tests
7 unit tests on validate_hostname cover: well-formed acceptance, apex rejection, subdomain rejection, local/internal namespaces, raw IPv4 literals, exact-localhost, and lookalike names that must still pass (mylocal.com, internal-prefix.com).
Frontend
/settings page gets a new owner-only Custom verification domain card: hostname input with monospace styling, Save/Remove flow, active-domain badge, and a three-step DNS setup block (CNAME target verify.genuflow.com, plus a note that Cloudflare provisions TLS automatically at the edge). Non-owners never see the section.
5. Mega-menu marketing nav
components/marketing/nav.tsx rewritten end to end.
- Discriminated union of
{ kind: "link" }and{ kind: "dropdown" }entries. - Structure per spec: How it works (link), Use cases (dropdown), Pricing (link), Resources (dropdown), Company (dropdown, renamed from About).
- Desktop: hover-driven dropdowns; only one panel open at a time; 420 px panel with a small icon tile + label + 12 px description per item; chevron rotates 180° when open.
- Mobile: accordion drawer; each dropdown collapses independently; any leaf-link tap closes the drawer.
- Ten consistent line-art SVG icons (bag, fabric, watch, journal, paper, wrench, code, standards, compass, mail).
aria-expandedon every collapsible trigger;aria-hiddenon decorative SVGs.
Verified gates
cargo clippy --workspace -- -D warnings— clean across all 6 crates (the newcrates/webhookscrate added cleanly).cargo test -p api --bin genuflow— 11/11 pass (4 webhook signature tests + 7 settings hostname-validation tests).cargo test -p webhooks— 4/4 pass (HMAC signing determinism + sensitivity).next build— 32 routes; new dashboard pages:/webhooks,/team,/billing(existing),/settingsrebuilt; new public page/accept-invite/[token].
Operational notes
- Postgres auto-applies migrations 0008–0012 on next API start, including the role back-fill in 0010.
- The webhook delivery service starts alongside the anchor service from
main.rs. No additional config required. - For real Stripe (Phase 4), set
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_ID_MAISON,STRIPE_PRICE_ID_COUTURE; without them, billing remains in placeholder mode. - Custom domains are stored only; production routing is handled by Cloudflare. Future micro-task: wire a Next.js middleware that reads
request.headers.host, callsgetByCustomDomain, and rewrites brand context on/verify/[product_id].