From 79d0dccb43ef3e8702ea9c6551e7765ed5f42ae8 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Tue, 21 Apr 2026 02:43:52 -0500 Subject: [PATCH 01/13] docs(fractal): add FractalCronJob CRD to architecture overview Document the new FractalCronJob resource, its spec fields, status, and note that FractalService serviceType: cronJob is deprecated in favor of the dedicated CRD. --- fractal/architecture.md | 227 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 fractal/architecture.md diff --git a/fractal/architecture.md b/fractal/architecture.md new file mode 100644 index 0000000..ddb80cc --- /dev/null +++ b/fractal/architecture.md @@ -0,0 +1,227 @@ +# Fractal Architecture Overview + +Fractal is a Kubernetes-native application deployment platform — a FOSS alternative to Railway. It consists of three components: the **CLI**, the **operator**, and the **dashboard**. + +## Cluster Architecture + +### Reference deployment (Omni production) + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| OS | Talos Linux | Immutable, API-managed, no SSH, minimal attack surface | +| CNI | Cilium | eBPF networking, replaces kube-proxy, Gateway API, Hubble observability | +| Ingress | Cilium Gateway API | Native Gateway API with eBPF acceleration, L7 visibility | +| Storage | Hetzner CSI (hcloud-csi) | Network-attached SSD persistent volumes | +| TLS | cert-manager + Let's Encrypt | DNS-01 via Cloudflare, auto-provisioned wildcard certs | +| DNS/CDN | Cloudflare | `*.fractal.omni.dev` wildcard, proxied | +| Load balancer | Hetzner LB | TCP forwarding to Cilium Gateway NodePorts (80->30640, 443->32596) | +| Databases | CloudNativePG | HA Postgres with streaming replication, auto-failover, PgBouncer | + +### Compute (Omni production) + +| Node | Hetzner Type | Specs | Cost | +|------|-------------|-------|------| +| control-1 | CPX31 | 4 vCPU, 8 GB RAM, 160 GB | ~$15/mo | +| control-2 | CPX31 | 4 vCPU, 8 GB RAM, 160 GB | ~$15/mo | +| control-3 | CPX31 | 4 vCPU, 8 GB RAM, 160 GB | ~$15/mo | +| Hetzner LB | LB11 | | ~$6/mo | + +~18 GB usable for workloads after system overhead. + +## Custom Resource Definitions (CRDs) + +Fractal defines the following CRDs in the `fractal.omni.dev` API group (version `v1alpha1`): + +### FractalProject (`fproj`) + +Top-level organizational unit. Each project maps to a Kubernetes namespace (`fractal-{name}`). + +**Spec:** +- `displayName` -- human-readable name +- `description` -- optional description +- `owner` -- owner email or HIDRA user ID + +**Status:** +- `phase` -- `Pending`, `Active`, `Suspended`, `Deleting` +- `namespace` -- created namespace name +- `serviceCount` -- number of services in the project + +### FractalService (`fsvc`) + +A deployable application within a project. + +**Spec:** +- `project` -- parent project name +- `source` -- either `git` (url + branch) or `image` (repository + tag) +- `build` -- build mode (`auto`, `dockerfile`, `none`) + optional Dockerfile path +- `deploy` -- replicas, env, resources, health check, volumes, schedule, autoscale +- `expose` -- port, domain, TLS +- `serviceType` -- `web`, `worker`, `cronJob`, `staticSite` (`cronJob` is deprecated in favor of the dedicated `FractalCronJob` CRD) + +**Status:** +- `phase` -- `Pending`, `Building`, `Deploying`, `Running`, `Failed` +- `readyReplicas` / `desiredReplicas` +- `lastBuildTime`, `currentImage`, `url` + +### FractalCronJob (`fcj`) + +A scheduled task within a project. Reconciled into a Kubernetes CronJob. + +**Spec:** +- `project` -- parent project name +- `source` -- either `git` (url + branch) or `image` (repository + tag) +- `build` -- build mode (`auto`, `dockerfile`, `none`) +- `schedule` -- cron expression (e.g. "0 3 * * *") +- `command`, `args` -- container entrypoint overrides +- `env` -- environment variables (literal or secret refs) +- `resources` -- CPU/memory requirements +- `concurrencyPolicy` -- `allow`, `forbid`, or `replace` +- `startingDeadlineSeconds` -- grace period for missed schedules +- `suspend` -- pause scheduling without deleting the resource +- `backoffLimit` -- retries before marking a job as failed + +**Status:** +- `phase` -- `Pending`, `Building`, `Active`, `Suspended` +- `lastScheduleTime`, `lastSuccessfulTime` +- `currentImage`, `activeJobs` + +### Planned CRDs + +| CRD | Purpose | Backed by | +|-----|---------|-----------| +| `FractalDatabase` | Managed Postgres databases | CloudNativePG Cluster + K8s Secret | +| `FractalCache` | Managed cache instances | Valkey/Dragonfly StatefulSet | + +## Reconciliation flow + +### FractalProject controller + +1. Receive `FractalProject` create/update event +2. Create the target namespace (`fractal-{name}`) if it doesn't exist +3. Count `FractalService` resources in the namespace +4. Update status (`Active`, namespace, service count) + +On delete (via finalizer): +1. Delete all `FractalService` resources in the namespace +2. Delete the namespace + +### FractalService controller + +1. Receive `FractalService` create/update event +2. **Build phase** (git sources only): + - Create a Kaniko `Job` to build the container image + - Monitor Job status; requeue until complete + - For localhost registries, pass `--insecure` to Kaniko +3. **Deploy phase** (by service type): + - **Web / Worker / StaticSite**: Apply `Deployment`, optional `Service`, optional `HTTPRoute` + - **CronJob**: Apply `CronJob` (no Service/HTTPRoute) +4. **PVCs**: Create `PersistentVolumeClaim` resources before workloads +5. **HPA**: Apply `HorizontalPodAutoscaler` if autoscale config is present +6. **TLS**: Auto-create cert-manager `Certificate` per HTTPRoute when TLS is enabled +7. Update status (phase, replica counts, current image, URL) +8. Requeue: 300s if Running, 10s if Deploying/Building + +On delete (via finalizer): +1. Delete Deployment, build Job, CronJob, HPA, Service, HTTPRoute, Certificate, and PVCs + +## Kubernetes resources per service type + +| Service type | Deployment | CronJob | Service | HTTPRoute | HPA | PVCs | Certificate | +|-------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| Web | x | | x (if expose) | x (if domain) | x (if autoscale) | x (if volumes) | x (if TLS) | +| Worker | x | | | | x (if autoscale) | x (if volumes) | | +| CronJob | | x | | | | | | +| StaticSite | x | | x (if expose) | x (if domain) | x (if autoscale) | x (if volumes) | x (if TLS) | + +## Installation modes + +| Mode | Command | Cluster | Registry | Use case | +|------|---------|---------|----------|----------| +| Quick start | `fractal install --distribution k3s` | k3s | Configurable | Local dev, homelab, single-node | +| Production | `fractal install --distribution talos --provider hetzner` | Talos Linux | Configurable | Teams running real workloads | +| BYOC | `fractal install --existing-cluster` | Any K8s | Configurable | GKE, EKS, AKS, self-managed | +| Local dev | `fractal install --distribution k3d` | k3d + local registry | `k3d-{name}-registry.localhost:5000` | Local development | + +All modes install: CRDs, operator, dashboard, CloudNativePG, cert-manager, metrics-server (each if not already present). + +Full provisioning modes (Talos/k3s) additionally install: Cilium CNI, CSI driver, Gateway API CRDs, DNS configuration. + +## Platform stack + +Components installed by `fractal install` (full provisioning): + +``` +Cilium -> Gateway API CRDs -> hcloud-csi -> cert-manager -> metrics-server -> CloudNativePG -> fractal-operator +``` + +The operator and dashboard run in the `fractal-system` namespace. + +## Dashboard data flow + +The dashboard is a React SPA served by nginx. + +**In-cluster (production):** +1. Browser loads static assets from nginx +2. API requests (`/api/*`, `/apis/*`) are proxied by nginx to the Kubernetes API server +3. Nginx reads the ServiceAccount token and sets the `Authorization` header +4. The dashboard queries `FractalProject` and `FractalService` resources via the K8s API + +**Local development:** +1. `bun dev` starts Vite dev server +2. Vite proxies `/api` and `/apis` to `kubectl proxy` on `localhost:8001` +3. `kubectl proxy` authenticates with the user's kubeconfig + +## Metrics and monitoring + +The operator exposes Prometheus metrics on port 8081 at `/metrics`: + +| Metric | Type | Description | +|--------|------|-------------| +| `reconcile_total` | counter | Total reconciliation attempts (labels: `resource`, `result`) | +| `reconcile_duration_seconds` | histogram | Reconciliation latency (labels: `resource`, `result`) | +| `active_services` | gauge | Number of active FractalService resources | +| `active_builds` | gauge | Number of active build jobs | + +A `ServiceMonitor` and Grafana dashboard are provided in the operator manifests. + +## Control plane layering + +``` +Pulumi (Mosaic) -> cloud resources (nodes, LB, DNS, Stripe, GitHub) +Flux / Pulumi -> cluster platform components (operators, CNI, cert-manager, monitoring) +Fractal operator -> application workloads (projects, services, databases, caches) +``` + +Clean separation: Pulumi owns infrastructure, Flux/Pulumi owns platform, Fractal owns apps. + +## Component diagram + +``` + +-----------+ + | fractal | + | CLI | + +-----+-----+ + | + kubectl / kube API + | + +-----v-----+ + | Kubernetes| + | API Server| + +-----+-----+ + | + +--------------+--------------+ + | | + +------v------+ +-------v-------+ + | FractalProject | | FractalService | + | Controller | | Controller | + +------+------+ +-------+-------+ + | | + create namespace create Deployment, + Service, HTTPRoute, + Certificate, HPA, PVC + | | + +------v------------------------------v------+ + | fractal-system | + | operator | dashboard | metrics service | + +---------------------------------------------+ +``` From 1c61bc5cacefaf1ee045829a3251dd2d6075b4ca Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Sat, 16 May 2026 11:21:42 -0600 Subject: [PATCH 02/13] chore: extend org-wide Renovate config Switches `extends` from `config:recommended` to `github>omnidotdev/.github` so this repo inherits the org-wide Renovate policy (weekend-only schedule, grouped non-major updates, auto-merge for low-risk bumps). --- renovate.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index 80bde9f..b90b806 100644 --- a/renovate.json +++ b/renovate.json @@ -1,4 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended", "customManagers:biomeVersions"] + "extends": [ + "github>omnidotdev/.github", + "customManagers:biomeVersions" + ] } From 130276a3211eb4e42251d60fafad67687fd13ef4 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Mon, 18 May 2026 12:42:47 -0600 Subject: [PATCH 03/13] docs(blink): add getting-started, link from index --- .../docs/kindred/blink/getting-started.mdx | 57 +++++++++++++ content/docs/kindred/blink/index.mdx | 81 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 content/docs/kindred/blink/getting-started.mdx create mode 100644 content/docs/kindred/blink/index.mdx diff --git a/content/docs/kindred/blink/getting-started.mdx b/content/docs/kindred/blink/getting-started.mdx new file mode 100644 index 0000000..6000c5d --- /dev/null +++ b/content/docs/kindred/blink/getting-started.mdx @@ -0,0 +1,57 @@ +--- +title: Getting Started +description: Sign up for Blink, create your first profile, add links, and share your branded landing page. +--- + +This guide walks you from a brand-new account to a published profile in under five minutes. If you intend to run Blink on your own hardware, jump to [Self-Hosting](/kindred/blink/self-hosting). + +## Create an account + +1. Visit [blink.omni.dev](https://blink.omni.dev) and click **Sign In**. +2. You will be redirected to `identity.omni.dev` to authenticate with Google, GitHub, or any OIDC provider configured by your organization. +3. After signing in, you land on the Blink dashboard. A default organization is created for you on first sign-in. + +## Create your first profile + +A profile is the public page people see when they visit `blink.omni.dev/u/`. + +1. From the dashboard, click **New Profile**. +2. Pick a **username** (this becomes the URL slug), a **display name**, and optionally a short bio. +3. Upload an avatar if you have one handy. +4. Click **Save**. Your profile is now live at `https://blink.omni.dev/u/`. + +Free accounts can have unlimited profiles, but each profile is capped at five links. Profiles above the free tier inherit your organization's plan. + +## Add links + +1. Open your profile and click **Add link**. +2. Choose the link **type** (regular link, short link, or section header). +3. Paste the destination URL, set a label, and optionally an icon. +4. Drag to reorder. Toggle visibility per link. + +Short links count against your plan's monthly quota (`max_short_links_per_month`); regular links count against `max_links`. See [Pricing](/kindred/blink/pricing) for current limits. + +## Customize the look + +Pro and Team plans unlock custom themes: + +- Open profile settings, click **Theme**, and pick a preset or define your own colors, fonts, and background. +- Team plans can also configure a **custom domain** (e.g., `links.yourbrand.com`) and remove the "Powered by Blink" footer. + +Free profiles always show the Blink footer and use the default theme. + +## Generate a QR code + +1. From the dashboard or any link's detail page, click **Generate QR**. +2. Pick the size, error-correction level, and optional logo overlay. +3. Download the SVG or PNG. The QR resolves through Blink so you can swap the destination later without reprinting. + +## Sharing and analytics + +Every profile has a canonical URL (`blink.omni.dev/u/`) and an OG image for previews in social apps. Analytics (per-link click counts, geo, referrers) ship on Pro and Team plans. The Free tier shows a "Coming soon" placeholder on the analytics page. + +## Next steps + +- [Pricing](/kindred/blink/pricing): plan comparison and what each entitlement unlocks +- [API Reference](/kindred/blink/api): GraphQL endpoint, REST helpers, webhooks +- [Self-Hosting](/kindred/blink/self-hosting): run Blink inside your own infrastructure diff --git a/content/docs/kindred/blink/index.mdx b/content/docs/kindred/blink/index.mdx new file mode 100644 index 0000000..32cd459 --- /dev/null +++ b/content/docs/kindred/blink/index.mdx @@ -0,0 +1,81 @@ +--- +title: 🖇️ Blink +description: One link to bind them all. Centralize your online identity, share branded links, and manage QR codes from a single dashboard. +--- + +import { ProductOverview } from "@/components/docs"; +import app from "@/lib/config/app.config"; +import { FaIdCard, FaLink, FaQrcode } from "react-icons/fa"; + +, + className: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300", + }, + { + label: "URL Shortener", + icon: , + className: "bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300", + }, + { + label: "QR Codes", + icon: , + className: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300", + }, + ]} + links={[ + { + href: "https://blink.omni.dev", + label: "Visit Website", + type: "website", + }, + { + href: `${app.socials.github}/blink`, + label: "Visit Repository", + type: "repository", + }, + ]} + alerts={[ + { + title: "What is Blink?", + description: + "A unified link-in-bio platform with branded short links, custom themes, and per-organization workspaces. Apache 2.0 licensed and self-hostable.", + }, + ]} +/> + +**Omni Blink** is a link-in-bio and short-link platform. Create a single shareable profile that points at every place you live online, generate branded QR codes, and shorten links with custom domains. + +## Why Blink? + +- **One profile, many facets**: a single public page that organizes every link you want to share +- **Branded short links**: per-organization short links with optional custom domains +- **Custom themes**: pick a preset or design your own to match your brand +- **Workspaces**: organizations and team seats backed by Omni's identity platform +- **Self-hostable**: ship it inside your own infrastructure with the official Compose stack + + + + Sign up, create your first profile, add links, and publish in under five minutes + + + Run Blink inside your own infrastructure with Docker Compose + + + Free, Pro, and Team plans with entitlements enforced by Aether + + + GraphQL endpoint, REST helpers, authentication, and rate limits + + + +## Architecture + +Blink runs as two services backed by PostgreSQL: + +- `blink-api`: Elysia + PostGraphile GraphQL backend with Drizzle migrations +- `blink-app`: TanStack Start frontend (React 19) served as the public-facing application + +The hosted version uses Omni's Identity (`identity.omni.dev`) for OAuth, Aether for billing entitlements, Warden for authorization, and the Fractal cluster for deployment. Self-hosters can wire any subset of these or run Blink standalone. From 603d3f156aa78fd4f7ebab6c08b8b16d0e57fa41 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Mon, 18 May 2026 21:42:52 -0600 Subject: [PATCH 04/13] docs(blink): add api, pricing, self-hosting; refresh getting-started + index for launch --- content/docs/kindred/blink/api.mdx | 69 ++++++++++++++ .../docs/kindred/blink/getting-started.mdx | 2 +- content/docs/kindred/blink/index.mdx | 4 +- content/docs/kindred/blink/pricing.mdx | 40 ++++++++ content/docs/kindred/blink/self-hosting.mdx | 92 +++++++++++++++++++ 5 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 content/docs/kindred/blink/api.mdx create mode 100644 content/docs/kindred/blink/pricing.mdx create mode 100644 content/docs/kindred/blink/self-hosting.mdx diff --git a/content/docs/kindred/blink/api.mdx b/content/docs/kindred/blink/api.mdx new file mode 100644 index 0000000..58b0a5f --- /dev/null +++ b/content/docs/kindred/blink/api.mdx @@ -0,0 +1,69 @@ +--- +title: API Reference +description: GraphQL endpoint, REST helpers, authentication, and rate limits for the Blink API. +--- + +The Blink API is served by a single Elysia process. It exposes a GraphQL endpoint, a small set of REST helpers, and standard health probes. + +## Base URL + +Hosted: `https://api.blink.omni.dev` + +Self-hosted: whatever `API_BASE_URL` is set to in your Compose environment (default `http://localhost:4000`). + +## Authentication + +Authenticated requests carry a JWT bearer token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Tokens are issued by Omni Identity (`identity.omni.dev`) via the OAuth 2.0 authorization code flow with PKCE. The frontend handles token acquisition and refresh; programmatic clients can mint tokens via the standard Identity API. + +Public read paths (such as `/api/profile/by-username/:username/branding`) do not require authentication. + +## GraphQL + +Endpoint: `POST /graphql` + +Schema introspection is **disabled in production**. To explore the schema, run `bun graphql:generate` in the API service to write the SDL to disk, or query a development instance where introspection is enabled. + +Common operations: + +- `query profileByUsername(username: String!)` to fetch a public profile and its tree +- `mutation createProfile`, `updateProfile`, `deleteProfile` +- `mutation createLink`, `updateLink`, `deleteLink` +- `mutation createTreeNode`, `updateTreeNode`, `deleteTreeNode` + +All write mutations enforce Aether entitlements and Warden authorization checks. + +## REST Helpers + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| `GET` | `/health` | none | Liveness probe | +| `GET` | `/ready` | none | Readiness probe (checks DB) | +| `GET` | `/api/profile/by-username/:username/branding` | none | Returns `{ removeBranding: boolean }` for a public profile's organization | +| `GET` | `/api/entitlements/check?feature=&organizationId=` | bearer | Returns `{ allowed: boolean }` for the given feature key | +| `GET` | `/api/billing/prices` | none | Returns the live Stripe price catalog for Blink, including marketing features per tier | +| `POST` | `/api/billing/checkout` | bearer | Creates a Stripe Checkout session for the requested tier and returns the redirect URL | +| `POST` | `/api/billing/portal` | bearer | Creates a Stripe Customer Portal session for self-serve subscription management | + +## Webhooks + +The API receives webhooks from related services. Each path verifies its own HMAC signature. + +| Path | Source | Purpose | +|------|--------|---------| +| `/webhooks/entitlements` | Aether | Invalidate cached entitlements on plan changes | +| `/webhooks/authz` | Vortex | Invalidate authorization tuples on org membership changes | +| `/webhooks/idp` | Gatekeeper | Sync user records on identity events | + +## Rate Limiting + +The API rate limits at **100 requests per minute per IP** (Elysia rate-limit plugin). Authenticated endpoints inherit the same limit. For higher throughput, contact us about Enterprise plans. + +## CORS + +The API only accepts cross-origin requests from origins listed in `CORS_ALLOWED_ORIGINS`. The hosted app (`https://blink.omni.dev`) is allowed by default in production. diff --git a/content/docs/kindred/blink/getting-started.mdx b/content/docs/kindred/blink/getting-started.mdx index 6000c5d..6e181d9 100644 --- a/content/docs/kindred/blink/getting-started.mdx +++ b/content/docs/kindred/blink/getting-started.mdx @@ -20,7 +20,7 @@ A profile is the public page people see when they visit `blink.omni.dev/u/`. -Free accounts can have unlimited profiles, but each profile is capped at five links. Profiles above the free tier inherit your organization's plan. +Free organizations are capped at ten links total across all profiles, plus 10 short links per month. Pro raises the link cap to 20 and 100 short links per month, Team removes both caps. See [Pricing](/kindred/blink/pricing) for the full breakdown. ## Add links diff --git a/content/docs/kindred/blink/index.mdx b/content/docs/kindred/blink/index.mdx index 32cd459..f37e60a 100644 --- a/content/docs/kindred/blink/index.mdx +++ b/content/docs/kindred/blink/index.mdx @@ -1,6 +1,6 @@ --- title: 🖇️ Blink -description: One link to bind them all. Centralize your online identity, share branded links, and manage QR codes from a single dashboard. +description: One link, infinite facets. Centralize your online identity, share branded links, and manage QR codes from a single dashboard. --- import { ProductOverview } from "@/components/docs"; @@ -32,7 +32,7 @@ import { FaIdCard, FaLink, FaQrcode } from "react-icons/fa"; type: "website", }, { - href: `${app.socials.github}/blink`, + href: `${app.socials.github}/blink-stack`, label: "Visit Repository", type: "repository", }, diff --git a/content/docs/kindred/blink/pricing.mdx b/content/docs/kindred/blink/pricing.mdx new file mode 100644 index 0000000..bf44b9e --- /dev/null +++ b/content/docs/kindred/blink/pricing.mdx @@ -0,0 +1,40 @@ +--- +title: Pricing +description: Tier breakdown and entitlement enforcement for Blink. +--- + +Blink offers a free tier for individuals, paid tiers for power users and teams, and self-hosting for anyone who would rather run the stack themselves. Entitlements are enforced at the API by Aether and surfaced through the app. + +## Plans + +Pricing and tier definitions are owned by the [omni-api product catalog](https://github.com/omnidotdev/omni-api/blob/master/services/api/src/lib/db/catalog/planConfigs.ts) under the `blink` key. The values below mirror that source of truth. + +| Plan | Monthly | Yearly | Links | Short links per month | Custom themes | Custom domain | Remove "Powered by Blink" | Analytics | +|------|---------|--------|-------|-----------------------|----------------|----------------|---------------------------|-----------| +| Free | $0 | $0 | 10 | 10 | No | No | No | No | +| Pro | $3 / mo | $27 / yr | 20 | 100 | Yes | No | No | Basic | +| Team | $8 / mo | $72 / yr | Unlimited | Unlimited | Yes | Yes | Yes | Advanced | + +Yearly billing applies a 25% discount over the monthly rate. For enterprise terms, dedicated support, or on-prem deployments, [contact us](mailto:support@omni.dev). + +## Entitlement Enforcement + +Each entitlement is enforced server-side. The relevant feature keys map to checks in `services/blink-api/src/lib/entitlements`: + +| Feature key | Where it's enforced | +|-------------|---------------------| +| `max_links` | Link create mutation | +| `max_short_links_per_month` | Short link create mutation | +| `custom_themes` | Profile update mutation when `customTheme` is set; also drives the analytics gate | +| `remove_branding` | Public profile renderer (hides the badge) | +| `custom_domain` | Custom domain settings tab | + +When Aether is unreachable, the API falls back to free-tier defaults rather than failing requests. This keeps the app responsive during partial outages. + +## Self-Hosting + +Self-hosters do not pay anything to use Blink. The Apache 2.0 license covers commercial use, modification, and redistribution. Aether integration is optional. See [self-hosting](/kindred/blink/self-hosting) for setup. + +## Billing + +Hosted plans are billed monthly via Aether. Invoices, seat changes, and cancellations are handled in the Omni Console. diff --git a/content/docs/kindred/blink/self-hosting.mdx b/content/docs/kindred/blink/self-hosting.mdx new file mode 100644 index 0000000..4e294fc --- /dev/null +++ b/content/docs/kindred/blink/self-hosting.mdx @@ -0,0 +1,92 @@ +--- +title: Self-Hosting +description: Run Blink inside your own infrastructure with Docker Compose. +--- + +Blink ships an Apache 2.0 licensed reference deployment via Docker Compose. The metarepo at [`omnidotdev/blink-stack`](https://github.com/omnidotdev/blink-stack) provides everything needed to bring up the full stack. + +## Prerequisites + +- Docker (or any OCI runtime that speaks `compose` v2) +- A registered OAuth application if you want federated sign-in +- A reachable public hostname for the app and API if not running on localhost + +## Services + +The bundled `compose.yaml` defines three services. `api` and `app` are built from source on first `docker compose up`; subsequent runs use the cached images. + +| Service | Image | Default Port | +|---------|-------|--------------| +| `db` | `postgres:18-alpine` | `5432` | +| `api` | `blink-api:latest` (built from `./services/blink-api`) | `4000` | +| `app` | `blink-app:latest` (built from `./services/blink-app`) | `3000` | + +The API runs migrations on boot, then serves GraphQL at `/graphql` and REST helpers under `/api/`. The app is a TanStack Start server that proxies API calls to the API service. + +## Required Environment Variables + +Configure these in a `.env` next to `compose.yaml`. A starter file is included as `.env.local.template`. + +| Variable | Purpose | +|----------|---------| +| `DB_PASSWORD` | PostgreSQL password used by the API to connect to `db` | +| `AUTH_SECRET` | Session signing secret. Generate with `openssl rand -hex 32` | + +## Optional Environment Variables + +| Variable | Purpose | +|----------|---------| +| `APP_BASE_URL` | Public URL of the app, used for CORS and OAuth callbacks. Defaults to `http://localhost:3000` | +| `API_BASE_URL` | URL the app uses to reach the API. Defaults to `http://api:4000` | +| `DB_NAME`, `DB_USER`, `DB_PORT` | Postgres overrides | +| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Enable Google OAuth | +| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | Enable GitHub OAuth | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Generic OIDC provider | + +If no OAuth provider is configured, sign-in features are disabled. The app boots successfully with only `DB_PASSWORD` and `AUTH_SECRET` set. + +## Running + +```sh +git clone https://github.com/omnidotdev/blink-stack.git +cd blink-stack +cp services.yaml.template services.yaml +cp .env.local.template .env +# edit .env to set DB_PASSWORD and AUTH_SECRET +docker compose up -d --build +``` + +The `--build` flag is only needed the first time; subsequent `docker compose up` runs reuse the cached images. + +The API is reachable at `http://localhost:4000`, the app at `http://localhost:3000`. + +## Diagnostics + +Verify each service is healthy: + +```sh +curl http://localhost:4000/health +curl http://localhost:4000/ready +curl http://localhost:3000 +``` + +`GET /ready` returns `503` if the database is not reachable. Logs are available via `docker compose logs -f api app`. + +## Volumes + +`db_data` is the only declared volume and persists Postgres state. Back it up before any image upgrade. + +## Optional Integrations + +Blink can integrate with other Omni services if those URLs are configured: + +- `BILLING_BASE_URL` and `BILLING_SERVICE_API_KEY`: enable Aether-backed entitlements (otherwise free-tier defaults apply) +- `AUTHZ_API_URL`: enable Warden authorization checks (otherwise allow-by-default within the app) +- `VORTEX_API_URL`: enable cross-product event delivery +- `MEILISEARCH_URL` and `MEILISEARCH_MASTER_KEY`: enable search indexing + +If these are unset, the API logs a warning at boot and continues with the corresponding feature disabled. + +## License + +Blink is licensed under Apache 2.0. See `LICENSE.md` in the metarepo for details. From 3bb9de95c08efb57a070e467ff173d3046cddc1e Mon Sep 17 00:00:00 2001 From: Brian Cooper <20056195+coopbri@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:57:17 -0700 Subject: [PATCH 05/13] docs(backfeed): document AI-native feedback direction as planned (#20) Add planned sections for the Signal model, multi-source ingestion, AI triage, product reviews, and the close-the-loop flow with Runa. All new capabilities are clearly marked (Planned). Unlaunched products are not named. --- content/docs/core/backfeed.mdx | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/content/docs/core/backfeed.mdx b/content/docs/core/backfeed.mdx index 6f95ef1..3e44cdc 100644 --- a/content/docs/core/backfeed.mdx +++ b/content/docs/core/backfeed.mdx @@ -61,6 +61,8 @@ import { **Omni Backfeed** is a user feedback reporting platform. Backfeed aggregates user data for your products, services, and experiences, providing a centralized location to iterate on your projects with user-driven insights and planning. +Backfeed is evolving from a feedback board into an **AI-native feedback brain**: it keeps the public board, voting, and roadmap at its core while learning to ingest feedback from many sources and triage it automatically. The board stays the source of truth, and human votes still set priority. AI handles the manual labor of sorting, not the judgment. See [AI & Multi-Source Feedback (Planned)](#ai--multi-source-feedback-planned). + ## Terminology | Term | Description | @@ -79,9 +81,28 @@ import { - **Roadmap Planning**: Communicate product direction by turning posts into actionable roadmap items. - **User Voting & Discussion**: Each post includes upvotes/downvotes and a community discussion section for comments. - **Public & Private Boards**: Control project visibility with a per-project toggle. +- **Multi-Source Ingestion**: Capture feedback from in-app widgets, email, social mentions, app-store reviews, and chat channels, all funneled into the same boards (planned). +- **AI Triage**: Automatically tag, cluster, dedupe, and run sentiment analysis on incoming feedback so teams skip the manual sorting (planned). +- **Product Reviews**: Collect star-rated reviews alongside feature requests as a distinct feedback type (planned). - **Analytics & Insights**: Gain deep insights into user needs and engagement trends (planned). - **API Access**: Integrate Backfeed seamlessly into your existing infrastructure with a powerful API (planned). +## AI & Multi-Source Feedback (Planned) + +Backfeed is being extended around a single new concept, the **Signal**: any inbound piece of user input, regardless of source. A Signal that is a feature request or bug becomes (or merges into) a Post, exactly as today, so the board, votes, and roadmap are unchanged. Reviews, emails, and social mentions are simply Signals with a different source and type. + +Planned capabilities: + +- **Multi-source ingestion**: pull feedback from in-app widgets, email, social mentions, app-store reviews, and chat channels into the same boards +- **AI triage**: automatically tag, cluster, dedupe, and score sentiment on incoming feedback +- **Moderation**: filter public and ingested submissions before they reach a board + +Every integration is opt-in and degrades gracefully: with no AI configured, manual tagging still works; with no ingestion connectors, the in-app widget still works. Human voting always remains the prioritization layer. + +## Closing the Loop with Runa (Planned) + +When a feature request is accepted, Backfeed can hand it to **[Runa](/core/runa)** as a task. When that task ships, Backfeed notifies the original reporters that their feedback was delivered, the single most effective way to keep feedback flowing. The Backfeed to Runa flow runs over CloudEvents, no manual status-syncing required. + ## Board Visibility Backfeed projects are **public by default** — this is intentional, since the primary use case is collecting feedback from external users who may not have accounts. @@ -114,11 +135,11 @@ When a project is public: ## Integrations (Planned) -Backfeed plans to support integrations with popular tools like: +Backfeed plans to support integrations across the Omni ecosystem and beyond: +- **Runa** for turning accepted feedback into tracked work (see [Closing the Loop](#closing-the-loop-with-runa-planned)) +- Additional feedback sources and AI triage (see [AI & Multi-Source Feedback](#ai--multi-source-feedback-planned)) - GitHub -- Linear -- Jira ## API & Webhooks (Planned) From fc4dfd1b4c44e970643bfa4a65eb71c0d776be0a Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Thu, 4 Jun 2026 15:07:08 -0700 Subject: [PATCH 06/13] docs(fractal): document autoscaling + scale-to-zero Add an Autoscaling section to the Fractal configuration docs covering the CPU-based HPA and the new request-driven scaleToZero mode (idle apps sleep, wake on first HTTP request via the KEDA HTTP add-on), including requirements and cold-start behaviour. --- content/docs/grid/fractal/configuration.mdx | 203 ++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 content/docs/grid/fractal/configuration.mdx diff --git a/content/docs/grid/fractal/configuration.mdx b/content/docs/grid/fractal/configuration.mdx new file mode 100644 index 0000000..459c328 --- /dev/null +++ b/content/docs/grid/fractal/configuration.mdx @@ -0,0 +1,203 @@ +--- +title: Configuration +description: fractal.toml reference +--- + +Fractal projects are configured with a `fractal.toml` file. Run `fractal init ` to generate a starter template. + +## Project + +```toml +[project] +name = "my-app" # Project name (used for CRD naming) +owner = "team@example.com" # Project owner +description = "Optional" # Project description +``` + +## Services + +Each service is declared as a `[[services]]` entry: + +```toml +[[services]] +name = "api" +service_type = "web" # web, worker, cronJob, staticSite +``` + +### Source + +Every service needs a source -- either a Git repository or a container image: + +```toml +# Git repository +[services.source.git] +url = "https://github.com/org/api" +branch = "main" + +# Container image (alternative to git) +[services.source.image] +repository = "nginx" +tag = "latest" +``` + +### Deploy + +```toml +[services.deploy] +replicas = 2 +env = { NODE_ENV = "production", PORT = "3000" } +``` + +### Expose + +Network exposure is optional. When set, the operator creates a Gateway API HTTPRoute and cert-manager Certificate: + +```toml +[services.expose] +port = 3000 +domain = "api.example.com" +tls = true +``` + +### Autoscaling + +Autoscaling is optional. The default mode is a CPU-based Horizontal Pod +Autoscaler between `minReplicas` and `maxReplicas`: + +```toml +[services.deploy.autoscale] +minReplicas = 1 +maxReplicas = 5 +targetCpuPercent = 80 +``` + +#### Scale to zero + +Set `scaleToZero = true` for request-driven scale-to-zero: idle apps scale to +**0** replicas and wake automatically on the first HTTP request (served through +the KEDA HTTP interceptor). This trades a small cold-start latency on the first +request for zero idle cost. + +```toml +[services.deploy.autoscale] +maxReplicas = 3 +scaleToZero = true +idleTimeoutSeconds = 300 # idle window before sleeping (default: 300) +``` + +Requirements and behaviour: + +- Requires an `[services.expose]` domain (HTTP traffic is what wakes the app). +- The operator emits a KEDA `HTTPScaledObject` and routes the Gateway HTTPRoute + through the interceptor instead of a CPU HPA. +- The first request after the app has slept incurs a cold start (pod schedule + + container boot); subsequent requests are served normally. +- Backward compatible: when `scaleToZero` is unset, the service keeps the + classic CPU HPA. + +## Databases + +Declare managed Postgres databases with `[[databases]]`: + +```toml +[[databases]] +name = "app-db" +engine = "postgres" +version = "16" +instances = 2 # Primary + 1 replica +storage = "10Gi" + +[databases.backup] +schedule = "0 2 * * *" # Daily at 2 AM +retention_days = 30 +``` + +The operator creates a CloudNativePG Cluster and a K8s Secret with connection credentials. Reference the secret in your service env: + +```toml +[[services]] +name = "api" +service_type = "web" + +[services.source.image] +repository = "org/api" +tag = "latest" + +[services.deploy] +replicas = 2 + +# Reference the database secret +[services.deploy.env_from_secret] +DATABASE_URL = { secret = "app-db-credentials", key = "uri" } + +[services.expose] +port = 3000 +domain = "api.example.com" +tls = true +``` + +## Service Types + +| Type | Description | +|------|-------------| +| `web` | HTTP-serving web application (default) | +| `worker` | Background worker process | +| `cronJob` | Scheduled cron job | +| `staticSite` | Static site (HTML/CSS/JS) | + +## Full Example + +```toml +[project] +name = "my-saas" +owner = "team@example.com" +description = "A multi-service SaaS application" + +# Managed Postgres database +[[databases]] +name = "main-db" +engine = "postgres" +version = "16" +instances = 2 +storage = "20Gi" + +[databases.backup] +schedule = "0 2 * * *" +retention_days = 30 + +# API service +[[services]] +name = "api" +service_type = "web" + +[services.source.git] +url = "https://github.com/org/api" +branch = "main" + +[services.deploy] +replicas = 3 +env = { NODE_ENV = "production" } + +[services.deploy.env_from_secret] +DATABASE_URL = { secret = "main-db-credentials", key = "uri" } + +[services.expose] +port = 3000 +domain = "api.my-saas.com" +tls = true + +# Background worker +[[services]] +name = "worker" +service_type = "worker" + +[services.source.image] +repository = "org/worker" +tag = "v1.2.0" + +[services.deploy] +replicas = 2 + +[services.deploy.env_from_secret] +DATABASE_URL = { secret = "main-db-credentials", key = "uri" } +``` From bc456f862a1098a863e227cc7c75f363eee43fe0 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Wed, 10 Jun 2026 13:09:43 -0700 Subject: [PATCH 07/13] fix(web): add favicon raster set and apple-touch icon Normalize the favicon set so the icon renders on iOS home screens and external link previews (Threads/Meta, iMessage), not just the .ico fallback. Add 16x16 and 32x32 PNGs and a 180x180 apple-touch-icon. --- public/apple-touch-icon.png | Bin 0 -> 11630 bytes public/favicon-16x16.png | Bin 0 -> 1018 bytes public/favicon-32x32.png | Bin 0 -> 1947 bytes src/routes/__root.tsx | 17 +++++++++++++++++ 4 files changed, 17 insertions(+) create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-16x16.png create mode 100644 public/favicon-32x32.png diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0a1790ddf56d9ea34ed2a7fc7cca4a52f7a78522 GIT binary patch literal 11630 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 literal 0 HcmV?d00001 diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..6012abdf355b42849d9d9b2f3f25b54b61d3a817 GIT binary patch literal 1947 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7e6l0AZa85pY67#JE_7#My5g&JNk zFq9fFFuY1&V6d9Oz#v{QXIG#N0|V2_0G|+7paO=)ix?L#Vpy_>anT~ig$o!LEo5A> zh+*+!hGp{^7B69#zmQ=eP-HRV;)M)Av}hqi+jNGdOBrU(Vg#z0H;ZBM0)}Pt7?vzy zSU4AG4gvv9L*|morcgVn)={pW1P6lynQ|K`GGQTku>(v1STrtL1WF`O_rMusBmlTx zI0KwG85k<$Is1WW^G3WVp^cY2Yv%4gv9>L>N4R2D!_Q;C-=(N2+{(W8y{q@b zn+u1oynUL#FeDgRz$Np8` zCzhGpi`&__tMZjfy!BI` ztH1v-zfojTq3XkX(`8(KEo1hx;nJ69mcJ*y0_Z`;ByV>Yh7ML)4ZhlH;S|x4`>isXpfEqO5Hk4%MrWThZ<`&@AQ!wkqNnp8h&(p;*#Nu@C&Hw+6hPq7+e~Z6MytX`-{imh#-sC$sFE5<` zPI;P#<^7YvDMdRE<-gkUlIP{+R;5{n!Ir15$1`?oGNlRJj4pLwbhD9Tmhp7WRSIAC z{N!#jRb0s3$ri=1XhXq?n!_{qc}Q=xF!GX;6+V6R=w$_M1uq7n=_@vbOj;0~*0<5d zFq8FSLU;4jlL5=t9C~%dH2eBm|H;epA583(oPDNpQ;GTO#2bs;4_wQgdHeC^Wp8iH z+~v)8qU`-kRhFi`zZpEudLI3atm)@_BC#|&nUCppQRRWoPkZ$(1b2{Hmv` zr)tX6>z4{{=e+pSx--C4qU37I^P-K6y}k|(f%kuO%+il#TW6A`CE=iJv2(X*>b)mv zzD;g2`d694*B7d!94J-2Wt8t=K0D@YD%VPly%U)8wsl9w@rA5q`nw{Lh7nzSk!`XzSNn zHv3tPOSa)fiRkApPd<94#a5=4mG8~DpZ;n#@BNxjKb1VgzJ1Yny? Date: Wed, 17 Jun 2026 22:19:01 -0500 Subject: [PATCH 08/13] docs(backfeed): sync feature docs with shipped product Move shipped capabilities (AI triage, dedupe, clustering, moderation, email ingestion, the Runa shipped-event) out of "planned" and document features that were missing entirely: reactions, rich-text editor with @-mentions and #-cross-references, status timeline, roadmap, labels/filtering/saved views, attachments, duplicate detection, project links/branding, search, vanity post numbers, and CloudEvents. Expand the terminology table and correct the implication that task assignment lives in Backfeed (it is Runa). --- content/docs/core/backfeed.mdx | 83 ++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/content/docs/core/backfeed.mdx b/content/docs/core/backfeed.mdx index 3e44cdc..328b2b4 100644 --- a/content/docs/core/backfeed.mdx +++ b/content/docs/core/backfeed.mdx @@ -61,47 +61,72 @@ import { **Omni Backfeed** is a user feedback reporting platform. Backfeed aggregates user data for your products, services, and experiences, providing a centralized location to iterate on your projects with user-driven insights and planning. -Backfeed is evolving from a feedback board into an **AI-native feedback brain**: it keeps the public board, voting, and roadmap at its core while learning to ingest feedback from many sources and triage it automatically. The board stays the source of truth, and human votes still set priority. AI handles the manual labor of sorting, not the judgment. See [AI & Multi-Source Feedback (Planned)](#ai--multi-source-feedback-planned). +Backfeed is evolving from a feedback board into an **AI-native feedback brain**: it keeps the public board, voting, and roadmap at its core while learning to ingest feedback from many sources and triage it automatically. The board stays the source of truth, and human votes still set priority. AI handles the manual labor of sorting, not the judgment. See [AI & Multi-Source Feedback](#ai--multi-source-feedback). ## Terminology | Term | Description | |------|-------------| -| **Organization** | The highest level, representing a company or group (managed via Gatekeeper) | +| **Organization** | The highest level, representing a company or group (managed via Gatekeeper). Surfaced in the app as your **workspace** | | **Project** | A product or service within an organization that collects feedback | | **Board** | The feedback collection area for a project where posts are displayed | -| **Post** | A single feedback submission (feature request, bug report, idea, etc.) | +| **Post** | A single feedback submission (feature request, bug report, idea, etc.), addressed by a per-project number (`#42`, or `PREFIX-42` when a prefix is set) | | **Vote** | An upvote or downvote on a post to indicate support or priority | -| **Comment** | Discussion on a post for collaboration and clarification | +| **Comment** | Threaded discussion on a post for collaboration and clarification | +| **Reaction** | An emoji response on a post or comment | +| **Status** | Where a post sits in your workflow (e.g. Under Review, Planned, In Progress, Completed); fully customizable per project | +| **Roadmap** | A read-only board that groups posts by status to show what you are building | +| **Label** | A project-scoped tag for categorizing and filtering posts | +| **Signal** | Any inbound piece of user input, regardless of source; a feature request or bug Signal becomes (or merges into) a Post | ## Features -- **Feedback Aggregation**: Collect and categorize posts from multiple sources, ensuring a streamlined workflow for product teams. -- **Prioritization Tools**: Rank posts based on user demand, impact, and urgency, making it easier to decide what to build next. -- **Roadmap Planning**: Communicate product direction by turning posts into actionable roadmap items. -- **User Voting & Discussion**: Each post includes upvotes/downvotes and a community discussion section for comments. -- **Public & Private Boards**: Control project visibility with a per-project toggle. -- **Multi-Source Ingestion**: Capture feedback from in-app widgets, email, social mentions, app-store reviews, and chat channels, all funneled into the same boards (planned). -- **AI Triage**: Automatically tag, cluster, dedupe, and run sentiment analysis on incoming feedback so teams skip the manual sorting (planned). -- **Product Reviews**: Collect star-rated reviews alongside feature requests as a distinct feedback type (planned). -- **Analytics & Insights**: Gain deep insights into user needs and engagement trends (planned). -- **API Access**: Integrate Backfeed seamlessly into your existing infrastructure with a powerful API (planned). +### Available today -## AI & Multi-Source Feedback (Planned) +- **Public feedback board**: A shareable board per project where users submit posts and the team triages them. Public by default, no account required to view or post. +- **Voting**: Up/down votes on every post to surface demand; sort the board by top-voted or most recent. +- **Comments & threaded replies**: Discuss any post, with nested replies. +- **Rich-text editor**: Posts and comments support bold, italic, lists, and links, plus `@`-mentions of people and `#`-references to other posts (GitHub `#123` style, with an inline search-as-you-type picker). +- **Reactions**: Emoji reactions on both posts and comments. +- **Statuses & roadmap**: Fully customizable per-project statuses, a status-change **timeline** on each post (who moved it where, and when), and a read-only **roadmap** board that groups posts by status to communicate what you are building. +- **Labels, filtering & saved views**: Tag posts with project-scoped labels, filter and search the board, sort it, and save view configurations for quick recall. +- **Attachments**: Attach images and video to posts. +- **Duplicate detection**: When semantic search is configured, drafting a post surfaces possible duplicates so feedback consolidates instead of fragmenting. +- **Project links & branding**: Add website/social links and a project logo; workspaces carry their organization branding. +- **Public & private boards**: Control project visibility with a per-project toggle. +- **Email-to-feedback**: Each project has a dedicated inbound email address; messages sent to it become Signals and flow onto the board. +- **AI triage (optional)**: With an embedding/AI provider configured, incoming feedback is automatically deduplicated, clustered into themes, sentiment-scored, and tagged. It degrades gracefully: with nothing configured, manual tagging and the in-app flow still work. +- **Content moderation (optional)**: Submissions can be screened before they reach a board. +- **Ecosystem events**: Every change emits a CloudEvent (`backfeed.post.*`, `backfeed.comment.*`, `backfeed.vote.*`, and more) so the rest of the Omni ecosystem can react, including closing the loop with [Runa](#closing-the-loop-with-runa). -Backfeed is being extended around a single new concept, the **Signal**: any inbound piece of user input, regardless of source. A Signal that is a feature request or bug becomes (or merges into) a Post, exactly as today, so the board, votes, and roadmap are unchanged. Reviews, emails, and social mentions are simply Signals with a different source and type. +### Planned -Planned capabilities: +- **More ingestion sources**: Beyond email, capture feedback from in-app widgets, social mentions, app-store reviews, and chat channels, all funneled into the same boards (the Signal model already supports them; connectors are rolling out). +- **Product reviews**: Star-rated reviews alongside feature requests as a distinct feedback type. +- **Analytics & insights**: Deeper dashboards on user needs and engagement trends. +- **Public API & webhooks**: A documented API and webhooks to integrate Backfeed with your existing stack. -- **Multi-source ingestion**: pull feedback from in-app widgets, email, social mentions, app-store reviews, and chat channels into the same boards -- **AI triage**: automatically tag, cluster, dedupe, and score sentiment on incoming feedback -- **Moderation**: filter public and ingested submissions before they reach a board +## AI & Multi-Source Feedback -Every integration is opt-in and degrades gracefully: with no AI configured, manual tagging still works; with no ingestion connectors, the in-app widget still works. Human voting always remains the prioritization layer. +Backfeed is built around a single concept, the **Signal**: any inbound piece of user input, regardless of source. A Signal that is a feature request or bug becomes (or merges into) a Post, exactly as a manual submission does, so the board, votes, and roadmap are unchanged. Reviews, emails, and social mentions are simply Signals with a different source and type. -## Closing the Loop with Runa (Planned) +Available today: -When a feature request is accepted, Backfeed can hand it to **[Runa](/core/runa)** as a task. When that task ships, Backfeed notifies the original reporters that their feedback was delivered, the single most effective way to keep feedback flowing. The Backfeed to Runa flow runs over CloudEvents, no manual status-syncing required. +- **Email ingestion**: every project has a dedicated inbound email address; messages become Signals on the board +- **AI triage**: with an embedding/AI provider configured, incoming feedback is automatically deduplicated, clustered into themes, sentiment-scored, and tagged +- **Moderation**: submissions can be screened before they reach a board + +Rolling out: + +- **More connectors**: in-app widgets, social mentions, app-store reviews, and chat channels (the Signal model already supports these source types) + +Every integration is opt-in and degrades gracefully: with no AI configured, manual tagging still works; with no ingestion connectors, the in-app submission flow still works. Human voting always remains the prioritization layer. + +## Closing the Loop with Runa + +When a feature request is accepted, Backfeed can hand it to **[Runa](/core/runa)** as a task. When the work ships, the original reporters are notified that their feedback was delivered, the single most effective way to keep feedback flowing. + +Backfeed already does its half of this over CloudEvents: the first time a post reaches a shipped status it emits a `backfeed.post.shipped` event carrying the reporters behind that feedback, with no manual status-syncing required. The Runa-side consumer that turns shipped tasks back into Backfeed updates is rolling out. ## Board Visibility @@ -131,15 +156,15 @@ When a project is public: 5. **Review & Prioritize** Use Backfeed's prioritization tools to filter, sort, and rank posts based on importance and feasibility. 6. **Plan & Execute** - Convert prioritized posts into roadmap items, assign tasks to your team, and start building based on user demand. + Move posts through your statuses to build the roadmap, hand accepted work to [Runa](/core/runa), and close the loop with reporters when it ships. -## Integrations (Planned) +## Integrations -Backfeed plans to support integrations across the Omni ecosystem and beyond: +Backfeed integrates across the Omni ecosystem and beyond: -- **Runa** for turning accepted feedback into tracked work (see [Closing the Loop](#closing-the-loop-with-runa-planned)) -- Additional feedback sources and AI triage (see [AI & Multi-Source Feedback](#ai--multi-source-feedback-planned)) -- GitHub +- **Runa** for turning accepted feedback into tracked work (see [Closing the Loop](#closing-the-loop-with-runa)) +- Additional feedback sources and AI triage (see [AI & Multi-Source Feedback](#ai--multi-source-feedback)) +- **GitHub** (planned) ## API & Webhooks (Planned) From 43224ca2397b85cf858dd18ab7448a1787554b6b Mon Sep 17 00:00:00 2001 From: Brian Cooper <20056195+coopbri@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:03:29 -0500 Subject: [PATCH 09/13] docs(vortex): Herald replaces the prior email provider (#21) Email moved to Herald; update Vortex docs (config vars, email step/trigger, integration catalog) to reference Herald instead of the prior provider. --- content/docs/core/vortex/integrations.mdx | 2 +- content/docs/grid/vortex/configuration.mdx | 2 +- content/docs/grid/vortex/execution.mdx | 2 +- content/docs/grid/vortex/index.mdx | 2 +- content/docs/grid/vortex/integrations.mdx | 2 +- content/docs/grid/vortex/self-hosting.mdx | 63 +-- content/docs/grid/vortex/step-types.mdx | 417 +++++++++++++++++++ content/docs/grid/vortex/triggers.mdx | 4 +- content/docs/grid/vortex/troubleshooting.mdx | 225 ++++++++++ 9 files changed, 652 insertions(+), 67 deletions(-) create mode 100644 content/docs/grid/vortex/step-types.mdx create mode 100644 content/docs/grid/vortex/troubleshooting.mdx diff --git a/content/docs/core/vortex/integrations.mdx b/content/docs/core/vortex/integrations.mdx index b4d14a9..2b6f175 100644 --- a/content/docs/core/vortex/integrations.mdx +++ b/content/docs/core/vortex/integrations.mdx @@ -19,7 +19,7 @@ Vortex supports 500+ integrations via the ActivePieces connector ecosystem, plus | **HubSpot** | CRM | Contact management, marketing automation | | **Stripe** | Payments | Payment processing, subscription management | | **SendGrid** | Email | Transactional and marketing email delivery | -| **Resend** | Email | Transactional email delivery | +| **Herald** | Email | Transactional email delivery | | **Twilio** | SMS | Send and receive SMS messages | | **Anthropic** | AI | Claude models for text generation and analysis | | **OpenAI** | AI | GPT models, DALL-E, embeddings | diff --git a/content/docs/grid/vortex/configuration.mdx b/content/docs/grid/vortex/configuration.mdx index 1700153..2631d6f 100644 --- a/content/docs/grid/vortex/configuration.mdx +++ b/content/docs/grid/vortex/configuration.mdx @@ -163,7 +163,7 @@ The worker executes workflow DSL definitions, manages trigger adapters, and runs | `CHRONICLE_API_URL` | Chronicle audit logging URL | | `MANTLE_API_URL` | Mantle notification service URL | | `MANTLE_SERVICE_KEY` | Mantle service key | -| `RESEND_API_KEY` | Resend API key (for email trigger adapter) | +| `HERALD_API_KEY` | Herald API key (for email trigger adapter) | | `AUTH_API_URL` | Auth API URL (for email rendering) | | `EMAIL_RENDER_SECRET` | Email rendering secret | | `MEILISEARCH_URL` | Meilisearch URL | diff --git a/content/docs/grid/vortex/execution.mdx b/content/docs/grid/vortex/execution.mdx index 44cf466..3d6b1ed 100644 --- a/content/docs/grid/vortex/execution.mdx +++ b/content/docs/grid/vortex/execution.mdx @@ -112,7 +112,7 @@ Plugins provide infrastructure capabilities that don't require external connecto | **Rate Limit** | Token bucket, sliding window, fixed window algorithms | | **Queue** | Push/pull with memory, Valkey, SQS, or RabbitMQ backends | | **Database** | Query, insert, update, delete with parameterized SQL | -| **Email** | Send emails via Resend | +| **Email** | Send emails via Herald | | **File** | Read, write, list, delete files | | **HTTP** | Make HTTP requests with auth and retry | | **Hash** | MD5, SHA-256, bcrypt, HMAC | diff --git a/content/docs/grid/vortex/index.mdx b/content/docs/grid/vortex/index.mdx index 31064f4..e1daa96 100644 --- a/content/docs/grid/vortex/index.mdx +++ b/content/docs/grid/vortex/index.mdx @@ -209,7 +209,7 @@ Vortex ships with 600+ built-in integrations (Slack, GitHub, Stripe, Google Shee | **Rate Limit** | Token bucket, sliding window, fixed window algorithms | | **Queue** | Push/pull with memory, Valkey, SQS, or RabbitMQ backends | | **Database** | Query, insert, update, delete with parameterized SQL | -| **Email** | Send emails via Resend | +| **Email** | Send emails via Herald | | **File** | Read, write, list, delete files | | **HTTP** | Make HTTP requests with auth and retry | | **Hash** | MD5, SHA-256, bcrypt, HMAC | diff --git a/content/docs/grid/vortex/integrations.mdx b/content/docs/grid/vortex/integrations.mdx index ab09e01..f730a0c 100644 --- a/content/docs/grid/vortex/integrations.mdx +++ b/content/docs/grid/vortex/integrations.mdx @@ -13,7 +13,7 @@ Vortex ships with 600+ built-in integrations powered by the [ActivePieces](https | **Communication** | Slack, Discord, Microsoft Teams, Twilio | | **CRM** | HubSpot, Salesforce, Pipedrive | | **Developer** | GitHub, GitLab, Linear, Jira | -| **Email** | Gmail, Resend, SendGrid, Mailchimp | +| **Email** | Gmail, Herald, SendGrid, Mailchimp | | **Productivity** | Notion, Airtable, Google Sheets, Asana | | **Cloud** | AWS S3, Azure Blob Storage, Google Cloud Storage | | **eCommerce** | Shopify, Stripe, WooCommerce | diff --git a/content/docs/grid/vortex/self-hosting.mdx b/content/docs/grid/vortex/self-hosting.mdx index 873739e..01db2e1 100644 --- a/content/docs/grid/vortex/self-hosting.mdx +++ b/content/docs/grid/vortex/self-hosting.mdx @@ -1,9 +1,9 @@ --- title: Self-Hosting -description: Deploy Vortex on your own infrastructure with Docker Compose or Kubernetes +description: Deploy Vortex on your own infrastructure with Docker Compose --- -Vortex is fully open source (Apache 2.0) and designed to run on your own infrastructure. Two deployment options are supported: Docker Compose for simpler setups and Kubernetes (via Helm) for production-grade orchestration. +Vortex is fully open source (Apache 2.0) and designed to run on your own infrastructure. Docker Compose is the supported path for self-hosting. A Helm chart is not currently published; Kubernetes operators who want to wire their own manifests can use the compose file as the source of truth for service topology, env vars, and health checks. ## Docker Compose @@ -81,7 +81,7 @@ Omni platform integrations (optional, omit to run Vortex standalone): |----------|-------------| | `MANTLE_API_URL`, `MANTLE_SERVICE_KEY` | Mantle notification service | | `AUTH_API_URL` | HIDRA identity provider | -| `RESEND_API_KEY`, `EMAIL_RENDER_SECRET` | Email trigger adapter | +| `HERALD_API_KEY`, `EMAIL_RENDER_SECRET` | Email trigger adapter | ### Architecture @@ -100,63 +100,6 @@ All services include health checks and proper dependency ordering. The worker de When billing is not configured, the pricing page shows "All Features" mode, and all entitlements are unlocked. Self-hosted deployments get the full feature set with no artificial limits: unlimited workflows, unlimited executions, all integrations, custom plugins, and SSO. -## Kubernetes (Helm) - -For production deployments, Vortex ships a Helm chart with configurable replicas, resource limits, ingress, TLS, and Pod Disruption Budgets. - -### Prerequisites - -The Helm chart deploys the three Vortex services (API, app, worker) and optionally a PostgreSQL subchart. The following external dependencies must be provisioned separately: - -- **Valkey / Redis** for caching and distributed locks. Configure via `externalCache` values, or deploy your own Valkey instance alongside the chart. -- **[Apache Iggy](https://iggy.rs)** for event streaming. The worker and API both require a reachable Iggy server. -- **[Hatchet](https://docs.hatchet.run/self-hosting)** for workflow execution. See the Hatchet self-hosting docs for setup instructions. - -### Install - -```sh -helm install vortex ./charts/vortex \ - --namespace vortex --create-namespace \ - -f charts/vortex/values.yaml \ - --set secrets.dbPassword= \ - --set secrets.authSecret= \ - --set secrets.encryptionKey= \ - --set secrets.internalApiSecret= \ - --set secrets.hatchetClientToken= \ - --set secrets.iggyUsername= \ - --set secrets.iggyPassword= \ - --set secrets.cachePassword= -``` - -| Secret | Description | Generate with | -|--------|-------------|---------------| -| `secrets.dbPassword` | PostgreSQL password | `openssl rand -hex 16` | -| `secrets.authSecret` | Session signing secret | `openssl rand -hex 16` | -| `secrets.encryptionKey` | Data encryption key | `openssl rand -base64 32` | -| `secrets.internalApiSecret` | Service-to-service auth | `openssl rand -hex 16` | -| `secrets.hatchetClientToken` | Hatchet API token | From Hatchet dashboard | -| `secrets.iggyUsername` | Iggy streaming username | Any value | -| `secrets.iggyPassword` | Iggy streaming password | Any value | -| `secrets.cachePassword` | Valkey/Redis password | `openssl rand -hex 16` | - -See `charts/vortex/values.yaml` for the full list of configurable values, and `charts/vortex/values.prod.yaml` for a production reference. - -### External database - -To use an external PostgreSQL instance instead of the bundled subchart: - -```yaml -postgresql: - enabled: false - -externalDatabase: - host: your-db-host - port: 5432 - database: vortex - username: postgres - password: -``` - ## Upgrading Pull the latest changes and rebuild: diff --git a/content/docs/grid/vortex/step-types.mdx b/content/docs/grid/vortex/step-types.mdx new file mode 100644 index 0000000..885c2f2 --- /dev/null +++ b/content/docs/grid/vortex/step-types.mdx @@ -0,0 +1,417 @@ +--- +title: Step Types +description: Reference for every step type in the Vortex DSL +--- + +This page documents every step type the worker recognizes. Each entry includes purpose, key fields, and a minimal example. For shared fields (`id`, `name`, `position`, `onError`), see [Concepts](./concepts) and [DSL](./dsl). + +## Control flow + +### `trigger` + +The entry point of every workflow. Wraps a [trigger configuration](./triggers). + +```json +{ + "id": "trigger_1", + "type": "trigger", + "name": "Webhook", + "position": { "x": 0, "y": 0 }, + "trigger": { "type": "webhook", "config": {} } +} +``` + +### `condition` + +Two-way branch on a boolean expression. Edges from this step use `sourceHandle: "true"` or `"false"`. + +```json +{ + "type": "condition", + "condition": { + "expression": "$.total > 100", + "trueBranch": "discount", + "falseBranch": "skip" + } +} +``` + +### `switch` + +Multi-way branch on a value, with named cases and an optional default. + +```json +{ + "type": "switch", + "switch": { + "expression": "$.status", + "cases": [ + { "value": "paid", "label": "Paid", "next": "fulfill" }, + { "value": "refunded", "label": "Refunded", "next": "credit" } + ], + "default": "review" + } +} +``` + +### `loop` + +Iterate with `forEach`, `while`, or `times`. `body` is an array of step IDs that run per iteration. + +```json +{ + "type": "loop", + "loop": { + "type": "forEach", + "collection": "$.items", + "itemVariable": "item", + "indexVariable": "i", + "body": ["process_item"] + } +} +``` + +### `parallel` + +Run multiple branches concurrently. `waitFor` accepts `"all"`, `"any"`, or a number. + +```json +{ + "type": "parallel", + "parallel": { + "branches": [["send_email"], ["send_slack"], ["log_event"]], + "waitFor": "all" + } +} +``` + +### `race` + +Run branches in parallel, the first to complete wins. + +```json +{ + "type": "race", + "race": { + "branches": [["primary_api"], ["fallback_api"]], + "timeout": "10s", + "output": "result", + "winnerIndex": "winner" + } +} +``` + +### `try_catch` + +Run a `tryBranch`. On failure, optionally retry, then run `catchBranch` with the error available as `errorOutput`. + +```json +{ + "type": "try_catch", + "tryCatch": { + "tryBranch": ["charge_card"], + "catchBranch": ["notify_failure"], + "errorOutput": "chargeError", + "retries": 2, + "retryDelay": "1s" + } +} +``` + +### `delay`, `sleep`, `wait` + +`delay` pauses for `duration` and `unit` (`seconds`, `minutes`, `hours`, `days`). `sleep` pauses in `ms`, `seconds`, or `minutes`. `wait` blocks until a webhook arrives, a named event fires, or a timeout elapses. + +```json +{ + "type": "wait", + "wait": { + "resumeOn": "webhook", + "webhookSuffix": "approval", + "timeout": 86400, + "timeoutUnit": "seconds", + "timeoutAction": "continue" + } +} +``` + +### `subworkflow` + +Invoke another workflow by ID. Pass inputs in, map outputs back out. + +```json +{ + "type": "subworkflow", + "subworkflow": { + "workflowId": "wf_audit_log", + "inputs": { "event": "{{event}}" }, + "waitForCompletion": true + } +} +``` + +### `stop`, `noop`, `comment` + +`stop` terminates the run with `success`, `failure`, or `cancelled`. `noop` is a passthrough. `comment` is a visual-only annotation skipped by the executor. + +### `retry`, `timeout` + +`retry` re-executes a referenced `stepId` with backoff. `timeout` enforces a deadline on a referenced step and can `error`, `continue`, or run a `fallback`. + +## Data + +### `set` + +Assign workflow variables. + +```json +{ + "type": "set", + "set": { "variables": { "approved": true, "reviewer": "{{user.id}}" } } +} +``` + +### `map`, `reduce`, `filter`, `sort`, `unique`, `group`, `flatten`, `chunk`, `zip` + +Standard collection operations. Each takes a `source` JSONPath and an `outputVariable`. + +```json +{ + "type": "filter", + "filter": { + "source": "$.orders", + "expression": "$.total > 50", + "outputVariable": "largeOrders" + } +} +``` + +### `merge`, `split` + +`merge` combines objects or arrays from multiple sources with a `conflictStrategy` (`first`, `last`, or `error`). `split` divides an array into batches. + +### `template`, `parse`, `format`, `validate` + +`template` renders a string with variable interpolation. `parse` reads `json`, `xml`, `csv`, `yaml`, or `querystring` input. `format` formats `date`, `number`, `currency`, `percentage`, or `bytes`. `validate` checks input against a schema. + +```json +{ + "type": "parse", + "parse": { + "input": "{{triggerData.body}}", + "format": "json", + "outputVariable": "payload" + } +} +``` + +### `aggregate`, `diff`, `change_detector`, `debounce`, `time_window` + +Stateful data steps. `aggregate` rolls up arrays (`collect`, `merge`, `sum`, `first`, `last`). `diff` reports `added`, `removed`, and `changed`. `change_detector` skips the run if the value matches the previous run (`hash` or `deep_equal`). `debounce` coalesces rapid triggers using a cache key and `windowMs`. `time_window` proceeds only when the current time falls inside a window. + +### `cache`, `state_get`, `state_set`, `state_wait` + +`cache` is per-organization with TTL and supports `get`, `set`, `delete`, `getOrSet`. The `state_*` steps operate on a cross-workflow KV store, useful for coordinating runs. + +## Integration + +### `action` + +Run a connector operation. The connector executor in `vortex-worker` dispatches on `integrationId` and `operation`. + +```json +{ + "type": "action", + "action": { + "integrationId": "slack", + "operation": "sendMessage", + "inputs": { "channel": "#alerts", "text": "{{message}}" } + } +} +``` + +### `database` + +Execute SQL through an MCP database server. `operation` is `query`, `insert`, `update`, or `delete`. + +### `email` + +Send an email through Herald (or another configured provider). Supports `to`/`cc`/`bcc` (single or array), HTML or text body, and attachments. + +### `file`, `queue` + +`file` supports `local`, `s3`, `gcs`, and `azure` providers with `read`, `write`, `delete`, `list`, `exists`. `queue` operates on `memory`, `valkey`, `sqs`, or `rabbitmq` queues with `push`, `pull`, `peek`, `ack`, `nack`. + +### `spreadsheet`, `googleSheets`, `pdf` + +Office-style operations. `spreadsheet` handles `xlsx`, `xls`, `csv`. `googleSheets` uses a stored connection ID. `pdf` supports `create`, `parse`, `merge`, `split`, `watermark`, `extractPages`, `info`. + +### `webhookResponse`, `webhookVerify` + +`webhookResponse` returns a status code and body to a synchronous webhook caller. `webhookVerify` validates incoming signatures for `stripe`, `github`, `slack`, `twilio`, `shopify`, `sendgrid`, `paddle`, `linear`, or a `custom` HMAC scheme. + +### `notification` + +Send a notification through `email`, `slack`, `webhook`, or `push` channel with a `priority` (`low`, `normal`, `high`, `urgent`). + +## AI + +### `llm` + +Call an LLM via an MCP server with optional memory and tool use. Supports `images` for vision models. + +```json +{ + "type": "llm", + "llm": { + "serverId": "openai-mcp", + "model": "gpt-4", + "systemPrompt": "You are a helpful assistant", + "userPrompt": "Summarize: {{document}}", + "temperature": 0.7, + "outputs": { "summary": "summary" } + } +} +``` + +### `prompt`, `chat`, `summarize`, `classify` + +Higher-level wrappers. `prompt` runs a template through a model. `chat` keeps a message history. `summarize` produces `brief`, `detailed`, or `bullets` output. `classify` returns one or many labels. + +### `agent`, `rag`, `vision`, `audio` + +`agent` executes an autonomous loop with `maxIterations`. `rag` queries a vector collection then prompts the model. `vision` performs `describe`, `ocr`, `detect`, or `classify` on an image. `audio` does `transcribe` or `synthesize`. + +### `embedding`, `vectorSearch` + +`embedding` generates vectors through an MCP server. `vectorSearch` queries `pinecone`, `pgvector`, `qdrant`, `weaviate`, or `chroma` with a `topK` and optional `minScore`. + +### `modelRegistry` + +BYOK access to `openai`, `anthropic`, `huggingface`, `ollama`, `replicate`, `together`, `groq`, `mistral`, `cohere`. Use a stored `connectionId` or an inline `apiKey`. + +### `ai_transform`, `ai_guardrails`, `rivet` + +`ai_transform` sends a value through a prompt template for structured output. `ai_guardrails` validates output against `regex`, `contains`, `not_contains`, `max_length`, or `json_schema` rules. `rivet` executes a Rivet AI agent graph by `graphId` or inline JSON. + +## Security + +### `encrypt`, `decrypt`, `sign`, `hash`, `jwt` + +`encrypt` and `decrypt` use AES-GCM or AES-CBC. `sign` produces HMAC signatures (SHA-256/384/512). `hash` accepts `md5`, `sha1`, `sha256`, `sha512`, `xxhash`. `jwt` creates, verifies, or decodes tokens with HS256/HS384/HS512. + +## Human in the loop + +### `approval`, `input`, `gate` + +`approval` blocks until approvers respond, with a `timeoutAction` of `approve`, `reject`, or `error`. `input` collects form data from `assignees` (`text`, `number`, `email`, `textarea`, `select`, `checkbox` fields). `gate` is a lower-level pause that resumes on approval, named signal, or manual continue. + +```json +{ + "type": "approval", + "approval": { + "title": "Refund over $1000", + "message": "Approve refund for order {{orderId}}", + "approvers": ["user_admin_1"], + "timeout": 86400, + "timeoutAction": "reject" + } +} +``` + +## Plugins and code + +### `plugin` + +Execute a registered plugin (built-in or WASM) with `pluginId`, `function`, and `inputs`. Supports `timeout` and `memoryLimit` for sandboxing. + +### `code` + +Run JavaScript or TypeScript. `sandbox` picks the execution environment: `mcp` (default), `worker` (isolated Bun Worker), or `wasm` (QuickJS through Extism). MCP sandbox can install npm `dependencies` before execution. + +### `mcp` + +Call any tool exposed by a registered MCP server. + +## Events and orchestration + +### `event` + +Emit an event to the Iggy stream. `operation` is `emit`. + +```json +{ + "type": "event", + "event": { + "operation": "emit", + "eventName": "order.processed", + "payload": { "orderId": "{{orderId}}" } + } +} +``` + +### `collect` + +Pause the workflow until matching cross-service events arrive. Useful for sagas that fan out across products. See [Events](./events). + +```json +{ + "type": "collect", + "collect": { + "events": [ + { "name": "billing", "sourcePattern": "omni.aether", "typePattern": "subscription.created" }, + { "name": "identity", "sourcePattern": "omni.gatekeeper", "typePattern": "user.created" } + ], + "correlationKey": "data.userId", + "timeout": "5m", + "mode": "all" + } +} +``` + +### `window` + +Collect events into `tumbling`, `sliding`, or `session` windows. Emit `onClose` or `onEach`. + +### `saga` + +Distributed transaction with execute and compensate pairs per step. Set `parallel: true` to run steps concurrently. + +```json +{ + "type": "saga", + "saga": { + "steps": [ + { + "name": "reserveInventory", + "execute": { "type": "http", "url": "https://inv.example.com/reserve", "method": "POST" }, + "compensate": { "type": "http", "url": "https://inv.example.com/release", "method": "POST" } + } + ] + } +} +``` + +## Observability + +### `log`, `assert`, `error` + +`log` writes structured records at `debug`, `info`, `warn`, `error`. `assert` evaluates an expression and fails (or soft-fails) if false. `error` raises a typed error with optional `fatal` to terminate the workflow. + +### `rateLimit` + +Apply `token_bucket`, `sliding_window`, or `fixed_window` rate limits keyed by a string. Operations: `acquire`, `check`, `reset`, `throttle`, `configure`. + +## Next steps + + + + Workflow templates that combine these steps + + + Configure how workflows start + + + When to reach for which step + + diff --git a/content/docs/grid/vortex/triggers.mdx b/content/docs/grid/vortex/triggers.mdx index eb9990f..042977c 100644 --- a/content/docs/grid/vortex/triggers.mdx +++ b/content/docs/grid/vortex/triggers.mdx @@ -323,7 +323,7 @@ A higher-level trigger that subscribes to structured events from any Omni produc ## Email -Fires when an inbound email is received via Resend webhook. Supports glob-pattern filters on sender address and subject line. +Fires when an inbound email is received via Herald webhook. Supports glob-pattern filters on sender address and subject line. ```json { @@ -332,7 +332,7 @@ Fires when an inbound email is received via Resend webhook. Supports glob-patter "type": "email", "config": {}, "address": "workflows@omni.dev", - "provider": "resend", + "provider": "herald", "filters": { "from": "*@stripe.com", "subject": "Invoice*" diff --git a/content/docs/grid/vortex/troubleshooting.mdx b/content/docs/grid/vortex/troubleshooting.mdx new file mode 100644 index 0000000..da123ba --- /dev/null +++ b/content/docs/grid/vortex/troubleshooting.mdx @@ -0,0 +1,225 @@ +--- +title: Troubleshooting +description: Diagnose common errors in workflow execution, webhooks, and integrations +--- + +This page lists the failures we see most often and how to resolve each one. Most errors surface in the dashboard's run inspector and in the `workflow_run` and `workflow_run_step` tables. + +## Execution limit reached + +**Symptom:** New workflow runs return `403` with a message like `Plan limit reached: executions (2500/2500). Upgrade your plan to continue.` + +**Cause:** Each plan has a `max_executions_per_month` quota enforced by the API. Free tier is 2500. The check runs every time a workflow is about to dispatch. + +**Resolution:** + +- Check `/api/v1/billing/usage` to see current consumption and reset window. +- Disable workflows that are firing more often than needed. Cron expressions like `* * * * *` and unbounded webhook handlers are common culprits. +- Add a [`debounce`](./step-types) step to coalesce rapid triggers. +- Add a [`change_detector`](./step-types) step early in the workflow so noop runs do not consume the budget. +- Upgrade the plan if real usage outgrows the quota. + +Other plan limits to watch: `max_workflows`, `max_subscriptions`, `max_integrations`, `max_routing_rules`, `max_event_schemas`, `max_mcp_servers`, `max_functions`, `max_plugins`. Each returns a similar `403` from the matching route. + +## Webhook signature verification fails + +**Symptom:** Outbound subscription deliveries always return `401` from your receiver, even though the URL is correct. + +**Cause:** Almost always one of: + +1. Comparing against a `sha256=` prefix that Vortex does not emit. +2. Parsing the JSON body before verifying, so the bytes used to compute the signature differ from the bytes Vortex signed. +3. Using a string compare (`==`) instead of a constant-time compare. +4. Wrong header name. Vortex uses the configured `signatureHeader` (default `x-vortex-signature`), not `X-Hub-Signature` or `Stripe-Signature`. + +**Resolution:** + +- Use the **raw request body bytes**, not the parsed object. +- Expect a plain lowercase hex digest with no prefix. +- Use `crypto.timingSafeEqual`, `hmac.Equal`, or `hmac.compare_digest`. +- Verify the secret matches what was returned at subscription creation. Rotate through `PATCH /api/v1/subscriptions/:id` if unsure. +- Send a test delivery with `POST /api/v1/subscriptions/:id/test` and compare what arrives against what you compute. See [Webhooks](./webhooks#signature-verification). + +## Inbound webhook returns 404 + +**Symptom:** `POST /webhooks/workflow/:id/:secret` returns `404 Not Found`. + +**Cause:** Either the workflow does not exist, the secret was rotated, or the workflow is disabled. + +**Resolution:** + +- Confirm the workflow ID in the dashboard URL bar matches the path. +- Re-fetch the webhook URL from the workflow detail page. Rotation generates a new secret. +- Make sure the workflow's `enabled` flag is true. Disabled workflows reject inbound webhooks. + +## Hatchet worker offline + +**Symptom:** Runs sit in `pending` indefinitely and never progress, or fail to dispatch with `Failed to push event to Hatchet`. + +**Cause:** The worker process is not connected to the Hatchet server, or `HATCHET_CLIENT_TOKEN` is missing or invalid. + +**Resolution:** + +- Check the worker container logs for `Hatchet client connected` on startup. The worker logs a warning on boot if Hatchet credentials are absent. +- Verify `HATCHET_CLIENT_TOKEN` is set in the worker environment. +- Confirm network reachability from the worker to the Hatchet host. +- For self-hosted Hatchet, ensure its database is healthy and migrations have run. +- For local development, the worker auto-bootstraps Hatchet through Docker Compose. In production, the [feedback note about auto-setup](./self-hosting) still applies: do not run dev mode against a production Hatchet instance. + +## Schema validation error on workflow save + +**Symptom:** `POST /api/v1/workflows` returns `400` with a Zod validation error pointing at `steps[N]` or `trigger`. + +**Cause:** The DSL JSON does not match the discriminated union defined in `vortex-worker/src/dsl/types.ts`. Common mistakes: + +- `type: "trigger"` step missing the inner `trigger` config object. +- Switch step with no `cases` array. +- Loop step omitting `body`. +- Action step with neither `integrationId` nor `pluginId` set. + +**Resolution:** + +- Read the Zod error path carefully. It tells you exactly which step and field is wrong. +- Cross-check against [Step types](./step-types) which mirrors the schema. +- Open the workflow in the visual editor. The editor refuses to save invalid graphs, so any persistable graph is structurally valid. + +## OAuth integration broken + +**Symptom:** An `action` step fails with `Integration credentials expired` or `Token refresh failed`. + +**Cause:** The stored OAuth token has expired and the refresh attempt failed. This usually happens because the third-party provider revoked the token (password change, app permissions revoked, scope change) or the refresh token was never issued. + +**Resolution:** + +- Open the **Integrations** page in the dashboard. +- Find the affected integration and click **Reconnect**. +- Re-authorize through the provider, accepting any new scopes. +- Re-run the failed workflow. New runs use the refreshed credentials immediately. + +For a workflow that runs frequently, wrap the action in a [`try_catch`](./step-types) so a single bad refresh does not stop scheduled fan-outs. + +## Slow workflow runs + +**Symptom:** Runs take many seconds or minutes longer than the underlying step durations suggest. + +**Diagnosis:** + +- Open the run detail in the dashboard. Each step shows `startedAt` and `completedAt`. Identify gaps between consecutive steps (queue delay) versus long step durations (work itself is slow). +- Check the worker logs for `connector executor` timings. +- For LLM steps, large `maxTokens` and `messages` history dominate latency. + +**Common causes and fixes:** + +- **Sequential HTTP calls in a loop.** Convert to a `parallel` step or use `map` with a sub-action if the connector supports batch operations. +- **Cold MCP server.** First call to an MCP server pays connection cost. Keep it warm with a periodic health check workflow. +- **Cache misses.** Wrap expensive lookups in a `cache` step with `getOrSet` and a sensible TTL. +- **Subworkflow with `waitForCompletion: true`.** Set it to `false` for fire-and-forget when the parent does not need the child's result. +- **Hatchet queue saturation.** Scale worker replicas or increase concurrency on the Hatchet side. + +## Trigger never fires + +**Symptom:** A workflow is enabled but never receives data from its configured trigger. + +**By trigger type:** + +| Trigger | Check | +|---|---| +| Webhook | Curl the URL with a small body and look at the response status | +| Cron | Confirm the expression is in UTC and the workflow is enabled | +| Event | Check the routing rule matches the expected `typePattern` and `sourcePattern` | +| Polling | Worker logs show the polling adapter starting; confirm the URL is reachable | +| Kafka/SQS/AMQP/MQTT/NATS/Redis/SSE/WebSocket | Worker logs show the adapter connecting; confirm broker credentials | +| Email | Confirm the Herald webhook is configured to point at Vortex's email endpoint | +| Omni / Event | Confirm the publishing service is emitting; query the event log | + +Streaming triggers (MQTT, NATS, Kafka, AMQP, WebSocket, Redis, SSE, gRPC) require the worker process to be running. If the worker is restarted, adapters reconnect automatically on startup. + +## Routing rule does not match + +**Symptom:** Events flow through the Iggy stream but no workflow runs. + +**Resolution:** + +- Open the **Events** page and click an arrived event to inspect its `type` and `source`. +- Compare against your routing rule's `typePattern` (glob) and `sourcePattern`. +- If the rule has a `condition` (JSONPath), confirm it returns a non-empty value for the event in question. Conditions are presence checks, not boolean checks. +- If `celCondition` is set, it overrides `condition`. Verify the CEL expression evaluates to `true`. +- Higher-priority rules can shadow lower-priority ones if they consume an event first. Check the rule list ordering. + +See [Events](./events) for routing semantics. + +## State not persisting across runs + +**Symptom:** A `state_set` step succeeds, but a later run's `state_get` returns `null`. + +**Cause:** + +- TTL expired between runs. +- The key includes a per-run variable like `{{runId}}`, so each run writes under a different key. +- The keys differ in organizationId scope (state is per-organization). + +**Resolution:** + +- Use a stable key derived from a business identifier (`order:{{orderId}}`), not a run identifier. +- Set a long enough TTL or omit it for indefinite retention. + +## Duplicate runs from the same event + +**Symptom:** A single event triggers the workflow twice. + +**Cause:** The event has no `correlationId`, so the deduplication SETNX in Redis cannot recognize the duplicate. + +**Resolution:** + +- Publish events with a `correlationId` (a stable hash of the operation works well). +- Vortex deduplicates within a 24-hour window per organization. See [Events](./events#idempotency). + +## Subscription delivery in DLQ + +**Symptom:** Deliveries pile up with `status: "dlq"` in `GET /api/v1/subscriptions/:id/deliveries?status=dlq`. + +**Common causes:** + +- Target endpoint consistently returns non-2xx. +- Target endpoint is unreachable (DNS, TLS, firewall). +- Target endpoint is on a blocked private network (SSRF guard rejects it). +- `maxRetries` reached. + +**Resolution:** + +- Inspect the `error` and `httpStatus` columns on the DLQ rows. +- Fix the receiver, then replay the DLQ entries through the events replay API or by sending fresh test deliveries. +- Increase `maxRetries`, `initialBackoffMs`, or `backoffMultiplier` on the subscription if transient errors are common. + +## Connector or plugin not found + +**Symptom:** `IntegrationError: Connector "foo" not found` or `PluginError: Plugin "bar" not installed`. + +**Cause:** The integration or plugin is referenced in the DSL but is not registered on the worker. + +**Resolution:** + +- Confirm the `integrationId` or `pluginId` matches a record in `integration_definition` or the plugin registry. +- For OAuth integrations, the user must connect their account first. +- For custom plugins, the WASM or built-in plugin must be loaded on worker startup. + +## When all else fails + +- Tail the worker logs (`docker compose logs -f vortex-worker`). Every step logs structured records at `info` and `error`. +- Drop a [`log`](./step-types) step early in the workflow with `data: { ctx: "{{triggerData}}" }` to see exactly what the executor received. +- Reproduce the failure with a minimal workflow before changing the production one. +- File an issue at [github.com/omnidotdev/vortex](https://github.com/omnidotdev/vortex) with the workflow ID and a redacted run trace. + +## Next steps + + + + Design patterns that avoid these errors + + + Environment variables and service tuning + + + Deployment topology + + From d239095f8b27909cb92c7acca28ebe372bd665a1 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Mon, 22 Jun 2026 22:22:33 -0500 Subject: [PATCH 10/13] docs(gatekeeper): document account-security notification emails Note that Gatekeeper sends security alert emails (via Herald) on password change, two-factor enable/disable, and account deletion, alongside the existing verification/reset/OTP emails. --- content/docs/armory/hidra/gatekeeper.mdx | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 content/docs/armory/hidra/gatekeeper.mdx diff --git a/content/docs/armory/hidra/gatekeeper.mdx b/content/docs/armory/hidra/gatekeeper.mdx new file mode 100644 index 0000000..b98bda3 --- /dev/null +++ b/content/docs/armory/hidra/gatekeeper.mdx @@ -0,0 +1,85 @@ +--- +title: 🚪 Gatekeeper +description: Authentication service for the HIDRA identity platform +--- + +import { ProductOverview } from "@/components/docs"; +import app from "@/lib/config/app.config"; +import { FaKey, FaUserCircle, FaShieldAlt } from "react-icons/fa"; + +, + className: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300", + }, + { + label: "OIDC", + icon: , + className: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/20 dark:text-cyan-300", + }, + { + label: "SSO", + icon: , + className: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300", + }, + ]} + links={[ + { + href: `${app.socials.github}/hidra`, + label: "Visit Repository", + type: "repository", + }, + ]} + alerts={[ + { + title: "Part of HIDRA", + description: + "Gatekeeper is the authentication component of HIDRA. See the HIDRA docs for the full architecture.", + }, + ]} +/> + +**Omni Gatekeeper** is a batteries-included OIDC identity provider powered by Better Auth. It handles login, registration, sessions, and identity federation for the Omni ecosystem. + +**"Who are you?"** + +## Key Features + +- **OpenID Connect (OIDC)**: Standards-compliant identity provider +- **Session Management**: Secure session handling with refresh tokens +- **Organization Membership**: Multi-tenant organization support +- **SSO Support**: Enterprise single sign-on integration +- **Social Login**: OAuth providers (Google, GitHub, etc.) +- **Identity Federation**: Connect external identity providers + +## Account Security Notifications + +Gatekeeper sends a security alert email (delivered through [Herald](/core/herald)) whenever a sensitive change is made to an account, so the owner is notified even if they did not make the change: + +- **Password changed** — after a reset or a self-service change +- **Two-factor authentication enabled or disabled** +- **Account deleted** + +These are in addition to the standard verification, password-reset, email-change, and one-time-code emails. + +## life.json Host + +Gatekeeper serves as the canonical host for [life.json](/codex/life-json) data. While Better Auth manages authentication concerns (email, sessions, organization membership), the life.json layer stores portable identity data alongside it: + +- **Identity** — Name, pronouns, timezone, locale, location, occupation, bio +- **Preferences** — Language, theme, communication style, accessibility, units +- **Assistants** — Per-assistant learned facts, preferences, context, and permissions + +### How it works + +Auth data and identity data are cleanly separated. The only shared field is `name`, where the Gatekeeper user record is the source of truth. + +Products read life.json slices via OIDC scopes (`life:identity`, `life:preferences`, `life:assistants`) and write to their assigned slices via the Gatekeeper REST API, authorized by [Warden](/armory/hidra/warden). + +Users can export a complete, valid life.json file at any time via `GET /api/life/export` — the portability guarantee of the spec. + +## Getting Started + +Detailed documentation coming soon. From 63103e8d5cc0ffdaad800b0e2c674f480657bdec Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Mon, 22 Jun 2026 22:26:46 -0500 Subject: [PATCH 11/13] revert(docs): unpublish gatekeeper page; HIDRA section not launched content/docs/armory/hidra/{index,warden,gatekeeper}.mdx are unpublished local WIP. gatekeeper.mdx was committed by mistake (an untracked draft); remove it from the published docs and keep it local until the HIDRA section launches. --- content/docs/armory/hidra/gatekeeper.mdx | 85 ------------------------ 1 file changed, 85 deletions(-) delete mode 100644 content/docs/armory/hidra/gatekeeper.mdx diff --git a/content/docs/armory/hidra/gatekeeper.mdx b/content/docs/armory/hidra/gatekeeper.mdx deleted file mode 100644 index b98bda3..0000000 --- a/content/docs/armory/hidra/gatekeeper.mdx +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: 🚪 Gatekeeper -description: Authentication service for the HIDRA identity platform ---- - -import { ProductOverview } from "@/components/docs"; -import app from "@/lib/config/app.config"; -import { FaKey, FaUserCircle, FaShieldAlt } from "react-icons/fa"; - -, - className: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300", - }, - { - label: "OIDC", - icon: , - className: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/20 dark:text-cyan-300", - }, - { - label: "SSO", - icon: , - className: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300", - }, - ]} - links={[ - { - href: `${app.socials.github}/hidra`, - label: "Visit Repository", - type: "repository", - }, - ]} - alerts={[ - { - title: "Part of HIDRA", - description: - "Gatekeeper is the authentication component of HIDRA. See the HIDRA docs for the full architecture.", - }, - ]} -/> - -**Omni Gatekeeper** is a batteries-included OIDC identity provider powered by Better Auth. It handles login, registration, sessions, and identity federation for the Omni ecosystem. - -**"Who are you?"** - -## Key Features - -- **OpenID Connect (OIDC)**: Standards-compliant identity provider -- **Session Management**: Secure session handling with refresh tokens -- **Organization Membership**: Multi-tenant organization support -- **SSO Support**: Enterprise single sign-on integration -- **Social Login**: OAuth providers (Google, GitHub, etc.) -- **Identity Federation**: Connect external identity providers - -## Account Security Notifications - -Gatekeeper sends a security alert email (delivered through [Herald](/core/herald)) whenever a sensitive change is made to an account, so the owner is notified even if they did not make the change: - -- **Password changed** — after a reset or a self-service change -- **Two-factor authentication enabled or disabled** -- **Account deleted** - -These are in addition to the standard verification, password-reset, email-change, and one-time-code emails. - -## life.json Host - -Gatekeeper serves as the canonical host for [life.json](/codex/life-json) data. While Better Auth manages authentication concerns (email, sessions, organization membership), the life.json layer stores portable identity data alongside it: - -- **Identity** — Name, pronouns, timezone, locale, location, occupation, bio -- **Preferences** — Language, theme, communication style, accessibility, units -- **Assistants** — Per-assistant learned facts, preferences, context, and permissions - -### How it works - -Auth data and identity data are cleanly separated. The only shared field is `name`, where the Gatekeeper user record is the source of truth. - -Products read life.json slices via OIDC scopes (`life:identity`, `life:preferences`, `life:assistants`) and write to their assigned slices via the Gatekeeper REST API, authorized by [Warden](/armory/hidra/warden). - -Users can export a complete, valid life.json file at any time via `GET /api/life/export` — the portability guarantee of the spec. - -## Getting Started - -Detailed documentation coming soon. From 48d4ee48399c2d1b622565a0ec5bdfaa850fa48c Mon Sep 17 00:00:00 2001 From: Brian Cooper <20056195+coopbri@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:11:28 -0500 Subject: [PATCH 12/13] docs(herald): product documentation (#22) Adds the Herald docs section: quickstart, API keys, sending domains, templates and variables, open/click tracking, webhooks, and the SDK/MCP server, alongside the existing overview and architecture pages. Navigation ordered via meta.json. Grounded in the shipped API (REST + GraphQL). --- content/docs/core/herald/api-keys.mdx | 51 ++++++++++++++ content/docs/core/herald/architecture.mdx | 80 +++++++++++++++++++++ content/docs/core/herald/domains.mdx | 53 ++++++++++++++ content/docs/core/herald/index.mdx | 80 +++++++++++++++++++++ content/docs/core/herald/meta.json | 14 ++++ content/docs/core/herald/quickstart.mdx | 84 +++++++++++++++++++++++ content/docs/core/herald/sdk.mdx | 52 ++++++++++++++ content/docs/core/herald/templates.mdx | 63 +++++++++++++++++ content/docs/core/herald/tracking.mdx | 54 +++++++++++++++ content/docs/core/herald/webhooks.mdx | 52 ++++++++++++++ 10 files changed, 583 insertions(+) create mode 100644 content/docs/core/herald/api-keys.mdx create mode 100644 content/docs/core/herald/architecture.mdx create mode 100644 content/docs/core/herald/domains.mdx create mode 100644 content/docs/core/herald/index.mdx create mode 100644 content/docs/core/herald/meta.json create mode 100644 content/docs/core/herald/quickstart.mdx create mode 100644 content/docs/core/herald/sdk.mdx create mode 100644 content/docs/core/herald/templates.mdx create mode 100644 content/docs/core/herald/tracking.mdx create mode 100644 content/docs/core/herald/webhooks.mdx diff --git a/content/docs/core/herald/api-keys.mdx b/content/docs/core/herald/api-keys.mdx new file mode 100644 index 0000000..e42bd9a --- /dev/null +++ b/content/docs/core/herald/api-keys.mdx @@ -0,0 +1,51 @@ +--- +title: API keys +description: Authenticate to Herald and scope access +--- + +Herald authenticates every request with a bearer API key that resolves to a +single tenant. Pass it as `authorization: Bearer ` on REST and GraphQL +requests alike. + +## Permission levels + +| Level | Can send | Can manage tenant (keys, domains, templates, suppressions, settings) | +| --------- | -------- | -------------------------------------------------------------------- | +| `full` | yes | yes | +| `sending` | yes | no | + +Use a `sending` key for the application code path that only emits mail, and keep +`full` keys for administrative tooling. + +## Creating a key + +From the dashboard (**API Keys → New key**) or the `createApiKey` mutation: + +```graphql +mutation { + createApiKey( + input: { name: "production-sender", permission: "sending", expiresAt: "2027-01-01T00:00:00Z" } + ) { + apiKeyId + secret + } +} +``` + +The `secret` is returned **once** and stored only as a peppered hash; Herald +cannot show it again. The leading prefix is retained so you can recognise a key +in the dashboard without revealing it. + +`expiresAt` is optional; after it passes the key stops authenticating. + +## Rotating and revoking + +Create the replacement key, deploy it, then revoke the old one with +`revokeApiKey(apiKeyId: ...)`. Revocation is immediate. A revoked or expired key +returns `401`. + +## Keep keys secret + +Treat keys like passwords: never commit them, never log them, and prefer the +shortest-lived key that works. Herald never logs the key, the recipient, or the +message body. diff --git a/content/docs/core/herald/architecture.mdx b/content/docs/core/herald/architecture.mdx new file mode 100644 index 0000000..91f24ad --- /dev/null +++ b/content/docs/core/herald/architecture.mdx @@ -0,0 +1,80 @@ +--- +title: Architecture +description: How Herald is built +--- + +Herald is two pieces: a stateless send API and a self-hosted MTA. The API owns +tenancy, validation, suppression, and delivery state; the MTA owns signing and +the SMTP path. + +## Components + +``` + caller (service or Vortex workflow) + │ POST /messages (Bearer ) + ▼ + ┌──────────────────┐ inject (HTTP, Basic auth) + │ herald-api │ ───────────────────────────────┐ + │ (Bun + Elysia) │ ▼ + └────────┬─────────┘ ┌──────────────────┐ + │ │ KumoMTA │ + ┌────────▼─────────┐ webhook (log_hooks) │ (mail.omni.dev) │ + │ PostgreSQL │ ◀──────────────────────┤ DKIM + SMTP :25 │ + │ tenants, keys, │ └────────┬─────────┘ + │ messages, events,│ │ deliver + │ suppression │ ▼ + └──────────────────┘ recipient mail servers +``` + +### herald-api + +A Bun + Elysia service. Responsibilities: + +- **Auth & tenancy**: an `Authorization: Bearer ` is hashed and matched to a tenant; routes decide whether a tenant is required. There is no public GraphQL surface in this phase. +- **Send (`POST /messages`)**: validate the body at the boundary, reject suppressed recipients, short-circuit duplicate idempotency keys, persist a `queued` message, then inject into the MTA. On an engine failure the message is marked `failed` and the caller gets a `502` so it can fail over. +- **Webhook (`POST /webhooks/kumomta`)**: ingest delivery lifecycle records, advance `message.status`, and auto-suppress hard bounces and complaints. Always acks `200` so the MTA does not retry forever. +- **Events**: emit `herald.message.*` CloudEvents to Vortex (source `omni.herald`). + +The mail engine is injected behind a `MailEngine` interface, so KumoMTA can be +swapped without touching the routes. + +### KumoMTA host + +A dedicated, isolated Hetzner box (`mail.omni.dev`) with a dedicated mail IP and +PTR, never sharing application egress. It runs: + +- an **HTTP injection** endpoint behind Basic auth (Caddy terminates TLS, proxies to KumoMTA), +- **DKIM signing** for the sending domain (selector `herald`), +- an **SMTP listener** on `:25` to receive bounces and DSNs, +- a **`log_hooks`** module that POSTs delivery/bounce/complaint records to `herald-api`. + +## Correlation + +KumoMTA's injection API does not return a per-message id, so Herald threads its +own message id through an `X-Herald-Message-Id` header on the injected content. +The MTA's `log_hooks` is configured to echo that header on every delivery record, +and the webhook correlates events back to the originating message by it (falling +back to the stored external id). This is what lets an asynchronous bounce or +complaint, arriving seconds or minutes later, find its message and auto-suppress +the recipient. + +## Data model (essentials) + +| Table | Holds | +|-------|-------| +| `tenant` | one row per sending tenant (org binding, name) | +| `api_key` | hashed API keys, `lastUsedAt`, tenant FK | +| `sending_domain` | per-tenant verified domains + DKIM selector | +| `message` | every send: addresses, subject, status, external id, idempotency key | +| `message_event` | immutable raw delivery/bounce/complaint records | +| `suppression` | per-tenant suppressed addresses with a reason | + +## Send path, end to end + +1. Caller `POST /messages` with a tenant API key. +2. Resolve tenant → validate body → suppression check → insert `queued` message. +3. Inject into KumoMTA with `X-Herald-Message-Id`; store the returned id; mark sent. +4. KumoMTA DKIM-signs and delivers over SMTP `:25`. +5. KumoMTA `log_hooks` POSTs the outcome to `/webhooks/kumomta`. +6. Herald correlates by the echoed header, advances status, auto-suppresses hard + bounces/complaints, and emits a `herald.message.*` CloudEvent. diff --git a/content/docs/core/herald/domains.mdx b/content/docs/core/herald/domains.mdx new file mode 100644 index 0000000..4e5d9a1 --- /dev/null +++ b/content/docs/core/herald/domains.mdx @@ -0,0 +1,53 @@ +--- +title: Sending domains +description: Add and verify a domain to send from +--- + +Herald sends only from domains you have verified. Verification proves control of +the domain and publishes a DKIM key so the MTA can sign your mail, which is what +makes it deliverable. + +## Add a domain + +From the dashboard (**Domains → Add domain**) or `createSendingDomain`: + +```graphql +mutation { + createSendingDomain(input: { domain: "send.example.com" }) { + domainId + dkimSelector + dkimPublicKey + verified + } +} +``` + +Herald generates a DKIM key pair and returns the public half. Use a dedicated +sending subdomain (for example `send.example.com`) rather than your root domain, +so sending reputation stays isolated from the rest of your mail. + +## Publish DNS and verify + +Add the DKIM public key as a `TXT` record at +`._domainkey.` (the dashboard shows the exact record), then +verify: + +```graphql +mutation { + verifySendingDomain(id: "") { + verified + } +} +``` + +Verification resolves the record and checks it matches the key Herald generated. +A missing record reads as "not yet verified" rather than an error, so you can +re-run it after DNS propagates. + +For the best inbox placement, also publish `SPF` and `DMARC` records for the +sending domain. Herald surfaces deliverability recommendations in the dashboard. + +## The send gate + +Until a domain is verified, any send whose `from` address uses it is rejected. +Once verified, messages from that domain are signed and sent. diff --git a/content/docs/core/herald/index.mdx b/content/docs/core/herald/index.mdx new file mode 100644 index 0000000..1d2a249 --- /dev/null +++ b/content/docs/core/herald/index.mdx @@ -0,0 +1,80 @@ +--- +title: Herald +description: Transactional email platform for the Omni stack +--- + +import { ProductOverview } from "@/components/docs"; +import app from "@/lib/config/app.config"; +import { PiEnvelopeSimple, PiPaperPlaneTilt, PiShieldCheck } from "react-icons/pi"; + +, + className: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300", + }, + { + label: "Transactional", + icon: , + className: "bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300", + }, + { + label: "Deliverability", + icon: , + className: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300", + }, + ]} + links={[ + { + href: `${app.socials.github}/herald-api`, + label: "Visit Repository", + type: "repository", + }, + ]} + alerts={[ + { + title: "Big Idea", + description: + "Own the transactional email path end to end: a multi-tenant send API in front of a self-hosted MTA with a dedicated sending reputation, suppression, and delivery feedback.", + }, + ]} +/> + +**Herald** is Omni's transactional email platform. It pairs a small multi-tenant +send API with a self-hosted [KumoMTA](https://kumomta.com) mail host on a +dedicated IP, so product emails (order receipts, notifications, verification) +flow over infrastructure Omni controls rather than a third-party ESP, while +keeping a third-party provider available as a fallback. + +## Key Features + +- **Multi-tenant send API**: API-key auth resolves a tenant; each tenant has its own suppression list and verified sending domains. +- **Self-hosted MTA**: KumoMTA on a dedicated Hetzner host with a dedicated mail IP, PTR, and DKIM signing, isolated from application egress to protect sending reputation. +- **Suppression built in**: hard bounces and spam complaints auto-suppress the recipient; suppressed addresses are rejected before any send. +- **Delivery feedback**: KumoMTA posts delivery/bounce/complaint events back to Herald, which advances message status and emits CloudEvents. +- **Idempotent sends**: an optional idempotency key dedupes retries so a redelivered request never sends twice. +- **Swappable engine**: the mail engine sits behind an interface, so the underlying MTA can change without touching callers. + +## How it fits + +Herald is consumed two ways: + +1. **Directly** by services that send their own mail (`POST /messages` with a tenant API key). +2. **Through [Vortex](/docs/core/vortex)** workflows, which render an email and hand it to Herald, falling back to a third-party provider on failure. + +Inter-service delivery events are published as [CloudEvents](https://cloudevents.io) +(`herald.message.delivered`, `herald.message.bounced`, `herald.message.complained`) +under the source `omni.herald`. + +## Sending domain + +Herald signs and sends from a dedicated sending subdomain (`send.omni.dev`) with +SPF, a `herald` DKIM selector, and DMARC configured, while the MTA host itself +(`mail.omni.dev`) carries the PTR and receives bounce/feedback mail. Keeping the +sending domain separate from the primary domain isolates reputation. + +## Status + +Herald is an internal platform service. See [Architecture](/docs/core/herald/architecture) +for how the API and MTA fit together. diff --git a/content/docs/core/herald/meta.json b/content/docs/core/herald/meta.json new file mode 100644 index 0000000..fa7c306 --- /dev/null +++ b/content/docs/core/herald/meta.json @@ -0,0 +1,14 @@ +{ + "title": "Herald", + "pages": [ + "index", + "quickstart", + "api-keys", + "domains", + "templates", + "tracking", + "webhooks", + "sdk", + "architecture" + ] +} diff --git a/content/docs/core/herald/quickstart.mdx b/content/docs/core/herald/quickstart.mdx new file mode 100644 index 0000000..dd571b0 --- /dev/null +++ b/content/docs/core/herald/quickstart.mdx @@ -0,0 +1,84 @@ +--- +title: Quickstart +description: Send your first email with Herald +--- + +This guide takes you from nothing to a delivered email: create an API key, +verify a sending domain, and send a message over REST or GraphQL. + +## 1. Create an API key + +Every request authenticates as a tenant with a bearer API key. Create one from +the dashboard (**API Keys → New key**) or with the `createApiKey` mutation. The +secret is shown **once**, so store it immediately. + +Keys carry a permission level: + +- `full` manages the tenant (keys, domains, templates, suppressions) and sends. +- `sending` may only send messages. + +See [API keys](/docs/core/herald/api-keys) for expiry and rotation. + +## 2. Verify a sending domain + +Herald only sends from a domain you have verified, which proves you control it +and lets Herald publish a DKIM key for signing. Add a domain (**Domains → Add +domain**), then publish the DKIM `TXT` record Herald shows you and click +**Verify**. See [Sending domains](/docs/core/herald/domains). + +## 3. Send a message + +### REST + +```bash +curl https://api.herald.omni.dev/messages \ + -H "authorization: Bearer $HERALD_API_KEY" \ + -H "content-type: application/json" \ + -d '{ + "to": "recipient@example.com", + "from": "hello@send.example.com", + "subject": "Welcome aboard", + "html": "

