Skip to content

Commit 23634e2

Browse files
authored
Merge pull request #137 from hypercerts-org/did-validation
feat(sdk-core): add DID format validation
2 parents 1000b93 + df84d85 commit 23634e2

7 files changed

Lines changed: 153 additions & 4 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@hypercerts-org/sdk-core": minor
3+
---
4+
5+
Add `isValidDid()` utility function for DID format validation
6+
7+
- Validates DID format (did:method:identifier) with support for numeric method names per W3C spec
8+
- Exported from `@hypercerts-org/sdk-core` for consumer use
9+
- `BlobOperationsImpl` constructor now validates `repoDid` and throws `ValidationError` for invalid formats
10+
11+
> **⚠️ Potentially breaking:** callers that previously passed invalid DID strings to `BlobOperationsImpl` (directly or
12+
> via `Repository`) will now receive a `ValidationError` at construction time instead of silently accepting the value.
13+
> Use `isValidDid(repoDid)` to check before constructing if needed.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ coverage.json
1818
npm-debug.log*
1919
yarn-debug.log*
2020
yarn-error.log*
21+
package-lock.json
2122
.DS_Store
2223
/.idea
2324
stats.html

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,34 @@ 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 digits, 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 and digits (per W3C DID Core spec)
48+
// Identifier: alphanumeric plus . _ : % -
49+
// method-specific-id must end with at least one non-colon idchar (W3C DID Core 1.0)
50+
return /^did:[a-z0-9]+:(?:[a-zA-Z0-9._%-]+:)*[a-zA-Z0-9._%-]+$/.test(did);
51+
}
52+
2553
/**
2654
* OAuth session with DPoP (Demonstrating Proof of Possession) support.
2755
*

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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
it("should accept DID with method containing digits", () => {
35+
expect(isValidDid("did:key2:abc123")).toBe(true);
36+
});
37+
38+
it("should accept DID with method containing multiple digits", () => {
39+
expect(isValidDid("did:btc1:xyz789")).toBe(true);
40+
});
41+
42+
it("should accept DID with method that is all digits", () => {
43+
expect(isValidDid("did:123:identifier")).toBe(true);
44+
});
45+
});
46+
47+
describe("invalid DIDs", () => {
48+
it("should reject empty string", () => {
49+
expect(isValidDid("")).toBe(false);
50+
});
51+
52+
it("should reject string not starting with did:", () => {
53+
expect(isValidDid("not-a-did")).toBe(false);
54+
});
55+
56+
it("should reject did: without method", () => {
57+
expect(isValidDid("did:")).toBe(false);
58+
});
59+
60+
it("should reject did:method without identifier", () => {
61+
expect(isValidDid("did:plc:")).toBe(false);
62+
});
63+
64+
it("should reject DID with trailing colon in identifier", () => {
65+
expect(isValidDid("did:example:abc:")).toBe(false);
66+
});
67+
68+
it("should reject method with uppercase letters", () => {
69+
expect(isValidDid("did:PLC:abc123")).toBe(false);
70+
});
71+
72+
it("should reject random URL", () => {
73+
expect(isValidDid("https://example.com")).toBe(false);
74+
});
75+
76+
it("should reject AT-URI", () => {
77+
expect(isValidDid("at://did:plc:abc123/collection/rkey")).toBe(false);
78+
});
79+
});
80+
});

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)