+24
-11
crates/slices-lexicon/src/validation/primitive/string.rs
+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
+1
-1
frontend-v2/schema.graphql
+8
-7
frontend-v2/server/profile-init.ts
+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
+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
+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
+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