Thanks for signing up.

", + "idempotencyKey": "signup-12345" + }' +``` + +`from` must belong to a verified sending domain or the send is rejected. An +optional `idempotencyKey` dedupes retries so a redelivered request never sends +twice. Other fields: `text`, `cc`, `bcc`, `replyTo`, `attachments`, `sendAt` +(future ISO-8601 to schedule), `variables`, `trackOpens`, `trackClicks`. + +### GraphQL + +```graphql +mutation Send { + sendMessage( + input: { + to: "recipient@example.com" + from: "hello@send.example.com" + subject: "Welcome aboard" + html: "

Thanks for signing up.

" + } + ) { + messageId + status + } +} +``` + +The GraphQL endpoint is `https://api.herald.omni.dev/graphql`, authenticated with +the same bearer key. + +## 4. Track delivery + +Herald records every message and its delivery events (`delivered`, `bounced`, +`complained`, and, when enabled, `opened`/`clicked`). View them in the dashboard +**Messages** log, query the `messages` connection, or subscribe to +[webhooks](/docs/core/herald/webhooks). Hard bounces and complaints add the +recipient to your suppression list automatically. + +## Next steps + +- [Templates and variables](/docs/core/herald/templates) for reusable, personalized content +- [Open and click tracking](/docs/core/herald/tracking) +- [The TypeScript SDK and MCP server](/docs/core/herald/sdk) diff --git a/content/docs/core/herald/sdk.mdx b/content/docs/core/herald/sdk.mdx new file mode 100644 index 0000000..d35ae84 --- /dev/null +++ b/content/docs/core/herald/sdk.mdx @@ -0,0 +1,52 @@ +--- +title: SDK and MCP server +description: Typed client and an agent-ready tool surface +--- + +Herald ships a typed TypeScript client and a Model Context Protocol (MCP) server, +both generated from the same GraphQL schema so they stay in lockstep with the +API. + +## TypeScript SDK + +`@omnidotdev/herald` wraps the generated GraphQL client with an ergonomic facade: +API-key auth, automatic idempotency keys, and named methods. + +```ts +import { createHerald } from "@omnidotdev/herald"; + +const herald = createHerald({ apiKey: process.env.HERALD_API_KEY }); + +await herald.emails.send({ + to: "recipient@example.com", + from: "hello@send.example.com", + subject: "Welcome aboard", + html: "

