Skip to content

Commit 52299cf

Browse files
authored
add canonical export of validation rules (#93)
* add canonical export of validation rules * fixes * use serde camelCase for JS limits
1 parent a46e4ba commit 52299cf

File tree

11 files changed

+282
-138
lines changed

11 files changed

+282
-138
lines changed

pkg/README.md

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,12 @@ async function createUser(pubkyId) {
7171
const specs = new PubkySpecsBuilder(pubkyId);
7272

7373
// Create user object with minimal fields
74-
const {user, meta} = specs.createUser(
74+
const { user, meta } = specs.createUser(
7575
"Alice", // Name
7676
"Hello from WASM", // Bio
7777
null, // Image URL or File
7878
null, // Links
79-
"active" // Status
79+
"active", // Status
8080
);
8181

8282
// meta contains { id, path, url }.
@@ -112,12 +112,12 @@ async function createPost(pubkyId, content) {
112112
const specs = new PubkySpecsBuilder(pubkyId);
113113

114114
// Create the Post object
115-
const {post, meta} = specs.createPost(
115+
const { post, meta } = specs.createPost(
116116
content,
117117
PubkyAppPostKind.Short,
118118
null, // parent post URI (for replies)
119119
null, // embed object (for reposts)
120-
null // attachments (array of file URLs, max 3)
120+
null, // attachments (array of file URLs, max 3)
121121
);
122122

123123
// Store the post
@@ -128,7 +128,7 @@ async function createPost(pubkyId, content) {
128128
});
129129

130130
console.log("Post stored at:", meta.url);
131-
return {post, meta};
131+
return { post, meta };
132132
}
133133
```
134134

@@ -143,12 +143,12 @@ async function createPostWithAttachments(pubkyId, content, fileUrls) {
143143
const specs = new PubkySpecsBuilder(pubkyId);
144144

145145
// Create post with attachments (max 3 allowed)
146-
const {post, meta} = specs.createPost(
146+
const { post, meta } = specs.createPost(
147147
content,
148148
PubkyAppPostKind.Image,
149149
null, // parent
150150
null, // embed
151-
fileUrls // e.g. ["pubky://user/pub/pubky.app/files/abc123"]
151+
fileUrls, // e.g. ["pubky://user/pub/pubky.app/files/abc123"]
152152
);
153153

154154
const postJson = post.toJson();
@@ -160,7 +160,7 @@ async function createPostWithAttachments(pubkyId, content, fileUrls) {
160160
});
161161

162162
console.log("Post with attachments stored at:", meta.url);
163-
return {post, meta};
163+
return { post, meta };
164164
}
165165
```
166166

@@ -174,7 +174,7 @@ async function followUser(myPubkyId, userToFollow) {
174174
const client = new Client();
175175
const specs = new PubkySpecsBuilder(myPubkyId);
176176

177-
const {follow, meta} = specs.createFollow(userToFollow);
177+
const { follow, meta } = specs.createFollow(userToFollow);
178178

179179
// We only need to store the JSON in the homeserver
180180
await client.fetch(meta.url, {
@@ -215,18 +215,18 @@ async function uploadFile(pubkyId, fileData, fileName, contentType, fileSize) {
215215

216216
// First, create and store the blob (raw binary data)
217217
const { blob, meta: blobMeta } = specs.createBlob(fileData);
218-
218+
219219
await client.fetch(blobMeta.url, {
220220
method: "PUT",
221221
body: JSON.stringify(blob.toJson()),
222222
});
223223

224224
// Then create the file metadata pointing to the blob
225225
const { file, meta: fileMeta } = specs.createFile(
226-
fileName, // e.g. "vacation-photo.jpg"
227-
blobMeta.url, // Reference to the blob
228-
contentType, // e.g. "image/jpeg"
229-
fileSize // Size in bytes
226+
fileName, // e.g. "vacation-photo.jpg"
227+
blobMeta.url, // Reference to the blob
228+
contentType, // e.g. "image/jpeg"
229+
fileSize, // Size in bytes
230230
);
231231

232232
await client.fetch(fileMeta.url, {
@@ -287,16 +287,16 @@ const userId = "8kkppkmiubfq4pxn6f73nqrhhhgkb5xyfprntc9si3np9ydbotto";
287287
const targetUserId = "dzswkfy7ek3bqnoc89jxuqqfbzhjrj6mi8qthgbxxcqkdugm3rio";
288288

289289
// Build URIs for different resources
290-
userUriBuilder(userId); // pubky://{userId}/pub/pubky.app/profile.json
291-
postUriBuilder(userId, "0033SSE3B1FQ0"); // pubky://{userId}/pub/pubky.app/posts/{postId}
292-
bookmarkUriBuilder(userId, "ABC123"); // pubky://{userId}/pub/pubky.app/bookmarks/{bookmarkId}
293-
followUriBuilder(userId, targetUserId); // pubky://{userId}/pub/pubky.app/follows/{targetUserId}
294-
tagUriBuilder(userId, "XYZ789"); // pubky://{userId}/pub/pubky.app/tags/{tagId}
295-
muteUriBuilder(userId, targetUserId); // pubky://{userId}/pub/pubky.app/mutes/{targetUserId}
296-
lastReadUriBuilder(userId); // pubky://{userId}/pub/pubky.app/last_read
297-
blobUriBuilder(userId, "BLOB123"); // pubky://{userId}/pub/pubky.app/blobs/{blobId}
298-
fileUriBuilder(userId, "FILE456"); // pubky://{userId}/pub/pubky.app/files/{fileId}
299-
feedUriBuilder(userId, "FEED789"); // pubky://{userId}/pub/pubky.app/feeds/{feedId}
290+
userUriBuilder(userId); // pubky://{userId}/pub/pubky.app/profile.json
291+
postUriBuilder(userId, "0033SSE3B1FQ0"); // pubky://{userId}/pub/pubky.app/posts/{postId}
292+
bookmarkUriBuilder(userId, "ABC123"); // pubky://{userId}/pub/pubky.app/bookmarks/{bookmarkId}
293+
followUriBuilder(userId, targetUserId); // pubky://{userId}/pub/pubky.app/follows/{targetUserId}
294+
tagUriBuilder(userId, "XYZ789"); // pubky://{userId}/pub/pubky.app/tags/{tagId}
295+
muteUriBuilder(userId, targetUserId); // pubky://{userId}/pub/pubky.app/mutes/{targetUserId}
296+
lastReadUriBuilder(userId); // pubky://{userId}/pub/pubky.app/last_read
297+
blobUriBuilder(userId, "BLOB123"); // pubky://{userId}/pub/pubky.app/blobs/{blobId}
298+
fileUriBuilder(userId, "FILE456"); // pubky://{userId}/pub/pubky.app/files/{fileId}
299+
feedUriBuilder(userId, "FEED789"); // pubky://{userId}/pub/pubky.app/feeds/{feedId}
300300
```
301301

302302
---
@@ -330,6 +330,52 @@ A `ParsedUriResult` object with:
330330

331331
---
332332

333+
## Validation limits
334+
335+
The WASM builder exposes validation limits as a JSON object so UIs can reuse
336+
the canonical rules without duplicating magic numbers.
337+
338+
```js
339+
import init, { PubkySpecsBuilder } from "pubky-app-specs";
340+
341+
await init();
342+
const builder = new PubkySpecsBuilder("pubky_id_here");
343+
const limits = builder.validationLimits;
344+
345+
console.log(limits);
346+
```
347+
348+
Example output shape:
349+
350+
```json
351+
{
352+
"maxBlobSizeBytes": 104857600,
353+
"maxFileSizeBytes": 104857600,
354+
"tagLabelMinLength": 1,
355+
"tagLabelMaxLength": 20,
356+
"tagInvalidChars": [",", ":", " ", "\t", "\n", "\r"],
357+
"userNameMinLength": 3,
358+
"userNameMaxLength": 50,
359+
"userBioMaxLength": 160,
360+
"userImageUrlMaxLength": 300,
361+
"userLinksMaxCount": 5,
362+
"userLinkTitleMaxLength": 100,
363+
"userLinkUrlMaxLength": 300,
364+
"userStatusMaxLength": 50,
365+
"postShortContentMaxLength": 2000,
366+
"postLongContentMaxLength": 50000,
367+
"postAttachmentsMaxCount": 3,
368+
"postAttachmentUrlMaxLength": 200,
369+
"postAllowedAttachmentProtocols": ["pubky", "http", "https"],
370+
"fileNameMinLength": 1,
371+
"fileNameMaxLength": 255,
372+
"fileSrcMaxLength": 1024,
373+
"feedTagsMaxCount": 5
374+
}
375+
```
376+
377+
---
378+
333379
## 📄 License
334380

335381
MIT

src/constants.rs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,3 @@ pub static VERSION: &str = "0.4.0";
55
pub static PUBLIC_PATH: &str = "/pub/";
66
pub static APP_PATH: &str = "pubky.app/";
77
pub static PROTOCOL: &str = "pubky://";
8-
9-
// Size limits
10-
/// Maximum blob/file size (100 MB) in bytes
11-
pub static MAX_SIZE: usize = 100 * (1 << 20); // 100MB
12-
13-
// Tag validation constants (shared across models)
14-
pub const MAX_TAG_LABEL_LENGTH: usize = 20;
15-
pub const MIN_TAG_LABEL_LENGTH: usize = 1;
16-
/// Disallowed characters, in addition to whitespace chars
17-
pub const INVALID_TAG_CHARS: &[char] = &[',', ':'];

src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
mod common;
22
mod constants;
3+
pub mod limits;
34
mod models;
45
pub mod traits;
56
mod types;
67
mod uri_parser;
78
mod utils;
89

910
// Re-export constants
10-
pub use constants::{
11-
APP_PATH, INVALID_TAG_CHARS, MAX_SIZE, MAX_TAG_LABEL_LENGTH, MIN_TAG_LABEL_LENGTH, PROTOCOL,
12-
PUBLIC_PATH, VERSION,
13-
};
11+
pub use constants::{APP_PATH, PROTOCOL, PUBLIC_PATH, VERSION};
12+
#[doc(inline)]
13+
pub use limits::*;
1414
// Re-export domain types
1515
pub use models::blob::PubkyAppBlob;
1616
pub use models::bookmark::PubkyAppBookmark;

src/limits.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//! Validation limits for pubky-app-specs data models.
2+
//!
3+
//! These constants are the single source of truth for client-side validation.
4+
//!
5+
//! # Examples
6+
//! Serialize the bundled limits for client consumption.
7+
//! ```
8+
//! use pubky_app_specs::VALIDATION_LIMITS;
9+
//!
10+
//! let limits_json = serde_json::to_value(&VALIDATION_LIMITS).unwrap();
11+
//! assert!(limits_json.is_object());
12+
//! ```
13+
14+
use serde::Serialize;
15+
16+
/// Bundled validation limits for quick consumption.
17+
#[derive(Debug, Clone, Copy, Serialize)]
18+
#[serde(rename_all = "camelCase")]
19+
pub struct ValidationLimits {
20+
/// Maximum blob/file size in bytes.
21+
///
22+
/// Chosen to cap user storage per upload and align with homeserver limits.
23+
pub max_blob_size_bytes: usize,
24+
/// Maximum file size in bytes.
25+
///
26+
/// Kept in sync with blob validation since files are blob-backed.
27+
pub max_file_size_bytes: usize,
28+
/// Minimum number of characters for tag labels.
29+
pub tag_label_min_length: usize,
30+
/// Maximum number of characters for tag labels.
31+
pub tag_label_max_length: usize,
32+
/// Disallowed characters, including common whitespace.
33+
pub tag_invalid_chars: &'static [char],
34+
/// Minimum username length in characters.
35+
pub user_name_min_length: usize,
36+
/// Maximum username length in characters.
37+
pub user_name_max_length: usize,
38+
/// Maximum bio length in characters.
39+
pub user_bio_max_length: usize,
40+
/// Maximum image URL length in characters.
41+
pub user_image_url_max_length: usize,
42+
/// Maximum number of profile links.
43+
pub user_links_max_count: usize,
44+
/// Maximum link title length in characters.
45+
pub user_link_title_max_length: usize,
46+
/// Maximum link URL length in characters.
47+
pub user_link_url_max_length: usize,
48+
/// Maximum status length in characters.
49+
pub user_status_max_length: usize,
50+
/// Maximum character count for short posts.
51+
pub post_short_content_max_length: usize,
52+
/// Maximum character count for long posts.
53+
pub post_long_content_max_length: usize,
54+
/// Maximum number of attachments per post.
55+
pub post_attachments_max_count: usize,
56+
/// Maximum length for attachment URLs.
57+
pub post_attachment_url_max_length: usize,
58+
/// Allowed protocols for attachment URLs.
59+
pub post_allowed_attachment_protocols: &'static [&'static str],
60+
/// Minimum file name length in characters.
61+
pub file_name_min_length: usize,
62+
/// Maximum file name length in characters.
63+
pub file_name_max_length: usize,
64+
/// Maximum file src length in characters.
65+
pub file_src_max_length: usize,
66+
/// Maximum number of tags allowed in a feed.
67+
pub feed_tags_max_count: usize,
68+
}
69+
70+
/// All validation limits in a single bundle.
71+
pub const VALIDATION_LIMITS: ValidationLimits = ValidationLimits {
72+
max_blob_size_bytes: 100 * (1 << 20), // 100 MB cap aligned with homeserver limits.
73+
max_file_size_bytes: 100 * (1 << 20), // Kept in sync with blob validation.
74+
tag_label_min_length: 1,
75+
tag_label_max_length: 20,
76+
tag_invalid_chars: &[',', ':', ' ', '\t', '\n', '\r'],
77+
user_name_min_length: 3,
78+
user_name_max_length: 50,
79+
user_bio_max_length: 160,
80+
user_image_url_max_length: 300,
81+
user_links_max_count: 5,
82+
user_link_title_max_length: 100,
83+
user_link_url_max_length: 300,
84+
user_status_max_length: 50,
85+
post_short_content_max_length: 2000,
86+
post_long_content_max_length: 50_000,
87+
post_attachments_max_count: 3,
88+
post_attachment_url_max_length: 200,
89+
post_allowed_attachment_protocols: &["pubky", "http", "https"],
90+
file_name_min_length: 1,
91+
file_name_max_length: 255,
92+
file_src_max_length: 1024,
93+
feed_tags_max_count: 5,
94+
};

src/models/blob.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{
2-
constants::MAX_SIZE,
2+
limits::VALIDATION_LIMITS,
33
traits::{HasIdPath, HashId, Validatable},
44
APP_PATH, PUBLIC_PATH,
55
};
@@ -93,7 +93,7 @@ impl Validatable for PubkyAppBlob {
9393
if self.0.is_empty() {
9494
return Err("Validation Error: Blob size cannot be zero".to_string());
9595
}
96-
if self.0.len() > MAX_SIZE {
96+
if self.0.len() > VALIDATION_LIMITS.max_blob_size_bytes {
9797
return Err("Validation Error: Blob size exceeds maximum limit of 100MB".to_string());
9898
}
9999

@@ -141,7 +141,7 @@ mod tests {
141141
#[test]
142142
fn test_validate_size_errors() {
143143
// Test blob at max size (should pass)
144-
let max_size_blob = PubkyAppBlob(vec![0; MAX_SIZE]);
144+
let max_size_blob = PubkyAppBlob(vec![0; VALIDATION_LIMITS.max_blob_size_bytes]);
145145
let id = max_size_blob.create_id();
146146
let result = max_size_blob.validate(Some(&id));
147147
assert!(result.is_ok(), "Blob at max size should be valid");
@@ -154,7 +154,7 @@ mod tests {
154154
assert!(result.unwrap_err().contains("cannot be zero"));
155155

156156
// Test blob exceeding max size (should fail)
157-
let oversized_blob = PubkyAppBlob(vec![0; MAX_SIZE + 1]);
157+
let oversized_blob = PubkyAppBlob(vec![0; VALIDATION_LIMITS.max_blob_size_bytes + 1]);
158158
let id = oversized_blob.create_id();
159159
let result = oversized_blob.validate(Some(&id));
160160
assert!(result.is_err());

0 commit comments

Comments
 (0)