From b5984f2da7bcc692d00708af529d8ff14d9834d6 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 4 May 2026 02:19:20 +0200 Subject: [PATCH 01/17] fix listenbrainzcards --- .../ListenBrainzNowPlayingCard.svelte | 4 +++- .../ListenBrainzCard/ListenBrainzNowPlayingCard/index.ts | 6 ++++++ .../ListenBrainzRecentListensCard.svelte | 4 +++- .../ListenBrainzCard/ListenBrainzRecentListensCard/index.ts | 6 ++++++ .../ListenBrainzTopAlbumsCard.svelte | 2 +- .../ListenBrainzCard/ListenBrainzTopAlbumsCard/index.ts | 6 ++++++ .../ListenBrainzTopArtistsCard.svelte | 2 +- .../ListenBrainzCard/ListenBrainzTopArtistsCard/index.ts | 6 ++++++ .../ListenBrainzTopSongsCard.svelte | 4 +++- .../ListenBrainzCard/ListenBrainzTopSongsCard/index.ts | 6 ++++++ 10 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/ListenBrainzNowPlayingCard.svelte b/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/ListenBrainzNowPlayingCard.svelte index 2516c49..a36e8b3 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/ListenBrainzNowPlayingCard.svelte +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/ListenBrainzNowPlayingCard.svelte @@ -4,7 +4,9 @@ import type { Listen } from '../types.ts'; const { item }: ContentComponentProps = $props(); - const playing = $derived(await nowPlaying(item.cardData.username)); + const playing = $derived( + item.cardData.username ? await nowPlaying(item.cardData.username) : null + ); function getCoverArtUrl(listen: Listen): string | undefined { const releaseMbid = listen.track_metadata?.additional_info?.release_mbid; diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/index.ts b/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/index.ts index ed3d4cf..5a52720 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/index.ts +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/index.ts @@ -41,6 +41,12 @@ export const ListenBrainzNowPlayingCardDefinition = { } return allData; }, + migrate: (item) => { + if (!item.cardData.username && item.cardData.listenbrainzUsername) { + item.cardData.username = item.cardData.listenbrainzUsername; + delete item.cardData.listenbrainzUsername; + } + }, urlHandlerPriority: 5, minW: 2, minH: 2, diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/ListenBrainzRecentListensCard.svelte b/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/ListenBrainzRecentListensCard.svelte index 5e43d86..8e049c0 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/ListenBrainzRecentListensCard.svelte +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/ListenBrainzRecentListensCard.svelte @@ -5,7 +5,9 @@ import CoverArt from '../CoverArt.svelte'; const { item }: ContentComponentProps = $props(); - const listens = $derived(await recentListens(item.cardData.username)); + const listens = $derived( + item.cardData.username ? await recentListens(item.cardData.username) : [] + );
diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/index.ts b/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/index.ts index 547e091..c41695a 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/index.ts +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/index.ts @@ -42,6 +42,12 @@ export const ListenBrainzRecentListensCardDefinition = { return allData; }, + migrate: (item) => { + if (!item.cardData.username && item.cardData.listenbrainzUsername) { + item.cardData.username = item.cardData.listenbrainzUsername; + delete item.cardData.listenbrainzUsername; + } + }, urlHandlerPriority: 5, minW: 3, minH: 2, diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/ListenBrainzTopAlbumsCard.svelte b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/ListenBrainzTopAlbumsCard.svelte index 0413808..3e4e111 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/ListenBrainzTopAlbumsCard.svelte +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/ListenBrainzTopAlbumsCard.svelte @@ -4,7 +4,7 @@ import CoverArt from '../CoverArt.svelte'; const { item }: ContentComponentProps = $props(); - const albums = $derived(await topAlbums(item.cardData.username)); + const albums = $derived(item.cardData.username ? await topAlbums(item.cardData.username) : []);
diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/index.ts b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/index.ts index 04e2717..630143c 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/index.ts +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/index.ts @@ -41,6 +41,12 @@ export const ListenBrainzTopAlbumsCardDefinition = { } return allData; }, + migrate: (item) => { + if (!item.cardData.username && item.cardData.listenbrainzUsername) { + item.cardData.username = item.cardData.listenbrainzUsername; + delete item.cardData.listenbrainzUsername; + } + }, allowSetColor: true, defaultColor: 'base', minW: 2, diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/ListenBrainzTopArtistsCard.svelte b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/ListenBrainzTopArtistsCard.svelte index db5999c..2954d4e 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/ListenBrainzTopArtistsCard.svelte +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/ListenBrainzTopArtistsCard.svelte @@ -3,7 +3,7 @@ import { topArtists } from './artists.remote'; const { item }: ContentComponentProps = $props(); - const artists = $derived(await topArtists(item.cardData.username)); + const artists = $derived(item.cardData.username ? await topArtists(item.cardData.username) : []);
diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/index.ts b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/index.ts index 9fef5c0..bb50e55 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/index.ts +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/index.ts @@ -41,6 +41,12 @@ export const ListenBrainzTopArtistsCardDefinition = { } return allData; }, + migrate: (item) => { + if (!item.cardData.username && item.cardData.listenbrainzUsername) { + item.cardData.username = item.cardData.listenbrainzUsername; + delete item.cardData.listenbrainzUsername; + } + }, allowSetColor: true, defaultColor: 'base', minW: 2, diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/ListenBrainzTopSongsCard.svelte b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/ListenBrainzTopSongsCard.svelte index 4867695..4d78831 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/ListenBrainzTopSongsCard.svelte +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/ListenBrainzTopSongsCard.svelte @@ -4,7 +4,9 @@ import CoverArt from '../CoverArt.svelte'; const { item }: ContentComponentProps = $props(); - const recordings = $derived(await fetchListenBrainzTopSongs(item.cardData.username)); + const recordings = $derived( + item.cardData.username ? await fetchListenBrainzTopSongs(item.cardData.username) : [] + );
diff --git a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/index.ts b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/index.ts index 35000bf..1bd4202 100644 --- a/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/index.ts +++ b/src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/index.ts @@ -41,6 +41,12 @@ export const ListenBrainzTopSongsCardDefinition = { } return allData; }, + migrate: (item) => { + if (!item.cardData.username && item.cardData.listenbrainzUsername) { + item.cardData.username = item.cardData.listenbrainzUsername; + delete item.cardData.listenbrainzUsername; + } + }, minW: 3, minH: 2, canHaveLabel: true, -- 2.51.2 From b93b13f893c757df3027f8a9efc08eed054e11f0 Mon Sep 17 00:00:00 2001 From: Tyler <26290074+tylersayshi@users.noreply.github.com> Date: Mon, 4 May 2026 13:27:08 -0400 Subject: [PATCH 02/17] fix: add `\$type` field to new records this is a requirement for the PDS validation here which we use for @atproto.la https://tangled.org/tranquil.farm/tranquil-pds/blob/main/crates/tranquil-api/src/repo/record/validation.rs#L24 --- src/lib/atproto/server/repo.remote.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/lib/atproto/server/repo.remote.ts b/src/lib/atproto/server/repo.remote.ts index 459626c..facff98 100644 --- a/src/lib/atproto/server/repo.remote.ts +++ b/src/lib/atproto/server/repo.remote.ts @@ -27,16 +27,29 @@ export const putRecord = command( const { locals } = getRequestEvent(); if (!locals.client || !locals.did) error(401, 'Not authenticated'); + const record = + input.record.$type === input.collection + ? input.record + : { ...input.record, $type: input.collection }; + const response = await locals.client.post('com.atproto.repo.putRecord', { input: { collection: input.collection as `${string}.${string}.${string}`, repo: locals.did, rkey: input.rkey || 'self', - record: input.record + record } }); - if (!response.ok) error(500, 'Failed to put record'); + if (!response.ok) { + console.error('putRecord failed', { + collection: input.collection, + rkey: input.rkey || 'self', + status: response.status, + data: response.data + }); + error(500, 'Failed to put record'); + } // Immediately index in contrail const { platform } = getRequestEvent(); -- 2.51.2 From d96b585539af470d893648c22e287005d9bfbc2a Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 4 May 2026 19:47:46 +0200 Subject: [PATCH 03/17] update --- docs/Contributing.md | 37 +-- lex.config.js | 5 +- .../app/blento/card/listRecords.json | 236 ------------------ lexicons/{ => custom}/app/blento/card.json | 0 lexicons/{ => custom}/app/blento/page.json | 0 lexicons/{ => custom}/app/blento/section.json | 0 lexicons/generated/app/blento/authFull.json | 35 +++ .../generated}/app/blento/card/getRecord.json | 30 +-- .../app/blento/card}/listRecords.json | 44 ++-- .../generated}/app/blento/getCursor.json | 0 .../generated}/app/blento/getOverview.json | 0 .../generated}/app/blento/getProfile.json | 13 +- .../generated}/app/blento/notifyOfUpdate.json | 0 .../generated}/app/blento/page/getRecord.json | 30 +-- .../app/blento/page/listRecords.json | 122 +++++++++ .../app/blento/section/getRecord.json | 98 ++++++++ .../app/blento/section/listRecords.json | 140 +++++++++++ lexicons/generated/index.ts | 20 ++ package.json | 15 +- pnpm-lock.yaml | 163 +++++++----- scripts/append-scheduled.ts | 29 --- scripts/generate.ts | 14 -- scripts/sync.ts | 68 ----- src/lexicon-types/index.ts | 3 + src/lexicon-types/types/app/blento/card.ts | 12 +- .../types/app/blento/card/getRecord.ts | 16 +- .../types/app/blento/card/listRecords.ts | 119 +-------- .../types/app/blento/getProfile.ts | 6 +- src/lexicon-types/types/app/blento/page.ts | 4 +- .../types/app/blento/page/getRecord.ts | 16 +- .../types/app/blento/page/listRecords.ts | 34 +-- src/lexicon-types/types/app/blento/section.ts | 31 +++ .../types/app/blento/section/getRecord.ts | 66 +++++ .../types/app/blento/section/listRecords.ts | 103 ++++++++ src/lib/cards/special/UpdatedBlentos/index.ts | 22 +- .../config.ts => contrail.config.ts} | 10 +- src/lib/contrail/index.ts | 16 +- src/lib/website/load.ts | 26 +- wrangler.jsonc | 4 +- 39 files changed, 903 insertions(+), 684 deletions(-) delete mode 100644 lexicons-generated/app/blento/card/listRecords.json rename lexicons/{ => custom}/app/blento/card.json (100%) rename lexicons/{ => custom}/app/blento/page.json (100%) rename lexicons/{ => custom}/app/blento/section.json (100%) create mode 100644 lexicons/generated/app/blento/authFull.json rename {lexicons-generated => lexicons/generated}/app/blento/card/getRecord.json (89%) rename {lexicons-generated/app/blento/page => lexicons/generated/app/blento/card}/listRecords.json (81%) rename {lexicons-generated => lexicons/generated}/app/blento/getCursor.json (100%) rename {lexicons-generated => lexicons/generated}/app/blento/getOverview.json (100%) rename {lexicons-generated => lexicons/generated}/app/blento/getProfile.json (94%) rename {lexicons-generated => lexicons/generated}/app/blento/notifyOfUpdate.json (100%) rename {lexicons-generated => lexicons/generated}/app/blento/page/getRecord.json (89%) create mode 100644 lexicons/generated/app/blento/page/listRecords.json create mode 100644 lexicons/generated/app/blento/section/getRecord.json create mode 100644 lexicons/generated/app/blento/section/listRecords.json create mode 100644 lexicons/generated/index.ts delete mode 100644 scripts/append-scheduled.ts delete mode 100644 scripts/generate.ts delete mode 100644 scripts/sync.ts create mode 100644 src/lexicon-types/types/app/blento/section.ts create mode 100644 src/lexicon-types/types/app/blento/section/getRecord.ts create mode 100644 src/lexicon-types/types/app/blento/section/listRecords.ts rename src/lib/{contrail/config.ts => contrail.config.ts} (76%) diff --git a/docs/Contributing.md b/docs/Contributing.md index 39a0650..6cd5b6c 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -1,28 +1,37 @@ # Contributing Guidelines -For creating new cards see [here](CustomCards.md) (and check out [existing card ideas](CardIdeas.md)) +For new cards see [CustomCards](CustomCards.md) and [CardIdeas](CardIdeas.md). -## Development +## Setup -``` +```sh git clone https://github.com/flo-bit/blento.git cd blento -cp .env.example .env pnpm install -pnpm run dev +pnpm env:setup-dev # creates .env, fills COOKIE_SECRET + CLIENT_ASSERTION_KEY +``` + +In `wrangler.jsonc`, flip the `DB` binding's `"remote": true` to `false` if not already set to false (don't commit that). Otherwise `pnpm dev` and `pnpm backfill` write to production and need cloudflare credentials. + +```sh +pnpm dev # site falls back to PDS when D1 is empty — no backfill needed +pnpm backfill # populates local D1 via contrail; needed only for /xrpc/* paths and the UpdatedBlentos card ``` -## AI assisted development +`pnpm backfill` is resumable, takes a few minutes the first time. -You can submit PRs written with AI assistance but please make sure: +## Before opening a PR -- there's no extra unnecessary changes/unnecessary verbose code (keep it simple) -- you test everything yourself - - in light/dark mode - - with and without colored cards - - in edit mode and not in edit mode - - on mobile and desktop (note that there's two different mobile "modes", one dependent on screen size and one enabled when pointer: coarse) +- `pnpm check` — must complete with 0 errors and 0 warnings (existing baseline excepted). +- `pnpm format` — runs eslint --fix + prettier --write across the project. ## Subpages -currently subpages exist but are not used yet, they are perfect for testing things though (as otherwise your profile on blento.app will show e.g. cards that dont exist on the deployed version yet), in the development verion go to `/your.handle/{pagename}/edit` to edit a subpage (where pagename can be any string that is not "edit" or "api") (note that currently when you login you always get redirected to your main page) +In-progress changes go on a subpage so your live profile stays clean: `/your.handle/p//edit` (any `` other than `edit` or `api`). Login redirects to the main page — navigate to the subpage URL manually. + +## AI-assisted PRs + +Welcome — please: + +- Keep diffs minimal; no unrelated cleanup or verbose code +- Test light/dark, colored cards, edit/view, desktop and both mobile modes (screen-size and `pointer: coarse`) diff --git a/lex.config.js b/lex.config.js index 7c88fce..7290070 100644 --- a/lex.config.js +++ b/lex.config.js @@ -1,11 +1,11 @@ import { defineLexiconConfig } from '@atcute/lex-cli'; export default defineLexiconConfig({ - files: ['lexicons/**/*.json', 'lexicons-pulled/**/*.json', 'lexicons-generated/**/*.json'], + files: ['lexicons/custom/**/*.json', 'lexicons/pulled/**/*.json', 'lexicons/generated/**/*.json'], outdir: 'src/lexicon-types/', imports: ['@atcute/atproto'], pull: { - outdir: 'lexicons-pulled/', + outdir: 'lexicons/pulled/', sources: [ { type: 'atproto', @@ -15,6 +15,7 @@ export default defineLexiconConfig({ 'app.blento.page', 'app.blento.section', 'app.bsky.actor.profile', + 'app.nearhorizon.actor.pronouns', 'site.standard.publication' ] } diff --git a/lexicons-generated/app/blento/card/listRecords.json b/lexicons-generated/app/blento/card/listRecords.json deleted file mode 100644 index 2e657ea..0000000 --- a/lexicons-generated/app/blento/card/listRecords.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "lexicon": 1, - "id": "app.blento.card.listRecords", - "defs": { - "main": { - "type": "query", - "description": "Query app.blento.card records with filters", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "wMin": { - "type": "string", - "description": "Minimum value for w" - }, - "wMax": { - "type": "string", - "description": "Maximum value for w" - }, - "hMin": { - "type": "string", - "description": "Minimum value for h" - }, - "hMax": { - "type": "string", - "description": "Maximum value for h" - }, - "xMin": { - "type": "string", - "description": "Minimum value for x" - }, - "xMax": { - "type": "string", - "description": "Maximum value for x" - }, - "yMin": { - "type": "string", - "description": "Minimum value for y" - }, - "yMax": { - "type": "string", - "description": "Maximum value for y" - }, - "mobileWMin": { - "type": "string", - "description": "Minimum value for mobileW" - }, - "mobileWMax": { - "type": "string", - "description": "Maximum value for mobileW" - }, - "mobileHMin": { - "type": "string", - "description": "Minimum value for mobileH" - }, - "mobileHMax": { - "type": "string", - "description": "Maximum value for mobileH" - }, - "mobileXMin": { - "type": "string", - "description": "Minimum value for mobileX" - }, - "mobileXMax": { - "type": "string", - "description": "Maximum value for mobileX" - }, - "mobileYMin": { - "type": "string", - "description": "Minimum value for mobileY" - }, - "mobileYMax": { - "type": "string", - "description": "Maximum value for mobileY" - }, - "cardType": { - "type": "string", - "description": "Filter by cardType" - }, - "color": { - "type": "string", - "description": "Filter by color" - }, - "page": { - "type": "string", - "description": "Filter by page" - }, - "updatedAtMin": { - "type": "string", - "description": "Minimum value for updatedAt" - }, - "updatedAtMax": { - "type": "string", - "description": "Maximum value for updatedAt" - }, - "versionMin": { - "type": "string", - "description": "Minimum value for version" - }, - "versionMax": { - "type": "string", - "description": "Maximum value for version" - }, - "sort": { - "type": "string", - "knownValues": [ - "w", - "h", - "x", - "y", - "mobileW", - "mobileH", - "mobileX", - "mobileY", - "cardType", - "color", - "page", - "updatedAt", - "version" - ], - "description": "Field to sort by (default: time_us)" - }, - "order": { - "type": "string", - "knownValues": ["asc", "desc"], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["records"], - "properties": { - "records": { - "type": "array", - "items": { - "type": "ref", - "ref": "#record" - } - }, - "cursor": { - "type": "string" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "record": { - "type": "object", - "required": ["uri", "did", "collection", "rkey", "time_us"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "app.blento.card#main" - }, - "time_us": { - "type": "integer" - } - } - }, - "profileEntry": { - "type": "object", - "required": ["did"], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "unknown" - } - } - } - } -} diff --git a/lexicons/app/blento/card.json b/lexicons/custom/app/blento/card.json similarity index 100% rename from lexicons/app/blento/card.json rename to lexicons/custom/app/blento/card.json diff --git a/lexicons/app/blento/page.json b/lexicons/custom/app/blento/page.json similarity index 100% rename from lexicons/app/blento/page.json rename to lexicons/custom/app/blento/page.json diff --git a/lexicons/app/blento/section.json b/lexicons/custom/app/blento/section.json similarity index 100% rename from lexicons/app/blento/section.json rename to lexicons/custom/app/blento/section.json diff --git a/lexicons/generated/app/blento/authFull.json b/lexicons/generated/app/blento/authFull.json new file mode 100644 index 0000000..70cf8fc --- /dev/null +++ b/lexicons/generated/app/blento/authFull.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "app.blento.authFull", + "defs": { + "main": { + "type": "permission-set", + "title": "app.blento", + "description": "Full access to the app.blento service", + "permissions": [ + { + "type": "permission", + "resource": "rpc", + "aud": "*", + "lxm": [ + "app.blento.card.getRecord", + "app.blento.card.listRecords", + "app.blento.getCursor", + "app.blento.getOverview", + "app.blento.getProfile", + "app.blento.notifyOfUpdate", + "app.blento.page.getRecord", + "app.blento.page.listRecords", + "app.blento.section.getRecord", + "app.blento.section.listRecords" + ] + }, + { + "type": "permission", + "resource": "repo", + "collection": ["app.blento.card", "app.blento.page", "app.blento.section"] + } + ] + } + } +} diff --git a/lexicons-generated/app/blento/card/getRecord.json b/lexicons/generated/app/blento/card/getRecord.json similarity index 89% rename from lexicons-generated/app/blento/card/getRecord.json rename to lexicons/generated/app/blento/card/getRecord.json index 2e5a76c..df2ff67 100644 --- a/lexicons-generated/app/blento/card/getRecord.json +++ b/lexicons/generated/app/blento/card/getRecord.json @@ -24,12 +24,20 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "did", "collection", "rkey", "time_us"], + "required": ["uri", "value", "did", "collection", "rkey", "time_us"], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.blento.card#main" + }, "did": { "type": "string", "format": "did" @@ -41,13 +49,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "app.blento.card#main" - }, "time_us": { "type": "integer" }, @@ -77,18 +78,19 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "unknown" } } } diff --git a/lexicons-generated/app/blento/page/listRecords.json b/lexicons/generated/app/blento/card/listRecords.json similarity index 81% rename from lexicons-generated/app/blento/page/listRecords.json rename to lexicons/generated/app/blento/card/listRecords.json index 9127056..ae25dd6 100644 --- a/lexicons-generated/app/blento/page/listRecords.json +++ b/lexicons/generated/app/blento/card/listRecords.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "app.blento.page.listRecords", + "id": "app.blento.card.listRecords", "defs": { "main": { "type": "query", - "description": "Query app.blento.page records with filters", + "description": "Query app.blento.card records with filters", "parameters": { "type": "params", "properties": { @@ -26,17 +26,17 @@ "type": "boolean", "description": "Include profile + identity info keyed by DID" }, - "name": { + "page": { "type": "string", - "description": "Filter by name" + "description": "Filter by page" }, - "description": { + "cardType": { "type": "string", - "description": "Filter by description" + "description": "Filter by cardType" }, "sort": { "type": "string", - "knownValues": ["name", "description"], + "knownValues": ["page", "cardType"], "description": "Field to sort by (default: time_us)" }, "order": { @@ -75,12 +75,20 @@ }, "record": { "type": "object", - "required": ["uri", "did", "collection", "rkey", "time_us"], + "required": ["uri", "cid", "value", "did", "collection", "rkey", "time_us"], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.blento.card#main" + }, "did": { "type": "string", "format": "did" @@ -92,13 +100,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "app.blento.page#main" - }, "time_us": { "type": "integer" } @@ -119,18 +120,19 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "unknown" } } } diff --git a/lexicons-generated/app/blento/getCursor.json b/lexicons/generated/app/blento/getCursor.json similarity index 100% rename from lexicons-generated/app/blento/getCursor.json rename to lexicons/generated/app/blento/getCursor.json diff --git a/lexicons-generated/app/blento/getOverview.json b/lexicons/generated/app/blento/getOverview.json similarity index 100% rename from lexicons-generated/app/blento/getOverview.json rename to lexicons/generated/app/blento/getOverview.json diff --git a/lexicons-generated/app/blento/getProfile.json b/lexicons/generated/app/blento/getProfile.json similarity index 94% rename from lexicons-generated/app/blento/getProfile.json rename to lexicons/generated/app/blento/getProfile.json index 22e735d..482aed6 100644 --- a/lexicons-generated/app/blento/getProfile.json +++ b/lexicons/generated/app/blento/getProfile.json @@ -48,18 +48,19 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "unknown" } } } diff --git a/lexicons-generated/app/blento/notifyOfUpdate.json b/lexicons/generated/app/blento/notifyOfUpdate.json similarity index 100% rename from lexicons-generated/app/blento/notifyOfUpdate.json rename to lexicons/generated/app/blento/notifyOfUpdate.json diff --git a/lexicons-generated/app/blento/page/getRecord.json b/lexicons/generated/app/blento/page/getRecord.json similarity index 89% rename from lexicons-generated/app/blento/page/getRecord.json rename to lexicons/generated/app/blento/page/getRecord.json index e6b996c..e57d73b 100644 --- a/lexicons-generated/app/blento/page/getRecord.json +++ b/lexicons/generated/app/blento/page/getRecord.json @@ -24,12 +24,20 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["uri", "did", "collection", "rkey", "time_us"], + "required": ["uri", "value", "did", "collection", "rkey", "time_us"], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.blento.page#main" + }, "did": { "type": "string", "format": "did" @@ -41,13 +49,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "app.blento.page#main" - }, "time_us": { "type": "integer" }, @@ -77,18 +78,19 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "unknown" } } } diff --git a/lexicons/generated/app/blento/page/listRecords.json b/lexicons/generated/app/blento/page/listRecords.json new file mode 100644 index 0000000..c912c31 --- /dev/null +++ b/lexicons/generated/app/blento/page/listRecords.json @@ -0,0 +1,122 @@ +{ + "lexicon": 1, + "id": "app.blento.page.listRecords", + "defs": { + "main": { + "type": "query", + "description": "Query app.blento.page records with filters", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "cursor": { + "type": "string" + }, + "actor": { + "type": "string", + "format": "at-identifier", + "description": "Filter by DID or handle (triggers on-demand backfill)" + }, + "profiles": { + "type": "boolean", + "description": "Include profile + identity info keyed by DID" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["records"], + "properties": { + "records": { + "type": "array", + "items": { + "type": "ref", + "ref": "#record" + } + }, + "cursor": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "#profileEntry" + } + } + } + } + } + }, + "record": { + "type": "object", + "required": ["uri", "cid", "value", "did", "collection", "rkey", "time_us"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.blento.page#main" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "time_us": { + "type": "integer" + } + } + }, + "profileEntry": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + } +} diff --git a/lexicons/generated/app/blento/section/getRecord.json b/lexicons/generated/app/blento/section/getRecord.json new file mode 100644 index 0000000..2d5a172 --- /dev/null +++ b/lexicons/generated/app/blento/section/getRecord.json @@ -0,0 +1,98 @@ +{ + "lexicon": 1, + "id": "app.blento.section.getRecord", + "defs": { + "main": { + "type": "query", + "description": "Get a single app.blento.section record by AT URI", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the record" + }, + "profiles": { + "type": "boolean", + "description": "Include profile + identity info keyed by DID" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "value", "did", "collection", "rkey", "time_us"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.blento.section#main" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "time_us": { + "type": "integer" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "#profileEntry" + } + } + } + } + } + }, + "profileEntry": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + } +} diff --git a/lexicons/generated/app/blento/section/listRecords.json b/lexicons/generated/app/blento/section/listRecords.json new file mode 100644 index 0000000..e384c7d --- /dev/null +++ b/lexicons/generated/app/blento/section/listRecords.json @@ -0,0 +1,140 @@ +{ + "lexicon": 1, + "id": "app.blento.section.listRecords", + "defs": { + "main": { + "type": "query", + "description": "Query app.blento.section records with filters", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "cursor": { + "type": "string" + }, + "actor": { + "type": "string", + "format": "at-identifier", + "description": "Filter by DID or handle (triggers on-demand backfill)" + }, + "profiles": { + "type": "boolean", + "description": "Include profile + identity info keyed by DID" + }, + "page": { + "type": "string", + "description": "Filter by page" + }, + "sectionType": { + "type": "string", + "description": "Filter by sectionType" + }, + "sort": { + "type": "string", + "knownValues": ["page", "sectionType"], + "description": "Field to sort by (default: time_us)" + }, + "order": { + "type": "string", + "knownValues": ["asc", "desc"], + "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["records"], + "properties": { + "records": { + "type": "array", + "items": { + "type": "ref", + "ref": "#record" + } + }, + "cursor": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "#profileEntry" + } + } + } + } + } + }, + "record": { + "type": "object", + "required": ["uri", "cid", "value", "did", "collection", "rkey", "time_us"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.blento.section#main" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "time_us": { + "type": "integer" + } + } + }, + "profileEntry": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + } +} diff --git a/lexicons/generated/index.ts b/lexicons/generated/index.ts new file mode 100644 index 0000000..b951507 --- /dev/null +++ b/lexicons/generated/index.ts @@ -0,0 +1,20 @@ +// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit. +// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them +// at `/xrpc/.lexicons` for consumer apps to typegen against. + +import _0 from '../custom/app/blento/card.json'; +import _1 from '../custom/app/blento/page.json'; +import _2 from '../custom/app/blento/section.json'; +import _3 from './app/blento/authFull.json'; +import _4 from './app/blento/card/getRecord.json'; +import _5 from './app/blento/card/listRecords.json'; +import _6 from './app/blento/getCursor.json'; +import _7 from './app/blento/getOverview.json'; +import _8 from './app/blento/getProfile.json'; +import _9 from './app/blento/notifyOfUpdate.json'; +import _10 from './app/blento/page/getRecord.json'; +import _11 from './app/blento/page/listRecords.json'; +import _12 from './app/blento/section/getRecord.json'; +import _13 from './app/blento/section/listRecords.json'; + +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13]; diff --git a/package.json b/package.json index 7384306..2768012 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "NODE_OPTIONS='--max-old-space-size=4096' vite build && tsx scripts/append-scheduled.ts", + "build": "NODE_OPTIONS='--max-old-space-size=4096' vite build && contrail append-scheduled", "preview": "pnpm run build && wrangler dev", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -19,13 +19,14 @@ "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", "tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts", - "generate": "npx tsx scripts/generate.ts", - "sync": "npx tsx scripts/sync.ts", - "sync:remote": "npx tsx scripts/sync.ts --remote" + "generate": "contrail-lex generate", + "backfill": "contrail backfill", + "backfill:remote": "contrail backfill --remote" }, "devDependencies": { - "@atcute/lex-cli": "^2.6.1", + "@atcute/lex-cli": "^2.8.1", "@atcute/lexicon-doc": "^2.1.2", + "@atmo-dev/contrail-lexicons": "^0.4.5", "@eslint/compat": "^2.0.3", "@eslint/js": "^10.0.1", "@sveltejs/adapter-cloudflare": "^7.2.8", @@ -59,12 +60,12 @@ "@atcute/bluesky-richtext-segmenter": "^3.0.0", "@atcute/client": "^4.2.1", "@atcute/identity-resolver": "^1.2.2", - "@atcute/lexicons": "^1.2.9", + "@atcute/lexicons": "^1.3.0", "@atcute/oauth-browser-client": "^3.0.0", "@atcute/oauth-node-client": "^1.1.0", "@atcute/standard-site": "^1.0.1", "@atcute/tid": "^1.1.2", - "@atmo-dev/contrail": "^0.0.8", + "@atmo-dev/contrail": "^0.5.0", "@cloudflare/workers-types": "^4.20260313.1", "@ethercorps/sveltekit-og": "^4.2.1", "@floating-ui/dom": "^1.7.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41aa821..98bd27d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,13 +25,13 @@ importers: version: 4.2.1 '@atcute/identity-resolver': specifier: ^1.2.2 - version: 1.2.2(@atcute/identity@1.1.3) + version: 1.2.2(@atcute/identity@1.1.4) '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.2.9 + specifier: ^1.3.0 + version: 1.3.0 '@atcute/oauth-browser-client': specifier: ^3.0.0 - version: 3.0.0(@atcute/identity@1.1.3) + version: 3.0.0(@atcute/identity@1.1.4) '@atcute/oauth-node-client': specifier: ^1.1.0 version: 1.1.0 @@ -42,8 +42,8 @@ importers: specifier: ^1.1.2 version: 1.1.2 '@atmo-dev/contrail': - specifier: ^0.0.8 - version: 0.0.8(@atcute/identity@1.1.3)(react@19.2.4) + specifier: ^0.5.0 + version: 0.5.0(react@19.2.4)(wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1)) '@cloudflare/workers-types': specifier: ^4.20260313.1 version: 4.20260313.1 @@ -208,11 +208,14 @@ importers: version: 4.73.0(@cloudflare/workers-types@4.20260313.1) devDependencies: '@atcute/lex-cli': - specifier: ^2.6.1 - version: 2.8.0 + specifier: ^2.8.1 + version: 2.8.1 '@atcute/lexicon-doc': specifier: ^2.1.2 version: 2.2.0 + '@atmo-dev/contrail-lexicons': + specifier: ^0.4.5 + version: 0.4.5(react@19.2.4)(wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1)) '@eslint/compat': specifier: ^2.0.3 version: 2.0.3(eslint@10.0.3(jiti@2.6.1)) @@ -332,8 +335,8 @@ packages: '@atcute/jetstream@1.1.2': resolution: {integrity: sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==} - '@atcute/lex-cli@2.8.0': - resolution: {integrity: sha512-eNPO0hhGhrCXQ7vEgVhqAaSHSsT3me1Jcc99rHaPgne1xP7fBfprf+E02M6BUqwrBz95YpnyuLPmVKNEk1jLwA==} + '@atcute/lex-cli@2.8.1': + resolution: {integrity: sha512-ab+NpnwvW7gXMjZYYqK3zqhcAPOEYnAaNAXHp28gmL3M33zHgz4IjNQpDnRAjTitCpaJ3KoA3KXlnXNjsV6USg==} hasBin: true '@atcute/lexicon-doc@2.2.0': @@ -345,9 +348,6 @@ packages: '@atcute/identity': ^1.1.0 '@atcute/identity-resolver': ^1.1.3 - '@atcute/lexicons@1.2.9': - resolution: {integrity: sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g==} - '@atcute/lexicons@1.3.0': resolution: {integrity: sha512-Eq5y+9onnCXNVUlNiMf31beSXHKqptB7lUo/68YbhlmxdaR7ooywHmahya9goP5AsmlYEA1z+dRPXIDAa9O7cg==} @@ -393,22 +393,33 @@ packages: '@atcute/util-fetch@1.0.5': resolution: {integrity: sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==} - '@atcute/util-text@1.1.1': - resolution: {integrity: sha512-JH0SxzUQJAmbOBTYyhxQbkkI6M33YpjlVLEcbP5GYt43xgFArzV0FJVmEpvIj0kjsmphHB45b6IitdvxPdec9w==} - '@atcute/util-text@1.2.0': resolution: {integrity: sha512-b8WSh+Z7K601eUFFmTFj8QPKDO8Ic0VDDj63sdKzpkm+ySQKsYT5nXekViGqFVKbyKj1V5FyvZvgXad6/aI4QQ==} + '@atcute/util-text@1.3.1': + resolution: {integrity: sha512-MRgJXkx67znuBXuoAYCJkBZyd3OApL7zZlNf5kXhuoCXcdiu1nblRDycYTADSkym4epBSQWxh26kmI9sewaq6A==} + '@atcute/varint@2.0.0': resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==} - '@atmo-dev/contrail@0.0.8': - resolution: {integrity: sha512-sXtdd3Z8VNVoSinrX3ww978ctxtHBl0bX5tP51XOY0IWKJ4xl9zqkPOIIBMzbVE3IyU2Vq2B9Whi3VAhyd2Qdg==} + '@atcute/xrpc-server@0.1.12': + resolution: {integrity: sha512-70KIerQlljp5+s6t0u6YNN9klEboQUZa2hhoi/hmXIO1cIKEORettTMctnyjfcCJaSfAuj42dxPu51GTZBlm8w==} + + '@atmo-dev/contrail-lexicons@0.4.5': + resolution: {integrity: sha512-gd3k8tFbcokjsgTpC9gKitYgImI6M+iipFCUYy60nCHomDCYMxkgrvhjJlii61ol9LEbcVDSPdMKKcKJGwm3gA==} + hasBin: true + + '@atmo-dev/contrail@0.5.0': + resolution: {integrity: sha512-PeeA3Q6NDwaho2dgaW4QANPRvhjKBml17MMxjcq6DwQG7p0MTUqoa2RM1G7DpA8CAIPrRXTVcosb6ybqshlWtQ==} + hasBin: true peerDependencies: pg: ^8.0.0 + wrangler: ^4.0.0 peerDependenciesMeta: pg: optional: true + wrangler: + optional: true '@badrap/valita@0.4.6': resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} @@ -986,12 +997,12 @@ packages: peerDependencies: svelte: ^4 || ^5 - '@optique/core@0.10.7': - resolution: {integrity: sha512-FwSX8ILFqzcCqZi6Xetsa4flJp/yyqFG4d4eFD98BtqdzxxuylzdrKvsXj/ow8mcoVjYkTuaIkqHSBxonqMcQg==} + '@optique/core@1.0.2': + resolution: {integrity: sha512-znsqMmjAdeOgSJzdJlpZpgAscojwQmeQYXzYnuEKllz5VCj6WyEkdzU4QuvJQtWQY3ve2taXwudEBRur0VHBOQ==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} - '@optique/run@0.10.7': - resolution: {integrity: sha512-1CVdH8uyptj1nFGS2MLacSmZceRClez4LD/G/Gm38wrAVnJq6I+9Fvyh2bVHErsZLQzR0a12CYMUWIgDKY3X1w==} + '@optique/run@1.0.2': + resolution: {integrity: sha512-0Wc+zC8SLGV8zXQX+pk+o0c6wE/ddx/36CHZ0toTh5lApsjruUuGhqbxvljerAAG5un1xQbOLxzksBVC6UPgSg==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} '@oxc-project/runtime@0.115.0': @@ -1741,6 +1752,10 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + camelize@1.0.1: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} @@ -2446,6 +2461,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + nanoid@5.1.6: resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} engines: {node: ^18 || >=20} @@ -3353,7 +3373,7 @@ snapshots: '@atcute/atproto@3.1.10': dependencies: - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/bluesky-richtext-parser@2.1.1': {} @@ -3362,7 +3382,7 @@ snapshots: '@atcute/bluesky@3.3.0': dependencies: '@atcute/atproto': 3.1.10 - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/car@5.1.1': dependencies: @@ -3374,7 +3394,7 @@ snapshots: '@atcute/cbor@2.3.2': dependencies: '@atcute/cid': 2.4.1 - '@atcute/multibase': 1.1.8 + '@atcute/multibase': 1.2.0 '@atcute/uint8array': 1.1.1 '@atcute/cid@2.4.1': @@ -3385,7 +3405,7 @@ snapshots: '@atcute/client@4.2.1': dependencies: '@atcute/identity': 1.1.3 - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/crypto@2.4.1': dependencies: @@ -3396,20 +3416,20 @@ snapshots: '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3)': dependencies: '@atcute/identity': 1.1.3 - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/util-fetch': 1.0.5 '@badrap/valita': 0.4.6 '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4)': dependencies: '@atcute/identity': 1.1.4 - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/util-fetch': 1.0.5 '@badrap/valita': 0.4.6 '@atcute/identity@1.1.3': dependencies: - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@badrap/valita': 0.4.6 '@atcute/identity@1.1.4': @@ -3419,7 +3439,7 @@ snapshots: '@atcute/jetstream@1.1.2(react@19.2.4)': dependencies: - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@badrap/valita': 0.4.6 '@mary-ext/event-iterator': 1.0.0 '@mary-ext/simple-event-emitter': 1.0.1 @@ -3429,16 +3449,16 @@ snapshots: transitivePeerDependencies: - react - '@atcute/lex-cli@2.8.0': + '@atcute/lex-cli@2.8.1': dependencies: '@atcute/identity': 1.1.4 '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) '@atcute/lexicon-doc': 2.2.0 - '@atcute/lexicon-resolver': 0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.4) + '@atcute/lexicon-resolver': 0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4))(@atcute/identity@1.1.4) '@atcute/lexicons': 1.3.0 '@badrap/valita': 0.4.6 - '@optique/core': 0.10.7 - '@optique/run': 0.10.7 + '@optique/core': 1.0.2 + '@optique/run': 1.0.2 picocolors: 1.1.1 prettier: 3.8.3 @@ -3450,28 +3470,21 @@ snapshots: '@atcute/util-text': 1.2.0 '@badrap/valita': 0.4.6 - '@atcute/lexicon-resolver@0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.4)': + '@atcute/lexicon-resolver@0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4))(@atcute/identity@1.1.4)': dependencies: '@atcute/crypto': 2.4.1 '@atcute/identity': 1.1.4 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) '@atcute/lexicon-doc': 2.2.0 '@atcute/lexicons': 1.3.0 '@atcute/repo': 0.1.4 '@atcute/util-fetch': 1.0.5 '@badrap/valita': 0.4.6 - '@atcute/lexicons@1.2.9': - dependencies: - '@atcute/uint8array': 1.1.1 - '@atcute/util-text': 1.1.1 - '@standard-schema/spec': 1.1.0 - esm-env: 1.2.2 - '@atcute/lexicons@1.3.0': dependencies: '@atcute/uint8array': 1.1.1 - '@atcute/util-text': 1.2.0 + '@atcute/util-text': 1.3.1 '@standard-schema/spec': 1.1.0 esm-env: 1.2.2 @@ -3489,11 +3502,11 @@ snapshots: dependencies: '@atcute/uint8array': 1.1.1 - '@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.3)': + '@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.4)': dependencies: '@atcute/client': 4.2.1 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) - '@atcute/lexicons': 1.2.9 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) + '@atcute/lexicons': 1.3.0 '@atcute/multibase': 1.1.8 '@atcute/oauth-crypto': 0.1.0 '@atcute/oauth-types': 0.1.1 @@ -3517,7 +3530,7 @@ snapshots: '@atcute/client': 4.2.1 '@atcute/identity': 1.1.3 '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/oauth-crypto': 0.1.0 '@atcute/oauth-keyset': 0.1.0 '@atcute/oauth-types': 0.1.1 @@ -3528,7 +3541,7 @@ snapshots: '@atcute/oauth-types@0.1.1': dependencies: '@atcute/identity': 1.1.3 - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/oauth-keyset': 0.1.0 '@badrap/valita': 0.4.6 @@ -3545,7 +3558,7 @@ snapshots: '@atcute/standard-site@1.0.1': dependencies: '@atcute/atproto': 3.1.10 - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 '@atcute/tid@1.1.2': dependencies: @@ -3559,26 +3572,54 @@ snapshots: dependencies: '@badrap/valita': 0.4.6 - '@atcute/util-text@1.1.1': + '@atcute/util-text@1.2.0': dependencies: unicode-segmenter: 0.14.5 - '@atcute/util-text@1.2.0': + '@atcute/util-text@1.3.1': dependencies: unicode-segmenter: 0.14.5 '@atcute/varint@2.0.0': {} - '@atmo-dev/contrail@0.0.8(@atcute/identity@1.1.3)(react@19.2.4)': + '@atcute/xrpc-server@0.1.12': + dependencies: + '@atcute/cbor': 2.3.2 + '@atcute/crypto': 2.4.1 + '@atcute/identity': 1.1.4 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) + '@atcute/lexicons': 1.3.0 + '@atcute/multibase': 1.2.0 + '@atcute/uint8array': 1.1.1 + '@badrap/valita': 0.4.6 + nanoid: 5.1.11 + + '@atmo-dev/contrail-lexicons@0.4.5(react@19.2.4)(wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1))': + dependencies: + '@atcute/lex-cli': 2.8.1 + '@atmo-dev/contrail': 0.5.0(react@19.2.4)(wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1)) + transitivePeerDependencies: + - pg + - react + - wrangler + + '@atmo-dev/contrail@0.5.0(react@19.2.4)(wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1))': dependencies: '@atcute/atproto': 3.1.10 + '@atcute/cbor': 2.3.2 + '@atcute/cid': 2.4.1 '@atcute/client': 4.2.1 - '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) + '@atcute/identity': 1.1.4 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) '@atcute/jetstream': 1.1.2(react@19.2.4) - '@atcute/lexicons': 1.2.9 + '@atcute/lexicons': 1.3.0 + '@atcute/xrpc-server': 0.1.12 + cac: 7.0.0 hono: 4.12.14 + jiti: 2.6.1 + optionalDependencies: + wrangler: 4.73.0(@cloudflare/workers-types@4.20260313.1) transitivePeerDependencies: - - '@atcute/identity' - react '@badrap/valita@0.4.6': {} @@ -4143,11 +4184,11 @@ snapshots: number-flow: 0.6.0 svelte: 5.53.11 - '@optique/core@0.10.7': {} + '@optique/core@1.0.2': {} - '@optique/run@0.10.7': + '@optique/run@1.0.2': dependencies: - '@optique/core': 0.10.7 + '@optique/core': 1.0.2 '@oxc-project/runtime@0.115.0': {} @@ -4879,6 +4920,8 @@ snapshots: dependencies: balanced-match: 4.0.4 + cac@7.0.0: {} + camelize@1.0.1: {} camera-controls@3.1.2(three@0.183.2): @@ -5558,6 +5601,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.11: {} + nanoid@5.1.6: {} natural-compare@1.4.0: {} diff --git a/scripts/append-scheduled.ts b/scripts/append-scheduled.ts deleted file mode 100644 index c9bd1cd..0000000 --- a/scripts/append-scheduled.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Post-build script: appends a `scheduled` handler to the SvelteKit worker output. - * - * SvelteKit's adapter-cloudflare doesn't support the `scheduled` export natively - * (see https://github.com/sveltejs/kit/issues/4841). This script patches the - * generated _worker.js to add one that self-calls the /api/cron endpoint. - */ -import { readFileSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const workerPath = join(root, '.svelte-kit', 'cloudflare', '_worker.js'); - -let code = readFileSync(workerPath, 'utf-8'); - -code += ` -// --- Appended by scripts/append-scheduled.ts --- -worker_default.scheduled = async function (event, env, ctx) { - const req = new Request('http://localhost/api/cron', { - method: 'POST', - headers: { 'X-Cron-Secret': env.CRON_SECRET || '' } - }); - ctx.waitUntil(this.fetch(req, env, ctx)); -}; -`; - -writeFileSync(workerPath, code); -console.log('Appended scheduled handler to _worker.js'); diff --git a/scripts/generate.ts b/scripts/generate.ts deleted file mode 100644 index 4a893d5..0000000 --- a/scripts/generate.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { config } from '../src/lib/contrail/config'; -import { generateLexicons } from '@atmo-dev/contrail/generate'; - -const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); - -generateLexicons({ - config, - rootDir: ROOT_DIR, - lexiconDir: join(ROOT_DIR, 'lexicons'), - outputDir: join(ROOT_DIR, 'lexicons-generated'), - writeRuntimeFiles: true -}); diff --git a/scripts/sync.ts b/scripts/sync.ts deleted file mode 100644 index c946f86..0000000 --- a/scripts/sync.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Discover users from relays and backfill their records from PDS. - * - * Usage: - * pnpm sync # local D1 - * pnpm sync:remote # prod D1 - */ -import { Contrail } from '@atmo-dev/contrail'; -import { config } from '../src/lib/contrail/config'; -import { getPlatformProxy } from 'wrangler'; - -function elapsed(start: number): string { - const ms = Date.now() - start; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - const mins = Math.floor(ms / 60_000); - const secs = ((ms % 60_000) / 1000).toFixed(0); - return `${mins}m ${secs}s`; -} - -async function main() { - const remote = process.argv.includes('--remote'); - const syncStart = Date.now(); - - console.log(`=== Sync (${remote ? 'remote/prod' : 'local'} D1) ===\n`); - - const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>({ - environment: remote ? 'production' : undefined - }); - - const contrail = new Contrail({ ...config, db: env.DB }); - - try { - await contrail.init(); - - console.log('--- Discovery ---'); - const discoveryStart = Date.now(); - const discovered = await contrail.discover(); - console.log(` Done: ${discovered.length} users in ${elapsed(discoveryStart)}\n`); - - console.log('--- Backfill ---'); - const backfillStart = Date.now(); - const total = await contrail.backfill({ - concurrency: 100, - onProgress: ({ records, usersComplete, usersTotal, usersFailed }) => { - const secs = (Date.now() - backfillStart) / 1000; - const rate = secs > 0 ? Math.round(records / secs) : 0; - const failStr = usersFailed > 0 ? ` | ${usersFailed} failed` : ''; - process.stdout.write( - `\r ${records} records | ${usersComplete}/${usersTotal} users | ${rate}/s | ${elapsed(backfillStart)}${failStr} ` - ); - } - }); - process.stdout.write('\n'); - console.log(` Done: ${total} records in ${elapsed(backfillStart)}\n`); - - console.log(`=== Finished in ${elapsed(syncStart)} ===`); - console.log(` Discovered: ${discovered.length} users`); - console.log(` Backfilled: ${total} records`); - } finally { - await dispose(); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/lexicon-types/index.ts b/src/lexicon-types/index.ts index 9d550a9..fb11cde 100644 --- a/src/lexicon-types/index.ts +++ b/src/lexicon-types/index.ts @@ -8,3 +8,6 @@ export * as AppBlentoNotifyOfUpdate from './types/app/blento/notifyOfUpdate.js'; export * as AppBlentoPage from './types/app/blento/page.js'; export * as AppBlentoPageGetRecord from './types/app/blento/page/getRecord.js'; export * as AppBlentoPageListRecords from './types/app/blento/page/listRecords.js'; +export * as AppBlentoSection from './types/app/blento/section.js'; +export * as AppBlentoSectionGetRecord from './types/app/blento/section/getRecord.js'; +export * as AppBlentoSectionListRecords from './types/app/blento/section/listRecords.js'; diff --git a/src/lexicon-types/types/app/blento/card.ts b/src/lexicon-types/types/app/blento/card.ts index 585271f..4b4e61d 100644 --- a/src/lexicon-types/types/app/blento/card.ts +++ b/src/lexicon-types/types/app/blento/card.ts @@ -6,15 +6,17 @@ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.tidString(), /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.literal('app.blento.card'), - cardData: /*#__PURE__*/ v.unknown(), + cardData: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), cardType: /*#__PURE__*/ v.string(), color: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), h: /*#__PURE__*/ v.integer(), - mobileH: /*#__PURE__*/ v.integer(), - mobileW: /*#__PURE__*/ v.integer(), - mobileX: /*#__PURE__*/ v.integer(), - mobileY: /*#__PURE__*/ v.integer(), + mobileH: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + mobileW: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + mobileX: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + mobileY: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), page: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + rotation: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + sectionId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), version: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), w: /*#__PURE__*/ v.integer(), diff --git a/src/lexicon-types/types/app/blento/card/getRecord.ts b/src/lexicon-types/types/app/blento/card/getRecord.ts index 3ad3987..78e2a34 100644 --- a/src/lexicon-types/types/app/blento/card/getRecord.ts +++ b/src/lexicon-types/types/app/blento/card/getRecord.ts @@ -17,18 +17,18 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.card.getRecord', { output: { type: 'lex', schema: /*#__PURE__*/ v.object({ - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), collection: /*#__PURE__*/ v.nsidString(), did: /*#__PURE__*/ v.didString(), get profiles() { return /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(profileEntrySchema)); }, - get record() { - return /*#__PURE__*/ v.optional(AppBlentoCard.mainSchema); - }, rkey: /*#__PURE__*/ v.string(), time_us: /*#__PURE__*/ v.integer(), - uri: /*#__PURE__*/ v.resourceUriString() + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return AppBlentoCard.mainSchema; + } }) } }); @@ -36,13 +36,13 @@ const _profileEntrySchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal('app.blento.card.getRecord#profileEntry') ), - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), did: /*#__PURE__*/ v.didString(), handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - record: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()) + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + value: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()) }); type main$schematype = typeof _mainSchema; diff --git a/src/lexicon-types/types/app/blento/card/listRecords.ts b/src/lexicon-types/types/app/blento/card/listRecords.ts index db056ba..4946599 100644 --- a/src/lexicon-types/types/app/blento/card/listRecords.ts +++ b/src/lexicon-types/types/app/blento/card/listRecords.ts @@ -13,19 +13,7 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.card.listRecords', { * Filter by cardType */ cardType: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Filter by color - */ - color: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for h - */ - hMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for h - */ - hMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), /** * @minimum 1 * @maximum 200 @@ -35,38 +23,6 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.card.listRecords', { /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [/*#__PURE__*/ v.integerRange(1, 200)]), 50 ), - /** - * Maximum value for mobileH - */ - mobileHMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for mobileH - */ - mobileHMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for mobileW - */ - mobileWMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for mobileW - */ - mobileWMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for mobileX - */ - mobileXMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for mobileX - */ - mobileXMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for mobileY - */ - mobileYMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for mobileY - */ - mobileYMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), /** * Sort direction (default: desc for dates/numbers/counts, asc for strings) */ @@ -82,64 +38,7 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.card.listRecords', { /** * Field to sort by (default: time_us) */ - sort: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.string< - | 'cardType' - | 'color' - | 'h' - | 'mobileH' - | 'mobileW' - | 'mobileX' - | 'mobileY' - | 'page' - | 'updatedAt' - | 'version' - | 'w' - | 'x' - | 'y' - | (string & {}) - >() - ), - /** - * Maximum value for updatedAt - */ - updatedAtMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for updatedAt - */ - updatedAtMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for version - */ - versionMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for version - */ - versionMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for w - */ - wMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for w - */ - wMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for x - */ - xMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for x - */ - xMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Maximum value for y - */ - yMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Minimum value for y - */ - yMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()) + sort: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<'cardType' | 'page' | (string & {})>()) }), output: { type: 'lex', @@ -158,25 +57,25 @@ const _profileEntrySchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal('app.blento.card.listRecords#profileEntry') ), - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), did: /*#__PURE__*/ v.didString(), handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - record: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()) + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + value: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()) }); const _recordSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal('app.blento.card.listRecords#record')), - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.cidString(), collection: /*#__PURE__*/ v.nsidString(), did: /*#__PURE__*/ v.didString(), - get record() { - return /*#__PURE__*/ v.optional(AppBlentoCard.mainSchema); - }, rkey: /*#__PURE__*/ v.string(), time_us: /*#__PURE__*/ v.integer(), - uri: /*#__PURE__*/ v.resourceUriString() + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return AppBlentoCard.mainSchema; + } }); type main$schematype = typeof _mainSchema; diff --git a/src/lexicon-types/types/app/blento/getProfile.ts b/src/lexicon-types/types/app/blento/getProfile.ts index cded05a..8a06969 100644 --- a/src/lexicon-types/types/app/blento/getProfile.ts +++ b/src/lexicon-types/types/app/blento/getProfile.ts @@ -20,13 +20,13 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.getProfile', { }); const _profileEntrySchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal('app.blento.getProfile#profileEntry')), - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), did: /*#__PURE__*/ v.didString(), handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - record: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()) + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + value: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()) }); type main$schematype = typeof _mainSchema; diff --git a/src/lexicon-types/types/app/blento/page.ts b/src/lexicon-types/types/app/blento/page.ts index 70f78e0..4e5db4c 100644 --- a/src/lexicon-types/types/app/blento/page.ts +++ b/src/lexicon-types/types/app/blento/page.ts @@ -10,7 +10,9 @@ const _mainSchema = /*#__PURE__*/ v.record( /** * @accept image/* */ - icon: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), + icon: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [/*#__PURE__*/ v.blobAccept(['image/*'])]) + ), name: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), get preferences() { return /*#__PURE__*/ v.optional(preferencesSchema); diff --git a/src/lexicon-types/types/app/blento/page/getRecord.ts b/src/lexicon-types/types/app/blento/page/getRecord.ts index 4f23ec9..bfab65e 100644 --- a/src/lexicon-types/types/app/blento/page/getRecord.ts +++ b/src/lexicon-types/types/app/blento/page/getRecord.ts @@ -17,18 +17,18 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.page.getRecord', { output: { type: 'lex', schema: /*#__PURE__*/ v.object({ - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), collection: /*#__PURE__*/ v.nsidString(), did: /*#__PURE__*/ v.didString(), get profiles() { return /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(profileEntrySchema)); }, - get record() { - return /*#__PURE__*/ v.optional(AppBlentoPage.mainSchema); - }, rkey: /*#__PURE__*/ v.string(), time_us: /*#__PURE__*/ v.integer(), - uri: /*#__PURE__*/ v.resourceUriString() + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return AppBlentoPage.mainSchema; + } }) } }); @@ -36,13 +36,13 @@ const _profileEntrySchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal('app.blento.page.getRecord#profileEntry') ), - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), did: /*#__PURE__*/ v.didString(), handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - record: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()) + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + value: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()) }); type main$schematype = typeof _mainSchema; diff --git a/src/lexicon-types/types/app/blento/page/listRecords.ts b/src/lexicon-types/types/app/blento/page/listRecords.ts index 643a284..e0ff3b0 100644 --- a/src/lexicon-types/types/app/blento/page/listRecords.ts +++ b/src/lexicon-types/types/app/blento/page/listRecords.ts @@ -10,10 +10,6 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.page.listRecords', { */ actor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Filter by description - */ - description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), /** * @minimum 1 * @maximum 200 @@ -23,22 +19,10 @@ const _mainSchema = /*#__PURE__*/ v.query('app.blento.page.listRecords', { /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [/*#__PURE__*/ v.integerRange(1, 200)]), 50 ), - /** - * Filter by name - */ - name: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * Sort direction (default: desc for dates/numbers/counts, asc for strings) - */ - order: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<'asc' | 'desc' | (string & {})>()), /** * Include profile + identity info keyed by DID */ - profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), - /** - * Field to sort by (default: time_us) - */ - sort: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<'description' | 'name' | (string & {})>()) + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()) }), output: { type: 'lex', @@ -57,25 +41,25 @@ const _profileEntrySchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal('app.blento.page.listRecords#profileEntry') ), - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), did: /*#__PURE__*/ v.didString(), handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - record: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()) + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + value: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()) }); const _recordSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal('app.blento.page.listRecords#record')), - cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cid: /*#__PURE__*/ v.cidString(), collection: /*#__PURE__*/ v.nsidString(), did: /*#__PURE__*/ v.didString(), - get record() { - return /*#__PURE__*/ v.optional(AppBlentoPage.mainSchema); - }, rkey: /*#__PURE__*/ v.string(), time_us: /*#__PURE__*/ v.integer(), - uri: /*#__PURE__*/ v.resourceUriString() + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return AppBlentoPage.mainSchema; + } }); type main$schematype = typeof _mainSchema; diff --git a/src/lexicon-types/types/app/blento/section.ts b/src/lexicon-types/types/app/blento/section.ts new file mode 100644 index 0000000..17ee2e1 --- /dev/null +++ b/src/lexicon-types/types/app/blento/section.ts @@ -0,0 +1,31 @@ +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal('app.blento.section'), + index: /*#__PURE__*/ v.integer(), + name: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + page: /*#__PURE__*/ v.string(), + sectionData: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), + sectionType: /*#__PURE__*/ v.string(), + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + version: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()) + }) +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface Main extends v.InferInput {} + +declare module '@atcute/lexicons/ambient' { + interface Records { + 'app.blento.section': mainSchema; + } +} diff --git a/src/lexicon-types/types/app/blento/section/getRecord.ts b/src/lexicon-types/types/app/blento/section/getRecord.ts new file mode 100644 index 0000000..0574249 --- /dev/null +++ b/src/lexicon-types/types/app/blento/section/getRecord.ts @@ -0,0 +1,66 @@ +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; +import * as AppBlentoSection from '../section.js'; + +const _mainSchema = /*#__PURE__*/ v.query('app.blento.section.getRecord', { + params: /*#__PURE__*/ v.object({ + /** + * Include profile + identity info keyed by DID + */ + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + /** + * AT URI of the record + */ + uri: /*#__PURE__*/ v.resourceUriString() + }), + output: { + type: 'lex', + schema: /*#__PURE__*/ v.object({ + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + collection: /*#__PURE__*/ v.nsidString(), + did: /*#__PURE__*/ v.didString(), + get profiles() { + return /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(profileEntrySchema)); + }, + rkey: /*#__PURE__*/ v.string(), + time_us: /*#__PURE__*/ v.integer(), + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return AppBlentoSection.mainSchema; + } + }) + } +}); +const _profileEntrySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal('app.blento.section.getRecord#profileEntry') + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), + did: /*#__PURE__*/ v.didString(), + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + value: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()) +}); + +type main$schematype = typeof _mainSchema; +type profileEntry$schematype = typeof _profileEntrySchema; + +export interface mainSchema extends main$schematype {} +export interface profileEntrySchema extends profileEntry$schematype {} + +export const mainSchema = _mainSchema as mainSchema; +export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; + +export interface ProfileEntry extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module '@atcute/lexicons/ambient' { + interface XRPCQueries { + 'app.blento.section.getRecord': mainSchema; + } +} diff --git a/src/lexicon-types/types/app/blento/section/listRecords.ts b/src/lexicon-types/types/app/blento/section/listRecords.ts new file mode 100644 index 0000000..5d377c0 --- /dev/null +++ b/src/lexicon-types/types/app/blento/section/listRecords.ts @@ -0,0 +1,103 @@ +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; +import * as AppBlentoSection from '../section.js'; + +const _mainSchema = /*#__PURE__*/ v.query('app.blento.section.listRecords', { + params: /*#__PURE__*/ v.object({ + /** + * Filter by DID or handle (triggers on-demand backfill) + */ + actor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * @minimum 1 + * @maximum 200 + * @default 50 + */ + limit: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [/*#__PURE__*/ v.integerRange(1, 200)]), + 50 + ), + /** + * Sort direction (default: desc for dates/numbers/counts, asc for strings) + */ + order: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<'asc' | 'desc' | (string & {})>()), + /** + * Filter by page + */ + page: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * Include profile + identity info keyed by DID + */ + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + /** + * Filter by sectionType + */ + sectionType: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * Field to sort by (default: time_us) + */ + sort: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<'page' | 'sectionType' | (string & {})>()) + }), + output: { + type: 'lex', + schema: /*#__PURE__*/ v.object({ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + get profiles() { + return /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(profileEntrySchema)); + }, + get records() { + return /*#__PURE__*/ v.array(recordSchema); + } + }) + } +}); +const _profileEntrySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal('app.blento.section.listRecords#profileEntry') + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), + did: /*#__PURE__*/ v.didString(), + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + value: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()) +}); +const _recordSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal('app.blento.section.listRecords#record')), + cid: /*#__PURE__*/ v.cidString(), + collection: /*#__PURE__*/ v.nsidString(), + did: /*#__PURE__*/ v.didString(), + rkey: /*#__PURE__*/ v.string(), + time_us: /*#__PURE__*/ v.integer(), + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return AppBlentoSection.mainSchema; + } +}); + +type main$schematype = typeof _mainSchema; +type profileEntry$schematype = typeof _profileEntrySchema; +type record$schematype = typeof _recordSchema; + +export interface mainSchema extends main$schematype {} +export interface profileEntrySchema extends profileEntry$schematype {} +export interface recordSchema extends record$schematype {} + +export const mainSchema = _mainSchema as mainSchema; +export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; +export const recordSchema = _recordSchema as recordSchema; + +export interface ProfileEntry extends v.InferInput {} +export interface Record extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module '@atcute/lexicons/ambient' { + interface XRPCQueries { + 'app.blento.section.listRecords': mainSchema; + } +} diff --git a/src/lib/cards/special/UpdatedBlentos/index.ts b/src/lib/cards/special/UpdatedBlentos/index.ts index 0ba6b83..b0a5e53 100644 --- a/src/lib/cards/special/UpdatedBlentos/index.ts +++ b/src/lib/cards/special/UpdatedBlentos/index.ts @@ -19,7 +19,7 @@ function extractProfiles( handle?: string; collection?: string; rkey?: string; - record?: unknown; + value?: unknown; }> ): Map { const map = new Map(); @@ -35,27 +35,27 @@ function extractProfiles( existing.handle = p.handle as `${string}.${string}`; } - const record = p.record as Record | undefined; + const value = p.value as Record | undefined; - if (p.collection === 'app.bsky.actor.profile' && record) { - existing.displayName ??= record.displayName as string | undefined; - if (!existing.avatar && record.avatar) { + if (p.collection === 'app.bsky.actor.profile' && value) { + existing.displayName ??= value.displayName as string | undefined; + if (!existing.avatar && value.avatar) { const cdnUrl = getCDNImageBlobUrl({ did: p.did, - blob: record.avatar as { $type: 'blob'; ref: { $link: string } } + blob: value.avatar as { $type: 'blob'; ref: { $link: string } } }); if (cdnUrl) existing.avatar = cdnUrl; } } - if (p.collection === 'site.standard.publication' && record) { + if (p.collection === 'site.standard.publication' && value) { existing.hasBlento = true; - existing.displayName = (record.name as string) ?? existing.displayName; - existing.url = record.url as string | undefined; - if (record.icon) { + existing.displayName = (value.name as string) ?? existing.displayName; + existing.url = value.url as string | undefined; + if (value.icon) { const cdnUrl = getCDNImageBlobUrl({ did: p.did, - blob: record.icon as { $type: 'blob'; ref: { $link: string } } + blob: value.icon as { $type: 'blob'; ref: { $link: string } } }); if (cdnUrl) existing.avatar = cdnUrl; } diff --git a/src/lib/contrail/config.ts b/src/lib/contrail.config.ts similarity index 76% rename from src/lib/contrail/config.ts rename to src/lib/contrail.config.ts index 60034a5..c7dc246 100644 --- a/src/lib/contrail/config.ts +++ b/src/lib/contrail.config.ts @@ -3,16 +3,18 @@ import type { ContrailConfig } from '@atmo-dev/contrail'; export const config: ContrailConfig = { namespace: 'app.blento', collections: { - 'app.blento.card': { + card: { + collection: 'app.blento.card', queryable: { page: {}, cardType: {} } }, - 'app.blento.page': { - queryable: {} + page: { + collection: 'app.blento.page' }, - 'app.blento.section': { + section: { + collection: 'app.blento.section', queryable: { page: {}, sectionType: {} diff --git a/src/lib/contrail/index.ts b/src/lib/contrail/index.ts index bb7ba5b..93b210a 100644 --- a/src/lib/contrail/index.ts +++ b/src/lib/contrail/index.ts @@ -1,8 +1,7 @@ import type { D1Database } from '@cloudflare/workers-types'; import { Contrail } from '@atmo-dev/contrail'; -import { createHandler } from '@atmo-dev/contrail/server'; -import { Client } from '@atcute/client'; -import { config } from './config'; +import { createHandler, createServerClient } from '@atmo-dev/contrail/server'; +import { config } from '../contrail.config'; export const contrail = new Contrail(config); @@ -19,14 +18,11 @@ const handle = createHandler(contrail); /** * Server-side: fully typed @atcute/client that routes through contrail in-process. - * No HTTP roundtrip — calls createHandler directly. + * No HTTP roundtrip — calls the handler directly with the per-request DB. */ export function getServerClient(db: D1Database) { - return new Client({ - handler: async (pathname, init) => { - await ensureInit(db); - const url = new URL(pathname, 'http://localhost'); - return handle(new Request(url, init), db) as Promise; - } + return createServerClient(async (req) => { + await ensureInit(db); + return handle(req, db); }); } diff --git a/src/lib/website/load.ts b/src/lib/website/load.ts index 7f318f0..91febde 100644 --- a/src/lib/website/load.ts +++ b/src/lib/website/load.ts @@ -40,7 +40,7 @@ type ContrailProfile = { handle?: string; collection?: string; rkey?: string; - record?: unknown; + value?: unknown; }; /** @@ -63,15 +63,15 @@ function extractProfileData( if (p.did !== did) continue; if (p.handle && p.handle !== 'handle.invalid') handle = p.handle; - const record = p.record as Record | undefined; - if (p.collection === 'app.bsky.actor.profile' && record) { - bskyRecord = record; + const value = p.value as Record | undefined; + if (p.collection === 'app.bsky.actor.profile' && value) { + bskyRecord = value; } - if (p.collection === 'site.standard.publication' && record) { - pubRecord = record; + if (p.collection === 'site.standard.publication' && value) { + pubRecord = value; } - if (p.collection === 'app.nearhorizon.actor.pronouns' && record) { - pronounsValue = record; + if (p.collection === 'app.nearhorizon.actor.pronouns' && value) { + pronounsValue = value; } } @@ -128,7 +128,7 @@ function loadCardFromContrail(did: Did, rkey: string, db: D1Database) { params: { uri } }); if (!res.ok) return null; - return { ...(res.data.record as object) } as Item; + return { ...(res.data.value as object) } as Item; }); } @@ -154,22 +154,22 @@ function loadFromContrail(actor: ActorIdentifier, db: D1Database, page: string) if (!cardRes.ok) return null; - const cards = cardRes.data.records.map((r) => ({ ...(r.record as object) }) as Item); + const cards = cardRes.data.records.map((r) => ({ ...(r.value as object) }) as Item); const pages = pageRes.ok ? pageRes.data.records - .filter((r) => r.record) + .filter((r) => r.value) .map((r) => ({ uri: r.uri, cid: r.cid ?? '', - value: r.record as Record + value: r.value as Record })) : []; const sections = sectionRes?.ok && sectionRes.data?.records ? (sectionRes.data.records as any[]).map( - (r: any) => ({ ...(r.record as object), id: parseUri(r.uri)?.rkey }) as SectionRecord + (r: any) => ({ ...(r.value as object), id: parseUri(r.uri)?.rkey }) as SectionRecord ) : []; diff --git a/wrangler.jsonc b/wrangler.jsonc index a882e82..773246a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -59,8 +59,8 @@ "d1_databases": [ { "binding": "DB", - "database_name": "blento", - "database_id": "922639e7-6321-42c8-a4bd-cdf48428fdac", + "database_name": "blento-2", + "database_id": "5ccd30a3-c8d8-446a-b675-6a96a8f08263", "remote": true } ], -- 2.51.2 From 2e97867bb72e80fee4d3693bb98e1c6500648ffb Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 4 May 2026 19:50:22 +0200 Subject: [PATCH 04/17] update contributing --- docs/Contributing.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/Contributing.md b/docs/Contributing.md index 6cd5b6c..eca8834 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -9,16 +9,12 @@ git clone https://github.com/flo-bit/blento.git cd blento pnpm install pnpm env:setup-dev # creates .env, fills COOKIE_SECRET + CLIENT_ASSERTION_KEY +pnpm dev ``` -In `wrangler.jsonc`, flip the `DB` binding's `"remote": true` to `false` if not already set to false (don't commit that). Otherwise `pnpm dev` and `pnpm backfill` write to production and need cloudflare credentials. +Note: if cloudflare authorization website opens when running `pnpm dev` flip the `DB` binding's `"remote": true` to `false` in `wrangler.jsonc` and re-run. -```sh -pnpm dev # site falls back to PDS when D1 is empty — no backfill needed -pnpm backfill # populates local D1 via contrail; needed only for /xrpc/* paths and the UpdatedBlentos card -``` - -`pnpm backfill` is resumable, takes a few minutes the first time. +Individual `/handle` pages load directly from each user's PDS — no backfill needed. ## Before opening a PR -- 2.51.2 From c0bd7e985abaa97aad237a49ec3b97747bdaf7cd Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 4 May 2026 19:50:47 +0200 Subject: [PATCH 05/17] switch remote off --- wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 773246a..d19604d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -61,7 +61,7 @@ "binding": "DB", "database_name": "blento-2", "database_id": "5ccd30a3-c8d8-446a-b675-6a96a8f08263", - "remote": true + "remote": false } ], "analytics_engine_datasets": [ -- 2.51.2 From 31a1bda606ad197ebde1a694f90af0b88a8f09aa Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 4 May 2026 20:18:14 +0200 Subject: [PATCH 06/17] update db --- wrangler.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index d19604d..9670f28 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -59,8 +59,8 @@ "d1_databases": [ { "binding": "DB", - "database_name": "blento-2", - "database_id": "5ccd30a3-c8d8-446a-b675-6a96a8f08263", + "database_name": "blento-v3", + "database_id": "83e7be08-8503-4b54-9f73-0bac105761b5", "remote": false } ], -- 2.51.2 From 18a3d63c19311ad7c8034bc05923be81f17792d8 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 4 May 2026 20:31:45 +0200 Subject: [PATCH 07/17] small fixes --- docs/Contributing.md | 5 +++-- src/lib/actor.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/Contributing.md b/docs/Contributing.md index eca8834..e2b852d 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -8,10 +8,11 @@ For new cards see [CustomCards](CustomCards.md) and [CardIdeas](CardIdeas.md). git clone https://github.com/flo-bit/blento.git cd blento pnpm install -pnpm env:setup-dev # creates .env, fills COOKIE_SECRET + CLIENT_ASSERTION_KEY pnpm dev ``` +No `.env` file required — dev uses the loopback OAuth public client, a fallback cookie secret, and `blento.app` as the default handle for `/`. + Note: if cloudflare authorization website opens when running `pnpm dev` flip the `DB` binding's `"remote": true` to `false` in `wrangler.jsonc` and re-run. Individual `/handle` pages load directly from each user's PDS — no backfill needed. @@ -27,7 +28,7 @@ In-progress changes go on a subpage so your live profile stays clean: `/your.han ## AI-assisted PRs -Welcome — please: +AI-assisted PRs are accepted, especially if you just create a new card, but please: - Keep diffs minimal; no unrelated cleanup or verbose code - Test light/dark, colored cards, edit/view, desktop and both mobile modes (screen-size and `pointer: coarse`) diff --git a/src/lib/actor.ts b/src/lib/actor.ts index 6b662ac..72f605a 100644 --- a/src/lib/actor.ts +++ b/src/lib/actor.ts @@ -30,7 +30,7 @@ export async function getActor({ console.error('failed to get custom domain kv', error); } } else { - actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier; + actor = (publicEnv.PUBLIC_HANDLE || 'blento.app') as ActorIdentifier; } } else if (customDomain && paramActor && blockBoth) { actor = undefined; -- 2.51.2 From 9e87b05527a851b35cdfb3ea46798517da324ff7 Mon Sep 17 00:00:00 2001 From: polijn Date: Mon, 4 May 2026 20:34:19 +0200 Subject: [PATCH 08/17] soundcloud card --- src/lib/cards/index.ts | 2 + .../CreateSoundCloudCardModal.svelte | 47 +++++++++++++++++ .../SoundCloudCard/SoundCloudCard.svelte | 31 +++++++++++ src/lib/cards/media/SoundCloudCard/index.ts | 51 +++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 src/lib/cards/media/SoundCloudCard/CreateSoundCloudCardModal.svelte create mode 100644 src/lib/cards/media/SoundCloudCard/SoundCloudCard.svelte create mode 100644 src/lib/cards/media/SoundCloudCard/index.ts diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 0e20704..15d875c 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -37,6 +37,7 @@ import { ClockCardDefinition } from './utilities/ClockCard'; import { CountdownCardDefinition } from './utilities/CountdownCard'; import { SpotifyCardDefinition } from './media/SpotifyCard'; import { AppleMusicCardDefinition } from './media/AppleMusicCard'; +import { SoundCloudCardDefinition } from './media/SoundCloudCard'; import { ButtonCardDefinition } from './utilities/ButtonCard'; import { GuestbookCardDefinition } from './social/GuestbookCard'; import { FriendsCardDefinition } from './social/FriendsCard'; @@ -105,6 +106,7 @@ export const AllCardDefinitions = [ CountdownCardDefinition, SpotifyCardDefinition, AppleMusicCardDefinition, + SoundCloudCardDefinition, // Model3DCardDefinition FriendsCardDefinition, GitHubContributorsCardDefinition, diff --git a/src/lib/cards/media/SoundCloudCard/CreateSoundCloudCardModal.svelte b/src/lib/cards/media/SoundCloudCard/CreateSoundCloudCardModal.svelte new file mode 100644 index 0000000..2bdb323 --- /dev/null +++ b/src/lib/cards/media/SoundCloudCard/CreateSoundCloudCardModal.svelte @@ -0,0 +1,47 @@ + + + + Enter a SoundCloud track, playlist, or profile URL + { + if (e.key === 'Enter' && checkUrl()) oncreate(); + }} + /> + + {#if errorMessage} + {errorMessage} + {/if} + +
+ + +
+
diff --git a/src/lib/cards/media/SoundCloudCard/SoundCloudCard.svelte b/src/lib/cards/media/SoundCloudCard/SoundCloudCard.svelte new file mode 100644 index 0000000..7a399fd --- /dev/null +++ b/src/lib/cards/media/SoundCloudCard/SoundCloudCard.svelte @@ -0,0 +1,31 @@ + + +{#if src} +
+ +
+{:else} +
+ Missing SoundCloud URL +
+{/if} diff --git a/src/lib/cards/media/SoundCloudCard/index.ts b/src/lib/cards/media/SoundCloudCard/index.ts new file mode 100644 index 0000000..fd5796a --- /dev/null +++ b/src/lib/cards/media/SoundCloudCard/index.ts @@ -0,0 +1,51 @@ +import type { CardDefinition } from '../../types'; +import CreateSoundCloudCardModal from './CreateSoundCloudCardModal.svelte'; +import SoundCloudCard from './SoundCloudCard.svelte'; + +const cardType = 'soundcloud-embed'; + +export const SoundCloudCardDefinition = { + type: cardType, + contentComponent: SoundCloudCard, + creationModalComponent: CreateSoundCloudCardModal, + createNew: (item) => { + item.cardType = cardType; + item.cardData = {}; + item.w = 4; + item.mobileW = 8; + item.h = 5; + item.mobileH = 10; + }, + + onUrlHandler: (url, item) => { + if (!matchSoundCloudUrl(url)) return null; + + item.cardData.href = url; + + item.w = 4; + item.mobileW = 8; + item.h = 5; + item.mobileH = 10; + + return item; + }, + + urlHandlerPriority: 2, + + canChange: (item) => matchSoundCloudUrl(item.cardData?.href), + change: (item) => item, + + name: 'SoundCloud Embed', + canResize: true, + minW: 4, + minH: 4, + + keywords: ['music', 'song', 'playlist', 'track', 'soundcloud', 'audio'], + groups: ['Media'], + icon: `` +} as CardDefinition & { type: typeof cardType }; + +function matchSoundCloudUrl(url: string | undefined): boolean { + if (!url) return false; + return /^https?:\/\/(www\.)?soundcloud\.com\/[\w-]+(\/[\w-]+)*\/?(\?.*)?$/.test(url); +} -- 2.51.2 From 4b0519198e86df82e6f6232879822f661f956b20 Mon Sep 17 00:00:00 2001 From: polijn Date: Mon, 4 May 2026 20:42:07 +0200 Subject: [PATCH 09/17] fix secret image in edit --- .../EditingSecretImageCard.svelte | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte b/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte index 42d2cb6..bed6357 100644 --- a/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte +++ b/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte @@ -1,15 +1,56 @@ + + + Login · blento + + + +
+
+ + ← Back to blento + + +
+ { + await login(handle as ActorIdentifier); + return true; + }} + signup={async () => { + await signup(); + return true; + }} + /> +
+
+ + +
diff --git a/src/routes/(auth)/oauth/callback/+server.ts b/src/routes/(auth)/oauth/callback/+server.ts index 0f31252..b048bde 100644 --- a/src/routes/(auth)/oauth/callback/+server.ts +++ b/src/routes/(auth)/oauth/callback/+server.ts @@ -4,6 +4,7 @@ import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; import { scopes } from '$lib/atproto/server/scopes'; import { getDetailedProfile } from '$lib/atproto/methods'; import { dev } from '$app/environment'; +import { env as publicEnv } from '$env/dynamic/public'; import type { Did } from '@atcute/lexicons'; import type { RequestHandler } from './$types'; @@ -33,5 +34,6 @@ export const GET: RequestHandler = async ({ url, platform, cookies, request }) = const profile = await getDetailedProfile({ did }).catch(() => undefined); const actor = profile?.handle && profile.handle !== 'handle.invalid' ? profile.handle : did; - redirect(303, `/${actor}/edit`); + const canonical = publicEnv.PUBLIC_DOMAIN || 'https://blento.app'; + redirect(303, customDomain ? `${canonical}/${actor}/edit` : `/${actor}/edit`); }; diff --git a/src/routes/[[actor=actor]]/(pages)/+layout.server.ts b/src/routes/[[actor=actor]]/(pages)/+layout.server.ts index 1ef6ed9..34467eb 100644 --- a/src/routes/[[actor=actor]]/(pages)/+layout.server.ts +++ b/src/routes/[[actor=actor]]/(pages)/+layout.server.ts @@ -1,6 +1,7 @@ import { loadData } from '$lib/website/load'; import { env } from '$env/dynamic/private'; -import { error } from '@sveltejs/kit'; +import { env as publicEnv } from '$env/dynamic/public'; +import { error, redirect } from '@sveltejs/kit'; import { createCache } from '$lib/cache'; import { getActor } from '$lib/actor.js'; import { logPageview } from '$lib/analytics'; @@ -18,9 +19,16 @@ export async function load({ params, platform, request, locals, route, setHeader const data = await loadData(actor, cache, params.page, env, platform); - const isInteractiveRoute = route.id?.endsWith('/edit') || route.id?.endsWith('/copy') || false; + const isEditRoute = route.id?.endsWith('/edit') || false; + const isInteractiveRoute = isEditRoute || route.id?.endsWith('/copy') || false; const isAnonymous = !locals.did; + if (isEditRoute && locals.did !== data.did) { + const customDomain = request.headers.get('X-Custom-Domain'); + const canonical = publicEnv.PUBLIC_DOMAIN || 'https://blento.app'; + redirect(303, customDomain ? `${canonical}/login` : '/login'); + } + if (isAnonymous && !isInteractiveRoute) { setHeaders({ 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=600' -- 2.51.2 From 02dac70c2b4f9c1afe2db7f4fef69c444df3bdb0 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 4 May 2026 22:44:57 +0200 Subject: [PATCH 11/17] add v0 --- docs/embed-sdk/v0.md | 272 ++++++++++++++++++++++++++ src/lib/embed/AtmoEmbed.svelte | 234 ++++++++++++++++++++++ src/lib/embed/allowlist.ts | 43 ++++ src/lib/embed/embed.remote.ts | 266 +++++++++++++++++++++++++ src/routes/embed-test/+page.server.ts | 7 + src/routes/embed-test/+page.svelte | 39 ++++ static/embed/v0/sdk.js | 229 ++++++++++++++++++++++ static/embed/v0/test.html | 257 ++++++++++++++++++++++++ 8 files changed, 1347 insertions(+) create mode 100644 docs/embed-sdk/v0.md create mode 100644 src/lib/embed/AtmoEmbed.svelte create mode 100644 src/lib/embed/allowlist.ts create mode 100644 src/lib/embed/embed.remote.ts create mode 100644 src/routes/embed-test/+page.server.ts create mode 100644 src/routes/embed-test/+page.svelte create mode 100644 static/embed/v0/sdk.js create mode 100644 static/embed/v0/test.html 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: … +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + + + +
+ +
+ + + +
+ +
+ + +
+ +
+ +
—
+
+ + + + + -- 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 @@ - -
{ - if (await validateAndCreate()) oncreate(); - }} - class="flex flex-col gap-2" - > - Enter an event URL - - - {#if errorMessage} - {errorMessage} + +
+
+ Choose an event or create a new one +
+ + + + + + + +
+
+ + {#if searchOpen} + {/if} -

- Paste an AT URI for a calendar event or a smokesignal.events URL. -

+ {#if isLoading} +

Loading your events...

+ {:else if errorMessage} + {errorMessage} + {:else if events.length === 0} + + You haven't created any events yet. Create one on atmo.rsvp first (it might take a few + minutes to show up). + + {:else if filteredEvents.length === 0} +

No events match your search.

+ {:else} +
+ {#each filteredEvents as event (event.uri)} + + {/each} +
+ {/if} -
+
-
- +
-- 2.51.2 From 8352581c368e19815c2b18220cd944ef4a4c803e Mon Sep 17 00:00:00 2001 From: polijn Date: Wed, 6 May 2026 07:12:11 +0200 Subject: [PATCH 16/17] event card fix --- src/lib/cards/social/EventCard/index.ts | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/lib/cards/social/EventCard/index.ts b/src/lib/cards/social/EventCard/index.ts index caba18d..f0a7a73 100644 --- a/src/lib/cards/social/EventCard/index.ts +++ b/src/lib/cards/social/EventCard/index.ts @@ -1,3 +1,6 @@ +import { parseUri } from '$lib/atproto'; +import { listRecords } from '$lib/atproto/methods'; +import type { Did } from '@atcute/lexicons'; import type { CardDefinition } from '../../types'; import CreateEventCardModal from './CreateEventCardModal.svelte'; import EventCard from './EventCard.svelte'; @@ -56,6 +59,47 @@ export const EventCardDefinition = { card.mobileH = 6; }, + loadData: async (items) => { + const eventDataMap: Record = {}; + + // Group items by repo so we can fetch each repo's events in one listRecords call. + const itemsByRepo = new Map(); + for (const item of items) { + const uri = item.cardData?.uri; + if (!uri) continue; + const parsed = parseUri(uri); + if (!parsed?.repo || !parsed.rkey) continue; + const list = itemsByRepo.get(parsed.repo) ?? []; + list.push({ item, rkey: parsed.rkey }); + itemsByRepo.set(parsed.repo, list); + } + + await Promise.all( + Array.from(itemsByRepo.entries()).map(async ([repo, entries]) => { + try { + const records = await listRecords({ + did: repo as Did, + collection: EVENT_COLLECTION, + limit: 100 + }); + const byRkey = new Map(); + for (const record of records) { + const rkey = (record.uri as string).split('/').pop(); + if (rkey) byRkey.set(rkey, record.value as EventData); + } + for (const { item, rkey } of entries) { + const value = byRkey.get(rkey); + if (value) eventDataMap[item.id] = value; + } + } catch (error) { + console.error('Failed to fetch events for', repo, error); + } + }) + ); + + return eventDataMap; + }, + onUrlHandler: (url, item) => { // Match atmo.rsvp URLs: https://atmo.rsvp/p/{didOrHandle}/e/{rkey} const atmoMatch = url.match(/^https?:\/\/atmo\.rsvp\/p\/([^/]+)\/e\/([^/?#]+)/); -- 2.51.2 From f9d6cc960cd708e70d5200496ef472cec445d821 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 6 May 2026 13:18:25 +0200 Subject: [PATCH 17/17] add rpg actor card --- src/lib/cards/index.ts | 4 +- .../social/RPGActorCard/RPGActorCard.svelte | 152 ++++++++++++++++++ src/lib/cards/social/RPGActorCard/index.ts | 68 ++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/lib/cards/social/RPGActorCard/RPGActorCard.svelte create mode 100644 src/lib/cards/social/RPGActorCard/index.ts diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 15d875c..6be995c 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -63,6 +63,7 @@ import { KichRecipeCardDefinition } from './social/KichRecipeCard'; import { KichRecipeCollectionCardDefinition } from './social/KichRecipeCollectionCard'; import { KichCookingLogCardDefinition } from './social/KichCookingLogCard'; import { SecretImageCardDefinition } from './media/SecretImageCard'; +import { RPGActorCardDefinition } from './social/RPGActorCard'; // import { Model3DCardDefinition } from './visual/Model3DCard'; export const AllCardDefinitions = [ @@ -131,7 +132,8 @@ export const AllCardDefinitions = [ KichRecipeCardDefinition, KichRecipeCollectionCardDefinition, KichCookingLogCardDefinition, - SecretImageCardDefinition + SecretImageCardDefinition, + RPGActorCardDefinition ] as const; export const CardDefinitionsByType = AllCardDefinitions.reduce( diff --git a/src/lib/cards/social/RPGActorCard/RPGActorCard.svelte b/src/lib/cards/social/RPGActorCard/RPGActorCard.svelte new file mode 100644 index 0000000..8d6e4a8 --- /dev/null +++ b/src/lib/cards/social/RPGActorCard/RPGActorCard.svelte @@ -0,0 +1,152 @@ + + +
+ {#if loaded && actor} + + {:else if loaded && !actor && isEditing} + + {:else if loaded && !actor} +
+ This person hasn't created a character yet +
+ {/if} +
diff --git a/src/lib/cards/social/RPGActorCard/index.ts b/src/lib/cards/social/RPGActorCard/index.ts new file mode 100644 index 0000000..07b8393 --- /dev/null +++ b/src/lib/cards/social/RPGActorCard/index.ts @@ -0,0 +1,68 @@ +import type { CardDefinition } from '../../types'; +import { getRecord, getBlobURL } from '$lib/atproto'; +import RPGActorCard from './RPGActorCard.svelte'; +import type { Did } from '@atcute/lexicons'; + +export type RpgSpriteRecord = { + rows: number; + columns: number; + frames: number; + width: number; + height: number; + frameWidth: number; + frameHeight: number; + isCustom?: boolean; + createdAt?: string; + spriteSheet: { + $type: 'blob'; + ref: { $link: string }; + mimeType: string; + size: number; + }; +}; + +export type RpgActorData = { + sprite: RpgSpriteRecord; + url: string; +} | null; + +export const RPGActorCardDefinition = { + type: 'rpgActor', + contentComponent: RPGActorCard, + + createNew: (item) => { + item.w = 4; + item.h = 2; + item.mobileW = 8; + item.mobileH = 2; + }, + + loadData: async (_items, { did }) => { + try { + const record = await getRecord({ + did: did as Did, + collection: 'actor.rpg.sprite', + rkey: 'self' + }); + const value = record?.value as RpgSpriteRecord | undefined; + if (!value?.spriteSheet) return null; + const url = await getBlobURL({ + did: did as Did, + blob: value.spriteSheet + }); + return { sprite: value, url } satisfies RpgActorData; + } catch { + return null; + } + }, + cacheLoadData: true, + + minW: 2, + minH: 1, + + name: 'RPG Character', + keywords: ['rpg', 'sprite', 'character', 'actor', 'avatar', 'pixel', 'game'], + groups: ['Social'], + canHaveLabel: true, + icon: `` +} as CardDefinition & { type: 'rpgActor' }; -- 2.51.2