···11-import type { Did, Handle } from '@atcute/lexicons';
11+import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons';
22import { user } from './auth.svelte';
33+import type { AllowedCollection } from './settings';
34import {
45 CompositeDidDocumentResolver,
56 CompositeHandleResolver,
···910 WellKnownHandleResolver
1011} from '@atcute/identity-resolver';
1112import { Client, simpleFetchHandler } from '@atcute/client';
1212-import type { AppBskyActorDefs } from '@atcute/bluesky';
1313-import { redirect } from '@sveltejs/kit';
1313+import { type AppBskyActorDefs } from '@atcute/bluesky';
14141515export type Collection = `${string}.${string}.${string}`;
1616+import * as TID from '@atcute/tid';
16171818+/**
1919+ * Parses an AT Protocol URI into its components.
2020+ * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
2121+ * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri
2222+ */
1723export function parseUri(uri: string) {
1818- const [did, collection, rkey] = uri.replace('at://', '').split('/');
1919- return { did, collection, rkey } as {
2020- collection: `${string}.${string}.${string}`;
2121- rkey: string;
2222- did: Did;
2323- };
2424+ const parts = parseResourceUri(uri);
2525+ if (!parts.ok) return;
2626+ return parts.value;
2427}
25282929+/**
3030+ * Resolves a handle to a DID using DNS and HTTP methods.
3131+ * @param handle - The handle to resolve (e.g., "alice.bsky.social")
3232+ * @returns The DID associated with the handle
3333+ */
2634export async function resolveHandle({ handle }: { handle: Handle }) {
2735 const handleResolver = new CompositeHandleResolver({
2836 methods: {
···3139 }
3240 });
33413434- try {
3535- const data = await handleResolver.resolve(handle);
3636- return data;
3737- } catch (error) {
3838- redirect(307, '/?error=handle_not_found&handle=' + handle);
3939- }
4242+ const data = await handleResolver.resolve(handle);
4343+ return data;
4044}
41454246const didResolver = new CompositeDidDocumentResolver({
···4650 }
4751});
48525353+/**
5454+ * Gets the PDS (Personal Data Server) URL for a given DID.
5555+ * @param did - The DID to look up
5656+ * @returns The PDS service endpoint URL
5757+ * @throws If no PDS is found in the DID document
5858+ */
4959export async function getPDS(did: Did) {
5050- const doc = await didResolver.resolve(did as `did:plc:${string}` | `did:web:${string}`);
6060+ const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>);
5161 if (!doc.service) throw new Error('No PDS found');
5262 for (const service of doc.service) {
5363 if (service.id === '#atproto_pds') {
···5666 }
5767}
58686969+/**
7070+ * Fetches a detailed Bluesky profile for a user.
7171+ * @param data - Optional object with did and client
7272+ * @param data.did - The DID to fetch the profile for (defaults to current user)
7373+ * @param data.client - The client to use (defaults to public Bluesky API)
7474+ * @returns The profile data or undefined if not found
7575+ */
5976export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
6077 data ??= {};
6178 data.did ??= user.did;
···7592 return response.data;
7693}
77947878-export async function getAuthorFeed(data?: {
7979- did?: Did;
8080- client?: Client;
8181- filter?: string;
8282- limit?: number;
8383-}) {
8484- data ??= {};
8585- data.did ??= user.did;
8686-8787- if (!data.did) throw new Error('Error getting detailed profile: no did');
8888-8989- data.client ??= new Client({
9090- handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
9191- });
9292-9393- const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
9494- params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
9595- });
9696-9797- if (!response.ok) return;
9898-9999- return response.data;
100100-}
101101-9595+/**
9696+ * Creates an AT Protocol client for a user's PDS.
9797+ * @param did - The DID of the user
9898+ * @returns A client configured for the user's PDS
9999+ * @throws If the PDS cannot be found
100100+ */
102101export async function getClient({ did }: { did: Did }) {
103102 const pds = await getPDS(did);
104103 if (!pds) throw new Error('PDS not found');
···110109 return client;
111110}
112111112112+/**
113113+ * Lists records from a repository collection with pagination support.
114114+ * @param did - The DID of the repository (defaults to current user)
115115+ * @param collection - The collection to list records from
116116+ * @param cursor - Pagination cursor for continuing from a previous request
117117+ * @param limit - Maximum number of records to return (default 100, set to 0 for all records)
118118+ * @param client - The client to use (defaults to user's PDS client)
119119+ * @returns An array of records from the collection
120120+ */
113121export async function listRecords({
114122 did,
115123 collection,
116124 cursor,
117117- limit = 0,
125125+ limit = 100,
118126 client
119127}: {
120128 did?: Did;
···141149 params: {
142150 repo: did,
143151 collection,
144144- limit: limit || 100,
152152+ limit: !limit || limit > 100 ? 100 : limit,
145153 cursor: currentCursor
146154 }
147155 });
···157165 return allRecords;
158166}
159167168168+/**
169169+ * Fetches a single record from a repository.
170170+ * @param did - The DID of the repository (defaults to current user)
171171+ * @param collection - The collection the record belongs to
172172+ * @param rkey - The record key (defaults to "self")
173173+ * @param client - The client to use (defaults to user's PDS client)
174174+ * @returns The record data
175175+ */
160176export async function getRecord({
161177 did,
162178 collection,
163163- rkey,
179179+ rkey = 'self',
164180 client
165181}: {
166182 did?: Did;
···169185 client?: Client;
170186}) {
171187 did ??= user.did;
172172- rkey ??= 'self';
173188174189 if (!collection) {
175190 throw new Error('Missing parameters for getRecord');
···188203 }
189204 });
190205191191- if (!record.ok) return;
192192-193206 return JSON.parse(JSON.stringify(record.data));
194207}
195208209209+/**
210210+ * Creates or updates a record in the current user's repository.
211211+ * Only accepts collections that are configured in permissions.
212212+ * @param collection - The collection to write to (must be in permissions.collections)
213213+ * @param rkey - The record key (defaults to "self")
214214+ * @param record - The record data to write
215215+ * @returns The response from the PDS
216216+ * @throws If the user is not logged in
217217+ */
196218export async function putRecord({
197219 collection,
198198- rkey,
220220+ rkey = 'self',
199221 record
200222}: {
201201- collection: Collection;
202202- rkey: string;
223223+ collection: AllowedCollection;
224224+ rkey?: string;
203225 record: Record<string, unknown>;
204226}) {
205227 if (!user.client || !user.did) throw new Error('No rpc or did');
···218240 return response;
219241}
220242221221-export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) {
243243+/**
244244+ * Deletes a record from the current user's repository.
245245+ * Only accepts collections that are configured in permissions.
246246+ * @param collection - The collection the record belongs to (must be in permissions.collections)
247247+ * @param rkey - The record key (defaults to "self")
248248+ * @returns True if the deletion was successful
249249+ * @throws If the user is not logged in
250250+ */
251251+export async function deleteRecord({
252252+ collection,
253253+ rkey = 'self'
254254+}: {
255255+ collection: AllowedCollection;
256256+ rkey: string;
257257+}) {
222258 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
223259224260 const response = await user.client.post('com.atproto.repo.deleteRecord', {
···232268 return response.ok;
233269}
234270271271+/**
272272+ * Uploads a blob to the current user's PDS.
273273+ * @param blob - The blob data to upload
274274+ * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
275275+ * @throws If the user is not logged in
276276+ */
235277export async function uploadBlob({ blob }: { blob: Blob }) {
236278 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
237279238280 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
239239- input: blob,
240240- data: {
281281+ params: {
241282 repo: user.did
242242- }
283283+ },
284284+ input: blob
243285 });
244286245245- if (!blobResponse?.ok) {
246246- return;
247247- }
287287+ if (!blobResponse?.ok) return;
248288249289 const blobInfo = blobResponse?.data.blob as {
250290 $type: 'blob';
···258298 return blobInfo;
259299}
260300301301+/**
302302+ * Gets metadata about a repository.
303303+ * @param client - The client to use
304304+ * @param did - The DID of the repository (defaults to current user)
305305+ * @returns Repository metadata or undefined on failure
306306+ */
261307export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
262308 did ??= user.did;
263309 if (!did) {
···275321 return repo.data;
276322}
277323324324+/**
325325+ * Constructs a URL to fetch a blob directly from a user's PDS.
326326+ * @param did - The DID of the user who owns the blob
327327+ * @param blob - The blob reference object
328328+ * @returns The URL to fetch the blob
329329+ */
278330export async function getBlobURL({
279331 did,
280332 blob
···291343 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
292344}
293345294294-export function getImageBlobUrl({
346346+/**
347347+ * Constructs a Bluesky CDN URL for an image blob.
348348+ * @param did - The DID of the user who owns the blob (defaults to current user)
349349+ * @param blob - The blob reference object
350350+ * @returns The CDN URL for the image in webp format
351351+ */
352352+export function getCDNImageBlobUrl({
295353 did,
296354 blob
297355}: {
298298- did: string;
356356+ did?: string;
299357 blob: {
300358 $type: 'blob';
301359 ref: {
···303361 };
304362 };
305363}) {
306306- if (!did || !blob?.ref?.$link) return '';
364364+ did ??= user.did;
365365+307366 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
308367}
309368369369+/**
370370+ * Searches for actors with typeahead/autocomplete functionality.
371371+ * @param q - The search query
372372+ * @param limit - Maximum number of results (default 10)
373373+ * @param host - The API host to use (defaults to public Bluesky API)
374374+ * @returns An object containing matching actors and the original query
375375+ */
310376export async function searchActorsTypeahead(
311377 q: string,
312378 limit: number = 10,
···329395330396 return { actors: response.data.actors, q };
331397}
398398+399399+/**
400400+ * Return a TID based on current time
401401+ *
402402+ * @returns TID for current time
403403+ */
404404+export function createTID() {
405405+ return TID.now();
406406+}
407407+408408+export async function getAuthorFeed(data?: {
409409+ did?: Did;
410410+ client?: Client;
411411+ filter?: string;
412412+ limit?: number;
413413+}) {
414414+ data ??= {};
415415+ data.did ??= user.did;
416416+417417+ if (!data.did) throw new Error('Error getting detailed profile: no did');
418418+419419+ data.client ??= new Client({
420420+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
421421+ });
422422+423423+ const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
424424+ params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
425425+ });
426426+427427+ if (!response.ok) return;
428428+429429+ return response.data;
430430+}
+43-16
src/lib/atproto/settings.ts
···11-export const SITE = 'https://blento.app';
11+export const SITE = 'https://flo-bit.dev';
22+33+type Permissions = {
44+ collections: readonly string[];
55+ rpc: Record<string, string | string[]>;
66+ blobs: readonly string[];
77+};
88+99+export const permissions = {
1010+ // collections you can create/delete/update
1111+1212+ // example: only allow create and delete
1313+ // collections: ['xyz.statusphere.status?action=create&action=update'],
1414+ collections: [
1515+ 'app.blento.card',
1616+ 'app.blento.page',
1717+ 'app.blento.settings',
1818+ 'app.blento.comment',
1919+ 'app.blento.guestbook.entry',
2020+ 'site.standard.publication',
2121+ 'site.standard.document',
2222+ 'xyz.statusphere.status'
2323+ ],
2424+2525+ // what types of authenticated proxied requests you can make to services
2626+2727+ // example: allow authenticated proxying to bsky appview to get a users liked posts
2828+ //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']}
2929+ rpc: {},
3030+3131+ // what types of blobs you can upload to a users PDS
23233-export const collections: string[] = [
44- 'app.blento.card',
55- 'app.blento.page',
66- 'app.blento.settings',
77- 'app.blento.comment',
88- 'app.blento.guestbook.entry',
99- 'site.standard.publication',
1010- 'site.standard.document',
1111- 'xyz.statusphere.status'
1212-];
3333+ // example: allowing video and html uploads
3434+ // blobs: ['video/*', 'text/html']
3535+ // example: allowing all blob types
3636+ // blobs: ['*/*']
3737+ blobs: ['*/*']
3838+} as const satisfies Permissions;
13391414-export const rpcCalls: Record<string, string | string[]> = {
1515- //'did:web:api.bsky.app#bsky_appview': ['app.bsky.actor.getProfile']
1616-};
4040+// Extract base collection name (before any query params)
4141+type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T;
17421818-export const blobs = ['*/*'] as string | string[] | undefined;
4343+export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>;
19442020-export const signUpPDS = 'https://pds.rip/';
4545+// which PDS to use for signup
4646+// ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week
4747+export const signUpPDS = 'https://selfhosted.social/';
+5-4
src/lib/cards/EventCard/EventCard.svelte
···88 import { parseUri } from '$lib/atproto';
99 import { browser } from '$app/environment';
1010 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte';
1111+ import type { Did } from '@atcute/lexicons';
11121213 let { item }: ContentComponentProps = $props();
1314···2728 let parsedUri = $derived(item.cardData?.uri ? parseUri(item.cardData.uri) : null);
28292930 onMount(async () => {
3030- if (!eventData && item.cardData?.uri) {
3131+ if (!eventData && item.cardData?.uri && parsedUri?.repo) {
3132 const loadedData = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
3232- did: parsedUri?.did ?? ('' as `did:${string}:${string}`),
3333+ did: parsedUri.repo as Did,
3334 handle: ''
3435 })) as Record<string, EventData> | undefined;
3536···9293 let eventUrl = $derived(() => {
9394 if (eventData?.url) return eventData.url;
9495 if (parsedUri) {
9595- return `https://smokesignal.events/${parsedUri.did}/${parsedUri.rkey}`;
9696+ return `https://smokesignal.events/${parsedUri.repo}/${parsedUri.rkey}`;
9697 }
9798 return '#';
9899 });
···104105 const header = eventData.media.find((m) => m.role === 'header');
105106 if (!header?.content?.ref?.$link) return null;
106107 return {
107107- url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.did}/${header.content.ref.$link}@jpeg`,
108108+ url: `https://cdn.bsky.app/img/feed_thumbnail/plain/${parsedUri.repo}/${header.content.ref.$link}@jpeg`,
108109 alt: header.alt || eventData.name
109110 };
110111 });