diff --git a/.changeset/did-format-validation.md b/.changeset/did-format-validation.md new file mode 100644 index 00000000..19b33fa7 --- /dev/null +++ b/.changeset/did-format-validation.md @@ -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 + +> **⚠️ 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. diff --git a/.gitignore b/.gitignore index f381003b..f3bd65a7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ coverage.json npm-debug.log* yarn-debug.log* yarn-error.log* +package-lock.json .DS_Store /.idea stats.html diff --git a/packages/sdk-core/src/core/types.ts b/packages/sdk-core/src/core/types.ts index 1a5d688e..5612946f 100644 --- a/packages/sdk-core/src/core/types.ts +++ b/packages/sdk-core/src/core/types.ts @@ -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::` + * 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: 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); +} + /** * OAuth session with DPoP (Demonstrating Proof of Possession) support. * diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index 54166417..32018307 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -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 diff --git a/packages/sdk-core/src/repository/BlobOperationsImpl.ts b/packages/sdk-core/src/repository/BlobOperationsImpl.ts index 1e8aefd3..0529f492 100644 --- a/packages/sdk-core/src/repository/BlobOperationsImpl.ts +++ b/packages/sdk-core/src/repository/BlobOperationsImpl.ts @@ -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"; /** @@ -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. diff --git a/packages/sdk-core/tests/core/types.test.ts b/packages/sdk-core/tests/core/types.test.ts new file mode 100644 index 00000000..cc658429 --- /dev/null +++ b/packages/sdk-core/tests/core/types.test.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts index 199a50c7..d5be4ec1 100644 --- a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts @@ -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", () => { @@ -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" });