Highly ambitious ATProtocol AppView service and sdks

fix BlobRef, add helper to render blob to cdn url, bring in bsky profile to Slices frontend

Changed files
+620 -233
api
frontend
src
lexicons
app
bsky
actor
com
atproto
+39 -2
api/scripts/generate_typescript.ts
··· 487 isExported: true, 488 properties: [ 489 { name: "$type", type: "string" }, 490 - { name: "ref", type: "string" }, 491 { name: "mimeType", type: "string" }, 492 { name: "size", type: "number" }, 493 ], ··· 1134 `return await response.json() as T;`, 1135 ], 1136 }, 1137 ], 1138 }); 1139 } ··· 1620 1621 const finalCode = await formatCode(unformattedCode); 1622 1623 // Output to stdout for the Rust handler to capture 1624 - Deno.stdout.writeSync(new TextEncoder().encode(finalCode));
··· 487 isExported: true, 488 properties: [ 489 { name: "$type", type: "string" }, 490 + { name: "ref", type: "{ $link: string }" }, 491 { name: "mimeType", type: "string" }, 492 { name: "size", type: "number" }, 493 ], ··· 1134 `return await response.json() as T;`, 1135 ], 1136 }, 1137 + { 1138 + name: "blobToCdnUrl", 1139 + scope: "public", 1140 + parameters: [ 1141 + { name: "blobRef", type: "BlobRef" }, 1142 + { name: "did", type: "string" }, 1143 + { name: "preset", type: "'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize'", hasQuestionToken: true }, 1144 + { name: "cdnBaseUrl", type: "string", hasQuestionToken: true }, 1145 + ], 1146 + returnType: "string", 1147 + statements: [ 1148 + `// Convert BlobRef to CDN URL with size preset`, 1149 + `const cdnBase = cdnBaseUrl || 'https://cdn.bsky.app/img';`, 1150 + `const sizePreset = preset || 'feed_fullsize';`, 1151 + `const cid = blobRef.ref;`, 1152 + `return \`\${cdnBase}/\${sizePreset}/plain/\${did}/\${cid}@jpeg\`;`, 1153 + ], 1154 + }, 1155 ], 1156 }); 1157 } ··· 1638 1639 const finalCode = await formatCode(unformattedCode); 1640 1641 + // Add utility function after the generated client code 1642 + const utilityFunction = ` 1643 + 1644 + // Utility function to convert BlobRef to CDN URL using record context 1645 + export function recordBlobToCdnUrl<T>( 1646 + record: RecordResponse<T>, 1647 + blobRef: BlobRef, 1648 + preset?: 'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize', 1649 + cdnBaseUrl?: string 1650 + ): string { 1651 + const cdnBase = cdnBaseUrl || 'https://cdn.bsky.app/img'; 1652 + const sizePreset = preset || 'feed_fullsize'; 1653 + const cid = blobRef.ref.$link; 1654 + return \`\${cdnBase}/\${sizePreset}/plain/\${record.did}/\${cid}@jpeg\`; 1655 + } 1656 + `; 1657 + 1658 + const finalCodeWithUtility = finalCode + utilityFunction; 1659 + 1660 // Output to stdout for the Rust handler to capture 1661 + Deno.stdout.writeSync(new TextEncoder().encode(finalCodeWithUtility));
+253 -225
frontend/src/client.ts
··· 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-08-30 17:33:35 UTC 3 // Lexicons: 6 4 5 /** ··· 9 * 10 * const client = new AtProtoClient( 11 * 'https://slices-api.fly.dev', 12 - * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q' 13 * ); 14 * 15 * // List records from the app.bsky.actor.profile collection ··· 228 229 export interface BlobRef { 230 $type: string; 231 - ref: string; 232 mimeType: string; 233 size: number; 234 } ··· 243 searchRecords(params: SearchRecordsParams): Promise<ListRecordsResponse<T>>; 244 } 245 246 export interface ComAtprotoLabelDefsLabel { 247 /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 248 cid?: string; ··· 264 ver?: number; 265 } 266 267 export interface ComAtprotoLabelDefsLabelValueDefinition { 268 - /** Does the user need to have adult content enabled in order to configure this label? */ 269 - adultOnly?: boolean; 270 /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 271 blurs: string; 272 - /** The default setting for this label. */ 273 - defaultSetting?: string; 274 - /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 275 - identifier: string; 276 locales: ComAtprotoLabelDefs["LabelValueDefinitionStrings"][]; 277 /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 278 severity: string; 279 } 280 281 export interface ComAtprotoLabelDefsLabelValueDefinitionStrings { 282 - /** A longer description of what the label means and why it might be applied. */ 283 - description: string; 284 /** The code of the language these strings are written in. */ 285 lang: string; 286 /** A short human-readable name for the label. */ 287 name: string; 288 - } 289 - 290 - export interface ComAtprotoLabelDefsSelfLabel { 291 - /** The short string name of the value or type of this label. */ 292 - val: string; 293 - } 294 - 295 - export interface ComAtprotoLabelDefsSelfLabels { 296 - values: ComAtprotoLabelDefs["SelfLabel"][]; 297 } 298 299 export interface ComAtprotoRepoStrongRef { ··· 301 uri: string; 302 } 303 304 - export interface AppBskyActorProfile { 305 - /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 306 - avatar?: BlobRef; 307 - /** Larger horizontal image to display behind profile view. */ 308 - banner?: BlobRef; 309 - createdAt?: string; 310 - /** Free-form profile description text. */ 311 - description?: string; 312 - displayName?: string; 313 - joinedViaStarterPack?: ComAtprotoRepoStrongRef; 314 - /** Self-label values, specific to the Bluesky application, on the overall account. */ 315 - labels?: 316 - | ComAtprotoLabelDefs["SelfLabels"] 317 - | { 318 - $type: string; 319 - [key: string]: unknown; 320 - }; 321 - pinnedPost?: ComAtprotoRepoStrongRef; 322 - } 323 - 324 - export type AppBskyActorProfileSortFields = 325 - | "createdAt" 326 - | "description" 327 - | "displayName"; 328 - 329 - export interface SocialSlicesActorProfile { 330 - /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 331 - avatar?: BlobRef; 332 - createdAt?: string; 333 - /** Free-form profile description text. */ 334 - description?: string; 335 - displayName?: string; 336 - } 337 - 338 - export type SocialSlicesActorProfileSortFields = 339 - | "createdAt" 340 - | "description" 341 - | "displayName"; 342 - 343 - export interface SocialSlicesLexicon { 344 - /** When the lexicon was created */ 345 - createdAt: string; 346 - /** The lexicon schema definitions as JSON */ 347 - definitions: string; 348 - /** Namespaced identifier for the lexicon */ 349 - nsid: string; 350 - /** AT-URI reference to the slice this lexicon belongs to */ 351 - slice: string; 352 - /** When the lexicon was last updated */ 353 - updatedAt?: string; 354 - } 355 - 356 - export type SocialSlicesLexiconSortFields = 357 - | "createdAt" 358 - | "definitions" 359 - | "nsid" 360 - | "slice" 361 - | "updatedAt"; 362 - 363 - export interface SocialSlicesSlice { 364 - /** When the slice was created */ 365 - createdAt: string; 366 - /** Name of the slice */ 367 - name: string; 368 - } 369 - 370 - export type SocialSlicesSliceSortFields = "createdAt" | "name"; 371 - 372 export interface ComAtprotoLabelDefs { 373 readonly Label: ComAtprotoLabelDefsLabel; 374 readonly LabelValueDefinition: ComAtprotoLabelDefsLabelValueDefinition; 375 readonly LabelValueDefinitionStrings: ComAtprotoLabelDefsLabelValueDefinitionStrings; 376 - readonly SelfLabel: ComAtprotoLabelDefsSelfLabel; 377 - readonly SelfLabels: ComAtprotoLabelDefsSelfLabels; 378 } 379 380 class BaseClient { ··· 488 } 489 490 return (await response.json()) as T; 491 } 492 } 493 ··· 612 } 613 } 614 615 - class ProfileActorSlicesSocialClient extends BaseClient { 616 private readonly sliceUri: string; 617 618 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 621 } 622 623 async listRecords( 624 - params?: ListRecordsParams<SocialSlicesActorProfileSortFields> 625 - ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 626 const requestParams = { ...params, slice: this.sliceUri }; 627 - return await this.makeRequest< 628 - ListRecordsResponse<SocialSlicesActorProfile> 629 - >("social.slices.actor.profile.listRecords", "GET", requestParams); 630 } 631 632 async getRecord( 633 params: GetRecordParams 634 - ): Promise<RecordResponse<SocialSlicesActorProfile>> { 635 const requestParams = { ...params, slice: this.sliceUri }; 636 - return await this.makeRequest<RecordResponse<SocialSlicesActorProfile>>( 637 - "social.slices.actor.profile.getRecord", 638 "GET", 639 requestParams 640 ); 641 } 642 643 async searchRecords( 644 - params: SearchRecordsParams<SocialSlicesActorProfileSortFields> 645 - ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 646 const requestParams = { ...params, slice: this.sliceUri }; 647 - return await this.makeRequest< 648 - ListRecordsResponse<SocialSlicesActorProfile> 649 - >("social.slices.actor.profile.searchRecords", "GET", requestParams); 650 } 651 652 async createRecord( 653 - record: SocialSlicesActorProfile, 654 useSelfRkey?: boolean 655 ): Promise<{ uri: string; cid: string }> { 656 - const recordValue = { $type: "social.slices.actor.profile", ...record }; 657 const payload = { 658 slice: this.sliceUri, 659 ...(useSelfRkey ? { rkey: "self" } : {}), 660 record: recordValue, 661 }; 662 return await this.makeRequest<{ uri: string; cid: string }>( 663 - "social.slices.actor.profile.createRecord", 664 "POST", 665 payload 666 ); ··· 668 669 async updateRecord( 670 rkey: string, 671 - record: SocialSlicesActorProfile 672 ): Promise<{ uri: string; cid: string }> { 673 - const recordValue = { $type: "social.slices.actor.profile", ...record }; 674 const payload = { 675 slice: this.sliceUri, 676 rkey, 677 record: recordValue, 678 }; 679 return await this.makeRequest<{ uri: string; cid: string }>( 680 - "social.slices.actor.profile.updateRecord", 681 "POST", 682 payload 683 ); ··· 685 686 async deleteRecord(rkey: string): Promise<void> { 687 return await this.makeRequest<void>( 688 - "social.slices.actor.profile.deleteRecord", 689 "POST", 690 { rkey } 691 ); 692 } 693 - } 694 695 - class ActorSlicesSocialClient extends BaseClient { 696 - readonly profile: ProfileActorSlicesSocialClient; 697 - private readonly sliceUri: string; 698 699 - constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 700 - super(baseUrl, oauthClient); 701 - this.sliceUri = sliceUri; 702 - this.profile = new ProfileActorSlicesSocialClient( 703 - baseUrl, 704 - sliceUri, 705 - oauthClient 706 ); 707 } 708 } ··· 791 } 792 } 793 794 - class SliceSlicesSocialClient extends BaseClient { 795 private readonly sliceUri: string; 796 797 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 800 } 801 802 async listRecords( 803 - params?: ListRecordsParams<SocialSlicesSliceSortFields> 804 - ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 805 const requestParams = { ...params, slice: this.sliceUri }; 806 - return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 807 - "social.slices.slice.listRecords", 808 - "GET", 809 - requestParams 810 - ); 811 } 812 813 async getRecord( 814 params: GetRecordParams 815 - ): Promise<RecordResponse<SocialSlicesSlice>> { 816 const requestParams = { ...params, slice: this.sliceUri }; 817 - return await this.makeRequest<RecordResponse<SocialSlicesSlice>>( 818 - "social.slices.slice.getRecord", 819 "GET", 820 requestParams 821 ); 822 } 823 824 async searchRecords( 825 - params: SearchRecordsParams<SocialSlicesSliceSortFields> 826 - ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 827 const requestParams = { ...params, slice: this.sliceUri }; 828 - return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 829 - "social.slices.slice.searchRecords", 830 - "GET", 831 - requestParams 832 - ); 833 } 834 835 async createRecord( 836 - record: SocialSlicesSlice, 837 useSelfRkey?: boolean 838 ): Promise<{ uri: string; cid: string }> { 839 - const recordValue = { $type: "social.slices.slice", ...record }; 840 const payload = { 841 slice: this.sliceUri, 842 ...(useSelfRkey ? { rkey: "self" } : {}), 843 record: recordValue, 844 }; 845 return await this.makeRequest<{ uri: string; cid: string }>( 846 - "social.slices.slice.createRecord", 847 "POST", 848 payload 849 ); ··· 851 852 async updateRecord( 853 rkey: string, 854 - record: SocialSlicesSlice 855 ): Promise<{ uri: string; cid: string }> { 856 - const recordValue = { $type: "social.slices.slice", ...record }; 857 const payload = { 858 slice: this.sliceUri, 859 rkey, 860 record: recordValue, 861 }; 862 return await this.makeRequest<{ uri: string; cid: string }>( 863 - "social.slices.slice.updateRecord", 864 "POST", 865 payload 866 ); ··· 868 869 async deleteRecord(rkey: string): Promise<void> { 870 return await this.makeRequest<void>( 871 - "social.slices.slice.deleteRecord", 872 "POST", 873 { rkey } 874 ); 875 } 876 - 877 - async codegen(request: CodegenXrpcRequest): Promise<CodegenXrpcResponse> { 878 - return await this.makeRequest<CodegenXrpcResponse>( 879 - "social.slices.slice.codegen", 880 - "POST", 881 - request 882 - ); 883 - } 884 - 885 - async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 886 - return await this.makeRequest<SliceStatsOutput>( 887 - "social.slices.slice.stats", 888 - "POST", 889 - params 890 - ); 891 - } 892 893 - async records(params: SliceRecordsParams): Promise<SliceRecordsOutput> { 894 - return await this.makeRequest<SliceRecordsOutput>( 895 - "social.slices.slice.records", 896 - "POST", 897 - params 898 - ); 899 - } 900 901 - async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 902 - const requestParams = { ...params, slice: this.sliceUri }; 903 - return await this.makeRequest<GetActorsResponse>( 904 - "social.slices.slice.getActors", 905 - "GET", 906 - requestParams 907 - ); 908 - } 909 - 910 - async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 911 - const requestParams = { ...params, slice: this.sliceUri }; 912 - return await this.makeRequest<SyncJobResponse>( 913 - "social.slices.slice.startSync", 914 - "POST", 915 - requestParams 916 - ); 917 - } 918 - 919 - async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 920 - return await this.makeRequest<JobStatus>( 921 - "social.slices.slice.getJobStatus", 922 - "GET", 923 - params 924 - ); 925 - } 926 - 927 - async getJobHistory( 928 - params: GetJobHistoryParams 929 - ): Promise<GetJobHistoryResponse> { 930 - return await this.makeRequest<GetJobHistoryResponse>( 931 - "social.slices.slice.getJobHistory", 932 - "GET", 933 - params 934 - ); 935 - } 936 - 937 - async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 938 - return await this.makeRequest<JetstreamStatusResponse>( 939 - "social.slices.slice.getJetstreamStatus", 940 - "GET" 941 - ); 942 - } 943 - 944 - async syncUserCollections( 945 - params?: SyncUserCollectionsRequest 946 - ): Promise<SyncUserCollectionsResult> { 947 - const requestParams = { slice: this.sliceUri, ...params }; 948 - return await this.makeRequest<SyncUserCollectionsResult>( 949 - "social.slices.slice.syncUserCollections", 950 - "POST", 951 - requestParams 952 ); 953 } 954 } 955 956 class SlicesSocialClient extends BaseClient { 957 readonly actor: ActorSlicesSocialClient; 958 - readonly lexicon: LexiconSlicesSocialClient; 959 - readonly slice: SliceSlicesSocialClient; 960 private readonly sliceUri: string; 961 962 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 963 super(baseUrl, oauthClient); 964 this.sliceUri = sliceUri; 965 - this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient); 966 this.lexicon = new LexiconSlicesSocialClient( 967 baseUrl, 968 sliceUri, 969 oauthClient 970 ); 971 - this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient); 972 } 973 } 974 ··· 1058 return await response.json(); 1059 } 1060 }
··· 1 // Generated TypeScript client for AT Protocol records 2 + // Generated at: 2025-08-30 19:20:05 UTC 3 // Lexicons: 6 4 5 /** ··· 9 * 10 * const client = new AtProtoClient( 11 * 'https://slices-api.fly.dev', 12 + * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q' 13 * ); 14 * 15 * // List records from the app.bsky.actor.profile collection ··· 228 229 export interface BlobRef { 230 $type: string; 231 + ref: { $link: string }; 232 mimeType: string; 233 size: number; 234 } ··· 243 searchRecords(params: SearchRecordsParams): Promise<ListRecordsResponse<T>>; 244 } 245 246 + export interface AppBskyActorProfile { 247 + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 248 + avatar?: BlobRef; 249 + /** Larger horizontal image to display behind profile view. */ 250 + banner?: BlobRef; 251 + /** Self-label values, specific to the Bluesky application, on the overall account. */ 252 + labels?: 253 + | ComAtprotoLabelDefs["SelfLabels"] 254 + | { 255 + $type: string; 256 + [key: string]: unknown; 257 + }; 258 + createdAt?: string; 259 + pinnedPost?: ComAtprotoRepoStrongRef; 260 + /** Free-form profile description text. */ 261 + description?: string; 262 + displayName?: string; 263 + joinedViaStarterPack?: ComAtprotoRepoStrongRef; 264 + } 265 + 266 + export type AppBskyActorProfileSortFields = 267 + | "createdAt" 268 + | "description" 269 + | "displayName"; 270 + 271 + export interface SocialSlicesSlice { 272 + /** Name of the slice */ 273 + name: string; 274 + /** Primary domain namespace for this slice (e.g. social.grain) */ 275 + domain: string; 276 + /** When the slice was created */ 277 + createdAt: string; 278 + } 279 + 280 + export type SocialSlicesSliceSortFields = "name" | "domain" | "createdAt"; 281 + 282 + export interface SocialSlicesLexicon { 283 + /** Namespaced identifier for the lexicon */ 284 + nsid: string; 285 + /** The lexicon schema definitions as JSON */ 286 + definitions: string; 287 + /** When the lexicon was created */ 288 + createdAt: string; 289 + /** When the lexicon was last updated */ 290 + updatedAt?: string; 291 + /** AT-URI reference to the slice this lexicon belongs to */ 292 + slice: string; 293 + } 294 + 295 + export type SocialSlicesLexiconSortFields = 296 + | "nsid" 297 + | "definitions" 298 + | "createdAt" 299 + | "updatedAt" 300 + | "slice"; 301 + 302 + export interface SocialSlicesActorProfile { 303 + displayName?: string; 304 + /** Free-form profile description text. */ 305 + description?: string; 306 + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 307 + avatar?: BlobRef; 308 + createdAt?: string; 309 + } 310 + 311 + export type SocialSlicesActorProfileSortFields = 312 + | "displayName" 313 + | "description" 314 + | "createdAt"; 315 + 316 export interface ComAtprotoLabelDefsLabel { 317 /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 318 cid?: string; ··· 334 ver?: number; 335 } 336 337 + export interface ComAtprotoLabelDefsSelfLabel { 338 + /** The short string name of the value or type of this label. */ 339 + val: string; 340 + } 341 + 342 + export interface ComAtprotoLabelDefsSelfLabels { 343 + values: ComAtprotoLabelDefs["SelfLabel"][]; 344 + } 345 + 346 export interface ComAtprotoLabelDefsLabelValueDefinition { 347 /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 348 blurs: string; 349 locales: ComAtprotoLabelDefs["LabelValueDefinitionStrings"][]; 350 /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 351 severity: string; 352 + /** Does the user need to have adult content enabled in order to configure this label? */ 353 + adultOnly?: boolean; 354 + /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 355 + identifier: string; 356 + /** The default setting for this label. */ 357 + defaultSetting?: string; 358 } 359 360 export interface ComAtprotoLabelDefsLabelValueDefinitionStrings { 361 /** The code of the language these strings are written in. */ 362 lang: string; 363 /** A short human-readable name for the label. */ 364 name: string; 365 + /** A longer description of what the label means and why it might be applied. */ 366 + description: string; 367 } 368 369 export interface ComAtprotoRepoStrongRef { ··· 371 uri: string; 372 } 373 374 export interface ComAtprotoLabelDefs { 375 readonly Label: ComAtprotoLabelDefsLabel; 376 + readonly SelfLabel: ComAtprotoLabelDefsSelfLabel; 377 + readonly SelfLabels: ComAtprotoLabelDefsSelfLabels; 378 readonly LabelValueDefinition: ComAtprotoLabelDefsLabelValueDefinition; 379 readonly LabelValueDefinitionStrings: ComAtprotoLabelDefsLabelValueDefinitionStrings; 380 } 381 382 class BaseClient { ··· 490 } 491 492 return (await response.json()) as T; 493 + } 494 + 495 + public blobToCdnUrl( 496 + blobRef: BlobRef, 497 + did: string, 498 + preset?: "avatar" | "banner" | "feed_thumbnail" | "feed_fullsize", 499 + cdnBaseUrl?: string 500 + ): string { 501 + // Convert BlobRef to CDN URL with size preset 502 + const cdnBase = cdnBaseUrl || "https://cdn.bsky.app/img"; 503 + const sizePreset = preset || "feed_fullsize"; 504 + const cid = blobRef.ref; 505 + return `${cdnBase}/${sizePreset}/plain/${did}/${cid}@jpeg`; 506 } 507 } 508 ··· 627 } 628 } 629 630 + class SliceSlicesSocialClient extends BaseClient { 631 private readonly sliceUri: string; 632 633 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 636 } 637 638 async listRecords( 639 + params?: ListRecordsParams<SocialSlicesSliceSortFields> 640 + ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 641 const requestParams = { ...params, slice: this.sliceUri }; 642 + return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 643 + "social.slices.slice.listRecords", 644 + "GET", 645 + requestParams 646 + ); 647 } 648 649 async getRecord( 650 params: GetRecordParams 651 + ): Promise<RecordResponse<SocialSlicesSlice>> { 652 const requestParams = { ...params, slice: this.sliceUri }; 653 + return await this.makeRequest<RecordResponse<SocialSlicesSlice>>( 654 + "social.slices.slice.getRecord", 655 "GET", 656 requestParams 657 ); 658 } 659 660 async searchRecords( 661 + params: SearchRecordsParams<SocialSlicesSliceSortFields> 662 + ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 663 const requestParams = { ...params, slice: this.sliceUri }; 664 + return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 665 + "social.slices.slice.searchRecords", 666 + "GET", 667 + requestParams 668 + ); 669 } 670 671 async createRecord( 672 + record: SocialSlicesSlice, 673 useSelfRkey?: boolean 674 ): Promise<{ uri: string; cid: string }> { 675 + const recordValue = { $type: "social.slices.slice", ...record }; 676 const payload = { 677 slice: this.sliceUri, 678 ...(useSelfRkey ? { rkey: "self" } : {}), 679 record: recordValue, 680 }; 681 return await this.makeRequest<{ uri: string; cid: string }>( 682 + "social.slices.slice.createRecord", 683 "POST", 684 payload 685 ); ··· 687 688 async updateRecord( 689 rkey: string, 690 + record: SocialSlicesSlice 691 ): Promise<{ uri: string; cid: string }> { 692 + const recordValue = { $type: "social.slices.slice", ...record }; 693 const payload = { 694 slice: this.sliceUri, 695 rkey, 696 record: recordValue, 697 }; 698 return await this.makeRequest<{ uri: string; cid: string }>( 699 + "social.slices.slice.updateRecord", 700 "POST", 701 payload 702 ); ··· 704 705 async deleteRecord(rkey: string): Promise<void> { 706 return await this.makeRequest<void>( 707 + "social.slices.slice.deleteRecord", 708 "POST", 709 { rkey } 710 ); 711 } 712 713 + async codegen(request: CodegenXrpcRequest): Promise<CodegenXrpcResponse> { 714 + return await this.makeRequest<CodegenXrpcResponse>( 715 + "social.slices.slice.codegen", 716 + "POST", 717 + request 718 + ); 719 + } 720 721 + async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 722 + return await this.makeRequest<SliceStatsOutput>( 723 + "social.slices.slice.stats", 724 + "POST", 725 + params 726 + ); 727 + } 728 + 729 + async records(params: SliceRecordsParams): Promise<SliceRecordsOutput> { 730 + return await this.makeRequest<SliceRecordsOutput>( 731 + "social.slices.slice.records", 732 + "POST", 733 + params 734 + ); 735 + } 736 + 737 + async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 738 + const requestParams = { ...params, slice: this.sliceUri }; 739 + return await this.makeRequest<GetActorsResponse>( 740 + "social.slices.slice.getActors", 741 + "GET", 742 + requestParams 743 + ); 744 + } 745 + 746 + async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 747 + const requestParams = { ...params, slice: this.sliceUri }; 748 + return await this.makeRequest<SyncJobResponse>( 749 + "social.slices.slice.startSync", 750 + "POST", 751 + requestParams 752 + ); 753 + } 754 + 755 + async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 756 + return await this.makeRequest<JobStatus>( 757 + "social.slices.slice.getJobStatus", 758 + "GET", 759 + params 760 + ); 761 + } 762 + 763 + async getJobHistory( 764 + params: GetJobHistoryParams 765 + ): Promise<GetJobHistoryResponse> { 766 + return await this.makeRequest<GetJobHistoryResponse>( 767 + "social.slices.slice.getJobHistory", 768 + "GET", 769 + params 770 + ); 771 + } 772 + 773 + async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 774 + return await this.makeRequest<JetstreamStatusResponse>( 775 + "social.slices.slice.getJetstreamStatus", 776 + "GET" 777 + ); 778 + } 779 + 780 + async syncUserCollections( 781 + params?: SyncUserCollectionsRequest 782 + ): Promise<SyncUserCollectionsResult> { 783 + const requestParams = { slice: this.sliceUri, ...params }; 784 + return await this.makeRequest<SyncUserCollectionsResult>( 785 + "social.slices.slice.syncUserCollections", 786 + "POST", 787 + requestParams 788 ); 789 } 790 } ··· 873 } 874 } 875 876 + class ProfileActorSlicesSocialClient extends BaseClient { 877 private readonly sliceUri: string; 878 879 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 882 } 883 884 async listRecords( 885 + params?: ListRecordsParams<SocialSlicesActorProfileSortFields> 886 + ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 887 const requestParams = { ...params, slice: this.sliceUri }; 888 + return await this.makeRequest< 889 + ListRecordsResponse<SocialSlicesActorProfile> 890 + >("social.slices.actor.profile.listRecords", "GET", requestParams); 891 } 892 893 async getRecord( 894 params: GetRecordParams 895 + ): Promise<RecordResponse<SocialSlicesActorProfile>> { 896 const requestParams = { ...params, slice: this.sliceUri }; 897 + return await this.makeRequest<RecordResponse<SocialSlicesActorProfile>>( 898 + "social.slices.actor.profile.getRecord", 899 "GET", 900 requestParams 901 ); 902 } 903 904 async searchRecords( 905 + params: SearchRecordsParams<SocialSlicesActorProfileSortFields> 906 + ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 907 const requestParams = { ...params, slice: this.sliceUri }; 908 + return await this.makeRequest< 909 + ListRecordsResponse<SocialSlicesActorProfile> 910 + >("social.slices.actor.profile.searchRecords", "GET", requestParams); 911 } 912 913 async createRecord( 914 + record: SocialSlicesActorProfile, 915 useSelfRkey?: boolean 916 ): Promise<{ uri: string; cid: string }> { 917 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 918 const payload = { 919 slice: this.sliceUri, 920 ...(useSelfRkey ? { rkey: "self" } : {}), 921 record: recordValue, 922 }; 923 return await this.makeRequest<{ uri: string; cid: string }>( 924 + "social.slices.actor.profile.createRecord", 925 "POST", 926 payload 927 ); ··· 929 930 async updateRecord( 931 rkey: string, 932 + record: SocialSlicesActorProfile 933 ): Promise<{ uri: string; cid: string }> { 934 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 935 const payload = { 936 slice: this.sliceUri, 937 rkey, 938 record: recordValue, 939 }; 940 return await this.makeRequest<{ uri: string; cid: string }>( 941 + "social.slices.actor.profile.updateRecord", 942 "POST", 943 payload 944 ); ··· 946 947 async deleteRecord(rkey: string): Promise<void> { 948 return await this.makeRequest<void>( 949 + "social.slices.actor.profile.deleteRecord", 950 "POST", 951 { rkey } 952 ); 953 } 954 + } 955 956 + class ActorSlicesSocialClient extends BaseClient { 957 + readonly profile: ProfileActorSlicesSocialClient; 958 + private readonly sliceUri: string; 959 960 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 961 + super(baseUrl, oauthClient); 962 + this.sliceUri = sliceUri; 963 + this.profile = new ProfileActorSlicesSocialClient( 964 + baseUrl, 965 + sliceUri, 966 + oauthClient 967 ); 968 } 969 } 970 971 class SlicesSocialClient extends BaseClient { 972 + readonly slice: SliceSlicesSocialClient; 973 + readonly lexicon: LexiconSlicesSocialClient; 974 readonly actor: ActorSlicesSocialClient; 975 private readonly sliceUri: string; 976 977 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 978 super(baseUrl, oauthClient); 979 this.sliceUri = sliceUri; 980 + this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient); 981 this.lexicon = new LexiconSlicesSocialClient( 982 baseUrl, 983 sliceUri, 984 oauthClient 985 ); 986 + this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient); 987 } 988 } 989 ··· 1073 return await response.json(); 1074 } 1075 } 1076 + 1077 + // Utility function to convert BlobRef to CDN URL using record context 1078 + export function recordBlobToCdnUrl<T>( 1079 + record: RecordResponse<T>, 1080 + blobRef: BlobRef, 1081 + preset?: "avatar" | "banner" | "feed_thumbnail" | "feed_fullsize", 1082 + cdnBaseUrl?: string 1083 + ): string { 1084 + const cdnBase = cdnBaseUrl || "https://cdn.bsky.app/img"; 1085 + const sizePreset = preset || "feed_fullsize"; 1086 + const cid = blobRef.ref.$link; 1087 + return `${cdnBase}/${sizePreset}/plain/${record.did}/${cid}@jpeg`; 1088 + }
+14 -3
frontend/src/components/Layout.tsx
··· 3 interface LayoutProps { 4 title?: string; 5 children: JSX.Element | JSX.Element[]; 6 - currentUser?: { handle?: string; isAuthenticated: boolean }; 7 } 8 9 export function Layout({ ··· 49 <div className="flex items-center space-x-4"> 50 {currentUser?.isAuthenticated ? ( 51 <div className="flex items-center space-x-3"> 52 <span className="text-sm text-gray-600"> 53 - {currentUser.handle ? `@${currentUser.handle}` : "Authenticated User"} 54 </span> 55 <a 56 href="/settings" ··· 78 </div> 79 </div> 80 </nav> 81 - <main className="max-w-5xl mx-auto mt-8 px-4 pb-16 min-h-[calc(100vh-200px)]">{children}</main> 82 </body> 83 </html> 84 );
··· 3 interface LayoutProps { 4 title?: string; 5 children: JSX.Element | JSX.Element[]; 6 + currentUser?: { handle?: string; isAuthenticated: boolean; avatar?: string }; 7 } 8 9 export function Layout({ ··· 49 <div className="flex items-center space-x-4"> 50 {currentUser?.isAuthenticated ? ( 51 <div className="flex items-center space-x-3"> 52 + {currentUser.avatar && ( 53 + <img 54 + src={currentUser.avatar} 55 + alt="Profile avatar" 56 + className="w-6 h-6 rounded-full" 57 + /> 58 + )} 59 <span className="text-sm text-gray-600"> 60 + {currentUser.handle 61 + ? `@${currentUser.handle}` 62 + : "Authenticated User"} 63 </span> 64 <a 65 href="/settings" ··· 87 </div> 88 </div> 89 </nav> 90 + <main className="max-w-5xl mx-auto mt-8 px-4 pb-16 min-h-[calc(100vh-200px)]"> 91 + {children} 92 + </main> 93 </body> 94 </html> 95 );
+1
frontend/src/pages/IndexPage.tsx
··· 96 data. 97 </p> 98 <button 99 className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded" 100 hx-get="/dialogs/create-slice" 101 hx-target="body"
··· 96 data. 97 </p> 98 <button 99 + type="button" 100 className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded" 101 hx-get="/dialogs/create-slice" 102 hx-target="body"
+33 -3
frontend/src/routes/middleware.ts
··· 1 - import { sessionStore } from "../config.ts"; 2 3 export interface AuthenticatedUser { 4 handle?: string; 5 sub?: string; 6 isAuthenticated: boolean; 7 } 8 9 export interface RouteContext { ··· 13 export async function withAuth(req: Request): Promise<RouteContext> { 14 // Get current user info from session store 15 const currentUser = await sessionStore.getCurrentUser(req); 16 return { 17 currentUser, 18 }; ··· 22 if (!context.currentUser.isAuthenticated) { 23 return new Response("", { 24 status: 401, 25 - headers: { 26 "HX-Redirect": "/login", 27 - "Location": "/login" 28 }, 29 }); 30 }
··· 1 + import { sessionStore, atprotoClient } from "../config.ts"; 2 + import { recordBlobToCdnUrl } from "../client.ts"; 3 4 export interface AuthenticatedUser { 5 handle?: string; 6 sub?: string; 7 isAuthenticated: boolean; 8 + avatar?: string; 9 } 10 11 export interface RouteContext { ··· 15 export async function withAuth(req: Request): Promise<RouteContext> { 16 // Get current user info from session store 17 const currentUser = await sessionStore.getCurrentUser(req); 18 + 19 + // If user is authenticated, try to fetch their Bluesky profile avatar 20 + if (currentUser.isAuthenticated && currentUser.sub) { 21 + try { 22 + // Try to get the user's Bluesky profile from external collections 23 + const profileRecords = 24 + await atprotoClient.app.bsky.actor.profile.listRecords({ 25 + authors: [currentUser.sub], 26 + limit: 1, 27 + }); 28 + 29 + if (profileRecords.records && profileRecords.records.length > 0) { 30 + const profileRecord = profileRecords.records[0]; 31 + if (profileRecord.value.avatar) { 32 + // Convert BlobRef to CDN URL for avatar 33 + currentUser.avatar = recordBlobToCdnUrl( 34 + profileRecord, 35 + profileRecord.value.avatar, 36 + "avatar" 37 + ); 38 + } 39 + } 40 + } catch (error) { 41 + console.log("Could not fetch user avatar:", error); 42 + // Continue without avatar - this is non-critical 43 + } 44 + } 45 + 46 return { 47 currentUser, 48 }; ··· 52 if (!context.currentUser.isAuthenticated) { 53 return new Response("", { 54 status: 401, 55 + headers: { 56 "HX-Redirect": "/login", 57 + Location: "/login", 58 }, 59 }); 60 }
+64
lexicons/app/bsky/actor/profile.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "key": "literal:self", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "avatar": { 12 + "type": "blob", 13 + "accept": [ 14 + "image/png", 15 + "image/jpeg" 16 + ], 17 + "maxSize": 1000000, 18 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 19 + }, 20 + "banner": { 21 + "type": "blob", 22 + "accept": [ 23 + "image/png", 24 + "image/jpeg" 25 + ], 26 + "maxSize": 1000000, 27 + "description": "Larger horizontal image to display behind profile view." 28 + }, 29 + "labels": { 30 + "refs": [ 31 + "com.atproto.label.defs#selfLabels" 32 + ], 33 + "type": "union", 34 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 35 + }, 36 + "createdAt": { 37 + "type": "string", 38 + "format": "datetime" 39 + }, 40 + "pinnedPost": { 41 + "ref": "com.atproto.repo.strongRef", 42 + "type": "ref" 43 + }, 44 + "description": { 45 + "type": "string", 46 + "maxLength": 2560, 47 + "description": "Free-form profile description text.", 48 + "maxGraphemes": 256 49 + }, 50 + "displayName": { 51 + "type": "string", 52 + "maxLength": 640, 53 + "maxGraphemes": 64 54 + }, 55 + "joinedViaStarterPack": { 56 + "ref": "com.atproto.repo.strongRef", 57 + "type": "ref" 58 + } 59 + } 60 + }, 61 + "description": "A declaration of a Bluesky account profile." 62 + } 63 + } 64 + }
+192
lexicons/com/atproto/label/defs.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "required": [ 8 + "src", 9 + "uri", 10 + "val", 11 + "cts" 12 + ], 13 + "properties": { 14 + "cid": { 15 + "type": "string", 16 + "format": "cid", 17 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 18 + }, 19 + "cts": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when this label was created." 23 + }, 24 + "exp": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp at which this label expires (no longer applies)." 28 + }, 29 + "neg": { 30 + "type": "boolean", 31 + "description": "If true, this is a negation label, overwriting a previous label." 32 + }, 33 + "sig": { 34 + "type": "bytes", 35 + "description": "Signature of dag-cbor encoded label." 36 + }, 37 + "src": { 38 + "type": "string", 39 + "format": "did", 40 + "description": "DID of the actor who created this label." 41 + }, 42 + "uri": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 46 + }, 47 + "val": { 48 + "type": "string", 49 + "maxLength": 128, 50 + "description": "The short string name of the value or type of this label." 51 + }, 52 + "ver": { 53 + "type": "integer", 54 + "description": "The AT Protocol version of the label object." 55 + } 56 + }, 57 + "description": "Metadata tag on an atproto resource (eg, repo or record)." 58 + }, 59 + "selfLabel": { 60 + "type": "object", 61 + "required": [ 62 + "val" 63 + ], 64 + "properties": { 65 + "val": { 66 + "type": "string", 67 + "maxLength": 128, 68 + "description": "The short string name of the value or type of this label." 69 + } 70 + }, 71 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 72 + }, 73 + "labelValue": { 74 + "type": "string", 75 + "knownValues": [ 76 + "!hide", 77 + "!no-promote", 78 + "!warn", 79 + "!no-unauthenticated", 80 + "dmca-violation", 81 + "doxxing", 82 + "porn", 83 + "sexual", 84 + "nudity", 85 + "nsfl", 86 + "gore" 87 + ] 88 + }, 89 + "selfLabels": { 90 + "type": "object", 91 + "required": [ 92 + "values" 93 + ], 94 + "properties": { 95 + "values": { 96 + "type": "array", 97 + "items": { 98 + "ref": "#selfLabel", 99 + "type": "ref" 100 + }, 101 + "maxLength": 10 102 + } 103 + }, 104 + "description": "Metadata tags on an atproto record, published by the author within the record." 105 + }, 106 + "labelValueDefinition": { 107 + "type": "object", 108 + "required": [ 109 + "identifier", 110 + "severity", 111 + "blurs", 112 + "locales" 113 + ], 114 + "properties": { 115 + "blurs": { 116 + "type": "string", 117 + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 118 + "knownValues": [ 119 + "content", 120 + "media", 121 + "none" 122 + ] 123 + }, 124 + "locales": { 125 + "type": "array", 126 + "items": { 127 + "ref": "#labelValueDefinitionStrings", 128 + "type": "ref" 129 + } 130 + }, 131 + "severity": { 132 + "type": "string", 133 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 134 + "knownValues": [ 135 + "inform", 136 + "alert", 137 + "none" 138 + ] 139 + }, 140 + "adultOnly": { 141 + "type": "boolean", 142 + "description": "Does the user need to have adult content enabled in order to configure this label?" 143 + }, 144 + "identifier": { 145 + "type": "string", 146 + "maxLength": 100, 147 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 148 + "maxGraphemes": 100 149 + }, 150 + "defaultSetting": { 151 + "type": "string", 152 + "default": "warn", 153 + "description": "The default setting for this label.", 154 + "knownValues": [ 155 + "ignore", 156 + "warn", 157 + "hide" 158 + ] 159 + } 160 + }, 161 + "description": "Declares a label value and its expected interpretations and behaviors." 162 + }, 163 + "labelValueDefinitionStrings": { 164 + "type": "object", 165 + "required": [ 166 + "lang", 167 + "name", 168 + "description" 169 + ], 170 + "properties": { 171 + "lang": { 172 + "type": "string", 173 + "format": "language", 174 + "description": "The code of the language these strings are written in." 175 + }, 176 + "name": { 177 + "type": "string", 178 + "maxLength": 640, 179 + "description": "A short human-readable name for the label.", 180 + "maxGraphemes": 64 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 100000, 185 + "description": "A longer description of what the label means and why it might be applied.", 186 + "maxGraphemes": 10000 187 + } 188 + }, 189 + "description": "Strings which describe the label in the UI, localized into a specific language." 190 + } 191 + } 192 + }
+24
lexicons/com/atproto/repo/strongRef.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "cid" 11 + ], 12 + "properties": { 13 + "cid": { 14 + "type": "string", 15 + "format": "cid" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + } 24 + }