+39
-2
api/scripts/generate_typescript.ts
+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
+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
+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
+1
frontend/src/pages/IndexPage.tsx
+33
-3
frontend/src/routes/middleware.ts
+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
+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
+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
+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
+
}