@spreadspace/react
v0.1.1
Published
React SDK for embedding SpreadSpace surfaces via a secure, token-isolated iframe.
Maintainers
Readme
@spreadspace/react
React SDK for embedding SpreadSpace surfaces in your
app via a secure, token-isolated <iframe>.
npm install @spreadspace/react
# or
pnpm add @spreadspace/reactreact and react-dom are peer dependencies (18 or 19).
What this is
<SpreadSpaceEmbed /> renders a SpreadSpace surface (e.g. the spreading
workspace or the document review panel) inside an iframe served from
embed.spreadspace.app, and wires the host side of the embed postMessage
protocol: auto-resize, navigation callbacks, theme sync, and — the important
part — token refresh that never exposes a token to your page.
This is the Path A (iframe) integration. For a co-resident React-only
widget that shares your bundle, see <SpreadSpaceReview /> shipped from the
SpreadSpace app. For server-side token/handle minting, see @spreadspace/node.
The security model in one paragraph
Your page never holds a SpreadSpace token. You give the SDK a getHandle
function that calls your backend, which proxies
POST /api/embed/iframe-urls with your ss_live_* API key and returns a
short-lived, single-use signed_url (it embeds an ih_… handle). The SDK
loads it directly as the iframe src. (Returning the bare handle_id also
works, but then you must set embedOrigin per environment.) Inside the iframe — same-origin to SpreadSpace — the handle is
exchanged for the real ss_embed_* token, which stays in the iframe. When the
token nears expiry or is rejected, the iframe asks your page (over
postMessage) for a fresh handle, the SDK calls getHandle again, and the
iframe re-exchanges it. A handle is worthless off SpreadSpace's origin and dies
in seconds; the token never crosses the iframe boundary.
Quickstart
import { SpreadSpaceEmbed } from '@spreadspace/react';
function LoanWorkspace({ loanId }: { loanId: string }) {
// getHandle calls YOUR backend, which proxies POST /api/embed/iframe-urls
// with your API key and returns the `signed_url` from the response.
const getHandle = async ({ surface, loanId }) => {
const res = await fetch('/my-backend/spreadspace/iframe-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ surface, loan_id: loanId }),
});
const { signed_url } = await res.json();
return signed_url; // a full, env-agnostic embed URL — never a token
};
return (
<SpreadSpaceEmbed
surface="spreading"
loanId={loanId}
getHandle={getHandle}
theme="dark"
onNav={(nav) => {
if (nav.action === 'open-loan') router.push(`/loans/${nav.data?.loanId}`);
if (nav.action === 'upload') router.push(`/loans/${loanId}/upload`);
}}
/>
);
}Your backend endpoint is a thin proxy:
// Server-side. Uses @spreadspace/node or a raw fetch with your API key.
app.post('/my-backend/spreadspace/iframe-url', async (req, res) => {
const r = await fetch('https://api.spreadspace.app/api/embed/iframe-urls', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPREADSPACE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ surface: req.body.surface, loan_id: req.body.loan_id }),
});
const data = await r.json();
res.json({ signed_url: data.signed_url }); // forward the signed URL (bare handle_id also works)
});Scope note: the minted token is capped to
documents:read,extractions:read, andspreads:read, locked to the oneloan_id. On plans without Analytics,spreads:readis dropped server-side, so thespreadingsurface falls back to documents-only — inspect the returnedscopesif you depend on it.
Props
| Prop | Type | Notes |
| --- | --- | --- |
| surface | 'spreading' \| 'documents' | Which surface to render. |
| loanId | string | Loan the surface is scoped to. |
| getHandle | (ctx) => Promise<string> \| string | Mints a fresh single-use handle. Called for the initial load and every refresh. |
| onNav | (payload) => void | Frame navigation intents (upload, open-loan, custom). |
| theme | 'light' \| 'dark' | Pushed to the frame on mount and on change. |
| onReady | (payload) => void | Frame shell mounted. |
| onError | (payload) => void | Frame error; fatal distinguishes severity. |
| embedOrigin | string | Override the embed app origin (rarely needed). |
| title / className / style / sandbox / initialHeight | — | Iframe element passthroughs. |
The component also forwards a ref exposing { reload(), iframe } — call
reload() to mint a fresh handle and reload the frame (e.g. after the user
re-authenticates).
Advanced: the protocol module
The typed envelope and both messengers are exported (also as
@spreadspace/react/postmessage) if you want to drive a hand-rolled iframe:
import { createHostMessenger, type NavPayload } from '@spreadspace/react/postmessage';createHostMessenger verifies the frame origin, pins inbound messages to your
iframe's contentWindow, correlates token-refresh request/response by id, and
replies with a handle — the response payload is type-branded so a token field
is a compile error.
License
MIT.
