diff --git a/docs/embed-sdk/v0.md b/docs/embed-sdk/v0.md new file mode 100644 index 0000000..c415d40 --- /dev/null +++ b/docs/embed-sdk/v0.md @@ -0,0 +1,272 @@ +# Blento Embed SDK — protocol v0 + +The Blento Embed SDK lets a third-party app (e.g. `atmo.rsvp`) hosted inside a +Blento page perform AT Proto writes on behalf of the visitor — without ever +seeing their session, cookies, or tokens. All writes are mediated by Blento's +server using the visitor's signed-in OAuth session. + +## Architecture + +``` +┌─ blento.app/ (top window, visitor signed in) ─────┐ +│ │ +│ ┌─ atmo.rsvp/embed/ (iframe, your app) ─────┐ │ +│ │ +``` + +The script exposes `window.Blento` synchronously and starts a handshake with +the parent. Wait for `Blento.ready` before any write. + +## Trust model + +Each origin is added to a hardcoded server-side allowlist with the collection +NSID prefixes it may write. v0 ships with: + +| Origin | Allowed collection prefixes | +| ------------------- | ----------------------------- | +| `https://atmo.rsvp` | `community.lexicon.calendar.` | + +Adding a new origin or collection requires: + +1. Adding the entry to `src/lib/embed/allowlist.ts`. +2. If the collection isn't already in `src/lib/atproto/settings.ts`'s + `collections` list, add it there too — Blento's OAuth scope only covers + collections it explicitly requests. + +There is **no runtime consent UI** — visiting a Blento page that contains +your embed implies trust. Don't request scopes a user wouldn't expect. + +## URL parameters + +Blento appends these query params to your iframe `src`. Parse them as soon as +your script runs (before paint, ideally) so initial render reflects the +parent's theme: + +| Param | Type | Meaning | +| -------- | ----------- | --------------------------------------------------------------------- | +| `base` | string | Tailwind neutral palette: `gray`, `stone`, `zinc`, `neutral`, `slate` | +| `accent` | string | Tailwind vivid palette: `red`, `pink`, `blue`, `…` | +| `dark` | `'1'`/`'0'` | Whether the parent is rendering dark mode | +| `did` | string | Visitor's DID (same value `getSession()` will report after `ready`) | + +`Blento.getTheme()` returns `{ base, accent, dark }` parsed from the URL. +You're free to apply them however you like — set CSS variables, add classes, +swap stylesheets. + +## API reference — `window.Blento` + +### `Blento.ready: Promise` + +Resolves once the parent has confirmed the handshake and reported a session +(which may be `null`). Rejects with `BlentoError({ code: 'unknown' })` after +~10s if the parent never responds (e.g. your page was loaded standalone, not +in a Blento frame). Always `await` this before any write. + +```js +await Blento.ready; +``` + +### `Blento.getTheme(): { base, accent, dark }` + +Synchronous; returns parsed URL params. Available before `ready`. + +### `Blento.getSession(): Session | null` + +Synchronous; returns the visitor's session or `null` if they aren't signed in +to Blento. Updated by parent over time. Available after `ready`. + +```ts +type Session = { + did: string; + handle?: string; + displayName?: string; + avatar?: string; +}; +``` + +### `Blento.on('session', cb): () => void` + +Subscribe to session changes (e.g. visitor logs in or out in another tab). +Returns an unsubscribe function. + +```js +const off = Blento.on('session', (s) => updateUI(s)); +// later: off(); +``` + +### `Blento.createRecord({ collection, rkey?, record }): Promise<{ uri, cid? }>` + +Calls `com.atproto.repo.createRecord` on the visitor's PDS. `rkey` is +auto-generated if omitted. Resolves with the new record's URI. + +### `Blento.putRecord({ collection, rkey, record }): Promise<{ uri, cid? }>` + +`com.atproto.repo.putRecord` (create-or-update at known rkey). + +### `Blento.deleteRecord({ collection, rkey }): Promise<{ ok: boolean }>` + +`com.atproto.repo.deleteRecord`. + +### `Blento.applyWrites({ writes, validate? }): Promise<{ results }>` + +Atomic batch via `com.atproto.repo.applyWrites`. Each write is one of: + +```ts +type Write = + | { $type: 'create'; collection: string; rkey?: string; value: object } + | { $type: 'update'; collection: string; rkey: string; value: object } + | { $type: 'delete'; collection: string; rkey: string }; +``` + +Resolves with `{ results: Array<{ uri?, cid? }> }` in the same order as input. + +### `Blento.uploadBlob(blob, opts?): Promise` + +Uploads a `Blob` to the visitor's PDS via `com.atproto.repo.uploadBlob`. Pass +the result inline in a subsequent record write. + +```ts +type BlobRef = { + $type: 'blob'; + ref: { $link: string }; + mimeType: string; + size: number; +}; +``` + +`opts.mimeType` overrides `blob.type` if provided. + +### `Blento.notifyResize(heightPx: number): void` + +Hint for the parent to resize the iframe. The parent clamps to a sane range +(80px–20000px). Compute the height however you like — `ResizeObserver` on the +body is a typical choice. + +### `Blento.notifyNavigate(url: string): void` + +Ask the parent to navigate top-level. The parent only honors **same-origin** +URLs (i.e. paths within `blento.app`). Useful after creating a record: + +```js +const { uri } = await Blento.createRecord({ ... }); +const rkey = uri.split('/').pop(); +Blento.notifyNavigate(`/${session.did}/event/r/${rkey}`); +``` + +## Errors + +All write rejections are `BlentoError` instances with a stable `.code`: + +| Code | Meaning | +| ----------------- | ----------------------------------------------------------------- | +| `no_session` | Visitor is not signed in to Blento | +| `user_cancelled` | User declined a confirmation prompt (reserved; not emitted in v0) | +| `rate_limited` | PDS throttled the request (reserved) | +| `pds_error` | The visitor's PDS rejected the write | +| `unsupported` | Method not available in this protocol version | +| `invalid_request` | Origin not allowed, collection not allowed, or malformed payload | +| `unknown` | Anything else | + +```js +try { + await Blento.createRecord({ ... }); +} catch (e) { + if (e.code === 'no_session') showLoginPrompt(); + else if (e.code === 'pds_error') retryLater(); + else console.error(e); +} +``` + +## Wire protocol (for partners not using the SDK) + +The SDK is plain JS and easy to drop in, but the protocol is small enough to +implement directly. All messages include `v: 0`. + +### iframe → parent (`window.parent.postMessage(msg, '*')`) + +``` +{ v: 0, id, type: 'hello' } // handshake +{ v: 0, id, type: 'createRecord', payload: { collection, rkey?, record } } +{ v: 0, id, type: 'putRecord', payload: { collection, rkey, record } } +{ v: 0, id, type: 'deleteRecord', payload: { collection, rkey } } +{ v: 0, id, type: 'applyWrites', payload: { writes, validate? } } +{ v: 0, id, type: 'uploadBlob', payload: { bytes: number[], mimeType } } +{ v: 0, type: 'blento:resize', heightPx } // unsolicited +{ v: 0, type: 'blento:navigate', url } // unsolicited +``` + +`id` is any unique string you generate — the parent echoes it on the response. + +### parent → iframe (`iframe.contentWindow.postMessage(msg, '')`) + +``` +{ v: 0, type: 'ready', session } // once after hello +{ v: 0, type: 'session', session } // on session change +{ v: 0, id, ok: true, result } // request response +{ v: 0, id, ok: false, error: { code, message } } // request error +``` + +The parent ignores any message whose `event.origin` doesn't match the iframe's +`src` origin or whose `event.source` isn't the iframe's contentWindow. + +### Blob transfer + +For `uploadBlob`, the SDK serializes the blob's bytes as a `number[]` array +(JSON-friendly). If you implement the protocol directly: + +```js +const buf = await blob.arrayBuffer(); +const bytes = Array.from(new Uint8Array(buf)); +parent.postMessage({ v: 0, id, type: 'uploadBlob', payload: { bytes, mimeType: blob.type } }, '*'); +``` + +This is inefficient for large blobs (~4× JSON overhead). Future protocol +versions may use structured-clone or transferable streams. + +## Local development + +The SDK and a test harness ship with the Blento dev server. + +1. Run Blento: `pnpm dev` (defaults to `http://localhost:5173`). +2. Sign in to Blento at `/login`. +3. Visit `http://localhost:5173/embed-test`. + +The test harness page (`/embed/v0/test.html`) is hosted as a static asset on +the same origin. The `` component there points at it. The dev +allowlist permits `http://localhost:5173`, `http://localhost:5174`, and the +`127.0.0.1` equivalents — so you can also serve your in-development partner +app on a separate port (e.g. `http://localhost:5174`) to exercise the +cross-origin postMessage path. To do that, change the `path`/`origin` props on +`/embed-test/+page.svelte` accordingly. + +## Versioning + +The URL `/embed/v0/sdk.js` is locked. Any breaking change to the protocol or +SDK surface ships at a new version (`/embed/v1/sdk.js`) — old embeds keep +working unchanged. Within v0, additions are backwards-compatible (new +optional fields, new methods). + +When v1 lands, the parent's `` host will dispatch by the `v` field +and support both, so you can roll over partner apps independently. diff --git a/src/lib/embed/AtmoEmbed.svelte b/src/lib/embed/AtmoEmbed.svelte new file mode 100644 index 0000000..7f4ed2c --- /dev/null +++ b/src/lib/embed/AtmoEmbed.svelte @@ -0,0 +1,234 @@ + + + diff --git a/src/lib/embed/allowlist.ts b/src/lib/embed/allowlist.ts new file mode 100644 index 0000000..b51fd99 --- /dev/null +++ b/src/lib/embed/allowlist.ts @@ -0,0 +1,43 @@ +import { dev } from '$app/environment'; + +export type AllowlistEntry = { + collectionPrefixes: string[]; + label: string; +}; + +const PROD_ALLOWLIST: Record = { + 'https://atmo.rsvp': { + collectionPrefixes: ['community.lexicon.calendar.'], + label: 'atmo.rsvp' + } +}; + +const DEV_ALLOWLIST: Record = { + 'http://localhost:5173': { collectionPrefixes: ['*'], label: 'Local dev (5173)' }, + 'http://localhost:5174': { collectionPrefixes: ['*'], label: 'Local dev (5174)' }, + 'http://127.0.0.1:5173': { collectionPrefixes: ['*'], label: 'Local dev (5173 IP)' }, + 'http://127.0.0.1:5174': { collectionPrefixes: ['*'], label: 'Local dev (5174 IP)' } +}; + +export const ALLOWLIST: Record = dev + ? { ...PROD_ALLOWLIST, ...DEV_ALLOWLIST } + : PROD_ALLOWLIST; + +export function getAllowlistEntry(origin: string): AllowlistEntry | null { + return ALLOWLIST[origin] ?? null; +} + +export function isAllowedOrigin(origin: string): boolean { + return origin in ALLOWLIST; +} + +function matchesPrefix(collection: string, prefix: string): boolean { + if (prefix === '*') return true; + return collection === prefix.replace(/\.$/, '') || collection.startsWith(prefix); +} + +export function isAllowedCollection(origin: string, collection: string): boolean { + const entry = ALLOWLIST[origin]; + if (!entry) return false; + return entry.collectionPrefixes.some((p) => matchesPrefix(collection, p)); +} diff --git a/src/lib/embed/embed.remote.ts b/src/lib/embed/embed.remote.ts new file mode 100644 index 0000000..834fcab --- /dev/null +++ b/src/lib/embed/embed.remote.ts @@ -0,0 +1,266 @@ +import { error } from '@sveltejs/kit'; +import { command, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; +import { isAllowedCollection, isAllowedOrigin } from './allowlist'; +import { contrail, ensureInit } from '$lib/contrail'; + +const originSchema = v.string(); + +const collectionSchema = v.pipe( + v.string(), + v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/) +); + +const rkeySchema = v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/)); + +const recordSchema = v.record(v.string(), v.unknown()); + +const writeSchema = v.union([ + v.object({ + $type: v.literal('create'), + collection: collectionSchema, + rkey: v.optional(rkeySchema), + value: recordSchema + }), + v.object({ + $type: v.literal('update'), + collection: collectionSchema, + rkey: rkeySchema, + value: recordSchema + }), + v.object({ + $type: v.literal('delete'), + collection: collectionSchema, + rkey: rkeySchema + }) +]); + +function requireAuth() { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'no_session'); + return { client: locals.client, did: locals.did }; +} + +function checkOrigin(origin: string) { + if (!isAllowedOrigin(origin)) error(403, 'origin_not_allowed'); +} + +function checkCollection(origin: string, collection: string) { + if (!isAllowedCollection(origin, collection)) error(403, 'collection_not_allowed'); +} + +async function notifyContrail(uri: string) { + const { platform } = getRequestEvent(); + const db = platform?.env?.DB; + if (!db) return; + await ensureInit(db); + await contrail.notify(uri, db).catch(() => {}); +} + +export const embedCreateRecord = command( + v.object({ + origin: originSchema, + collection: collectionSchema, + rkey: v.optional(rkeySchema), + record: recordSchema + }), + async ({ origin, collection, rkey, record }) => { + const { client, did } = requireAuth(); + checkOrigin(origin); + checkCollection(origin, collection); + + const response = await client.post('com.atproto.repo.createRecord', { + input: { + collection: collection as `${string}.${string}.${string}`, + repo: did, + rkey, + record + } + }); + + if (!response.ok) { + console.error('embedCreateRecord failed', { + origin, + collection, + status: response.status, + data: response.data + }); + error(502, 'pds_error'); + } + + await notifyContrail(response.data.uri); + + return { uri: response.data.uri, cid: response.data.cid }; + } +); + +export const embedPutRecord = command( + v.object({ + origin: originSchema, + collection: collectionSchema, + rkey: rkeySchema, + record: recordSchema + }), + async ({ origin, collection, rkey, record }) => { + const { client, did } = requireAuth(); + checkOrigin(origin); + checkCollection(origin, collection); + + const valueWithType = record.$type === collection ? record : { ...record, $type: collection }; + + const response = await client.post('com.atproto.repo.putRecord', { + input: { + collection: collection as `${string}.${string}.${string}`, + repo: did, + rkey, + record: valueWithType + } + }); + + if (!response.ok) { + console.error('embedPutRecord failed', { + origin, + collection, + rkey, + status: response.status, + data: response.data + }); + error(502, 'pds_error'); + } + + await notifyContrail(response.data.uri); + + return { uri: response.data.uri, cid: response.data.cid }; + } +); + +export const embedDeleteRecord = command( + v.object({ + origin: originSchema, + collection: collectionSchema, + rkey: rkeySchema + }), + async ({ origin, collection, rkey }) => { + const { client, did } = requireAuth(); + checkOrigin(origin); + checkCollection(origin, collection); + + const response = await client.post('com.atproto.repo.deleteRecord', { + input: { + collection: collection as `${string}.${string}.${string}`, + repo: did, + rkey + } + }); + + if (response.ok) { + await notifyContrail(`at://${did}/${collection}/${rkey}`); + } + + return { ok: response.ok }; + } +); + +export const embedApplyWrites = command( + v.object({ + origin: originSchema, + writes: v.array(writeSchema), + validate: v.optional(v.boolean()) + }), + async ({ origin, writes, validate }) => { + const { client, did } = requireAuth(); + checkOrigin(origin); + for (const w of writes) checkCollection(origin, w.collection); + + const atprotoWrites = writes.map((w) => { + if (w.$type === 'create') { + return { + $type: 'com.atproto.repo.applyWrites#create' as const, + collection: w.collection as `${string}.${string}.${string}`, + rkey: w.rkey, + value: + (w.value as { $type?: string }).$type === w.collection + ? w.value + : { ...w.value, $type: w.collection } + }; + } + if (w.$type === 'update') { + return { + $type: 'com.atproto.repo.applyWrites#update' as const, + collection: w.collection as `${string}.${string}.${string}`, + rkey: w.rkey, + value: + (w.value as { $type?: string }).$type === w.collection + ? w.value + : { ...w.value, $type: w.collection } + }; + } + return { + $type: 'com.atproto.repo.applyWrites#delete' as const, + collection: w.collection as `${string}.${string}.${string}`, + rkey: w.rkey + }; + }); + + const response = await client.post('com.atproto.repo.applyWrites', { + input: { repo: did, validate, writes: atprotoWrites } + }); + + if (!response.ok) { + console.error('embedApplyWrites failed', { + origin, + count: writes.length, + status: response.status, + data: response.data + }); + error(502, 'pds_error'); + } + + const results = + response.data.results?.map((r) => ({ + uri: 'uri' in r ? (r.uri as string | undefined) : undefined, + cid: 'cid' in r ? (r.cid as string | undefined) : undefined + })) ?? []; + + for (const r of results) { + if (r.uri) await notifyContrail(r.uri); + } + + return { results }; + } +); + +export const embedUploadBlob = command( + v.object({ + origin: originSchema, + bytes: v.array(v.number()), + mimeType: v.string() + }), + async ({ origin, bytes, mimeType }) => { + const { client } = requireAuth(); + checkOrigin(origin); + + const blob = new Blob([new Uint8Array(bytes)], { type: mimeType }); + + const response = await client.post('com.atproto.repo.uploadBlob', { + input: blob + }); + + if (!response.ok) { + console.error('embedUploadBlob failed', { + origin, + size: bytes.length, + status: response.status, + data: response.data + }); + error(502, 'pds_error'); + } + + return response.data.blob as { + $type: 'blob'; + ref: { $link: string }; + mimeType: string; + size: number; + }; + } +); diff --git a/src/routes/embed-test/+page.server.ts b/src/routes/embed-test/+page.server.ts new file mode 100644 index 0000000..95adc6e --- /dev/null +++ b/src/routes/embed-test/+page.server.ts @@ -0,0 +1,7 @@ +import { dev } from '$app/environment'; +import { error } from '@sveltejs/kit'; + +export const load = () => { + if (!dev) error(404); + return {}; +}; diff --git a/src/routes/embed-test/+page.svelte b/src/routes/embed-test/+page.svelte new file mode 100644 index 0000000..db0e84b --- /dev/null +++ b/src/routes/embed-test/+page.svelte @@ -0,0 +1,39 @@ + + + + Embed SDK · v0 test + + +
+
+

