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
26 changes: 26 additions & 0 deletions .changeset/fix-contributor-strongref-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@hypercerts-org/sdk-core": patch
---

Fix contributor identity and contribution details to include `$type` for lexicon validation

**Breaking Context:** The lexicon defines `contributorIdentity` and `contributionDetails` as union types wrapped in
`$Typed<>`, which requires `$type: "com.atproto.repo.strongRef"` as a discriminator for validation. Unlike the `rights`
field (which uses plain `ComAtprotoRepoStrongRef.Main`), these fields require `$type` to pass validation.

**Implementation Changes:**

- `resolveContributorIdentity()`: Now converts string DIDs to `contributorInformation` records and returns StrongRefs
with `$type`
- `resolveContributionDetails()`: Added `$type: "com.atproto.repo.strongRef"` to all StrongRef returns
- Updated `ResolvedContributorIdentity` and `ResolvedContributionDetails` types to include `$type` in StrongRef objects

**Test Updates:**

- Added mocks for `contributorInformation` record creation when string DIDs are provided (e.g., `"did:plc:contrib1"`)
- Updated assertions to expect `$type: "com.atproto.repo.strongRef"` in all contributor and contribution detail
StrongRefs
- Adjusted mock call indices to account for additional `contributorInformation` record creation calls
- Fixed 10 failing tests that were expecting plain `{ uri, cid }` objects instead of properly typed StrongRefs

This ensures hypercert records with contributors pass lexicon validation and can be created successfully.
13 changes: 7 additions & 6 deletions packages/sdk-core/src/repository/HypercertOperationsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1225,7 +1225,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
return details;
} else if ("uri" in details && "cid" in details && !("role" in details)) {
// StrongRef to existing record
return { uri: details.uri as string, cid: details.cid as string };
return { uri: details.uri as string, cid: details.cid as string, $type: "com.atproto.repo.strongRef" };
} else if ("role" in details) {
// CreateContributionDetailsParams - auto-create record
try {
Expand All @@ -1249,7 +1249,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
status: "success",
data: result,
});
return { uri: result.uri, cid: result.cid };
return { uri: result.uri, cid: result.cid, $type: "com.atproto.repo.strongRef" };
} catch (error) {
this.emitProgress(onProgress, {
name: "createContribution",
Expand All @@ -1272,11 +1272,12 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
onProgress?: (step: ProgressStep) => void,
): Promise<ResolvedContributorIdentity> {
if (typeof identity === "string") {
// DID or inline string
return identity;
// we still store as contribtorInformation since it cant directly be a string
const result = await this.addContributorInformation({ identifier: identity });
return { uri: result.uri, cid: result.cid, $type: "com.atproto.repo.strongRef" };
} else if ("uri" in identity && "cid" in identity && !("identifier" in identity)) {
// StrongRef to existing record
return { uri: identity.uri as string, cid: identity.cid as string };
return { $type: "com.atproto.repo.strongRef", uri: identity.uri as string, cid: identity.cid as string };
} else if ("identifier" in identity) {
// CreateContributorInformationParams - auto-create record
try {
Expand Down Expand Up @@ -1308,7 +1309,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
status: "success",
data: result,
});
return { uri: result.uri, cid: result.cid };
return { $type: "com.atproto.repo.strongRef", uri: result.uri, cid: result.cid };
} catch (error) {
this.emitProgress(onProgress, {
name: "createContributorInformation",
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk-core/src/repository/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export type ContributionDetailsParams = string | { uri: string; cid: string } |
* Resolved contribution details (after processing).
* CreateContributionDetailsParams is converted to a StrongRef.
*/
export type ResolvedContributionDetails = string | { uri: string; cid: string };
export type ResolvedContributionDetails = string | { uri: string; cid: string; $type: "com.atproto.repo.strongRef" };

// ============================================================================
// Contributor Identity Types
Expand Down Expand Up @@ -134,7 +134,7 @@ export type ContributorIdentityParams = string | { uri: string; cid: string } |
* Resolved contributor identity (after processing).
* CreateContributorInformationParams is converted to a StrongRef.
*/
export type ResolvedContributorIdentity = string | { uri: string; cid: string };
export type ResolvedContributorIdentity = string | { uri: string; cid: string; $type: "com.atproto.repo.strongRef" };

// ============================================================================
// Hypercert Operation Types
Expand Down
134 changes: 117 additions & 17 deletions packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,20 @@ describe("HypercertOperationsImpl", () => {

it("should create contributions when provided", async () => {
// Contributors are now embedded in the claim record, not created as separate records
// String DIDs are converted to contributorInformation records first
mockAgent.com.atproto.repo.createRecord.mockReset();
mockAgent.com.atproto.repo.createRecord
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand All @@ -311,11 +319,20 @@ describe("HypercertOperationsImpl", () => {

expect(result.hypercertUri).toBeDefined();

// Verify contributors are embedded in the claim record
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
// Verify contributorInformation record was created
const contributorCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
expect(contributorCall.collection).toBe("org.hypercerts.claim.contributorInformation");
expect(contributorCall.record.identifier).toBe("did:plc:contrib1");

// Verify contributors are embedded in the claim record with StrongRef (includes $type)
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
expect(createCall.record.contributors).toBeDefined();
expect(createCall.record.contributors).toHaveLength(1);
expect(createCall.record.contributors[0].contributorIdentity).toBe("did:plc:contrib1");
expect(createCall.record.contributors[0].contributorIdentity).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
});
expect(createCall.record.contributors[0].contributionDetails).toBe("Developer");
});

Expand All @@ -330,6 +347,13 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand All @@ -353,11 +377,16 @@ describe("HypercertOperationsImpl", () => {
expect(contributionCall.record.role).toBe("Developer");
expect(contributionCall.record.contributionDescription).toBe("Backend work");

// Verify contributors use StrongRef in the claim record
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
// Verify contributors use StrongRef in the claim record (with $type for lexicon validation)
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[3][0];
expect(createCall.record.contributors).toBeDefined();
expect(createCall.record.contributors[0].contributorIdentity).toBe("did:plc:contrib1");
expect(createCall.record.contributors[0].contributorIdentity).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
});
expect(createCall.record.contributors[0].contributionDetails).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz",
cid: "details-cid",
});
Expand All @@ -370,6 +399,13 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand All @@ -380,7 +416,7 @@ describe("HypercertOperationsImpl", () => {
contributions: [{ contributors: ["did:plc:contrib1"], contributionDetails: "Developer", weight: "0.75" }],
});

const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
expect(createCall.record.contributors[0].contributionWeight).toBe("0.75");
expect(createCall.record.contributors[0].contributionDetails).toBe("Developer");
});
Expand All @@ -392,6 +428,13 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand All @@ -402,7 +445,7 @@ describe("HypercertOperationsImpl", () => {
contributions: [{ contributors: ["did:plc:contrib1"], contributionDetails: "Developer" }],
});

const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
expect(createCall.record.contributors[0].contributionWeight).toBeUndefined();
});

Expand All @@ -417,6 +460,13 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand All @@ -433,9 +483,10 @@ describe("HypercertOperationsImpl", () => {
],
});

