Skip to content

v0.5.0 — Phase 5: webhooks, bulk import, RBAC, custom domain, mega-menu

Latest

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05
· 7 commits to main since this release

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 on active = 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 geometric 5 × 5^attempts seconds, computed in SQL via interval. 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/webhooks crate runs a tokio loop. Concurrency cap of 20 in-flight deliveries via FuturesUnordered + 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-tls only.
  • HMAC signing is panic-free under deny(warnings) (let Ok ... else over Hmac::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.registered
  • routes::events::record → event.recorded
  • anchor::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 UPDATE on subscriptions(brand_id), COUNT(*), per-row INSERT, commit. LimitExceeded rolls back the whole batch (no partial inserts at the plan boundary).
  • routes::bulk_products::bulk_json — accepts Vec<BulkProductItem> JSON. MAX_BULK_ITEMS = 1000. Per-item validation collects failures into failed: [{ index, reason }] and never reaches the DB.
  • routes::bulk_products::bulk_csv — axum::extract::Multipart. CSV parsed via the csv crate, columns name (required), external_ref, metadata_json (optional). Unknown columns tolerated. Malformed metadata_json reports the row number with BadRequest.
  • 5 MB body cap enforced pre-allocation via .layer(DefaultBodyLimit::max(MAX_CSV_BYTES)) on the route — fixes the memory-DoS where field.bytes().await would 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 as owner (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 NULL so 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 — create now takes a role parameter. New: list_by_brand, set_role, delete, count_owners for the last-owner guard.
  • db::invites — claim_by_token (atomic UPDATE ... 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_session extended to inject UserRole alongside brand_id and UserId. 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} — FromRequestParts extractors. 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

  • /team dashboard 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 UNIQUE plus a partial index WHERE 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_domain and get_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.com and any *.genuflow.com subdomain (including our advertised CNAME target verify.genuflow.com), localhost and 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-expanded on every collapsible trigger; aria-hidden on decorative SVGs.

Verified gates

  • cargo clippy --workspace -- -D warnings — clean across all 6 crates (the new crates/webhooks crate 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), /settings rebuilt; 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, calls getByCustomDomain, and rewrites brand context on /verify/[product_id].