Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions .changeset/add-richtext-utility.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,24 @@

Add RichText utility functions for auto-detecting facets from text

**0.x Versioning Note:** This is a non-breaking addition. No existing APIs are modified or removed.

New utility functions to simplify creating rich text facets:

- `createFacetsFromText(text, agent?)` - async function that auto-detects URLs, hashtags, and @mentions. If an agent is
provided, resolves mentions to DIDs.
- `createFacetsFromTextSync(text)` - sync function for fast detection without mention resolution
provided, resolves mentions to DIDs; otherwise mentions use the handle string as the DID.
- `createFacetsFromTextSync(text)` - sync function for fast detection without mention resolution (mentions use handle as
DID)
- Re-exports `RichText` class from `@atproto/api` for advanced use cases

**Usage:**

```typescript
import { createFacetsFromText, createFacetsFromTextSync } from "@hypercerts-org/sdk-core";

// Sync (no DID resolution)
const facets = createFacetsFromTextSync("Check out #hypercerts by @alice");

// Async with DID resolution (requires authenticated agent)
const facets = await createFacetsFromText("Check out #hypercerts by @alice", agent);
```
27 changes: 27 additions & 0 deletions packages/sdk-core/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ import { z } from "zod";
*/
export type DID = string;

/**
* Validates that a string is a valid DID format.
*
* DIDs must follow the format: `did:<method>:<method-specific-id>`
* where method is lowercase letters and the identifier contains
* alphanumeric characters plus `.`, `_`, `:`, `%`, and `-`.
*
* @param did - The string to validate
* @returns true if the string is a valid DID format
*
* @example
* ```typescript
* isValidDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz"); // true
* isValidDid("did:web:example.com"); // true
* isValidDid("not-a-did"); // false
* isValidDid("did:"); // false
* ```
*
* @see https://www.w3.org/TR/did-core/#did-syntax for DID syntax specification
*/
export function isValidDid(did: string): boolean {
// DID format: did:<method>:<method-specific-id>
// Method: lowercase letters only
// Identifier: alphanumeric plus . _ : % -
return /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/.test(did);
Comment thread
aspiers marked this conversation as resolved.
Outdated
}
Comment thread
aspiers marked this conversation as resolved.

/**
* OAuth session with DPoP (Demonstrating Proof of Possession) support.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export { InMemoryStateStore } from "./storage/InMemoryStateStore.js";

// Core types and schemas
export type { DID, Organization, Collaborator, CollaboratorPermissions } from "./core/types.js";
export { OrganizationSchema, CollaboratorSchema, CollaboratorPermissionsSchema } from "./core/types.js";
export { OrganizationSchema, CollaboratorSchema, CollaboratorPermissionsSchema, isValidDid } from "./core/types.js";
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BlobInput type is not exported from the main index.ts file, even though it's mentioned in the PR description as being exported. This type is useful for consumers who want type-safe usage when passing blob parameters to profile operations.

The type should be exported alongside other repository types in index.ts to ensure it's available to SDK consumers.

Copilot uses AI. Check for mistakes.
export { ATProtoSDKConfigSchema, OAuthConfigSchema, ServerConfigSchema, TimeoutConfigSchema } from "./core/config.js";

// OAuth Permissions System
Expand Down
11 changes: 9 additions & 2 deletions packages/sdk-core/src/repository/BlobOperationsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
*/

import type { Agent } from "@atproto/api";
import { NetworkError } from "../core/errors.js";
import { NetworkError, ValidationError } from "../core/errors.js";
import { isValidDid } from "../core/types.js";
import type { BlobOperations } from "./interfaces.js";

/**
Expand Down Expand Up @@ -66,7 +67,13 @@ export class BlobOperationsImpl implements BlobOperations {
private repoDid: string,
private _serverUrl: string,
private isSDS: boolean,
) {}
) {
if (!isValidDid(repoDid)) {
throw new ValidationError(
`Invalid DID format: "${repoDid}". DIDs must start with "did:" (e.g., "did:plc:abc123")`,
);
}
}

/**
* Uploads a blob to the server.
Expand Down
46 changes: 40 additions & 6 deletions packages/sdk-core/src/repository/ProfileOperationsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
*/

import type { Agent } from "@atproto/api";
import type { JsonBlobRef } from "@atproto/lexicon";
import { NetworkError } from "../core/errors.js";
import type { BlobOperations, ProfileOperations, ProfileParams } from "./interfaces.js";
import type { BlobInput, BlobOperations, ProfileOperations, ProfileParams } from "./interfaces.js";
import type { CreateResult, UpdateResult } from "./types.js";
import { uploadResultToBlobRef } from "./types.js";

