Changelog
The complete release history of @businessdash/sdk — published as @businessdash/sdk through 0.9.53. The current published version is 0.9.90.
Versioning convention. Starting with 0.9.50, significant SDK feature groups use an explicit release version. The earlier 0.9.35 patch series remains the supported legacy baseline; use the navbar version selector to switch between its docs and the current API.
0.9.x — Full programmatic consumer surface
0.9.90
Every Biab* export is now Bd* — a breaking rename with no aliases
biab was the old application name (Business In A Box). The product is
BusinessDash, and the old name survived across the SDK's whole public
surface long after that stopped being true.
Renamed as a clean break rather than with a deprecation layer, because nothing outside this repo consumes these names yet. Waiting would only have made the alias layer larger.
BiabDevClient -> BdApiClient (the HTTP transport)
BiabClient -> BdClient (the composed client interface)
BiabForm, BiabProvider -> BdForm, BdProvider
BiabApiError and every other error class
createBiabClient, initBiabAnalytics, mountBiabForm, getBiabEnv, …
.biab-* CSS classes -> .bd-* (54 of them)
BIAB_* env vars -> BD_*
@biab-dev/* packages -> @businessdash/*
Two names needed a decision rather than a pattern. BiabClient (the
interface a consumer holds) and BiabDevClient (the HTTP transport under it)
would both have become BdClient; the interface keeps the plain name and the
transport is now BdApiClient.
Upgrading. Every rename is mechanical — a find-and-replace of Biab to
Bd and biab to bd covers all of it except BiabDevClient, which
becomes BdApiClient. If you style the form components, the CSS hooks moved
from .biab-* to .bd-* in the same way.
The stylesheet subpath moved too, from ./biab-forms.css to
./bd-forms.css. An alias was considered and dropped: every symbol in the
package renamed, so leaving one filename behind would have been the odd
exception rather than a kindness.
Three things deliberately did NOT change. biab.app is the API host and is
served forever. The biab.app/schemas/* URLs are JSON-Schema $ids —
identifiers rather than addresses — so changing one would break validation
for anything pinned to it. And the historical note that this was formerly
Business In A Box stays true.
Proposals: the document a customer is sent, not just the quote
customerProposalSchema, listProposals() and getProposal().
A proposal is the document built around a quote — either a rendered PDF or a
live page — and kind tells you which, so a UI can offer a download or frame
a page rather than guessing and being wrong half the time. Only rendered
proposals appear: before that it is a rep's working copy, possibly at a price
nobody has agreed, and a customer refreshing the list should not watch it
take shape.
It carries no template, bindings or resolved snapshot. Those describe how an org builds proposals; a customer's client has no business reading them.
The customer quote detail carries the proposal narrative
customerQuoteDetailSchema was dropping sections — the cover letter, scope
of work and terms that make a quote a proposal — so a consumer could fetch
one and receive only line items. It now carries them, along with the decision
state (canAccept, expired, and the sent/accepted/rejected timestamps) so
a client does not have to re-derive whether accepting is still possible.
items was z.array(z.unknown()). It is typed now: no consumer should have
to guess a line item's shape before rendering a row.
React's marketing pages run on the shared core
useMarketingPage, useMarketingPagesList and useMarketingPagePreloader
reimplemented the fetch, the status flag and the in-flight guard that
marketing-core already had, so a fix in the core never reached React. They
are thin shells over createMarketingController now, with the
snapshot-to-view derivation moved into the core as marketingPageView() and
marketingListView() for every binding to share.
Public signatures are unchanged. The synchronous cache read stays in React, because the controller does not duplicate the client's cache and without that read an already-cached page flashes "loading".
0.9.81
The createBdClient 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 BdApiClient, 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: initBdAnalytics() 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')) initBdAnalytics({ siteId, baseUrl, apiKey })
if (consent.mustAsk) showYourBanner(consent)
initBdAnalytics 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 bd_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 BD_PACKAGE_API_KEY. The CLI reads BD_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 BD_API_KEY first and falls back to
NEXT_PUBLIC_BD_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 <BdForm> 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/BD-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 <bd-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,
cart and blog are embed on every binding including React, so on those
surfaces the non-React bindings were never behind.
The release gate derives the level from the source — it reads implementations, not export names — and asserts the manifest and the published docs table agree.
Framework capability matrix is now published data
@businessdash/sdk/framework-support gained a capabilities field per adapter
plus CAPABILITY_LABELS, ALL_CAPABILITIES and REACT_TARGETS. This makes an
uncomfortable fact discoverable instead of implicit: react exposes all 14
capability areas and every other binding exposes forms, plus in most cases a
shared client. Chat, customer auth, storefront, cart, checkout, blog, the
customer portal, marketing pages, followers, social links and the verification
flows are React-only.
REACT_TARGETS documents that Next, Astro, Remix / React Router 7 and TanStack
Start are not lesser targets — they render React, import
@businessdash/sdk/react, and get the full surface. React Native uses
@businessdash/sdk/native.
A release gate now asserts the code, the manifest and the published docs table all agree, so the framework page cannot advertise a capability an adapter doesn't export.
Contract fixes found by auditing the SDK against the platform
Four places where the SDK's schemas disagreed with what the platform actually sends. All are fixes to existing behaviour, not new surface.
Form definitions were losing most of their render contract.
bd.chatbot.getForm() / getChatForm() stripped settings, orgIcon,
actions and businessFunctions — the server sent all four, the schema
declared none of them. Anyone building a form UI on the typed client got a
bare field list with no animation, no multi-step or progress configuration, no
exit confirmation, no branded header, and no way to compute calculation
fields. All four are now declared, and the schema passes unknown fields
through so the next server addition reaches you instead of vanishing.
Dry-run submissions threw. A validate-only submit legitimately returns
submissionId: null, but the schema required a string, so every test submit
raised a parse error. It is nullable now, dryRun is returned alongside it,
and the submit input accepts dryRun, source, referrer and metadata —
all of which the route already supported and the typed client could not send.
Gallery items were losing their description through the marketing bundle.
Docs: Developer → By feature → Framework Support.
Coupons from the SDK, live in your Stripe account
bd.coupons.create() makes a discount code and mints it in your own
Stripe account in the same call — a Coupon plus a Promotion Code, so the same
string works on your storefront and on Stripe-hosted Checkout.
const { coupon, stripeSynced } = await bd.coupons.create({
code: 'WELCOME10',
discountType: 'percent',
value: 10,
})
Creation and the Stripe sync are one operation deliberately. A coupon that
exists locally but not in Stripe is the failure worth designing out: your
storefront accepts the code and Stripe rejects it, stranding the customer at
payment. If the sync fails the coupon is still created and stripeSynced comes
back false with the reason, rather than pretending it is live.
bd.coupons.list() returns every coupon with its redemption count and Stripe
ids. Both need a secret key holding the new storefront:write scope —
validate() is unchanged and still works with a publishable token.
Buy-X-get-Y and bundle-gift coupons stay dashboard-only: Stripe has no native BXGY, so they run through our own cart math and need offer rows the API doesn't collect.
Docs: Developer → By feature → Store → Coupons.
Framework version support is now declared, not implied
The SDK ships form bindings for eight targets, and until now said nothing
about which versions of those frameworks it was built for — package.json
declared peer ranges for React only. A developer on a brand-new framework
major got no signal either way.
Every adapter now declares its range as an optional peer dependency, so your package manager warns on a mismatch instead of failing later with a confusing build error. The same data is importable:
import { FRAMEWORK_SUPPORT, supportFor } from '@businessdash/sdk/framework-support'
Each entry also names the framework APIs its binding depends on, which is how we assess whether a framework's major actually breaks us — a diff against a list rather than a reread of the adapter.
When a framework ships a breaking major, we add a second adapter for it rather than replacing the first. Both ship, the docs show both, and code on either major keeps working.
Solid 2.0 (August 2026) reworked a substantial part of the framework API.
@businessdash/sdk/solid is written against the 1.x dialect and is not yet
verified on 2.x; a v2 dialect is planned alongside it. On Solid 2.0 today,
@businessdash/sdk/element or @businessdash/sdk/vanilla give you the same
form behaviour with no framework dependency.
Docs: Developer → By feature → Framework Support.
Push your catalog to your own Stripe account
bd.storefront.syncToStripe() takes the products you manage in
BusinessDash and creates them in your own Stripe account — each product as
a Stripe Product, each variant combination as a Price. It is the same operation
as the dashboard's "Sync to Stripe" button, now available to your code, so you
can run it on your own schedule, in a deploy step, or immediately after
creating a product.
const result = await bd.storefront.syncToStripe()
console.log(result.synced) // [{ productId, stripeProductId, combinationsSynced }]
console.log(result.remaining) // products still awaiting a sync
Pass productId to sync one product; omit it to sync everything not yet
pushed, in bounded batches — check remaining and call again until it reaches
zero. Large catalogs are paged deliberately rather than risking a timeout
halfway through.
Re-running is safe. An existing Stripe Product is updated rather than
duplicated, and a new Price is minted only when the amount actually changed
(Stripe Prices are immutable on amount). A product that fails comes back in
failed with its reason instead of abandoning the rest of the batch.
Requires a secret key holding the new storefront:write scope — never a
publishable token, because this spends against Stripe on your behalf — plus
the ecommerce.storefront entitlement and a connected Stripe account. The
endpoint is rate-limited more tightly than the rest of the package API for the
same reason.
Docs: Developer → By feature → Store → Sync to Stripe.
0.9.60 — Seed Export, a GraphQL schema for your data model, and the complete artifact
The package is now @businessdash/sdk
This release publishes under the new @businessdash/sdk name (formerly
@businessdash/sdk), matching the platform's move to BusinessDash. Nothing about
the API surface changes: same exports, same subpaths, same contracts. The
bd CLI name keeps working, with businessdash added as an alias, and
existing bd.config.ts / bd.data-model.config.ts files are picked up
forever (businessdash.* variants are now preferred for new projects).
@businessdash/sdk on npm stays installable at ≤0.9.53 with a deprecation notice
pointing here.
Seed Export — your org's custom database, on your machine
npx bd export-data-model writes a complete, versionable snapshot of the
org's custom data model — every object, every field, and (non-sensitive)
records — to the developer's disk. Two jobs: a real backup you control before a
big migration, and ground truth an AI agent can read while building, so it
knows what exists without touching production. --schema-only for structure
without contents; pair with the "Download seed" surface in the dashboard.
@businessdash/sdk/graphql — a GraphQL SDL for the same model
npx bd export-graphql-schema generates GraphQL SDL from the org's data
model (--include-standard folds in the platform's standard objects), and the
new ./graphql entrypoint carries the supporting types. Schema-first for now —
the SDL is the contract your tooling, codegen, and agents can consume while the
executable query layer lands.
If you installed 0.9.53 from npm, this is the release you actually wanted.
The npm artifact published as 0.9.53 predated the tree it claimed to be: its
exports map lacked ./distribution, ./mcp, ./native, and
./analytics-server, and its shipped src/ was missing files — so a fresh
install broke every template importing the AEO/MCP entrypoints under strict
resolvers (vite 7.3.6+, vite 8, rolldown, turbopack, tsc). This release ships
the full surface the 0.9.53 notes below describe.
Compilation fixes for source-consuming frameworks (the ./angular, ./qwik,
./svelte, ./solid, and ./vue subpaths resolve to src/ by design, so SDK
source must compile under consumer tsconfigs):
- Every index-signature property access rewritten to bracket form (140 sites) —
SDK source now compiles clean under
noPropertyAccessFromIndexSignature(Angular's default strict template config). - Qwik
<textarea>used the React-styleautoCompleteattribute; qwik 1.20's JSX types requireautocomplete. - Qwik
ServerCompatQRLnow names the payload-only handler shapes explicitly —server$((payload) => …)from qwik-city ^1.20 assigns again (QRL's invariant type parameter hides ordinary parameter-narrowing compatibility). - Packaging: the tarball now ships the complete
src/tree. The previousfilesallowlist shippedsrc/sdk.tswithout the modules it imports, which broke every source-consuming framework (Could not resolve './client.js'). A pack-closure CI guard now fails any release whose shipped sources reference files missing from the tarball.
0.9.53 — AI Distribution, AEO Visibility & the MCP Proxy
The release that makes a consumer-hosted site legible to answer engines. Three
new entrypoints — @businessdash/sdk/distribution, @businessdash/sdk/mcp, and
@businessdash/sdk/analytics-server — plus an AI-visibility read-back on the
analytics client, a product() JSON-LD builder, and two formula functions.
@businessdash/sdk/distribution — llms.txt + product feed on your domain
Orgs curate products/services (or custom entries) under Dashboard →
Marketing → AI Distribution, and the platform keeps two public artifacts
current per site: an OpenAI merchant-feed-shaped product feed (submittable
to ChatGPT's product-feed program as-is) 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 BD where
the org curates it. The new entrypoint proxies the two together:
// app/llms.txt/route.ts
import { llmsTxtHandler } from '@businessdash/sdk/distribution'
export const GET = llmsTxtHandler({
siteId: process.env.BD_SITE_ID!,
baseUrl: process.env.NEXT_PUBLIC_BD_APP_URL!, // e.g. https://www.biab.app
})
llmsTxtHandler(options)— GET handler serving the curated llms.txt from your own domain root. Upstream 404 passes through untouched (feed disabled or entitlement lapsed), network failure returns a plain 503 — it never throws into the framework. Responses carry a 5-minutes-maxage+stale-while-revalidate.productFeedUrl(options)/llmsTxtUrl(options)— build the public BD feed URLs. The product feed needs no proxy — you submit its BD URL directly to merchant/feed programs;productFeedUrlbuilds it for display/submission.- Gated by the org's AI Distribution feature (
marketing.ai_distribution); a lapsed entitlement surfaces as the pass-through 404.
@businessdash/sdk/mcp — the site's AI connector on ANY 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 these two handlers and
your domain gains the same connector surface — so the URL an org hands to
Claude / ChatGPT / Gemini is their own site, exactly like the docs promise:
// app/api/mcp/route.ts
import { mcpHandler } from '@businessdash/sdk/mcp'
export const { POST, GET } = mcpHandler({
siteId: process.env.BD_SITE_ID!,
baseUrl: process.env.NEXT_PUBLIC_BD_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.BD_SITE_ID!,
baseUrl: process.env.NEXT_PUBLIC_BD_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.
@businessdash/sdk/analytics-server — first-party AEO visibility
AI crawlers (GPTBot, ClaudeBot, PerplexityBot, …) fetch your HTML without
executing JavaScript, so <BDAnalytics /> never sees them. Your server does:
// 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.BD_SITE_ID!,
baseUrl: process.env.NEXT_PUBLIC_BD_API_URL!,
apiKey: process.env.NEXT_PUBLIC_BD_PUBLISHABLE_KEY!,
userAgent: ua,
path: new URL(request.url).pathname,
});
}
}
-
isAiCrawlerUserAgent(ua)— cheap prefilter mirroring the platform's AI-crawler classifier (GPTBot, OAI-SearchBot, ClaudeBot/Claude-User/ Claude-SearchBot, PerplexityBot, GrokBot/xAI-Crawler, Google-CloudVertexBot, GoogleOther, Applebot-Extended, Bytespider, CCBot, Amazonbot, MistralAI-User, DuckAssistBot, YouBot, AI2Bot, …). The platform's list is authoritative — a lagging local list only under-reports, never records bad data. -
reportAiCrawlerHit(options)— reports one crawler fetch with the publishable key. Resolves quietly on every outcome — analytics must never break page serving. Abuse posture: the ingest endpoint keeps a forwarded user-agent ONLY when it classifies as a known AI crawler, so this path can record crawler visibility but can never inflate human visitor counts. -
Read it back —
analytics.aiVisibility(). Which crawlers actually fetched your pages: fetch counts, distinct pages read, vendor, intent (training/search/user— corpus building vs. answer-engine indexing vs. a live assistant reading on a person's behalf), and when each last came:const ai = await bd.site(siteId).analytics.aiVisibility({ days: 30 }); if (ai.available) { for (const c of ai.crawlers) console.log(`${c.name}: ${c.fetches} fetches over ${c.distinctPages} pages`); }Ordered most-fetches-first; defaults to a 30-day window. Branch on
.available— same plan gating aspageViews(). Contracts export theaiVisibility*schemas andAiCrawlerVisibilitytypes.
product() JSON-LD builder
@businessdash/sdk/seo adds a schema.org Product (+ nested Offer) builder —
what shopping surfaces and answer engines read off a product page:
import { product } from "@businessdash/sdk/seo";
const jsonLd = product({
url: "https://acme.co/store/widget",
name: "Widget",
brand: "Acme",
offer: { priceCents: 12900, availability: "InStock" },
});
priceCents is integer cents, formatted to schema.org's decimal string here.
Pair it with the AI Distribution product feed: the feed gets your product INTO
the engines' catalogs; this markup is what their crawlers verify on the landing
page itself.
Formula library: SUBSTITUTE + SLUGIFY
Two additions to FORMULA_FUNCTIONS (the engine behind the Math/calculation
form element and Business Functions, via @businessdash/sdk/forms):
SUBSTITUTE(text, find, replace, [find2, replace2, …])— multi-pair text replacement applied in a single pass, so a replacement's output is never itself re-replaced (pairs stay independent of their order; longest match wins at each position).SLUGIFY(text)— the platform's canonical strict slug: lowercase, accents stripped, non-alphanumerics collapsed to single hyphens.SLUGIFY("Solar Panel Install (2026)")→"solar-panel-install-2026".
Platform-side in this release window
Customer Loyalty (points + tiers) and a Customer Portal expansion (including a
staff preview-as-customer mode) shipped on the dashboard/portal side — no new
SDK exports yet. The org-grade OAuth MCP connector (the assistant acting as org
STAFF, with a consent screen and per-tool write gates) lives on the platform
host; the SDK's mcp entrypoint above fronts the public site-visitor
connector.
0.9.52 — Single-Resource Forms, Typed Join Tables & a Careers Surface
The schema pipeline learns to generate atomic Create/Edit/Delete forms, many-to-many joins carry typed payloads, public job postings get a client, two new form elements land, and a batch of parity fixes makes the dashboard and an SDK-rendered form finally agree on conditional logic.
createSingleResourceForm() — Create / Edit / Delete instead of a wizard
import { createSingleResourceForm } from "@businessdash/sdk/forms";
const { create, edit, delete: remove, dependencies } =
createSingleResourceForm(object);
- Generates the three atomic, single-record forms for one collection — Create, Edit and Delete — instead of one big multi-step wizard. Edit and Delete lead with a record picker ("which row are we acting on?").
- Relation fields become live collection pickers automatically, single or multi according to the relation's own cardinality, so a many-to-many is filled in inline and the join table never surfaces as a concept.
- Delete states its impact honestly — read from the relations that actually
point AT the collection and their declared
onDelete, rather than asserting a generic "respects referential integrity". - Lineage-safe.
createkeeps the basedata-model:<uid>sourceKey the one-form-per-collection generator has always used, so promoting a model that previously produced a single form updates that draft rather than orphaning it and creating a duplicate;edit/deletetake suffixed keys off the same stem. TypesSingleResourceForms/SingleResourceFormOptionsare exported from@businessdash/sdkand@businessdash/sdk/forms.
Typed join tables — belongsToMany({ fields }) end-to-end
- A join's columns — declared with the same
bdfield builders as a collection's — now compile into typed link-attribute definitions (LinkAttributeDefinition, mirroring the platform's link-attributes contract), the typed payload each many-to-many link row may carry. - A link is an EDGE, not a record: only a small set of value kinds makes sense on one. Anything richer maps to TEXT rather than silently pretending to be supported — richer shapes are asking for a real collection.
client.hiring — careers pages on your own domain
The org's open job postings and their categories, so a consumer can render a careers page in their own design system:
const { items } = await client.hiring.list({ limit: 20 });
const posting = await client.hiring.get("senior-installer");
if (posting.applicationFormId) {
const form = await client.forms.schema(posting.applicationFormId);
}
list({ limit, offset, category })— one page of open postings, newest-first, offset-paged vianextOffset. Only postings that are published, active, have seats left, and are under their application cap are returned — a careers page never advertises a role that can no longer be applied to.get(slug)— rejects with a 404 for drafts, closed, archived, filled, or capped postings; treat that as "this role is gone", not a transient failure.categories()— job categories with a count of open postings in each, enough for filter chips or grouped sections without fetching every posting.- Read-only by construction. Internal hiring state (openings filled,
application caps, default permissions/roles, interview questions) never
crosses this boundary. To take applications, hand the posting's
applicationFormIdto the forms surface — this client grants no submit path of its own. Contracts exportjobPostingSchema,jobCategorySchema, and friends.
New form elements: icon_select and image_input
icon_select— pick an icon by name from a searchable grid. The value is the icon's kebab-case name;iconConfig.allowedIconscurates the choices (a curated list skips the network entirely), otherwise the picker lazily fetches the platform's icon-name index. Names render as<img src="…/api/icons/<name>.svg">— the SDK deliberately bundles no icon set (~1,600 icons, and three of the bindings can't import a React package), so the platform stays the single source of truth for what a name means.image_input— one image as a single URL string, from an upload OR a pasted URL (imageInputConfig.sources: "both" | "upload" | "url").- Display variants —
displayextends beyondtoggle/checkboxto"dropdown"and"combobox". Display-only: never affects the submitted value or its validation. Dropdowns self-upgrade to a searchable input past 10 options, or when the builder chose the ComboBox card (SEARCHABLE_THRESHOLD/shouldSearchOptions). - Shared element helpers exported from
@businessdash/sdk/forms:BD_ICON_BASE,iconUrl,ICON_INDEX_URL,fetchIconNames,filterIconNames,SEARCHABLE_THRESHOLD,shouldSearchOptions. The new elements render in the React, Vue, Svelte and Angular bindings.
Conditional-logic parity — the dashboard and your site now agree
match: "any"is honored. The clause combinator was missing from the SDK's rule type, so a rule the builder saved as "ANY of" silently evaluated as "ALL of" once it rendered through the SDK — the dashboard and a consumer's site disagreed about the same form. Fixed across every binding.matchFieldon collection-picker rules. Collection pickers store record IDs — a rule can now compare a column of the chosen record instead ("__label__"= the displayed label); unresolved falls back to the raw value.- Conditional PAGES.
input_groupnow gates on itsconditionRule(at the root of a consecutive form a group IS a page, so a rule there makes the whole page conditional). A page whose rule is currently false is DROPPED from the step partition — "Next" skips straight past it and the progress indicator loses its entry — in React, Svelte, Vue, Angular, Solid, Qwik and vanilla alike. Its required fields no longer block Next/Submit, and stale values behind a hidden group no longer decide anor_conditionbranch. NewisNodeVisibleexport.
CLI: wipe-database
bd wipe-database --collections a,b— developer shortcut that wipes records AND removes the named collections from the schema: the backup-first data-wipe flow plus a pruned schema-draft push. The schema change still requires dashboard promotion.--allcalls the data-model reset endpoint, which atomically soft-deletes all metadata, clears records, and removes schema slots. The server saves and verifies the backup before resetting any data.- Accepts
--dry-run, and--yesto collapse to a single confirmation (unlikewipe-collections, which stays interactive-only by design).
Fixes
- Node-ESM import specifiers. Internal extensionless imports in the
validation and schema modules gained explicit
.jsspecifiers (./enums.js,./email-domains.js,./units.js,./us-states.js) — no more resolution failures for Node ESM /NodeNextconsumers. createFormSchemaOrchestratorthrows a focused error whenboundariesisn't an array, instead of failing obscurely mid-walk.
0.9.51 — Pipeline Completion
The 0.9.50 notes below were written at 0.9.51 and describe the unified Schema/Validation/Form pipeline as completed in this release — several documented pieces landed between the two npm artifacts. If you installed the 0.9.50 artifact, upgrading gets you:
bd wipe-collections— the backup-first record wipe (two-stage confirmation, verified Media backup, state-bound execution) documented under 0.9.50.- Generated-form schema sync —
sync-data-modelaccepts the full orchestrator result, offers the interactive candidate multi-select, and supports--forms,--forms slug-a,slug-b, and--no-formsfor CI. - The compile-time
Infer*family —InferFieldValue,InferFieldsValues,InferCollectionValues,InferFormValues,InferSchemaValues(+ the type-inference compile tests behind them). - Multi-location business details —
companyAddressSchema/companyLocationSchema,BusinessAddress/BusinessLocation,localBusinessLocations()+bdSchemas.localBusinessLocations(), and the extendedlocalBusiness()builder. - Deterministic fluent identities — collection, field, injected
primary-key, junction, and generated-form IDs converge across CLI
evaluations; camelCase junction fields and distinct
source…Id/target…Idself-relation fields. - Correct published artifacts — root declarations generated from the
TypeScript build (replacing the handwritten ambient declaration) and the
dist/index.cjsruntime file therequireexport map had been promising. - Legacy docs archive — the 0.9.35 README/CHANGELOG moved to
docs-legacy/0.9.35/, and the package README was rebuilt around the 0.9.50 pipeline.
0.9.50 — Unified Schema, Validation & Form Orchestration
Sweeping changes — migration required. Version 0.9.50 replaces the former schema-declaration and local form-orchestration model. Existing projects should follow the 0.9.50 migration guide before upgrading. For new builds targeting 0.9.50 or later, starting on 0.9.35 or an earlier declaration API is not recommended.
This is the first
bdSDK version override. 0.9.50 replaces the recommended 0.9.35 declaration path with a consolidated schema pipeline. The explicitdefineDataModel/defineField/defineObjectfunctions remain for compatibility, while new code should use the exports below.
New Imports & Core Namespace
import { collection, bd, Enums, createFormSchemaOrchestrator, layoutContainers, SchemaBoundary } from "@businessdash/sdk";
const { FormStep, ConditionalBlock, MultiPopulator, OrCondition } = layoutContainers;
bdfactory namespace — Compact alias forfieldswith fluent chainable builders:.formElement(),.validate(),.helper(),.regex(),.check(),.encrypted(),.encryptedLight(),.private(),.strategy(),.references(),.belongsToMany(),.onDelete().Enums()engine — Auto-generates humanized display labels from literal arrays. Supports camelCase/PascalCase splitting, custom label overrides, and compile-time value inference.
Multi-location Business Details
company.profile.locations. The marketing page bundle now carries typed branches, stores, offices, showrooms, warehouses, and service centers alongside the existing organization-wide address/contact defaults. Each location has a stableidandslug, primary/public flags, address and country, contact overrides, Google Place ID, optional coordinates/timezone, hours, service areas, and an optional schema.org business subtype.- Separate structured data nodes.
company.locationJsonLdcontains one independently addressableLocalBusinessnode per public location, linked tocompany.jsonLdas its parentOrganization. Internal locations are omitted; location hours and service areas inherit the company defaults when they are not overridden. - Typed custom rendering. Export
BusinessAddress,BusinessLocation,companyAddressSchema, andcompanyLocationSchemafor custom location selectors, route pages, forms, and API boundaries. - Programmatic multi-location SEO. Add
localBusinessLocations()andbdSchemas.localBusinessLocations()for code-owned data. The existinglocalBusiness()builder now accepts explicit IDs, branch URLs, parent organization links, geo coordinates, and map URLs. - Dashboard authoring. Settings → Company Profile now includes a Business locations editor. The former Address, Contact, Hours, and Service Areas sections remain the organization-wide fallback and continue to work for single-location businesses.
bd Primitive Modifiers
.formElement(element)— Maps the raw DB type to a preferred front-end input component (short_text,dropdown,currency,yes_no_toggle, etc.)..validate(rules)— Structural validation:minLength,maxLength,lessThan,greaterThan,round,positive,notNull,floor,isDecimal,regex,lessThanToday,greaterThanToday,specificDateValue..helper(text)— Descriptive metadata consumed natively by front-end form tooltips and input headers..regex(pattern)— Absolute input string validation via RegExp literal or string..check(sqlExpression)— Stores a SQL check expression in validation metadata..encrypted()/.encryptedLight()/.private()— Record security intent in local field metadata. The current serializer does not emit every security modifier; do not treat these as end-to-end server enforcement yet.
Complex Field Types
bd.measurement(options)— Physics metrics block. Handlessound,pressure,torque,speed(incl.Mach,ly/yr),length(µm,nm),storageSpeed,currency, etc. with automatic target unit scaling.bd.object({ properties }).strategy(mode)— Nested JSON with storage intent for'flattened','jsonb', or'separateTable'.- JSONB object-element output — All
RAW_JSON-backed object form elements can opt into.strategy('jsonb');strategyandseparateTableNamenow survivetoJSONSchema()serialization. bd.file(config)— File uploads withmax_size_mbandallowed_mimetypesconstraints.
Layout Containers (0.9.50+)
New semantic layout wrappers isolate UI flow rules from the database layer:
FormStep(name, options, ...children)— Sequential multi-step wizard pages. SupportsshowTitleand per-stephelperText.ConditionalBlock(config, ...children)— Dynamic visibility toggling.configacceptsmatchStrategy: 'AND' | 'OR', optional title/helper metadata, and rules withequals,not_equals,any_of, ornone_of.MultiPopulator(config, ...children)— N+1 array row repeater grid with explicit output routing. A single child collection infersseparateTable;{ output: { strategy: 'jsonb', fieldRef }, entryFields }binds the repeated array to a JSONB field. The orchestrator validates both targets.OrCondition(options, branches)— Multi-path router layout engine. Takes an array of namedOptionGroupobjects for branching pathways.
Unified Orchestration
createFormSchemaOrchestrator(boundaries[], options?)— Takes a flat array ofSchemaBoundary/collection()/ layout container nodes and compiles them into isolated channels:databaseSchema— Pure collection models (stripped of UI), ready for Prisma / Drizzle / sync pipelines.uiFormTree— Complete visual rendering graph without DB metadata.generatedForms— One collection-backed form candidate per compiled collection, plus a composite candidate for each multi-collectionFormStep. Fields use their explicit.formElement()or the first compatible recommendation and include an idempotentcreate_recordsaction; composite candidates link newly created parent records by alias.
SchemaBoundary({ featureId, name, description }, ...collections)— Optionally tags collections, layout containers, or nested boundaries with a named feature. It neither creates nor requires a form step or group.- Generated-form schema sync — The CLI accepts the full orchestrator result,
offers an interactive candidate multi-select, and supports
--forms,--forms slug-a,slug-b, and--no-formsfor CI. Selected forms travel with the draft schema, are validated against their object/field targets, and are idempotently created or refreshed as inactive drafts on promotion. - Deterministic fluent identities — Collection, field, injected primary-key, many-to-many junction, and generated-form IDs are deterministic across CLI evaluations. An unchanged config therefore converges instead of appearing as destructive object replacement.
- CamelCase junction fields and self-relations — Generated relationship
fields satisfy the receiver's camelCase contract. Self-referential
many-to-many joins use distinct
source…Idandtarget…Idfields.
Compile-Time Safety
-
Schema-inferred form and record values —
InferFieldValue,InferFieldsValues,InferCollectionValues,InferFormValues, andInferSchemaValuesexport the TypeScript value shape ofbdfields, collections, custom form boundaries, and collection maps. Inference preserves required/optional keys, nullability, exact enum literals, nestedbd.object()properties, relations, files, JSON, arrays, and measurements. These types can drive hand-built forms, Zod definitions, React Hook Form, shadcn/ui, submit handlers, and custom content components without requiring a BD-rendered form. -
Published declaration correctness — Root package declarations now come from the TypeScript build instead of the former handwritten ambient declaration. ESM and CommonJS declaration aliases are copied from that generated source, keeping root imports aligned with subpath exports.
-
CommonJS runtime artifact — The release build now emits the
dist/index.cjsfile declared by the packagerequireexport. Bothimport { layoutContainers } from "@businessdash/sdk"andrequire("@businessdash/sdk").layoutContainersresolve to matching 0.9.50 runtime surfaces. -
Alignment validation — Intercepts unmapped layout references at orchestration runtime. If a
ConditionalBlockrule references a collection or field that does not exist, orchestration throws a focused error:[BD SDK] Conditional rule references field "isActive" in collection "project" which does not exist. -
EvaluationRule.fieldRefis currently a string; field-path alignment is a runtime orchestration check rather than a template-literal compile-time check. -
Primary key auto-injection —
idfields are generated if omitted, usinguuidorintegerstrategy based ondefaultIdType.
Backup-first Collection Wipes
bd wipe-collections --collections a,b. Hard-delete the records inside explicitly named live custom collections without deleting their schema definitions. There is deliberately no implicit--alltarget and no--yesbypass.- Two-stage confirmation. The CLI reads live record and relationship
counts, requires an initial
yes, then requires the developer to type a plan-specific phrase exactly. Non-interactive terminals may preview with--dry-runbut cannot execute a wipe. - Verified Media backup before deletion. The receiver exports collection
metadata, field definitions, JSONB record values, soft-delete state,
timestamps, and every incoming/outgoing relationship into a versioned JSON
backup under Media → Database Backups → Collection Wipes. R2 object size
and SHA-256 metadata are verified before deletion begins. The Media row uses
an authenticated,
media.read-gated download bridge instead of exposing the database export through a public bucket URL. - State-bound execution. The confirmation token hashes the selected record
values and relationship graph. If data changes after preview—or while the
backup is being written—the wipe aborts. A final serializable transaction
rechecks that token and deletes only the backed-up record and relationship
IDs. The final recheck holds a short exclusive lock on the record and
relationship tables so an in-flight writer cannot create an orphaned link.
The operation requires a secret, user-bound key with
metadata:write_records; its user also needsmetadata.manageandmedia.manage.
Migration from the 0.9.35 baseline
| Area | 0.9.35 (legacy) | 0.9.50 |
|---|---|---|
| Schema declaration | defineDataModel() / defineField() / defineObject() | collection() + bd.* + createSchema() |
| Local form orchestration | Not available | FormStep(), ConditionalBlock(), OrCondition(), and MultiPopulator() via layoutContainers |
| Unified output | Separate custom-database and dashboard-form flows | createFormSchemaOrchestrator(nodes, options) returns databaseSchema, uiFormTree, generated form candidates, form controls, and feature tags |
| CLI export | defineDataModel() result | Full orchestrator result (database + selectable generated forms), or result.databaseSchema for collections only |
| Compatibility API | Explicit definition functions | Still exported but deprecated; use @businessdash/sdk/collections and @businessdash/sdk/forms for new work |
Legacy Docs
Select v0.9.35 in the BD developer docs navbar for the legacy documentation.
0.9.36
- Native Enums in the SDK. Export
Enums(),EnumEntry, andInferEnumValuesnatively from@businessdash/sdkand@businessdash/sdk/enums. Enables automatic label formatting (splitting camelCase/PascalCase, normalizing separators, capitalizing words) with support for custom label overrides and compile-time value inference.
0.9.34
-
Read your visitor counts back. Analytics used to be write-only — the
<BDAnalytics/>tracker recorded pageviews and you could only see the totals in the dashboard. Newclient.site(siteId).analytics.pageViews()returns them:// Site-wide, all time const all = await bd.site(siteId).analytics.pageViews() if (all.available) console.log(`${all.total.views} views`) // A batch of pages in one round-trip (e.g. a view count per portfolio item) const each = await bd.site(siteId).analytics.pageViews({ paths: ['/work/atrium', '/work/harbor'], days: 30, })Pass
pathsfor a per-path breakdown (every requested path present,0if unvisited) plus a combinedtotal; omit them for a site-wide total.dayswindows it.visitorsapproximates unique visitors (distinct daily-rotating anon id);viewsis the exact pageview number. Branch on.available— visitor analytics is a plan feature. -
“Powered by BusinessDash” footer, now in the SDK. New export
@businessdash/sdk/react-attribution:import { BusinessDashFooter } from '@businessdash/sdk/react-attribution' // In a Server Component — reads your plan's policy and renders accordingly <BusinessDashFooter client={bd.site(SITE_ID)} companyName="Acme Co" />BusinessDashFooterBanneris the presentational badge (passsiteId);BusinessDashFooteris a policy-aware Server Component. The badge carries abiab.app/?ref=<siteId>backlink — server-render it so it's visible.client.site(id).attributionexposes the policy read + an (optional) heartbeat. On Launch the badge is required; Growth/Scale may remove it — or keep it for a discount, toggled from Settings → Billing. -
sync-recordsshows live progress. A large seed used to sit silent between “Discovered N rows” and the final tally. It now prints a progress line (rows done, running created/updated) — a single updating line in a terminal, a bounded trail in CI.
0.9.33
-
Read your custom database back. The data model used to be write-only from the SDK — you could push a schema and seed rows, then never read either. New
client.site(siteId).dataModel.listRecords({ object })returns a page of a custom object's rows, newest first, keyset-paginated (nextCursor);listAllRecords()walks every page for you. Relations come back expanded as links —record.relations.<field>is[{ recordId, object }], since a relation value lives in a link table, not on the row. Every declared relation field is present,[]when it has none, so an empty relation is never mistaken for a missing one.Not the same as
collections/rows, which read Site Data. Rows seeded withsync-recordslive in the custom database and are reachable only throughdataModel. -
visibilityper object — decide who can read a table.defineObject()takes an optionalvisibility:private(default) — your server only, with a secret key.authenticated— any signed-in customer (tenant auth).public— anyone, from a publishable (pk_…) key in page JS.
So a table you mark
publicis readable straight from the browser; a private one never is. Because it's part of your model, opening a table is a reviewed change — a push shows it on the dashboard's Review tab as an exposure, confirmed separately from destructive changes, and a human promotes it. You can also flip it from Site Builder → Site Data → Database → Schema.⚠️
authenticatedmeans every signed-in customer can read every row of that table. It is not per-customer ownership — never use it to keep one customer's data away from another's. -
sync-data-modelplans now flag exposure. Opening a table to a wider audience shows up as its own EXPOSURE group in the printed plan, distinct from destructive changes — a change that loses no data but can't be undone once the data's been read shouldn't read as "you might lose rows". -
New scope
metadata:read_records. Reads from the custom database need it. Scopes freeze when a key is minted, so if you get "Missing scope metadata:read_records", issue a fresh key from Site Builder → Developer — it grants every org-safe scope.
0.9.32
- Corrected the dashboard locations the CLI prints.
sync-data-modelpointed at "CRM → Settings → Data Model", which was wrong twice over: the review + promote surface moved to Site Builder → Site Data → Database (beside Schema, since both are artifacts pushed from a repo for a human to review), and "Static Data" is now Site Data.sync-schema/sync-contentupdated to match.
0.9.31
-
CLI docs fix —
npx bd, notbd. The help text and docs showed the bare command as though it were globally installed. It's a package bin: run it vianpx bd <command>(orpnpm exec). -
Point
BD_PACKAGE_API_BASE_URLat the APEX host.https://www.biab.app307-redirects tohttps://biab.app, andfetchdrops theAuthorizationheader across that cross-origin hop — so the server reports "Missing bearer API key" as if the key were absent, when it never arrived. Usehttps://biab.app. Now called out in--help. -
Note on scopes: they're frozen when a key is minted. A key issued before the
metadata:*scopes existed reports "Missing scope metadata:read_schema" — mint a fresh one from Site Builder → Developer.
0.9.30
-
Data model as code — declare your custom database in your repo. New
defineDataModel()/defineObject()/defineField()(from@businessdash/sdkor@businessdash/sdk/data-model-schema) let you declare an org's custom tables, fields, and enums in abd.data-model.config.ts, push them from CI, and promote them from the dashboard. Deliberately separate from the marketing flow — its own config file, commands, endpoint, and API scopes, so a key for one can never reach the other. -
Four new CLI commands.
bd view-data-model— what's live in BD right now. Not "what I last pushed": the dashboard writes this model too, so the two drift.--jsonfor CI,--difffor the migration plan without pushing,--outto scaffold a config from an existing dashboard-built model.bd print-data-model— what your local config resolves to. No network.bd sync-data-model— push to the draft slot. Prints the migration plan first;--dry-runprints it and stops;--pruneopts into full reconciliation.bd sync-records— upsert rows from JSON (--records-dir, default./bd-records). Keyed on row identity, so re-running converges instead of duplicating.
-
A key proposes, a human promotes. Pushing never changes your database. It writes a draft; promoting is a dashboard action behind a separate permission. That's what makes it safe to hand CI a push key — and it's why the CLI has no promote command.
-
The migration plan. Every change is classified before it applies:
safe(no data touched),backfill(rewrites stored rows),destructive(may lose or invalidate data — needs an explicit confirmation). Knowing what a promote will do before you do it is the point of the feature. -
Identity over names. Objects and fields carry a
universalIdentifier, so renaming a field is a rename — one backfill — rather than a drop-and-recreate that quietly destroys a column of data. -
Enums with optional identity.
SelectOptiongains an optionaluniversalIdentifier. Supply it and renaming an option'svalueis understood as a rename and backfilled; omit it and behavior is unchanged (a rename reads as remove-plus-add). Additive — nothing existing breaks. -
Relations that propagate.
RELATIONfields take arelationTarget(another object'suniversalIdentifier) and anonDeletepolicy (restrict— the default — /setNull/cascade). Links store the target's id rather than a copy, so changing the original is reflected everywhere with nothing to sync. -
New scopes:
metadata:read_schema(view + plan),metadata:write_schema(push a draft),metadata:write_records(seed rows). None of them can promote.Docs:
/docs/developer/byFeature/custom-db.
0.9.20
- Invoice payment flow — confirmation number, receipt PDF, richer reads.
customerPortal().getInvoice(id)now also returnspayments[](each withamount/method/date/reference/status),confirmationNumber(the most recent completed payment reference — the Stripe PaymentIntent id for cards), andpdfPath. NewcustomerPortal().downloadInvoicePdf(id)returns the invoice as a PDF — decodedbytesplusfilename/contentType; once paid it carries a PAID marker and a payment-history summary. This closes the email → pay → dashboard/confirmation → download loop end to end (the paid confirmation email, with the confirmation number, is still sent automatically by BD). See the customer-portal docs for the full walkthrough + edge cases (failed, refund, dispute). - Return / refund requests (
ecommerce.shipping). NewcustomerPortal().submitReturnRequest(orderId, { reason?, customerNote?, items? })opens a return/refund request the org reviews; the org's approval (not the customer) triggers the Stripe refund + refund notifications. The order detail (getOrder) now includesreturnRequests[]so the customer can track status (requested → approved → refunded/denied). - Digital-goods downloads. New
customerPortal().getDownloads(orderId)returns one entitlement-checked link per paid line whose product has a file attached — a short-lived presigned URL for BD-hosted files (expiresInSecondsset) or the org's external link. Empty until the order's payment settles.
0.9.19
-
Chat file uploads (
chat.file_uploads). Visitors can attach files in the chatbot.getConfig()now returnsfileUploadsEnabled(true when the org'schat.file_uploadsentitlement is active) andemailCaptureMode("off" | "request" | "require"). Newclient.chatbot.uploadFile({ sessionId, file })presigns a direct-to-R2 PUT (7 MB cap, 5 files/hour per session) and returns a{ url, name, type, size }ref; the file lands in the org'schatUploadsFiles & Media folder (2-day TTL unless the org keeps it). Also surfaced on theuseChatbot()hook. -
<ChatbotInline>gained an upload affordance + email-capture gate. WhenfileUploadsEnabled, it renders a 📎 button;emailCaptureModedrives an email ask ("require"blocks the first message,"request"is skippable). Uploaded files render as a file bubble. All overridable via the existingclassNames/renderMessageAPI. The<Chatbot>iframe drop-in already supports uploads via the hosted embed. -
<ChatbotInline>is now a polished drop-in, not just headless. The inline (non-iframe) chatbot ships a built-in default theme — message bubbles, a shimmer "typing" indicator, styled input + inline form cards, and a scroll-aware bottom edge fade — plus a smarter scroll model matching the iframe<Chatbot>: it follows the live edge only while you're at the bottom (no more yanking you down while you read), anchors each new turn near the top with a peek of the previous message, and shows a "↓ Latest" jump button when you've scrolled away.- Still fully yours to style. Defaults are injected as zero-specificity
:where()rules keyed to per-slotdata-slotattributes, so anyclassNamesslot you pass already overrides them — no!importantneeded. Accent + surface colors read from the--bd-chatbot-accent/--bd-chatbot-bgCSS variables. Newunstyledprop renders the previous bare-div baseline; newjumpButtonclassName slot styles the jump control. - No API breaks.
classNames,renderMessage,renderPresenceStrip, and theuseChatbot()hook are unchanged; reactions remain internal-only and are not part of the consumer chatbot. The iframe<Chatbot>already gets the matching UI via the hosted embed.
- Still fully yours to style. Defaults are injected as zero-specificity
0.9.18
- Canonical social-profile support.
storefrontMeta().socialsis a free-form record keyed by BD's exact field keys (meta,twitterX,tikTok, …), so consumers kept hand-mapping keys→icons and silently dropping platforms (e.g. mappingfacebook/xand missingmeta/twitterX). NewresolveSocialProfiles(socials)SOCIAL_PLATFORMSregistry are the single source of truth: every platform in render order, with a stable simple-icons slug, handle→URL normalizer, and alias resolution. Also exportsresolveSocialPlatform,socialHref, and theSocialPlatformKey/SocialPlatformMeta/ResolvedSocialProfiletypes.
<SocialLinks>(@businessdash/sdk/react). Drop-in that renders an icon + link for every configured profile (branded glyphs via the simple-icons CDN; a globe fallback for platforms without one).<SocialLinks socials={meta.socials} color="white" />.
0.9.17
- Blog post likes from consumers.
client.blog().withSession(token)now exposestoggleLike(slug)(POST) andlikeStatus(slug)(GET →{ liked, likesCount }), mirroringpostComment. Previously consumers could read like counts and read/post comments, but had no way to like a post — that was a platform-only mutation.likeStatusreports whether the session visitor has already liked, so you can render a filled heart on load.
0.9.16
- Inline chat-card forms now render nested inputs.
InlineFormCardrecurses into container field types (input_group,condition_block,or_condition,flex_row,multi_populator) so chat forms that group inputs render their leaf fields instead of nothing. (Previously a consumer-side patch; folded into the SDK so every consumer gets it — no patch needed.) - Products can carry their resolved category. Storefront product rows, cards,
and detail now accept an optional
category { id, name, description }alongsidecategoryId, so a storefront can label/group products without a secondlistCategories()join. (listCategories()and thecategoryIdfilter are unchanged.) - Reminder: live staff presence is already available via
client.chatbot.getAvailability()→{ staffAvailable, onlineHandlers, … }.
0.9.15
-
Payment-method brand icons — framework-agnostic. The SDK now ships the SVG data for every payment method a Company Profile can enable, plus
paymentIconSvg(), which returns a ready-to-inject<svg>markup string that works in any framework — React (dangerouslySetInnerHTML), Vue (v-html), Svelte ({@html}), Angular ([innerHTML]), or the DOM (el.innerHTML).paymentIconData()returns the raw{ viewBox, paths }for full control.- Theming — the icon is "the color of the text" by default. It renders with
fill="currentColor", so it inherits the surrounding text color and adapts to light/dark automatically. Lock it permanently withtheme: "light" | "dark", or set any CSS color withcolor: "#1a1a1a"(wins overtheme). Text-only still works — just render the label and skip the icon. Unknown / custom ("additional") methods returnnullso you fall back to text. - Covers
cash,visa,mastercard,discover,americanExpress,personalCheck,paypal,venmo,cashApp,zelle,applePay. Icon art: Font Awesome Free (CC BY 4.0) + Simple Icons (CC0).
import { paymentIconSvg } from "@businessdash/sdk"; el.innerHTML = paymentIconSvg("visa") ?? ""; // currentColor (light/dark safe) paymentIconSvg("applePay", { theme: "dark", size: 28 }); // forced light icon, 28px paymentIconSvg("zelle", { color: "#6d1ed4" }); // explicit brand color - Theming — the icon is "the color of the text" by default. It renders with
0.9.14
- Forms: "Live" is now decoupled from "Chatbot access." A form is reachable
over the SDK when it is Live (
isActive, the master switch).chatbotAccessis now purely additive — it only governs whether the AI chatbot may surface the form. You no longer have to opt a form into the chatbot just to embed it on your own page or share it.client.forms.schema(slug)/client.forms.submit(slug, …)(and the class-basedgetForm/submitForm) now fetch & submit over the non-chatbot/forms/{slug}path, gated on Live only. The file-upload, payment-intent, and schedule-slots sub-resources moved with them. No code change required — just make sure the form is Live. New publishable keys include the newforms:read/forms:submitscopes by default; reissue an older key if a fetch/submit returnsmissing_scope.- New
getChatForm/submitChatFormkeep the chatbot path (Live andchatbotAccess) for theshow_formchat flow —submitChatFormstill carries the chat transcript to the resulting CRM inquiry. The ReactuseChatbot()getForm/submitFormhelpers use these automatically.
- Per-form public link. A Live form can be published to a hosted, shareable
page at
{orgSlug}.<platform>/f/{formSlug}via a new "Public link" toggle in the form's settings (a third, independent axis from Live and Chatbot). No SDK call — it's a BD-hosted page you can link anywhere. ^0.9.12/^0.9.13consumers pick this up automatically; the behavior change is strictly a loosening (a form that worked before still works).
0.9.13
-
Completes the 0.9.12 release. 0.9.12 went to npm before its README finished documenting the storefront listing-with-meta surface and the cross-adapter form autofill, and before the final non-React autofill wiring landed. 0.9.13 re-publishes the complete set — the features themselves are described in the 0.9.12 notes below.
^0.9.12consumers pick this up automatically. -
Customer-portal org pin no longer asks for a provider-specific id. The proxy handler now takes
organizationId— your BD org id (theidfrom the customer-portal context) — instead of an auth-provider org id. The platform resolves theX-BD-Customer-Portal-Orgpin by BD id or the legacy id, so this is backwards compatible:export const GET = createCustomerPortalProxyHandler({ bdBaseUrl: "https://biab.app/api/package/v1", apiKey: process.env.BD_API_KEY!, - workosOrganizationId: process.env.WORKOS_ORG_ID!, + organizationId: process.env.BD_ORG_ID!, });The old
workosOrganizationIdoption still works (now deprecated). TheworkosOrganizationIdfield on the customer-portalorganization/ other-orgs payloads is likewise deprecated — readid/orgIdinstead. Internal auth-provider names were scrubbed from the public JSDoc. No runtime break; migrate whenever it's convenient.
0.9.12
-
Followers / subscribe from the browser — new
useFollowershook. A publishable token can now subscribe a visitor (newsletter / "follow"), read their status, edit, and unsubscribe — allfollowers:self, no backend:import { useFollowers } from "@businessdash/sdk/react"; const f = useFollowers({ token: pk, siteId }); if (!f.subscribedLocally) { /* show the form */ await f.join({ email, source: "footer" }); } const { follower } = await f.me(user.email); // authoritative status (logged-in) await f.leave(email); // unsubscribeSource of truth for "already subscribed": logged-in → pass
user.emailtome()(authoritative, cross-device); anonymous →subscribedLocally, a per-browser flag the hook sets onjoin()(instant, no network, no PII). Email is the real key; the local flag is just a UX hint so an anonymous visitor isn't shown the form forever. Re-subscribing is idempotent. (Server:followers.joinmoved fromfollowers:write→followers:self, matchingfollowers.leave— self-service subscribe is a public action like form submit.) -
Storefront listing with facets, ratings, reviews, related & cross-sell. New
client.storefrontdata methods power a full platform-style shop (sidebar filters, star ratings, "you may also like", a companion-addon rail) without hand-rolling queries:// Faceted grid: filters + per-card price/ratings/badges + sidebar facets. const { items, categoryCounts, priceRange } = await client.storefront.listProductsWithMeta({ search, categoryId, minPriceCents, maxPriceCents, minRating, // 1..5 sort, // featured | newest | price-asc | price-desc | rating-desc }); await client.storefront.listCategories(); // sidebar categories await client.storefront.getProductReviews(id, { limit }); // { items, avgRating, totalCount, nextCursor } await client.storefront.getRelatedProducts(id, { limit }); // "you may also like" await client.storefront.getProductAddons(id); // companion / cross-sell railgetProduct(id)detail is enriched too — variants, per-image rows (images/variantImages), cross-variant pricing (crossVariantTitles/crossVariants), and structuredspecifications. Card prices are cents (cheapestPriceCents/comparePriceCents);categoryCountsis{ categoryId, count };priceRangeis{ minDollars, maxDollars }. -
<BdForm>inputs now carry HTML autofill (autocomplete) tokens. Each field gets a sensible token inferred from its type and label/key (a "Full name" text field →name, email →email, phone →tel, address sub-fields →street-address/address-level2/address-level1/postal-code, …), so browsers offer autofill. Controls:<BdForm slug="contact" token={pk} siteId={id} autoComplete={false} // global off → autocomplete="off" on all fieldAutoComplete={{ // per-field overrides (id → token|true|false) [companyFieldId]: "organization", [secretFieldId]: false, // off }} />Precedence: per-field override → schema token (
field.autocomplete) → globalautoComplete→ inference. Helpers exported from@businessdash/sdk/forms(inferAutocompleteToken,resolveFieldAutocomplete). Works across every adapter — React, Vue, Svelte, Solid, Qwik, Angular, vanilla, and the<bd-form>custom element (which exposesauto-complete/field-auto-completeattributes) — all honoring the sameautoComplete/fieldAutoCompleteprops. -
bd-forms.cssnow styles the file-upload field and the multi-step progress header (both the numbered-steps and the bar variants), in addition to the date-range / slot pickers. Theme-aware (reads your shadcn/COSS vars, neutral fallbacks). Containers are intentionally background-less — add a surface fill in your own app if you want one; the SDK won't impose one. -
Fix:
<BdForm>no longer hangs on "loading" under React StrictMode. The form controller'sload()bailed on adestroyedguard, but StrictMode runs effects mount → cleanup → mount — the cleanup destroyed the controller, so the remount'sload()returned early and the schema fetch's 200 was discarded, leaving the form spinning forever.load()now revives the controller (it's idempotent by contract). Affects any client-load path (publishable token or proxy) in dev; SSR/pre-fetched-schema was unaffected. -
Fix: browser publishable-token clients now default to the apex host. The default package-API base URL was
https://www.biab.app/…, butwww301/307-redirects to the apex (https://biab.app/…) and a CORS preflight does not follow redirects — so<BdForm token siteId>failed withPreflight response is not successful. Status code: 307and spun forever. The default is now the apex in bothreact.tsx(DEFAULT_PACKAGE_BASE_URL) andsdk.ts(DEFAULT_BASE_URL). If you pinned abaseUrl/NEXT_PUBLIC_*base URL, point it at the apex too. No API changes.
0.9.11
-
Publishable tokens (
pk_…) — drop<BdForm>on a public page with zero backend. A publishable token is browser-safe: it's origin-locked, rate-limited, and scoped to public read/submit actions only (never CRM writes or admin). Generate one in Site Builder → Developer → Publishable tokens, expose it as aNEXT_PUBLIC_*env, and pass it to<BdForm>:import { BdForm } from "@businessdash/sdk/react"; export function QuoteForm() { return ( <BdForm slug="service-quote" token={process.env.NEXT_PUBLIC_BD_PK!} siteId={process.env.NEXT_PUBLIC_BD_SITE_ID!} /> ); }Or set it once at the root and drop
<BdForm slug="…" />anywhere below:import { BdFormProvider, BdForm } from "@businessdash/sdk/react"; <BdFormProvider token={process.env.NEXT_PUBLIC_BD_PK!} siteId={process.env.NEXT_PUBLIC_BD_SITE_ID!} > <BdForm slug="service-quote" /> <BdForm slug="send_us_a_message" /> </BdFormProvider>No proxy route and no secret
BD_API_KEYin the browser.<BdForm>calls BD directly; the browser sendsOriginautomatically, which satisfies the token's allowed-origin lock.baseUrldefaults to production — override only for staging. The secret-key path (createBdClient/ same-origin proxy) still works for server-side use and write scopes.
0.9.10
-
Form-element refinements. New input types — URL (with link verification), multi-select, a date-range picker, and a color picker — plus validation and option upgrades to existing fields (extra
measurementcategories, business-only email, address ZIP/state handling, richer availability windows). All rendered + validated by<BdForm/>. -
Auto-book scheduling (
schedulefield,mode: "auto"). Ascheduleelement can now book a real meeting on submit instead of only capturing availability.<BdForm/>renders a live day/time slot picker (BdScheduleAuto) that pulls open times from the form's assigned staff's real calendars via the SDK'sforms.scheduleSlots(slug, { fieldId, from, to })endpoint, and submits the chosen slot as{ slotStartAt, timezone }. On submit the meeting books server-side — calendar event, video link, and confirmation emails all fire. Gated by the org'sscheduling.calendlyentitlement; without it the field falls back to availability capture.import { BdForm } from "@businessdash/sdk/react"; // The schedule field renders as a slot picker automatically — no wiring. export default function BookPage() { return <BdForm slug="book-a-consult" />; }The propose-back path (visitor states availability → staff offer up to 3 times → visitor confirms via a tokenized link) is handled entirely on the platform side; the same
schedulefield the SDK already renders captures the availability, and the confirm page is a platform-hosted link, so no extra consumer surface is needed. See the Forms guide.
0.9.9
- Two display elements (static — carry no submitted value, rendered by
<BdForm/>with stable BEM classes):- Body Text (
type: "body_text") — a plain paragraph fromfield.content(bd-field__body). - Bullet List (
type: "bullet_list") —field.listConfigwithvariant: "bullets"(optionaltitle+items[].text) or"details"(one row per point with an optional Lucideiconvia adata-iconhook, atextheading, and adescription). Classes:bd-field__bullets/bd-field__points+…__point-icon/-title/-desc. - Table (
type: "table") —field.tableConfig(columnswithheader/type"text"|"check"/align,rows[].cells, optionalhighlightColumn). A "check" column renders ✓/– from a truthy/falsy cell. Rendered as a real<table>withdata-align/data-highlight/data-checkhooks underbd-field__table. FormFieldDefnow also exposescontent(display HTML/plain text),listConfig, andtableConfigso consumers building their own UI can read them.
- Body Text (
- Measurement field (
measurement). A number with a unit the visitor picks (or a fixed unit). Submitted value is{ value, unit, base, baseUnit }—baseisvaluein the category's base unit, recomputed authoritatively server-side so downstream tables can sort/filter on a single number without per-row conversion.unitConfigcarriescategory,units,mode(select|fixed),defaultUnit,outputUnit(the org's preferred unit — convertbase→outputUnitto resolve internally), andallowDecimal.<BdForm/>renders the input + unit picker with a live "≈" conversion hint. Catalog + converters live in@businessdash/sdk(forms-core) — length, mass/weight, temperature, storage, energy, power, volume, area, speed, duration. - Currency field (
currency). A money amount with a symbol;currencyConfighassymbol,allowNegative,allowDecimal. - Number
allowDecimal. Thenumberfield gains anallowDecimalflag (whole-numbers-only when off), enforced in validation. - Multi-Populator (repeater). A new
multi_populatorfield type — a container whose children are an instance template the visitor repeats with "Add another". The submitted value is an array of instance records, each keyed by the child field ids (a scoped sub-submission).forms.schema(slug)exposespopulatorConfig({ maxInstances, addButtonLabel, itemLabel }) and the template onchildren.<BdForm/>renders a working repeater (bd-populator/…__item/…__add); validation enforces the instance cap, rejects unknown keys inside instances, and checks required child fields per-instance. - Coupon on completion + notice. A new
grant_couponform action attaches a coupon to the submission and surfaces a pre-submit notice on the form.forms.schema(slug).actionsnow includes acouponblock ({ code, notice, finePrint }) forgrant_couponactions;<BdForm/>renders it above the submit button (bd-coupon-notice), with{code}substituted in the notice. The coupon id / field mapping stay server-side. - Exit confirmation. When the org enables it,
<BdForm/>attaches abeforeunloadguard once the visitor has edited a field and hasn't submitted — warning before they abandon the form. Readforms.schema(slug).settings.exitConfirm({ enabled, message }); the browser shows a generic native prompt, andmessageis provided so you can also show it in your own modal. - Phone fields auto-format + cap length.
<BdForm/>'s phone input now formats as you type — national →(555) 123-4567(10-digit cap), international → loose 3-digit grouping preserving a leading+(15-digit E.164 cap). The submitted value carries the formatting;validateFormSubmissionand the server strip non-digits before the digit-count check, so it stays valid. Purely a UX improvement — no schema or contract change. - Calculation / Math fields (
type: "calculation"). A field whose value is computed from other fields rather than typed — either an inline formula or a reference to one of the org's reusable "business functions".FormFieldDefgains acalculationConfigdescribing the source. Non-sensitive calcs compute live in the browser as the visitor edits inputs, so the result updates in place; sensitive ones (the org marks them) compute authoritatively server-side on submit and never expose their formula. To make live compute possible without the org re-shipping logic,forms.schema(slug)now carries abusinessFunctionsarray (the non-sensitive function defs only) so the SDK can evaluate them on the client.<BdForm/>renders calculation fields read-only and keeps them in sync; the forms-core entry (@businessdash/sdk/forms) exports the evaluation primitives so consumers building their own UI can compute the same values:computeCalculation,formatCalcValue,validateFormula,runFormula,runBusinessFunction,functionsToEvalOptions, plus the formula / calculation types. - Deferred file uploads + form-wide cap. File fields no longer upload on
select —
<BdForm/>buffers the chosenFilein the field value and the upload happens duringsubmit(), so an abandoned form never orphans storage. Until submit, a file field holds aPendingFileValue(useisPendingFileValueto detect it). A form-wide cap ofFORM_WIDE_MAX_FILES(5) applies across all file fields combined, not per field. iPhone HEIC photos and HEVC /.movvideo are accepted automatically whenever a field allows images / video. forms-core (@businessdash/sdk/forms) exportsbuildAcceptAttr(theacceptstring for an<input type="file">),validateFile,isFileTypeAllowed,FORM_WIDE_MAX_FILES, andPendingFileValue/isPendingFileValueso a custom file UI matches the binding's rules exactly. - Fix-it wizard. When
submit()fails validation,<BdForm/>routes to the first errored field's step (consecutive multi-step forms) and scrolls it into view, so the visitor lands on what to fix instead of a top-of-form error list. The controller returns structuredissuesfor every error — required fields, the form-wide file cap, and upload failures — so a headless consumer can drive the same focus / scroll behaviour. - Additive only; safe to upgrade with
pnpm add @businessdash/sdk@^0.9.
0.9.8
- Choice Cards field (
type: "choice_cards") — selectable cards instead of a plain radio/checkbox list.forms.schema(slug)returns achoiceConfig:mode: "single"→ radio semantics; the submitted value is the chosen option'slabel(a string).mode: "multi"→ checkbox semantics; the value is astring[]of chosen labels.- Each option is
{ label, icon?, imageUrl?, price?, description?, productId?, href? }.labelis the only thing submitted;icon(a Lucide name),imageUrl,price,description,href, andproductIdare display + metadata — they let a card showcase a product/service and map a selection back to your catalog, but they never change validation. validateFormSubmissionenforces the same rule as the server: single must be one of the option labels, multi must be a subset.
- Headless render hooks.
<BdForm/>renders choice cards with stable BEM classes (bd-field__choice-cards,bd-field__choice-card,…__choice-card-image/-label/-price/-description/-link) plusdata-active,data-product-id, anddata-icon(the Lucide name) attributes — style them or swap in your own icon set without the SDK shipping an icon dependency. - Additive only; safe to upgrade with
pnpm add @businessdash/sdk@^0.9.
0.9.7
- Form schema carries render settings.
client.forms.schema(slug)now returnssettings(animation, multi-step, progress style) + anorgIconURL, so a consumer can render the form exactly as the org configured it. - New + richer form field types (all flow through
forms.schema/submitandvalidateFormSubmission):- Boolean fields expose
display: "toggle" | "checkbox"(same value, two UIs) andmustBeTrue— when set, the value must betrueto submit (consent, e.g. "I agree"); otherwise an optional yes/no accepts true OR false. - Tags (
type: "tags") — a free-text tag input whose value is astring[]. - Custom values —
allowCustomonselect/radio/checkboxlets a submission carry a value outsideoptions(the "Other…" choice); the options whitelist isn't enforced for that field.
- Boolean fields expose
- Submission provenance.
forms.submit(slug, data, { source, referrer, metadata })stamps where/how a form was filled (e.g."facebook","google-ads:summer"); it flows into CRM lead-source. No channel is invented — a missing source stays missing, and a per-form Require Source setting can reject sourceless submissions. Chatbot fills self-label as"chatbot". - Stricter client validation.
validateFormSubmissionnow matches the server: email requires a real TLD, phone enforces digit count. A required yes/no toggle is now satisfied by either answer — "no" is valid, not just "yes". - Reserved keys.
sourceandreferrerare reserved for submission provenance and can't be used as a field's output key. - Customer portal — billing actions & referrals.
customerPortal().payInvoice(id, { successUrl?, cancelUrl? })→ a Stripe-hosted Checkout URL to redirect to (card entry stays on Stripe; your app never touches card data).customerPortal().signContract(id)→ a ready, absolute hosted signing URL for the org's branded e-sign page.customerPortal().viewQuote(id)(alias ofgetQuote) alongsideacceptQuote/rejectQuote.customerPortal().myReferralStats()/myReferralHistory({ limit? })/myReferralPayouts()— the customer's own referral activity as an affiliate.customerPortal().listOrders({ limit? })/getOrder(id)/trackShipment(id)— orders + a shipping-status tracker (carrier, tracking link, status timeline, ETA) with a best-effort live carrier poll. Requires the org'secommerce.shippingentitlement.
- Storefront branding.
client.site(siteId).branding()returns the org's logo + favicon (site media) and org name + icon — render the masthead/favicon without the org re-supplying assets. - Shipping settings.
client.shipping.settings()returns the org's storefront shipping config (enabled, coarse ships-from, destination countries, duty model, carriers) — sanitized (no keys, no precise origin). - Form actions (preview).
forms.schema(slug)now includes a sanitizedactionsarray ({ type, condition }) describing what a submission triggers (e.g. "add to followers") so you can preview it. Execution is server-side on submit; action wiring (URLs, secrets, mapping) never leaves the server. - Additive only; safe to upgrade with
pnpm add @businessdash/sdk@^0.9.
0.9.6
- Form output keys + validated submissions. Each field in a form
schema (
client.forms.schema(slug)) now publishes a stable, readablekey(camelCase or snake_case, configurable per org / form / field). Build yourforms.submitpayload keyed by thatkey— the legacy fieldidis still accepted, so existing integrations keep working. - Strict server-side validation. Because form schemas are externally
addressable, every submission is now validated against the live form
definition before it's stored; payloads that don't match the schema are
rejected and nothing is written.
validateFormSubmission(schema, data)mirrors the server contract (and now accepts eitherkeyorid) so you can check client-side first. forms.submitreturns a result, not a throw. It now resolves to aFormSubmitResult—{ ok: true, submissionId }or{ ok: false, reason, message, issues? }wherereasondistinguishes API-key problems, plan/billing gates, rate limits, schema-validation failures, not- found, and network errors. Branch onresult.okinstead of catching.- Dry-run testing.
forms.submit(slug, data, { dryRun: true })validates a payload against the live schema and returns the same result shape, but stores nothing and fires no CRM / staff-notify side effects (dryRun: true, nosubmissionId). Same authenticated endpoint, so Postman/curl works too (send{ "test": true, ... }). Authenticated submits are rate-limited per key+form. - Additive only; safe to upgrade with
pnpm add @businessdash/sdk@^0.9.
0.9.5
- News / Updates feed on the bundle.
bundle.updatescarries Google-Business-style posts (offers / events / plain updates) with images, link, and posted date — render an "updates" surface alongside the blog without a second fetch. Untyped passthrough today (a localBundleUpdateItemtype matches the shape; see the starter templates). - Commerce SDK docs filled out (cart / checkout / coupons / subscriptions / customer portal) and migrations moved onto the journaled drizzle-kit flow.
- Additive only; safe to upgrade with
pnpm add @businessdash/sdk@^0.9.
0.9.4
- Reviews wall — lazy-loaded.
client.reviews.list({ limit, offset })paginates the public review wall, and the marketing bundle now carries a review aggregate (average, count, star histogram) so the first paint shows the summary + first page with no extra round-trip. Bundles served by pre-0.9.4 hosts simply omit the aggregate and the SDK tolerates it.
0.9.3
- Deploy/consumption hardening. Smooths the "install from npm in a real build pipeline" path (the starter templates and reference consumer now build against the published package rather than a workspace link) plus assorted fixes. No API changes.
0.9.2
- Programmatic Local SEO.
defineParallelPage({ routePattern: "/services/[service]/[area]", ... })declares one URL pattern; BD materialises the cartesian cross-product (e.g. 5 services × 30 areas = 150 indexed pages), each with its own meta, canonical, and sitemap entry.client.parallelPages.{list,listVariants,render,sitemapUrl, robotsUrl}. Gated onmarketing.programmatic_pages. - Billing-lifecycle degradation. Three states — grace (everything
works), lapsed (static reads keep serving, interactive surfaces return
a typed
billing_lapsed), suspended (robots →Disallow: /, sitemap empty, pages 503). Surface it withBdPaymentLapsedError/BdServiceSuspendedError. The customer's URLs survive the org's billing problem. - Privacy-conscious analytics.
<BDAnalytics />(@businessdash/sdk/react-analytics) orinitBdAnalytics(...)(@businessdash/sdk/analytics-core, framework-agnostic). No cookies, no localStorage, no third-party requests; daily-rotating anonymous-ID hash; DNT + GPC honoured. Wired into every starter template. - Three new starter templates (Angular, HTMX, Remix) bring the starter count to 11.
- Full notes: see the 0.9.2 release notes in the docs.
0.9.1
- Revalidation webhook architecture. BD publish events fan out to
the consumer via an HMAC-signed webhook. Drop-in for Next
(
export { POST } from "@businessdash/sdk/next/revalidate") callsrevalidateTag; the framework-agnosticcreateGenericRevalidateHandler(@businessdash/sdk/adapters/revalidate) lets any runtime bust its own cache. Replay-window + signature verification shared viaverifyAndExtractTags. - Scheduling + forms surface.
client.scheduling.*(event types + slots + bookings, Calendly-style) andclient.forms.*(schema-driven form definitions + submit + file upload).
0.9.0
- Full programmatic consumer surface — no iframes. Orgs render
native UI on top of every BD feature:
client.blog.*,client.storefront.*,client.cart.*(visitor-token or session-bound),client.checkout.*(Stripe handoff- status, Stripe Link opted in on every session),
client.coupons.validate(five kinds: amount / percent / free-shipping / BXGY / bundle-gift),client.address.*(Google Places autocomplete + verify),client.shipping.*(Shippo multi-carrier rates + tracking),client.notifications.*,client.subscriptions.*.
- status, Stripe Link opted in on every session),
- Customer portal + tenant auth (first npm release carrying the
0.8.6–0.8.9 interim work):
createAuthHandlermounts the WorkOS callback on the tenant's own domain;getTenantSession(...)validates thebd_sessioncookie server-side;<SignIn/>,<SignUp/>,<SignOut/>, anduseUsership from@businessdash/sdk/react. The portal (customerPortal(orgId).withSession(token)) exposes the signed-in customer's work bundle, jobs, quotes (accept/reject), per-job comments, and review submission. - Marketing bundle gains
banner(bundle.banner— scheduled, dismissible news messages) plus the brand / company / gallery / reviews sections, and a "contact the company" + staff-ETA surface on portal jobs. - Plan-gating contract. Gated reads return HTTP 200 with a
discriminated-union body (
{ available: false, reason, requiredPlan, upgradeUrl }); gated mutations return 403 with the same shape — branch onavailablewithout try/catch. - No breaking changes from 0.8.7. Full notes: see the 0.9.0 release notes in the docs.
0.8.x — Schema-driven marketing content
0.8.7
- Customer-portal review submission + per-job comments (migration 0139). Signed-in customers leave a review (optionally linked to a job) and comment on their own jobs from the portal.
0.8.6
- Customer-portal work bundle. Per-item detail, accept/reject on quotes, and the auth shortcuts the SDK's React components build on.
- Bundle schema gains
brand/company/gallery/reviewssections (the 0.8.4 interim work, first published here).
0.8.3
- CLI: better failure diagnostics for
sync-content. When the host responds toPOST /sites/:siteId/marketing/sectionswith an HTML page instead of JSON (e.g. Next's 404 shell because the route hasn't been deployed yet, a CDN error page, or an auth-portal redirect), the CLI now reports a clear "server returned an HTML page, not JSON — the bulk-upsert route may not exist on this host yet" message instead of the crypticUnexpected token '<'fromJSON.parse. A similar guard now wraps successful 2xx responses too, in case an edge proxy ever wraps the JSON body. - No API changes; safe to upgrade with
pnpm add @businessdash/sdk@^0.8.
0.8.2
- New:
bd sync-contentCLI command. Walks a local<rootDir>/<locale>/<page>/<section>.jsontree and bulk-uploads section values to BD via a new package-API endpoint (POST /sites/:siteId/marketing/sections). Per-row failures don't abort the batch — the CLI prints a✓ N succeeded ✕ M failedsummary with per-failure detail so first-time imports of legacy content surface every validation gap in one pass. Pass--laxto skip schema validation on the import (useful for staging content that still needs cleanup); pass--note "first import"to label every revision row created in this run. - New:
contentSyncfield ondefineSiteMarketingSchema(). Declares the local content layout to the CLI:{ rootDir, sectionAliases?, pageAliases?, locales?, skipSections? }.sectionAliaseslets filename → schema-key mismatches (e.g.marquee.json→trustMarquee) stay in one place;skipSectionsexcludes managed-data sections (gallery, blogFeed, pricing) whose values come from other dashboards. - New:
marketing:write_contentscope. Distinct frommarketing:write_schema— a content-sync key cannot reshape the schema, and a schema-only key cannot rewrite values. - Dashboard: V7 Pages list now shows real per-locale coverage. A
new
siteMarketing.content.listPagesOverviewtRPC procedure unions legacy single-doc pages with section-only pages and computes per-locale coverage against the EN section universe, so the<CoverageBar>atom renders accurate fill in one round-trip.
0.8.1
- Fix: Add explicit
.jsextensions on every relative import in the SDK source so Node's ESM loader resolves them at runtime. Without this,import "@businessdash/sdk"from a Node-ESM consumer (CLI, Astro, Next runtime) fails withCannot find module './client'because themodule: ES2022 + moduleResolution: Bundlerbuild leaves imports extension-less. Bundler-based consumers (Vite, Webpack, Turbopack) weren't affected — they resolve without extensions — but Node and the SDK's ownbdCLI were broken. - No API changes; safe to upgrade with
pnpm add @businessdash/sdk@^0.8.
0.8.0
- Schema-driven marketing flow. Consumers declare each marketing
section as a typed zod schema in
bd.config.tsviadefineSiteMarketingSchema(). The CLI (pnpm bd sync-schema) canonicalizes the body, computes a SHA-256, and POSTs to a draft slot on the host. The dashboard's "Promote" button copies draft → published — only path that writes the live slot, gated onwebsite.manage. A leaked SDK API key can stage a bad draft but cannot affect visitors. client.site(id).marketing.getPageBundle({ pageKey, locale }). Live-site read path. Returns sections tagged{ ok: true, data, source } | { ok: false, error }so one malformed row never crashes the page. Same call returns SEO + JSON-LD nodes + hreflang + availableLocales.@businessdash/sdk/seo— typed JSON-LD builders (LocalBusiness, Organization, Website, Service, FAQ, Breadcrumb, Article, Review, ProductOffer) +renderJsonLdNodes/renderJsonLdToHtmlfor<head>injection. AIEO-ready out of the box.- React hooks:
useMarketingPageBundle,useMarketingSection,useMarketingPageSeo,useMarketingLocales,usePublishedMarketingSchema,useMarketingBundlePreloader. - Versioning + restore on every content-bearing surface. Section values, SEO rows, brand tokens, and the schema itself. Restores are themselves revisions (append-only timeline, never destructive).
- Brand-token expansion server-side — SEO crawlers see resolved copy and rebranding is a one-row edit at the site level.
marketingPages.get(pageKey)still works. A server-side legacy adapter keeps the existingcustom-demo-style flow unchanged.
0.7.x — Early surfaces
0.7.62
- Chatbot tool-loop parity (recommend_form + uiActions, inline form rendering, CRM mirrors), live front-desk availability + wait-time hints, and BYOK provider keys (OpenAI / Anthropic / Grok).
0.7.51
- CORS preflight on chatbot routes for browser-side widgets hosted on cross-origin domains.
0.7.4
- Tenant auth via WorkOS Organizations — sign-in / sign-up /
sign-out for org end-users,
useUser()hook, SSRgetTenantSession().