Thanks for signing up.

", +}); +``` + +The client also exposes the messages, domains, and API-key operations. Because it +is generated from the schema, new fields and operations appear in the client as +the API grows. + +## MCP server + +`herald-mcp` is a standalone [Model Context Protocol](https://modelcontextprotocol.io) +server that exposes a curated, agent-friendly subset of Herald as tools, so an +assistant can send and inspect mail on a tenant's behalf: + +- `send_email` +- `list_messages` +- `get_message` +- `list_domains` +- `verify_domain` + +It speaks MCP over stdio and calls the Herald API with the tenant token supplied +in its environment, so the same permission model applies: the agent can only do +what its key allows. + +## Regenerating + +Both targets are generated with [ODK](/docs/armory/odk) from the committed Herald +schema and operation documents. Regenerate after the schema changes so the SDK, +its hooks, and the MCP tools reflect the current API. diff --git a/content/docs/core/herald/templates.mdx b/content/docs/core/herald/templates.mdx new file mode 100644 index 0000000..4b69978 --- /dev/null +++ b/content/docs/core/herald/templates.mdx @@ -0,0 +1,63 @@ +--- +title: Templates and variables +description: Reusable content with Handlebars merge fields +--- + +Templates store reusable subject, HTML, and text content. Combined with +variables, they let you send personalized mail without rebuilding the body on +every call. + +## Create a template + +From the dashboard (**Templates**) or `createTemplate`: + +```graphql +mutation { + createTemplate( + input: { + name: "welcome" + subject: "Welcome, {{name}}" + html: "