Embed SDK · v0 test

+

+ Hosts /embed/v0/test.html via the AtmoEmbed component. Logged-in session + is forwarded to the iframe. +

+

+ Logged in as: {user.profile?.handle ?? user.did ?? 'not signed in'} +

+
+ + {#if origin} + + {/if} +
diff --git a/static/embed/v0/sdk.js b/static/embed/v0/sdk.js new file mode 100644 index 0000000..5ad79d0 --- /dev/null +++ b/static/embed/v0/sdk.js @@ -0,0 +1,229 @@ +/*! + * Blento Embed SDK — protocol v0 + * + * Loaded by third-party iframes hosted inside a Blento page (e.g. atmo.rsvp event embeds). + * Exposes window.Blento, which talks to the parent Blento window via postMessage. + * The parent forwards authenticated AT Proto writes to the user's PDS using the visitor's + * Blento session — no tokens or cookies are exposed to the iframe. + * + * ─── Wire protocol (iframe → parent) ───────────────────────────────────────── + * { v: 0, id, type: 'hello' } + * { v: 0, id, type: 'getSession' } + * { v: 0, id, type: 'createRecord', payload: { collection, rkey?, record } } + * { v: 0, id, type: 'putRecord', payload: { collection, rkey, record } } + * { v: 0, id, type: 'deleteRecord', payload: { collection, rkey } } + * { v: 0, id, type: 'applyWrites', payload: { writes, validate? } } + * { v: 0, id, type: 'uploadBlob', payload: { bytes: number[], mimeType } } + * { v: 0, type: 'blento:resize', heightPx } + * { v: 0, type: 'blento:navigate', url } + * + * ─── Wire protocol (parent → iframe) ───────────────────────────────────────── + * { v: 0, type: 'ready', session } // sent once after handshake + * { v: 0, type: 'session', session } // on session change + * { v: 0, id, ok: true, result } // response to a request + * { v: 0, id, ok: false, error: { code, message } } // error response + * + * ─── Session shape ─────────────────────────────────────────────────────────── + * { did, handle, displayName?, avatar?, pdsUrl } | null + * + * ─── BlobRef shape (returned by uploadBlob) ────────────────────────────────── + * { $type: 'blob', ref: { $link: string }, mimeType: string, size: number } + * + * ─── Write shape (applyWrites payload) ─────────────────────────────────────── + * { $type: 'create', collection, rkey?, value } + * { $type: 'update', collection, rkey, value } + * { $type: 'delete', collection, rkey } + * + * ─── Theme (URL params on iframe src) ──────────────────────────────────────── + * ?base=stone&accent=pink&dark=1&did=did:plc:... + * - base: one of Tailwind's neutral palettes (gray, stone, zinc, neutral, slate) + * - accent: one of Tailwind's vivid palettes (red, pink, blue, …) + * - dark: '1' if parent is in dark mode, '0' or absent otherwise + * - did: visitor's DID (same value getSession() will report after ready) + * + * ─── Error codes ───────────────────────────────────────────────────────────── + * no_session | user_cancelled | rate_limited | pds_error + * unsupported | invalid_request | unknown + */ +(function () { + 'use strict'; + + if (typeof window === 'undefined') return; + if (window.Blento) return; + + var PROTOCOL_VERSION = 0; + var READY_TIMEOUT_MS = 10000; + var ERROR_CODES = [ + 'no_session', + 'user_cancelled', + 'rate_limited', + 'pds_error', + 'unsupported', + 'invalid_request', + 'unknown' + ]; + + function BlentoError(code, message, cause) { + var err = new Error(message || code); + err.name = 'BlentoError'; + err.code = ERROR_CODES.indexOf(code) >= 0 ? code : 'unknown'; + if (cause !== undefined) err.cause = cause; + Object.setPrototypeOf(err, BlentoError.prototype); + return err; + } + BlentoError.prototype = Object.create(Error.prototype); + BlentoError.prototype.constructor = BlentoError; + + var params = new URLSearchParams(window.location.search); + var theme = Object.freeze({ + base: params.get('base'), + accent: params.get('accent'), + dark: params.get('dark') === '1' + }); + + var session = null; + var sessionListeners = new Set(); + var pending = new Map(); + var nextId = 1; + + var readyResolve, readyReject; + var ready = new Promise(function (resolve, reject) { + readyResolve = resolve; + readyReject = reject; + }); + var readySettled = false; + function settleReady(ok, value) { + if (readySettled) return; + readySettled = true; + if (ok) readyResolve(value); + else readyReject(value); + } + + function sendToParent(msg) { + try { + window.parent.postMessage(msg, '*'); + } catch (e) { + /* parent may be gone */ + } + } + + function call(type, payload) { + return new Promise(function (resolve, reject) { + var id = 'r' + nextId++; + pending.set(id, { resolve: resolve, reject: reject }); + sendToParent({ v: PROTOCOL_VERSION, id: id, type: type, payload: payload }); + }); + } + + function notifySessionListeners() { + sessionListeners.forEach(function (cb) { + try { + cb(session); + } catch (e) { + /* swallow */ + } + }); + } + + function handleMessage(ev) { + if (ev.source !== window.parent) return; + var data = ev.data; + if (!data || typeof data !== 'object') return; + if (data.v !== PROTOCOL_VERSION) return; + + if (data.type === 'ready') { + session = data.session || null; + settleReady(true); + return; + } + + if (data.type === 'session') { + session = data.session || null; + notifySessionListeners(); + return; + } + + if (data.id && pending.has(data.id)) { + var entry = pending.get(data.id); + pending.delete(data.id); + if (data.ok) { + entry.resolve(data.result); + } else { + var err = data.error || {}; + entry.reject(new BlentoError(err.code, err.message)); + } + } + } + + window.addEventListener('message', handleMessage); + + function on(event, cb) { + if (event !== 'session') { + throw new BlentoError('unsupported', 'Unknown event: ' + event); + } + sessionListeners.add(cb); + return function () { + sessionListeners.delete(cb); + }; + } + + function uploadBlob(blob, opts) { + var mimeType = (opts && opts.mimeType) || blob.type || 'application/octet-stream'; + return blob.arrayBuffer().then(function (buffer) { + var bytes = Array.from(new Uint8Array(buffer)); + return call('uploadBlob', { bytes: bytes, mimeType: mimeType }); + }); + } + + var Blento = { + ready: ready, + getTheme: function () { + return { base: theme.base, accent: theme.accent, dark: theme.dark }; + }, + getSession: function () { + return session; + }, + on: on, + createRecord: function (opts) { + return call('createRecord', opts); + }, + putRecord: function (opts) { + return call('putRecord', opts); + }, + deleteRecord: function (opts) { + return call('deleteRecord', opts); + }, + applyWrites: function (opts) { + return call('applyWrites', opts); + }, + uploadBlob: uploadBlob, + notifyResize: function (heightPx) { + sendToParent({ v: PROTOCOL_VERSION, type: 'blento:resize', heightPx: heightPx }); + }, + notifyNavigate: function (url) { + sendToParent({ v: PROTOCOL_VERSION, type: 'blento:navigate', url: url }); + } + }; + + Object.freeze(Blento); + + Object.defineProperty(window, 'Blento', { + value: Blento, + writable: false, + configurable: false + }); + + sendToParent({ v: PROTOCOL_VERSION, type: 'hello' }); + + setTimeout(function () { + if (!readySettled) { + settleReady( + false, + new BlentoError( + 'unknown', + 'Blento parent did not respond within ' + READY_TIMEOUT_MS + 'ms' + ) + ); + } + }, READY_TIMEOUT_MS); +})(); diff --git a/static/embed/v0/test.html b/static/embed/v0/test.html new file mode 100644 index 0000000..44e8e4e --- /dev/null +++ b/static/embed/v0/test.html @@ -0,0 +1,257 @@ + + + + + + Blento Embed SDK · v0 test harness + + + +

Blento Embed SDK · v0 test harness

+ +
Theme: …
+
+ Ready: pending · Session: … +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + + + +
+ +
+ + + +
+ +
+ + +
+ +
+ +
—
+
+ + + + +