Expand Down Expand Up @@ -44,10 +45,14 @@ import { uploadResultToBlobRef } from "./types.js";
* description: "Updated bio",
* });
*
* // Update with new avatar
* // Update with new avatar (Blob - will be uploaded)
* const avatarBlob = new Blob([imageData], { type: "image/png" });
* await repo.profile.update({ avatar: avatarBlob });
*
* // Update with existing blob reference (no re-upload)
* const existingRef = { $type: "blob", ref: { $link: "bafyrei..." }, mimeType: "image/png", size: 1234 };
* await repo.profile.update({ avatar: existingRef });
*
* // Remove a field
* await repo.profile.update({ website: null });
* ```
Expand Down Expand Up @@ -85,22 +90,51 @@ export class ProfileOperationsImpl implements ProfileOperations {
}
}

/**
* Checks if a value is an existing JsonBlobRef.
*
* JsonBlobRef has the structure: { $type: "blob", ref: { $link }, mimeType, size }
*
* @internal
*/
private isJsonBlobRef(value: unknown): value is JsonBlobRef {
return (
typeof value === "object" &&
value !== null &&
"$type" in value &&
(value as Record<string, unknown>).$type === "blob" &&
"ref" in value &&
"mimeType" in value &&
"size" in value
);
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isJsonBlobRef type guard does not validate the nested structure of the ref field. According to the AT Protocol specification, JsonBlobRef.ref must be an object with a $link property containing the CID string.

The current implementation only checks for the presence of the ref property but doesn't verify that it has the correct structure { $link: string }. This could allow malformed blob references to pass validation, potentially causing runtime errors when the blob ref is used.

Consider adding validation for the nested structure:

"ref" in value &&
typeof (value as Record<string, unknown>).ref === "object" &&
(value as Record<string, unknown>).ref !== null &&
"$link" in (value as Record<string, unknown>).ref
Suggested change
* JsonBlobRef has the structure: { $type: "blob", ref: { $link }, mimeType, size }
*
* @internal
*/
private isJsonBlobRef(value: unknown): value is JsonBlobRef {
return (
typeof value === "object" &&
value !== null &&
"$type" in value &&
(value as Record<string, unknown>).$type === "blob" &&
"ref" in value &&
"mimeType" in value &&
"size" in value
);
* JsonBlobRef has the structure: { $type: "blob", ref: { $link: string }, mimeType, size }
*
* @internal
*/
private isJsonBlobRef(value: unknown): value is JsonBlobRef {
if (typeof value !== "object" || value === null) {
return false;
}
const record = value as Record<string, unknown>;
if (record.$type !== "blob" || !("ref" in record) || !("mimeType" in record) || !("size" in record)) {
return false;
}
const ref = record.ref;
if (typeof ref !== "object" || ref === null) {
return false;
}
const refRecord = ref as Record<string, unknown>;
return typeof refRecord.$link === "string";

Copilot uses AI. Check for mistakes.
}

/**
* Applies a blob field to the profile, uploading if needed.
*
* Handles three input types:
* - undefined: Field is not modified
* - null: Field is removed from the profile
* - Blob: Uploaded and converted to JsonBlobRef
* - JsonBlobRef: Used directly without re-uploading
*
* @internal
*/
private async applyBlobField(
result: Record<string, unknown>,
field: string,
blob: Blob | null | undefined,
input: BlobInput | null | undefined,
): Promise<void> {
if (blob === undefined) return;
if (input === undefined) return;

if (blob === null) {
if (input === null) {
delete result[field];
} else if (this.isJsonBlobRef(input)) {
// Use existing blob ref directly
result[field] = input;
} else {
const uploadResult = await this.blobs.upload(blob);
// Upload new blob
const uploadResult = await this.blobs.upload(input);
result[field] = uploadResultToBlobRef(uploadResult);
}
}
Expand Down
38 changes: 36 additions & 2 deletions packages/sdk-core/src/repository/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import type { AppBskyRichtextFacet } from "@atproto/api";
import type { JsonBlobRef } from "@atproto/lexicon";
import type { EventEmitter } from "eventemitter3";
import type {
LocationParams,
Expand Down Expand Up @@ -656,6 +657,29 @@ export interface BlobOperations {
* });
* ```
*/
/**
* Input type for blob fields that accepts either new data or existing references.
*
* Accepts either:
* - A Blob to be uploaded (will be converted to JsonBlobRef)
* - An existing JsonBlobRef (used directly without re-uploading)
*
* This allows reusing previously uploaded blobs without re-uploading them.
*
* @example Upload new data
* ```typescript
* const imageBlob = new Blob([imageData], { type: "image/png" });
* await repo.profile.update({ avatar: imageBlob });
* ```
*
* @example Reuse an existing blob reference
* ```typescript
* const existingRef = { $type: "blob", ref: { $link: "bafyrei..." }, mimeType: "image/png", size: 1234 };
* await repo.profile.update({ avatar: existingRef });
* ```
*/
export type BlobInput = Blob | JsonBlobRef;

