diff --git a/.agents/skills/hypercerts-api/SKILL.md b/.agents/skills/hypercerts-api/SKILL.md index 393a284..4ab77df 100644 --- a/.agents/skills/hypercerts-api/SKILL.md +++ b/.agents/skills/hypercerts-api/SKILL.md @@ -20,7 +20,7 @@ Read the relevant reference, then inspect the exact operation in the [bundled Op ## Bundled API contract -- [`references/openapi.json`](references/openapi.json) is a self-contained snapshot of all 55 query operations, including parameters, response schemas, declared XRPC errors, and their referenced schema definitions. The included contract makes the skill self-contained when installed separately. +- [`references/openapi.json`](references/openapi.json) is a self-contained snapshot of all 59 query operations, including parameters, response schemas, declared XRPC errors, and their referenced schema definitions. The included contract makes the skill self-contained when installed separately. - To inspect an exact contract, find `/xrpc/` in the snapshot, read its operation, and follow any `#/components/schemas/...` references by schema name. Each operation and schema is stored on its own line for focused lookup. - The snapshot describes declared behavior, not endpoint availability or data freshness on a particular host. Its OpenAPI `info.version` is not the deployed server's version. - Use the API directly when a task needs live indexed results. Do not send extra diagnostic probes just to validate deployment; a deployment-specific availability check requires explicit approval for the target. diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 68eb970..de40fe2 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -1,6 +1,6 @@ # Endpoint map: choose by consumer task -This task index lists the 55 public query NSIDs in the bundled [OpenAPI contract](openapi.json). It helps choose an operation but does not replace its exact contract: inspect that operation and its referenced schemas for required parameters, types, defaults, limits, filter semantics, response fields, and declared errors. The static snapshot describes the contract, not availability or freshness on a selected deployment. +This task index lists the 59 public query NSIDs in the bundled [OpenAPI contract](openapi.json). It helps choose an operation but does not replace its exact contract: inspect that operation and its referenced schemas for required parameters, types, defaults, limits, filter semantics, response fields, and declared errors. The static snapshot describes the contract, not availability or freshness on a selected deployment. ## Fast selection rules @@ -9,6 +9,7 @@ This task index lists the 55 public query NSIDs in the bundled [OpenAPI contract - **Browse or filter without free text:** choose `list...` in the relevant domain. - **Text discovery:** use `search...` only where listed below. A search query is not interchangeable with a list filter, and search semantics vary by endpoint. - **Relationship checks:** use `get...Follow` for a single pair; list queries answer who follows whom or which entities an actor follows. +- **Feed reactions:** use `getLikes` or `getReposts` for distinct actors reacting to a subject URI, or `getActorLikes` / `getActorReposts` for an actor's own raw representative reactions. Inspect the exact operation in the bundled OpenAPI contract before constructing a request: names such as `authors`, `actors`, `uri`, and `collection` have endpoint-specific meanings. Do not infer that a filter, search operation, output array name, or error exists just because a related endpoint has one. @@ -82,6 +83,17 @@ An `unanswered` award is a successful indexed award result, not a not-found resp Do not confuse actor follows with entity follows; their identifiers and list outputs differ. Recent raw follows preserve duplicates and are not a snapshot; consult that endpoint's description before using cursors. +## Feed reactions (4) + +| User task | Query NSID | Practical choice | +|---|---|---| +| List actors who liked a record | `app.certified.feed.getLikes` | `subject` is a full record AT-URI; matching ignores CID. The response includes a total distinct-actor count, paginated actor sidecars, and each actor's raw representative like. | +| List actors who reposted a record | `app.certified.feed.getReposts` | `subject` is a full record AT-URI; matching ignores CID. The response includes a total distinct-actor count, paginated actor sidecars, and each actor's raw representative repost. | +| List an actor's likes | `app.certified.feed.getActorLikes` | `actor` is a repository-owner DID. Returns raw like records, one per subject URI; subjects need not be indexed. | +| List an actor's reposts | `app.certified.feed.getActorReposts` | `actor` is a repository-owner DID. Returns raw repost records, one per subject URI; subjects need not be indexed. | + +Reaction queries use the earliest record per author/subject URI, with URI ascending as the tie-breaker. They sort pages by the representative record's `createdAt` and URI; check each operation for exact cursor and response details. + ## Context and supporting records (8) | User task | Query NSID | Practical choice | diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 72f0dd1..8b4da9d 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": {"title":"Hypercerts API","version":"contract-snapshot","description":"Public, read-only Hypercerts XRPC query endpoints. This static contract snapshot describes declared behavior, not availability on a particular deployment."}, "servers": [{"url":"https://api.hypercerts.dev","description":"Production API"},{"url":"https://api.staging.hypercerts.dev","description":"Staging API"}], - "tags": [{"name":"app.certified.actor"},{"name":"app.certified.badge"},{"name":"app.certified.graph"},{"name":"app.certified.link"},{"name":"app.certified.location"},{"name":"org.hypercerts.claim"},{"name":"org.hypercerts.collection"},{"name":"org.hypercerts.context"},{"name":"org.hypercerts.entity"},{"name":"org.hypercerts.funding"},{"name":"org.hypercerts.vocab"},{"name":"org.hypercerts.workscope"}], + "tags": [{"name":"app.certified.actor"},{"name":"app.certified.badge"},{"name":"app.certified.feed"},{"name":"app.certified.graph"},{"name":"app.certified.link"},{"name":"app.certified.location"},{"name":"org.hypercerts.claim"},{"name":"org.hypercerts.collection"},{"name":"org.hypercerts.context"},{"name":"org.hypercerts.entity"},{"name":"org.hypercerts.funding"},{"name":"org.hypercerts.vocab"},{"name":"org.hypercerts.workscope"}], "paths": { "/xrpc/app.certified.actor.getOrganization": {"get":{"operationId":"app_certified_actor_getOrganization","tags":["app.certified.actor"],"description":"Gets an actor's indexed organization sidecar and associated profile by DID. Authentication is not required.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID of the actor to look up.","schema":{"type":"string","format":"did"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.actor.getOrganization.output"}}}}},"x-lexicon-id":"app.certified.actor.getOrganization","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID is invalid or an unsupported parameter was supplied."},{"name":"RecordNotFound","description":"No indexed organization sidecar exists for this DID."},{"name":"OrganizationQueryFailed","description":"The indexed organization sidecar or associated profile could not be queried."}]}}, "/xrpc/app.certified.actor.getOrganizations": {"get":{"operationId":"app_certified_actor_getOrganizations","tags":["app.certified.actor"],"description":"Returns an indexed organization sidecar and associated profile for each supplied actor DID. Authentication is not required.","parameters":[{"name":"actors","in":"query","required":true,"description":"Actor DIDs to look up, supplied as repeated unbracketed query values.","schema":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.actor.getOrganizations.output"}}}}},"x-lexicon-id":"app.certified.actor.getOrganizations","x-lexicon-errors":[{"name":"InvalidRequest","description":"An actor DID or query parameter is invalid."},{"name":"OrganizationQueryFailed","description":"The indexed organization sidecars or associated profiles could not be queried."}]}}, @@ -19,6 +19,10 @@ "/xrpc/app.certified.badge.listBadgeDefinitions": {"get":{"operationId":"app_certified_badge_listBadgeDefinitions","tags":["app.certified.badge"],"description":"Lists indexed badge definitions. Author and badgeType filters are combined with AND; values within each filter are matched with OR. Authentication is not required.","parameters":[{"name":"authors","in":"query","required":false,"description":"Publisher DIDs to include.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"badgeTypes","in":"query","required":false,"description":"Exact badgeType strings to include.","schema":{"type":"array","maxItems":100,"items":{"type":"string","maxLength":100}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Selects ascending or descending order by createdAt, then AT-URI.","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"limit","in":"query","required":false,"description":"Maximum number of definitions to return in the page.","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page, bound to sortDirection. Keep other query parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.badge.listBadgeDefinitions.output"}}}}},"x-lexicon-id":"app.certified.badge.listBadgeDefinitions","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter, query parameter, page bound, or cursor is invalid."}]}}, "/xrpc/app.certified.badge.listBadgeResponses": {"get":{"operationId":"app_certified_badge_listBadgeResponses","tags":["app.certified.badge"],"description":"Lists raw public badge responses with optional award filtering and pagination without authentication.","parameters":[{"name":"badgeAward","in":"query","required":false,"description":"Badge-award AT-URI to match against response records.","schema":{"type":"string","format":"at-uri"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort direction for (createdAt, uri).","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"limit","in":"query","required":false,"description":"Maximum responses returned.","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.badge.listBadgeResponses.output"}}}}},"x-lexicon-id":"app.certified.badge.listBadgeResponses","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter or page parameter is invalid."},{"name":"BadgeResponseQueryFailed","description":"The indexed badge responses could not be queried."}]}}, "/xrpc/app.certified.badge.searchBadgeDefinitions": {"get":{"operationId":"app_certified_badge_searchBadgeDefinitions","tags":["app.certified.badge"],"description":"Searches badge definitions by title or description without authentication.","parameters":[{"name":"search","in":"query","required":true,"description":"Text matched against definition titles and descriptions.","schema":{"type":"string"}},{"name":"authors","in":"query","required":false,"description":"DIDs of repositories that published the definitions.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"badgeTypes","in":"query","required":false,"description":"Exact badgeType values.","schema":{"type":"array","maxItems":100,"items":{"type":"string","maxLength":100}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Sort direction for (createdAt, uri).","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"limit","in":"query","required":false,"description":"Maximum definitions returned.","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.badge.searchBadgeDefinitions.output"}}}}},"x-lexicon-id":"app.certified.badge.searchBadgeDefinitions","x-lexicon-errors":[{"name":"InvalidRequest","description":"A parameter is invalid, repeated where scalar, or unknown."},{"name":"BadgeDefinitionQueryFailed","description":"The indexed badge definitions could not be queried."}]}}, + "/xrpc/app.certified.feed.getActorLikes": {"get":{"operationId":"app_certified_feed_getActorLikes","tags":["app.certified.feed"],"description":"Lists an actor's indexed likes, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw like records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.","parameters":[{"name":"actor","in":"query","required":true,"description":"Repository-owner DID of the actor whose likes to list.","schema":{"type":"string","format":"did"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort by representative like createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getActorLikes.output"}}}}},"x-lexicon-id":"app.certified.feed.getActorLikes","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID, sort direction, limit, cursor, or another query parameter is invalid."},{"name":"LikeQueryFailed","description":"The actor likes query failed; retry the request."}]}}, + "/xrpc/app.certified.feed.getActorReposts": {"get":{"operationId":"app_certified_feed_getActorReposts","tags":["app.certified.feed"],"description":"Lists an actor's indexed reposts, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw repost records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.","parameters":[{"name":"actor","in":"query","required":true,"description":"Repository-owner DID of the actor whose reposts to list.","schema":{"type":"string","format":"did"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort by representative repost createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getActorReposts.output"}}}}},"x-lexicon-id":"app.certified.feed.getActorReposts","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID, sort direction, limit, cursor, or another query parameter is invalid."},{"name":"RepostQueryFailed","description":"The actor reposts query failed; retry the request."}]}}, + "/xrpc/app.certified.feed.getLikes": {"get":{"operationId":"app_certified_feed_getLikes","tags":["app.certified.feed"],"description":"Lists distinct actors liking a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.","parameters":[{"name":"subject","in":"query","required":true,"description":"Full DID-authority record AT-URI; any target collection is accepted.","schema":{"type":"string","format":"at-uri"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort by representative like createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getLikes.output"}}}}},"x-lexicon-id":"app.certified.feed.getLikes","x-lexicon-errors":[{"name":"InvalidRequest","description":"The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid."},{"name":"LikeQueryFailed","description":"The like query, total count, or required actor hydration failed; retry the request."}]}}, + "/xrpc/app.certified.feed.getReposts": {"get":{"operationId":"app_certified_feed_getReposts","tags":["app.certified.feed"],"description":"Lists distinct actors reposting a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.","parameters":[{"name":"subject","in":"query","required":true,"description":"Full DID-authority record AT-URI; any target collection is accepted.","schema":{"type":"string","format":"at-uri"}},{"name":"sortDirection","in":"query","required":false,"description":"Sort by representative repost createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.feed.getReposts.output"}}}}},"x-lexicon-id":"app.certified.feed.getReposts","x-lexicon-errors":[{"name":"InvalidRequest","description":"The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid."},{"name":"RepostQueryFailed","description":"The repost query, total count, or required actor hydration failed; retry the request."}]}}, "/xrpc/app.certified.graph.getEntityFollow": {"get":{"operationId":"app_certified_graph_getEntityFollow","tags":["app.certified.graph"],"description":"Returns the representative entity-follow record for an actor and entity AT-URI, or null if none is indexed. No authentication is required; lookup failures return errors rather than null.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID that published the entity-follow record.","schema":{"type":"string","format":"did"}},{"name":"entity","in":"query","required":true,"description":"Full AT-URI of the followed record using a DID authority; any target collection is accepted, and matching is by URI, not CID.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.graph.getEntityFollow.output"}}}}},"x-lexicon-id":"app.certified.graph.getEntityFollow","x-lexicon-errors":[{"name":"InvalidRequest","description":"The actor DID, entity AT-URI, or another query parameter is invalid."}]}}, "/xrpc/app.certified.graph.getFollow": {"get":{"operationId":"app_certified_graph_getFollow","tags":["app.certified.graph"],"description":"Returns the representative follow record for an actor-to-actor relationship, or null if none is indexed. No authentication is required.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID of the actor who published the follow record.","schema":{"type":"string","format":"did"}},{"name":"subject","in":"query","required":true,"description":"DID named as the follow record's subject.","schema":{"type":"string","format":"did"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.graph.getFollow.output"}}}}},"x-lexicon-id":"app.certified.graph.getFollow","x-lexicon-errors":[{"name":"InvalidRequest","description":"A DID or query parameter is invalid."}]}}, "/xrpc/app.certified.graph.listActorFollowers": {"get":{"operationId":"app_certified_graph_listActorFollowers","tags":["app.certified.graph"],"description":"Lists actors whose follow records name the requested DID as subject. No authentication is required; duplicate relationships are collapsed before pagination, and only the returned actors are hydrated.","parameters":[{"name":"actor","in":"query","required":true,"description":"DID whose incoming follows are listed.","schema":{"type":"string","format":"did"}},{"name":"sortDirection","in":"query","required":false,"description":"Sorts by representative follow createdAt and URI; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum relationships per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep the other query parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app.certified.graph.listActorFollowers.output"}}}}},"x-lexicon-id":"app.certified.graph.listActorFollowers","x-lexicon-errors":[{"name":"InvalidRequest","description":"A DID or query parameter is invalid."}]}}, @@ -64,7 +68,7 @@ "schemas": { "app.certified.actor.profile.main": {"type":"record","description":"A declaration of a Certified account profile."}, "org.hypercerts.api.defs.profileView": {"type":"object","description":"Profile record view preserving the full indexed record; indexedAt may be null when the index has no timestamp.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.actor.profile.main"}}}, - "app.certified.actor.organization.main": {"type":"record","description":"Extended metadata for an organization actor. Complements the base actor profile with organization-specific fields like legal structure and reference links."}, + "app.certified.actor.organization.main": {"type":"record","description":"Extended metadata for an organization actor. Complements the base actor profile (app.certified.actor.profile), which holds the display name, description, website, avatar, and banner, with organization-specific fields such as legal name, full logo, public contact email, legal structure, reference links, and locations."}, "org.hypercerts.api.defs.organizationView": {"type":"object","description":"Raw organization sidecar record view preserving the full indexed record; indexedAt may be null when the index has no timestamp.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.actor.organization.main"}}}, "app.certified.actor.getOrganization.organizationActorView": {"type":"object","description":"Actor with an indexed organization sidecar and nullable profile view.","required":["did","profile","organization"],"properties":{"did":{"type":"string","format":"did"},"profile":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.profileView"},{"type":"null"}]},"organization":{"$ref":"#/components/schemas/org.hypercerts.api.defs.organizationView"}}}, "app.certified.actor.getOrganization.output": {"type":"object","required":["actor"],"properties":{"actor":{"$ref":"#/components/schemas/app.certified.actor.getOrganization.organizationActorView"}}}, @@ -94,10 +98,20 @@ "app.certified.badge.listBadgeResponses.output": {"type":"object","required":["badgeResponses"],"properties":{"badgeResponses":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.badge.getBadgeResponse.badgeResponseView"}},"cursor":{"type":"string","description":"Opaque cursor for the next page; omitted when pagination is complete."}}}, "app.certified.badge.searchBadgeDefinitions.badgeDefinitionView": {"type":"object","description":"Indexed badge definition with its publisher actor.","required":["uri","cid","indexedAt","did","author","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"author":{"$ref":"#/components/schemas/org.hypercerts.api.defs.actorView"},"record":{"$ref":"#/components/schemas/app.certified.badge.definition.main"}}}, "app.certified.badge.searchBadgeDefinitions.output": {"type":"object","required":["badgeDefinitions"],"properties":{"badgeDefinitions":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.badge.searchBadgeDefinitions.badgeDefinitionView"}},"cursor":{"type":"string","description":"Opaque cursor for the next page; omitted when pagination is complete."}}}, - "app.certified.graph.entityFollow.main": {"type":"record","description":"Record declaring a social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future. Duplicate follows will be ignored by the AppView."}, + "app.certified.feed.like.main": {"type":"record","description":"Record declaring a 'like' of a piece of subject content. Social feedback only: a like carries no protocol meaning about the subject (it is not an evaluation, endorsement, or acknowledgement). Schema-compatible with app.bsky.feed.like."}, + "app.certified.feed.getLikes.likeRecordView": {"type":"object","description":"Raw indexed like preserving the full record, including subject, via, and signatures when present. Duplicates use the earliest createdAt, with URI ascending on ties.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","description":"Repository-owner DID of the like publisher.","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.feed.like.main"}}}, + "app.certified.feed.getActorLikes.output": {"type":"object","required":["likes"],"properties":{"likes":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.feed.getLikes.likeRecordView"}},"cursor":{"type":"string","description":"Next-page cursor; omitted when pagination is complete."}}}, + "app.certified.feed.repost.main": {"type":"record","description":"Record representing a 'repost' of an existing record, surfacing it to the reposter's followers. The subject is pinned by CID, so the repost refers to the version the reposter saw, not to later edits. Schema-compatible with app.bsky.feed.repost."}, + "app.certified.feed.getReposts.repostRecordView": {"type":"object","description":"Raw indexed repost preserving the full record, including subject, via, and signatures when present. Its URI and CID identify the repost for feed attribution. Duplicates use the earliest createdAt, with URI ascending on ties.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","description":"Repository-owner DID of the repost publisher.","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.feed.repost.main"}}}, + "app.certified.feed.getActorReposts.output": {"type":"object","required":["reposts"],"properties":{"reposts":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.feed.getReposts.repostRecordView"}},"cursor":{"type":"string","description":"Next-page cursor; omitted when pagination is complete."}}}, + "app.certified.feed.getLikes.likeView": {"type":"object","description":"Liker actor sidecars paired with the representative raw like record. Missing profile and organization records are null.","required":["did","profile","organization","like"],"properties":{"did":{"type":"string","format":"did"},"profile":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.profileView"},{"type":"null"}]},"organization":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.organizationView"},{"type":"null"}]},"like":{"$ref":"#/components/schemas/app.certified.feed.getLikes.likeRecordView"}}}, + "app.certified.feed.getLikes.output": {"type":"object","required":["likeCount","likes"],"properties":{"likeCount":{"type":"integer","description":"Total distinct indexed author DIDs liking this subject URI, independent of page size. Zero when no likes are indexed; totals may change between requests.","minimum":0},"likes":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.feed.getLikes.likeView"}},"cursor":{"type":"string","description":"Next-page cursor; omitted when pagination is complete."}}}, + "app.certified.feed.getReposts.repostView": {"type":"object","description":"Reposter actor sidecars paired with the representative raw repost record. Missing profile and organization records are null.","required":["did","profile","organization","repost"],"properties":{"did":{"type":"string","format":"did"},"profile":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.profileView"},{"type":"null"}]},"organization":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.organizationView"},{"type":"null"}]},"repost":{"$ref":"#/components/schemas/app.certified.feed.getReposts.repostRecordView"}}}, + "app.certified.feed.getReposts.output": {"type":"object","required":["repostCount","reposts"],"properties":{"repostCount":{"type":"integer","description":"Total distinct indexed author DIDs reposting this subject URI, independent of page size. Zero when no reposts are indexed; totals may change between requests.","minimum":0},"reposts":{"type":"array","items":{"$ref":"#/components/schemas/app.certified.feed.getReposts.repostView"}},"cursor":{"type":"string","description":"Next-page cursor; omitted when pagination is complete."}}}, + "app.certified.graph.entityFollow.main": {"type":"record","description":"Record declaring a social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future."}, "app.certified.graph.getEntityFollow.entityFollowRecordView": {"type":"object","description":"Raw indexed entity-follow record view, including the DID that published the relationship.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.graph.entityFollow.main"}}}, "app.certified.graph.getEntityFollow.output": {"type":"object","required":["follow"],"properties":{"follow":{"anyOf":[{"$ref":"#/components/schemas/app.certified.graph.getEntityFollow.entityFollowRecordView"},{"type":"null"}]}}}, - "app.certified.graph.follow.main": {"type":"record","description":"Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView."}, + "app.certified.graph.follow.main": {"type":"record","description":"Record declaring a social 'follow' relationship of another account."}, "app.certified.graph.getFollow.followRecordView": {"type":"object","description":"Raw indexed follow record with its publisher DID. For duplicates, the earliest createdAt wins, with URI ascending on ties.","required":["uri","cid","indexedAt","did","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"record":{"$ref":"#/components/schemas/app.certified.graph.follow.main"}}}, "app.certified.graph.getFollow.output": {"type":"object","description":"Representative raw follow record, or null if no relationship is indexed.","required":["follow"],"properties":{"follow":{"anyOf":[{"$ref":"#/components/schemas/app.certified.graph.getFollow.followRecordView"},{"type":"null"}]}}}, "app.certified.graph.getFollow.actorFollowView": {"type":"object","description":"Displayed actor with nullable profile and organization sidecars, paired with the representative follow record.","required":["did","profile","organization","follow"],"properties":{"did":{"type":"string","format":"did"},"profile":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.profileView"},{"type":"null"}]},"organization":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.organizationView"},{"type":"null"}]},"follow":{"$ref":"#/components/schemas/app.certified.graph.getFollow.followRecordView"}}}, diff --git a/.changeset/feed-reactions-queries.md b/.changeset/feed-reactions-queries.md new file mode 100644 index 0000000..579aaea --- /dev/null +++ b/.changeset/feed-reactions-queries.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': minor +--- + +Add public queries for actors who liked or reposted a record and for an actor's own likes and reposts. Subject queries include distinct-actor totals; all four queries support bounded pagination. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c4a63d..b05d7ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ This updates `docs/sources/index.json`, the committed Lexicon snapshots, `docs/o ## HTTP runtime tests -`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, EVM-link, acknowledgement, collection, context-measurement, context attachment and evaluation, contributor-information, funding, location, profile, organization, feature, work-scope-tag, rights, vocabulary-tag, graph, and contribution query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. +`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, EVM-link, acknowledgement, collection, context-measurement, context attachment and evaluation, contributor-information, funding, location, profile, organization, feature, work-scope-tag, rights, vocabulary-tag, graph, contribution, and feed-reaction query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. The local runner requires a local Docker Compose daemon, `psql`, and the pinned PostgreSQL and HappyView images already cached locally. Set `PSQL_PATH` to the absolute path of a trusted `psql` executable: @@ -57,6 +57,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Activity retrieval with contributor-sidecar hydration, author/organization/contributor/URI filters, tied timestamp pagination, and literal wildcard search. - Collection retrieval with CBOR-derived CIDs, location/tag projections, author, organization, item and tag filters, title/shortDescription search, `createdAt`/URI pagination ties, and source-order item pagination with exact-version resolution. - Graph actor/entity lookups and lists, tied-key pagination, nullable profile/organization sidecars, entity target resolution, and the global recent-follows `before` filter. +- Feed-reaction subject and actor queries, earliest duplicate selection, URI-only subject matching across CIDs, distinct-actor totals, tied cursor pagination, page-only sidecar hydration, raw `via`/signature preservation, and actor queries without hydration. - Contribution exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. - Badge, badge-query, acknowledgement, contribution, and rights HTTP fixtures with CBOR-derived record CIDs; contribution DIDs are distinct from baseline fixture identities. - Location retrieval with nullable sidecars, repeated author/URI/location-type filters, unsupported-search errors, tied pagination in both directions, malformed/absent `createdAt` handling, and named errors. diff --git a/api/README.md b/api/README.md index 594bf0b..b1fb4cb 100644 --- a/api/README.md +++ b/api/README.md @@ -6,6 +6,12 @@ This package owns the shared API installer and build tooling, pinned upstream Le Use the [`docs/` endpoint explorer](../docs/) and generated [OpenAPI reference](../docs/openapi.json) for endpoint names and contracts. The [coverage report](../docs/coverage.json) lists manifest-registered operations; inclusion does not imply runtime or deployment validation. See [docs/README.md](../docs/README.md) to run the explorer locally and refresh its committed reference. +## Feed reaction capability + +`modules/feed-reactions/manifest.json` owns the like and repost record schemas and four public queries: `app.certified.feed.getLikes`, `getReposts`, `getActorLikes`, and `getActorReposts`. The root manifest includes this independently installable module in the aggregate bundle. Its record schemas enable backfill, and queries read the current indexed records directly; deleted reactions therefore stop appearing once removed from HappyView's index, without a separate stored count. + +Subject queries match by AT-URI without requiring a subject CID match, return one earliest reaction per author, and include a distinct-actor total with nullable profile and organization sidecars. Actor queries return raw reaction records without subject hydration. All four use bounded `createdAt`/URI cursor pagination. + ## Local validation From the repository root: diff --git a/api/lexicons/app.certified.feed.getActorLikes.json b/api/lexicons/app.certified.feed.getActorLikes.json new file mode 100644 index 0000000..3751ff3 --- /dev/null +++ b/api/lexicons/app.certified.feed.getActorLikes.json @@ -0,0 +1,33 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getActorLikes", + "defs": { + "main": { + "type": "query", + "description": "Lists an actor's indexed likes, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw like records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { "type": "string", "format": "did", "description": "Repository-owner DID of the actor whose likes to list." }, + "sortDirection": { "type": "string", "enum": ["asc", "desc"], "description": "Sort by representative like createdAt and URI; defaults to desc." }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum relationships per page; defaults to 25." }, + "cursor": { "type": "string", "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." } + } + }, + "output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } }, + "errors": [ + { "name": "InvalidRequest", "description": "The actor DID, sort direction, limit, cursor, or another query parameter is invalid." }, + { "name": "LikeQueryFailed", "description": "The actor likes query failed; retry the request." } + ] + }, + "output": { + "type": "object", + "required": ["likes"], + "properties": { + "likes": { "type": "array", "items": { "type": "ref", "ref": "app.certified.feed.getLikes#likeRecordView" } }, + "cursor": { "type": "string", "description": "Next-page cursor; omitted when pagination is complete." } + } + } + } +} diff --git a/api/lexicons/app.certified.feed.getActorReposts.json b/api/lexicons/app.certified.feed.getActorReposts.json new file mode 100644 index 0000000..58546cb --- /dev/null +++ b/api/lexicons/app.certified.feed.getActorReposts.json @@ -0,0 +1,33 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getActorReposts", + "defs": { + "main": { + "type": "query", + "description": "Lists an actor's indexed reposts, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw repost records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { "type": "string", "format": "did", "description": "Repository-owner DID of the actor whose reposts to list." }, + "sortDirection": { "type": "string", "enum": ["asc", "desc"], "description": "Sort by representative repost createdAt and URI; defaults to desc." }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum relationships per page; defaults to 25." }, + "cursor": { "type": "string", "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." } + } + }, + "output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } }, + "errors": [ + { "name": "InvalidRequest", "description": "The actor DID, sort direction, limit, cursor, or another query parameter is invalid." }, + { "name": "RepostQueryFailed", "description": "The actor reposts query failed; retry the request." } + ] + }, + "output": { + "type": "object", + "required": ["reposts"], + "properties": { + "reposts": { "type": "array", "items": { "type": "ref", "ref": "app.certified.feed.getReposts#repostRecordView" } }, + "cursor": { "type": "string", "description": "Next-page cursor; omitted when pagination is complete." } + } + } + } +} diff --git a/api/lexicons/app.certified.feed.getLikes.json b/api/lexicons/app.certified.feed.getLikes.json new file mode 100644 index 0000000..7125c7c --- /dev/null +++ b/api/lexicons/app.certified.feed.getLikes.json @@ -0,0 +1,59 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getLikes", + "defs": { + "main": { + "type": "query", + "description": "Lists distinct actors liking a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.", + "parameters": { + "type": "params", + "required": ["subject"], + "properties": { + "subject": { "type": "string", "format": "at-uri", "description": "Full DID-authority record AT-URI; any target collection is accepted." }, + "sortDirection": { "type": "string", "enum": ["asc", "desc"], "description": "Sort by representative like createdAt and URI; defaults to desc." }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum relationships per page; defaults to 25." }, + "cursor": { "type": "string", "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." } + } + }, + "output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } }, + "errors": [ + { "name": "InvalidRequest", "description": "The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid." }, + { "name": "LikeQueryFailed", "description": "The like query, total count, or required actor hydration failed; retry the request." } + ] + }, + "likeRecordView": { + "type": "object", + "description": "Raw indexed like preserving the full record, including subject, via, and signatures when present. Duplicates use the earliest createdAt, with URI ascending on ties.", + "required": ["uri", "cid", "indexedAt", "did", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did", "description": "Repository-owner DID of the like publisher." }, + "record": { "type": "ref", "ref": "app.certified.feed.like" } + } + }, + "likeView": { + "type": "object", + "description": "Liker actor sidecars paired with the representative raw like record. Missing profile and organization records are null.", + "required": ["did", "profile", "organization", "like"], + "nullable": ["profile", "organization"], + "properties": { + "did": { "type": "string", "format": "did" }, + "profile": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" }, + "organization": { "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" }, + "like": { "type": "ref", "ref": "#likeRecordView" } + } + }, + "output": { + "type": "object", + "required": ["likeCount", "likes"], + "properties": { + "likeCount": { "type": "integer", "minimum": 0, "description": "Total distinct indexed author DIDs liking this subject URI, independent of page size. Zero when no likes are indexed; totals may change between requests." }, + "likes": { "type": "array", "items": { "type": "ref", "ref": "#likeView" } }, + "cursor": { "type": "string", "description": "Next-page cursor; omitted when pagination is complete." } + } + } + } +} diff --git a/api/lexicons/app.certified.feed.getReposts.json b/api/lexicons/app.certified.feed.getReposts.json new file mode 100644 index 0000000..9706736 --- /dev/null +++ b/api/lexicons/app.certified.feed.getReposts.json @@ -0,0 +1,59 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getReposts", + "defs": { + "main": { + "type": "query", + "description": "Lists distinct actors reposting a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.", + "parameters": { + "type": "params", + "required": ["subject"], + "properties": { + "subject": { "type": "string", "format": "at-uri", "description": "Full DID-authority record AT-URI; any target collection is accepted." }, + "sortDirection": { "type": "string", "enum": ["asc", "desc"], "description": "Sort by representative repost createdAt and URI; defaults to desc." }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum relationships per page; defaults to 25." }, + "cursor": { "type": "string", "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." } + } + }, + "output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } }, + "errors": [ + { "name": "InvalidRequest", "description": "The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid." }, + { "name": "RepostQueryFailed", "description": "The repost query, total count, or required actor hydration failed; retry the request." } + ] + }, + "repostRecordView": { + "type": "object", + "description": "Raw indexed repost preserving the full record, including subject, via, and signatures when present. Its URI and CID identify the repost for feed attribution. Duplicates use the earliest createdAt, with URI ascending on ties.", + "required": ["uri", "cid", "indexedAt", "did", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did", "description": "Repository-owner DID of the repost publisher." }, + "record": { "type": "ref", "ref": "app.certified.feed.repost" } + } + }, + "repostView": { + "type": "object", + "description": "Reposter actor sidecars paired with the representative raw repost record. Missing profile and organization records are null.", + "required": ["did", "profile", "organization", "repost"], + "nullable": ["profile", "organization"], + "properties": { + "did": { "type": "string", "format": "did" }, + "profile": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" }, + "organization": { "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" }, + "repost": { "type": "ref", "ref": "#repostRecordView" } + } + }, + "output": { + "type": "object", + "required": ["repostCount", "reposts"], + "properties": { + "repostCount": { "type": "integer", "minimum": 0, "description": "Total distinct indexed author DIDs reposting this subject URI, independent of page size. Zero when no reposts are indexed; totals may change between requests." }, + "reposts": { "type": "array", "items": { "type": "ref", "ref": "#repostView" } }, + "cursor": { "type": "string", "description": "Next-page cursor; omitted when pagination is complete." } + } + } + } +} diff --git a/api/lua/endpoints/getActorLikes.lua b/api/lua/endpoints/getActorLikes.lua new file mode 100644 index 0000000..2f29f12 --- /dev/null +++ b/api/lua/endpoints/getActorLikes.lua @@ -0,0 +1,283 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local FEED_REACTIONS = { + likes = { + collection = "app.certified.feed.like", + output = "likes", + count = "likeCount", + failure = "LikeQueryFailed", + }, + reposts = { + collection = "app.certified.feed.repost", + output = "reposts", + count = "repostCount", + failure = "RepostQueryFailed", + }, +} + +local function feed_reaction_sort_key() + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" +end + +local function feed_reaction_query(reaction, sql, values) + if db.backend() ~= "postgres" then error(reaction.failure .. ": reaction queries require PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error(reaction.failure .. ": indexed reaction query failed", 0) end + return result +end + +local function feed_reaction_decode_cursor(token, direction, collection) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, cursor_collection = valid_record_uri(value.u) + if not valid or cursor_collection ~= collection or not valid_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local filters, values = { "collection = $1" }, { reaction.collection, identity } + if mode == "subject" then + -- Subject identity is the AT-URI only; a newer CID must not split one reaction relationship. + filters[#filters + 1] = "record::jsonb->'subject'->>'uri' = $2" + else + filters[#filters + 1] = "did = $2" + end + + local cursor_filter = "" + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values .. "::timestamptz" + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + cursor_filter = " WHERE (sort_at, uri) " .. operator .. " (" .. timestamp .. ", " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local subject_uri = "record::jsonb->'subject'->>'uri'" + -- Select the earliest representative before applying the caller's independent page direction. + local sql = "WITH ranked_reactions AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. feed_reaction_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. + "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_reactions WHERE relationship_rank = 1), " + + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " + local select_page = "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " + if mode == "subject" then + sql = sql .. "total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. + select_page .. ", total.total_count FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = sql .. page .. select_page .. "FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end + return sql, values +end + +local function feed_reaction_build_views(reaction, rows, mode) + local views = {} + for _, row in ipairs(rows) do + if mode == "subject" then + views[#views + 1] = { did = row.did, [reaction.output == "likes" and "like" or "repost"] = record_view(row) } + else + views[#views + 1] = record_view(row) + end + end + if mode == "subject" then + local ok = pcall(hydrate_actor_views, views, function(sql, values) + return feed_reaction_query(reaction, sql, values) + end) + if not ok then error(reaction.failure .. ": actor hydration failed", 0) end + end + return views +end + +local function feed_reaction_list(reaction_name, mode) + local reaction = FEED_REACTIONS[reaction_name] + local allowed = mode == "subject" + and { subject = true, sortDirection = true, limit = true, cursor = true } + or { actor = true, sortDirection = true, limit = true, cursor = true } + keys_only(params, allowed) + + local identity = scalar(params, mode == "subject" and "subject" or "actor") + if mode == "subject" then + if not identity or #identity > 8192 or not valid_record_uri(identity) then + invalid("subject must be a full DID-authority AT-URI") + end + elseif not identity or not valid_did(identity) then + invalid("actor must be a valid DID") + end + + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = feed_reaction_decode_cursor(scalar(params, "cursor"), direction, reaction.collection) + local sql, values = feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local rows = feed_reaction_query(reaction, sql, values) + + local total_count + if mode == "subject" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error(reaction.failure .. ": distinct actor count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end + + local more = #rows > limit + if more then rows[#rows] = nil end + local ok, views = pcall(feed_reaction_build_views, reaction, rows, mode) + if not ok then error(reaction.failure .. ": reaction view construction failed", 0) end + + local next_cursor + if more then + local last = rows[#rows] + if not last or type(last.sort_timestamp) ~= "string" or type(last.uri) ~= "string" then + error(reaction.failure .. ": next-page cursor fields unavailable", 0) + end + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + + local response = { [reaction.output] = toarray(views) } + if mode == "subject" then response[reaction.count] = total_count end + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return feed_reaction_list("likes", "actor") +end diff --git a/api/lua/endpoints/getActorReposts.lua b/api/lua/endpoints/getActorReposts.lua new file mode 100644 index 0000000..1fc71f6 --- /dev/null +++ b/api/lua/endpoints/getActorReposts.lua @@ -0,0 +1,283 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local FEED_REACTIONS = { + likes = { + collection = "app.certified.feed.like", + output = "likes", + count = "likeCount", + failure = "LikeQueryFailed", + }, + reposts = { + collection = "app.certified.feed.repost", + output = "reposts", + count = "repostCount", + failure = "RepostQueryFailed", + }, +} + +local function feed_reaction_sort_key() + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" +end + +local function feed_reaction_query(reaction, sql, values) + if db.backend() ~= "postgres" then error(reaction.failure .. ": reaction queries require PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error(reaction.failure .. ": indexed reaction query failed", 0) end + return result +end + +local function feed_reaction_decode_cursor(token, direction, collection) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, cursor_collection = valid_record_uri(value.u) + if not valid or cursor_collection ~= collection or not valid_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local filters, values = { "collection = $1" }, { reaction.collection, identity } + if mode == "subject" then + -- Subject identity is the AT-URI only; a newer CID must not split one reaction relationship. + filters[#filters + 1] = "record::jsonb->'subject'->>'uri' = $2" + else + filters[#filters + 1] = "did = $2" + end + + local cursor_filter = "" + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values .. "::timestamptz" + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + cursor_filter = " WHERE (sort_at, uri) " .. operator .. " (" .. timestamp .. ", " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local subject_uri = "record::jsonb->'subject'->>'uri'" + -- Select the earliest representative before applying the caller's independent page direction. + local sql = "WITH ranked_reactions AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. feed_reaction_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. + "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_reactions WHERE relationship_rank = 1), " + + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " + local select_page = "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " + if mode == "subject" then + sql = sql .. "total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. + select_page .. ", total.total_count FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = sql .. page .. select_page .. "FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end + return sql, values +end + +local function feed_reaction_build_views(reaction, rows, mode) + local views = {} + for _, row in ipairs(rows) do + if mode == "subject" then + views[#views + 1] = { did = row.did, [reaction.output == "likes" and "like" or "repost"] = record_view(row) } + else + views[#views + 1] = record_view(row) + end + end + if mode == "subject" then + local ok = pcall(hydrate_actor_views, views, function(sql, values) + return feed_reaction_query(reaction, sql, values) + end) + if not ok then error(reaction.failure .. ": actor hydration failed", 0) end + end + return views +end + +local function feed_reaction_list(reaction_name, mode) + local reaction = FEED_REACTIONS[reaction_name] + local allowed = mode == "subject" + and { subject = true, sortDirection = true, limit = true, cursor = true } + or { actor = true, sortDirection = true, limit = true, cursor = true } + keys_only(params, allowed) + + local identity = scalar(params, mode == "subject" and "subject" or "actor") + if mode == "subject" then + if not identity or #identity > 8192 or not valid_record_uri(identity) then + invalid("subject must be a full DID-authority AT-URI") + end + elseif not identity or not valid_did(identity) then + invalid("actor must be a valid DID") + end + + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = feed_reaction_decode_cursor(scalar(params, "cursor"), direction, reaction.collection) + local sql, values = feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local rows = feed_reaction_query(reaction, sql, values) + + local total_count + if mode == "subject" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error(reaction.failure .. ": distinct actor count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end + + local more = #rows > limit + if more then rows[#rows] = nil end + local ok, views = pcall(feed_reaction_build_views, reaction, rows, mode) + if not ok then error(reaction.failure .. ": reaction view construction failed", 0) end + + local next_cursor + if more then + local last = rows[#rows] + if not last or type(last.sort_timestamp) ~= "string" or type(last.uri) ~= "string" then + error(reaction.failure .. ": next-page cursor fields unavailable", 0) + end + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + + local response = { [reaction.output] = toarray(views) } + if mode == "subject" then response[reaction.count] = total_count end + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return feed_reaction_list("reposts", "actor") +end diff --git a/api/lua/endpoints/getLikes.lua b/api/lua/endpoints/getLikes.lua new file mode 100644 index 0000000..7b9d2a2 --- /dev/null +++ b/api/lua/endpoints/getLikes.lua @@ -0,0 +1,283 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local FEED_REACTIONS = { + likes = { + collection = "app.certified.feed.like", + output = "likes", + count = "likeCount", + failure = "LikeQueryFailed", + }, + reposts = { + collection = "app.certified.feed.repost", + output = "reposts", + count = "repostCount", + failure = "RepostQueryFailed", + }, +} + +local function feed_reaction_sort_key() + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" +end + +local function feed_reaction_query(reaction, sql, values) + if db.backend() ~= "postgres" then error(reaction.failure .. ": reaction queries require PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error(reaction.failure .. ": indexed reaction query failed", 0) end + return result +end + +local function feed_reaction_decode_cursor(token, direction, collection) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, cursor_collection = valid_record_uri(value.u) + if not valid or cursor_collection ~= collection or not valid_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local filters, values = { "collection = $1" }, { reaction.collection, identity } + if mode == "subject" then + -- Subject identity is the AT-URI only; a newer CID must not split one reaction relationship. + filters[#filters + 1] = "record::jsonb->'subject'->>'uri' = $2" + else + filters[#filters + 1] = "did = $2" + end + + local cursor_filter = "" + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values .. "::timestamptz" + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + cursor_filter = " WHERE (sort_at, uri) " .. operator .. " (" .. timestamp .. ", " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local subject_uri = "record::jsonb->'subject'->>'uri'" + -- Select the earliest representative before applying the caller's independent page direction. + local sql = "WITH ranked_reactions AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. feed_reaction_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. + "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_reactions WHERE relationship_rank = 1), " + + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " + local select_page = "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " + if mode == "subject" then + sql = sql .. "total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. + select_page .. ", total.total_count FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = sql .. page .. select_page .. "FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end + return sql, values +end + +local function feed_reaction_build_views(reaction, rows, mode) + local views = {} + for _, row in ipairs(rows) do + if mode == "subject" then + views[#views + 1] = { did = row.did, [reaction.output == "likes" and "like" or "repost"] = record_view(row) } + else + views[#views + 1] = record_view(row) + end + end + if mode == "subject" then + local ok = pcall(hydrate_actor_views, views, function(sql, values) + return feed_reaction_query(reaction, sql, values) + end) + if not ok then error(reaction.failure .. ": actor hydration failed", 0) end + end + return views +end + +local function feed_reaction_list(reaction_name, mode) + local reaction = FEED_REACTIONS[reaction_name] + local allowed = mode == "subject" + and { subject = true, sortDirection = true, limit = true, cursor = true } + or { actor = true, sortDirection = true, limit = true, cursor = true } + keys_only(params, allowed) + + local identity = scalar(params, mode == "subject" and "subject" or "actor") + if mode == "subject" then + if not identity or #identity > 8192 or not valid_record_uri(identity) then + invalid("subject must be a full DID-authority AT-URI") + end + elseif not identity or not valid_did(identity) then + invalid("actor must be a valid DID") + end + + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = feed_reaction_decode_cursor(scalar(params, "cursor"), direction, reaction.collection) + local sql, values = feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local rows = feed_reaction_query(reaction, sql, values) + + local total_count + if mode == "subject" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error(reaction.failure .. ": distinct actor count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end + + local more = #rows > limit + if more then rows[#rows] = nil end + local ok, views = pcall(feed_reaction_build_views, reaction, rows, mode) + if not ok then error(reaction.failure .. ": reaction view construction failed", 0) end + + local next_cursor + if more then + local last = rows[#rows] + if not last or type(last.sort_timestamp) ~= "string" or type(last.uri) ~= "string" then + error(reaction.failure .. ": next-page cursor fields unavailable", 0) + end + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + + local response = { [reaction.output] = toarray(views) } + if mode == "subject" then response[reaction.count] = total_count end + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return feed_reaction_list("likes", "subject") +end diff --git a/api/lua/endpoints/getReposts.lua b/api/lua/endpoints/getReposts.lua new file mode 100644 index 0000000..7870273 --- /dev/null +++ b/api/lua/endpoints/getReposts.lua @@ -0,0 +1,283 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local FEED_REACTIONS = { + likes = { + collection = "app.certified.feed.like", + output = "likes", + count = "likeCount", + failure = "LikeQueryFailed", + }, + reposts = { + collection = "app.certified.feed.repost", + output = "reposts", + count = "repostCount", + failure = "RepostQueryFailed", + }, +} + +local function feed_reaction_sort_key() + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" +end + +local function feed_reaction_query(reaction, sql, values) + if db.backend() ~= "postgres" then error(reaction.failure .. ": reaction queries require PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error(reaction.failure .. ": indexed reaction query failed", 0) end + return result +end + +local function feed_reaction_decode_cursor(token, direction, collection) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, cursor_collection = valid_record_uri(value.u) + if not valid or cursor_collection ~= collection or not valid_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local filters, values = { "collection = $1" }, { reaction.collection, identity } + if mode == "subject" then + -- Subject identity is the AT-URI only; a newer CID must not split one reaction relationship. + filters[#filters + 1] = "record::jsonb->'subject'->>'uri' = $2" + else + filters[#filters + 1] = "did = $2" + end + + local cursor_filter = "" + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values .. "::timestamptz" + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + cursor_filter = " WHERE (sort_at, uri) " .. operator .. " (" .. timestamp .. ", " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local subject_uri = "record::jsonb->'subject'->>'uri'" + -- Select the earliest representative before applying the caller's independent page direction. + local sql = "WITH ranked_reactions AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. feed_reaction_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. + "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_reactions WHERE relationship_rank = 1), " + + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " + local select_page = "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " + if mode == "subject" then + sql = sql .. "total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. + select_page .. ", total.total_count FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = sql .. page .. select_page .. "FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end + return sql, values +end + +local function feed_reaction_build_views(reaction, rows, mode) + local views = {} + for _, row in ipairs(rows) do + if mode == "subject" then + views[#views + 1] = { did = row.did, [reaction.output == "likes" and "like" or "repost"] = record_view(row) } + else + views[#views + 1] = record_view(row) + end + end + if mode == "subject" then + local ok = pcall(hydrate_actor_views, views, function(sql, values) + return feed_reaction_query(reaction, sql, values) + end) + if not ok then error(reaction.failure .. ": actor hydration failed", 0) end + end + return views +end + +local function feed_reaction_list(reaction_name, mode) + local reaction = FEED_REACTIONS[reaction_name] + local allowed = mode == "subject" + and { subject = true, sortDirection = true, limit = true, cursor = true } + or { actor = true, sortDirection = true, limit = true, cursor = true } + keys_only(params, allowed) + + local identity = scalar(params, mode == "subject" and "subject" or "actor") + if mode == "subject" then + if not identity or #identity > 8192 or not valid_record_uri(identity) then + invalid("subject must be a full DID-authority AT-URI") + end + elseif not identity or not valid_did(identity) then + invalid("actor must be a valid DID") + end + + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = feed_reaction_decode_cursor(scalar(params, "cursor"), direction, reaction.collection) + local sql, values = feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local rows = feed_reaction_query(reaction, sql, values) + + local total_count + if mode == "subject" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error(reaction.failure .. ": distinct actor count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end + + local more = #rows > limit + if more then rows[#rows] = nil end + local ok, views = pcall(feed_reaction_build_views, reaction, rows, mode) + if not ok then error(reaction.failure .. ": reaction view construction failed", 0) end + + local next_cursor + if more then + local last = rows[#rows] + if not last or type(last.sort_timestamp) ~= "string" or type(last.uri) ~= "string" then + error(reaction.failure .. ": next-page cursor fields unavailable", 0) + end + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + + local response = { [reaction.output] = toarray(views) } + if mode == "subject" then response[reaction.count] = total_count end + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return feed_reaction_list("reposts", "subject") +end diff --git a/api/lua/shared/feedReaction.lua b/api/lua/shared/feedReaction.lua new file mode 100644 index 0000000..fa2f694 --- /dev/null +++ b/api/lua/shared/feedReaction.lua @@ -0,0 +1,154 @@ +local FEED_REACTIONS = { + likes = { + collection = "app.certified.feed.like", + output = "likes", + count = "likeCount", + failure = "LikeQueryFailed", + }, + reposts = { + collection = "app.certified.feed.repost", + output = "reposts", + count = "repostCount", + failure = "RepostQueryFailed", + }, +} + +local function feed_reaction_sort_key() + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" +end + +local function feed_reaction_query(reaction, sql, values) + if db.backend() ~= "postgres" then error(reaction.failure .. ": reaction queries require PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error(reaction.failure .. ": indexed reaction query failed", 0) end + return result +end + +local function feed_reaction_decode_cursor(token, direction, collection) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, cursor_collection = valid_record_uri(value.u) + if not valid or cursor_collection ~= collection or not valid_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local filters, values = { "collection = $1" }, { reaction.collection, identity } + if mode == "subject" then + -- Subject identity is the AT-URI only; a newer CID must not split one reaction relationship. + filters[#filters + 1] = "record::jsonb->'subject'->>'uri' = $2" + else + filters[#filters + 1] = "did = $2" + end + + local cursor_filter = "" + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values .. "::timestamptz" + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + cursor_filter = " WHERE (sort_at, uri) " .. operator .. " (" .. timestamp .. ", " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local subject_uri = "record::jsonb->'subject'->>'uri'" + -- Select the earliest representative before applying the caller's independent page direction. + local sql = "WITH ranked_reactions AS (SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, sorted.sort_at, ROW_NUMBER() OVER (PARTITION BY did, " .. subject_uri .. " ORDER BY sorted.sort_at ASC, uri ASC) AS relationship_rank FROM happyview_records CROSS JOIN LATERAL (SELECT " .. feed_reaction_sort_key() .. " AS sort_at) sorted WHERE " .. table.concat(filters, " AND ") .. "), " .. + "representatives AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM ranked_reactions WHERE relationship_rank = 1), " + + local page = "page AS (SELECT uri, did, cid, indexed_at, record, sort_at FROM representatives" .. cursor_filter .. " ORDER BY sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values .. ") " + local select_page = "SELECT page.uri, page.did, page.cid, page.indexed_at, page.record, to_char(page.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " + if mode == "subject" then + sql = sql .. "total AS (SELECT COUNT(*) AS total_count FROM representatives), " .. page .. + select_page .. ", total.total_count FROM total LEFT JOIN page ON TRUE ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + else + sql = sql .. page .. select_page .. "FROM page ORDER BY page.sort_at " .. ordering .. ", page.uri " .. ordering + end + return sql, values +end + +local function feed_reaction_build_views(reaction, rows, mode) + local views = {} + for _, row in ipairs(rows) do + if mode == "subject" then + views[#views + 1] = { did = row.did, [reaction.output == "likes" and "like" or "repost"] = record_view(row) } + else + views[#views + 1] = record_view(row) + end + end + if mode == "subject" then + local ok = pcall(hydrate_actor_views, views, function(sql, values) + return feed_reaction_query(reaction, sql, values) + end) + if not ok then error(reaction.failure .. ": actor hydration failed", 0) end + end + return views +end + +local function feed_reaction_list(reaction_name, mode) + local reaction = FEED_REACTIONS[reaction_name] + local allowed = mode == "subject" + and { subject = true, sortDirection = true, limit = true, cursor = true } + or { actor = true, sortDirection = true, limit = true, cursor = true } + keys_only(params, allowed) + + local identity = scalar(params, mode == "subject" and "subject" or "actor") + if mode == "subject" then + if not identity or #identity > 8192 or not valid_record_uri(identity) then + invalid("subject must be a full DID-authority AT-URI") + end + elseif not identity or not valid_did(identity) then + invalid("actor must be a valid DID") + end + + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = feed_reaction_decode_cursor(scalar(params, "cursor"), direction, reaction.collection) + local sql, values = feed_reaction_query_page(reaction, mode, identity, limit, cursor, direction) + local rows = feed_reaction_query(reaction, sql, values) + + local total_count + if mode == "subject" then + total_count = rows[1] and tonumber(rows[1].total_count) + if not total_count or total_count < 0 or total_count % 1 ~= 0 then + error(reaction.failure .. ": distinct actor count unavailable", 0) + end + if rows[1].uri == nil then rows = {} end + end + + local more = #rows > limit + if more then rows[#rows] = nil end + local ok, views = pcall(feed_reaction_build_views, reaction, rows, mode) + if not ok then error(reaction.failure .. ": reaction view construction failed", 0) end + + local next_cursor + if more then + local last = rows[#rows] + if not last or type(last.sort_timestamp) ~= "string" or type(last.uri) ~= "string" then + error(reaction.failure .. ": next-page cursor fields unavailable", 0) + end + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + + local response = { [reaction.output] = toarray(views) } + if mode == "subject" then response[reaction.count] = total_count end + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/api/lua/src/getActorLikes.lua b/api/lua/src/getActorLikes.lua new file mode 100644 index 0000000..cd601cc --- /dev/null +++ b/api/lua/src/getActorLikes.lua @@ -0,0 +1,3 @@ +function handle() + return feed_reaction_list("likes", "actor") +end diff --git a/api/lua/src/getActorReposts.lua b/api/lua/src/getActorReposts.lua new file mode 100644 index 0000000..6719174 --- /dev/null +++ b/api/lua/src/getActorReposts.lua @@ -0,0 +1,3 @@ +function handle() + return feed_reaction_list("reposts", "actor") +end diff --git a/api/lua/src/getLikes.lua b/api/lua/src/getLikes.lua new file mode 100644 index 0000000..f943a40 --- /dev/null +++ b/api/lua/src/getLikes.lua @@ -0,0 +1,3 @@ +function handle() + return feed_reaction_list("likes", "subject") +end diff --git a/api/lua/src/getReposts.lua b/api/lua/src/getReposts.lua new file mode 100644 index 0000000..b66fac9 --- /dev/null +++ b/api/lua/src/getReposts.lua @@ -0,0 +1,3 @@ +function handle() + return feed_reaction_list("reposts", "subject") +end diff --git a/api/manifest.json b/api/manifest.json index a51addc..413e2cf 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -29,6 +29,10 @@ "getFollow": "implemented", "listActorFollowers": "implemented", "listActorFollowing": "implemented", + "getLikes": "implemented", + "getReposts": "implemented", + "getActorLikes": "implemented", + "getActorReposts": "implemented", "getEntityFollow": "implemented", "listEntityFollowers": "implemented", "listEntityFollowing": "implemented", @@ -111,6 +115,22 @@ "id": "app.certified.graph.listRecentFollows", "path": "lexicons/app.certified.graph.listRecentFollows.json" }, + { + "id": "app.certified.feed.getLikes", + "path": "lexicons/app.certified.feed.getLikes.json" + }, + { + "id": "app.certified.feed.getReposts", + "path": "lexicons/app.certified.feed.getReposts.json" + }, + { + "id": "app.certified.feed.getActorLikes", + "path": "lexicons/app.certified.feed.getActorLikes.json" + }, + { + "id": "app.certified.feed.getActorReposts", + "path": "lexicons/app.certified.feed.getActorReposts.json" + }, { "id": "app.certified.actor.profile", "packagePath": "lexicons/app/certified/actor/profile.json" @@ -263,6 +283,14 @@ "id": "app.certified.signature.defs", "packagePath": "lexicons/app/certified/signature/defs.json" }, + { + "id": "app.certified.feed.like", + "packagePath": "lexicons/app/certified/feed/like.json" + }, + { + "id": "app.certified.feed.repost", + "packagePath": "lexicons/app/certified/feed/repost.json" + }, { "id": "app.certified.defs", "packagePath": "lexicons/app/certified/defs.json" @@ -519,6 +547,7 @@ "modules/actor-follow/manifest.json", "modules/entity-follow/manifest.json", "modules/recent-follows/manifest.json", + "modules/feed-reactions/manifest.json", "modules/context-evaluation/manifest.json", "modules/context-attachments/manifest.json", "modules/contributor-information/manifest.json", diff --git a/api/modules/feed-reactions/manifest.json b/api/modules/feed-reactions/manifest.json new file mode 100644 index 0000000..294a7ff --- /dev/null +++ b/api/modules/feed-reactions/manifest.json @@ -0,0 +1,122 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "app.certified.feed.like", + "packagePath": "lexicons/app/certified/feed/like.json", + "config": { "backfill": true }, + "dependsOn": ["app.certified.signature.defs"] + }, + { + "kind": "lexicon", + "id": "app.certified.feed.repost", + "packagePath": "lexicons/app/certified/feed/repost.json", + "config": { "backfill": true }, + "dependsOn": ["app.certified.signature.defs"] + }, + { + "kind": "lexicon", + "id": "app.certified.feed.getLikes", + "path": "../../lexicons/app.certified.feed.getLikes.json", + "config": { "backfill": false, "target_collection": "app.certified.feed.like" }, + "dependsOn": ["app.certified.feed.like", "org.hypercerts.api.defs"] + }, + { + "kind": "lexicon", + "id": "app.certified.feed.getReposts", + "path": "../../lexicons/app.certified.feed.getReposts.json", + "config": { "backfill": false, "target_collection": "app.certified.feed.repost" }, + "dependsOn": ["app.certified.feed.repost", "org.hypercerts.api.defs"] + }, + { + "kind": "lexicon", + "id": "app.certified.feed.getActorLikes", + "path": "../../lexicons/app.certified.feed.getActorLikes.json", + "config": { "backfill": false, "target_collection": "app.certified.feed.like" }, + "dependsOn": ["app.certified.feed.getLikes"] + }, + { + "kind": "lexicon", + "id": "app.certified.feed.getActorReposts", + "path": "../../lexicons/app.certified.feed.getActorReposts.json", + "config": { "backfill": false, "target_collection": "app.certified.feed.repost" }, + "dependsOn": ["app.certified.feed.getReposts"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.feed.getLikes", + "path": "../../lua/endpoints/getLikes.lua", + "sourcePath": "../../lua/src/getLikes.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/feedReaction.lua" + ], + "config": { "script_type": "lua", "description": "Feed reaction API handler" }, + "dependsOn": ["app.certified.feed.getLikes"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.feed.getReposts", + "path": "../../lua/endpoints/getReposts.lua", + "sourcePath": "../../lua/src/getReposts.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/feedReaction.lua" + ], + "config": { "script_type": "lua", "description": "Feed reaction API handler" }, + "dependsOn": ["app.certified.feed.getReposts"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.feed.getActorLikes", + "path": "../../lua/endpoints/getActorLikes.lua", + "sourcePath": "../../lua/src/getActorLikes.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/feedReaction.lua" + ], + "config": { "script_type": "lua", "description": "Feed reaction API handler" }, + "dependsOn": ["app.certified.feed.getActorLikes"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.feed.getActorReposts", + "path": "../../lua/endpoints/getActorReposts.lua", + "sourcePath": "../../lua/src/getActorReposts.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/feedReaction.lua" + ], + "config": { "script_type": "lua", "description": "Feed reaction API handler" }, + "dependsOn": ["app.certified.feed.getActorReposts"] + } + ] +} diff --git a/api/package.json b/api/package.json index a9c3aec..d331e11 100644 --- a/api/package.json +++ b/api/package.json @@ -8,7 +8,7 @@ "@atcute/cid": "2.4.1", "@atproto/lexicon": "0.6.2", "@atproto/syntax": "0.5.4", - "@hypercerts-org/lexicon": "1.4.0" + "@hypercerts-org/lexicon": "1.5.0" }, "scripts": { "lint:js": "eslint --max-warnings=0 --report-unused-disable-directives .", diff --git a/api/tests/http/feed-reactions.http.test.js b/api/tests/http/feed-reactions.http.test.js new file mode 100644 index 0000000..cec2afb --- /dev/null +++ b/api/tests/http/feed-reactions.http.test.js @@ -0,0 +1,215 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; +import { reactionDids, reactionUris, seedRows, subjectCids } from './fixtures/feed-reactions.fixture.js'; + +const likesEndpoint = 'app.certified.feed.getLikes'; +const repostsEndpoint = 'app.certified.feed.getReposts'; +const actorLikesEndpoint = 'app.certified.feed.getActorLikes'; +const actorRepostsEndpoint = 'app.certified.feed.getActorReposts'; +const likeCollection = 'app.certified.feed.like'; +const repostCollection = 'app.certified.feed.repost'; + +async function request(nsid, params) { + const response = await fetch(contractUrl(requireContractTarget(), nsid, params), { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { response, body }; +} + +function fixtureRow(uri) { + const row = seedRows.find((candidate) => candidate.uri === uri); + assert.ok(row, `missing reaction fixture row ${uri}`); + return row; +} + +function recordView(row) { + return { + uri: row.uri, + cid: row.cid, + indexedAt: row.indexedAt, + did: row.did, + record: JSON.parse(JSON.stringify(row.record)), + }; +} + +function expectedActorView(did, reactionCollection, rkey, relationKey) { + const profile = seedRows.find((row) => row.did === did && row.collection === 'app.certified.actor.profile'); + const organization = seedRows.find((row) => row.did === did && row.collection === 'app.certified.actor.organization'); + const reaction = fixtureRow(`at://${did}/${reactionCollection}/${rkey}`); + return { + did, + profile: profile ? recordView(profile) : null, + organization: organization ? recordView(organization) : null, + [relationKey]: recordView(reaction), + }; +} + +test('getLikes paginates distinct actors by earliest reaction, ignores subject CID, and preserves raw record fields', async () => { + const expected = [ + { did: reactionDids.actorA, uri: `at://${reactionDids.actorA}/${likeCollection}/3jzfcijpj2z2a`, hasNext: true }, + { did: reactionDids.actorB, uri: `at://${reactionDids.actorB}/${likeCollection}/3jzfcijpj2z2d`, hasNext: true }, + { did: reactionDids.actorC, uri: `at://${reactionDids.actorC}/${likeCollection}/3jzfcijpj2z2e`, hasNext: false }, + ]; + let cursor; + const items = []; + for (const [index, expectation] of expected.entries()) { + const { response, body } = await request(likesEndpoint, { + subject: reactionUris.subject, + limit: 1, + sortDirection: 'asc', + cursor, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.likeCount, 3, 'count distinct authors across the entire result, not the page'); + assert.equal(body.likes.length, 1); + const [item] = body.likes; + assert.equal(item.did, expectation.did); + assert.equal(item.like.uri, expectation.uri); + assert.equal(item.like.record.$type, likeCollection); + items.push(item); + cursor = body.cursor; + if (expectation.hasNext) assert.equal(typeof cursor, 'string'); + else assert.equal(Object.hasOwn(body, 'cursor'), false); + assert.equal(body.likeCount, 3, `page ${index + 1} retains the total count`); + } + + assert.deepEqual(items[0], expectedActorView(reactionDids.actorA, likeCollection, '3jzfcijpj2z2a', 'like')); + assert.equal(items[0].like.record.subject.uri, reactionUris.subject); + assert.equal(items[0].like.record.subject.cid, subjectCids.likeA); + assert.equal(items[0].like.record.via.uri, `at://${reactionDids.source}/${repostCollection}/3jzfcijpj2z2a`); + assert.deepEqual(items[0].like.record.signatures[0].signature, { $bytes: 'AQID' }); + assert.deepEqual(items[1], expectedActorView(reactionDids.actorB, likeCollection, '3jzfcijpj2z2d', 'like')); + assert.deepEqual(items[2], expectedActorView(reactionDids.actorC, likeCollection, '3jzfcijpj2z2e', 'like')); +}); + +test('getLikes pages tied representatives in descending URI order', async () => { + const expected = [ + { did: reactionDids.actorC, uri: `at://${reactionDids.actorC}/${likeCollection}/3jzfcijpj2z2e` }, + { did: reactionDids.actorB, uri: `at://${reactionDids.actorB}/${likeCollection}/3jzfcijpj2z2d` }, + { did: reactionDids.actorA, uri: `at://${reactionDids.actorA}/${likeCollection}/3jzfcijpj2z2a` }, + ]; + let cursor; + for (const [index, expectation] of expected.entries()) { + const { response, body } = await request(likesEndpoint, { + subject: reactionUris.subject, + limit: 1, + sortDirection: 'desc', + cursor, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.likeCount, expected.length); + assert.equal(body.likes.length, 1); + assert.equal(body.likes[0].did, expectation.did); + assert.equal(body.likes[0].like.uri, expectation.uri); + cursor = body.cursor; + if (index < expected.length - 1) assert.equal(typeof cursor, 'string'); + else assert.equal(Object.hasOwn(body, 'cursor'), false); + } +}); + +test('getReposts returns total distinct reposters and earliest raw reposts with nullable sidecars', async () => { + const { response, body } = await request(repostsEndpoint, { + subject: reactionUris.subject, + limit: 10, + sortDirection: 'asc', + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.repostCount, 2); + assert.deepEqual(body.reposts.map(({ did, repost }) => [did, repost.uri]), [ + [reactionDids.actorA, `at://${reactionDids.actorA}/${repostCollection}/3jzfcijpj2z2a`], + [reactionDids.actorD, `at://${reactionDids.actorD}/${repostCollection}/3jzfcijpj2z2d`], + ]); + assert.deepEqual(body.reposts[0], expectedActorView(reactionDids.actorA, repostCollection, '3jzfcijpj2z2a', 'repost')); + assert.equal(body.reposts[0].repost.record.subject.cid, subjectCids.repostA); + assert.equal(body.reposts[0].repost.record.via.uri, `at://${reactionDids.source}/${repostCollection}/3jzfcijpj2z2a`); + assert.deepEqual(body.reposts[0].repost.record.signatures[0].signature, { $bytes: 'BAUG' }); + assert.deepEqual(body.reposts[1], expectedActorView(reactionDids.actorD, repostCollection, '3jzfcijpj2z2d', 'repost')); + assert.equal(body.reposts[1].profile, null); + assert.equal(body.reposts[1].organization, null); +}); + +test('actor reaction queries deduplicate subject URIs and return raw records without hydration or counts', async () => { + for (const { endpoint, collection, outputKey, relationKey } of [ + { endpoint: actorLikesEndpoint, collection: likeCollection, outputKey: 'likes', relationKey: 'like' }, + { endpoint: actorRepostsEndpoint, collection: repostCollection, outputKey: 'reposts', relationKey: 'repost' }, + ]) { + const { response, body } = await request(endpoint, { + actor: reactionDids.actorA, + limit: 10, + sortDirection: 'asc', + }); + assert.equal(response.status, 200, `${endpoint}: ${JSON.stringify(body)}`); + assert.equal(Object.hasOwn(body, 'likeCount'), false); + assert.equal(Object.hasOwn(body, 'repostCount'), false); + assert.deepEqual(body[outputKey].map(({ record }) => record.subject.uri), [ + reactionUris.subject, + reactionUris.otherSubject, + ]); + assert.deepEqual(body[outputKey].map(({ uri }) => uri), [ + `at://${reactionDids.actorA}/${collection}/3jzfcijpj2z2a`, + `at://${reactionDids.actorA}/${collection}/3jzfcijpj2z2${collection === likeCollection ? 'f' : 'e'}`, + ]); + for (const item of body[outputKey]) { + assert.equal(Object.hasOwn(item, 'profile'), false); + assert.equal(Object.hasOwn(item, 'organization'), false); + assert.equal(item.record.$type, collection); + assert.equal(item.did, reactionDids.actorA); + } + const [first] = body[outputKey]; + assert.equal(first.record.via.uri, `at://${reactionDids.source}/${repostCollection}/3jzfcijpj2z2a`); + assert.deepEqual(first.record.signatures[0].signature, { $bytes: collection === likeCollection ? 'AQID' : 'BAUG' }); + assert.equal(first[relationKey], undefined, 'actor endpoints expose raw record views, not subject actor views'); + } +}); + +test('actor reaction queries keyset-page URI ties in descending order without hydration', async () => { + for (const { endpoint, collection, outputKey, otherKey } of [ + { endpoint: actorLikesEndpoint, collection: likeCollection, outputKey: 'likes', otherKey: '3jzfcijpj2z2f' }, + { endpoint: actorRepostsEndpoint, collection: repostCollection, outputKey: 'reposts', otherKey: '3jzfcijpj2z2e' }, + ]) { + let cursor; + const expected = [otherKey, '3jzfcijpj2z2a']; + for (const [index, rkey] of expected.entries()) { + const { response, body } = await request(endpoint, { + actor: reactionDids.actorA, + limit: 1, + sortDirection: 'desc', + cursor, + }); + assert.equal(response.status, 200, `${endpoint}: ${JSON.stringify(body)}`); + assert.equal(body[outputKey].length, 1); + assert.equal(body[outputKey][0].uri, `at://${reactionDids.actorA}/${collection}/${rkey}`); + assert.equal(body[outputKey][0].record.createdAt, '2025-02-01T00:00:00.000Z'); + assert.equal(Object.hasOwn(body[outputKey][0], 'profile'), false); + cursor = body.cursor; + if (index < expected.length - 1) assert.equal(typeof cursor, 'string'); + else assert.equal(Object.hasOwn(body, 'cursor'), false); + } + } +}); + +test('reaction endpoints expose named request errors for invalid pagination and identifiers', async () => { + const cases = [ + { endpoint: likesEndpoint, params: { subject: 'alice.example' } }, + { endpoint: repostsEndpoint, params: { subject: reactionUris.subject, limit: 101 } }, + { endpoint: actorLikesEndpoint, params: { actor: 'alice.example' } }, + { endpoint: actorRepostsEndpoint, params: { actor: reactionDids.actorA, sortDirection: 'sideways' } }, + ]; + for (const { endpoint, params } of cases) { + const { response, body } = await request(endpoint, params); + assert.equal(response.status, 500, `${endpoint}: ${JSON.stringify(body)}`); + assert.equal(body.error, 'script_error'); + assert.equal(body.errorType, 'runtime'); + assert.equal(body.method, endpoint); + assert.match(body.message, /InvalidRequest/); + } +}); diff --git a/api/tests/http/fixtures/feed-reactions.fixture.js b/api/tests/http/fixtures/feed-reactions.fixture.js new file mode 100644 index 0000000..223a1f5 --- /dev/null +++ b/api/tests/http/fixtures/feed-reactions.fixture.js @@ -0,0 +1,131 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const likeCollection = 'app.certified.feed.like'; +const repostCollection = 'app.certified.feed.repost'; +const profileCollection = 'app.certified.actor.profile'; +const organizationCollection = 'app.certified.actor.organization'; +const indexedAt = '2025-04-01T00:00:00.000Z'; + +export const reactionDids = { + actorA: 'did:web:feed-reactions-actor-a.invalid', + actorB: 'did:web:feed-reactions-actor-b.invalid', + actorC: 'did:web:feed-reactions-actor-c.invalid', + actorD: 'did:web:feed-reactions-actor-d.invalid', + source: 'did:web:feed-reactions-source.invalid', + target: 'did:web:feed-reactions-target.invalid', +}; + +export const reactionUris = { + subject: `at://${reactionDids.target}/org.example.unindexed/subject`, + otherSubject: `at://${reactionDids.target}/org.example.unindexed/other`, +}; + +// Keep the LexJSON $bytes wrapper so CBOR hashing and pinned-schema validation use the same raw bytes. +function signatureBytes(values) { + return { $bytes: Buffer.from(values).toString('base64') }; +} + +async function unindexedSubjectCid(version) { + return CID.toString(await CID.create(0x71, encode({ $type: 'org.example.unindexed', version }))); +} + +export const subjectCids = { + likeA: await unindexedSubjectCid('like-a'), + likeB: await unindexedSubjectCid('like-b'), + likeC: await unindexedSubjectCid('like-c'), + likeD: await unindexedSubjectCid('like-d'), + likeE: await unindexedSubjectCid('like-e'), + otherLike: await unindexedSubjectCid('other-like'), + repostA: await unindexedSubjectCid('repost-a'), + repostB: await unindexedSubjectCid('repost-b'), + repostC: await unindexedSubjectCid('repost-c'), + repostD: await unindexedSubjectCid('repost-d'), + otherRepost: await unindexedSubjectCid('other-repost'), +}; + +async function seedRow(collection, did, rkey, fields, rowIndexedAt = indexedAt) { + const record = { $type: collection, ...fields }; + return { + uri: `at://${did}/${collection}/${rkey}`, + did, + collection, + rkey, + cid: CID.toString(await CID.create(0x71, encode(record))), + indexedAt: rowIndexedAt, + record, + }; +} + +function like(did, rkey, createdAt, cid, extras = {}) { + return seedRow(likeCollection, did, rkey, { + subject: { uri: reactionUris.subject, cid }, + createdAt, + ...extras, + }); +} + +function repost(did, rkey, createdAt, subjectUri, cid, extras = {}) { + return seedRow(repostCollection, did, rkey, { + subject: { uri: subjectUri, cid }, + createdAt, + ...extras, + }); +} + +const sourceReference = { + uri: `at://${reactionDids.source}/app.certified.feed.repost/3jzfcijpj2z2a`, + cid: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy', +}; + +const likeRows = [ + like(reactionDids.actorA, '3jzfcijpj2z2a', '2025-02-01T00:00:00.000Z', subjectCids.likeA, { + via: sourceReference, + signatures: [{ + $type: 'app.certified.signature.defs#inline', + signature: signatureBytes([1, 2, 3]), + key: `${reactionDids.actorA}#test-key`, + }], + }), + like(reactionDids.actorA, '3jzfcijpj2z2b', '2025-02-01T00:00:00.000Z', subjectCids.likeB), + like(reactionDids.actorA, '3jzfcijpj2z2c', '2025-02-03T00:00:00.000Z', subjectCids.likeC), + like(reactionDids.actorB, '3jzfcijpj2z2d', '2025-02-01T00:00:00.000Z', subjectCids.likeD), + like(reactionDids.actorC, '3jzfcijpj2z2e', '2025-02-01T00:00:00.000Z', subjectCids.likeE), + seedRow(likeCollection, reactionDids.actorA, '3jzfcijpj2z2f', { + subject: { uri: reactionUris.otherSubject, cid: subjectCids.otherLike }, + createdAt: '2025-02-01T00:00:00.000Z', + }), +]; + +const repostRows = [ + repost(reactionDids.actorA, '3jzfcijpj2z2a', '2025-02-01T00:00:00.000Z', reactionUris.subject, subjectCids.repostA, { + via: sourceReference, + signatures: [{ + $type: 'app.certified.signature.defs#inline', + signature: signatureBytes([4, 5, 6]), + key: `${reactionDids.actorA}#test-key`, + }], + }), + repost(reactionDids.actorA, '3jzfcijpj2z2b', '2025-02-01T00:00:00.000Z', reactionUris.subject, subjectCids.repostB), + repost(reactionDids.actorA, '3jzfcijpj2z2c', '2025-02-03T00:00:00.000Z', reactionUris.subject, subjectCids.repostC), + repost(reactionDids.actorD, '3jzfcijpj2z2d', '2025-02-01T00:00:00.000Z', reactionUris.subject, subjectCids.repostD), + repost(reactionDids.actorA, '3jzfcijpj2z2e', '2025-02-01T00:00:00.000Z', reactionUris.otherSubject, subjectCids.otherRepost), +]; + +const sidecars = [ + seedRow(profileCollection, reactionDids.actorA, 'self', { + displayName: 'Reaction actor A', + createdAt: indexedAt, + }), + seedRow(organizationCollection, reactionDids.actorA, 'self', { + organizationType: ['nonprofit'], + visibility: 'public', + createdAt: indexedAt, + }), + seedRow(profileCollection, reactionDids.actorB, 'self', { + displayName: 'Reaction actor B', + createdAt: indexedAt, + }), +]; + +export const seedRows = await Promise.all([...likeRows, ...repostRows, ...sidecars]); diff --git a/api/tests/unit/tooling/feed-reactions.test.js b/api/tests/unit/tooling/feed-reactions.test.js new file mode 100644 index 0000000..0ede940 --- /dev/null +++ b/api/tests/unit/tooling/feed-reactions.test.js @@ -0,0 +1,318 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { jsonToLex, lexToJson } from '@atproto/lexicon'; +import { validatePackageLexicons } from '../../../tooling/validate-lexicons.js'; +import { + reactionDids, + reactionUris, + seedRows as reactionFixtureRows, +} from '../../http/fixtures/feed-reactions.fixture.js'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const actor = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'; +const secondActor = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; +const subject = 'at://did:plc:cccccccccccccccccccccccc/org.example.subject/item'; +const indexedAt = '2025-01-02T03:04:05.000Z'; +const profileCollection = 'app.certified.actor.profile'; +const organizationCollection = 'app.certified.actor.organization'; +const collections = { + Like: 'app.certified.feed.like', + Repost: 'app.certified.feed.repost', +}; + +function reactionRow(kind, did, key, createdAt, extra = {}) { + const collection = collections[kind]; + const uri = `at://${did}/${collection}/${key}`; + const recordValue = { + $type: collection, + subject: { uri: subject, cid: `bafy-subject-${key}` }, + createdAt, + ...extra, + }; + return { + uri, + cid: `bafy-reaction-${key}`, + did, + indexed_at: indexedAt, + record: `record-${key}`, + recordValue, + sort_timestamp: createdAt.replace('Z', '.000000Z'), + }; +} + +function sidecarRow(collection, did, key, recordValue) { + return { + uri: `at://${did}/${collection}/self`, + cid: `bafy-${key}`, + did, + indexed_at: indexedAt, + record: `record-${key}`, + recordValue, + }; +} + +function lua(value) { + if (value === null) return 'nil'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `{${value.map(lua).join(',')}}`; + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .filter(([, item]) => item !== undefined) + .map(([key, item]) => `[${JSON.stringify(key)}]=${lua(item)}`) + .join(',')}}`; + } + throw new TypeError(`Cannot encode ${typeof value} as a Lua fixture`); +} + +function cursor({ direction = 'desc', timestamp = '2025-01-02T03:04:05.000000Z', uri } = {}) { + return Buffer.from(JSON.stringify({ + v: 1, + d: direction, + t: timestamp, + u: uri ?? `at://${actor}/${collections.Like}/cursor`, + }), 'utf8').toString('hex'); +} + +function runLua({ + endpoint, + params, + rows = [], + profiles = [], + organizations = [], + failure, + backend = 'postgres', + expectError, + expectedCalls, + assertions, +}) { + const endpointPath = `lua/endpoints/${endpoint}.lua`; + assert.ok(existsSync(`${root}/${endpointPath}`), `${endpointPath} must be generated before exercising the handler`); + const records = Object.fromEntries([ + ...rows.filter(({ recordValue }) => recordValue).map(({ record, recordValue }) => [record, recordValue]), + ...profiles.map(({ record, recordValue }) => [record, recordValue]), + ...organizations.map(({ record, recordValue }) => [record, recordValue]), + ]); + const luaSource = ` +local NULL_VALUE = {} +local RECORDS = ${lua(records)} +local rows = ${lua(rows)} +local profiles = ${lua(profiles)} +local organizations = ${lua(organizations)} +local calls = {} +local function decode_cursor(value) + local version = tonumber(value:match('"v":(%d+)')) + local direction = value:match('"d":"([^"]*)"') + local timestamp = value:match('"t":"([^"]*)"') + local uri = value:match('"u":"([^"]*)"') + if not version or not direction or not timestamp or not uri then error('invalid cursor JSON') end + return { v = version, d = direction, t = timestamp, u = uri } +end +json = { + decode = function(value) + if value == 'null' then return NULL_VALUE end + if RECORDS[value] then return RECORDS[value] end + if value:sub(1, 1) == '{' then return decode_cursor(value) end + local decoded = value:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) + return decode_cursor(decoded) + end, + encode = function(value) + return string.format('{"v":%d,"d":"%s","t":"%s","u":"%s"}', value.v, value.d, value.t, value.u) + end, +} +toarray = function(value) return value end +params = ${lua(params)} +db = { + backend = function() return '${backend}' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if ${failure === 'reaction' ? 'true' : 'false'} and values[1] == '${collections.Like}' then error('injected query failure') end + if ${failure === 'repost' ? 'true' : 'false'} and values[1] == '${collections.Repost}' then error('injected query failure') end + if ${failure === 'profile' ? 'true' : 'false'} and values[1] == '${profileCollection}' then error('injected profile failure') end + if ${failure === 'organization' ? 'true' : 'false'} and values[1] == '${organizationCollection}' then error('injected organization failure') end + if values[1] == '${collections.Like}' or values[1] == '${collections.Repost}' then return rows end + if values[1] == '${profileCollection}' then return profiles end + if values[1] == '${organizationCollection}' then return organizations end + error('unexpected collection ' .. tostring(values[1])) + end, +} +dofile('${endpointPath}') +local ok, result = pcall(handle) +${expectError + ? `assert(not ok, 'expected handler to reject the request')\nassert(tostring(result):find('${expectError}', 1, true), tostring(result))\nassert(#calls == ${expectedCalls ?? 0}, 'unexpected PostgreSQL query count')` + : `assert(ok, tostring(result))\n${assertions ?? ''}`} +`; + const result = spawnSync('lua5.4', ['-e', luaSource], { cwd: root, encoding: 'utf8' }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + return result.stdout; +} + +test('getLikes returns distinct-actor count, page-only sidecars, and the complete raw like record', () => { + const like = reactionRow('Like', actor, 'like-page', '2025-01-03T00:00:00Z', { + via: { uri: 'at://did:plc:dddddddddddddddddddddddd/app.certified.feed.repost/via', cid: 'bafy-via' }, + signatures: [{ $type: 'app.certified.signature.defs#inline', signature: 'AQID', key: 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa#key-1' }], + }); + const lookahead = reactionRow('Like', secondActor, 'like-lookahead', '2025-01-02T00:00:00Z'); + like.total_count = 7; + lookahead.total_count = 7; + like.sort_timestamp = '2025-01-03T00:00:00.000000Z'; + lookahead.sort_timestamp = '2025-01-02T00:00:00.000000Z'; + const profile = sidecarRow(profileCollection, actor, 'profile', { + $type: profileCollection, displayName: 'Liker', + }); + runLua({ + endpoint: 'getLikes', + params: { subject, limit: '1', sortDirection: 'desc' }, + rows: [like, lookahead], + profiles: [profile], + assertions: ` +assert(result.likeCount == 7 and type(result.likeCount) == 'number') +assert(#result.likes == 1 and result.cursor ~= nil) +local item = result.likes[1] +assert(item.did == '${actor}' and item.profile.record.displayName == 'Liker') +assert(item.organization == NULL_VALUE) +assert(item.like.uri == '${like.uri}' and item.like.cid == '${like.cid}' and item.like.did == '${actor}') +assert(item.like.indexedAt == '${indexedAt}') +assert(item.like.record.subject.uri == '${subject}' and item.like.record.subject.cid == 'bafy-subject-like-page') +assert(item.like.record.via.uri == 'at://did:plc:dddddddddddddddddddddddd/app.certified.feed.repost/via') +assert(item.like.record.signatures[1].signature == 'AQID') +assert(item.like.via == nil and item.like.signatures == nil, 'record extensions stay inside the raw record') +assert(#calls == 3, 'query the page and hydrate only its profile and organization sidecars') +assert(calls[1].values[1] == '${collections.Like}' and calls[1].values[2] == '${subject}') +assert(calls[2].values[1] == '${profileCollection}' and calls[2].values[2] == '${actor}') +assert(calls[2].values[3] == nil, 'do not hydrate the lookahead actor') +assert(calls[3].values[1] == '${organizationCollection}' and calls[3].values[2] == '${actor}') +assert(calls[3].values[3] == nil, 'do not hydrate the lookahead actor') +local token_json = result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) +local token = decode_cursor(token_json) +assert(token.v == 1 and token.d == 'desc' and token.t == '2025-01-03T00:00:00.000000Z' and token.u == '${like.uri}') +`, + }); +}); + +test('getReposts returns the complete page view and preserves a zero count on an empty page', () => { + const repost = reactionRow('Repost', actor, 'repost-one', '2025-01-03T00:00:00Z', { + via: { uri: 'at://did:plc:eeeeeeeeeeeeeeeeeeeeeeee/app.certified.feed.repost/origin', cid: 'bafy-origin' }, + }); + repost.total_count = 1; + runLua({ + endpoint: 'getReposts', + params: { subject }, + rows: [repost], + assertions: ` +assert(result.repostCount == 1 and #result.reposts == 1 and result.cursor == nil) +assert(result.reposts[1].did == '${actor}') +assert(result.reposts[1].profile == NULL_VALUE and result.reposts[1].organization == NULL_VALUE) +assert(result.reposts[1].repost.record.via.uri == 'at://did:plc:eeeeeeeeeeeeeeeeeeeeeeee/app.certified.feed.repost/origin') +assert(calls[1].values[1] == '${collections.Repost}' and calls[1].values[2] == '${subject}') +assert(calls[1].values[3] == 26, 'subject queries default to 25 and fetch one lookahead') +`, + }); + + runLua({ + endpoint: 'getReposts', + params: { subject }, + rows: [{ total_count: 0 }], + assertions: 'assert(result.repostCount == 0 and #result.reposts == 0 and result.cursor == nil)', + }); +}); + +test('actor queries return raw representative records without subject or actor hydration', () => { + const like = reactionRow('Like', actor, 'actor-like', '2025-01-03T00:00:00Z'); + const repost = reactionRow('Repost', actor, 'actor-repost', '2025-01-02T00:00:00Z'); + for (const { endpoint, row, outputKey, collection } of [ + { endpoint: 'getActorLikes', row: like, outputKey: 'likes', collection: collections.Like }, + { endpoint: 'getActorReposts', row: repost, outputKey: 'reposts', collection: collections.Repost }, + ]) { + runLua({ + endpoint, + params: { actor }, + rows: [row], + assertions: ` +assert(#result.${outputKey} == 1 and result.cursor == nil) +assert(result.${outputKey}[1].uri == '${row.uri}' and result.${outputKey}[1].did == '${actor}') +assert(result.${outputKey}[1].record['$type'] == '${collection}') +assert(result.${outputKey}[1].record.subject.uri == '${subject}') +assert(result.${outputKey}[1].record.subject.cid == 'bafy-subject-${row.uri.split('/').at(-1)}') +assert(result.${outputKey}[1].profile == nil and result.${outputKey}[1].organization == nil, 'actor queries return raw records only') +assert(#calls == 1 and calls[1].values[1] == '${collection}' and calls[1].values[2] == '${actor}') +assert(calls[1].values[3] == 26, 'limit defaults to 25 and fetches one lookahead') +`, + }); + } + + runLua({ + endpoint: 'getActorLikes', + params: { actor, limit: '100' }, + assertions: 'assert(#result.likes == 0 and calls[1].values[3] == 101)', + }); +}); + +test('reaction requests reject invalid identifiers, repeated/unknown parameters, and malformed or mismatched cursors before querying', () => { + const cases = [ + { endpoint: 'getLikes', params: { subject: 'alice.example' } }, + { endpoint: 'getReposts', params: { subject: 'at://alice.example/org.example.subject/key' } }, + { endpoint: 'getActorLikes', params: { actor: 'alice.example' } }, + { endpoint: 'getActorReposts', params: { actor, limit: '101' } }, + { endpoint: 'getLikes', params: { subject, limit: '0' } }, + { endpoint: 'getLikes', params: { subject, limit: { repeated: true } } }, + { endpoint: 'getReposts', params: { subject, sortDirection: 'sideways' } }, + { endpoint: 'getActorLikes', params: { actor, extra: 'unsupported' } }, + { endpoint: 'getActorReposts', params: { actor, cursor: 'not-hex' } }, + { endpoint: 'getLikes', params: { subject, cursor: cursor({ direction: 'asc' }), sortDirection: 'desc' } }, + { endpoint: 'getReposts', params: { subject, cursor: cursor({ uri: `at://${actor}/${collections.Like}/other` }) } }, + ]; + for (const { endpoint, params } of cases) { + runLua({ endpoint, params, expectError: 'InvalidRequest:', expectedCalls: 0 }); + } +}); + +test('serialized feed-reaction fixtures validate against the pinned Lexicons and preserve URI versions and via', async () => { + const { lexicons } = await validatePackageLexicons(); + const reactionCollections = new Set([collections.Like, collections.Repost]); + const serializedRows = reactionFixtureRows + .filter(({ collection }) => reactionCollections.has(collection)) + .map((row) => ({ ...row, serializedRecord: JSON.parse(JSON.stringify(row.record)) })); + + assert.ok(serializedRows.length > 0, 'expected HTTP reaction fixtures'); + for (const row of serializedRows) { + const record = jsonToLex(row.serializedRecord); + assert.doesNotThrow(() => lexicons.assertValidRecord(row.collection, record), row.uri); + assert.deepEqual(lexToJson(record), row.serializedRecord, row.uri); + } + + for (const collection of reactionCollections) { + const subjectVersions = new Set(serializedRows + .filter(({ collection: rowCollection, serializedRecord }) => + rowCollection === collection && serializedRecord.subject.uri === reactionUris.subject) + .map(({ serializedRecord }) => serializedRecord.subject.cid)); + assert.ok(subjectVersions.size > 1, `${collection} retains multiple CIDs for one unindexed subject URI`); + } + + const viaRecords = serializedRows.filter(({ serializedRecord }) => serializedRecord.via); + assert.equal(viaRecords.length, 2); + assert.ok(viaRecords.every(({ serializedRecord }) => + serializedRecord.via.uri === `at://${reactionDids.source}/app.certified.feed.repost/3jzfcijpj2z2a`)); +}); + +test('reaction query and actor hydration failures use endpoint-specific errors without returning partial pages', () => { + for (const { endpoint, kind, params, failure, expected } of [ + { endpoint: 'getLikes', kind: 'Like', params: { subject }, failure: 'reaction', expected: 'LikeQueryFailed:' }, + { endpoint: 'getActorReposts', kind: 'Repost', params: { actor }, failure: 'repost', expected: 'RepostQueryFailed:' }, + { endpoint: 'getReposts', kind: 'Repost', params: { subject }, failure: 'profile', expected: 'RepostQueryFailed:' }, + ]) { + const row = reactionRow(kind, actor, `failure-${kind}`, '2025-01-03T00:00:00Z'); + row.total_count = 1; + runLua({ + endpoint, + params, + rows: [row], + failure, + expectError: expected, + expectedCalls: failure === 'profile' ? 2 : 1, + }); + } +}); diff --git a/api/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index 23a2ce3..24db7b6 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -27,6 +27,8 @@ test('the full validation Lexicon closure resolves locally while only selected p 'app.certified.badge.definition', 'app.certified.badge.response', 'app.certified.defs', + 'app.certified.feed.like', + 'app.certified.feed.repost', 'app.certified.graph.entityFollow', 'app.certified.graph.follow', 'app.certified.link.evm', diff --git a/docs/coverage.json b/docs/coverage.json index b68ba56..596a9d2 100644 --- a/docs/coverage.json +++ b/docs/coverage.json @@ -1,16 +1,16 @@ { "source": { "apiManifest": "api/manifest.json", - "pinnedLexiconPackage": "1.4.0" + "pinnedLexiconPackage": "1.5.0" }, "runtimeValidation": "not-assessed", "deploymentValidation": "not-assessed", - "endpointCount": 55, + "endpointCount": 59, "methods": { - "GET": 55 + "GET": 59 }, "inclusion": { - "manifest-registered": 55 + "manifest-registered": 59 }, "unresolvedReferences": [], "endpoints": [ @@ -104,6 +104,30 @@ "inclusion": "manifest-registered", "module": "modules/badge-queries/manifest.json" }, + { + "id": "app.certified.feed.getActorLikes", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/feed-reactions/manifest.json" + }, + { + "id": "app.certified.feed.getActorReposts", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/feed-reactions/manifest.json" + }, + { + "id": "app.certified.feed.getLikes", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/feed-reactions/manifest.json" + }, + { + "id": "app.certified.feed.getReposts", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/feed-reactions/manifest.json" + }, { "id": "app.certified.graph.getEntityFollow", "method": "GET", diff --git a/docs/openapi.json b/docs/openapi.json index c0860c9..d1247de 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -18,6 +18,9 @@ { "name": "app.certified.badge" }, + { + "name": "app.certified.feed" + }, { "name": "app.certified.graph" }, @@ -1262,6 +1265,330 @@ ] } }, + "/xrpc/app.certified.feed.getActorLikes": { + "get": { + "operationId": "app_certified_feed_getActorLikes", + "tags": [ + "app.certified.feed" + ], + "description": "Lists an actor's indexed likes, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw like records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.", + "parameters": [ + { + "name": "actor", + "in": "query", + "required": true, + "description": "Repository-owner DID of the actor whose likes to list.", + "schema": { + "type": "string", + "format": "did" + } + }, + { + "name": "sortDirection", + "in": "query", + "required": false, + "description": "Sort by representative like createdAt and URI; defaults to desc.", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum relationships per page; defaults to 25.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.feed.getActorLikes.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.feed.getActorLikes", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/feed-reactions/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The actor DID, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "LikeQueryFailed", + "description": "The actor likes query failed; retry the request." + } + ] + } + }, + "/xrpc/app.certified.feed.getActorReposts": { + "get": { + "operationId": "app_certified_feed_getActorReposts", + "tags": [ + "app.certified.feed" + ], + "description": "Lists an actor's indexed reposts, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw repost records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.", + "parameters": [ + { + "name": "actor", + "in": "query", + "required": true, + "description": "Repository-owner DID of the actor whose reposts to list.", + "schema": { + "type": "string", + "format": "did" + } + }, + { + "name": "sortDirection", + "in": "query", + "required": false, + "description": "Sort by representative repost createdAt and URI; defaults to desc.", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum relationships per page; defaults to 25.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.feed.getActorReposts.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.feed.getActorReposts", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/feed-reactions/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The actor DID, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "RepostQueryFailed", + "description": "The actor reposts query failed; retry the request." + } + ] + } + }, + "/xrpc/app.certified.feed.getLikes": { + "get": { + "operationId": "app_certified_feed_getLikes", + "tags": [ + "app.certified.feed" + ], + "description": "Lists distinct actors liking a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.", + "parameters": [ + { + "name": "subject", + "in": "query", + "required": true, + "description": "Full DID-authority record AT-URI; any target collection is accepted.", + "schema": { + "type": "string", + "format": "at-uri" + } + }, + { + "name": "sortDirection", + "in": "query", + "required": false, + "description": "Sort by representative like createdAt and URI; defaults to desc.", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum relationships per page; defaults to 25.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.feed.getLikes.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.feed.getLikes", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/feed-reactions/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "LikeQueryFailed", + "description": "The like query, total count, or required actor hydration failed; retry the request." + } + ] + } + }, + "/xrpc/app.certified.feed.getReposts": { + "get": { + "operationId": "app_certified_feed_getReposts", + "tags": [ + "app.certified.feed" + ], + "description": "Lists distinct actors reposting a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.", + "parameters": [ + { + "name": "subject", + "in": "query", + "required": true, + "description": "Full DID-authority record AT-URI; any target collection is accepted.", + "schema": { + "type": "string", + "format": "at-uri" + } + }, + { + "name": "sortDirection", + "in": "query", + "required": false, + "description": "Sort by representative repost createdAt and URI; defaults to desc.", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum relationships per page; defaults to 25.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app.certified.feed.getReposts.output" + } + } + } + } + }, + "x-lexicon-id": "app.certified.feed.getReposts", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/feed-reactions/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "RepostQueryFailed", + "description": "The repost query, total count, or required actor hydration failed; retry the request." + } + ] + } + }, "/xrpc/app.certified.graph.getEntityFollow": { "get": { "operationId": "app_certified_graph_getEntityFollow", @@ -4610,7 +4937,7 @@ }, "app.certified.actor.organization.main": { "type": "record", - "description": "Extended metadata for an organization actor. Complements the base actor profile with organization-specific fields like legal structure and reference links." + "description": "Extended metadata for an organization actor. Complements the base actor profile (app.certified.actor.profile), which holds the display name, description, website, avatar, and banner, with organization-specific fields such as legal name, full logo, public contact email, legal structure, reference links, and locations." }, "org.hypercerts.api.defs.organizationView": { "type": "object", @@ -5285,9 +5612,259 @@ } } }, + "app.certified.feed.like.main": { + "type": "record", + "description": "Record declaring a 'like' of a piece of subject content. Social feedback only: a like carries no protocol meaning about the subject (it is not an evaluation, endorsement, or acknowledgement). Schema-compatible with app.bsky.feed.like." + }, + "app.certified.feed.getLikes.likeRecordView": { + "type": "object", + "description": "Raw indexed like preserving the full record, including subject, via, and signatures when present. Duplicates use the earliest createdAt, with URI ascending on ties.", + "required": [ + "uri", + "cid", + "indexedAt", + "did", + "record" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "indexedAt": { + "anyOf": [ + { + "type": "string", + "format": "datetime" + }, + { + "type": "null" + } + ] + }, + "did": { + "type": "string", + "description": "Repository-owner DID of the like publisher.", + "format": "did" + }, + "record": { + "$ref": "#/components/schemas/app.certified.feed.like.main" + } + } + }, + "app.certified.feed.getActorLikes.output": { + "type": "object", + "required": [ + "likes" + ], + "properties": { + "likes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/app.certified.feed.getLikes.likeRecordView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + }, + "app.certified.feed.repost.main": { + "type": "record", + "description": "Record representing a 'repost' of an existing record, surfacing it to the reposter's followers. The subject is pinned by CID, so the repost refers to the version the reposter saw, not to later edits. Schema-compatible with app.bsky.feed.repost." + }, + "app.certified.feed.getReposts.repostRecordView": { + "type": "object", + "description": "Raw indexed repost preserving the full record, including subject, via, and signatures when present. Its URI and CID identify the repost for feed attribution. Duplicates use the earliest createdAt, with URI ascending on ties.", + "required": [ + "uri", + "cid", + "indexedAt", + "did", + "record" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "indexedAt": { + "anyOf": [ + { + "type": "string", + "format": "datetime" + }, + { + "type": "null" + } + ] + }, + "did": { + "type": "string", + "description": "Repository-owner DID of the repost publisher.", + "format": "did" + }, + "record": { + "$ref": "#/components/schemas/app.certified.feed.repost.main" + } + } + }, + "app.certified.feed.getActorReposts.output": { + "type": "object", + "required": [ + "reposts" + ], + "properties": { + "reposts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/app.certified.feed.getReposts.repostRecordView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + }, + "app.certified.feed.getLikes.likeView": { + "type": "object", + "description": "Liker actor sidecars paired with the representative raw like record. Missing profile and organization records are null.", + "required": [ + "did", + "profile", + "organization", + "like" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "profile": { + "anyOf": [ + { + "$ref": "#/components/schemas/org.hypercerts.api.defs.profileView" + }, + { + "type": "null" + } + ] + }, + "organization": { + "anyOf": [ + { + "$ref": "#/components/schemas/org.hypercerts.api.defs.organizationView" + }, + { + "type": "null" + } + ] + }, + "like": { + "$ref": "#/components/schemas/app.certified.feed.getLikes.likeRecordView" + } + } + }, + "app.certified.feed.getLikes.output": { + "type": "object", + "required": [ + "likeCount", + "likes" + ], + "properties": { + "likeCount": { + "type": "integer", + "description": "Total distinct indexed author DIDs liking this subject URI, independent of page size. Zero when no likes are indexed; totals may change between requests.", + "minimum": 0 + }, + "likes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/app.certified.feed.getLikes.likeView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + }, + "app.certified.feed.getReposts.repostView": { + "type": "object", + "description": "Reposter actor sidecars paired with the representative raw repost record. Missing profile and organization records are null.", + "required": [ + "did", + "profile", + "organization", + "repost" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "profile": { + "anyOf": [ + { + "$ref": "#/components/schemas/org.hypercerts.api.defs.profileView" + }, + { + "type": "null" + } + ] + }, + "organization": { + "anyOf": [ + { + "$ref": "#/components/schemas/org.hypercerts.api.defs.organizationView" + }, + { + "type": "null" + } + ] + }, + "repost": { + "$ref": "#/components/schemas/app.certified.feed.getReposts.repostRecordView" + } + } + }, + "app.certified.feed.getReposts.output": { + "type": "object", + "required": [ + "repostCount", + "reposts" + ], + "properties": { + "repostCount": { + "type": "integer", + "description": "Total distinct indexed author DIDs reposting this subject URI, independent of page size. Zero when no reposts are indexed; totals may change between requests.", + "minimum": 0 + }, + "reposts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/app.certified.feed.getReposts.repostView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + }, "app.certified.graph.entityFollow.main": { "type": "record", - "description": "Record declaring a social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future. Duplicate follows will be ignored by the AppView." + "description": "Record declaring a social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future." }, "app.certified.graph.getEntityFollow.entityFollowRecordView": { "type": "object", @@ -5348,7 +5925,7 @@ }, "app.certified.graph.follow.main": { "type": "record", - "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView." + "description": "Record declaring a social 'follow' relationship of another account." }, "app.certified.graph.getFollow.followRecordView": { "type": "object", @@ -7493,6 +8070,6 @@ } } }, - "x-hypercerts-source": "api/manifest.json; @hypercerts-org/lexicon@1.4.0", + "x-hypercerts-source": "api/manifest.json; @hypercerts-org/lexicon@1.5.0", "x-hypercerts-unresolved-references": [] } diff --git a/docs/sources/index.json b/docs/sources/index.json index 65ca6a2..e12e27e 100644 --- a/docs/sources/index.json +++ b/docs/sources/index.json @@ -1,6 +1,6 @@ { "apiManifest": "api/manifest.json", - "pinnedLexiconPackage": "1.4.0", + "pinnedLexiconPackage": "1.5.0", "sourcePolicy": "Explorer operations are the query/procedure Lexicons declared by modules in api/manifest.json. Referenced schemas are resolved from that manifest and its pinned Lexicon package; no remote refs are fetched.", "runtimeValidation": "not-assessed", "deploymentValidation": "not-assessed", @@ -140,6 +140,42 @@ "path": "../../lexicons/app.certified.badge.searchBadgeDefinitions.json" } }, + { + "id": "app.certified.feed.getActorLikes", + "type": "query", + "module": "modules/feed-reactions/manifest.json", + "file": "sources/lexicons/app.certified.feed.getActorLikes.json", + "source": { + "path": "../../lexicons/app.certified.feed.getActorLikes.json" + } + }, + { + "id": "app.certified.feed.getActorReposts", + "type": "query", + "module": "modules/feed-reactions/manifest.json", + "file": "sources/lexicons/app.certified.feed.getActorReposts.json", + "source": { + "path": "../../lexicons/app.certified.feed.getActorReposts.json" + } + }, + { + "id": "app.certified.feed.getLikes", + "type": "query", + "module": "modules/feed-reactions/manifest.json", + "file": "sources/lexicons/app.certified.feed.getLikes.json", + "source": { + "path": "../../lexicons/app.certified.feed.getLikes.json" + } + }, + { + "id": "app.certified.feed.getReposts", + "type": "query", + "module": "modules/feed-reactions/manifest.json", + "file": "sources/lexicons/app.certified.feed.getReposts.json", + "source": { + "path": "../../lexicons/app.certified.feed.getReposts.json" + } + }, { "id": "app.certified.graph.getEntityFollow", "type": "query", @@ -693,6 +729,58 @@ "packagePath": "lexicons/app/certified/defs.json" } }, + { + "id": "app.certified.feed.getActorLikes", + "file": "sources/lexicons/app.certified.feed.getActorLikes.json", + "kind": "endpoint", + "source": { + "module": "modules/feed-reactions/manifest.json", + "path": "../../lexicons/app.certified.feed.getActorLikes.json" + } + }, + { + "id": "app.certified.feed.getActorReposts", + "file": "sources/lexicons/app.certified.feed.getActorReposts.json", + "kind": "endpoint", + "source": { + "module": "modules/feed-reactions/manifest.json", + "path": "../../lexicons/app.certified.feed.getActorReposts.json" + } + }, + { + "id": "app.certified.feed.getLikes", + "file": "sources/lexicons/app.certified.feed.getLikes.json", + "kind": "endpoint", + "source": { + "module": "modules/feed-reactions/manifest.json", + "path": "../../lexicons/app.certified.feed.getLikes.json" + } + }, + { + "id": "app.certified.feed.getReposts", + "file": "sources/lexicons/app.certified.feed.getReposts.json", + "kind": "endpoint", + "source": { + "module": "modules/feed-reactions/manifest.json", + "path": "../../lexicons/app.certified.feed.getReposts.json" + } + }, + { + "id": "app.certified.feed.like", + "file": "sources/lexicons/app.certified.feed.like.json", + "kind": "schema", + "source": { + "packagePath": "lexicons/app/certified/feed/like.json" + } + }, + { + "id": "app.certified.feed.repost", + "file": "sources/lexicons/app.certified.feed.repost.json", + "kind": "schema", + "source": { + "packagePath": "lexicons/app/certified/feed/repost.json" + } + }, { "id": "app.certified.graph.entityFollow", "file": "sources/lexicons/app.certified.graph.entityFollow.json", diff --git a/docs/sources/lexicons/app.certified.actor.organization.json b/docs/sources/lexicons/app.certified.actor.organization.json index 4053223..55652f6 100644 --- a/docs/sources/lexicons/app.certified.actor.organization.json +++ b/docs/sources/lexicons/app.certified.actor.organization.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "Extended metadata for an organization actor. Complements the base actor profile with organization-specific fields like legal structure and reference links.", + "description": "Extended metadata for an organization actor. Complements the base actor profile (app.certified.actor.profile), which holds the display name, description, website, avatar, and banner, with organization-specific fields such as legal name, full logo, public contact email, legal structure, reference links, and locations.", "key": "literal:self", "record": { "type": "object", @@ -12,6 +12,25 @@ "createdAt" ], "properties": { + "legalName": { + "type": "string", + "description": "The organization's registered legal name. The public-facing name belongs in app.certified.actor.profile displayName.", + "maxLength": 2560, + "maxGraphemes": 256 + }, + "logo": { + "type": "union", + "refs": [ + "org.hypercerts.defs#uri", + "org.hypercerts.defs#smallImage" + ], + "description": "The organization's full logo: a wordmark, or a combination mark pairing the symbol with the name. Used where the logo appears without the name beside it, such as partner listings or badge displays. Prefer a horizontal image with a transparent background (PNG or WebP). The square symbol alone (brandmark) belongs in app.certified.actor.profile avatar, which is cropped to a square or circle." + }, + "publicEmail": { + "type": "string", + "description": "A contact email address the organization wants shown publicly (e.g. 'hello@example.org'). Records are public, so never put a personal or internal address here.", + "maxLength": 254 + }, "organizationType": { "type": "array", "description": "Legal or operational structures of the organization (e.g. 'nonprofit', 'ngo', 'government', 'social-enterprise', 'cooperative').", @@ -24,7 +43,7 @@ }, "urls": { "type": "array", - "description": "Additional reference URLs (social media profiles, contact pages, donation links, etc.) with a display label for each URL.", + "description": "Additional reference URLs, each with a display label: social media profiles, contact pages, donation links, and similar. The organization's main website belongs in app.certified.actor.profile website, not here.", "items": { "type": "ref", "ref": "#urlItem" @@ -33,7 +52,16 @@ "location": { "type": "ref", "ref": "com.atproto.repo.strongRef", - "description": "A strong reference to the location where the organization is based. The record referenced must conform with the lexicon app.certified.location." + "description": "A strong reference to the organization's primary location: where it is based, and where it is shown when a map displays one point per organization. The referenced record must conform with the lexicon app.certified.location. Because a strong reference pins a specific version (CID) of that record, update this reference whenever the location record is edited. Further locations go in additionalLocations." + }, + "additionalLocations": { + "type": "array", + "description": "Strong references to further locations where the organization operates, such as branches, outlets, or field offices. Excludes the primary location, which is in location; do not repeat it here. Each referenced record must conform with the lexicon app.certified.location. As with location, update a reference whenever its record is edited.", + "items": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "maxLength": 1000 }, "foundedDate": { "type": "string", @@ -86,7 +114,7 @@ }, "label": { "type": "string", - "description": "Optional human-readable label for this URL (e.g. 'Support page', 'Donation page').", + "description": "Optional human-readable label for this URL (e.g. 'Donation page').", "maxLength": 640, "maxGraphemes": 64 } diff --git a/docs/sources/lexicons/app.certified.actor.profile.json b/docs/sources/lexicons/app.certified.actor.profile.json index d234df0..38a46e5 100644 --- a/docs/sources/lexicons/app.certified.actor.profile.json +++ b/docs/sources/lexicons/app.certified.actor.profile.json @@ -14,7 +14,7 @@ "properties": { "displayName": { "type": "string", - "description": "Display name for the account", + "description": "Display name for the account. For an organization, the public-facing name it uses in communications; a different registered legal name belongs in app.certified.actor.organization legalName.", "maxGraphemes": 64, "maxLength": 640 }, @@ -41,7 +41,7 @@ "org.hypercerts.defs#uri", "org.hypercerts.defs#smallImage" ], - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'. Clients crop it to a square or circle, so an organization should use its brandmark (the symbol without the name); the full logo belongs in app.certified.actor.organization logo." }, "banner": { "type": "union", diff --git a/docs/sources/lexicons/app.certified.badge.award.json b/docs/sources/lexicons/app.certified.badge.award.json index ff6d0b5..7fe85e0 100644 --- a/docs/sources/lexicons/app.certified.badge.award.json +++ b/docs/sources/lexicons/app.certified.badge.award.json @@ -38,6 +38,16 @@ "description": "Optional URL the badge award links to.", "maxLength": 2048 }, + "validFrom": { + "type": "string", + "format": "datetime", + "description": "When the badge starts to apply, which can differ from when this record was written (e.g. a certification granted in 2019 and recorded later). If omitted, the badge applies from createdAt. For date-only values, use midnight UTC (e.g. '2019-06-01T00:00:00.000Z'); consumers should treat only the date portion as canonical." + }, + "validUntil": { + "type": "string", + "format": "datetime", + "description": "When the badge stops applying, e.g. when a certification lapses or a verification needs renewal. Must not be earlier than validFrom. If omitted, the badge has no end date. After this time the award is expired: still a true historical statement ('was certified until …'), and clients should present it as such. Expiry is not revocation; to revoke an award, the issuer deletes the record. For date-only values, use midnight UTC; consumers should treat only the date portion as canonical." + }, "createdAt": { "type": "string", "format": "datetime", diff --git a/docs/sources/lexicons/app.certified.feed.getActorLikes.json b/docs/sources/lexicons/app.certified.feed.getActorLikes.json new file mode 100644 index 0000000..044ffb3 --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.getActorLikes.json @@ -0,0 +1,77 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getActorLikes", + "defs": { + "main": { + "type": "query", + "description": "Lists an actor's indexed likes, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw like records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.", + "parameters": { + "type": "params", + "required": [ + "actor" + ], + "properties": { + "actor": { + "type": "string", + "format": "did", + "description": "Repository-owner DID of the actor whose likes to list." + }, + "sortDirection": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort by representative like createdAt and URI; defaults to desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum relationships per page; defaults to 25." + }, + "cursor": { + "type": "string", + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The actor DID, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "LikeQueryFailed", + "description": "The actor likes query failed; retry the request." + } + ] + }, + "output": { + "type": "object", + "required": [ + "likes" + ], + "properties": { + "likes": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.certified.feed.getLikes#likeRecordView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + } + } +} diff --git a/docs/sources/lexicons/app.certified.feed.getActorReposts.json b/docs/sources/lexicons/app.certified.feed.getActorReposts.json new file mode 100644 index 0000000..5e7d2b4 --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.getActorReposts.json @@ -0,0 +1,77 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getActorReposts", + "defs": { + "main": { + "type": "query", + "description": "Lists an actor's indexed reposts, one representative per subject URI regardless of subject CID. No authentication is required. Duplicates use the earliest createdAt, with URI ascending on ties. Returns raw repost records including subject, via, and signatures when present; subjects are not hydrated and need not be indexed.", + "parameters": { + "type": "params", + "required": [ + "actor" + ], + "properties": { + "actor": { + "type": "string", + "format": "did", + "description": "Repository-owner DID of the actor whose reposts to list." + }, + "sortDirection": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort by representative repost createdAt and URI; defaults to desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum relationships per page; defaults to 25." + }, + "cursor": { + "type": "string", + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The actor DID, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "RepostQueryFailed", + "description": "The actor reposts query failed; retry the request." + } + ] + }, + "output": { + "type": "object", + "required": [ + "reposts" + ], + "properties": { + "reposts": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.certified.feed.getReposts#repostRecordView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + } + } +} diff --git a/docs/sources/lexicons/app.certified.feed.getLikes.json b/docs/sources/lexicons/app.certified.feed.getLikes.json new file mode 100644 index 0000000..21d5127 --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.getLikes.json @@ -0,0 +1,152 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getLikes", + "defs": { + "main": { + "type": "query", + "description": "Lists distinct actors liking a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.", + "parameters": { + "type": "params", + "required": [ + "subject" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "Full DID-authority record AT-URI; any target collection is accepted." + }, + "sortDirection": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort by representative like createdAt and URI; defaults to desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum relationships per page; defaults to 25." + }, + "cursor": { + "type": "string", + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "LikeQueryFailed", + "description": "The like query, total count, or required actor hydration failed; retry the request." + } + ] + }, + "likeRecordView": { + "type": "object", + "description": "Raw indexed like preserving the full record, including subject, via, and signatures when present. Duplicates use the earliest createdAt, with URI ascending on ties.", + "required": [ + "uri", + "cid", + "indexedAt", + "did", + "record" + ], + "nullable": [ + "indexedAt" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "indexedAt": { + "type": "string", + "format": "datetime" + }, + "did": { + "type": "string", + "format": "did", + "description": "Repository-owner DID of the like publisher." + }, + "record": { + "type": "ref", + "ref": "app.certified.feed.like" + } + } + }, + "likeView": { + "type": "object", + "description": "Liker actor sidecars paired with the representative raw like record. Missing profile and organization records are null.", + "required": [ + "did", + "profile", + "organization", + "like" + ], + "nullable": [ + "profile", + "organization" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "profile": { + "type": "ref", + "ref": "org.hypercerts.api.defs#profileView" + }, + "organization": { + "type": "ref", + "ref": "org.hypercerts.api.defs#organizationView" + }, + "like": { + "type": "ref", + "ref": "#likeRecordView" + } + } + }, + "output": { + "type": "object", + "required": [ + "likeCount", + "likes" + ], + "properties": { + "likeCount": { + "type": "integer", + "minimum": 0, + "description": "Total distinct indexed author DIDs liking this subject URI, independent of page size. Zero when no likes are indexed; totals may change between requests." + }, + "likes": { + "type": "array", + "items": { + "type": "ref", + "ref": "#likeView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + } + } +} diff --git a/docs/sources/lexicons/app.certified.feed.getReposts.json b/docs/sources/lexicons/app.certified.feed.getReposts.json new file mode 100644 index 0000000..6b26a5b --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.getReposts.json @@ -0,0 +1,152 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.getReposts", + "defs": { + "main": { + "type": "query", + "description": "Lists distinct actors reposting a subject record. No authentication is required. Matches subject.uri regardless of CID or whether the subject is indexed. Duplicate author and subject URI pairs use the earliest createdAt, with URI ascending on ties. Only the returned page is hydrated; missing sidecars are null and hydration failures return errors, not partial pages.", + "parameters": { + "type": "params", + "required": [ + "subject" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "Full DID-authority record AT-URI; any target collection is accepted." + }, + "sortDirection": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort by representative repost createdAt and URI; defaults to desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum relationships per page; defaults to 25." + }, + "cursor": { + "type": "string", + "description": "Opaque next-page cursor bound to sortDirection; keep all other parameters unchanged between pages." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subject AT-URI, sort direction, limit, cursor, or another query parameter is invalid." + }, + { + "name": "RepostQueryFailed", + "description": "The repost query, total count, or required actor hydration failed; retry the request." + } + ] + }, + "repostRecordView": { + "type": "object", + "description": "Raw indexed repost preserving the full record, including subject, via, and signatures when present. Its URI and CID identify the repost for feed attribution. Duplicates use the earliest createdAt, with URI ascending on ties.", + "required": [ + "uri", + "cid", + "indexedAt", + "did", + "record" + ], + "nullable": [ + "indexedAt" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "indexedAt": { + "type": "string", + "format": "datetime" + }, + "did": { + "type": "string", + "format": "did", + "description": "Repository-owner DID of the repost publisher." + }, + "record": { + "type": "ref", + "ref": "app.certified.feed.repost" + } + } + }, + "repostView": { + "type": "object", + "description": "Reposter actor sidecars paired with the representative raw repost record. Missing profile and organization records are null.", + "required": [ + "did", + "profile", + "organization", + "repost" + ], + "nullable": [ + "profile", + "organization" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "profile": { + "type": "ref", + "ref": "org.hypercerts.api.defs#profileView" + }, + "organization": { + "type": "ref", + "ref": "org.hypercerts.api.defs#organizationView" + }, + "repost": { + "type": "ref", + "ref": "#repostRecordView" + } + } + }, + "output": { + "type": "object", + "required": [ + "repostCount", + "reposts" + ], + "properties": { + "repostCount": { + "type": "integer", + "minimum": 0, + "description": "Total distinct indexed author DIDs reposting this subject URI, independent of page size. Zero when no reposts are indexed; totals may change between requests." + }, + "reposts": { + "type": "array", + "items": { + "type": "ref", + "ref": "#repostView" + } + }, + "cursor": { + "type": "string", + "description": "Next-page cursor; omitted when pagination is complete." + } + } + } + } +} diff --git a/docs/sources/lexicons/app.certified.feed.like.json b/docs/sources/lexicons/app.certified.feed.like.json new file mode 100644 index 0000000..35c70f2 --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.like.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.like", + "defs": { + "main": { + "type": "record", + "description": "Record declaring a 'like' of a piece of subject content. Social feedback only: a like carries no protocol meaning about the subject (it is not an evaluation, endorsement, or acknowledgement). Schema-compatible with app.bsky.feed.like.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the record being liked. May be a record of any lexicon (e.g. an activity, a collection, or a repost's subject)." + }, + "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 the record through which the subject was encountered, typically an app.certified.feed.repost. Lets AppViews credit the account that surfaced the subject. Mirrors the optional `via` field on app.bsky.feed.like; the referenced record may conform with any lexicon." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." + } + } + } + } + } +} diff --git a/docs/sources/lexicons/app.certified.feed.repost.json b/docs/sources/lexicons/app.certified.feed.repost.json new file mode 100644 index 0000000..3cd2569 --- /dev/null +++ b/docs/sources/lexicons/app.certified.feed.repost.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "app.certified.feed.repost", + "defs": { + "main": { + "type": "record", + "description": "Record representing a 'repost' of an existing record, surfacing it to the reposter's followers. The subject is pinned by CID, so the repost refers to the version the reposter saw, not to later edits. Schema-compatible with app.bsky.feed.repost.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the record being reposted. May be a record of any lexicon (e.g. an activity or a collection)." + }, + "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 the record through which the subject was encountered, typically another app.certified.feed.repost. Lets AppViews trace repost chains and credit the account that surfaced the subject. Mirrors the optional `via` field on app.bsky.feed.repost; the referenced record may conform with any lexicon." + }, + "signatures": { + "type": "ref", + "ref": "app.certified.signature.defs#list", + "description": "Optional cryptographic signatures attesting to this record's content." + } + } + } + } + } +} diff --git a/docs/sources/lexicons/app.certified.graph.entityFollow.json b/docs/sources/lexicons/app.certified.graph.entityFollow.json index 45f52be..6d9ba50 100644 --- a/docs/sources/lexicons/app.certified.graph.entityFollow.json +++ b/docs/sources/lexicons/app.certified.graph.entityFollow.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "Record declaring a social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future. Duplicate follows will be ignored by the AppView.", + "description": "Record declaring a social 'follow' relationship with a non-account entity, such as a specific record. Account (DID) follows are out of scope and belong to app.certified.graph.follow. The subject is an open union so additional non-DID entity kinds can be added in the future.", "key": "tid", "record": { "type": "object", diff --git a/docs/sources/lexicons/app.certified.graph.follow.json b/docs/sources/lexicons/app.certified.graph.follow.json index c6116e0..2fedfaf 100644 --- a/docs/sources/lexicons/app.certified.graph.follow.json +++ b/docs/sources/lexicons/app.certified.graph.follow.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", + "description": "Record declaring a social 'follow' relationship of another account.", "key": "tid", "record": { "type": "object", diff --git a/docs/sources/lexicons/org.hypercerts.context.attachment.json b/docs/sources/lexicons/org.hypercerts.context.attachment.json index 28e0ab6..6df8c5d 100644 --- a/docs/sources/lexicons/org.hypercerts.context.attachment.json +++ b/docs/sources/lexicons/org.hypercerts.context.attachment.json @@ -79,6 +79,15 @@ "ref": "com.atproto.repo.strongRef", "description": "A strong reference to the location where this attachment's subject matter occurred. The record referenced must conform with the lexicon app.certified.location." }, + "tags": { + "type": "array", + "description": "References to org.hypercerts.vocab.tag records classifying this record. All listed tags apply simultaneously (logical AND); the array carries no ordering, weighting, negation, inheritance, or rule logic, and never will — any future expression logic must arrive as a new field and must never reinterpret this one.", + "maxLength": 20, + "items": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } + }, "createdAt": { "type": "string", "format": "datetime", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61ab40b..3584053 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: 0.5.4 version: 0.5.4 '@hypercerts-org/lexicon': - specifier: 1.4.0 - version: 1.4.0(@atproto/xrpc@0.8.13) + specifier: 1.5.0 + version: 1.5.0(@atproto/xrpc@0.8.13) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -380,8 +380,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@hypercerts-org/lexicon@1.4.0': - resolution: {integrity: sha512-M1yG6+EI1yOpCZU6CO2plZcMS9fcXq8i/h/gXM/sj9txLH4Roo6de5Rs7L7Zhn/8iYoIp+akuGoolZ0e3EuUcg==} + '@hypercerts-org/lexicon@1.5.0': + resolution: {integrity: sha512-zg7//t8Mghrb6Z2fsSE0dIaNlGn6vBC5Hy8s7xodF7Fb1cpxUA3iLvzfvGQfR7yNV62o4bUyLlPE6Ez10eqfaQ==} peerDependencies: '@atproto/xrpc': '*' @@ -2436,7 +2436,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@hypercerts-org/lexicon@1.4.0(@atproto/xrpc@0.8.13)': + '@hypercerts-org/lexicon@1.5.0(@atproto/xrpc@0.8.13)': dependencies: '@atproto/lexicon': 0.6.2 '@atproto/xrpc': 0.8.13