Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changeset/did-format-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@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.

> **⚠️ Potentially breaking:** callers that previously passed invalid DID strings to `BlobOperationsImpl` (directly or
> via `Repository`) will now receive a `ValidationError` at construction time instead of silently accepting the value.
> Use `isValidDid(repoDid)` to check before constructing if needed.
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
28 changes: 28 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,34 @@ 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 . _ : % -
// method-specific-id must end with at least one non-colon idchar (W3C DID Core 1.0)
return /^did:[a-z0-9]+:(?:[a-zA-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 with trailing colon in identifier", () => {
expect(isValidDid("did:example:abc:")).toBe(false);
});
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