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

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 via relList.supports("ar") rather than user-agent sniffing.
  • Web viewer (.glb/.gltf/.obj/.stl) — neutralMaterial flags the geometry-only formats, which render as a black silhouette in a viewer that lights them like a .glb.
  • CAD download (.step/.iges) — deliberately not rendered. STEP needs a geometry kernel, and the browser WASM builds are 10–30MB. It is also the wrong shape for who asks: an engineer wants the file in their own CAD package.

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

The CAD URL never appears in a payload. client.storefront.getCadDownload(id) issues it from a route that requires a 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.

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

Sitemaps — @businessdash/sdk/sitemap

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

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

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

Scheduling and conference calls on the main client

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

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

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

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

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

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

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

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

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

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

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

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

SEO for blogs and storefronts

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

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

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

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

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

Local SEO: service areas × services, and products

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Notification settings, per company

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

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

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

Every non-JS starter gained the same surface.

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

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

Measured, then closed:

StarterBeforeAfter
Swift11/1515/15
Phoenix12/1515/15
Flutter11/1515/15
Laravel11/1515/15
Kotlin10/1515/15
Vapor9/1515/15

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

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

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

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

The social-platform table is generated, not copied

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

businessdash seed — one command for the whole seed

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

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

Fixed: seedTargetFromEnv() read a variable nobody sets

It looked for 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:

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

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

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:

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

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

Checkout, customer auth and followers on every framework

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

@businessdash/sdk/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:

FrameworkStorefrontChat
ReactuseStorefrontuseChatbot (now core-backed)
SolidcreateStorefrontcreateChat
VueuseStorefrontuseChat
SveltecreateStorefrontcreateChat
QwikuseStorefrontuseChat
AngularcreateStorefrontcreateChat
Vanilla / elementcreateStorefrontcreateChat

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-style autoComplete attribute; qwik 1.20's JSX types require autocomplete.
  • Qwik ServerCompatQRL now 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 previous files allowlist shipped src/sdk.ts without 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-minute s-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; productFeedUrl builds 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) — POST proxies JSON-RPC to the platform's public per-site connector; GET mirrors the spec's 405. An unreachable upstream answers with a proper JSON-RPC -32603 error and a 502.
  • mcpManifestHandler(options) — serves .well-known/mcp.json with the endpoint URL rewritten to YOUR domain (the requesting origin).
  • Thin by design. The body is forwarded verbatim and the platform still enforces the org's MCP opt-in and per-tool write gates — a proxy can't widen anything.

@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 as pageViews(). Contracts export the aiVisibility* schemas and AiCrawlerVisibility types.

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. create keeps the base data-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/delete take suffixed keys off the same stem. Types SingleResourceForms / SingleResourceFormOptions are exported from @businessdash/sdk and @businessdash/sdk/forms.

