Skip to content

Releases: ChronoCoders/genuflow

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

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05

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, ...

Read more

v0.4.0 — Phase 4: billing infrastructure, Stripe-ready

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05

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

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05

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.notes in a subtle inset card when present.
  • Footer: "Powered by Genuflow" wordmark + tagline + monospace product ID for paste-anywhere lookups.
  • New branded not-found.tsx for 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 existing GET /verify/:product_id in crates/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 sold event (else 400 — items not yet sold are not transferable).
    • new_owner_email non-empty, contains @, ≤ 254 chars.
    • note ≤ 500 chars when present.
  • Records a Transferred event with detail = { new_owner_email, note } via db::events::create. Returns the created ProvenanceEvent.

Frontend

  • frontend/lib/api.ts — new transferOwnership(productId, body) calling through the same-origin Next.js proxy.
  • frontend/components/transfer-section.tsx — client component with idle → open → submitting → success | error state 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 when events.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 BannerImage component defaults to h-[320px] sm:h-[400px] per the approved design pass.

Verified gates

  • cargo clippy --workspace -- -D warnings clean.
  • next build clean — 28 routes total, all marketing routes statically prerendered.

v0.2.3 — Events, QR codes, sqlx 0.8 security upgrade

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05

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_filtered uses QueryBuilder<Postgres> for conditional WHERE clauses. Limit clamped to [1, 200], default 50.
  • Frontend /events page (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-codes page — grid of cards, browser fetches each QR via the same-origin Next.js proxy, Download PNG link uses the HTML download attribute with filename genuflow-<product_id>.png.
  • New env var PUBLIC_BASE_URL (default http://localhost:3000) wired through AppState, .env.example, and docker-compose.yml.

Split-screen auth

  • /login and /register get a two-column layout at md+: 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-report warning that trailed every clippy run for the prior six commits is gone.
  • Breaking changes encountered: one, config-only. The runtime-tokio feature in sqlx 0.8 no longer implies a TLS backend; you must pick tls-rustls or tls-native-tls explicitly. We chose tls-rustls (pure-Rust, no system OpenSSL needed in the Docker slim base). Zero source-code changes were required across common, db, anchor, or api — the runtime query API (sqlx::query, sqlx::query_as::<_, T>, QueryBuilder, FromRow, Type, migrate!) is binary-compatible for our usage.
  • cargo clippy --workspace -- -D warnings clean (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 uses sqlx-mysql for 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 through alloy 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 -d now requires no manual env tweaks for the QR codes feature; PUBLIC_BASE_URL defaults 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 (/events and /qr-codes joined the bundle).

v0.2.1 — Phase 2 patch: dockerized local-dev + auth UX fixes

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05

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 compiled genuflow binary runs as the container CMD. SQLx migrations are embedded at compile time, so the runtime image carries no source.
  • .dockerignore (new) — keeps target/, frontend/, .git/, .env and similar out of the build context.
  • docker-compose.yml — adds JWT_SECRET and COOKIE_SECURE to 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 name db for DATABASE_URL inside the api container.
  • frontend/lib/api.ts — request() previously called res.json() on every non-204 success, which threw SyntaxError against 200 OK / 201 Created responses 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 every 409 Conflict to "An account with that email already exists." But the backend returns 409 for both email and brand-slug collisions 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

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05

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 by COOKIE_SECURE env, off for local HTTP dev).
  • Composed /v1/* middleware accepts the JWT cookie or X-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 metrics
  • GET /v1/products — paginated list (limit, offset, clamped to 200)
  • GET /v1/products/:id/events — chronological event list, tenant-scoped
  • GET /v1/keys — list keys for the authenticated brand
  • GET /v1/anchors — paginated BrandAnchorView list

Migrations

  • 0004_users.sql — users table with brand_id FK
  • 0005_anchor_brand_hashes.sql — per-brand hash projection with ON DELETE CASCADE from both anchor_batches and brands

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

Choose a tag to compare

@ChronoCoders ChronoCoders released this 02 Oct 23:05

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_ref and metadata. 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_id returns a PublicProduct projection (no external_ref, no metadata), 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_hash and 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 NULL releases its events) and the next tick re-broadcasts cleanly. No duplicate anchors under sustained RPC instability.

Workspace

  • crates/common — shared types and AppError
  • crates/db — SQLx queries and migrations
  • crates/anchor — Base anchor background service
  • crates/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.