Skip to content
Open
Show file tree
Hide file tree
Changes from all 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);
```
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ coverage.json
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
.DS_Store
/.idea
stats.html
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,6 @@
"@hypercerts-org/lexicon": "0.10.0-beta.13",
"eventemitter3": "^5.0.1",
"type-fest": "^5.4.1",
"zod": "^3.24.4"
"zod": "^3.25.76"
}
}
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 digits, 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 and digits (per W3C DID Core spec)
// Identifier: alphanumeric plus . _ : % -
return /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/.test(did);
}
Comment thread
aspiers marked this conversation as resolved.

/**
* OAuth session with DPoP (Demonstrating Proof of Possession) support.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export type {
ContributorIdentityParams,
CreateContributorInformationParams,
ResolvedContributorIdentity,
BlobInput,
} from "./repository/interfaces.js";

// ============================================================================
Expand Down Expand Up @@ -195,7 +196,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
7 changes: 4 additions & 3 deletions packages/sdk-core/src/repository/HypercertOperationsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
const uploadResult = await this.blobs.upload(content);
return {
$type: "org.hypercerts.defs#smallBlob" as const,
blob: uploadResult,
blob: this.blobToJsonRef(uploadResult),
};
}

Expand All @@ -974,11 +974,12 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
}

const uploadResult = await this.blobs.upload(input);
const blobRef = this.blobToJsonRef(uploadResult);
if (isBanner) {
return { $type: "org.hypercerts.defs#largeImage" as const, image: uploadResult };
return { $type: "org.hypercerts.defs#largeImage" as const, image: blobRef };
}

return { $type: "org.hypercerts.defs#smallImage" as const, image: uploadResult };
return { $type: "org.hypercerts.defs#smallImage" as const, image: blobRef };
}

/**
Expand Down
54 changes: 48 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,59 @@ export class ProfileOperationsImpl implements ProfileOperations {
}
}

/**
* Checks if a value is an existing JsonBlobRef.
*
* 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";
}

/**
* 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
80 changes: 80 additions & 0 deletions packages/sdk-core/tests/core/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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);
});

it("should accept DID with method containing digits", () => {
expect(isValidDid("did:key2:abc123")).toBe(true);
});

it("should accept DID with method containing multiple digits", () => {
expect(isValidDid("did:btc1:xyz789")).toBe(true);
});

it("should accept DID with method that is all digits", () => {
expect(isValidDid("did:123:identifier")).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 random URL", () => {
expect(isValidDid("https://example.com")).toBe(false);
});

it("should reject AT-URI", () => {
expect(isValidDid("at://did:plc:abc123/collection/rkey")).toBe(false);
});
});
});
Loading