Skip to content

Commit 06befd9

Browse files
committed
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
1 parent 935769e commit 06befd9

5 files changed

Lines changed: 130 additions & 4 deletions

File tree

packages/sdk-core/src/core/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,33 @@ import { z } from "zod";
2222
*/
2323
export type DID = string;
2424

25+
/**
26+
* Validates that a string is a valid DID format.
27+
*
28+
* DIDs must follow the format: `did:<method>:<method-specific-id>`
29+
* where method is lowercase letters and the identifier contains
30+
* alphanumeric characters plus `.`, `_`, `:`, `%`, and `-`.
31+
*
32+
* @param did - The string to validate
33+
* @returns true if the string is a valid DID format
34+
*
35+
* @example
36+
* ```typescript
37+
* isValidDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz"); // true
38+
* isValidDid("did:web:example.com"); // true
39+
* isValidDid("not-a-did"); // false
40+
* isValidDid("did:"); // false
41+
* ```
42+
*
43+
* @see https://www.w3.org/TR/did-core/#did-syntax for DID syntax specification
44+
*/
45+
export function isValidDid(did: string): boolean {
46+
// DID format: did:<method>:<method-specific-id>
47+
// Method: lowercase letters only
48+
// Identifier: alphanumeric plus . _ : % -
49+
return /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/.test(did);
50+
}
51+
2552
/**
2653
* OAuth session with DPoP (Demonstrating Proof of Possession) support.
2754
*

packages/sdk-core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ export { InMemoryStateStore } from "./storage/InMemoryStateStore.js";
197197

198198
// Core types and schemas
199199
export type { DID, Organization, Collaborator, CollaboratorPermissions } from "./core/types.js";
200-
export { OrganizationSchema, CollaboratorSchema, CollaboratorPermissionsSchema } from "./core/types.js";
200+
export { OrganizationSchema, CollaboratorSchema, CollaboratorPermissionsSchema, isValidDid } from "./core/types.js";
201201
export { ATProtoSDKConfigSchema, OAuthConfigSchema, ServerConfigSchema, TimeoutConfigSchema } from "./core/config.js";
202202

203203
// OAuth Permissions System

packages/sdk-core/src/repository/BlobOperationsImpl.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
import type { Agent } from "@atproto/api";
1111
import { BlobRef } from "@atproto/lexicon";
1212
import { CID } from "multiformats/cid";
13-
import { NetworkError } from "../core/errors.js";
13+
import { NetworkError, ValidationError } from "../core/errors.js";
14+
import { isValidDid } from "../core/types.js";
1415
import type { BlobOperations } from "./interfaces.js";
1516

1617
/**
@@ -68,7 +69,13 @@ export class BlobOperationsImpl implements BlobOperations {
6869
private repoDid: string,
6970
private _serverUrl: string,
7071
private isSDS: boolean,
71-
) {}
72+
) {
73+
if (!isValidDid(repoDid)) {
74+
throw new ValidationError(
75+
`Invalid DID format: "${repoDid}". DIDs must start with "did:" (e.g., "did:plc:abc123")`,
76+
);
77+
}
78+
}
7279

7380
/**
7481
* Uploads a blob to the server.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from "vitest";
2+
import { isValidDid } from "../../src/core/types.js";
3+
4+
describe("isValidDid", () => {
5+
describe("valid DIDs", () => {
6+
it("should accept did:plc format", () => {
7+
expect(isValidDid("did:plc:abc123")).toBe(true);
8+
});
9+
10+
it("should accept did:web format", () => {
11+
expect(isValidDid("did:web:example.com")).toBe(true);
12+
});
13+
14+
it("should accept DID with alphanumeric identifier", () => {
15+
expect(isValidDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz")).toBe(true);
16+
});
17+
18+
it("should accept DID with dots in identifier", () => {
19+
expect(isValidDid("did:web:sub.example.com")).toBe(true);
20+
});
21+
22+
it("should accept DID with colons in identifier", () => {
23+
expect(isValidDid("did:web:example.com:user:123")).toBe(true);
24+
});
25+
26+
it("should accept DID with percent-encoded characters", () => {
27+
expect(isValidDid("did:example:abc%20def")).toBe(true);
28+
});
29+
30+
it("should accept DID with hyphens and underscores", () => {
31+
expect(isValidDid("did:example:my-test_id")).toBe(true);
32+
});
33+
});
34+
35+
describe("invalid DIDs", () => {
36+
it("should reject empty string", () => {
37+
expect(isValidDid("")).toBe(false);
38+
});
39+
40+
it("should reject string not starting with did:", () => {
41+
expect(isValidDid("not-a-did")).toBe(false);
42+
});
43+
44+
it("should reject did: without method", () => {
45+
expect(isValidDid("did:")).toBe(false);
46+
});
47+
48+
it("should reject did:method without identifier", () => {
49+
expect(isValidDid("did:plc:")).toBe(false);
50+
});
51+
52+
it("should reject did:method: with empty identifier", () => {
53+
expect(isValidDid("did:plc:")).toBe(false);
54+
});
55+
56+
it("should reject method with uppercase letters", () => {
57+
expect(isValidDid("did:PLC:abc123")).toBe(false);
58+
});
59+
60+
it("should reject method with numbers", () => {
61+
expect(isValidDid("did:plc2:abc123")).toBe(false);
62+
});
63+
64+
it("should reject random URL", () => {
65+
expect(isValidDid("https://example.com")).toBe(false);
66+
});
67+
68+
it("should reject AT-URI", () => {
69+
expect(isValidDid("at://did:plc:abc123/collection/rkey")).toBe(false);
70+
});
71+
});
72+
});

packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import type { Agent } from "@atproto/api";
33
import { BlobOperationsImpl } from "../../src/repository/BlobOperationsImpl.js";
4-
import { NetworkError } from "../../src/core/errors.js";
4+
import { NetworkError, ValidationError } from "../../src/core/errors.js";
55
import { createMockAgent, TEST_REPO_DID, TEST_PDS_URL, TEST_SDS_URL } from "../utils/mocks.js";
66

77
describe("BlobOperationsImpl", () => {
@@ -13,6 +13,26 @@ describe("BlobOperationsImpl", () => {
1313
blobOps = new BlobOperationsImpl(mockAgent as unknown as Agent, TEST_REPO_DID, TEST_PDS_URL, false);
1414
});
1515

16+
describe("constructor", () => {
17+
it("should accept valid DID", () => {
18+
expect(
19+
() => new BlobOperationsImpl(mockAgent as unknown as Agent, "did:plc:abc123", TEST_PDS_URL, false),
20+
).not.toThrow();
21+
});
22+
23+
it("should throw ValidationError for invalid DID", () => {
24+
expect(() => new BlobOperationsImpl(mockAgent as unknown as Agent, "not-a-did", TEST_PDS_URL, false)).toThrow(
25+
ValidationError,
26+
);
27+
});
28+
29+
it("should include helpful error message with the invalid DID", () => {
30+
expect(() => new BlobOperationsImpl(mockAgent as unknown as Agent, "invalid", TEST_PDS_URL, false)).toThrow(
31+
/Invalid DID format: "invalid"/,
32+
);
33+
});
34+
});
35+
1636
describe("upload", () => {
1737
it("should upload a blob successfully", async () => {
1838
const mockBlob = new Blob(["test content"], { type: "text/plain" });

0 commit comments

Comments
 (0)