Highly ambitious ATProtocol AppView service and sdks

Compare changes

Choose any two refs to compare.

Changed files
+97 -49
crates
slices-lexicon
src
validation
primitive
frontend-v2
packages
session
src
adapters
+24 -11
crates/slices-lexicon/src/validation/primitive/string.rs
··· 577 577 578 578 /// Validates TID (Timestamp Identifier) format 579 579 /// 580 - /// TID format: 13-character base32-encoded timestamp + random bits 581 - /// Uses Crockford base32 alphabet: 0123456789ABCDEFGHJKMNPQRSTVWXYZ (case-insensitive) 580 + /// TID format: 13-character base32-sortable encoded timestamp + random bits 581 + /// Uses ATProto base32-sortable alphabet: 234567abcdefghijklmnopqrstuvwxyz (lowercase only) 582 582 pub fn is_valid_tid(&self, value: &str) -> bool { 583 583 use regex::Regex; 584 584 ··· 586 586 return false; 587 587 } 588 588 589 - // TID uses Crockford base32 (case-insensitive, excludes I, L, O, U) 590 - let tid_regex = Regex::new(r"^[0-9A-HJKMNP-TV-Z]{13}$").unwrap(); 591 - let uppercase_value = value.to_uppercase(); 589 + // TID uses base32-sortable (s32) - lowercase only 590 + // First character must be from limited set (ensures top bit is 0) 591 + // Remaining 12 characters from full base32-sortable alphabet 592 + let tid_regex = Regex::new(r"^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$").unwrap(); 592 593 593 - tid_regex.is_match(&uppercase_value) 594 + tid_regex.is_match(value) 594 595 } 595 596 596 597 /// Validates Record Key format ··· 1096 1097 1097 1098 let validator = StringValidator; 1098 1099 1099 - // Valid TIDs (13 characters, Crockford base32) 1100 - assert!(validator.validate_data(&json!("3JZFKJT0000ZZ"), &schema, &ctx).is_ok()); 1101 - assert!(validator.validate_data(&json!("3jzfkjt0000zz"), &schema, &ctx).is_ok()); // case insensitive 1100 + // Valid TIDs (base32-sortable, 13 chars, lowercase) 1101 + assert!(validator.validate_data(&json!("3m3zm7eurxk26"), &schema, &ctx).is_ok()); 1102 + assert!(validator.validate_data(&json!("2222222222222"), &schema, &ctx).is_ok()); // minimum TID 1103 + assert!(validator.validate_data(&json!("a222222222222"), &schema, &ctx).is_ok()); // leading 'a' (lower bound) 1104 + assert!(validator.validate_data(&json!("j234567abcdef"), &schema, &ctx).is_ok()); // leading 'j' (upper bound) 1105 + 1102 1106 1103 - // Invalid TIDs 1107 + // Invalid TIDs - uppercase not allowed (charset is lowercase only) 1108 + assert!(validator.validate_data(&json!("3m3zM7eurxk26"), &schema, &ctx).is_err()); // mixed case 1109 + 1110 + // Invalid TIDs - wrong length 1104 1111 assert!(validator.validate_data(&json!("too-short"), &schema, &ctx).is_err()); 1105 1112 assert!(validator.validate_data(&json!("too-long-string"), &schema, &ctx).is_err()); 1113 + 1114 + // Invalid TIDs - invalid characters (hyphen/punct rejected; digits 0,1,8,9 not allowed) 1106 1115 assert!(validator.validate_data(&json!("invalid-chars!"), &schema, &ctx).is_err()); 1107 - assert!(validator.validate_data(&json!("invalid-ILOU0"), &schema, &ctx).is_err()); // invalid chars (I, L, O, U) 1116 + assert!(validator.validate_data(&json!("xyz1234567890"), &schema, &ctx).is_err()); // has 0,1,8,9 1117 + 1118 + // Invalid TIDs - first character must be one of 234567abcdefghij 1119 + assert!(validator.validate_data(&json!("k222222222222"), &schema, &ctx).is_err()); // leading 'k' forbidden 1120 + assert!(validator.validate_data(&json!("z234567abcdef"), &schema, &ctx).is_err()); // leading 'z' forbidden 1108 1121 } 1109 1122 1110 1123 #[test]
+1 -1
frontend-v2/schema.graphql
··· 931 931 } 932 932 933 933 type BlobUploadResponse { 934 - blob: JSON! 934 + blob: Blob! 935 935 } 936 936 937 937 type CollectionStats {
+8 -7
frontend-v2/server/profile-init.ts
··· 18 18 export async function initializeUserProfile( 19 19 userDid: string, 20 20 userHandle: string, 21 - tokens: TokenInfo 21 + tokens: TokenInfo, 22 22 ): Promise<void> { 23 23 if (!API_URL || !SLICE_URI) { 24 24 console.error("Missing API_URL or VITE_SLICE_URI environment variables"); ··· 26 26 } 27 27 28 28 try { 29 - const graphqlUrl = `${API_URL}/graphql?slice=${encodeURIComponent(SLICE_URI)}`; 29 + const graphqlUrl = `${API_URL}/graphql?slice=${ 30 + encodeURIComponent(SLICE_URI) 31 + }`; 30 32 const authHeader = `${tokens.tokenType} ${tokens.accessToken}`; 31 33 32 34 // 1. Check if profile already exists ··· 132 134 }); 133 135 134 136 if (!bskyResponse.ok) { 135 - throw new Error(`Fetch Bluesky profile failed: ${bskyResponse.statusText}`); 137 + throw new Error( 138 + `Fetch Bluesky profile failed: ${bskyResponse.statusText}`, 139 + ); 136 140 } 137 141 138 142 const bskyData = await bskyResponse.json(); ··· 160 164 ) { 161 165 // Reconstruct blob format for AT Protocol 162 166 profileInput.avatar = { 163 - $type: "blob", 164 - ref: { 165 - $link: bskyProfile.avatar.ref, 166 - }, 167 + ref: bskyProfile.avatar.ref, 167 168 mimeType: bskyProfile.avatar.mimeType, 168 169 size: bskyProfile.avatar.size, 169 170 };
+35 -6
frontend-v2/src/__generated__/ProfileSettingsUploadBlobMutation.graphql.ts
··· 1 1 /** 2 - * @generated SignedSource<<a2334c7e93bb6d5b4748df1211a418ae>> 2 + * @generated SignedSource<<728b9a3525f975b6c58a5cdcd323f89e>> 3 3 * @lightSyntaxTransform 4 4 * @nogrep 5 5 */ ··· 15 15 }; 16 16 export type ProfileSettingsUploadBlobMutation$data = { 17 17 readonly uploadBlob: { 18 - readonly blob: any; 18 + readonly blob: { 19 + readonly mimeType: string; 20 + readonly ref: string; 21 + readonly size: number; 22 + }; 19 23 }; 20 24 }; 21 25 export type ProfileSettingsUploadBlobMutation = { ··· 59 63 { 60 64 "alias": null, 61 65 "args": null, 62 - "kind": "ScalarField", 66 + "concreteType": "Blob", 67 + "kind": "LinkedField", 63 68 "name": "blob", 69 + "plural": false, 70 + "selections": [ 71 + { 72 + "alias": null, 73 + "args": null, 74 + "kind": "ScalarField", 75 + "name": "ref", 76 + "storageKey": null 77 + }, 78 + { 79 + "alias": null, 80 + "args": null, 81 + "kind": "ScalarField", 82 + "name": "mimeType", 83 + "storageKey": null 84 + }, 85 + { 86 + "alias": null, 87 + "args": null, 88 + "kind": "ScalarField", 89 + "name": "size", 90 + "storageKey": null 91 + } 92 + ], 64 93 "storageKey": null 65 94 } 66 95 ], ··· 85 114 "selections": (v1/*: any*/) 86 115 }, 87 116 "params": { 88 - "cacheID": "3a4a6b19d2898f14635b098941614cab", 117 + "cacheID": "afd8db2ee7590308e81afc0b0e5c86dd", 89 118 "id": null, 90 119 "metadata": {}, 91 120 "name": "ProfileSettingsUploadBlobMutation", 92 121 "operationKind": "mutation", 93 - "text": "mutation ProfileSettingsUploadBlobMutation(\n $data: String!\n $mimeType: String!\n) {\n uploadBlob(data: $data, mimeType: $mimeType) {\n blob\n }\n}\n" 122 + "text": "mutation ProfileSettingsUploadBlobMutation(\n $data: String!\n $mimeType: String!\n) {\n uploadBlob(data: $data, mimeType: $mimeType) {\n blob {\n ref\n mimeType\n size\n }\n }\n}\n" 94 123 } 95 124 }; 96 125 })(); 97 126 98 - (node as any).hash = "76da65b07a282ed7f2dee12b4cac82d6"; 127 + (node as any).hash = "74a3a8bf43181cd62d2e81c45be384e5"; 99 128 100 129 export default node;
+18 -13
frontend-v2/src/pages/ProfileSettings.tsx
··· 1 - import { useParams, Link } from "react-router-dom"; 1 + import { Link, useParams } from "react-router-dom"; 2 2 import { useState } from "react"; 3 3 import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; 4 4 import type { ProfileSettingsQuery } from "../__generated__/ProfileSettingsQuery.graphql.ts"; ··· 44 44 where: { 45 45 actorHandle: { eq: handle }, 46 46 }, 47 - } 47 + }, 48 48 ); 49 49 50 50 const profile = data.networkSlicesActorProfiles.edges[0]?.node; ··· 59 59 graphql` 60 60 mutation ProfileSettingsUploadBlobMutation($data: String!, $mimeType: String!) { 61 61 uploadBlob(data: $data, mimeType: $mimeType) { 62 - blob 62 + blob { 63 + ref 64 + mimeType 65 + size 66 + } 63 67 } 64 68 } 65 - ` 69 + `, 66 70 ); 67 71 68 72 const [commitUpdateProfile, isUpdatingProfile] = useMutation( ··· 80 84 } 81 85 } 82 86 } 83 - ` 87 + `, 84 88 ); 85 89 86 90 const [commitCreateProfile, isCreatingProfile] = useMutation( ··· 98 102 } 99 103 } 100 104 } 101 - ` 105 + `, 102 106 ); 103 107 104 108 // Helper to convert File to base64 ··· 108 112 reader.onload = () => { 109 113 const arrayBuffer = reader.result as ArrayBuffer; 110 114 const bytes = new Uint8Array(arrayBuffer); 111 - const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join(''); 115 + const binary = Array.from(bytes).map((b) => String.fromCharCode(b)) 116 + .join(""); 112 117 resolve(btoa(binary)); 113 118 }; 114 119 reader.onerror = reject; ··· 129 134 // Upload new avatar 130 135 const base64Data = await fileToBase64(avatarFile); 131 136 132 - const uploadResult = await new Promise<{ uploadBlob: { blob: unknown } }>((resolve, reject) => { 137 + const uploadResult = await new Promise< 138 + { uploadBlob: { blob: unknown } } 139 + >((resolve, reject) => { 133 140 commitUploadBlob({ 134 141 variables: { 135 142 data: base64Data, 136 143 mimeType: avatarFile.type, 137 144 }, 138 - onCompleted: (data) => resolve(data as { uploadBlob: { blob: unknown } }), 145 + onCompleted: (data) => 146 + resolve(data as { uploadBlob: { blob: unknown } }), 139 147 onError: (error) => reject(error), 140 148 }); 141 149 }); ··· 144 152 } else if (profile?.avatar) { 145 153 // Keep existing avatar - reconstruct blob with $type field for AT Protocol 146 154 avatarBlob = { 147 - $type: "blob", 148 - ref: { 149 - $link: profile.avatar.ref, 150 - }, 155 + ref: profile.avatar.ref, 151 156 mimeType: profile.avatar.mimeType, 152 157 size: profile.avatar.size, 153 158 };
+11 -11
packages/session/src/adapters/postgres.ts
··· 6 6 user_id: string; 7 7 handle: string | null; 8 8 is_authenticated: boolean; 9 - data: string | null; 10 - created_at: Date; 11 - expires_at: Date; 12 - last_accessed_at: Date; 9 + data: Record<string, unknown> | null; 10 + created_at: number; 11 + expires_at: number; 12 + last_accessed_at: number; 13 13 } 14 14 15 15 export class PostgresAdapter implements SessionAdapter { ··· 100 100 data.userId, 101 101 data.handle || null, 102 102 data.isAuthenticated, 103 - data.data ? JSON.stringify(data.data) : null, 103 + data.data || null, 104 104 data.createdAt, 105 105 data.expiresAt, 106 106 data.lastAccessedAt, ··· 116 116 updates: Partial<SessionData> 117 117 ): Promise<boolean> { 118 118 const setParts: string[] = []; 119 - const values: (string | number | boolean | null)[] = []; 119 + const values: (string | number | boolean | null | Record<string, unknown>)[] = []; 120 120 let paramIndex = 1; 121 121 122 122 if (updates.userId !== undefined) { ··· 136 136 137 137 if (updates.data !== undefined) { 138 138 setParts.push(`data = $${paramIndex++}`); 139 - values.push(updates.data ? JSON.stringify(updates.data) : null); 139 + values.push(updates.data || null); 140 140 } 141 141 142 142 if (updates.expiresAt !== undefined) { ··· 226 226 userId: row.user_id, 227 227 handle: row.handle || undefined, 228 228 isAuthenticated: row.is_authenticated, 229 - data: row.data ? JSON.parse(row.data) : undefined, 230 - createdAt: row.created_at.getTime(), 231 - expiresAt: row.expires_at.getTime(), 232 - lastAccessedAt: row.last_accessed_at.getTime(), 229 + data: row.data || undefined, 230 + createdAt: row.created_at, 231 + expiresAt: row.expires_at, 232 + lastAccessedAt: row.last_accessed_at, 233 233 }; 234 234 } 235 235