Hi {{name}}, thanks for joining {{company}}.

" + } + ) { + templateId + } +} +``` + +## Variables + +Herald renders [Handlebars](https://handlebarsjs.com) tags server-side before +the message is stored and injected, so the stored body and the delivered body +are identical. + +- `{{value}}` inserts an HTML-escaped value (the safe default). +- `{{{value}}}` inserts a raw, unescaped value. Use it only for content you + trust, since values are tenant-provided. + +Pass `variables` on a send to fill the tags: + +```json +{ + "to": "ada@example.com", + "from": "hello@send.example.com", + "subject": "Welcome, {{name}}", + "html": "

Hi {{name}}.

", + "variables": { "name": "Ada", "company": "Omni" } +} +``` + +When no `variables` are supplied the content is sent verbatim, so a body that +contains literal braces is never reinterpreted. + +## Preview + +Use the template preview (the editor's live preview, backed by the +`renderTemplate` query) to render a template against sample variables and see the +final subject and body before sending. + +## Broadcasts + +For audience sends, store per-contact attributes on each contact; a broadcast +renders the template with each recipient's own attributes, so one template +personalizes the whole send. diff --git a/content/docs/core/herald/tracking.mdx b/content/docs/core/herald/tracking.mdx new file mode 100644 index 0000000..5612a9f --- /dev/null +++ b/content/docs/core/herald/tracking.mdx @@ -0,0 +1,54 @@ +--- +title: Open and click tracking +description: Measure engagement on the messages you send +--- + +Herald can record when a recipient opens a message or clicks a link, surfacing +engagement in the message log and emitting `opened`/`clicked` events to your +webhooks. + +## Enable per send + +Set `trackOpens` and/or `trackClicks` on a send: + +```json +{ + "to": "recipient@example.com", + "from": "hello@send.example.com", + "subject": "Product update", + "html": "

