From 06befd9bbf4f6b424d420b5f83539d5a87107545 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 19:13:17 +0000 Subject: [PATCH 1/3] feat(sdk-core): add DID format validation - Add isValidDid() utility function to core/types.ts - Export isValidDid from main index for public use - Add DID validation in BlobOperationsImpl constructor - Add tests for DID validation (valid and invalid formats) This prevents cryptic errors from SDS endpoints when invalid DIDs are passed. Closes hypercerts-sdk-4ni --- packages/sdk-core/src/core/types.ts | 27 +++++++ packages/sdk-core/src/index.ts | 2 +- .../src/repository/BlobOperationsImpl.ts | 11 ++- packages/sdk-core/tests/core/types.test.ts | 72 +++++++++++++++++++ .../repository/BlobOperationsImpl.test.ts | 22 +++++- 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/sdk-core/tests/core/types.test.ts diff --git a/packages/sdk-core/src/core/types.ts b/packages/sdk-core/src/core/types.ts index 1a5d688e..a2b88f24 100644 --- a/packages/sdk-core/src/core/types.ts +++ b/packages/sdk-core/src/core/types.ts @@ -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::` + * 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: lowercase letters only + // Identifier: alphanumeric plus . _ : % - + return /^did:[a-z]+:[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..71b5de48 --- /dev/null +++ b/packages/sdk-core/tests/core/types.test.ts @@ -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); + }); + + 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" }); From 6f914e5a1f76ede52af4b8b75cf71dc935314dd7 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 19:14:16 +0000 Subject: [PATCH 2/3] fix(sdk-core): update DID regex to allow digits in method names per W3C spec - Update DID validation regex to allow digits in method names per W3C spec - Add test cases for DIDs with numeric method names (did:key2:, did:btc1:) - Update JSDoc to reflect digits allowed in DID method names - Add package-lock.json to .gitignore to prevent future commits - Add changeset for DID format validation feature --- .changeset/did-format-validation.md | 9 +++++++++ .gitignore | 1 + packages/sdk-core/src/core/types.ts | 6 +++--- packages/sdk-core/tests/core/types.test.ts | 16 ++++++++++++---- 4 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 .changeset/did-format-validation.md diff --git a/.changeset/did-format-validation.md b/.changeset/did-format-validation.md new file mode 100644 index 00000000..7f447917 --- /dev/null +++ b/.changeset/did-format-validation.md @@ -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 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 a2b88f24..7fde3f63 100644 --- a/packages/sdk-core/src/core/types.ts +++ b/packages/sdk-core/src/core/types.ts @@ -26,7 +26,7 @@ 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 the identifier contains + * where method is lowercase letters and digits, and the identifier contains * alphanumeric characters plus `.`, `_`, `:`, `%`, and `-`. * * @param did - The string to validate @@ -44,9 +44,9 @@ export type DID = string; */ export function isValidDid(did: string): boolean { // DID format: did:: - // Method: lowercase letters only + // Method: lowercase letters and digits (per W3C DID Core spec) // Identifier: alphanumeric plus . _ : % - - return /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/.test(did); + return /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/.test(did); } /** diff --git a/packages/sdk-core/tests/core/types.test.ts b/packages/sdk-core/tests/core/types.test.ts index 71b5de48..7c132a16 100644 --- a/packages/sdk-core/tests/core/types.test.ts +++ b/packages/sdk-core/tests/core/types.test.ts @@ -30,6 +30,18 @@ describe("isValidDid", () => { 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", () => { @@ -57,10 +69,6 @@ describe("isValidDid", () => { expect(isValidDid("did:PLC:abc123")).toBe(false); }); - it("should reject method with numbers", () => { - expect(isValidDid("did:plc2:abc123")).toBe(false); - }); - it("should reject random URL", () => { expect(isValidDid("https://example.com")).toBe(false); }); From df84d85afb5688a83b7c818e7f70fa238835f96e Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 19:26:35 +0000 Subject: [PATCH 3/3] fix(validation): tighten isValidDid regex and document breaking behaviour - Regex now rejects trailing colons in method-specific-id (e.g. did:x:abc:) per W3C DID Core 1.0 spec: id must end with at least one non-colon idchar - Replace duplicate test case (did:plc:) with trailing-colon case (did:example:abc:) - Changeset notes the potentially breaking constructor behaviour --- .changeset/did-format-validation.md | 4 ++++ packages/sdk-core/src/core/types.ts | 3 ++- packages/sdk-core/tests/core/types.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.changeset/did-format-validation.md b/.changeset/did-format-validation.md index 7f447917..19b33fa7 100644 --- a/.changeset/did-format-validation.md +++ b/.changeset/did-format-validation.md @@ -7,3 +7,7 @@ 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/packages/sdk-core/src/core/types.ts b/packages/sdk-core/src/core/types.ts index 7fde3f63..5612946f 100644 --- a/packages/sdk-core/src/core/types.ts +++ b/packages/sdk-core/src/core/types.ts @@ -46,7 +46,8 @@ export function isValidDid(did: string): boolean { // DID format: did:: // Method: lowercase letters and digits (per W3C DID Core spec) // Identifier: alphanumeric plus . _ : % - - return /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/.test(did); + // 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); } /** diff --git a/packages/sdk-core/tests/core/types.test.ts b/packages/sdk-core/tests/core/types.test.ts index 7c132a16..cc658429 100644 --- a/packages/sdk-core/tests/core/types.test.ts +++ b/packages/sdk-core/tests/core/types.test.ts @@ -61,8 +61,8 @@ describe("isValidDid", () => { expect(isValidDid("did:plc:")).toBe(false); }); - it("should reject did:method: with empty 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", () => {