π SPACE Client SDK for React
Release 0.4.0β
This minor release preserves existing public methods and adds subscription reads, contract events,
multiorganization token claims, event deduplication and JWKS verification. Backend SDKs also
provide pricing YAML reads, novation previews, optimistic concurrency and opt-in idempotent retries.
Use a SPACE server advertising the required X-Space-Capabilities; merging server changes alone
does not deploy them. Read userId through the provided helper: sub is opaque.
Browser and backend responsibilitiesβ
useSubscription, TokenService.getSubscription() and getUserId() work in token-only mode.
Have your backend generate the pricing token and deliver it to the browser; do not embed an
organization management key. Contract mutations, consumption and novation previews belong in
that backend. Events and resync require a live connection; token-only apps receive updates through
their own backend. On reconnect, explicitly refetch the current token: events have no replay guarantee.
Import the optional verifier from space-react-client/verify; verifying a token in a page does not
replace server-side authorization. The normal entry point does not bundle the verifier.
The SPACE React SDK provides fully-typed React/TypeScript components, hooks, and utilities to seamlessly integrate your web apps with SPACE.
With this SDK you can:
- β‘ Connect a React application to a SPACE instance (HTTP + WebSocket).
- π Generate and manage Pricing Tokens directly in the client.
- π§© Activate/deactivate UI components declaratively with the
<Feature>component. - π Subscribe to SPACE pricing events to keep your UI in sync.
[!WARNING] This SDK is intended for research and experimentation. For production usage, see License & Disclaimer.
β οΈ Important Noteβ
- Feature evaluation is always based on the Pricing Token.
space-react-clientdoes not call SPACE directly to check features; instead, it uses the Pricing Token stored in the browserβslocalStorage. (See SPACE communication protocol for more details on how pricing tokens work.)
π¦ Installationβ
Install with your package manager of choice:
npm install space-react-client@^0.4.0
# or
yarn add space-react-client@^0.4.0
# or
pnpm add space-react-client@^0.4.0
Peer dependencies:
react >= 18react-dom >= 18
β‘ Quick Startβ
The minimal setup to connect to SPACE, load a userβs Pricing Token, and render UI conditionally with <Feature>.
1. Wrap your app with SpaceProviderβ
import React from 'react';
import { createRoot } from 'react-dom/client';
import { SpaceProvider } from 'space-react-client';
import App from './App';
const config = {
url: 'http://localhost:5403', // Your SPACE instance URL
apiKey: 'YOUR_API_KEY', // API key issued by SPACE
allowConnectionWithSpace: true,
};
createRoot(document.getElementById('root')!)
.render(
<SpaceProvider config={config}>
<App />
</SpaceProvider>
);
[!WARNING] Setting
allowConnectionWithSpace: falsedisables all connections to SPACE. This means you can still evaluate features from a token, but event listeners (e.g., pricing_created, pricing_archived, etc.) as well as methods likesetUserIdandgenerateUserPricingTokenwill not work.
2. Identify the user and load a Pricing Tokenβ
import { useEffect } from 'react';
import { useSpaceClient } from 'space-react-client';
export function TokenBootstrapper() {
const spaceClient = useSpaceClient();
useEffect(() => {
spaceClient.setUserId('user-123')
.then(() => console.log("User's pricing token set"))
.catch(console.error);
// Listen for SPACE sync events
const onSync = () => console.log('Connected & synchronized with SPACE');
spaceClient.on('synchronized', onSync);
return () => spaceClient.off('synchronized', onSync);
}, [spaceClient]);
return <YourComponent />;
}
3. Gate UI with <Feature>β
import { Feature, On, Default, Loading, ErrorFallback } from 'space-react-client';
export function MeetingButton() {
return (
<Feature id="zoom-meetings">
<On>
{/* Rendered when feature is enabled */}
<button>Start meeting</button>
</On>
<Default>
{/* Rendered when feature is disabled */}
<button disabled>Upgrade to enable meetings</button>
</Default>
<Loading>
{/* Rendered while evaluating */}
<span>Checking your planβ¦</span>
</Loading>
<ErrorFallback>
{/* Rendered on error */}
<span>Could not verify your feature access.</span>
</ErrorFallback>
</Feature>
);
}
π Alternative: Token-only mode (no live connection)β
Set allowConnectionWithSpace: false to disable the WebSocket client.
You can then inject a Pricing Token from your backend:
import { useEffect } from 'react';
import { useTokenService } from 'space-react-client';
export function InjectTokenFromServer() {
const tokenService = useTokenService();
useEffect(() => {
fetch('/api/my-pricing-token')
.then(res => res.text()) // token as string
.then(token => tokenService.update(token))
.catch(console.error);
}, [tokenService]);
return <YourComponent />;
}
π API Referenceβ
Providersβ
-
SpaceProvider({ config, children })Initializes the client and provides context.- Props:
config: SpaceConfigurationurl: stringβ SPACE instance URLapiKey: stringβ Authentication key emitted by SPACEorganizationId?: stringβ Only for member user keys (usr_...): sent with the contract events handshakeallowConnectionWithSpace: booleanβ Iffalse, event listeners take no effect (default:true)
children: React.ReactNode
- Props:
Hooksβ
-
useSpaceClient(): SpaceClientAccess the connected SPACE client. Throws if not available. -
useTokenService(): TokenServiceManage Pricing Tokens in context. Available even if live connection is disabled. -
usePricingTokenPayload<T>(): T | nullThe decoded payload of the current Pricing Token (nullif missing or expired). Re-renders on token updates. -
useSubscription(): PricingTokenSubscription | nullThe subscription carried by the current Pricing Token:{ contractedServices, subscriptionPlans, subscriptionAddOns, contractVersion }. Re-renders on token updates and needs no connection to SPACE, so it works in token-only mode. Returnsnullwithout a valid token, or when the token was issued by a SPACE version that does not include thesubscriptionclaim.import { useSubscription } from 'space-react-client';
export function CurrentPlan() {
const subscription = useSubscription();
if (!subscription) return <span>No plan information</span>;
return (
<span>
Plan {subscription.subscriptionPlans['acme']} (pricing {subscription.contractedServices['acme']})
</span>
);
}
UI Componentsβ
-
<Feature id="feature-id">β¦</Feature>Declarative feature gating.- Subcomponents:
<On>β Rendered when feature evaluates totrue.<Default>β Rendered when feature evaluates tofalse.<Loading>β Rendered while evaluating.<ErrorFallback>β Rendered on errors (invalid id, expired token, etc).
- Subcomponents:
[!WARNING] The
feature-idis a string in the formatsaasName-featureName, always in lowercase. For example, to reference the pets feature from PetClinic, the resulting feature-id would be:petclinic-pets.
Client APIβ
SpaceClient is instantiated by SpaceProvider.
Methods:
on(event, callback)β Listen to SPACE events.off(event?, callback?)β Remove listeners. If no args, removes all listeners.setUserId(userId)β Set user for evaluations, generates a pricing token, and stores it.generateUserPricingToken()β Generate and return a fresh Pricing Token for the user configured withsetUserId. It does not store the token.
β Supported Events
synchronizedβ Client is connected and synced with SPACE.pricing_createdβ A new pricing was added.pricing_activatedβ A pricing moved from archived β active.pricing_archivedβ A pricing moved from active β archived.service_disabledβ A service was disabled.contract_created,contract_novated,contract_deleted,contract_renewedβ A contract of the API key's organisation was created, changed subscription, was deleted or renewed.resyncβ A namespace (pricingsorcontracts) connected or reconnected; see Delivery guarantees.errorβ Connection or processing error (including a rejected API key on the contract events namespace).
Pricing events (pricing_*, service_disabled) include the following object:
{
serviceName: string; // REQUIRED: The name of the service that triggered the event
pricingVersion?: string; // OPTIONAL: The version of the pricing involved
}
Contract events include:
{
userId: string;
organizationId: string;
contractVersion: string | null; // version of the contract after the change; null for contract_deleted
services: string[]; // services affected
}
[!IMPORTANT] Contract events travel over SPACE's authenticated
/contractsnamespace. The client opens it only when you register the first contract listener, and closes it when the last one is removed. The handshake carries{ apiKey }for an organisation key, or{ apiKey, organizationId }when the configuration hasorganizationId(SPACE requires it for member user keys,usr_...). They therefore only work when the client connects to SPACE with an API key (allowConnectionWithSpace: true).Token-only apps (
allowConnectionWithSpace: false, the recommended production setup, since the browser holds no SPACE key) do not receive contract events. Let your backend listen to them withspace-node-client(client.on('contract_novated', ...)) and push a fresh Pricing Token to the user's sessions (or have the page refetch it after a plan change).useSubscription()and<Feature>update as soon astokenService.update(newToken)runs.
// Only with allowConnectionWithSpace: true and an API key in the browser (development, internal tools).
spaceClient.on('contract_novated', ({ userId }) => {
if (userId === currentUserId) spaceClient.setUserId(userId); // reloads the Pricing Token
});
// Token-only: your backend tells the page that the contract changed (SSE, WebSocket, polling, ...).
const tokenService = useTokenService();
useEffect(() => {
const events = new EventSource('/api/my-contract-events');
events.onmessage = async () => tokenService.update(await (await fetch('/api/my-pricing-token')).text());
return () => events.close();
}, [tokenService]);
Event ids and delivery guaranteesβ
Pricing and contract listeners get a second argument, SpaceEventMeta: code, eventId (the same in every copy of one event), occurredAt (a Date: when SPACE stored the change) and rawOccurredAt (the string as sent). SPACE sends eventId and occurredAt from the version that advertises contract-event-ids in X-Space-Capabilities; with an older SPACE they are undefined.
spaceClient.on('contract_novated', (details, meta) => {
console.log(meta.eventId, meta.occurredAt, details.contractVersion);
});
SPACE events are hints that something changed, not a log of the changes:
- At most once per connection, no replay. While the client is disconnected or reconnecting, events are lost, and SPACE does not resend them.
- Repeats are possible. A consumer with several connections gets one copy per connection, all with the same
eventId. The client drops a repeatedeventIdbefore calling your listener (it remembers the last 10,000 ids for up to 10 minutes; events withouteventIdare always delivered). Configure it witheventDeduplication: { maxEntries, windowMs }in the client configuration, or turn it off witheventDeduplication: false. - No ordering. Never order events by
eventId(compare it for equality only), and do not compareoccurredAtacross SPACE instances.contractVersionis a hash, not a counter.
So treat a contract event as a reason to re-read (for the browser, to reload the Pricing Token), not as the new state. After a reconnection, re-read too, because what changed meanwhile was not announced. The resync event ({ namespace, reconnect }, with reconnect false on a namespace's first connection) tells you when:
spaceClient.on('resync', ({ namespace, reconnect }) => {
if (namespace === 'contracts' && reconnect && currentUserId) {
spaceClient.setUserId(currentUserId); // reloads the Pricing Token
}
});
Token-only apps get the same guarantees from their backend's SDK: space-node-client documents them and offers the same resync event.
Token Serviceβ
Methods:
update(token)β Validates & stores a pricing token.getPayload()β Return parsed token payload.getSubscription()β Return the token'ssubscriptionclaim, ornull.getUserId()β Return the userId the token was issued for (see below), ornull.evaluateFeature(featureId)β Returnstrue | false | null.
Token expectations:
exp: numberβ UNIX expiration.features: Record<string, { eval: boolean; limit?: number | null; used?: number | null }>subscription?: { contractedServices, subscriptionPlans, subscriptionAddOns, contractVersion }β optional; present in tokens from SPACE versions with the subscription-change API (typed asPricingTokenPayload/PricingTokenSubscription).userId?,organizationId?,aud?,iss?β optional; present in tokens from SPACE versions with per-organization userIds (capabilitycontract-userid-per-org).
Token claims: userId, organizationId, aud, issβ
A userId is unique only within its organization, so SPACE versions with per-organization userIds issue tokens with these claims:
| Claim | Value |
|---|---|
sub | urn:space:<organizationId>:<percent-encoded userId>. Opaque: do not parse it. |
userId | The userId as stored in SPACE (not encoded). |
organizationId | The organization of the contract. |
aud | The organization the token is for (same value as organizationId). |
iss | The SPACE server: its JWT_ISSUER, space by default. |
Tokens of older SPACE versions only have the bare userId in sub, so the new claims are optional in PricingTokenPayload. tokenService.getUserId() and pricingTokenUserId(payload) return the userId claim and fall back to sub only when the token has no userId claim (an older server); they never parse the URN. Code that used payload.sub as the userId should switch to them:
import { pricingTokenUserId, usePricingTokenPayload } from 'space-react-client';
const payload = usePricingTokenPayload();
const userId = payload ? pricingTokenUserId(payload) : null;
The token payload is decoded as base64url UTF-8 JSON (as every JWT is encoded), so userIds outside Latin-1 come out intact.
Verifying pricing tokens (opt-in)β
tokenService.update(token) decodes a token; it does not check its signature, and it does not need to when the token comes straight from your own backend over HTTPS. Apps that receive tokens through a channel they do not control (another frame via postMessage, a token kept in storage, a third-party embed) can verify them first with the public keys SPACE publishes. Verification is a separate entry point, so apps that do not import it do not bundle it (nor its dependency, jose):
import { PricingTokenVerifier } from 'space-react-client/verify';
// One verifier per SPACE, reused (it caches the keys).
const verifier = new PricingTokenVerifier({ url: 'https://space.example.com', organizationId: 'my-org-id' });
const payload = await verifier.verify(token); // throws if the token cannot be trusted
tokenService.update(token);
It needs SPACE with the capability pricing-token-jwks (tokens signed with ES256, public keys at GET {url}/api/v1/.well-known/jwks.json, no API key; SPACE's CORS settings, ALLOWED_ORIGINS, must allow your origin). It checks:
- the ES256 signature by a key of the JWKS, chosen by the token's
kid; any otheralg(HS256,none, ...) is rejected, and so is a token with ajwk,jku,x5uorx5cheader; exp, required (clockToleranceSeconds, default 0);iss(issuer, defaultspace, theJWT_ISSUERof your SPACE);audandorganizationId: the expected organization, required in the options or per call (verifier.verify(token, { organizationId })).
The keys are cached for the max-age of the JWKS response (5 minutes from SPACE) and fetched again, at most once per refetchCooldownMs (30 s), when a token names an unknown kid. Errors: PricingTokenSignatureError, PricingTokenExpiredError, PricingTokenAudienceError, PricingTokenIssuerError, PricingTokenUnknownKeyError, and PricingTokenVerificationError (their base class) with code PRICING_TOKEN_INVALID or PRICING_TOKEN_JWKS_UNAVAILABLE; SpaceFeatureUnsupportedError (capability: 'pricing-token-jwks') when SPACE does not publish its keys.
A check in the browser does not replace the checks of your backend: code running in the page can be changed by whoever controls the page. Authorize requests on the server (for example with space-node-client's verifyPricingToken).
π‘οΈ Security Considerationsβ
- Do not expose SPACE API keys in production. Instead, issue Pricing Tokens from your backend and deliver them to the client.
- Tokens are validated client-side with the
expclaim. Rotate or shorten TTLs as needed. - To check the signature of a token in the browser, use the opt-in
space-react-client/verifyentry point (see Verifying pricing tokens).