/**
* Parameters for creating or updating a profile.
*
Expand All @@ -670,8 +694,18 @@ export interface ProfileParams {
createdAt?: string;
displayName?: string | null;
description?: string | null;
avatar?: Blob | null;
banner?: Blob | null;
/**
* Profile avatar image.
* Can be a Blob (will be uploaded) or an existing JsonBlobRef (used directly).
* Pass null to remove the avatar.
*/
avatar?: BlobInput | null;
/**
* Profile banner image.
* Can be a Blob (will be uploaded) or an existing JsonBlobRef (used directly).
* Pass null to remove the banner.
*/
banner?: BlobInput | null;
website?: string | null;
}

Expand Down
72 changes: 72 additions & 0 deletions packages/sdk-core/tests/core/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { isValidDid } from "../../src/core/types.js";

describe("isValidDid", () => {
describe("valid DIDs", () => {
it("should accept did:plc format", () => {
expect(isValidDid("did:plc:abc123")).toBe(true);
});

it("should accept did:web format", () => {
expect(isValidDid("did:web:example.com")).toBe(true);
});

it("should accept DID with alphanumeric identifier", () => {
expect(isValidDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz")).toBe(true);
});

it("should accept DID with dots in identifier", () => {
expect(isValidDid("did:web:sub.example.com")).toBe(true);
});

it("should accept DID with colons in identifier", () => {
expect(isValidDid("did:web:example.com:user:123")).toBe(true);
});

it("should accept DID with percent-encoded characters", () => {
expect(isValidDid("did:example:abc%20def")).toBe(true);
});

it("should accept DID with hyphens and underscores", () => {
expect(isValidDid("did:example:my-test_id")).toBe(true);
});
});

describe("invalid DIDs", () => {
it("should reject empty string", () => {
expect(isValidDid("")).toBe(false);
});

it("should reject string not starting with did:", () => {
expect(isValidDid("not-a-did")).toBe(false);
});

it("should reject did: without method", () => {
expect(isValidDid("did:")).toBe(false);
});

it("should reject did:method without identifier", () => {
expect(isValidDid("did:plc:")).toBe(false);
});

it("should reject did:method: with empty identifier", () => {
expect(isValidDid("did:plc:")).toBe(false);
});

it("should reject method with uppercase letters", () => {
expect(isValidDid("did:PLC:abc123")).toBe(false);
});

it("should reject method with numbers", () => {
expect(isValidDid("did:plc2:abc123")).toBe(false);
});
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests should include cases for DID methods that contain digits (e.g., did:key2:abc123, did:btc1:xyz789) to ensure the validation function correctly handles numeric characters in the method name. These are valid according to the W3C DID Core specification but would currently fail with the existing regex.

Copilot uses AI. Check for mistakes.

it("should reject random URL", () => {
expect(isValidDid("https://example.com")).toBe(false);
});

it("should reject AT-URI", () => {
expect(isValidDid("at://did:plc:abc123/collection/rkey")).toBe(false);
});
});
});
22 changes: 21 additions & 1 deletion packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Agent } from "@atproto/api";
import { BlobOperationsImpl } from "../../src/repository/BlobOperationsImpl.js";
import { NetworkError } from "../../src/core/errors.js";
import { NetworkError, ValidationError } from "../../src/core/errors.js";
import { createMockAgent, TEST_REPO_DID, TEST_PDS_URL, TEST_SDS_URL } from "../utils/mocks.js";

describe("BlobOperationsImpl", () => {
Expand All @@ -13,6 +13,26 @@ describe("BlobOperationsImpl", () => {
blobOps = new BlobOperationsImpl(mockAgent as unknown as Agent, TEST_REPO_DID, TEST_PDS_URL, false);
});

describe("constructor", () => {
it("should accept valid DID", () => {
expect(
() => new BlobOperationsImpl(mockAgent as unknown as Agent, "did:plc:abc123", TEST_PDS_URL, false),
).not.toThrow();
});

it("should throw ValidationError for invalid DID", () => {
expect(() => new BlobOperationsImpl(mockAgent as unknown as Agent, "not-a-did", TEST_PDS_URL, false)).toThrow(
ValidationError,
);
});

it("should include helpful error message with the invalid DID", () => {
expect(() => new BlobOperationsImpl(mockAgent as unknown as Agent, "invalid", TEST_PDS_URL, false)).toThrow(
/Invalid DID format: "invalid"/,
);
});
});

describe("upload", () => {
it("should upload a blob successfully", async () => {
const mockBlob = new Blob(["test content"], { type: "text/plain" });
Expand Down
Loading
Loading