diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index aafe387..7f5228f 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -247,6 +247,7 @@ if (result.success) { | **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity | | **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award | | **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID to EVM wallet link via EIP-712 signature | +| **Follow** | `app.certified.graph.follow` | Social-graph follow: declares the author follows another account by DID. Schema-compatible with `app.bsky.graph.follow` | ## Relationship Map @@ -283,6 +284,7 @@ CERTIFIED actor/profile (user profile) actor/organization (org metadata) badge/response ──> badge/award ──> badge/definition + graph/follow ───────────> account DID (social follow) ``` Every arrow is a `strongRef` or union reference stored on AT Protocol. @@ -387,6 +389,30 @@ const location = { }; ``` +### Following another account + +```typescript +import { GRAPH_FOLLOW_NSID } from "@hypercerts-org/lexicon"; + +const follow = { + $type: GRAPH_FOLLOW_NSID, + // DID of the account being followed (any valid DID — did:plc, did:web, etc.) + subject: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + createdAt: new Date().toISOString(), + // Optional `via` strongRef — set when the follow was mediated by another + // record (e.g. a starter-pack-style curated list). Omit for direct follows. + // via: { + // uri: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.certified.graph.starterpack/3k2abc", + // cid: "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + // }, +}; +``` + +`app.certified.graph.follow` mirrors `app.bsky.graph.follow` (same +key strategy and fields including the optional `via` strongRef), so +feed-builders and view services can index it with the same logic +they already use for Bluesky follows. + ### Linking an EVM Wallet ```typescript diff --git a/.changeset/add-graph-follow-lexicon.md b/.changeset/add-graph-follow-lexicon.md new file mode 100644 index 0000000..5f86d74 --- /dev/null +++ b/.changeset/add-graph-follow-lexicon.md @@ -0,0 +1,5 @@ +--- +"@hypercerts-org/lexicon": minor +--- + +Add `app.certified.graph.follow` lexicon — a social-graph follow record schema-compatible with `app.bsky.graph.follow` (same `tid` key, same `subject` / `createdAt` / optional `via` strongRef fields). Exports new `GRAPH_FOLLOW_NSID`, `GRAPH_FOLLOW_LEXICON_JSON`, `GRAPH_FOLLOW_LEXICON_DOC`, and `AppCertifiedGraphFollow` type namespace. diff --git a/ERD.png b/ERD.png index ad44743..219dd33 100644 Binary files a/ERD.png and b/ERD.png differ diff --git a/ERD.puml b/ERD.puml index b8c0533..130db6c 100644 --- a/ERD.puml +++ b/ERD.puml @@ -193,6 +193,15 @@ dataclass badgeResponse { !endif } +' app.certified.graph.follow +dataclass follow { + !if (SHOW_FIELDS == "true") + subject + via? + createdAt + !endif +} + ' org.hypercerts.workscope.cel dataclass celExpression { !if (SHOW_FIELDS == "true") @@ -390,6 +399,9 @@ badgeAward::badge --> badgeDefinition badgeResponse::badgeAward --> badgeAward badgeAward::subject --> contributorInformation badgeAward::subject --> activity + +' app.certified.graph.follow points at an account DID (the entity being followed) +follow::subject --> contributorEntity : follows ' This screws up the layout 'badgeAward::subject --[norank]-> collection diff --git a/ERD.svg b/ERD.svg index faecaff..ed38c6e 100644 --- a/ERD.svg +++ b/ERD.svg @@ -1 +1 @@ -Hypercert ERDHypercert ERDorganizationcontributor (DID/profile)activityattachmentmeasurementevaluationcontributorInformationcontributionlocationbadgeDefinitionbadgeAwardbadgeResponsecelExpressionworkScopeTagrightscollectionacknowledgementfunderfundingReceipttoken recursivenestingstrongRefhierarchydeprecationidentifiestokenizesfundshasfunds \ No newline at end of file +Hypercert ERDHypercert ERDorganizationcontributor (DID/profile)activityattachmentmeasurementevaluationcontributorInformationcontributionlocationbadgeDefinitionbadgeAwardbadgeResponsefollowcelExpressionworkScopeTagrightscollectionacknowledgementfunderfundingReceipttoken recursivenestingstrongRefhierarchydeprecationidentifiestokenizesfundshasfundsfollows \ No newline at end of file diff --git a/README.md b/README.md index 0f114eb..831dd34 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ CERTIFIED ─ shared lexicons (certified.app) actor/profile (user profile) actor/organization (org metadata) badge/response ──► badge/award ──► badge/definition + graph/follow ────────────► account DID (social follow) ``` Every arrow (`►`) is a `strongRef` or union reference stored on the @@ -266,15 +267,16 @@ await agent.api.com.atproto.repo.createRecord({ ### Certified (`app.certified.*`) -| Lexicon | NSID | Description | -| -------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| **Location** | `app.certified.location` | Geographic reference using the [Location Protocol](https://spec.decentralizedgeo.org) (coordinates, GeoJSON, H3, WKT, etc.). | -| **Profile** | `app.certified.actor.profile` | User account profile with display name, bio, avatar, and banner. | -| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date, optional long description, and discoverability visibility. | -| **Badge Definition** | `app.certified.badge.definition` | Defines a badge type with title, icon, and optional issuer allowlist. | -| **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity. | -| **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award. | -| **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID ↔ EVM wallet link via EIP-712 signature. Extensible for future proof methods (e.g. ERC-1271, ERC-6492). | +| Lexicon | NSID | Description | +| -------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **Location** | `app.certified.location` | Geographic reference using the [Location Protocol](https://spec.decentralizedgeo.org) (coordinates, GeoJSON, H3, WKT, etc.). | +| **Profile** | `app.certified.actor.profile` | User account profile with display name, bio, avatar, and banner. | +| **Organization** | `app.certified.actor.organization` | Organization metadata: legal structure, URLs, location, founding date, optional long description, and discoverability visibility. | +| **Badge Definition** | `app.certified.badge.definition` | Defines a badge type with title, icon, and optional issuer allowlist. | +| **Badge Award** | `app.certified.badge.award` | Awards a badge to a user, project, or activity. | +| **Badge Response** | `app.certified.badge.response` | Recipient accepts or rejects a badge award. | +| **EVM Link** | `app.certified.link.evm` | Verifiable ATProto DID ↔ EVM wallet link via EIP-712 signature. Extensible for future proof methods (e.g. ERC-1271, ERC-6492). | +| **Follow** | `app.certified.graph.follow` | Social-graph follow relationship — declares that the author follows another account by DID. Schema-compatible with `app.bsky.graph.follow`. | > **Full property tables** → [SCHEMAS.md](SCHEMAS.md) @@ -576,6 +578,35 @@ npm run gen-schemas-md # Regenerate SCHEMAS.md npm run test # Run tests ``` +### Following another account + +The `app.certified.graph.follow` record is the social-graph primitive +for `certified.app`. Its shape is identical to `app.bsky.graph.follow` +(same `key: tid`, same `subject` / `createdAt` / optional `via` +fields), so feed-builders and view services can index it with the +same logic they already use for Bluesky follows. + +```typescript +import { GRAPH_FOLLOW_NSID } from "@hypercerts-org/lexicon"; + +const follow = { + $type: GRAPH_FOLLOW_NSID, + // DID of the account being followed (any valid DID — did:plc, did:web, etc.) + subject: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + createdAt: new Date().toISOString(), + // Optional `via` strongRef — set when the follow was mediated by another + // record (e.g. a starter-pack-style curated list). Omit for direct follows. + // via: { + // uri: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.certified.graph.starterpack/3k2abc", + // cid: "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + // }, +}; +``` + +The optional `via` field is a `com.atproto.repo.strongRef` to any +record that mediated the follow (e.g. a starter pack or other curated +list), mirroring the equivalent field on `app.bsky.graph.follow`. + ### Linking ATProto Identity to EVM Wallets The `app.certified.link.evm` record enables verifiable linking between diff --git a/SCHEMAS.md b/SCHEMAS.md index 9de403d..6e4f150 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -467,6 +467,22 @@ A labeled URL reference. --- +### `app.certified.graph.follow` + +**Description:** Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView. + +**Key:** `tid` + +#### Properties + +| Property | Type | Required | Description | +| ----------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `subject` | `string` | ✅ | DID of the account being followed. | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created. | +| `via` | `ref` | ❌ | Optional strong reference to a record that mediated this follow (e.g. a starter pack or other curated list). Mirrors the optional `via` field on app.bsky.graph.follow; the referenced record may conform with any lexicon. | + +--- + ### `app.certified.link.evm` **Description:** A verifiable link between an ATProto DID and an EVM wallet address, proven via a cryptographic signature. Currently supports EOA wallets via EIP-712 typed data signatures; the proof field is an open union to allow future signature methods. diff --git a/lexicons/app/certified/graph/follow.json b/lexicons/app/certified/graph/follow.json new file mode 100644 index 0000000..7bfc41f --- /dev/null +++ b/lexicons/app/certified/graph/follow.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "app.certified.graph.follow", + "defs": { + "main": { + "type": "record", + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { + "type": "string", + "format": "did", + "description": "DID of the account being followed." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was originally created." + }, + "via": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Optional strong reference to a record that mediated this follow (e.g. a starter pack or other curated list). Mirrors the optional `via` field on app.bsky.graph.follow; the referenced record may conform with any lexicon." + } + } + } + } + } +} diff --git a/tests/validate-graph-follow.test.ts b/tests/validate-graph-follow.test.ts new file mode 100644 index 0000000..d75b9c9 --- /dev/null +++ b/tests/validate-graph-follow.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { validate, ids } from "../generated/lexicons"; +import * as Follow from "../generated/types/app/certified/graph/follow"; + +const VALID_DID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz"; +const VALID_CID = "bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy"; + +describe("app.certified.graph.follow", () => { + it("should accept a valid follow record (subject + createdAt only)", () => { + const result = Follow.validateMain({ + $type: ids.AppCertifiedGraphFollow, + subject: VALID_DID, + createdAt: "2024-01-01T00:00:00Z", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.subject).toBe(VALID_DID); + } + }); + + it("should accept a follow record with optional via strongRef", () => { + const result = Follow.validateMain({ + $type: ids.AppCertifiedGraphFollow, + subject: VALID_DID, + createdAt: "2024-01-01T00:00:00Z", + via: { + uri: "at://did:plc:alice/app.certified.graph.starterpack/3k2abc", + cid: VALID_CID, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.via?.uri).toBe( + "at://did:plc:alice/app.certified.graph.starterpack/3k2abc", + ); + } + }); + + it("should reject a record missing required subject", () => { + const result = validate( + { createdAt: "2024-01-01T00:00:00Z" }, + ids.AppCertifiedGraphFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject a record missing required createdAt", () => { + const result = validate( + { subject: VALID_DID }, + ids.AppCertifiedGraphFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject a subject that is not a valid DID", () => { + const result = validate( + { + subject: "not-a-did", + createdAt: "2024-01-01T00:00:00Z", + }, + ids.AppCertifiedGraphFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject an invalid datetime", () => { + const result = validate( + { + subject: VALID_DID, + createdAt: "not-a-datetime", + }, + ids.AppCertifiedGraphFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should reject a via that is not a valid strongRef", () => { + const result = validate( + { + subject: VALID_DID, + createdAt: "2024-01-01T00:00:00Z", + via: { uri: "at://did:plc:alice/app.certified.graph.starterpack/x" }, // missing cid + }, + ids.AppCertifiedGraphFollow, + "main", + false, + ); + expect(result.success).toBe(false); + }); + + it("should require $type when requiredType is true", () => { + const result = validate( + { + subject: VALID_DID, + createdAt: "2024-01-01T00:00:00Z", + }, + ids.AppCertifiedGraphFollow, + "main", + true, + ); + expect(result.success).toBe(false); + }); +});