Skip to content

feat(client): read the public catalogue with a publishable key - #4

Draft
fuzcap wants to merge 1 commit into
feat/billingfrom
feat/public-catalog
Draft

fuzcap wants to merge 1 commit into
feat/billingfrom
feat/public-catalog

Conversation

@fuzcap

@fuzcap fuzcap commented Oct 7, 2026

Copy link
Copy Markdown

The client side of kaitencloud/kaiten#32: in public mode (authScheme: "publishable", a pk_ key), components.getCatalog() now reads GET /api/public/catalog, the only route a publishable key authorizes. Before this, public mode asked GraphQL, which answers 401 to a pk_.

Stacked on #2 (feat/billing).

Behaviour

  • Public mode sends X-Kaiten-Publishable-Key to /public/catalog and nowhere else. It never falls back to GraphQL or the REST fan-out: both refuse the key, and the key has no business being sent there (billing spec §14.6.1). A refused key rejects with the API's 401/403, and onDegraded does not fire.
  • Bearer mode reads the catalogue exactly as before.

Mapping (§14.5)

API SDK
default MONTHLY / ANNUAL FLAT_FEE unitAmount Plan.price / Plan.annualPrice, minor units
billingPeriod interval, plus new intervalCount (quarterly = month × 3, semi-annual = month × 6)
currency currencyCode
familySlug Plan.familyId
lifecycleState published, lifecycleState
entitlement type + inner value EntitlementValue ({ type: "number", value }), unlimited, presentation fields
compatibleAddonFamilies Plan.addOns, resolved against the public add-ons

All new fields are optional, so the client stays on 1.x:

  • PlanPrice: id, intervalCount, unitAmountDecimal, billingModel, meteredEntitlementSlug, saleUnitFactor, isDefault;
  • Plan: pricingType, selfServe, selfServeCtaUrl, trialPeriodDays, requiresPaymentMethod;
  • Addon: familySlug, maxQuantity;
  • KaitenCatalog: addOns, capabilities.

⚠️ One deviation, for the components

§14.5 says a fractional-minor-unit price (unitAmount absent, e.g. €0.005 per token) must never go into amount. But PlanPrice.amount is a required number, and making it optional would break every consumer's types. So such a price carries the exact decimal in unitAmountDecimal, and amount is that same fraction as a number (0.5), documented on the field. Components must format metered prices from unitAmountDecimal, per sale unit, and never add them to a headline. Flat fees are always whole minor units, so price/annualPrice are unaffected.

For the React SDK (PricingTable)

  • Read plan.selfServe: when false, render plan.selfServeCtaUrl ("Contact us"), always for pricingType: "CUSTOM".
  • Metered prices are the ones with billingModel USAGE_BASED/OVERAGE. Render them "per saleUnitFactor units", not as a monthly headline.
  • catalog.capabilities.checkout is false until customer sessions ship.

Notes

  • The wire types live in src/client/public-catalog.ts, written by hand. contracts/openapi.yaml follows released kaiten versions and this route is newer; when contract-drift brings it in, the generated types can replace them and the mapping stays.
  • Six existing catalogue tests used publishable mode as convenient setup for the GraphQL/REST path. They now construct a bearer client, which is the path they test.

Tests

  • vp run -r build && vp check && vp test: 221 passing.
  • New tests:
    • the request goes to /public/catalog with the key and no Authorization;
    • the mapping: headline prices, quarterly interval, fractional overage, unlimited entitlement, add-on resolution;
    • no fallback after a 401.

With authScheme "publishable", getCatalog reads GET /api/public/catalog, the
one route a pk_ key authorizes, and maps it to the SDK's catalogue (billing
spec 14.5): the default monthly and annual flat fees become price and
annualPrice, periods become intervals (quarterly is month x 3), metered prices
keep their sale unit and exact decimal, and each plan lists the public add-ons
that fit it. Public mode never falls back to GraphQL or the REST fan-out: both
refuse a publishable key, and the key has no business being sent there.

Every new field is optional, so the client stays on 1.x. The wire types are
written by hand until contract-drift brings the route into contracts/.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Ribuot <tom.ribuot@kaiten.sh>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant