= 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: …
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
--
2.51.2
From 16b930abb357d641873a07576735fc1dacf8a4bb Mon Sep 17 00:00:00 2001
From: Florian <45694132+flo-bit@users.noreply.github.com>
Date: Mon, 4 May 2026 22:55:14 +0200
Subject: [PATCH 12/17] add request login
---
docs/embed-sdk/v0.md | 26 ++++++++++++++++++++++++--
src/lib/embed/AtmoEmbed.svelte | 6 ++++++
static/embed/v0/sdk.js | 8 ++++++--
static/embed/v0/test.html | 6 ++++++
4 files changed, 42 insertions(+), 4 deletions(-)
diff --git a/docs/embed-sdk/v0.md b/docs/embed-sdk/v0.md
index c415d40..9b3d2ab 100644
--- a/docs/embed-sdk/v0.md
+++ b/docs/embed-sdk/v0.md
@@ -175,6 +175,27 @@ const rkey = uri.split('/').pop();
Blento.notifyNavigate(`/${session.did}/event/r/${rkey}`);
```
+### `Blento.promptLogin(): void`
+
+Ask the parent to show its login modal. Fire-and-forget — there is no
+returned Promise. To detect when the user has signed in, subscribe to
+`session` events:
+
+```js
+if (!Blento.getSession()) {
+ const off = Blento.on('session', (s) => {
+ if (s) {
+ off();
+ doTheThing();
+ }
+ });
+ Blento.promptLogin();
+}
+```
+
+Calling `promptLogin()` while the user is already signed in is a no-op from
+the iframe's perspective; the parent may still display the modal.
+
## Errors
All write rejections are `BlentoError` instances with a stable `.code`:
@@ -213,8 +234,9 @@ implement directly. All messages include `v: 0`.
{ 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
+{ v: 0, type: 'blento:resize', heightPx } // unsolicited
+{ v: 0, type: 'blento:navigate', url } // unsolicited
+{ v: 0, type: 'blento:promptLogin' } // unsolicited
```
`id` is any unique string you generate — the parent echoes it on the response.
diff --git a/src/lib/embed/AtmoEmbed.svelte b/src/lib/embed/AtmoEmbed.svelte
index 7f4ed2c..7cc3490 100644
--- a/src/lib/embed/AtmoEmbed.svelte
+++ b/src/lib/embed/AtmoEmbed.svelte
@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import { page } from '$app/state';
import { user } from '$lib/atproto';
+ import { atProtoLoginModalState } from '$lib/atproto/LoginModal.svelte';
import {
embedApplyWrites,
embedCreateRecord,
@@ -201,6 +202,11 @@
return;
}
+ if (data.type === 'blento:promptLogin') {
+ atProtoLoginModalState.show();
+ return;
+ }
+
if (typeof data.id === 'string' && typeof data.type === 'string') {
handleRequest(data.id, data.type, data.payload);
}
diff --git a/static/embed/v0/sdk.js b/static/embed/v0/sdk.js
index 5ad79d0..1ab4bd6 100644
--- a/static/embed/v0/sdk.js
+++ b/static/embed/v0/sdk.js
@@ -14,8 +14,9 @@
* { 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 }
+ * { v: 0, type: 'blento:resize', heightPx }
+ * { v: 0, type: 'blento:navigate', url }
+ * { v: 0, type: 'blento:promptLogin' }
*
* ─── Wire protocol (parent → iframe) ─────────────────────────────────────────
* { v: 0, type: 'ready', session } // sent once after handshake
@@ -202,6 +203,9 @@
},
notifyNavigate: function (url) {
sendToParent({ v: PROTOCOL_VERSION, type: 'blento:navigate', url: url });
+ },
+ promptLogin: function () {
+ sendToParent({ v: PROTOCOL_VERSION, type: 'blento:promptLogin' });
}
};
diff --git a/static/embed/v0/test.html b/static/embed/v0/test.html
index 44e8e4e..19b652d 100644
--- a/static/embed/v0/test.html
+++ b/static/embed/v0/test.html
@@ -130,6 +130,7 @@
+
@@ -249,6 +250,11 @@
show('notifyNavigate(/) sent', null);
};
+ $('btn-prompt-login').onclick = () => {
+ window.Blento.promptLogin();
+ show('promptLogin() sent', null);
+ };
+
if (window.Blento.getTheme().dark) {
document.documentElement.classList.add('dark');
}
--
2.51.2
From 60587c42469a5b4d54ae3c4d25205733cd7e438a Mon Sep 17 00:00:00 2001
From: Florian <45694132+flo-bit@users.noreply.github.com>
Date: Mon, 4 May 2026 23:01:35 +0200
Subject: [PATCH 13/17] moar updates
---
docs/embed-sdk/v0.md | 42 +++++++++++++++++++++++++++---
src/lib/embed/AtmoEmbed.svelte | 13 ++++++---
src/lib/embed/allowlist.ts | 5 ++--
src/routes/embed-test/+page.svelte | 10 +++++++
static/embed/v0/sdk.js | 7 +++++
static/embed/v0/test.html | 7 +++++
6 files changed, 76 insertions(+), 8 deletions(-)
diff --git a/docs/embed-sdk/v0.md b/docs/embed-sdk/v0.md
index 9b3d2ab..bb50d69 100644
--- a/docs/embed-sdk/v0.md
+++ b/docs/embed-sdk/v0.md
@@ -44,9 +44,13 @@ the parent. Wait for `Blento.ready` before any write.
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.` |
+| Origin | Allowed collections |
+| ------------------- | ---------------------------------------------------- |
+| `https://atmo.rsvp` | `community.lexicon.calendar.*`, `app.bsky.feed.post` |
+
+Prefix entries ending with `.` match anything under that namespace
+(`community.lexicon.calendar.event`, `community.lexicon.calendar.rsvp`, …).
+Entries without a trailing dot match the exact NSID only.
Adding a new origin or collection requires:
@@ -196,6 +200,37 @@ if (!Blento.getSession()) {
Calling `promptLogin()` while the user is already signed in is a no-op from
the iframe's perspective; the parent may still display the modal.
+### `Blento.notify(name: string, payload?: unknown): void`
+
+Generic iframe → parent signal for app-defined events. Names are not
+validated by Blento — they're a contract between your embed and the Blento
+surface that hosts it. Fire-and-forget; no response.
+
+Typical uses: tell the parent to close a modal after a successful create,
+nudge the parent to refresh a sibling counter, surface an "edit cancelled"
+intent.
+
+```js
+// in the iframe
+await Blento.createRecord({ ... });
+Blento.notify('event-created', { uri });
+
+// in Blento, on the host component
+
{
+ if (name === 'event-created') closeModal();
+ if (name === 'cancel') closeModal();
+ }}
+/>
+```
+
+Prefer `notify()` over `notifyNavigate()` when the parent wants to react
+locally (close a modal, show a toast, refresh a count) without changing the
+top-level URL.
+
## Errors
All write rejections are `BlentoError` instances with a stable `.code`:
@@ -237,6 +272,7 @@ implement directly. All messages include `v: 0`.
{ v: 0, type: 'blento:resize', heightPx } // unsolicited
{ v: 0, type: 'blento:navigate', url } // unsolicited
{ v: 0, type: 'blento:promptLogin' } // unsolicited
+{ v: 0, type: 'blento:notify', name, payload? } // unsolicited
```
`id` is any unique string you generate — the parent echoes it on the response.
diff --git a/src/lib/embed/AtmoEmbed.svelte b/src/lib/embed/AtmoEmbed.svelte
index 7cc3490..a301cc1 100644
--- a/src/lib/embed/AtmoEmbed.svelte
+++ b/src/lib/embed/AtmoEmbed.svelte
@@ -21,6 +21,7 @@
maxHeight?: number;
title?: string;
class?: string;
+ onnotify?: (name: string, payload: unknown) => void;
};
let {
@@ -31,7 +32,8 @@
minHeight = 80,
maxHeight = 20000,
title = 'Embedded content',
- class: className = ''
+ class: className = '',
+ onnotify
}: Props = $props();
const PROTOCOL_VERSION = 0;
@@ -61,8 +63,8 @@
function isAllowedCollectionLocal(collection: string): boolean {
return allowedCollectionPrefixes.some((p) => {
if (p === '*') return true;
- const stripped = p.replace(/\.$/, '');
- return collection === stripped || collection.startsWith(p);
+ if (p.endsWith('.')) return collection.startsWith(p);
+ return collection === p;
});
}
@@ -207,6 +209,11 @@
return;
}
+ if (data.type === 'blento:notify' && typeof data.name === 'string') {
+ onnotify?.(data.name, data.payload);
+ return;
+ }
+
if (typeof data.id === 'string' && typeof data.type === 'string') {
handleRequest(data.id, data.type, data.payload);
}
diff --git a/src/lib/embed/allowlist.ts b/src/lib/embed/allowlist.ts
index b51fd99..1f1b363 100644
--- a/src/lib/embed/allowlist.ts
+++ b/src/lib/embed/allowlist.ts
@@ -7,7 +7,7 @@ export type AllowlistEntry = {
const PROD_ALLOWLIST: Record = {
'https://atmo.rsvp': {
- collectionPrefixes: ['community.lexicon.calendar.'],
+ collectionPrefixes: ['community.lexicon.calendar.', 'app.bsky.feed.post'],
label: 'atmo.rsvp'
}
};
@@ -33,7 +33,8 @@ export function isAllowedOrigin(origin: string): boolean {
function matchesPrefix(collection: string, prefix: string): boolean {
if (prefix === '*') return true;
- return collection === prefix.replace(/\.$/, '') || collection.startsWith(prefix);
+ if (prefix.endsWith('.')) return collection.startsWith(prefix);
+ return collection === prefix;
}
export function isAllowedCollection(origin: string, collection: string): boolean {
diff --git a/src/routes/embed-test/+page.svelte b/src/routes/embed-test/+page.svelte
index db0e84b..7d5ee49 100644
--- a/src/routes/embed-test/+page.svelte
+++ b/src/routes/embed-test/+page.svelte
@@ -4,6 +4,7 @@
import { user } from '$lib/atproto';
let origin = $state('');
+ let lastNotify = $state<{ name: string; payload: unknown; at: number } | null>(null);
onMount(() => {
origin = window.location.origin;
@@ -24,6 +25,12 @@
Logged in as: {user.profile?.handle ?? user.did ?? 'not signed in'}
+ {#if lastNotify}
+
+ Last notify: {lastNotify.name} ·
+ {JSON.stringify(lastNotify.payload)}
+
+ {/if}
{#if origin}
@@ -34,6 +41,9 @@
height={700}
title="Embed SDK test harness"
class="w-full rounded-lg border border-black/10 dark:border-white/10"
+ onnotify={(name, payload) => {
+ lastNotify = { name, payload, at: Date.now() };
+ }}
/>
{/if}
diff --git a/static/embed/v0/sdk.js b/static/embed/v0/sdk.js
index 1ab4bd6..8fe7fa6 100644
--- a/static/embed/v0/sdk.js
+++ b/static/embed/v0/sdk.js
@@ -17,6 +17,7 @@
* { v: 0, type: 'blento:resize', heightPx }
* { v: 0, type: 'blento:navigate', url }
* { v: 0, type: 'blento:promptLogin' }
+ * { v: 0, type: 'blento:notify', name, payload? }
*
* ─── Wire protocol (parent → iframe) ─────────────────────────────────────────
* { v: 0, type: 'ready', session } // sent once after handshake
@@ -206,6 +207,12 @@
},
promptLogin: function () {
sendToParent({ v: PROTOCOL_VERSION, type: 'blento:promptLogin' });
+ },
+ notify: function (name, payload) {
+ if (typeof name !== 'string' || !name) {
+ throw new BlentoError('invalid_request', 'notify(name): name must be a non-empty string');
+ }
+ sendToParent({ v: PROTOCOL_VERSION, type: 'blento:notify', name: name, payload: payload });
}
};
diff --git a/static/embed/v0/test.html b/static/embed/v0/test.html
index 19b652d..ed81e35 100644
--- a/static/embed/v0/test.html
+++ b/static/embed/v0/test.html
@@ -131,6 +131,7 @@
+
@@ -255,6 +256,12 @@
show('promptLogin() sent', null);
};
+ $('btn-notify').onclick = () => {
+ const payload = { ts: Date.now() };
+ window.Blento.notify('test-event', payload);
+ show('notify("test-event") sent', payload);
+ };
+
if (window.Blento.getTheme().dark) {
document.documentElement.classList.add('dark');
}
--
2.51.2
From e6cfa74de7d063e7247c4c2ee8f787ab1793a063 Mon Sep 17 00:00:00 2001
From: Florian <45694132+flo-bit@users.noreply.github.com>
Date: Tue, 5 May 2026 00:48:50 +0200
Subject: [PATCH 14/17] add event creation and event view pages
---
src/lib/cards/social/EventCard/index.ts | 51 +++++----------
.../UpcomingEventsCard.svelte | 26 ++------
src/lib/embed/AtmoEmbed.svelte | 37 ++++++++++-
.../event/create/+page.server.ts | 11 ++++
.../[[actor=actor]]/event/create/+page.svelte | 40 ++++++++++++
.../event/r/[rkey]/+page.server.ts | 11 ++++
.../event/r/[rkey]/+page.svelte | 63 +++++++++++++++++++
7 files changed, 182 insertions(+), 57 deletions(-)
create mode 100644 src/routes/[[actor=actor]]/event/create/+page.server.ts
create mode 100644 src/routes/[[actor=actor]]/event/create/+page.svelte
create mode 100644 src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts
create mode 100644 src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte
diff --git a/src/lib/cards/social/EventCard/index.ts b/src/lib/cards/social/EventCard/index.ts
index 3df1db7..caba18d 100644
--- a/src/lib/cards/social/EventCard/index.ts
+++ b/src/lib/cards/social/EventCard/index.ts
@@ -1,8 +1,6 @@
-import { parseUri, getRecord } from '$lib/atproto';
import type { CardDefinition } from '../../types';
import CreateEventCardModal from './CreateEventCardModal.svelte';
import EventCard from './EventCard.svelte';
-import type { Did } from '@atcute/lexicons';
const EVENT_COLLECTION = 'community.lexicon.calendar.event';
@@ -58,35 +56,20 @@ export const EventCardDefinition = {
card.mobileH = 6;
},
- loadData: async (items) => {
- const eventDataMap: Record
= {};
-
- for (const item of items) {
- const uri = item.cardData?.uri;
- if (!uri) continue;
-
- const parsedUri = parseUri(uri);
- if (!parsedUri || !parsedUri.rkey || !parsedUri.repo) continue;
-
- try {
- const record = await getRecord({
- did: parsedUri.repo as Did,
- collection: EVENT_COLLECTION,
- rkey: parsedUri.rkey
- });
-
- if (record?.value) {
- eventDataMap[item.id] = record.value as EventData;
- }
- } catch (error) {
- console.error('Failed to fetch event data:', error);
- }
+ onUrlHandler: (url, item) => {
+ // Match atmo.rsvp URLs: https://atmo.rsvp/p/{didOrHandle}/e/{rkey}
+ const atmoMatch = url.match(/^https?:\/\/atmo\.rsvp\/p\/([^/]+)\/e\/([^/?#]+)/);
+ if (atmoMatch) {
+ const [, repo, rkey] = atmoMatch;
+ item.w = 4;
+ item.h = 4;
+ item.mobileW = 8;
+ item.mobileH = 6;
+ item.cardType = 'event';
+ item.cardData.uri = `at://${repo}/${EVENT_COLLECTION}/${rkey}`;
+ return item;
}
- return eventDataMap;
- },
-
- onUrlHandler: (url, item) => {
// Match smokesignal.events URLs: https://smokesignal.events/{did}/{rkey}
const smokesignalMatch = url.match(/^https?:\/\/smokesignal\.events\/(did:[^/]+)\/([^/?#]+)/);
if (smokesignalMatch) {
@@ -100,17 +83,17 @@ export const EventCardDefinition = {
return item;
}
- // Match AT URIs: at://{did}/community.lexicon.calendar.event/{rkey}
- const atUriMatch = url.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/?#]+)/);
+ // Match AT URIs: at://{didOrHandle}/community.lexicon.calendar.event/{rkey}
+ const atUriMatch = url.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/?#]+)/);
if (atUriMatch) {
- const [, did, collection, rkey] = atUriMatch;
+ const [, repo, collection, rkey] = atUriMatch;
if (collection === EVENT_COLLECTION) {
item.w = 4;
item.h = 4;
item.mobileW = 8;
item.mobileH = 6;
item.cardType = 'event';
- item.cardData.uri = `at://${did}/${collection}/${rkey}`;
+ item.cardData.uri = `at://${repo}/${collection}/${rkey}`;
return item;
}
}
@@ -122,7 +105,7 @@ export const EventCardDefinition = {
name: 'Event',
- keywords: ['calendar', 'meetup', 'schedule', 'date', 'rsvp', 'smokesignal'],
+ keywords: ['calendar', 'meetup', 'schedule', 'date', 'rsvp', 'atmo', 'smokesignal'],
groups: ['Social'],
icon: ``
} as CardDefinition & { type: 'event' };
diff --git a/src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte b/src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte
index 3e49506..cd61ff2 100644
--- a/src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte
+++ b/src/lib/cards/social/UpcomingEventsCard/UpcomingEventsCard.svelte
@@ -1,13 +1,12 @@
+
+
+ Create event · Blento
+
+
+
+
+
diff --git a/src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts
new file mode 100644
index 0000000..58d5ff2
--- /dev/null
+++ b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts
@@ -0,0 +1,11 @@
+import { error } from '@sveltejs/kit';
+import { getActor } from '$lib/actor';
+
+export async function load({ params, request, platform }) {
+ if (!params.rkey) error(404, 'Event URL missing rkey');
+
+ const actor = await getActor({ request, paramActor: params.actor, platform });
+ if (!actor) error(404, 'Could not resolve actor');
+
+ return { actor, rkey: params.rkey };
+}
diff --git a/src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte
new file mode 100644
index 0000000..789c1a1
--- /dev/null
+++ b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte
@@ -0,0 +1,63 @@
+
+
+
+ Event · Blento
+
+
+
+
+
+
+
+
+
+
--
2.51.2
From 8e3b09bbafbad42caa1eccc171ad5c627711c4ba Mon Sep 17 00:00:00 2001
From: polijn
Date: Tue, 5 May 2026 19:21:24 +0200
Subject: [PATCH 15/17] event card update
---
.../EventCard/CreateEventCardModal.svelte | 286 +++++++++++++-----
1 file changed, 218 insertions(+), 68 deletions(-)
diff --git a/src/lib/cards/social/EventCard/CreateEventCardModal.svelte b/src/lib/cards/social/EventCard/CreateEventCardModal.svelte
index f52769f..b78f4b3 100644
--- a/src/lib/cards/social/EventCard/CreateEventCardModal.svelte
+++ b/src/lib/cards/social/EventCard/CreateEventCardModal.svelte
@@ -1,99 +1,249 @@
-
-