Repository navigation
Releases: ChronoCoders/genuflow
Release list
v0.5.0 — Phase 5: webhooks, bulk import, RBAC, custom domain, mega-menu
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, ...
v0.4.0 — Phase 4: billing infrastructure, Stripe-ready
Overview
Phase 4 introduces the billing layer. Genuflow now tracks each brand's plan and product usage, enforces per-plan limits at registration time, and is fully wired for Stripe Checkout and webhook ingestion — without taking a hard dependency on a Stripe account. When `STRIPE_SECRET_KEY` is set, real Stripe flows engage; when it is not, the same endpoints respond with placeholder behaviour so the integration can be exercised end-to-end during development.
The implementation is intentionally SDK-free. Webhook signature verification is hmac+sha2; Checkout session creation is one POST via `reqwest` over `rustls-tls`. No `async-stripe` dependency, no native OpenSSL link.
Schema
Two new migrations, applied automatically by the API on startup.
`migrations/0006_subscriptions.sql`
One row per brand (UNIQUE on `brand_id`):
| Column | Type | Notes |
|---|---|---|
| `id` | UUID | `gen_random_uuid()` |
| `brand_id` | UUID | FK `brands(id)`, ON DELETE CASCADE, UNIQUE |
| `plan` | TEXT | CHECK IN (`atelier`, `maison`, `couture`) |
| `status` | TEXT | CHECK IN (`active`, `past_due`, `canceled`, `trialing`) |
| `stripe_customer_id` | TEXT | nullable, set by webhook |
| `stripe_subscription_id` | TEXT | nullable, set by webhook |
| `current_period_end` | TIMESTAMPTZ | nullable |
| `created_at` | TIMESTAMPTZ | `NOW()` |
Includes a one-shot back-fill that gives every pre-existing brand the default `(atelier, active)` subscription.
`migrations/0007_usage_records.sql`
Periodic snapshots keyed by `(brand_id, period_start)`. The anchor for future usage-based reconciliation when Stripe enters the loop.
Types and errors
In `crates/common`:
- `Plan { Atelier, Maison, Couture }` with `product_limit() -> Option` returning `Some(500)`, `Some(10_000)`, and `None` respectively.
- `SubscriptionStatus { Active, PastDue, Canceled, Trialing }` — vocabulary mirrors Stripe so webhook ingestion is a direct field copy.
- `Subscription` — full record type.
- `AppError::PaymentRequired(String)` → HTTP 402.
DB layer
| Module | Functions |
|---|---|
| `db::subscriptions` | `get_by_brand`, `create`, `update_status`, `update_stripe_ids` |
| `db::usage_records` | `create`, `list_by_brand` |
| `db::products` | New: `create_with_plan_check` — atomic plan-checked insert |
TOCTOU-safe registration
`create_with_plan_check` wraps the count check and the INSERT in a single transaction. The brand's `subscriptions` row is taken with `SELECT ... FOR UPDATE` at the top of the transaction, which serializes all concurrent product inserts for that brand at the row-lock level. Two concurrent requests at count = 499 / limit = 500 cannot both pass the check and both insert. Returns:
```rust
pub enum RegisterOutcome {
Created(Product),
LimitExceeded { count: i64, limit: i64 },
}
```
Couture (unlimited) skips the count entirely but still acquires the row lock — uniform lock semantics for future per-brand state writes.
API endpoints
| Endpoint | Behaviour |
|---|---|
| `POST /auth/register` | After creating the brand and user, inserts `(Atelier, Active)` into `subscriptions`. |
| `POST /v1/products` | Plan limit enforced atomically via `create_with_plan_check`. Exceeding returns 402 with `"product limit reached (N/M); upgrade your plan to register more"`. |
| `GET /v1/billing` | Returns `{ plan, status, product_count, product_limit, current_period_end, stripe_configured }`. Falls back to `(Atelier, Active)` for legacy brands without a subscription row. |
| `POST /v1/billing/create-checkout-session` | With Stripe configured: POSTs to `api.stripe.com/v1/checkout/sessions` with the brand id round-tripped via `client_reference_id`, returns the hosted URL. Without Stripe: returns a placeholder URL pointing back to `/billing?placeholder=1`. Stripe errors are logged via `tracing` and surface to the client as a generic `"payment provider error"`; Stripe response bodies are never echoed. |
| `POST /webhooks/stripe` | Public, no middleware, raw body via `axum::body::Bytes`. |
Webhook handler
`crates/api/src/routes/webhooks.rs`:
- Reads the raw body as `Bytes` before any JSON parsing.
- Validates the `Stripe-Signature` header per Stripe's documented `t=…,v1=…` scheme: HMAC-SHA256 over `{timestamp}.{raw_body}`, 5-minute timestamp tolerance, constant-time comparison via `subtle::ct_eq`.
- Only deserializes the JSON after the signature passes.
- Returns 503 when `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` are not set.
Event dispatch:
- `checkout.session.completed` → `update_stripe_ids` (round-trips `client_reference_id = brand_id`).
- `customer.subscription.updated` / `customer.subscription.deleted` → `update_status`. Plan is inferred from `items.data[0].price.id` by matching against `STRIPE_PRICE_ID_MAISON` / `STRIPE_PRICE_ID_COUTURE` env vars.
- Any other event type is acknowledged with 200 so Stripe doesn't retry.
Dependencies
`crates/api/Cargo.toml` gains:
- `hmac = "0.12"`
- `subtle = "2"` — constant-time hex comparison
- `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }`
No `async-stripe`, no `native-tls`, no system OpenSSL link.
Frontend
- `lib/api.ts` — `Plan`, `SubscriptionStatus`, `BillingResponse`, `getBilling()`, `createCheckoutSession()`.
- `app/(dashboard)/billing/` — server-rendered page with a client `BillingPanel` component. Renders the current plan badge with a status pill, a usage bar that tints red above 85% utilization, the billing period card, and a three-tier plan strip with Upgrade buttons. Honors `?session=success`, `?session=cancel`, and `?placeholder=1` for visual feedback after the Checkout round-trip. Surfaces a banner when `stripe_configured` is `false`.
- `components/sidebar.tsx` — new `Billing` nav item between API keys and Settings.
- `middleware.ts` — `/billing` added to the auth-protected matcher.
Operational notes
To enable real Stripe in any environment, set these env vars on the API service:
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `STRIPE_PRICE_ID_MAISON`
- `STRIPE_PRICE_ID_COUTURE`
Without them, billing endpoints run in placeholder mode. Postgres will auto-apply migrations 0006 and 0007 on next API start, including the back-fill of existing brands.
Verified gates
- `cargo clippy --workspace -- -D warnings` — clean.
- `cargo test -p api --bin genuflow` — 4/4 webhook signature tests pass (valid signature, tampered body, expired timestamp, wrong secret).
- `next build` — 29 routes, `/billing` server-rendered.
v0.3.0 — Phase 3: verify polish, resale transfers, marketing site
Overview
Phase 3 delivers the consumer-facing surface of Genuflow: a redesigned public verification page, a self-service resale/transfer flow, and a full marketing website at the root of the Next.js app.
Workstream 1 — Public verification page polish
Redesigned /verify/[product_id] for the consumer who scans a QR. The page now feels premium and luxury-brand-appropriate rather than developer-tool-appropriate.
- Editorial layout: eyebrow → authentication seal → brand wordmark → serif product name → intro.
- Centered authentication seal SVG (accent-tinted shield + check) signals confirmed vs awaiting anchor.
- Anchor card with structured
dl: Network, Block, Anchored timestamp, Transaction (truncated hash, opens Basescan in a new tab, external-link glyph), status pill (Confirmed / Pending / Not yet anchored). - Provenance timeline:
- Chronologically sorted, oldest to newest.
- Per-event-type SVG icons:
manufactured,inspected,shipped,sold,transferred. - Connector line between entries, accent ring when anchored.
- Surfaces
detail.location,detail.new_owner_email,detail.notesin a subtle inset card when present.
- Footer: "Powered by Genuflow" wordmark + tagline + monospace product ID for paste-anywhere lookups.
- New branded
not-found.tsxfor invalid/unknown product codes.
Workstream 2 — Resale / transfer flow
When a consumer scans a QR on a product that has already been sold, they can register themselves as the new owner directly from the verification page. The action is public and unauthenticated.
Backend
POST /verify/:product_id/transfer— new public endpoint, no middleware, wired alongside the existingGET /verify/:product_idincrates/api/src/routes/verify.rs.- Body:
{ new_owner_email: String, note: Option<String> }. - Server invariants:
- Product must exist (else 404).
- Product must have at least one prior
soldevent (else 400 — items not yet sold are not transferable). new_owner_emailnon-empty, contains@, ≤ 254 chars.note≤ 500 chars when present.
- Records a
Transferredevent withdetail = { new_owner_email, note }viadb::events::create. Returns the createdProvenanceEvent.
Frontend
frontend/lib/api.ts— newtransferOwnership(productId, body)calling through the same-origin Next.js proxy.frontend/components/transfer-section.tsx— client component withidle → open → submitting → success | errorstate machine.- Idle: CTA card "Register ownership transfer".
- Open: form with required email + optional 500-char note.
- Success: confirmation card with check-mark glyph, the new owner email, and "anchored on next batch" copy.
- Conditionally rendered on
/verify/[product_id]only whenevents.some(e => e.event_type === "sold").
Workstream 3 — Genuflow.com marketing website
Full marketing site under frontend/app/(marketing)/, dark-mode default, DM Serif Display + DM Sans, #1D9E75 accent, fashion-magazine editorial aesthetic. Real content throughout — no lorem-ipsum, no placeholders.
Pages
/— Hero (editorial eyebrow, oversized serif, dual CTA), right-column Unsplash image, "the principle" section, three-step "how it works", three use-case cards, two testimonial quotes, pricing preview, closing CTA./how-it-works— Five-step deep walkthrough (issue identity → record events → anchor every two hours → verify anywhere → transfer across resale market) + architecture split between Genuflow and Base./use-cases— Index linking to the three category pages./use-cases/luxury— Handbags, leather, ready-to-wear. OECD framing, what we record, where the tag lives, end-to-end outcomes./use-cases/fashion-textiles— Built for the EU Digital Product Passport. Regulation overview, full data model (composition / manufacture / use & care / end of life)./use-cases/watches-jewelry— Service history as product. What we record for watches vs jewelry, secondary-market outcomes./pricing— Three tiers: Atelier €0.40/product, Maison €0.28/product, Couture custom. Feature lists, five-question pricing FAQ./about— Mission, vision, four operating principles (brands own the relationship, the protocol outlives the company, no proprietary lock-in by default, multi-tenant means private), team note./blog— Index linking to three long-form articles./blog/eu-dpp-regulation— ~1,400 words on the ESPR, the DPP record's contents, the three non-negotiable properties (persistence, openness, tamper-evidence), why anchor to a public chain, what to build now./blog/counterfeit-statistics— ~1,000 words on OECD framing, why visual cues stopped working, the secondary-market multiplier, what actually works against counterfeit./blog/blockchain-authentication— ~1,400 words on cryptographic hashes, commitments, why a public chain, Merkle roots, what Genuflow actually anchors, what the technology does and does not guarantee./resources— Three whitepapers (DPP architecture, economics of authentication, migration from QR+DB) + three guides (integration playbook, tag-placement, transfer comms) + four open-standards cards (GS1 Digital Link, W3C Verifiable Credentials, ISO 3758, EU ESPR)./docs— Full API reference with sticky TOC, working curl quickstart, endpoint documentation for products, events, verify, transfer, and anchors. Authentication explainer, error reference, real TypeScript request/response shapes./contact— Direct lines (general / sales / press), offices, client-side contact form that composes a mailto.
Shared components
components/marketing/nav.tsx— Sticky, responsive, mobile drawer. Links: How it works, Use cases, Pricing, Journal, Resources, About + Sign in / Request access CTAs.components/marketing/footer.tsx— Five-column with Product / Use cases / Resources / Company.components/marketing/section.tsx—Eyebrow,SectionTitle,PageHero,CTABand.components/marketing/article.tsx— Editorial typography for blog posts, optional header image.components/marketing/code-block.tsx— Language-labelled monospace blocks.components/marketing/contact-form.tsx— Mailto-composing client form.components/marketing/hero-image.tsx—HeroImage,BannerImage(capped at 400px),ArticleHeaderImage.
Imagery
Unsplash images placed on the homepage hero, the three use-case banners, the about banner, and the three blog article headers.
Notes for operators
- The existing
app/page.tsx(which redirected/to/login) was removed so the marketing homepage takes over/. The dashboard is reachable via the Sign in link in the marketing nav. - The verify page's transfer flow runs entirely through the existing same-origin Next.js proxy — no CORS, no new edge configuration.
- The
BannerImagecomponent defaults toh-[320px] sm:h-[400px]per the approved design pass.
Verified gates
cargo clippy --workspace -- -D warningsclean.next buildclean — 28 routes total, all marketing routes statically prerendered.
v0.2.3 — Events, QR codes, sqlx 0.8 security upgrade
New features
/events — global event log
GET /v1/events?product_id=&event_type=&anchor_status=&limit=&offset=— paginated event listing scoped to the authenticated brand. All filters compose with AND.db::events::list_by_brand_filteredusesQueryBuilder<Postgres>for conditional WHERE clauses. Limit clamped to[1, 200], default 50.- Frontend
/eventspage (client component) — Product / Type / Anchor-status dropdown filters with reset, paginated table, links to product detail.
/qr-codes — per-product QR codes
GET /v1/products/:id/qr— returns a PNG QR encoding the public verification URL (PUBLIC_BASE_URL/verify/:id). 512×512 minimum, quiet zone enabled,Cache-Control: private, max-age=3600.- Tenant-scoped via
db::products::get_by_id_for_brand— a brand can't fetch QR codes for products it doesn't own. - Frontend
/qr-codespage — grid of cards, browser fetches each QR via the same-origin Next.js proxy, Download PNG link uses the HTMLdownloadattribute with filenamegenuflow-<product_id>.png. - New env var
PUBLIC_BASE_URL(defaulthttp://localhost:3000) wired throughAppState,.env.example, anddocker-compose.yml.
Split-screen auth
/loginand/registerget a two-column layout atmd+: marketing copy on the left (DM Serif hero, accent-colored anchored, three-bullet value prop, BSL footer), form panel on the right.- Mobile (
<md) stays single-column with the wordmark above the form. Forms themselves are unchanged. - All visual changes live in
components/auth-shell.tsx— page-level code untouched.
Security
sqlx 0.7.4 → 0.8.6 upgrade
- Closes RUSTSEC-2024-0363 — binary protocol misinterpretation in sqlx 0.7.x. Priority fix.
- Side benefit: the chronic
sqlx-postgres v0.7.4 future-incompat-reportwarning that trailed every clippy run for the prior six commits is gone. - Breaking changes encountered: one, config-only. The
runtime-tokiofeature in sqlx 0.8 no longer implies a TLS backend; you must picktls-rustlsortls-native-tlsexplicitly. We chosetls-rustls(pure-Rust, no system OpenSSL needed in the Docker slim base). Zero source-code changes were required acrosscommon,db,anchor, orapi— the runtime query API (sqlx::query,sqlx::query_as::<_, T>,QueryBuilder,FromRow,Type,migrate!) is binary-compatible for our usage. cargo clippy --workspace -- -D warningsclean (zero warnings, zero notes).
Audit results
cargo audit against the post-upgrade Cargo.lock:
rsa 0.9.10(RUSTSEC-2023-0071, medium 5.9) — present pre-upgrade and post-upgrade. Path:rsa → sqlx-mysql → sqlx-macros-core → sqlx-macros → sqlx. Compile-time-only: the macros crate usessqlx-mysqlfor cross-backend prepared-statement validation; that code is not linked into the runtime binary. No exploitable path through Genuflow — the Marvin Attack requires a TLS-server role that this code never plays. Upstream has no fixed release yet. Documented as a known carryover.- 3 unmaintained/unsound warnings on
derivative,paste,lru— all reached throughalloy 0.3. Will clear when alloy bumps to 0.4+. Tracked separately, not blocking.
No new vulnerabilities introduced by the sqlx bump.
Operational
docker compose up -dnow requires no manual env tweaks for the QR codes feature;PUBLIC_BASE_URLdefaults via${VAR:-http://localhost:3000}.- Sidebar nav adds "Events" and "QR codes" entries.
- No new migrations.
Verification
cargo clippy --workspace --all-targets -- -D warnings✓cargo audit— 1 finding, documented above, accepted carryover.tsc --noEmit✓next build— 14 routes generated cleanly (/eventsand/qr-codesjoined the bundle).
v0.2.1 — Phase 2 patch: dockerized local-dev + auth UX fixes
Patch release on top of v0.2.0 — same scope, fixes discovered during the first end-to-end local-dev bring-up.
Fixes
Dockerfile(new) — multi-stage build (rust:1.90-bookworm → debian:bookworm-slim). The api crate's compiledgenuflowbinary runs as the container CMD. SQLx migrations are embedded at compile time, so the runtime image carries no source..dockerignore(new) — keepstarget/,frontend/,.git/,.envand similar out of the build context.docker-compose.yml— addsJWT_SECRETandCOOKIE_SECUREto the api service env, threads sensible dev defaults through${VAR:-…}, remaps the db service host port to 5433 and the api host port to 8081 to avoid common local-port collisions, and uses the compose-network service namedbforDATABASE_URLinside the api container.frontend/lib/api.ts—request()previously calledres.json()on every non-204 success, which threwSyntaxErroragainst200 OK/201 Createdresponses with an empty body (login and register both fit this shape — they only set a cookie). Both flows surfaced as "Network error." in the UI even though the backend succeeded and the cookie was set. The helper now reads the body as text first and only parses if non-empty.frontend/app/register/page.tsx— the register form mapped every409 Conflictto "An account with that email already exists." But the backend returns 409 for bothemailand brand-slugcollisions with distinct messages. The page now surfaces the backend's message verbatim so the user knows which field to change.
Operational notes
After this release, the canonical local-dev bring-up is:
docker compose up -d
cd frontend && INTERNAL_API_URL=http://localhost:8081 npm run dev
…then http://localhost:3000/register.
Unchanged from v0.2.0
No backend behavior changes. No new migrations. No schema changes. Same per-brand anchor hash projection, same auth model, same endpoint surface. cargo clippy --workspace -- -D warnings and tsc --noEmit && next build remain clean.
v0.2.0 — Phase 2: Brand Dashboard
Phase 2 — shipped
Auth
POST /auth/register— creates brand (auto-slug) + first user, hashes password with argon2id, signs a session JWT.POST /auth/login— argon2id verify; constant-time dummy verify when user is missing (closes user-existence timing side channel).POST /auth/logout— clears the cookie.GET /auth/me— returns current user + brand, behind a session-cookie middleware.- JWT: HS256, 24h expiry, no refresh tokens.
- Cookie:
gf_session,HttpOnly; SameSite=Lax; Path=/; Max-Age=86400+Secure(gated byCOOKIE_SECUREenv, off for local HTTP dev). - Composed
/v1/*middleware accepts the JWT cookie orX-API-Key— dashboard and machine integrations share one route surface.
Dashboard pages
All pages dark-by-default, Tailwind only, no component library, DM Serif Display + DM Sans, accent #1D9E75:
/dashboard— product/event counts + last anchor + recent events/products— paginated list (50/page)/products/new— register product (name, external_ref, JSON metadata)/products/[id]— product card + provenance timeline (anchored vs pending dot)/api-keys— list, create (plain key shown once), revoke/anchors— anchor batch log with Basescan tx links/settings— brand + account read-only view/verify/[product_id]— public, mobile-optimized, no auth required
Next.js proxy (no CORS)
next.config.mjs rewrites /api/:path* to ${INTERNAL_API_URL ?? "http://localhost:8080"}/:path*. The browser only ever talks to the Next host, so the session cookie stays first-party. lib/api.ts::apiBase() resolves /api in the browser and the absolute backend URL on the server (server components can't proxy through themselves).
Per-brand anchor hash projection
New table anchor_brand_hashes(anchor_batch_id, brand_id, brand_hash) carries a SHA-256 over only that brand's events in the batch. After every confirmation (fresh submit and recovery-confirmed pending alike), the poller groups events by brand and inserts one row per (batch, brand) pair. Every endpoint that previously surfaced the global records_hash — GET /v1/anchors, GET /v1/dashboard.last_anchor, GET /verify/:id.latest_anchor — now returns BrandAnchorView with the brand-scoped brand_hash. Two brands sharing a batch can no longer correlate their anchor activity by comparing hashes.
Backend endpoints added for the dashboard
GET /v1/dashboard— aggregate metricsGET /v1/products— paginated list (limit,offset, clamped to 200)GET /v1/products/:id/events— chronological event list, tenant-scopedGET /v1/keys— list keys for the authenticated brandGET /v1/anchors— paginatedBrandAnchorViewlist
Migrations
0004_users.sql—userstable withbrand_idFK0005_anchor_brand_hashes.sql— per-brand hash projection withON DELETE CASCADEfrom bothanchor_batchesandbrands
Standards
#![deny(warnings)] workspace-wide, no unwrap/expect/println in production paths, tracing throughout, cargo clippy --workspace -- -D warnings clean. tsc --noEmit + next build clean across 12 routes + middleware.
v0.1.0 — Phase 1: Core API
Phase 1 — shipped
- Brand onboarding — admin endpoint to register a brand (name, slug).
- API key management — per-brand key issuance and revocation. Plain keys are returned once at creation; only SHA-256 hashes are persisted. Cross-tenant revocation is rejected.
- Product registry — products owned by a brand, with optional
external_refandmetadata. Tenant isolation enforced at the query layer. - Provenance events — typed events (
manufactured,inspected,shipped,sold,transferred) recorded against a product, with optional JSONB detail. - Public verification endpoint —
/verify/:product_idreturns aPublicProductprojection (noexternal_ref, nometadata), brand, full event timeline, and the latest confirmed anchor batch. - Base mainnet anchor service — background task on a 2-hour tick. Submits a self-transaction containing
keccak256("genuflow:anchor:v1") ++ records_hashand waits for 3 block confirmations before marking the batch final. - Idempotent submission + recovery — pending rows are persisted between broadcast and confirmation. On RPC drop or process restart, the next tick resumes via on-chain receipt lookup; if a tx is dropped from the mempool past TTL, the row is deleted (FK
ON DELETE SET NULLreleases its events) and the next tick re-broadcasts cleanly. No duplicate anchors under sustained RPC instability.
Workspace
crates/common— shared types andAppErrorcrates/db— SQLx queries and migrationscrates/anchor— Base anchor background servicecrates/api— Axum server, handlers, middleware
#![deny(warnings)] enforced workspace-wide. No unwrap, expect, or println in production paths. All errors propagate as AppError; tracing throughout. cargo clippy --workspace --all-targets -- -D warnings clean.