Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 9 additions & 0 deletions .changeset/did-format-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@hypercerts-org/sdk-core": minor
---

Add `isValidDid()` utility function for DID format validation

- Validates DID format (did:method:identifier) with support for numeric method names per W3C spec
- Exported from `@hypercerts-org/sdk-core` for consumer use
- `BlobOperationsImpl` constructor now validates `repoDid` and throws `ValidationError` for invalid formats
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
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
coderabbitai[bot] 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 @@ -197,7 +197,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";
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 @@ -10,7 +10,8 @@
import type { Agent } from "@atproto/api";
import { BlobRef } from "@atproto/lexicon";
import { CID } from "multiformats/cid";
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 @@ -68,7 +69,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
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);
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

This test case is a duplicate of the test on line 60-62. Both test the same input "did:plc:" with the same expectation. Consider either removing this duplicate or modifying it to test a different edge case, such as "did:method:identifier:" (trailing colon after identifier).

Suggested change
it("should reject did:method: with empty identifier", () => {
expect(isValidDid("did:plc:")).toBe(false);
it("should reject did with trailing colon after identifier", () => {
expect(isValidDid("did:plc:abc123:")).toBe(false);

Copilot uses AI. Check for mistakes.
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
});
});
});
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