Typed join tables — belongsToMany({ fields }) end-to-end

  • A join's columns — declared with the same bd field 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 via nextOffset. 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 applicationFormId to the forms surface — this client grants no submit path of its own. Contracts export jobPostingSchema, 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.allowedIcons curates 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 — display extends beyond toggle/checkbox to "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.
  • matchField on 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_group now gates on its conditionRule (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 an or_condition branch. New isNodeVisible export.

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.
  • --all calls 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 --yes to collapse to a single confirmation (unlike wipe-collections, which stays interactive-only by design).

Fixes

  • Node-ESM import specifiers. Internal extensionless imports in the validation and schema modules gained explicit .js specifiers (./enums.js, ./email-domains.js, ./units.js, ./us-states.js) — no more resolution failures for Node ESM / NodeNext consumers.
  • createFormSchemaOrchestrator throws a focused error when boundaries isn'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-model accepts the full orchestrator result, offers the interactive candidate multi-select, and supports --forms, --forms slug-a,slug-b, and --no-forms for 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 extended localBusiness() 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…Id self-relation fields.
  • Correct published artifacts — root declarations generated from the TypeScript build (replacing the handwritten ambient declaration) and the dist/index.cjs runtime file the require export 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 bd SDK version override. 0.9.50 replaces the recommended 0.9.35 declaration path with a consolidated schema pipeline. The explicit defineDataModel / defineField / defineObject functions 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;
  • bd factory namespace — Compact alias for fields with 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 stable id and slug, 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.locationJsonLd contains one independently addressable LocalBusiness node per public location, linked to company.jsonLd as its parent Organization. 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, and companyLocationSchema for custom location selectors, route pages, forms, and API boundaries.
  • Programmatic multi-location SEO. Add localBusinessLocations() and bdSchemas.localBusinessLocations() for code-owned data. The existing localBusiness() 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. Handles sound, 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'); strategy and separateTableName now survive toJSONSchema() serialization.
  • bd.file(config) — File uploads with max_size_mb and allowed_mimetypes constraints.

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. Supports showTitle and per-step helperText.
  • ConditionalBlock(config, ...children) — Dynamic visibility toggling. config accepts matchStrategy: 'AND' | 'OR', optional title/helper metadata, and rules with equals, not_equals, any_of, or none_of.
  • MultiPopulator(config, ...children) — N+1 array row repeater grid with explicit output routing. A single child collection infers separateTable; { 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 named OptionGroup objects for branching pathways.

Unified Orchestration

  • createFormSchemaOrchestrator(boundaries[], options?) — Takes a flat array of SchemaBoundary / 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-collection FormStep. Fields use their explicit .formElement() or the first compatible recommendation and include an idempotent create_records action; 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-forms for 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…Id and target…Id fields.

Compile-Time Safety

  • Schema-inferred form and record values — InferFieldValue, InferFieldsValues, InferCollectionValues, InferFormValues, and InferSchemaValues export the TypeScript value shape of bd fields, collections, custom form boundaries, and collection maps. Inference preserves required/optional keys, nullability, exact enum literals, nested bd.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.cjs file declared by the package require export. Both import { layoutContainers } from "@businessdash/sdk" and require("@businessdash/sdk").layoutContainers resolve to matching 0.9.50 runtime surfaces.

  • Alignment validation — Intercepts unmapped layout references at orchestration runtime. If a ConditionalBlock rule 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.fieldRef is currently a string; field-path alignment is a runtime orchestration check rather than a template-literal compile-time check.

  • Primary key auto-injection — id fields are generated if omitted, using uuid or integer strategy based on defaultIdType.

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 --all target and no --yes bypass.
  • 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-run but 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 needs metadata.manage and media.manage.

Migration from the 0.9.35 baseline

Area0.9.35 (legacy)0.9.50
Schema declarationdefineDataModel() / defineField() / defineObject()collection() + bd.* + createSchema()
Local form orchestrationNot availableFormStep(), ConditionalBlock(), OrCondition(), and MultiPopulator() via layoutContainers
Unified outputSeparate custom-database and dashboard-form flowscreateFormSchemaOrchestrator(nodes, options) returns databaseSchema, uiFormTree, generated form candidates, form controls, and feature tags
CLI exportdefineDataModel() resultFull orchestrator result (database + selectable generated forms), or result.databaseSchema for collections only
Compatibility APIExplicit definition functionsStill 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, and InferEnumValues natively from @businessdash/sdk and @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. New client.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 paths for a per-path breakdown (every requested path present, 0 if unvisited) plus a combined total; omit them for a site-wide total. days windows it. visitors approximates unique visitors (distinct daily-rotating anon id); views is 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" />
    

    BusinessDashFooterBanner is the presentational badge (pass siteId); BusinessDashFooter is a policy-aware Server Component. The badge carries a biab.app/?ref=<siteId> backlink — server-render it so it's visible. client.site(id).attribution exposes 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-records shows 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 with sync-records live in the custom database and are reachable only through dataModel.

  • visibility per object — decide who can read a table. defineObject() takes an optional visibility:

    • 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 public is 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.

    ⚠️ authenticated means 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-model plans 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-model pointed 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-content updated to match.

0.9.31

  • CLI docs fix — npx bd, not bd. The help text and docs showed the bare command as though it were globally installed. It's a package bin: run it via npx bd <command> (or pnpm exec).

  • Point BD_PACKAGE_API_BASE_URL at the APEX host. https://www.biab.app 307-redirects to https://biab.app, and fetch drops the Authorization header across that cross-origin hop — so the server reports "Missing bearer API key" as if the key were absent, when it never arrived. Use https://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/sdk or @businessdash/sdk/data-model-schema) let you declare an org's custom tables, fields, and enums in a bd.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. --json for CI, --diff for the migration plan without pushing, --out to 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-run prints it and stops; --prune opts 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. SelectOption gains an optional universalIdentifier. Supply it and renaming an option's value is 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. RELATION fields take a relationTarget (another object's universalIdentifier) and an onDelete policy (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 returns payments[] (each with amount / method / date / reference / status), confirmationNumber (the most recent completed payment reference — the Stripe PaymentIntent id for cards), and pdfPath. New customerPortal().downloadInvoicePdf(id) returns the invoice as a PDF — decoded bytes plus filename/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). New customerPortal().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 includes returnRequests[] 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 (expiresInSeconds set) 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 returns fileUploadsEnabled (true when the org's chat.file_uploads entitlement is active) and emailCaptureMode ("off" | "request" | "require"). New client.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's chatUploads Files & Media folder (2-day TTL unless the org keeps it). Also surfaced on the useChatbot() hook.

  • <ChatbotInline> gained an upload affordance + email-capture gate. When fileUploadsEnabled, it renders a 📎 button; emailCaptureMode drives an email ask ("require" blocks the first message, "request" is skippable). Uploaded files render as a file bubble. All overridable via the existing classNames / renderMessage API. 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-slot data-slot attributes, so any classNames slot you pass already overrides them — no !important needed. Accent + surface colors read from the --bd-chatbot-accent / --bd-chatbot-bg CSS variables. New unstyled prop renders the previous bare-div baseline; new jumpButton className slot styles the jump control.
    • No API breaks. classNames, renderMessage, renderPresenceStrip, and the useChatbot() 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.

0.9.18

  • Canonical social-profile support. storefrontMeta().socials is 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. mapping facebook/x and missing meta/twitterX). New resolveSocialProfiles(socials)
    • SOCIAL_PLATFORMS registry are the single source of truth: every platform in render order, with a stable simple-icons slug, handle→URL normalizer, and alias resolution. Also exports resolveSocialPlatform, socialHref, and the SocialPlatformKey / SocialPlatformMeta / ResolvedSocialProfile types.
  • <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 exposes toggleLike(slug) (POST) and likeStatus(slug) (GET → { liked, likesCount }), mirroring postComment. Previously consumers could read like counts and read/post comments, but had no way to like a post — that was a platform-only mutation. likeStatus reports 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. InlineFormCard recurses 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 } alongside categoryId, so a storefront can label/group products without a second listCategories() join. (listCategories() and the categoryId filter 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 with theme: "light" | "dark", or set any CSS color with color: "#1a1a1a" (wins over theme). Text-only still works — just render the label and skip the icon. Unknown / custom ("additional") methods return null so 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
    

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). chatbotAccess is 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-based getForm / 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 new forms:read / forms:submit scopes by default; reissue an older key if a fetch/submit returns missing_scope.
    • New getChatForm / submitChatForm keep the chatbot path (Live and chatbotAccess) for the show_form chat flow — submitChatForm still carries the chat transcript to the resulting CRM inquiry. The React useChatbot() getForm / submitForm helpers 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.13 consumers 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.12 consumers 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 (the id from the customer-portal context) — instead of an auth-provider org id. The platform resolves the X-BD-Customer-Portal-Org pin 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 workosOrganizationId option still works (now deprecated). The workosOrganizationId field on the customer-portal organization / other-orgs payloads is likewise deprecated — read id / orgId instead. 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 useFollowers hook. A publishable token can now subscribe a visitor (newsletter / "follow"), read their status, edit, and unsubscribe — all followers: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);                          // unsubscribe
    

    Source of truth for "already subscribed": logged-in → pass user.email to me() (authoritative, cross-device); anonymous → subscribedLocally, a per-browser flag the hook sets on join() (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.join moved from followers:write → followers:self, matching followers.leave — self-service subscribe is a public action like form submit.)

  • Storefront listing with facets, ratings, reviews, related & cross-sell. New client.storefront data 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 rail
    

    getProduct(id) detail is enriched too — variants, per-image rows (images / variantImages), cross-variant pricing (crossVariantTitles / crossVariants), and structured specifications. Card prices are cents (cheapestPriceCents / comparePriceCents); categoryCounts is { categoryId, count }; priceRange is { 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) → global autoComplete → 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 exposes auto-complete / field-auto-complete attributes) — all honoring the same autoComplete / fieldAutoComplete props.

  • bd-forms.css now 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's load() bailed on a destroyed guard, but StrictMode runs effects mount → cleanup → mount — the cleanup destroyed the controller, so the remount's load() 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/…, but www 301/307-redirects to the apex (https://biab.app/…) and a CORS preflight does not follow redirects — so <BdForm token siteId> failed with Preflight response is not successful. Status code: 307 and spun forever. The default is now the apex in both react.tsx (DEFAULT_PACKAGE_BASE_URL) and sdk.ts (DEFAULT_BASE_URL). If you pinned a baseUrl / 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 a NEXT_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_KEY in the browser. <BdForm> calls BD directly; the browser sends Origin automatically, which satisfies the token's allowed-origin lock. baseUrl defaults 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 measurement categories, business-only email, address ZIP/state handling, richer availability windows). All rendered + validated by <BdForm/>.

  • Auto-book scheduling (schedule field, mode: "auto"). A schedule element 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's forms.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's scheduling.calendly entitlement; 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 schedule field 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 from field.content (bd-field__body).
    • Bullet List (type: "bullet_list") — field.listConfig with variant: "bullets" (optional title + items[].text) or "details" (one row per point with an optional Lucide icon via a data-icon hook, a text heading, and a description). Classes: bd-field__bullets / bd-field__points + …__point-icon/-title/-desc.
    • Table (type: "table") — field.tableConfig (columns with header/type "text"|"check"/align, rows[].cells, optional highlightColumn). A "check" column renders ✓/– from a truthy/falsy cell. Rendered as a real <table> with data-align / data-highlight / data-check hooks under bd-field__table.
    • FormFieldDef now also exposes content (display HTML/plain text), listConfig, and tableConfig so consumers building their own UI can read them.
  • Measurement field (measurement). A number with a unit the visitor picks (or a fixed unit). Submitted value is { value, unit, base, baseUnit } — base is value in the category's base unit, recomputed authoritatively server-side so downstream tables can sort/filter on a single number without per-row conversion. unitConfig carries category, units, mode (select|fixed), defaultUnit, outputUnit (the org's preferred unit — convert base → outputUnit to resolve internally), and allowDecimal. <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; currencyConfig has symbol, allowNegative, allowDecimal.
  • Number allowDecimal. The number field gains an allowDecimal flag (whole-numbers-only when off), enforced in validation.
  • Multi-Populator (repeater). A new multi_populator field 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) exposes populatorConfig ({ maxInstances, addButtonLabel, itemLabel }) and the template on children. <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_coupon form action attaches a coupon to the submission and surfaces a pre-submit notice on the form. forms.schema(slug).actions now includes a coupon block ({ code, notice, finePrint }) for grant_coupon actions; <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 a beforeunload guard once the visitor has edited a field and hasn't submitted — warning before they abandon the form. Read forms.schema(slug).settings.exitConfirm ({ enabled, message }); the browser shows a generic native prompt, and message is 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; validateFormSubmission and 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". FormFieldDef gains a calculationConfig describing 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 a businessFunctions array (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 chosen File in the field value and the upload happens during submit(), so an abandoned form never orphans storage. Until submit, a file field holds a PendingFileValue (use isPendingFileValue to detect it). A form-wide cap of FORM_WIDE_MAX_FILES (5) applies across all file fields combined, not per field. iPhone HEIC photos and HEVC / .mov video are accepted automatically whenever a field allows images / video. forms-core (@businessdash/sdk/forms) exports buildAcceptAttr (the accept string for an <input type="file">), validateFile, isFileTypeAllowed, FORM_WIDE_MAX_FILES, and PendingFileValue / isPendingFileValue so 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 structured issues for 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 a choiceConfig:
    • mode: "single" → radio semantics; the submitted value is the chosen option's label (a string). mode: "multi" → checkbox semantics; the value is a string[] of chosen labels.
    • Each option is { label, icon?, imageUrl?, price?, description?, productId?, href? }. label is the only thing submitted; icon (a Lucide name), imageUrl, price, description, href, and productId are display + metadata — they let a card showcase a product/service and map a selection back to your catalog, but they never change validation.
    • validateFormSubmission enforces 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) plus data-active, data-product-id, and data-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 returns settings (animation, multi-step, progress style) + an orgIcon URL, so a consumer can render the form exactly as the org configured it.
  • New + richer form field types (all flow through forms.schema/submit and validateFormSubmission):
    • Boolean fields expose display: "toggle" | "checkbox" (same value, two UIs) and mustBeTrue — when set, the value must be true to 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 a string[].
    • Custom values — allowCustom on select / radio / checkbox lets a submission carry a value outside options (the "Other…" choice); the options whitelist isn't enforced for that field.
  • 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. validateFormSubmission now 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. source and referrer are 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 of getQuote) alongside acceptQuote / 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's ecommerce.shipping entitlement.
  • 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 sanitized actions array ({ 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, readable key (camelCase or snake_case, configurable per org / form / field). Build your forms.submit payload keyed by that key — the legacy field id is 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 either key or id) so you can check client-side first.
  • forms.submit returns a result, not a throw. It now resolves to a FormSubmitResult — { ok: true, submissionId } or { ok: false, reason, message, issues? } where reason distinguishes API-key problems, plan/billing gates, rate limits, schema-validation failures, not- found, and network errors. Branch on result.ok instead 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, no submissionId). 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.updates carries 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 local BundleUpdateItem type 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 on marketing.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 with BdPaymentLapsedError / BdServiceSuspendedError. The customer's URLs survive the org's billing problem.
  • Privacy-conscious analytics. <BDAnalytics /> (@businessdash/sdk/react-analytics) or initBdAnalytics(...) (@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") calls revalidateTag; the framework-agnostic createGenericRevalidateHandler (@businessdash/sdk/adapters/revalidate) lets any runtime bust its own cache. Replay-window + signature verification shared via verifyAndExtractTags.
  • Scheduling + forms surface. client.scheduling.* (event types + slots + bookings, Calendly-style) and client.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.*.
  • Customer portal + tenant auth (first npm release carrying the 0.8.6–0.8.9 interim work): createAuthHandler mounts the WorkOS callback on the tenant's own domain; getTenantSession(...) validates the bd_session cookie server-side; <SignIn/>, <SignUp/>, <SignOut/>, and useUser ship 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 on available without 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 / reviews sections (the 0.8.4 interim work, first published here).

0.8.3

  • CLI: better failure diagnostics for sync-content. When the host responds to POST /sites/:siteId/marketing/sections with 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 cryptic Unexpected token '<' from JSON.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-content CLI command. Walks a local <rootDir>/<locale>/<page>/<section>.json tree 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 failed summary with per-failure detail so first-time imports of legacy content surface every validation gap in one pass. Pass --lax to 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: contentSync field on defineSiteMarketingSchema(). Declares the local content layout to the CLI: { rootDir, sectionAliases?, pageAliases?, locales?, skipSections? }. sectionAliases lets filename → schema-key mismatches (e.g. marquee.json → trustMarquee) stay in one place; skipSections excludes managed-data sections (gallery, blogFeed, pricing) whose values come from other dashboards.
  • New: marketing:write_content scope. Distinct from marketing: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.listPagesOverview tRPC 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 .js extensions 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 with Cannot find module './client' because the module: ES2022 + moduleResolution: Bundler build leaves imports extension-less. Bundler-based consumers (Vite, Webpack, Turbopack) weren't affected — they resolve without extensions — but Node and the SDK's own bd CLI 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.ts via defineSiteMarketingSchema(). 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 on website.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 / renderJsonLdToHtml for <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 existing custom-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, SSR getTenantSession().