Skip to content

Commit af0dfc0

Browse files
authored
Merge pull request #119 from hypercerts-org/fix/contributors-record
2 parents dfa40bf + 2354987 commit af0dfc0

4 files changed

Lines changed: 152 additions & 25 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@hypercerts-org/sdk-core": patch
3+
---
4+
5+
Fix contributor identity and contribution details to include `$type` for lexicon validation
6+
7+
**Breaking Context:** The lexicon defines `contributorIdentity` and `contributionDetails` as union types wrapped in
8+
`$Typed<>`, which requires `$type: "com.atproto.repo.strongRef"` as a discriminator for validation. Unlike the `rights`
9+
field (which uses plain `ComAtprotoRepoStrongRef.Main`), these fields require `$type` to pass validation.
10+
11+
**Implementation Changes:**
12+
13+
- `resolveContributorIdentity()`: Now converts string DIDs to `contributorInformation` records and returns StrongRefs
14+
with `$type`
15+
- `resolveContributionDetails()`: Added `$type: "com.atproto.repo.strongRef"` to all StrongRef returns
16+
- Updated `ResolvedContributorIdentity` and `ResolvedContributionDetails` types to include `$type` in StrongRef objects
17+
18+
**Test Updates:**
19+
20+
- Added mocks for `contributorInformation` record creation when string DIDs are provided (e.g., `"did:plc:contrib1"`)
21+
- Updated assertions to expect `$type: "com.atproto.repo.strongRef"` in all contributor and contribution detail
22+
StrongRefs
23+
- Adjusted mock call indices to account for additional `contributorInformation` record creation calls
24+
- Fixed 10 failing tests that were expecting plain `{ uri, cid }` objects instead of properly typed StrongRefs
25+
26+
This ensures hypercert records with contributors pass lexicon validation and can be created successfully.

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,7 +1225,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
12251225
return details;
12261226
} else if ("uri" in details && "cid" in details && !("role" in details)) {
12271227
// StrongRef to existing record
1228-
return { uri: details.uri as string, cid: details.cid as string };
1228+
return { uri: details.uri as string, cid: details.cid as string, $type: "com.atproto.repo.strongRef" };
12291229
} else if ("role" in details) {
12301230
// CreateContributionDetailsParams - auto-create record
12311231
try {
@@ -1249,7 +1249,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
12491249
status: "success",
12501250
data: result,
12511251
});
1252-
return { uri: result.uri, cid: result.cid };
1252+
return { uri: result.uri, cid: result.cid, $type: "com.atproto.repo.strongRef" };
12531253
} catch (error) {
12541254
this.emitProgress(onProgress, {
12551255
name: "createContribution",
@@ -1272,11 +1272,12 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
12721272
onProgress?: (step: ProgressStep) => void,
12731273
): Promise<ResolvedContributorIdentity> {
12741274
if (typeof identity === "string") {
1275-
// DID or inline string
1276-
return identity;
1275+
// we still store as contribtorInformation since it cant directly be a string
1276+
const result = await this.addContributorInformation({ identifier: identity });
1277+
return { uri: result.uri, cid: result.cid, $type: "com.atproto.repo.strongRef" };
12771278
} else if ("uri" in identity && "cid" in identity && !("identifier" in identity)) {
12781279
// StrongRef to existing record
1279-
return { uri: identity.uri as string, cid: identity.cid as string };
1280+
return { $type: "com.atproto.repo.strongRef", uri: identity.uri as string, cid: identity.cid as string };
12801281
} else if ("identifier" in identity) {
12811282
// CreateContributorInformationParams - auto-create record
12821283
try {
@@ -1308,7 +1309,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
13081309
status: "success",
13091310
data: result,
13101311
});
1311-
return { uri: result.uri, cid: result.cid };
1312+
return { $type: "com.atproto.repo.strongRef", uri: result.uri, cid: result.cid };
13121313
} catch (error) {
13131314
this.emitProgress(onProgress, {
13141315
name: "createContributorInformation",

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export type ContributionDetailsParams = string | { uri: string; cid: string } |
8888
* Resolved contribution details (after processing).
8989
* CreateContributionDetailsParams is converted to a StrongRef.
9090
*/
91-
export type ResolvedContributionDetails = string | { uri: string; cid: string };
91+
export type ResolvedContributionDetails = string | { uri: string; cid: string; $type: "com.atproto.repo.strongRef" };
9292

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

139139
// ============================================================================
140140
// Hypercert Operation Types

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

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -293,12 +293,20 @@ describe("HypercertOperationsImpl", () => {
293293

294294
it("should create contributions when provided", async () => {
295295
// Contributors are now embedded in the claim record, not created as separate records
296+
// String DIDs are converted to contributorInformation records first
296297
mockAgent.com.atproto.repo.createRecord.mockReset();
297298
mockAgent.com.atproto.repo.createRecord
298299
.mockResolvedValueOnce({
299300
success: true,
300301
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
301302
})
303+
.mockResolvedValueOnce({
304+
success: true,
305+
data: {
306+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
307+
cid: "contributor-cid",
308+
},
309+
})
302310
.mockResolvedValueOnce({
303311
success: true,
304312
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
@@ -311,11 +319,20 @@ describe("HypercertOperationsImpl", () => {
311319

312320
expect(result.hypercertUri).toBeDefined();
313321

314-
// Verify contributors are embedded in the claim record
315-
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
322+
// Verify contributorInformation record was created
323+
const contributorCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
324+
expect(contributorCall.collection).toBe("org.hypercerts.claim.contributorInformation");
325+
expect(contributorCall.record.identifier).toBe("did:plc:contrib1");
326+
327+
// Verify contributors are embedded in the claim record with StrongRef (includes $type)
328+
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
316329
expect(createCall.record.contributors).toBeDefined();
317330
expect(createCall.record.contributors).toHaveLength(1);
318-
expect(createCall.record.contributors[0].contributorIdentity).toBe("did:plc:contrib1");
331+
expect(createCall.record.contributors[0].contributorIdentity).toEqual({
332+
$type: "com.atproto.repo.strongRef",
333+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
334+
cid: "contributor-cid",
335+
});
319336
expect(createCall.record.contributors[0].contributionDetails).toBe("Developer");
320337
});
321338

@@ -330,6 +347,13 @@ describe("HypercertOperationsImpl", () => {
330347
success: true,
331348
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
332349
})
350+
.mockResolvedValueOnce({
351+
success: true,
352+
data: {
353+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
354+
cid: "contributor-cid",
355+
},
356+
})
333357
.mockResolvedValueOnce({
334358
success: true,
335359
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
@@ -353,11 +377,16 @@ describe("HypercertOperationsImpl", () => {
353377
expect(contributionCall.record.role).toBe("Developer");
354378
expect(contributionCall.record.contributionDescription).toBe("Backend work");
355379

356-
// Verify contributors use StrongRef in the claim record
357-
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
380+
// Verify contributors use StrongRef in the claim record (with $type for lexicon validation)
381+
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[3][0];
358382
expect(createCall.record.contributors).toBeDefined();
359-
expect(createCall.record.contributors[0].contributorIdentity).toBe("did:plc:contrib1");
383+
expect(createCall.record.contributors[0].contributorIdentity).toEqual({
384+
$type: "com.atproto.repo.strongRef",
385+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
386+
cid: "contributor-cid",
387+
});
360388
expect(createCall.record.contributors[0].contributionDetails).toEqual({
389+
$type: "com.atproto.repo.strongRef",
361390
uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz",
362391
cid: "details-cid",
363392
});
@@ -370,6 +399,13 @@ describe("HypercertOperationsImpl", () => {
370399
success: true,
371400
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
372401
})
402+
.mockResolvedValueOnce({
403+
success: true,
404+
data: {
405+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
406+
cid: "contributor-cid",
407+
},
408+
})
373409
.mockResolvedValueOnce({
374410
success: true,
375411
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
@@ -380,7 +416,7 @@ describe("HypercertOperationsImpl", () => {
380416
contributions: [{ contributors: ["did:plc:contrib1"], contributionDetails: "Developer", weight: "0.75" }],
381417
});
382418

383-
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
419+
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
384420
expect(createCall.record.contributors[0].contributionWeight).toBe("0.75");
385421
expect(createCall.record.contributors[0].contributionDetails).toBe("Developer");
386422
});
@@ -392,6 +428,13 @@ describe("HypercertOperationsImpl", () => {
392428
success: true,
393429
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
394430
})
431+
.mockResolvedValueOnce({
432+
success: true,
433+
data: {
434+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
435+
cid: "contributor-cid",
436+
},
437+
})
395438
.mockResolvedValueOnce({
396439
success: true,
397440
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
@@ -402,7 +445,7 @@ describe("HypercertOperationsImpl", () => {
402445
contributions: [{ contributors: ["did:plc:contrib1"], contributionDetails: "Developer" }],
403446
});
404447

405-
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
448+
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
406449
expect(createCall.record.contributors[0].contributionWeight).toBeUndefined();
407450
});
408451

@@ -417,6 +460,13 @@ describe("HypercertOperationsImpl", () => {
417460
success: true,
418461
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
419462
})
463+
.mockResolvedValueOnce({
464+
success: true,
465+
data: {
466+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
467+
cid: "contributor-cid",
468+
},
469+
})
420470
.mockResolvedValueOnce({
421471
success: true,
422472
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
@@ -433,9 +483,10 @@ describe("HypercertOperationsImpl", () => {
433483
],
434484
});
435485

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

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

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

466523
it("should support mixed string DIDs and StrongRefs for contributors", async () => {
@@ -470,21 +527,39 @@ describe("HypercertOperationsImpl", () => {
470527
success: true,
471528
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
472529
})
530+
.mockResolvedValueOnce({
531+
success: true,
532+
data: {
533+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/string-did",
534+
cid: "string-contributor-cid",
535+
},
536+
})
473537
.mockResolvedValueOnce({
474538
success: true,
475539
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
476540
});
477541

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

484-
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0];
549+
const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[2][0];
485550
expect(createCall.record.contributors).toHaveLength(2);
486-
expect(createCall.record.contributors[0].contributorIdentity).toBe("did:plc:string-did");
487-
expect(createCall.record.contributors[1].contributorIdentity).toEqual(contributorRef);
551+
// String DID gets converted to StrongRef pointing to contributorInformation record
552+
expect(createCall.record.contributors[0].contributorIdentity).toEqual({
553+
$type: "com.atproto.repo.strongRef",
554+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/string-did",
555+
cid: "string-contributor-cid",
556+
});
557+
// Provided StrongRef gets $type added
558+
expect(createCall.record.contributors[1].contributorIdentity).toEqual({
559+
$type: "com.atproto.repo.strongRef",
560+
uri: "at://did:plc:test/org.hypercerts.actor.profile/xyz",
561+
cid: "profile-cid",
562+
});
488563
});
489564

490565
it("should use contributionDetailsRef directly when provided", async () => {
@@ -494,6 +569,13 @@ describe("HypercertOperationsImpl", () => {
494569
success: true,
495570
data: { uri: "at://did:plc:test/org.hypercerts.claim.rights/abc", cid: "rights-cid" },
496571
})
572+
.mockResolvedValueOnce({
573+
success: true,
574+
data: {
575+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
576+
cid: "contributor-cid",
577+
},
578+
})
497579
.mockResolvedValueOnce({
498580
success: true,
499581
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
@@ -508,10 +590,14 @@ describe("HypercertOperationsImpl", () => {
508590
contributions: [{ contributors: ["did:plc:contrib1"], contributionDetails: detailsRef }],
509591
});
510592

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

517603
it("should pass through extra properties to contributionDetails record", async () => {
@@ -525,6 +611,13 @@ describe("HypercertOperationsImpl", () => {
525611
success: true,
526612
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
527613
})
614+
.mockResolvedValueOnce({
615+
success: true,
616+
data: {
617+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
618+
cid: "contributor-cid",
619+
},
620+
})
528621
.mockResolvedValueOnce({
529622
success: true,
530623
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },
@@ -561,6 +654,13 @@ describe("HypercertOperationsImpl", () => {
561654
success: true,
562655
data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" },
563656
})
657+
.mockResolvedValueOnce({
658+
success: true,
659+
data: {
660+
uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/contrib1",
661+
cid: "contributor-cid",
662+
},
663+
})
564664
.mockResolvedValueOnce({
565665
success: true,
566666
data: { uri: "at://did:plc:test/org.hypercerts.claim.record/def", cid: "hypercert-cid" },

0 commit comments

Comments
 (0)