See what's new.

", + "trackOpens": true, + "trackClicks": true +} +``` + +- **Opens** inject a 1x1 tracking pixel before ``. The pixel URL carries + an HMAC-signed token, so tokens cannot be forged or enumerated. +- **Clicks** rewrite each `` to a redirect endpoint that records the + click and then `302`s to the original URL. The original URL is stored, which + avoids an open redirect and yields per-link stats. + +Tracking only ever rewrites the delivered copy; the stored message body stays +clean, so a resend re-applies tracking fresh. + +## Tenant defaults + +Rather than setting flags on every send, set tenant-wide defaults in the +dashboard **Settings** page (`trackOpensDefault`, `trackClicksDefault`). A send +that omits `trackOpens`/`trackClicks` inherits the tenant default; an explicit +value on the send always wins. + +Transactional and verification mail is best left untracked for deliverability +and privacy; marketing broadcasts default to tracking on. + +## Tracking host + +Tracking links are served by Herald on a dedicated host so they sit on your +sending brand rather than the API host. A sending domain may also override the +host per domain with its own tracking subdomain. + +## Consuming engagement + +Opens and clicks appear on the message in the dashboard log and are delivered to +[webhook endpoints](/docs/core/herald/webhooks) subscribed to those event types, +alongside the standard delivery events. diff --git a/content/docs/core/herald/webhooks.mdx b/content/docs/core/herald/webhooks.mdx new file mode 100644 index 0000000..1d2bfdf --- /dev/null +++ b/content/docs/core/herald/webhooks.mdx @@ -0,0 +1,52 @@ +--- +title: Webhooks +description: Receive delivery and engagement events +--- + +Webhooks push message events to your own endpoint as they happen, so you can +react to deliveries, bounces, complaints, opens, and clicks without polling. + +## Register an endpoint + +From the dashboard (**Webhooks**) or `createWebhook`: + +```graphql +mutation { + createWebhook( + input: { url: "https://example.com/hooks/herald", eventTypes: ["bounced", "complained"] } + ) { + webhookId + secret + } +} +``` + +The `secret` is returned once and used to sign deliveries. Omit `eventTypes` +(or pass an empty list) to receive every event; otherwise you only receive the +types you list. + +## Event types + +- `delivered` +- `bounced` +- `complained` +- `opened` (requires [open tracking](/docs/core/herald/tracking)) +- `clicked` (requires click tracking) + +## Verifying signatures + +Each delivery is signed with an HMAC-SHA256 over the payload using your endpoint +secret, alongside a timestamp and version field. Recompute the signature with +your stored secret and compare before trusting a payload. Reject anything that +does not match. + +## Delivery, retries, and replay + +Deliveries are durable: each is enqueued and sent by a poller with exponential +backoff (escalating from about a minute up to several hours), and dead-lettered +after the maximum attempts rather than dropped. Recent deliveries appear in the +dashboard with their status, and a failed or dead delivery can be **replayed** +for an immediate retry once your endpoint is healthy again. + +Respond `2xx` promptly to acknowledge a delivery; a non-`2xx` or timeout is +treated as a failure and retried. From 3c2347f9adf04de78c0e53eb9cfb92d83360bfbc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:12:30 +0000 Subject: [PATCH 13/13] chore(deps): update dependency @vitejs/plugin-react to v6 --- bun.lock | 20 +++----------------- package.json | 2 +- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/bun.lock b/bun.lock index 529aac7..ef6bffd 100644 --- a/bun.lock +++ b/bun.lock @@ -36,7 +36,7 @@ "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", + "@vitejs/plugin-react": "^6.0.0", "husky": "^9.1.7", "knip": "^5.79.0", "nitro": "3.0.1-alpha.1", @@ -80,10 +80,6 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="], - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], @@ -406,7 +402,7 @@ "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="], @@ -570,14 +566,6 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], @@ -614,7 +602,7 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], "@xyflow/react": ["@xyflow/react@12.10.0", "", { "dependencies": { "@xyflow/system": "0.0.74", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw=="], @@ -1130,8 +1118,6 @@ "react-medium-image-zoom": ["react-medium-image-zoom@5.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BsE+EnFVQzFIlyuuQrZ9iTwyKpKkqdFZV1ImEQN573QPqGrIUuNni7aF+sZwDcxlsuOMayCr6oO/PZR/yJnbRg=="], - "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], diff --git a/package.json b/package.json index d212832..d9af447 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", + "@vitejs/plugin-react": "^6.0.0", "husky": "^9.1.7", "knip": "^5.79.0", "nitro": "3.0.1-alpha.1",