const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[3][0];
expect(createCall.record.contributors[0].contributionWeight).toBe("1.5");
expect(createCall.record.contributors[0].contributionDetails).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz",
cid: "details-cid",
});
Expand All @@ -453,14 +504,20 @@ describe("HypercertOperationsImpl", () => {
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
});

// When a StrongRef is provided directly, no contributorInformation record is created
const contributorRef = { uri: "at://did:plc:test/org.hypercerts.actor.profile/xyz", cid: "profile-cid" };
await hypercertOps.create({
...validParams,
contributions: [{ contributors: [contributorRef], contributionDetails: "Developer" }],
});

const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
expect(createCall.record.contributors[0].contributorIdentity).toEqual(contributorRef);
// StrongRef gets $type added for lexicon validation
expect(createCall.record.contributors[0].contributorIdentity).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.actor.profile/xyz",
cid: "profile-cid",
});
});

it("should support mixed string DIDs and StrongRefs for contributors", async () => {
Expand All @@ -470,21 +527,39 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/string-did",
cid: "string-contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
});

// String DID creates a contributorInformation record, StrongRef is used directly
const contributorRef = { uri: "at://did:plc:test/org.hypercerts.actor.profile/xyz", cid: "profile-cid" };
await hypercertOps.create({
...validParams,
contributions: [{ contributors: ["did:plc:string-did", contributorRef], contributionDetails: "Developer" }],
});

const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
expect(createCall.record.contributors).toHaveLength(2);
expect(createCall.record.contributors[0].contributorIdentity).toBe("did:plc:string-did");
expect(createCall.record.contributors[1].contributorIdentity).toEqual(contributorRef);
// String DID gets converted to StrongRef pointing to contributorInformation record
expect(createCall.record.contributors[0].contributorIdentity).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/string-did",
cid: "string-contributor-cid",
});
// Provided StrongRef gets $type added
expect(createCall.record.contributors[1].contributorIdentity).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.actor.profile/xyz",
cid: "profile-cid",
});
});

it("should use contributionDetailsRef directly when provided", async () => {
Expand All @@ -494,6 +569,13 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand All @@ -508,10 +590,14 @@ describe("HypercertOperationsImpl", () => {
contributions: [{ contributors: ["did:plc:contrib1"], contributionDetails: detailsRef }],
});

// Should only create rights + hypercert, NOT contributionDetails (since ref was provided)
expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(2);
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
expect(createCall.record.contributors[0].contributionDetails).toEqual(detailsRef);
// Should create rights + contributorInformation + hypercert (NOT contributionDetails since ref was provided)
expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(3);
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
expect(createCall.record.contributors[0].contributionDetails).toEqual({
$type: "com.atproto.repo.strongRef",
uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/existing",
cid: "existing-cid",
});
});

it("should pass through extra properties to contributionDetails record", async () => {
Expand All @@ -525,6 +611,13 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand Down Expand Up @@ -561,6 +654,13 @@ describe("HypercertOperationsImpl", () => {
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
})
.mockResolvedValueOnce({
success: true,
data: {
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
cid: "contributor-cid",
},
})
.mockResolvedValueOnce({
success: true,
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
Expand Down