npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@businessdash/sdk

v0.9.81

Published

BusinessDash developer SDK — the data layer that lets you build customer-facing surfaces on your own domain while BusinessDash handles the operations stack behind the scenes. 0.9.x ships a comprehensive programmatic surface: storefront (products, categori

Readme

@businessdash/sdk

Formerly published as @biab-dev/sdk (≤0.9.53) — same API, new name.

Alpha — 0.9.53. This package declares, validates, and orchestrates your application's database schema, form layout, and type-safe field rules in a single declarative pipeline — and ships the answer-engine surface (llms.txt + product feed, MCP connector proxy, AI-crawler analytics) that makes the site you build with it legible to AI assistants.


Install

pnpm add @businessdash/sdk

Imports

import {
  collection,
  bd,
  Enums,
  createFormSchemaOrchestrator,
  layoutContainers,
  SchemaBoundary,
} from "@businessdash/sdk";

// Destructure visual layout wrappers for intuitive, HTML-like inline grouping
const { FormStep, ConditionalBlock, MultiPopulator, OrCondition } = layoutContainers;

Core bd Primitive Modifiers

The compact bd factory namespace eliminates boilerplate. Each chain method implements fluent builders to define data rules, UI presentation hints, database constraints, and cryptographic security features.

Core Field API Reference

bd.text().formElement("short_text")       // Map raw DB type to a front-end input component
  .validate({ minLength: 3, maxLength: 100 })  // Append structural validation parameters
  .helper("The display name of the item")       // Descriptive metadata for tooltips
  .regex(/^[A-Z][a-z]+$/)                      // Absolute input string validation
  .check("name != ''")                          // Low-level SQL CHECK constraint
  .encrypted()                                  // Per-user cryptographic isolation
  .encryptedLight()                             // Master key encryption-at-rest
  .private()                                    // Strip from public log telemetry
  .required();

Available .validate() rules:

| Rule | Applies to | Description | |---|---|---| | minLength | TEXT, ARRAY | Minimum characters or entries | | maxLength | TEXT, ARRAY | Maximum characters or entries | | lessThan | NUMBER, NUMERIC | Strictly less than | | greaterThan | NUMBER, NUMERIC | Strictly greater than | | round | NUMBER, NUMERIC | Decimal places to round to | | positive | NUMBER, NUMERIC | Must be >= 0 | | notNull | Any | Reject null/empty | | floor | NUMBER, NUMERIC | Floor the value | | isDecimal | NUMBER, NUMERIC | Must have decimal component | | regex | TEXT | Pattern match | | checkExpression | TEXT, NUMBER | SQL CHECK expression | | lessThanToday | DATE, DATE_TIME | Must be historical | | greaterThanToday | DATE, DATE_TIME | Must be future |

Form Elements

.formElement("short_text")     // TEXT → short/long/email/url/dropdown/radio/color/…
.formElement("currency")       // NUMBER/NUMERIC → currency/range/star_rating/…
.formElement("yes_no_toggle")  // BOOLEAN → yes_no_toggle / yes_no_checkbox
.formElement("dropdown")       // SELECT → dropdown / radio_group
.formElement("measurement")    // RAW_JSON → measurement/address_group/geo/object
.formElement("formFileValue")  // FILES → file upload

Complex Data Fields

bd.measurement(options) — Explicit physics metrics compiler block:

memoryBusSpeed: bd.measurement({
  category: "storageSpeed",
  unitPicker: "fixed",
  default_unit: "MHz",
  resolve_to_your_unit: "MT/s",
  allow_decimals: true,
}).required()

Supported categories: sound, pressure, torque, speed (incl. Mach, ly/yr), time, area, volume, power, energy, storage, storageSpeed, frequency, temperature, mass, length ( incl. µm, nm), currency.

bd.object(properties) — Structural nested JSON with storage strategy:

metadata: bd.object({
  sku: bd.text().required(),
  warehouse: bd.text(),
}).strategy("flattened")  // or "jsonb" | "separateTable" (spawns 1:1 side-table)

All RAW_JSON object elements support JSONB output, not only bd.object():

shippingAddress: bd.address().strategy("jsonb"),
availability: bd.json().formElement("availability").strategy("jsonb"),
bookingWindow: bd.json().formElement("date_range").strategy("jsonb"),
coordinates: bd.geo().strategy("jsonb"),

strategy and separateTableName are included in toJSONSchema().

bd.file(options) — File uploads:

architectureSpecs: bd.file({
  max_size_mb: 15,
  allowed_mimetypes: ["application/pdf"],
}).optional()

Optional Schema Boundaries

SchemaBoundary() is feature metadata, not form layout. It accepts collections or nested boundaries directly and does not require a step, wizard, repeater, or group:

const projects = collection("projects", {
  fields: { title: bd.text().required() },
});

const direct = createFormSchemaOrchestrator([projects]);

const tagged = createFormSchemaOrchestrator([
  SchemaBoundary(
    { featureId: "projects", name: "Projects" },
    projects,
  ),
]);

Both declarations compile the same collection. The second additionally sets tagged.featureTags.project to "projects".

High-Utility Form Layout Containers

Layout containers are optional semantic wrappers. Add them only when the UI needs steps, conditional visibility, repetition, or branching.

FormStep(name, options, ...children)

Wraps schema portions into sequential steps for multi-step wizards:

FormStep("taxonomy_setup", {
  showTitle: true,
  helperText: "Configure high-level project taxonomy.",
},
  collection("categories", { fields: { name: bd.text().required() } }),
  collection("feature_groups", { fields: { name: bd.text().required() } }),
)

ConditionalBlock(config, ...children)

Dynamically toggles visibility based on matching logic gates:

ConditionalBlock(
  {
    matchStrategy: "AND",
    showTitle: true,
    rules: [
      { fieldRef: "project.isActive", op: "equals", values: [true] },
    ],
  },
  collection("features", { fields: { name: bd.text().required() } }),
)

Supports matchStrategy: 'AND' | 'OR' and operators: equals, not_equals, any_of, none_of.

MultiPopulator(config, ...children)

Generates an N+1 array row repeater grid. A single child collection is inferred as a secondary-table output:

MultiPopulator({ max_entries: 5, button_label: "➕ Register Node" },
  collection("hardware_nodes", {
    fields: {
      nodeSerial: bd.text().required().private(),
      memoryBusSpeed: bd.measurement({
        category: "storageSpeed",
        unitPicker: "fixed",
        default_unit: "MHz",
        resolve_to_your_unit: "MT/s",
        allow_decimals: true,
      }).required(),
    },
  }),
)

To keep the repeated array on a parent record instead, bind the repeater to a JSONB field and declare the shape of one entry:

const projects = collection("projects", {
  fields: {
    name: bd.text().required(),
    teamMembers: bd.array().strategy("jsonb"),
  },
})

createFormSchemaOrchestrator([
  projects,
  MultiPopulator({
    max_entries: 10,
    output: {
      strategy: "jsonb",
      fieldRef: "project.teamMembers",
    },
    entryFields: {
      name: bd.text().required(),
      role: bd.text().optional(),
    },
  }),
])

The orchestrator rejects missing targets, scalar JSONB targets, fields without .strategy("jsonb"), and ambiguous implicit secondary-table outputs.

OrCondition(options, branches)

Multi-path router layout engine for custom branching (tabbed selections):

OrCondition({ showTitle: true, button_label: "Select Target Pathway" }, [
  {
    name: "Hardware Validation Path",
    helperText: "Provisions system telemetry benchmarks.",
    nodes: [
      MultiPopulator({ max_entries: 5, button_label: "➕ Register Node" },
        collection("hardware_nodes", { fields: { /* ... */ } }),
      ),
    ],
  },
  {
    name: "Standard Baseline Deployment Only",
    nodes: [ /* ... */ ],
  },
])

Compile-Time Type Safety & Form Alignment

The SDK leverages TypeScript type inference to turn common engineering mistakes into instant IDE compile errors.

Invalid Field Refs

A typo inside a conditional block path (e.g. projects.isActveeee) fails validation at orchestration time:

⚠️ [BusinessDash SDK] Conditional rule references field "isActveeee"
   in collection "projects" which does not exist.

Data Type Mismatches

Conditional fieldRef values are validated when orchestration runs. The current public type is string, so invalid collection or field paths are runtime alignment errors rather than template-literal compile-time errors.

Implicit Key Generation

Primary keys are optional. The orchestration engine pre-scans collections, automatically injecting type-safe sequential integer or secure random uuid primary identifiers.


Unified Schema & Form Orchestration Blueprint

The complete declaration — combining database layers, field constraints, RLS policies, and layout:

import {
  collection, bd, Enums,
  createFormSchemaOrchestrator,
  layoutContainers,
} from "@businessdash/sdk";

const { FormStep, ConditionalBlock, MultiPopulator, OrCondition } = layoutContainers;

export const ProjectStatus = Enums(["planned", "in_progress", "beta", "completed"] as const);

export const applicationMasterContext = createFormSchemaOrchestrator([

  FormStep("taxonomy_setup", {
      showTitle: true,
      helperText: "Configure high-level project taxonomy and categorization mappings.",
    },
      collection("categories", {
        fields: {
          name: bd.text().formElement("short_text").required()
            .validate({ minLength: 3 }).helper("Category name."),
          icon: bd.text().formElement("short_text").optional(),
        },
      }),

      collection("feature_groups", {
        fields: {
          name: bd.text().formElement("short_text").required(),
          category_id: bd.relation()
            .references("categories").onDelete("CASCADE").required(),
        },
      }),
    ),

  FormStep("feature_engineering", { showTitle: true },

      collection("features", {
        rlsPolicies: [
          { roles: ["authenticated"], actions: ["read"], expression: "owner_id = auth.uid()" },
        ],
        fields: {
          name: bd.text().formElement("short_text").required().regex(/^[A-Z][a-z]+$/),
          status: bd.text().formElement("dropdown").required(),

          estimatedBudget: bd.number().formElement("currency")
            .check("estimated_budget >= 0").validate({ round: 2 }),

          feature_group_id: bd.relation()
            .references("feature_groups").onDelete("CASCADE").required(),

          related_features: bd.relation().belongsToMany("features", {
            joinCollectionName: "feature_references",
            fields: {
              isBidirectional: bd.boolean().formElement("yes_no_toggle").default(false),
            },
          }),
        },
      }),
  ),

  FormStep("routing_and_sandbox_studio", { showTitle: true },

      OrCondition({ showTitle: true, button_label: "Select Target Pathway" }, [
        {
          name: "Hardware Validation Path",
          helperText: "Provisions system telemetry benchmarks using physical metrics components.",
          nodes: [
            MultiPopulator({ max_entries: 5, button_label: "➕ Register Network Hardware Node" },
              collection("hardware_nodes", {
                fields: {
                  nodeSerial: bd.text().formElement("short_text").required().private(),
                  memoryBusSpeed: bd.measurement({
                    category: "storageSpeed",
                    unitPicker: "fixed",
                    default_unit: "MHz",
                    resolve_to_your_unit: "MT/s",
                    allow_decimals: true,
                  }).required(),
                  architectureSpecs: bd.file({
                    max_size_mb: 15,
                    allowed_mimetypes: ["application/pdf"],
                  }).optional(),
                },
              }),
            ),
          ],
        },
        {
          name: "Standard Baseline Deployment Only",
          nodes: [],
        },
      ]),
  ),

], {
  defaultIdType: "uuid",        // Auto-injects unique string identifiers for missing primary keys
  consecutive: true,            // Forces strict wizard sequence stepping rules
  progress_style: "progress_bar", // Instructs the layout renderer to generate progress indicators
});

Deployment Validation Lifecycle

When createFormSchemaOrchestrator compiles, it parses the layout tree and splits it into two isolated channels:

  • databaseSchema — A pure array of raw collection models, stripped of UI components, ready to stream to Prisma, Drizzle, or raw storage pipelines.
  • uiFormTree — The complete visual rendering graph mapping steps, multi-populators, validation constraints, and helper text fields for your form engine.

If a layout node points to an unmapped collection or field, orchestration stops with a focused diagnostic:

[BusinessDash SDK] Conditional rule references field "isActive"
in collection "project" which does not exist.

AI Distribution — llms.txt & Product Feed on Your Domain

Orgs curate products/services for answer engines under Dashboard → Marketing → AI Distribution; the platform keeps two public artifacts current per site — an OpenAI merchant-feed-shaped product feed and an llms.txt. The llms.txt convention only works at the site's own root (an AI crawler asks for https://yoursite.com/llms.txt), while the source of truth lives on BusinessDash where the org curates it. @businessdash/sdk/distribution proxies the two together:

// app/llms.txt/route.ts
import { llmsTxtHandler } from '@businessdash/sdk/distribution'

export const GET = llmsTxtHandler({
  siteId: process.env.BIAB_SITE_ID!,
  baseUrl: process.env.NEXT_PUBLIC_BIAB_APP_URL!, // e.g. https://www.biab.app
})
  • llmsTxtHandler(options) serves the curated llms.txt from your own domain root, with a 5-minute s-maxage + stale-while-revalidate. An upstream 404 (feed disabled or entitlement lapsed) passes through untouched; network failure returns a plain 503 — it never throws into the framework.
  • productFeedUrl(options) / llmsTxtUrl(options) build the public BIAB feed URLs. The product feed needs no proxy — submit its BusinessDash URL directly to merchant/feed programs; productFeedUrl builds it for display/submission.

Pair the feed with the product() JSON-LD builder from @businessdash/sdk/seo: the feed gets your product INTO the engines' catalogs; the markup is what their crawlers verify on the landing page itself.

MCP Connector on Your Domain

The platform's host-resolved /api/mcp only exists on sites the platform serves. If you host your own app with the SDK, mount the two @businessdash/sdk/mcp handlers and your domain gains the same connector surface — so the URL an org hands to Claude / ChatGPT / Gemini is their own site:

// app/api/mcp/route.ts
import { mcpHandler } from '@businessdash/sdk/mcp'
export const { POST, GET } = mcpHandler({
  siteId: process.env.BIAB_SITE_ID!,
  baseUrl: process.env.NEXT_PUBLIC_BIAB_APP_URL!, // https://www.biab.app
})

// app/.well-known/mcp.json/route.ts
import { mcpManifestHandler } from '@businessdash/sdk/mcp'
export const GET = mcpManifestHandler({
  siteId: process.env.BIAB_SITE_ID!,
  baseUrl: process.env.NEXT_PUBLIC_BIAB_APP_URL!,
})
  • mcpHandler(options)POST proxies JSON-RPC to the platform's public per-site connector; GET mirrors the spec's 405. An unreachable upstream answers with a proper JSON-RPC -32603 error and a 502.
  • mcpManifestHandler(options) — serves .well-known/mcp.json with the endpoint URL rewritten to YOUR domain (the requesting origin).
  • Thin by design. The body is forwarded verbatim and the platform still enforces the org's MCP opt-in and per-tool write gates — a proxy can't widen anything.

AI-Crawler Visibility (AEO Analytics)

AI crawlers (GPTBot, ClaudeBot, PerplexityBot, …) fetch your HTML without executing JavaScript, so <BIABAnalytics /> never sees them. Your server does — report them from middleware via @businessdash/sdk/analytics-server:

// middleware.ts
import { isAiCrawlerUserAgent, reportAiCrawlerHit } from "@businessdash/sdk/analytics-server";

export function middleware(request: Request) {
  const ua = request.headers.get("user-agent");
  if (isAiCrawlerUserAgent(ua)) {
    // Fire-and-forget — never block or fail the crawler's request.
    void reportAiCrawlerHit({
      siteId: process.env.BIAB_SITE_ID!,
      baseUrl: process.env.NEXT_PUBLIC_BIAB_API_URL!,
      apiKey: process.env.NEXT_PUBLIC_BIAB_PUBLISHABLE_KEY!,
      userAgent: ua,
      path: new URL(request.url).pathname,
    });
  }
}

Read it back with biab.site(siteId).analytics.aiVisibility({ days: 30 }) — per-crawler fetch counts, distinct pages read, vendor, intent (training / search / user), and last-seen timestamps. Branch on .available; same plan gating as pageViews().


Entrypoints

| Import path | Contents | |---|---| | @businessdash/sdk | Main barrel — all schema, layout, client, auth, SEO exports | | @businessdash/sdk/contracts | Zod schemas + inferred types for every API surface | | @businessdash/sdk/enums | Enum helpers — label formatting, enum entry utilities | | @businessdash/sdk/collections | Schema primitives — collection(), createSchema(), bd, Enums(), SchemaBoundary | | @businessdash/sdk/forms | Form layout + runtime — layoutContainers, createFormSchemaOrchestrator(), createSingleResourceForm(), controller, formula engine | | @businessdash/sdk/static | Static data — Enums(), enum types | | @businessdash/sdk/react | React bindings — the full UI surface: forms, chat, auth, storefront, cart, checkout, blog, customer portal, marketing pages, followers, social links, verification | | @businessdash/sdk/vue | Vue binding — <BiabForm> + shared client | | @businessdash/sdk/svelte | Svelte binding — <BiabForm> + shared client | | @businessdash/sdk/solid | Solid binding — <BiabForm> + shared client | | @businessdash/sdk/qwik | Qwik binding — <BiabForm> | | @businessdash/sdk/angular | Angular binding — <biab-form> component + shared client | | @businessdash/sdk/element | <biab-form> custom element — drop-in web component | | @businessdash/sdk/vanilla | Framework-free imperative DOM binding — mountBiabForm() | | @businessdash/sdk/react-analytics | <BIABAnalytics /> — browser page-view tracker for React | | @businessdash/sdk/react-attribution | "Powered by BusinessDash" attribution badge | | @businessdash/sdk/analytics-core | Framework-agnostic analytics core — initBiabAnalytics() | | @businessdash/sdk/analytics-server | Server-side AEO reporting — isAiCrawlerUserAgent(), reportAiCrawlerHit() | | @businessdash/sdk/distribution | llms.txt proxy + feed URLs — llmsTxtHandler(), productFeedUrl(), llmsTxtUrl() | | @businessdash/sdk/mcp | MCP connector proxy — mcpHandler(), mcpManifestHandler() | | @businessdash/sdk/proxy | BiabDevClient / customer-portal client re-exports for proxy setups | | @businessdash/sdk/seo | JSON-LD builders — localBusiness(), product(), … | | @businessdash/sdk/data-model-schema | defineDataModel — org custom database (tables + enums) as code | | @businessdash/sdk/alpha | Legacy/deprecated pre-0.9 schema API, re-exported for compatibility | | @businessdash/sdk/marketing-schema | defineSiteMarketingSchema() — schema-driven marketing-page flow | | @businessdash/sdk/next/revalidate | One-line Next.js route handler for BusinessDash revalidation webhooks | | @businessdash/sdk/adapters/revalidate | Framework-agnostic revalidation handler builder | | @businessdash/sdk/biab-forms.css | Default form stylesheet |

Framework parity — read this before picking a stack

Forms work identically on all eight bindings, and every binding renders them natively — real components in your own DOM, styled by your own CSS.

Beyond forms, two questions matter: does the binding have the surface at all, and does it render into your DOM or into an iframe?

| Capability | react | solid | vue | svelte | qwik | angular | vanilla | element | |---|---|---|---|---|---|---|---|---| | Forms | native | native | native | native | native | native | native | native | | Shared client | native | native | native | native | — | native | — | native | | AI chat | both | both | native | native | native | native | native | native | | Storefront | both | both | native | native | native | native | native | native | | Cart | both | both | native | native | native | native | native | native | | Blog | both | both | native | native | native | native | native | native | | Checkout | both | both | native | native | native | native | native | native | | Customer portal | both | both | native | native | native | native | native | native | | Customer auth | native | native | native | native | native | native | native | native | | Followers | native | native | native | native | native | native | native | native | | Marketing pages | native | native | native | native | native | native | native | native | | Social links | native | native | native | native | native | native | native | native | | Email verification | native | native | native | native | native | native | native | native | | Phone verification | native | native | native | native | native | native | native | native | | Surfaces | 14 | 14 | 14 | 14 | 13 | 14 | 13 | 14 |

iframe means a BusinessDash-hosted page in a sandbox. It works and it ships fast, but it renders our markup, not yours.

Two things worth reading against the obvious summary:

Every capability is available on every binding, via ten shared cores (forms, store, chat, blog, checkout, account, portal, marketing, verify, social) — all as data you render yourself. React and Solid also offer the hosted iframe widgets, hence both.

The only cells are the shared-client primitive on qwik and vanilla, and neither needs one: both take the client per form instead.

The customer portal is the largest surface — 34 methods. sessionToken is required (without one it renders empty rather than signed-out); there are no list endpoints for invoices, quotes, contracts or shipments, so work carries the counts and you open each by id; and a submitted review is queued for staff approval, so watch reviewAwaitingModeration rather than expecting it in reviews.

React remains ahead on <ChatbotInline> — a rendered chat UI over the same shared controller.

For anything a binding lacks, BiabClient from the root export is framework-agnostic: client.storefront(), client.cart(), client.customerPortal() and the rest work anywhere JavaScript runs. You write the markup; the data layer is already there.

React-compatible targets get the full surface, because they render React: Next.js (App Router included — the adapter ships its own "use client" boundary), Astro via @astrojs/react, Remix / React Router 7, TanStack Start, and Preact via the standard preact/compat alias all import @businessdash/sdk/react. React Native uses @businessdash/sdk/native.

The matrix lives in @businessdash/sdk/framework-support as data, and release gates assert that the code, the manifest and the published docs table all agree — including the level, so an iframe can never be published as if it were a native component.

Releases

Versioning convention (0.9.50+): Significant feature groups bump the minor field directly. Previous releases used the 36-band patch series (0.9.36, 0.9.35…). The 0.9.35 docs are archived at docs-legacy/0.9.35/ — this is the first supported SDK baseline. 0.9.50 introduces the unified Schema, Validation, and Form orchestration pipeline with createFormSchemaOrchestrator, layoutContainers, and the full bd field builder namespace.

0.8.x = schema-driven marketing flow. Major surface bump (still alpha). New entrypoints: defineSiteMarketingSchema(), @biab-dev/sdk/seo for typed JSON-LD builders, and a biab-dev CLI.

0.9.x = full programmatic consumer surface. The largest expansion yet: native storefront / cart / checkout / coupons / subscriptions, customer portal + tenant auth (createAuthHandler, getTenantSession, <SignIn/> <SignUp/> <SignOut/> useUser), blog, a paginated reviews wall, address autocomplete + shipping, programmatic local SEO (defineParallelPage()), the revalidation webhook channel (@biab-dev/sdk/next/revalidate + @biab-dev/sdk/adapters/revalidate), privacy-conscious analytics (@biab-dev/sdk/react-analytics + @biab-dev/sdk/analytics-core), and a three-state billing-lifecycle degradation contract. Detailed per-version notes below.

The 3 most recent releases are below. For the complete release history, see the changelog.

0.9.81

The createBiabClient facade serves the legal and sitemap routes

The facade lacked legal and site, so any starter handing it to resolveLegalPage() or buildSitemap() failed to typecheck against the published package — the template fleet caught T3-App, Qwik and Svelte on 0.9.80. client.legal and client.site(siteId) now exist on the facade, mirroring the raw BiabDevClient, and compile-time contract guards in sdk.ts fail typecheck if a helper-required surface is ever dropped again.

Sitemaps actually include blog posts and products now

SitemapClientLike described responses as { posts } / { products }, but the real client serves { items } — the collector's destructure came back undefined, the loop threw, and every real sitemap silently reported those sections as "unreachable". The contract and collector now consume the shapes the API actually serves.

Shipped source compiles on consumers' older TypeScript

push-core used the Uint8Array<ArrayBuffer> generic, which only parses on TS 5.7+ — consumers whose toolchains typecheck the shipped source (Qwik's does) failed to build. The annotation is gone; the one BufferSource call site casts instead.

0.9.80

Tracking consent — @businessdash/sdk/consent

There was no consent mechanism anywhere: initBiabAnalytics() started unconditionally and analytics, attribution and AEO all collected regardless.

loadConsent() returns what a visitor currently allows, at THIS org, and recordConsent() stores their answer. Gate analytics on it:

const consent = await loadConsent({ client })
if (consent.allows('analytics')) initBiabAnalytics({ siteId, baseUrl, apiKey })
if (consent.mustAsk) showYourBanner(consent)

initBiabAnalytics is deliberately NOT made to call this itself — a gate that fires implicitly is one nobody can see in review, and what happens before consent (render nothing, render a placeholder, collect essential-only) is the site's decision, not ours.

Consent is per-org and never travels. A visitor who refuses at org 2 has said nothing to org 1, and one who consents at org 1 has NOT consented to org 2 — that second half is the one with legal teeth. The visitor key lives in a first-party cookie on the org's own domain, so two sites cannot see each other's, and the isolation is the browser's rather than something we remember to scope.

The gate fails closed. Any failure returns deny-everything: a gate that fails open collects data nobody agreed to, and only one of those outcomes is an incident.

Also ships client.consent.get() / .record() as the transport underneath, and browserOptOut() for DNT and Global Privacy Control.

0.9.70

3D product models — @businessdash/sdk/model3d

Schema columns and a three.js viewer for model3dUrl / model3dUsdzUrl shipped in migration 0162 and reached the BusinessDash-hosted storefront only. Nothing was typed in the SDK, nothing was gated, and STEP was unsupported.

planProductModel() decides which of three surfaces applies and hands back the facts. No renderer ships — three.js is ~600KB and <model-viewer> ~300KB, and the choice of engine should be yours.

  • AR Quick Look (.usdz) — an <a rel="ar"> opens the model in the room on iOS/iPadOS/visionOS with no JavaScript at all. Feature-detected via relList.supports("ar") rather than user-agent sniffing.
  • Web viewer (.glb/.gltf/.obj/.stl) — neutralMaterial flags the geometry-only formats, which render as a black silhouette in a viewer that lights them like a .glb.
  • CAD download (.step/.iges) — deliberately not rendered. STEP needs a geometry kernel, and the browser WASM builds are 10–30MB. It is also the wrong shape for who asks: an engineer wants the file in their own CAD package.

Gated behind the new ecommerce.product_3d add-on (bundled at Scale). The gate is PARTIAL — an unentitled org's products still return, minus four fields. Failing the whole request would take a catalog offline over a lapsed $5.

The CAD URL never appears in a payload. client.storefront.getCadDownload(id) issues it from a route that requires a biab_cad cookie, rate-limits 10/min per IP, and re-checks the entitlement — a catalog of naked URLs is a machine shop's design library published to anyone willing to write a loop.

Legal pages — @businessdash/sdk/legal

Privacy policy, terms and refund policy written once in the dashboard and served on the org's own domain. The SDK never claims a literal path: it exposes a resolver for a catch-all, and every file-based framework resolves a static segment first — so your own /privacy shadows ours with no build conflict.

Sitemaps — @businessdash/sdk/sitemap

buildSitemap() merges your own routes, what the platform owns the shape of, and platform content mapped onto paths you declare. Opt-in per content type: declare nothing and nothing is emitted, which is right for a business without that surface. Never emits the customer portal or other token-gated paths.

Browser push — @businessdash/sdk/push-core and /notifications

enablePush() handles the whole service-worker dance; createNotificationFeed() gives one handler for every notification, deduplicated across push and polling, with no toast UI of its own.

Scheduling and conference calls on the main client

Booking lived on sdk.ts's separate scheduling resource. The OpenAPI generator reads client.ts, so it never saw any of it: all seven scheduling routes sat in the recorded spec gap, and no non-JS starter had scheduling at all.

client.site(siteId).scheduling now covers event types, slots, booking, and reading/rescheduling/cancelling with the invitee's signed token — plus a new staff path (rescheduleBookingAsStaff / cancelBookingAsStaff) so an org can move a booking with its API key rather than a token from someone's email.

Both sides call the same service, which is what keeps them honest: the invitee and every host are notified either way, the reminders queued against the old time are cancelled either way, and the booking records which side changed it. actorUserId is what lets a customer's history say "they rescheduled" rather than leaving an unexplained change.

The staff route needs scheduling:write, mapped to the same scheduling.manage permission the dashboard requires and deliberately not publishable — a browser token able to cancel any booking by id would let anyone reading the page source cancel every meeting the org has.

Two generator bugs surfaced while wiring this up, both the same class as the nested-template one fixed earlier: it could not see paths built through a this.path() helper, and it was letting query strings leak into path templates (bookings/{token}?type={type} was being emitted as a path). Both fixed; the corrupt-path gate stays green and the recorded spec gap dropped from 39 routes to 33.

All six non-JS starters gained the full surface, including both paths — parity is 19/19 each.

SEO reaches the page, on every framework — @businessdash/sdk/seo-core

The platform has always produced per-page SEO — every marketing page bundle carries title, description, canonical, noIndex, Open Graph, Twitter card, keywords, JSON-LD and hreflang. The SDK re-exported that as a TYPE and stopped there, so every consumer hand-mapped eleven fields into their framework's metadata shape, along with the fallbacks, the absolute-URL rules and the robots string.

Eleven fields is enough that everyone does four. The two people skip are noIndex and canonical — the two where being wrong costs something and says nothing.

Layout and page compose. Precedence runs layout defaults → the platform's page SEO → overrides in code, because a developer has context the CMS does not. An omitted field inherits; an explicit null clears. JSON-LD accumulates rather than replaces, since a layout's Organization node and a page's Product node both belong in the document.

Every framework, no framework imports. toNextMetadata, toRemixMeta, toNuxtHead, toQwikDocumentHead, toTanStackHead, toHeadTags, renderHeadTags and applyToDocument each return the plain shape their framework expects, so the module works in all of them and depends on none.

Two rules are enforced rather than left to the caller. A relative canonical is dropped instead of emitted — crawlers resolve it against whatever URL they fetched, and a wrong canonical consolidates ranking onto the wrong page while a missing one is recoverable. And robots is always emitted, both ways, because absence means "index" and relying on absence to express noindex is how a hidden page gets published.

SEO for blogs and storefronts

blogPostSeo emits BlogPosting with dates, byline, tags and breadcrumbs, plus og:type: article and the article:* meta that make a shared link render as a dated card rather than a bare URL.

Gated posts stay indexed and say so. A subscriber-only post should be findable — discoverability is how anyone subscribes — but serving the full article to a crawler and a paywall to a reader is cloaking. So a gated post carries schema.org's paywall markup (isAccessibleForFree: false plus a hasPart naming the region), which declares the difference rather than hiding it. Anything not public counts as gated, including unrecognised access levels: failing the other way would declare open access for restricted content.

productListingSeo emits CollectionPage + ItemList, and canonicalises filtered views to the unfiltered category — faceted URLs multiply into thousands of near-identical pages that eat crawl budget and split ranking. transactionalPageSeo keeps cart, checkout and order confirmations out of the index; confirmations have been indexed with customer details in the query string by more than one large retailer.

productPageSeo gained aggregateRating and AggregateOffer. The rating is emitted only when reviewCount > 0, because an AggregateRating with zero reviews is invalid and Google rejects the whole Product node for it — a product with no reviews yet would lose its price and availability too.

Archive and listing pages past the first are noindex: the posts and products are what should rank, and a thin page 7 competes with them.

Local SEO: service areas × services, and products

servicesTimesAreas() expands the Cartesian product into page plans, each with its own canonical and a Service node scoped with areaServed to that place. Without those two, near-identical pages are deduplicated away and the node is indistinguishable from the generic services page.

describe is a callback rather than a template on purpose: pages differing only by a swapped town name are doorway pages, which are penalised rather than ranked. include skips combinations the org does not actually cover, so it does not rank for work it has to turn down.

productPageSeo() emits Product + Offer + BreadcrumbList, money as integer cents. It pairs with the AI Distribution product feed — the feed gets the product into the engines' catalogues, this markup is what their crawlers verify on the landing page.

All six non-JS starters gained the same surface, with the same rules. Swift's JSONValue gained Encodable on the way: it could decode the org's JSON-LD and had no way to render it back out.

Subscriptions in the customer portal — and the entitlement check that was a stub

hasActiveSubscription() returned true. For everyone. Every accessLevel: "subscribers" gate called it, so subscriber-only content was readable by anyone signed in.

That was survivable while nothing was gated, and it stopped being survivable the moment the portal grew a surface that advertises "your subscriber content": a page promising an entitlement check that does not perform one is worse than no page. It now reads user_subscriptions for real. There were 0 subscriptions, 0 offerings and 0 subscriber-gated posts in production when this landed, so switching it on changed nothing anyone could see — which is the cheapest possible moment to fix an access-control default.

Three cases grant access, and two of them a status === "active" check gets wrong in the direction that takes something from a customer who paid:

  • lifetime has no period, so an expiry check must not be applied to it;
  • cancelled but paid through keeps access until the period ends;
  • failed (dunning) and pending (incomplete) do not grant — a payment that has not succeeded has not bought anything.

The rule lives in subscription-access-rules.ts with no database import, so it is unit-tested directly — the same split, for the same reason, as connect-subscription-mapping.ts.

The portal surface: getSubscription() returns state plus the org's live offerings (a portal reporting "not subscribed" and nothing else is a dead end), cancelSubscription() / resumeSubscription(), and getSubscriberContent().

Cancelling ends the RENEWAL, not the access: cancel_at_period_end only, with accessUntil in the response. The customer paid for the period they are in, and ending access on the click is the most common way a subscription flow feels like a trap. The route deliberately writes no local status — the provider's webhook owns it, and racing that would leave the row disagreeing with the money — and the controller re-reads rather than guessing what the webhook will write.

getSubscriberContent() answers "what am I actually getting for this?", which is the question asked right before someone cancels. When entitled is false it returns LOCKED previews rather than an empty list: titles and excerpts, never bodies. An empty list would hide the offer at exactly the moment it is most relevant.

Found while building it: org_blog_posts.access_level is a TEXT column with two vocabularies written into it — public|members|subscribers by the gate and the create route, public|followers|paid by the public reads. Nothing reconciles or constrains them. Both subscriber spellings now gate and anything unrecognised gates too, because an access check must fail closed. Converging the column is a migration and a naming decision, not something to settle in a read path.

All six non-JS starters gained the same four calls.

Notification settings, per company

A customer who buys from three businesses has three independent preference matrices — they are stored per (org, customer) and always have been. What did not exist was a way to reach more than one of them: createPortalController built its client once, pinned to the API key's org, for its whole life. A dashboard could LIST the customer's other companies and change nothing about them, so muting marketing email meant muting it for whichever company the portal happened to be pointed at.

loadNotificationPreferencesFor(orgId) and updateNotificationPreferencesFor(orgId, input) close that, with notificationPreferencesByOrg on the snapshot keyed by org id — separate from this org's, so the current company renders without waiting on companies the customer has not expanded. Pinned clients are memoised per org, because a dashboard listing five companies would otherwise build one per render.

Writes stay sparse and merged, and the controller stores what the server ANSWERED rather than what was sent: the server merges into the stored matrix, so echoing the request would show the customer a matrix it never agreed to.

Every non-JS starter gained the same surface.

Non-JS starters reach every surface — and are now measured

Swift, Kotlin, Dart, PHP and Elixir consumers do not import this package; each starter carries a hand-written client. Nothing that keeps the JS bindings honest said anything about them, so they fell behind quietly and the only record of how far was an estimate that turned out to be wrong in both directions.

Measured, then closed:

| Starter | Before | After | |---|---|---| | Swift | 11/15 | 15/15 | | Phoenix | 12/15 | 15/15 | | Flutter | 11/15 | 15/15 | | Laravel | 11/15 | 15/15 | | Kotlin | 10/15 | 15/15 | | Vapor | 9/15 | 15/15 |

The shared gap was social links and the two verification kinds — surfaces added to the JS side and never carried across. Laravel also lacked the chatbot; Kotlin, Flutter and Vapor lacked the customer portal; Vapor lacked customer auth; Kotlin lacked the data model.

non-js-parity.test.ts measures this every run by matching request paths, the one thing six languages have in common — a Swift func reviews(_ id:) and an Elixir product_reviews/2 share no convention, but both must contain storefront/products. The baseline may only grow.

Two detector bugs were found and fixed while writing it, both the same shape: social links have no endpoint at all (they are derived from the branding bundle), and the two verification kinds share one endpoint separated by a kind field. Both would have reported "missing" forever no matter what anyone built. A gate that cannot pass is worse than no gate — it teaches people to ignore it.

Full coverage means every surface is REACHABLE from every language. It does not mean the ergonomics match, and the gate cannot tell you that.

The social-platform table is generated, not copied

31 platforms — key, label, icon slug, URL prefix — emitted from src/socials.ts into all six languages by pnpm gen:socials, with gen:socials:check failing CI when they drift. Six hand-maintained copies would separate the first time a platform was added, and the copy that drifted would be whichever language nobody was using that week.

The resolver around the table stays hand-written per language, because that is what a developer reads. Generate the data, hand-write the idiom — the same split the whole non-JS plan uses.

Exchanges, "it never arrived", and verified-buyer product reviews

Three gaps in the post-purchase surface, each of which the customer could experience but not report.

Exchanges. A return request could only ask for money back. Customers asked for replacements in the free-text reason, where nothing could act on it — the org read prose, then refunded and re-ordered by hand. Return requests now carry kind (refund | exchange), plus the variant wanted and the shipment that eventually carries it. portal.submitExchangeRequest() routes through the same call as a return, because an exchange is a return request with a different ask rather than a separate flow. kind defaults to refund, so existing callers mean exactly what they meant. The staff notification now names the actual ask, since staff reaching for a refund when the customer wanted a replacement is the miscommunication the field exists to remove.

"It never arrived." portal.reportNotReceived(shipmentId) records the claim — and deliberately does not touch the shipment's status.

The carrier scanned the parcel delivered; the customer says otherwise. Both are facts, and the carrier's is the more valuable one right then: it is what the org disputes with, and what decides who absorbs the loss. Overwriting it would destroy that evidence, quietly redefine "delivered" as "delivered and undisputed" in the org's own analytics, and lose the race anyway — status is webhook-driven, so a customer-written value survives until the next carrier event and then looks like the report vanished.

So the claim sits in its own columns and the result carries both sides, with contested: true when they disagree. A static gate now fails the build if that route ever assigns a carrier-owned field, because this is a mistake that arrives by refactor rather than by decision.

Product reviews. The portal could review the ORG but not a PRODUCT, despite org_product_reviews already existing and the storefront already reading it. submitProductReview / listProductReviews close that, and require a matching order: the portal is standing inside the customer's purchase history, which the public storefront is not. Verified-buyer reviews are the ones worth surfacing, and it stops a competitor one-starring a catalogue they never bought. Approval comes from the org's own moderation policy — the same call the storefront makes, so an org that holds reviews does not find portal reviews bypassing it.

The customer portal can list what a customer has, and talk to staff

Invoices, quotes, contracts and shipments were detail-by-id only. Work carried the counts, so a customer could be told they had three unpaid invoices and had no call that would name them. "Show me everything I owe" was unanswerable without faking a list from repeated detail fetches, which is a different bug wearing a list's clothes.

Four list routes close it: loadInvoices({ unpaid }), loadQuotes({ status }), loadContracts({ status }) and loadShipments({ active }). unpaid filters on the computed balance, not the status string, because a partially-paid invoice still owes money whatever it is called. Shipments list stored carrier status; trackShipment(id) still spends a live carrier request, because that is one parcel the customer deliberately opened rather than every parcel in their history on every page load.

Staff chat lands in the portal too — loadMessages, postMessage, markMessagesRead, with unreadMessageCount on the snapshot. Both directions write the same table the CRM timeline reads, so a customer's question appears beside everything else known about them instead of in a parallel inbox nobody opens.

Return requests read back on the order, resolutionNote included: when staff deny a return, their reason is the answer, and withholding it turns a decision into silence.

Products and blog posts can be seeded — the last two things you had to hand-enter

Seeding could push a schema, its records, static collections and coupons. It could not create a product or a blog post, because no package-API route existed for either — the dashboard created them over tRPC. So an org could build its entire site from code and still had to hand-enter its catalogue, which made "seed your site" a half-answer precisely where the content mattered most.

Two routes now exist, and @businessdash/sdk/seed exposes them as seedProducts and seedBlogPosts:

  • POST storefront/products — scope storefront:write. Takes the whole tree, product plus variants plus cross-variants, and writes it in one transaction. The dashboard does this in four dependent round trips; making a seed script replay that means threading ids by hand with no way to recover halfway through. Either the product exists complete, or it does not exist.
  • POST blog/posts — scope blog:write, a new scope that is deliberately not publishable: authoring a post is an operator action, never something a browser token should be able to do.

Both create unpublished by default (isLive / publishNow are opt-in). A seed that silently pushed a half-configured catalogue to a live storefront — or drafts to every follower's inbox — is worse than one that needs a second, deliberate step.

Products land in your own tables, not in Stripe. Pushing the catalogue to your Stripe account stays a separate explicit call, because it mints objects in an external account you are billed against and that should never be a side effect of a seed script.

businessdash seed — one command for the whole seed

npx tsx node_modules/@businessdash/sdk/dist/cli.js seed [--dry-run] [--yes]

Reads businessdash.seed.ts and runs schema → records → collections → products → posts → coupons in dependency order. It calls the same runSeed the programmatic API exposes rather than reimplementing it, so CI and a Nuxt module behave identically — including the refusal on a destructive schema plan, which --yes overrides.

Fixed: seedTargetFromEnv() read a variable nobody sets

It looked for BIAB_PACKAGE_API_KEY. The CLI reads BIAB_API_KEY, and so does every starter's .env.example — all fifteen. A project whose CLI worked fine would fail to seed from a build step, and fail naming a variable the consumer had never seen. It now reads BIAB_API_KEY first and falls back to NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL for the base URL, exactly as the CLI does. A test pins the two together.

The MCP connector mounts on every framework

mcpHandler returns Web-standard (Request) => Promise<Response>, which is right for Next, Astro, Remix and TanStack Start and wrong for everything else: SvelteKit hands you { request }, Nitro an H3 event, Qwik City a RequestEvent you answer by calling send(), and Express Node's req/res. Four of eight frameworks had to work that out themselves, for a surface whose whole point is an org handing Claude or ChatGPT a URL on their own domain.

@businessdash/sdk/mcp-adapters ships that bridging: sveltekitMcpHandler (aliased astroMcpHandler), nitroMcpHandler, qwikMcpHandler and expressMcpHandler, each with a manifest counterpart. Every one is a shape-change over the same handler — none reimplements the proxy, the error mapping or the manifest caching, so a fix lands everywhere at once. On Node the manifest origin is rebuilt from Host and x-forwarded-proto, because advertising the wrong domain is the one failure this surface cannot tolerate.

@businessdash/sdk/react now carries its own "use client" boundary

React Server Components frameworks treat a module without the directive as server code. react.tsx holds every stateful component in the SDK and did not have it, so importing <BiabForm> into a Next App Router page threw "you're importing a component that needs useState". The only workaround was a hand-written "use client" wrapper per component — the Next starter in this repo carries sixteen of them.

The directive is now on react.tsx and react-marketing.tsx, so components import directly into a server-rendered page. Every export in those files is browser-only anyway: the components hold state, and the four programmatic helpers (signIn, signUp, signOut, requestPasswordReset) each return early on typeof window === "undefined" because they navigate.

Existing wrappers keep working — this removes the need for new ones, it does not invalidate old ones. Bundlers that don't implement the directive treat it as an inert string expression.

Preact is supported, and verified rather than assumed

@businessdash/sdk/react runs unmodified under Preact via the standard preact/compat alias, which means Preact gets the full fourteen-capability surface — not the forms-only subset every non-React binding has — for the cost of a bundler alias rather than a port.

Two properties make it work, and both are now gated. The binding imports nine runtime symbols from react (createContext, Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore), all present in preact/compat; and it never imports react-dom, so nothing pulls in React's reconciler.

test/preact-compat.test.ts bundles the real source twice through esbuild — once against React, once with react aliased to preact/compat — renders the same trees on both and asserts identical markup, covering the pure-render path, the context path and the useSyncExternalStore subscription path. The only tolerated difference is inline-style serialisation (margin:0 vs margin:0px), which computes identically.

preact and preact-render-to-string are devDependencies; nothing new ships.

The customer portal, natively, on every framework

@businessdash/sdk/portalcreatePortalController over the portal's 34 methods: work, jobs, quotes, contracts, invoices, orders, downloads, shipping, reviews, referrals, profile and notification preferences. Until now the only way to render any of it was <Dashboard>, a hosted page in an iframe, so a business could not put their own customer portal on their own domain.

Four properties of the surface shape the controller, all from the audit in Resources/BIAB-Customer-Portal-SDK-Audit.md:

  • sessionToken is required. Every route is scoped to the signed-in customer, and without one the calls are unauthenticated — which fails quietly enough to look like an empty portal rather than a signed-out one. The controller takes the token up front and calls withSession itself.
  • There are no list endpoints for invoices, quotes, contracts or shipments. work carries the counts and each opens by id. The controller does NOT fake a list from repeated detail fetches; the gap stays visible where someone can decide to close it in the API.
  • Nothing can be deleted. There is no delete action anywhere, because there is none in the API — and a test asserts the controller never grows one.
  • A submitted review is queued for moderation. It is deliberately not added to reviews; reviewAwaitingModeration is set instead, so a portal can say "awaiting review" rather than implying it is live.

Supporting detail never blanks the thing the customer asked for: a job renders even when its activity, ETA and comments all fail, and an order renders when it has no digital downloads (which most orders don't).

Social links on every framework

The last gap, and the only one that was never about logic — every binding already had resolveSocialProfiles. What was missing was markup, which cannot be shared the way a controller can.

@businessdash/sdk/social shares everything up to the markup: the resolved rows, the icon URL and the handful of layout styles the list needs. Each binding writes the six elements around it — a render function for Vue (rather than an SFC, which would put it back outside the typechecker), a standalone component for Angular, an SFC for Svelte, JSX for React, Solid and Qwik, an imperative mountSocialLinks for plain DOM, and <biab-social-links> for the web-component entry.

React and Solid were refactored onto it, so the CDN path and colour convention now live in one place rather than three.

Marketing pages and verification on every framework

Two final cores. Twelve of the fourteen capabilities are now native on all eight bindings, and Solid joins React at the full fourteen.

@businessdash/sdk/marketingcreateMarketingController holds the page index and loads one page at a time, because a site can have many marketing pages and a visitor reads one. prefetch() warms a page you expect next; its failures are swallowed, since a wrong guess must never surface as an error on the page actually being read.

@businessdash/sdk/verifycreateVerifyController is the email/phone state machine: request a token, confirm it, handle it being wrong. One controller serves both kinds; kind selects which.

Two details the React components had buried and that are now explicit:

  • flow is on the snapshot. The server decides per request whether to send a one-time code or an emailed link. A binding that assumes a code field would leave every link-flow visitor staring at a box waiting for a code that is never coming.
  • A rejected code is retryable, not fatal. The confirm endpoint answers { ok: true } or rejects, so a rejection is the ordinary wrong-code path: the step stays awaiting-code and the visitor can try again.

The resend cooldown lives in the controller rather than the binding. Left to eight bindings, one of them gets it wrong and a visitor can spam themselves with texts.

Checkout, customer auth and followers on every framework

Two more cores and bindings for all eight adapters, taking the count of capabilities every binding has natively from six to nine.

@businessdash/sdk/checkoutcreateCheckoutController mints a Stripe session and resolves the outcome. start() deliberately does NOT navigate; it returns the session and leaves redirect() as a separate call, because the previous React component set window.location.href itself, which is fine in a browser and wrong in a native app, an SSR render or a test.

resolve(sessionId) reads the payment status from the server. A session_id in the return URL proves the customer came back, not that they paid — that distinction is the point of the method and has its own test.

@businessdash/sdk/accountcreateUserController (the signed-in customer, via the auth handler's /me, never an API key) and createFollowersController (subscribe/unsubscribe plus the local already-subscribed hint, which is a convenience for hiding a footer form and explicitly not authority — me() is).

Sign-in / sign-up / sign-out are navigations rather than requests, so they ship as signIn / signUp / signOut plus signInHref / signUpHref / signOutHref for bindings that render their own links. All of them no-op outside a browser rather than throwing.

React and Solid were REFACTORED onto these rather than left alone, so all eight bindings share one implementation. That removed 104 lines of hand-rolled fetch and local-storage handling from the Solid binding alone.

Native blog on every framework — nothing is iframe-only any more

@businessdash/sdk/blogcreateBlogController owns the post list, paging, category filtering, the active post, comments and likes. Bindings on all eight adapters: React/Vue/Qwik useBlog, Solid/Svelte/Angular/vanilla/element createBlog.

Blog was the last surface with no native path anywhere, React included — the worst place to lose control of markup, since the content is what readers and search engines came for and an iframe hides it.

Two behaviours worth calling out, both tested:

  • Posting a comment re-reads the thread instead of inserting locally. A comment may be held for moderation, and splicing a local copy in would show its author an approved-looking comment nobody else can see.
  • A likes outage never blanks the article. Like state is fetched after the post and its failure is swallowed, because a decoration must not take down the thing it decorates.

Every framework binding is now typechecked

@builder.io/qwik is a devDependency and tsconfig.qwik.json checks the Qwik binding, which closes the last hole: Solid, Qwik, Vue, Svelte and Angular were all excluded from tsc and therefore never verified. pnpm typecheck runs four configs.

Qwik's first run found two real pre-existing errors, both exactOptionalPropertyTypes violations where an optional JSX attribute was passed as undefined rather than omitted.

Native storefront, cart and chat on EVERY framework

Two new framework-agnostic cores, and a binding for each of the eight adapters. Storefront, cart and chat are no longer iframe-only anywhere.

@businessdash/sdk/storecreateStoreController owns products, pagination, category filtering, the product detail, the cart, quantities and coupons. Prices cross the boundary as integer minor units and are never divided, rounded or formatted by the controller.

@businessdash/sdk/chatcreateChatController owns the transcript, pending state, UI actions, availability and the human-escalation call. Lifted out of React's useChatbot rather than rewritten, so React's behaviour is preserved and now backs every other binding.

Both follow the createFormController contract exactly: snapshot(), subscribe() (which does NOT replay), destroy(), and actions.

Per-framework bindings, all thin views over those cores:

| Framework | Storefront | Chat | |---|---|---| | React | useStorefront | useChatbot (now core-backed) | | Solid | createStorefront | createChat | | Vue | useStorefront | useChat | | Svelte | createStorefront | createChat | | Qwik | useStorefront | useChat | | Angular | createStorefront | createChat | | Vanilla / element | createStorefront | createChat |

The vanilla binding is the controllers themselves — there is no reactivity system to bridge — which makes it the reference the other seven wrap.

Qwik's controllers are wrapped in noSerialize and created inside useVisibleTask$. That is not stylistic: a controller holds a client, a subscriber set and timers, and Qwik would otherwise try to serialise it into the HTML on pause and throw.

The parity gate now checks HOW a capability is delivered

capabilities in framework-support.ts records native, embed or both per capability, and the gate derives the same levels from the source by reading implementations rather than export names. Recorded as booleans, an iframe wrapper looked identical to real components.

It also asserts each binding REACHES the core behind every capability it claims — level-aware, so the iframe path requires embed-protocol and the native path requires store-core / chat-core — and that the postMessage origin check exists in exactly one file. A second, subtly weaker copy of that check would be a security bug affecting one framework's users only.

Vue, Svelte and Angular headless bindings are typechecked

Those bindings are excluded from the main tsconfig because of SFC and decorator syntax. Their new headless modules are plain .ts, so tsconfig.headless.json checks them properly; pnpm typecheck now runs the main, Solid and headless configs. It caught a real generic-inference bug on the first run: the snapshot type was resolving to unknown, which would have stripped types from every Vue and Angular consumer.

Solid gains most of the product surface

@businessdash/sdk/solid went from forms-only to eleven of fourteen surfaces: storefront, cart, checkout, blog, the customer portal, the chat widget, tenant auth, followers and social links, alongside the forms binding it already had.

Most of that was cheap for a reason worth knowing: Storefront, Cart, Checkout, Blog, Chatbot and Dashboard are BusinessDash-hosted pages in a sandboxed iframe, in React exactly as in Solid. Two primitives — createEmbedSession and an internal EmbedIframe — carry all six, and each surface is a dozen lines on top of them.

New Solid exports: Storefront, Cart, Checkout, CheckoutResult, Blog, Chatbot, Dashboard, DashboardProvider, useDashboardSession, SignIn, SignUp, SignOut, signIn, signUp, signOut, createUser, createFollowers, SocialLinks, createEmbedSession.

Not ported: marketing pages, and the email/phone verification flows. The headless chat pair (useChatbot + <ChatbotInline>) also remains React-only — Solid gets the iframe widget, not the build-your-own-UI surface.

The Solid binding is typechecked for the first time

Source-shipped bindings are excluded from the main tsconfig, because tsc here is configured for React's JSX dialect. Excluded also meant never checked. tsconfig.solid.json now typechecks it under Solid's own pragma, and pnpm typecheck runs both — so it is a release gate.

It found two real errors on its first run, both in the pre-existing forms binding and both the same Solid hazard: calling an accessor twice inside a ternary, so the guard narrows the first read and the second is still possibly-undefined. One of them could have passed an undefined client into createFormController.

Shared cores extracted, so bindings cannot fork

embed-protocol.ts and auth-links.ts now own the postMessage protocol and origin check, the session-refresh schedule, the auth-handler URL contract and the follower storage format. React was refactored onto them rather than the Solid binding copying them across.

The origin check is the reason this matters: it decides whether a message from an arbitrary window may drive a cart or a checkout. A second, subtly weaker copy would be a security bug affecting one framework's users only, so a release gate asserts it exists in exactly one file.

The parity matrix now records HOW, not just whether

capabilities changed from a list to a map of capability to level: native (components in your DOM), embed (a hosted page in an iframe) or both.

The distinction is not cosmetic. Recorded as booleans, Solid's iframe chat widget looked equal to React's, which additionally ships ~1,200 lines of headless chat UI. It also surfaces something that reads the other way: storefront, car