@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
Maintainers
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/sdkImports
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 uploadComplex 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-minutes-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;productFeedUrlbuilds 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)—POSTproxies JSON-RPC to the platform's public per-site connector;GETmirrors the spec's 405. An unreachable upstream answers with a proper JSON-RPC-32603error and a 502.mcpManifestHandler(options)— serves.well-known/mcp.jsonwith 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 withcreateFormSchemaOrchestrator,layoutContainers, and the fullbdfield builder namespace.0.8.x = schema-driven marketing flow. Major surface bump (still alpha). New entrypoints:
defineSiteMarketingSchema(),@biab-dev/sdk/seofor typed JSON-LD builders, and abiab-devCLI.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 viarelList.supports("ar")rather than user-agent sniffing. - Web viewer (
.glb/.gltf/.obj/.stl) —neutralMaterialflags 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) andpending(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— scopestorefront: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— scopeblog: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/portal — createPortalController 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:
sessionTokenis 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 callswithSessionitself.- There are no list endpoints for invoices, quotes, contracts or shipments.
workcarries 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;reviewAwaitingModerationis 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/marketing — createMarketingController 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/verify — createVerifyController 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:
flowis 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 staysawaiting-codeand 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/checkout — createCheckoutController 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/account — createUserController (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/blog — createBlogController 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/store — createStoreController 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/chat — createChatController 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
