From 97c2d989e1ac5dcda17bd0b3399e0860135bf639 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 10 Nov 2026 17:41:05 +0600 Subject: [PATCH 1/3] context: add batched getCommentCounts query Counts current comment attachments (contentType "comment") per subject URI for up to 100 subjects in one statement, counting records rather than authors and ignoring subject CIDs. Shares one subject-URI validator across getCommentCounts, getEntityFollows and getActorReactions, and scopes the listAttachments pagination HTTP test to its own publishers so other attachment fixtures cannot break it. --- .../hypercerts-api/references/endpoint-map.md | 4 +- .../hypercerts-api/references/openapi.json | 79 +++++++ .../references/request-patterns.md | 2 + .changeset/batch-comment-counts.md | 6 + CONTRIBUTING.md | 2 +- docs/coverage.json | 12 +- docs/openapi.json | 90 ++++++++ docs/sources/index.json | 18 ++ ...g.hypercerts.context.getCommentCounts.json | 81 +++++++ happyview-modules/README.md | 4 + ...g.hypercerts.context.getCommentCounts.json | 64 ++++++ .../lua/endpoints/getActorReactions.lua | 14 +- .../lua/endpoints/getCommentCounts.lua | 204 ++++++++++++++++++ .../lua/endpoints/getEntityFollows.lua | 21 +- .../lua/endpoints/getFollows.lua | 7 +- .../lua/shared/feedActorReactions.lua | 7 +- happyview-modules/lua/shared/followBatch.lua | 7 +- .../lua/shared/subjectRecordUri.lua | 6 + .../lua/src/getCommentCounts.lua | 68 ++++++ .../lua/src/getEntityFollows.lua | 7 +- happyview-modules/manifest.json | 4 + .../modules/context-attachments/manifest.json | 28 +++ .../modules/entity-follow/manifest.json | 3 +- .../modules/feed-reactions/manifest.json | 1 + .../tests/http/comment-counts.http.test.js | 54 +++++ .../http/context-attachments.http.test.js | 11 +- .../http/fixtures/comment-counts.fixture.js | 79 +++++++ .../tests/unit/tooling/comment-counts.test.js | 120 +++++++++++ .../src/lexicons/org/hypercerts/context.ts | 1 + .../context/getCommentCounts.defs.ts | 62 ++++++ .../hypercerts/context/getCommentCounts.ts | 6 + 31 files changed, 1037 insertions(+), 35 deletions(-) create mode 100644 .changeset/batch-comment-counts.md create mode 100644 docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json create mode 100644 happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json create mode 100644 happyview-modules/lua/endpoints/getCommentCounts.lua create mode 100644 happyview-modules/lua/shared/subjectRecordUri.lua create mode 100644 happyview-modules/lua/src/getCommentCounts.lua create mode 100644 happyview-modules/tests/http/comment-counts.http.test.js create mode 100644 happyview-modules/tests/http/fixtures/comment-counts.fixture.js create mode 100644 happyview-modules/tests/unit/tooling/comment-counts.test.js create mode 100644 packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts create mode 100644 packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.ts diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 3d3e3919..9358f675 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -10,6 +10,7 @@ This task index lists the public query NSIDs in the bundled [OpenAPI contract](o - **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, or `getFollows` / `getEntityFollows` to check repeated target values for one actor. List queries answer who follows whom or which entities an actor follows. - **Feed reactions:** use `getLikes` or `getReposts` for distinct actors reacting to one subject URI, `getActorLikes` / `getActorReposts` for one actor's raw representative reactions, `getActorReactions` to check one actor across a bounded set of known subject URIs, or `getReactionCounts` for indexed like and repost counts across known subjects. +- **Comment counts:** use `org.hypercerts.context.getCommentCounts` for comment counts across known subjects. Comments are attachments, not feed reactions; list them with `listAttachments` filtered by `subjects` and `contentTypes=comment`. 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. @@ -98,7 +99,7 @@ Do not confuse actor follows with entity follows; their identifiers and list out Paginated reaction-list queries use the earliest record per author/subject URI, with reaction URI ascending on ties, and sort pages by the representative's `createdAt` and URI. `getActorReactions` uses the same representative selection but preserves request order and has no pagination. Check each operation for exact cursor and response details. -## Context and supporting records (8) +## Context and supporting records (9) | User task | Query NSID | Practical choice | |---|---|---| @@ -106,6 +107,7 @@ Paginated reaction-list queries use the earliest record per author/subject URI, | List acknowledgements | `org.hypercerts.context.listAcknowledgements` | Filter by publisher or subject as documented. | | Load one attachment | `org.hypercerts.context.getAttachment` | Exact record AT-URI in `uri`. | | List attachments | `org.hypercerts.context.listAttachments` | Filter by publisher, record URI, subject, or content type. | +| Count comments for subjects | `org.hypercerts.context.getCommentCounts` | Pass 1–100 full subject record AT-URIs as repeated, unbracketed `subjects`. Counts attachments with `contentType` `comment` whose `subjects` include the URI, ignoring CID. Each comment record counts, so several by one author each count. Results preserve input order and duplicates. | | Load one evaluation | `org.hypercerts.context.getEvaluation` | Exact record AT-URI in `uri`; includes publisher/evaluator projections and preserves raw records when malformed evaluator entries are omitted. | | List evaluations | `org.hypercerts.context.listEvaluations` | Filter by publisher, evaluator, or subject; malformed evaluator entries do not hide otherwise valid records. | | Load one measurement | `org.hypercerts.context.getMeasurement` | Exact record AT-URI in `uri`. | diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 2e5ca70e..a230c96c 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -3071,6 +3071,56 @@ ] } }, + "/xrpc/org.hypercerts.context.getCommentCounts": { + "get": { + "operationId": "org_hypercerts_context_getCommentCounts", + "tags": ["org.hypercerts.context"], + "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "parameters": [ + { + "name": "subjects", + "in": "query", + "required": true, + "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "format": "at-uri", + "maxLength": 8192 + } + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/org.hypercerts.context.getCommentCounts.output" + } + } + } + } + }, + "x-lexicon-id": "org.hypercerts.context.getCommentCounts", + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains an invalid subject AT-URI; correct the list and retry." + }, + { + "name": "CommentCountQueryFailed", + "description": "The indexed comment count query failed; retry the request." + } + ] + } + }, "/xrpc/org.hypercerts.context.getEvaluation": { "get": { "operationId": "org_hypercerts_context_getEvaluation", @@ -5726,6 +5776,35 @@ "type": "record", "description": "An evaluation of a hypercert record (e.g. an activity and its impact)." }, + "org.hypercerts.context.getCommentCounts.subjectCommentCount": { + "type": "object", + "required": ["subject", "commentCount"], + "properties": { + "subject": { + "type": "string", + "description": "Subject AT-URI requested by the caller; matching ignores the subject CID.", + "format": "at-uri" + }, + "commentCount": { + "type": "integer", + "description": "Number of current indexed comment attachments whose subjects include this URI; zero when none are indexed.", + "minimum": 0 + } + } + }, + "org.hypercerts.context.getCommentCounts.output": { + "type": "object", + "required": ["counts"], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "$ref": "#/components/schemas/org.hypercerts.context.getCommentCounts.subjectCommentCount" + } + } + } + }, "org.hypercerts.context.getEvaluation.hydratedEvaluationEvaluatorView": { "type": "object", "description": "A valid original evaluator entry with its profile and organization sidecars looked up.", diff --git a/.agents/skills/hypercerts-api/references/request-patterns.md b/.agents/skills/hypercerts-api/references/request-patterns.md index b793d41d..a28bab6f 100644 --- a/.agents/skills/hypercerts-api/references/request-patterns.md +++ b/.agents/skills/hypercerts-api/references/request-patterns.md @@ -56,6 +56,8 @@ const url = new URL( The response contains one `results` item per supplied occurrence in that same order. Each item repeats the explicit `subject` AT-URI and has `like` and `repost` fields that are either raw record views or null. Matching ignores the subject CID. A null means the successful indexed lookup found no such reaction; a query failure is an error, not null. Preserve a non-null view's `uri` and `cid` when you need to identify that reaction record elsewhere. `app.certified.feed.getReactionCounts` accepts 1–100 full subject record AT-URIs with DID authorities, not handle authorities. Append each value under the unbracketed `subjects` key; do not use `subjects[]` or comma-join values. Its response has one entry per input occurrence in the same order, including duplicates. A zero count means no matching indexed reaction publishers were found at query time; counts may change as indexing changes. +`org.hypercerts.context.getCommentCounts` takes the same `subjects` encoding: 1–100 full subject record AT-URIs with DID authorities, appended under the unbracketed `subjects` key, within a 7,000-byte encoded request-target. Each `{subject, commentCount}` entry follows input order, including duplicates. The count is the number of current comment attachment records, not distinct commenters, and zero means none were indexed at query time. + ## Keep page requests cursor-consistent For a list/search operation whose output has a cursor, keep a base set of all non-cursor parameters and replace only the cursor on continuation. Preserve repeated array values, filters, `search`, `limit`, and `sortDirection` exactly. The example below is illustrative; verify the chosen endpoint's cursor binding and response-array name. diff --git a/.changeset/batch-comment-counts.md b/.changeset/batch-comment-counts.md new file mode 100644 index 00000000..79999d67 --- /dev/null +++ b/.changeset/batch-comment-counts.md @@ -0,0 +1,6 @@ +--- +'@hypercerts-org/happyview-modules': minor +'@hypercerts-org/api': minor +--- + +Add `org.hypercerts.context.getCommentCounts`, which returns comment counts for up to 100 subject records in one call. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df03bb5d..15774c8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Badge-query baseline-aware definition feeds and discriminating filters, exact-version award/response lookups, recipient status, raw response history, bidirectional tied pagination, nullable sidecars, and named runtime errors. - Profile and organization queries across all four endpoints for each record type, including batch null results, profile-sidecar hydration, filters, `createdAt`/URI pagination ties, and named errors. - Vocabulary-tag exact-URI retrieval, repeated-author filtering and no-match behavior, hydrated and nullable sidecars, bidirectional pagination ties, unsupported-search rejection, and named errors. -- Context attachment and evaluation retrieval with publisher/evaluator sidecar hydration, list filters, pagination across tied `createdAt` values, and named runtime errors. +- Context attachment and evaluation retrieval with publisher/evaluator sidecar hydration, list filters, pagination across tied `createdAt` values, and named runtime errors. Batched comment counts cover per-record counting, CID-independent subject matching, excluded non-comment attachments, and agreement with `listAttachments`. - 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, entity-follower totals across pages and empty pages, and the global recent-follows `before` filter. The `getFollows` and `getEntityFollows` batch queries also cover repeated target order and duplicates, explicit target keys, nullable raw views, and invalid-entry rejection. diff --git a/docs/coverage.json b/docs/coverage.json index 5e596e43..93499133 100644 --- a/docs/coverage.json +++ b/docs/coverage.json @@ -5,12 +5,12 @@ }, "runtimeValidation": "not-assessed", "deploymentValidation": "not-assessed", - "endpointCount": 63, + "endpointCount": 64, "methods": { - "GET": 63 + "GET": 64 }, "inclusion": { - "manifest-registered": 63 + "manifest-registered": 64 }, "unresolvedReferences": [], "endpoints": [ @@ -308,6 +308,12 @@ "inclusion": "manifest-registered", "module": "modules/context-attachments/manifest.json" }, + { + "id": "org.hypercerts.context.getCommentCounts", + "method": "GET", + "inclusion": "manifest-registered", + "module": "modules/context-attachments/manifest.json" + }, { "id": "org.hypercerts.context.getEvaluation", "method": "GET", diff --git a/docs/openapi.json b/docs/openapi.json index ac206638..bb36ff0f 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3933,6 +3933,62 @@ ] } }, + "/xrpc/org.hypercerts.context.getCommentCounts": { + "get": { + "operationId": "org_hypercerts_context_getCommentCounts", + "tags": [ + "org.hypercerts.context" + ], + "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "parameters": [ + { + "name": "subjects", + "in": "query", + "required": true, + "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "format": "at-uri", + "maxLength": 8192 + } + }, + "style": "form", + "explode": true + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/org.hypercerts.context.getCommentCounts.output" + } + } + } + } + }, + "x-lexicon-id": "org.hypercerts.context.getCommentCounts", + "x-hypercerts-coverage": "manifest-registered", + "x-hypercerts-source": { + "module": "modules/context-attachments/manifest.json" + }, + "x-lexicon-errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains an invalid subject AT-URI; correct the list and retry." + }, + { + "name": "CommentCountQueryFailed", + "description": "The indexed comment count query failed; retry the request." + } + ] + } + }, "/xrpc/org.hypercerts.context.getEvaluation": { "get": { "operationId": "org_hypercerts_context_getEvaluation", @@ -7943,6 +7999,40 @@ } } }, + "org.hypercerts.context.getCommentCounts.subjectCommentCount": { + "type": "object", + "required": [ + "subject", + "commentCount" + ], + "properties": { + "subject": { + "type": "string", + "description": "Subject AT-URI requested by the caller; matching ignores the subject CID.", + "format": "at-uri" + }, + "commentCount": { + "type": "integer", + "description": "Number of current indexed comment attachments whose subjects include this URI; zero when none are indexed.", + "minimum": 0 + } + } + }, + "org.hypercerts.context.getCommentCounts.output": { + "type": "object", + "required": [ + "counts" + ], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "$ref": "#/components/schemas/org.hypercerts.context.getCommentCounts.subjectCommentCount" + } + } + } + }, "org.hypercerts.context.evaluation.main": { "type": "record", "description": "An evaluation of a hypercert record (e.g. an activity and its impact)." diff --git a/docs/sources/index.json b/docs/sources/index.json index e87fdaa3..3eada5b6 100644 --- a/docs/sources/index.json +++ b/docs/sources/index.json @@ -446,6 +446,15 @@ "path": "../../lexicons/org.hypercerts.context.getAttachment.json" } }, + { + "id": "org.hypercerts.context.getCommentCounts", + "type": "query", + "module": "modules/context-attachments/manifest.json", + "file": "sources/lexicons/org.hypercerts.context.getCommentCounts.json", + "source": { + "path": "../../lexicons/org.hypercerts.context.getCommentCounts.json" + } + }, { "id": "org.hypercerts.context.getEvaluation", "type": "query", @@ -1207,6 +1216,15 @@ "path": "../../lexicons/org.hypercerts.context.getAttachment.json" } }, + { + "id": "org.hypercerts.context.getCommentCounts", + "file": "sources/lexicons/org.hypercerts.context.getCommentCounts.json", + "kind": "endpoint", + "source": { + "module": "modules/context-attachments/manifest.json", + "path": "../../lexicons/org.hypercerts.context.getCommentCounts.json" + } + }, { "id": "org.hypercerts.context.getEvaluation", "file": "sources/lexicons/org.hypercerts.context.getEvaluation.json", diff --git a/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json b/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json new file mode 100644 index 00000000..ee3359e8 --- /dev/null +++ b/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json @@ -0,0 +1,81 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.context.getCommentCounts", + "defs": { + "main": { + "type": "query", + "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "parameters": { + "type": "params", + "required": [ + "subjects" + ], + "properties": { + "subjects": { + "type": "array", + "minLength": 1, + "maxLength": 100, + "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "items": { + "type": "string", + "format": "at-uri", + "maxLength": 8192 + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains an invalid subject AT-URI; correct the list and retry." + }, + { + "name": "CommentCountQueryFailed", + "description": "The indexed comment count query failed; retry the request." + } + ] + }, + "subjectCommentCount": { + "type": "object", + "required": [ + "subject", + "commentCount" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "Subject AT-URI requested by the caller; matching ignores the subject CID." + }, + "commentCount": { + "type": "integer", + "minimum": 0, + "description": "Number of current indexed comment attachments whose subjects include this URI; zero when none are indexed." + } + } + }, + "output": { + "type": "object", + "required": [ + "counts" + ], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { + "type": "ref", + "ref": "#subjectCommentCount" + } + } + } + } + } +} diff --git a/happyview-modules/README.md b/happyview-modules/README.md index 93ecae83..d871bb92 100644 --- a/happyview-modules/README.md +++ b/happyview-modules/README.md @@ -18,6 +18,10 @@ Subject queries match by AT-URI without requiring a subject CID match, return on `getReactionCounts` accepts 1–100 repeated, unbracketed `subjects` values and returns one ordered count pair per input occurrence, counting distinct indexed publisher DIDs by subject URI across both reaction collections. Its counts do not require the subject record to be indexed and may change as reaction indexing changes. +## Comment counts + +`modules/context-attachments/manifest.json` also provides `org.hypercerts.context.getCommentCounts`. A comment is an `org.hypercerts.context.attachment` with `contentType` `comment` whose `subjects` point at the record it comments on. The query accepts 1–100 repeated subject AT-URIs and counts, per subject, the current comment records whose `subjects[].uri` matches, ignoring CID. It counts records rather than authors, so a comment that lists a subject twice counts once while two comments by one author count twice. Results keep input order and duplicates, and the subject need not be indexed. + ## Local validation From the repository root: diff --git a/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json b/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json new file mode 100644 index 00000000..921b3bdc --- /dev/null +++ b/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json @@ -0,0 +1,64 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.context.getCommentCounts", + "defs": { + "main": { + "type": "query", + "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "parameters": { + "type": "params", + "required": ["subjects"], + "properties": { + "subjects": { + "type": "array", + "minLength": 1, + "maxLength": 100, + "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "items": { "type": "string", "format": "at-uri", "maxLength": 8192 } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The subjects list is empty, has more than 100 entries, exceeds the request-target limit, or contains an invalid subject AT-URI; correct the list and retry." + }, + { + "name": "CommentCountQueryFailed", + "description": "The indexed comment count query failed; retry the request." + } + ] + }, + "subjectCommentCount": { + "type": "object", + "required": ["subject", "commentCount"], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "Subject AT-URI requested by the caller; matching ignores the subject CID." + }, + "commentCount": { + "type": "integer", + "minimum": 0, + "description": "Number of current indexed comment attachments whose subjects include this URI; zero when none are indexed." + } + } + }, + "output": { + "type": "object", + "required": ["counts"], + "properties": { + "counts": { + "type": "array", + "description": "One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests.", + "items": { "type": "ref", "ref": "#subjectCommentCount" } + } + } + } + } +} diff --git a/happyview-modules/lua/endpoints/getActorReactions.lua b/happyview-modules/lua/endpoints/getActorReactions.lua index dab2fca3..c4d66f00 100644 --- a/happyview-modules/lua/endpoints/getActorReactions.lua +++ b/happyview-modules/lua/endpoints/getActorReactions.lua @@ -68,6 +68,13 @@ local function valid_nsid(value) return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil end +-- A record subject: a full DID-authority AT-URI with a valid collection NSID. +local function valid_subject_record_uri(value) + if #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + return valid and valid_nsid(collection) +end + local ACTOR_REACTION_NULL = json.decode("null") local ACTOR_REACTION_LIMIT = 100 local ACTOR_REACTION_COLLECTIONS = { @@ -84,11 +91,6 @@ local function actor_reaction_sort_key() ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" end -local function valid_actor_reaction_subject_uri(value) - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) -end - local function feed_actor_reaction_subjects(value) if value == nil then invalid("subjects is required") end @@ -116,7 +118,7 @@ local function feed_actor_reaction_subjects(value) invalid("subjects must contain 1 through 100 AT-URIs") end for _, subject in ipairs(subjects) do - if #subject > 8192 or not valid_actor_reaction_subject_uri(subject) then + if not valid_subject_record_uri(subject) then invalid("each subjects value must be a full DID-authority AT-URI with a valid collection NSID") end end diff --git a/happyview-modules/lua/endpoints/getCommentCounts.lua b/happyview-modules/lua/endpoints/getCommentCounts.lua new file mode 100644 index 00000000..ea1ca7d2 --- /dev/null +++ b/happyview-modules/lua/endpoints/getCommentCounts.lua @@ -0,0 +1,204 @@ +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 + +-- An atproto NSID: at least three dot-separated segments, at most 317 characters. +local function valid_nsid(value) + if type(value) ~= "string" or #value > 317 then return false end + local segments = {} + for segment in value:gmatch("[^%.]+") do segments[#segments + 1] = segment end + if #segments < 3 or table.concat(segments, ".") ~= value then return false end + for index = 1, #segments - 1 do + local segment = segments[index] + if #segment > 63 or segment:find("[^A-Za-z0-9%-]") then return false end + local first, last = segment:sub(1, 1), segment:sub(-1) + if not first:match(index == 1 and "^[A-Za-z]$" or "^[A-Za-z0-9]$") + or not last:match("^[A-Za-z0-9]$") then return false end + end + local name = segments[#segments] + return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil +end + +-- A record subject: a full DID-authority AT-URI with a valid collection NSID. +local function valid_subject_record_uri(value) + if #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + return valid and valid_nsid(collection) +end + +local FOLLOW_BATCH_MAX_TARGETS = 100 +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 + +local function encoded_query_value_length(value) + local length = 0 + for index = 1, #value do + local byte = value:byte(index) + if (byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or (byte >= 97 and byte <= 122) + or byte == 42 or byte == 45 or byte == 46 or byte == 95 then + length = length + 1 + else + length = length + 3 + end + end + return length +end + +local function follow_batch_targets(actor, key, value, validate, description, endpoint) + if value == nil then invalid(key .. " is required") end + + local targets = {} + if type(value) == "string" then + targets[1] = value + elseif type(value) == "table" then + local count, max_index = 0, 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must be a repeated string parameter") + end + count = count + 1 + if index > max_index then max_index = index end + end + if count ~= max_index then invalid(key .. " must be a contiguous array") end + if count < 1 or count > FOLLOW_BATCH_MAX_TARGETS then + invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") + end + for index = 1, max_index do + targets[index] = value[index] + end + else + invalid(key .. " must be a repeated string parameter") + end + + if #targets < 1 or #targets > FOLLOW_BATCH_MAX_TARGETS then + invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") + end + + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + local unique, seen = {}, {} + for _, target in ipairs(targets) do + if type(target) ~= "string" or not validate(target) then + invalid("each " .. key .. " value must be " .. description) + end + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + if not seen[target] then + seen[target] = true + unique[#unique + 1] = target + end + end + + if request_target_length > FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") + end + return targets, unique +end + +local COMMENT_ATTACHMENT = "org.hypercerts.context.attachment" +local COMMENT_CONTENT_TYPE = "comment" + +-- Counts comment records, not distinct authors. COUNT(DISTINCT uri) keeps a comment +-- that lists the same subject twice from counting twice. A non-array subjects value +-- expands to no rows, matching listAttachments. +local function comment_count_query(subjects) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("CommentCountQueryFailed: comment counts require PostgreSQL", 0) + end + + local values, placeholders = { COMMENT_ATTACHMENT, COMMENT_CONTENT_TYPE }, {} + for _, subject in ipairs(subjects) do + values[#values + 1] = subject + placeholders[#placeholders + 1] = "$" .. #values + end + + local sql = "SELECT subject.value->>'uri' AS subject, COUNT(DISTINCT comment.uri) AS comment_count " .. + "FROM happyview_records AS comment CROSS JOIN LATERAL jsonb_array_elements(" .. + "CASE WHEN jsonb_typeof(comment.record::jsonb->'subjects') = 'array' " .. + "THEN comment.record::jsonb->'subjects' ELSE '[]'::jsonb END) AS subject(value) " .. + "WHERE comment.collection = $1 AND comment.record::jsonb->>'contentType' = $2 " .. + "AND subject.value->>'uri' IN (" .. table.concat(placeholders, ", ") .. ") " .. + "GROUP BY subject.value->>'uri'" + local ok, rows = pcall(db.raw, sql, values) + if not ok or type(rows) ~= "table" then + error("CommentCountQueryFailed: indexed comment count query failed", 0) + end + return rows +end + +local function comment_count_number(value) + local count = tonumber(value) + if not count or count < 0 or count % 1 ~= 0 then + error("CommentCountQueryFailed: indexed comment count was unavailable", 0) + end + return count +end + +local function get_comment_counts() + keys_only(params, { subjects = true }) + -- A single subject arrives as a plain value; read it like getReactionCounts does. + local subjects_param = params.subjects + if type(subjects_param) == "string" then subjects_param = scalar(params, "subjects") end + local subjects, unique_subjects = follow_batch_targets( + nil, "subjects", subjects_param, valid_subject_record_uri, + "a full AT-URI with a DID authority and valid collection NSID", + "org.hypercerts.context.getCommentCounts") + + local counts_by_subject = {} + for _, row in ipairs(comment_count_query(unique_subjects)) do + if type(row) ~= "table" or type(row.subject) ~= "string" then + error("CommentCountQueryFailed: indexed comment count subject was unavailable", 0) + end + counts_by_subject[row.subject] = comment_count_number(row.comment_count) + end + + local counts = {} + for _, subject in ipairs(subjects) do + counts[#counts + 1] = { subject = subject, commentCount = counts_by_subject[subject] or 0 } + end + return { counts = toarray(counts) } +end + +function handle() + return get_comment_counts() +end diff --git a/happyview-modules/lua/endpoints/getEntityFollows.lua b/happyview-modules/lua/endpoints/getEntityFollows.lua index 882bb606..ae88a0a5 100644 --- a/happyview-modules/lua/endpoints/getEntityFollows.lua +++ b/happyview-modules/lua/endpoints/getEntityFollows.lua @@ -123,7 +123,12 @@ local function follow_batch_targets(actor, key, value, validate, description, en invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") end - local request_target_length = #("/xrpc/" .. endpoint .. "?actor=") + encoded_query_value_length(actor) + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end local unique, seen = {}, {} for _, target in ipairs(targets) do if type(target) ~= "string" or not validate(target) then @@ -159,17 +164,19 @@ local function valid_nsid(value) return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil end +-- A record subject: a full DID-authority AT-URI with a valid collection NSID. +local function valid_subject_record_uri(value) + if #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + return valid and valid_nsid(collection) +end + local function get_entity_follows() keys_only(params, { actor = true, entities = true }) local actor = scalar(params, "actor") if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end - local function valid_entity_uri(value) - if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) - end local entities, unique_entities = follow_batch_targets( - actor, "entities", params.entities, valid_entity_uri, + actor, "entities", params.entities, valid_subject_record_uri, "full AT-URI with a DID authority and valid collection NSID", "app.certified.graph.getEntityFollows") local values, requested = { ENTITY_FOLLOW, actor }, {} diff --git a/happyview-modules/lua/endpoints/getFollows.lua b/happyview-modules/lua/endpoints/getFollows.lua index ed89a8e5..9467185c 100644 --- a/happyview-modules/lua/endpoints/getFollows.lua +++ b/happyview-modules/lua/endpoints/getFollows.lua @@ -102,7 +102,12 @@ local function follow_batch_targets(actor, key, value, validate, description, en invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") end - local request_target_length = #("/xrpc/" .. endpoint .. "?actor=") + encoded_query_value_length(actor) + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end local unique, seen = {}, {} for _, target in ipairs(targets) do if type(target) ~= "string" or not validate(target) then diff --git a/happyview-modules/lua/shared/feedActorReactions.lua b/happyview-modules/lua/shared/feedActorReactions.lua index 578ff264..44370f4b 100644 --- a/happyview-modules/lua/shared/feedActorReactions.lua +++ b/happyview-modules/lua/shared/feedActorReactions.lua @@ -14,11 +14,6 @@ local function actor_reaction_sort_key() ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" end -local function valid_actor_reaction_subject_uri(value) - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) -end - local function feed_actor_reaction_subjects(value) if value == nil then invalid("subjects is required") end @@ -46,7 +41,7 @@ local function feed_actor_reaction_subjects(value) invalid("subjects must contain 1 through 100 AT-URIs") end for _, subject in ipairs(subjects) do - if #subject > 8192 or not valid_actor_reaction_subject_uri(subject) then + if not valid_subject_record_uri(subject) then invalid("each subjects value must be a full DID-authority AT-URI with a valid collection NSID") end end diff --git a/happyview-modules/lua/shared/followBatch.lua b/happyview-modules/lua/shared/followBatch.lua index a888503c..dd15088e 100644 --- a/happyview-modules/lua/shared/followBatch.lua +++ b/happyview-modules/lua/shared/followBatch.lua @@ -45,7 +45,12 @@ local function follow_batch_targets(actor, key, value, validate, description, en invalid(key .. " must contain 1 through " .. FOLLOW_BATCH_MAX_TARGETS .. " values") end - local request_target_length = #("/xrpc/" .. endpoint .. "?actor=") + encoded_query_value_length(actor) + -- actor is nil for batch queries that take only the target list; the first + -- target's "&" then stands in for the "?". + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end local unique, seen = {}, {} for _, target in ipairs(targets) do if type(target) ~= "string" or not validate(target) then diff --git a/happyview-modules/lua/shared/subjectRecordUri.lua b/happyview-modules/lua/shared/subjectRecordUri.lua new file mode 100644 index 00000000..2d9d955e --- /dev/null +++ b/happyview-modules/lua/shared/subjectRecordUri.lua @@ -0,0 +1,6 @@ +-- A record subject: a full DID-authority AT-URI with a valid collection NSID. +local function valid_subject_record_uri(value) + if #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + return valid and valid_nsid(collection) +end diff --git a/happyview-modules/lua/src/getCommentCounts.lua b/happyview-modules/lua/src/getCommentCounts.lua new file mode 100644 index 00000000..4c15261c --- /dev/null +++ b/happyview-modules/lua/src/getCommentCounts.lua @@ -0,0 +1,68 @@ +local COMMENT_ATTACHMENT = "org.hypercerts.context.attachment" +local COMMENT_CONTENT_TYPE = "comment" + +-- Counts comment records, not distinct authors. COUNT(DISTINCT uri) keeps a comment +-- that lists the same subject twice from counting twice. A non-array subjects value +-- expands to no rows, matching listAttachments. +local function comment_count_query(subjects) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("CommentCountQueryFailed: comment counts require PostgreSQL", 0) + end + + local values, placeholders = { COMMENT_ATTACHMENT, COMMENT_CONTENT_TYPE }, {} + for _, subject in ipairs(subjects) do + values[#values + 1] = subject + placeholders[#placeholders + 1] = "$" .. #values + end + + local sql = "SELECT subject.value->>'uri' AS subject, COUNT(DISTINCT comment.uri) AS comment_count " .. + "FROM happyview_records AS comment CROSS JOIN LATERAL jsonb_array_elements(" .. + "CASE WHEN jsonb_typeof(comment.record::jsonb->'subjects') = 'array' " .. + "THEN comment.record::jsonb->'subjects' ELSE '[]'::jsonb END) AS subject(value) " .. + "WHERE comment.collection = $1 AND comment.record::jsonb->>'contentType' = $2 " .. + "AND subject.value->>'uri' IN (" .. table.concat(placeholders, ", ") .. ") " .. + "GROUP BY subject.value->>'uri'" + local ok, rows = pcall(db.raw, sql, values) + if not ok or type(rows) ~= "table" then + error("CommentCountQueryFailed: indexed comment count query failed", 0) + end + return rows +end + +local function comment_count_number(value) + local count = tonumber(value) + if not count or count < 0 or count % 1 ~= 0 then + error("CommentCountQueryFailed: indexed comment count was unavailable", 0) + end + return count +end + +local function get_comment_counts() + keys_only(params, { subjects = true }) + -- A single subject arrives as a plain value; read it like getReactionCounts does. + local subjects_param = params.subjects + if type(subjects_param) == "string" then subjects_param = scalar(params, "subjects") end + local subjects, unique_subjects = follow_batch_targets( + nil, "subjects", subjects_param, valid_subject_record_uri, + "a full AT-URI with a DID authority and valid collection NSID", + "org.hypercerts.context.getCommentCounts") + + local counts_by_subject = {} + for _, row in ipairs(comment_count_query(unique_subjects)) do + if type(row) ~= "table" or type(row.subject) ~= "string" then + error("CommentCountQueryFailed: indexed comment count subject was unavailable", 0) + end + counts_by_subject[row.subject] = comment_count_number(row.comment_count) + end + + local counts = {} + for _, subject in ipairs(subjects) do + counts[#counts + 1] = { subject = subject, commentCount = counts_by_subject[subject] or 0 } + end + return { counts = toarray(counts) } +end + +function handle() + return get_comment_counts() +end diff --git a/happyview-modules/lua/src/getEntityFollows.lua b/happyview-modules/lua/src/getEntityFollows.lua index d2f3cde3..fc92e944 100644 --- a/happyview-modules/lua/src/getEntityFollows.lua +++ b/happyview-modules/lua/src/getEntityFollows.lua @@ -2,13 +2,8 @@ local function get_entity_follows() keys_only(params, { actor = true, entities = true }) local actor = scalar(params, "actor") if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end - local function valid_entity_uri(value) - if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) - end local entities, unique_entities = follow_batch_targets( - actor, "entities", params.entities, valid_entity_uri, + actor, "entities", params.entities, valid_subject_record_uri, "full AT-URI with a DID authority and valid collection NSID", "app.certified.graph.getEntityFollows") local values, requested = { ENTITY_FOLLOW, actor }, {} diff --git a/happyview-modules/manifest.json b/happyview-modules/manifest.json index 6b0d856d..5da6aa5f 100644 --- a/happyview-modules/manifest.json +++ b/happyview-modules/manifest.json @@ -190,6 +190,10 @@ "id": "org.hypercerts.context.listAttachments", "path": "lexicons/org.hypercerts.context.listAttachments.json" }, + { + "id": "org.hypercerts.context.getCommentCounts", + "path": "lexicons/org.hypercerts.context.getCommentCounts.json" + }, { "id": "org.hypercerts.context.attachment", "packagePath": "lexicons/org/hypercerts/context/attachment.json" diff --git a/happyview-modules/modules/context-attachments/manifest.json b/happyview-modules/modules/context-attachments/manifest.json index 6434cb8a..db658ef0 100644 --- a/happyview-modules/modules/context-attachments/manifest.json +++ b/happyview-modules/modules/context-attachments/manifest.json @@ -61,6 +61,34 @@ "description": "Hypercerts attachment listing handler" }, "dependsOn": ["org.hypercerts.context.listAttachments"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.context.getCommentCounts", + "path": "../../lexicons/org.hypercerts.context.getCommentCounts.json", + "config": { + "backfill": false + }, + "dependsOn": ["org.hypercerts.context.attachment"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.context.getCommentCounts", + "path": "../../lua/endpoints/getCommentCounts.lua", + "sourcePath": "../../lua/src/getCommentCounts.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/nsidValidation.lua", + "../../lua/shared/subjectRecordUri.lua", + "../../lua/shared/followBatch.lua" + ], + "config": { + "script_type": "lua", + "description": "Batched comment count query handler" + }, + "dependsOn": ["org.hypercerts.context.getCommentCounts"] } ] } diff --git a/happyview-modules/modules/entity-follow/manifest.json b/happyview-modules/modules/entity-follow/manifest.json index b9af0b0d..d42bfe61 100644 --- a/happyview-modules/modules/entity-follow/manifest.json +++ b/happyview-modules/modules/entity-follow/manifest.json @@ -74,7 +74,8 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/entityFollow.lua", "../../lua/shared/followBatch.lua", - "../../lua/shared/nsidValidation.lua" + "../../lua/shared/nsidValidation.lua", + "../../lua/shared/subjectRecordUri.lua" ], "config": { "script_type": "lua", diff --git a/happyview-modules/modules/feed-reactions/manifest.json b/happyview-modules/modules/feed-reactions/manifest.json index ecb0b51c..d8039815 100644 --- a/happyview-modules/modules/feed-reactions/manifest.json +++ b/happyview-modules/modules/feed-reactions/manifest.json @@ -143,6 +143,7 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/recordView.lua", "../../lua/shared/nsidValidation.lua", + "../../lua/shared/subjectRecordUri.lua", "../../lua/shared/feedActorReactions.lua" ], "config": { "script_type": "lua", "description": "Actor feed reaction API handler" }, diff --git a/happyview-modules/tests/http/comment-counts.http.test.js b/happyview-modules/tests/http/comment-counts.http.test.js new file mode 100644 index 00000000..d4b8c8e1 --- /dev/null +++ b/happyview-modules/tests/http/comment-counts.http.test.js @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { commentSubjects } from "./fixtures/comment-counts.fixture.js"; +import { contractUrl, requireContractTarget } from "./helpers.js"; + +const baseUrl = requireContractTarget(); +const endpoint = "org.hypercerts.context.getCommentCounts"; +const uncommented = + "at://did:web:comment-counts-post.invalid/org.hypercerts.claim.activity/3jzfcijpj2z2z"; + +async function request(nsid, params) { + const response = await fetch(contractUrl(baseUrl, 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 }; +} + +test("getCommentCounts counts comment records per subject, in request order", async () => { + const subjects = [ + commentSubjects.post, + uncommented, + commentSubjects.secondPost, + commentSubjects.post, + ]; + const { response, body } = await request(endpoint, { subjects }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.counts, [ + // Two by one author, one with another CID, one listing the post twice; the update is excluded. + { subject: commentSubjects.post, commentCount: 4 }, + { subject: uncommented, commentCount: 0 }, + { subject: commentSubjects.secondPost, commentCount: 1 }, + { subject: commentSubjects.post, commentCount: 4 }, + ]); + + const listed = await request("org.hypercerts.context.listAttachments", { + subjects: [commentSubjects.post], + contentTypes: ["comment"], + limit: 100, + }); + assert.equal(listed.response.status, 200, JSON.stringify(listed.body)); + assert.equal( + listed.body.attachments.length, + 4, + "the count matches the comments listAttachments returns for the same subject", + ); +}); diff --git a/happyview-modules/tests/http/context-attachments.http.test.js b/happyview-modules/tests/http/context-attachments.http.test.js index 617071f3..a197e8ae 100644 --- a/happyview-modules/tests/http/context-attachments.http.test.js +++ b/happyview-modules/tests/http/context-attachments.http.test.js @@ -99,7 +99,7 @@ test("listAttachments applies each filter and ANDs distinct filters", async () = } }); -test("listAttachments paginates the complete feed across tied createdAt timestamps", async () => { +test("listAttachments paginates every fixture attachment across tied createdAt timestamps", async () => { const listEndpoint = "org.hypercerts.context.listAttachments"; const expected = [ `at://${publisher}/org.hypercerts.context.attachment/3jzfcijpj2z2a`, @@ -107,7 +107,13 @@ test("listAttachments paginates the complete feed across tied createdAt timestam "at://did:web:context-attachment-b.invalid/org.hypercerts.context.attachment/3jzfcijpj2z2c", "at://did:web:context-attachment-c.invalid/org.hypercerts.context.attachment/3jzfcijpj2z2d", ]; - const first = await request(listEndpoint, { sortDirection: "asc", limit: 2 }); + // Other suites seed attachments too, so page through this fixture's publishers only. + const authors = [ + publisher, + "did:web:context-attachment-b.invalid", + "did:web:context-attachment-c.invalid", + ]; + const first = await request(listEndpoint, { authors, sortDirection: "asc", limit: 2 }); assert.equal(first.response.status, 200, JSON.stringify(first.body)); assert.deepEqual( first.body.attachments.map(({ uri }) => uri), @@ -116,6 +122,7 @@ test("listAttachments paginates the complete feed across tied createdAt timestam assert.equal(typeof first.body.cursor, "string"); const second = await request(listEndpoint, { + authors, sortDirection: "asc", limit: 2, cursor: first.body.cursor, diff --git a/happyview-modules/tests/http/fixtures/comment-counts.fixture.js b/happyview-modules/tests/http/fixtures/comment-counts.fixture.js new file mode 100644 index 00000000..548c0e22 --- /dev/null +++ b/happyview-modules/tests/http/fixtures/comment-counts.fixture.js @@ -0,0 +1,79 @@ +import { encode } from "@atcute/cbor"; +import * as CID from "@atcute/cid"; + +const attachmentCollection = "org.hypercerts.context.attachment"; +const indexedAt = "2025-04-01T00:00:00.000Z"; +const createdAt = "2025-03-20T00:00:00.000Z"; +const firstCid = "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const secondCid = "bafyreibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +export const commentDids = { + commenter: "did:web:comment-counts-a.invalid", + secondCommenter: "did:web:comment-counts-b.invalid", + postAuthor: "did:web:comment-counts-post.invalid", + malformedCommenter: "did:web:comment-counts-malformed.invalid", +}; + +// Neither post is seeded: counts must not require the subject to be indexed. +export const commentSubjects = { + post: `at://${commentDids.postAuthor}/${attachmentCollection}/3jzfcijpj2z2a`, + secondPost: `at://${commentDids.postAuthor}/${attachmentCollection}/3jzfcijpj2z2b`, +}; + +function comment(subjects, contentType = "comment") { + return { title: "Comment", createdAt, contentType, subjects }; +} + +const specs = [ + // Two comments by one author both count. + [commentDids.commenter, "3jzfcijpj2z2a", comment([{ uri: commentSubjects.post, cid: firstCid }])], + [commentDids.commenter, "3jzfcijpj2z2b", comment([{ uri: commentSubjects.post, cid: firstCid }])], + // A different subject CID still matches by URI; one comment can cover two posts. + [ + commentDids.secondCommenter, + "3jzfcijpj2z2c", + comment([ + { uri: commentSubjects.post, cid: secondCid }, + { uri: commentSubjects.secondPost, cid: firstCid }, + ]), + ], + // Listing the same subject twice counts the comment once. + [ + commentDids.secondCommenter, + "3jzfcijpj2z2d", + comment([ + { uri: commentSubjects.post, cid: firstCid }, + { uri: commentSubjects.post, cid: secondCid }, + ]), + ], + // An update about the post is not a comment. + [ + commentDids.secondCommenter, + "3jzfcijpj2z2e", + comment([{ uri: commentSubjects.post, cid: firstCid }], "update"), + ], + // Malformed comments must neither count nor make the query fail. + [commentDids.malformedCommenter, "3jzfcijpj2z2a", comment("not-an-array")], + [ + commentDids.malformedCommenter, + "3jzfcijpj2z2b", + comment([null, 7, { cid: firstCid }, { uri: 5 }]), + ], +]; + +async function seedRow(did, rkey, fields) { + const record = { $type: attachmentCollection, ...fields }; + return { + uri: `at://${did}/${attachmentCollection}/${rkey}`, + did, + collection: attachmentCollection, + rkey, + cid: CID.toString(await CID.create(0x71, encode(record))), + indexedAt, + record, + }; +} + +export const seedRows = await Promise.all( + specs.map(([did, rkey, record]) => seedRow(did, rkey, record)), +); diff --git a/happyview-modules/tests/unit/tooling/comment-counts.test.js b/happyview-modules/tests/unit/tooling/comment-counts.test.js new file mode 100644 index 00000000..60931daa --- /dev/null +++ b/happyview-modules/tests/unit/tooling/comment-counts.test.js @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const endpointPath = "lua/endpoints/getCommentCounts.lua"; +const post = "at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/org.hypercerts.context.attachment/post"; +const activity = "at://did:web:example.com/org.hypercerts.claim.activity/missing"; + +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) + .map(([key, item]) => `[${JSON.stringify(key)}]=${lua(item)}`) + .join(",")}}`; + } + throw new TypeError(`Cannot encode ${typeof value} as a Lua fixture`); +} + +function runLua({ + params, + rows = [], + failure = false, + backend = "postgres", + expectError, + expectedCalls, + assertions, +}) { + assert.ok(existsSync(path.join(root, endpointPath)), `${endpointPath} must be generated first`); + const source = ` +local rows = ${lua(rows)} +local calls = {} +toarray = function(value) return value end +json = { decode = function() error('unexpected JSON decode') end } +params = ${lua(params)} +db = { + backend = function() return '${backend}' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if ${failure} then error('injected comment count failure') end + return rows + end, +} +dofile('${endpointPath}') +local ok, result = pcall(handle) +${ + expectError + ? `assert(not ok, 'expected handler to reject the request') +assert(tostring(result):find('${expectError}', 1, true), tostring(result)) +assert(#calls == ${expectedCalls ?? 0}, 'unexpected PostgreSQL query count: ' .. #calls)` + : `assert(ok, tostring(result))\n${assertions ?? ""}` +} +`; + const result = spawnSync("lua5.4", ["-e", source], { cwd: root, encoding: "utf8" }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +} + +test("getCommentCounts counts every subject in one query and keeps request order", () => { + runLua({ + params: { subjects: [post, activity, post] }, + rows: [{ subject: post, comment_count: "3" }], + assertions: ` +assert(#calls == 1, 'one statement must serve every subject') +local values = calls[1].values +assert(values[1] == 'org.hypercerts.context.attachment' and values[2] == 'comment') +assert(values[3] == '${post}' and values[4] == '${activity}' and #values == 4, 'duplicates are queried once') +assert(#result.counts == 3) +assert(result.counts[1].subject == '${post}' and result.counts[1].commentCount == 3) +assert(result.counts[2].subject == '${activity}' and result.counts[2].commentCount == 0) +assert(result.counts[3].subject == '${post}' and result.counts[3].commentCount == 3) +assert(math.type(result.counts[1].commentCount) == 'integer') +`, + }); + runLua({ + params: { subjects: post }, + assertions: "assert(#result.counts == 1 and result.counts[1].commentCount == 0)", + }); +}); + +test("getCommentCounts rejects subjects that are not full record AT-URIs before querying", () => { + const longPost = `at://did:plc:dddddddddddddddddddddddd/org.example.thing/${"k".repeat(500)}`; + for (const subjects of [ + ["did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"], + ["at://alice.example/org.hypercerts.context.attachment/post"], + ["at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/invalid/key"], + [`${post}?query`], + Array(15).fill(longPost), + ]) { + runLua({ params: { subjects }, expectError: "InvalidRequest:", expectedCalls: 0 }); + } +}); + +test("getCommentCounts reports named errors for database and count failures", () => { + runLua({ + params: { subjects: [post] }, + failure: true, + expectError: "CommentCountQueryFailed:", + expectedCalls: 1, + }); + runLua({ + params: { subjects: [post] }, + backend: "sqlite", + expectError: "CommentCountQueryFailed:", + expectedCalls: 0, + }); + for (const row of [{ subject: post, comment_count: "-1" }, { comment_count: "1" }]) { + runLua({ + params: { subjects: [post] }, + rows: [row], + expectError: "CommentCountQueryFailed:", + expectedCalls: 1, + }); + } +}); diff --git a/packages/api/src/lexicons/org/hypercerts/context.ts b/packages/api/src/lexicons/org/hypercerts/context.ts index 32cdc307..ad7d1b13 100644 --- a/packages/api/src/lexicons/org/hypercerts/context.ts +++ b/packages/api/src/lexicons/org/hypercerts/context.ts @@ -7,6 +7,7 @@ export * as attachment from "@hypercerts-org/lexicon/org/hypercerts/context/atta export * as evaluation from "@hypercerts-org/lexicon/org/hypercerts/context/evaluation"; export * as getAcknowledgement from "./context/getAcknowledgement.js"; export * as getAttachment from "./context/getAttachment.js"; +export * as getCommentCounts from "./context/getCommentCounts.js"; export * as getEvaluation from "./context/getEvaluation.js"; export * as getMeasurement from "./context/getMeasurement.js"; export * as listAcknowledgements from "./context/listAcknowledgements.js"; diff --git a/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts b/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts new file mode 100644 index 00000000..77237b63 --- /dev/null +++ b/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts @@ -0,0 +1,62 @@ +/* + * THIS FILE WAS GENERATED BY scripts/generate.js WITH "@atproto/lex". DO NOT EDIT. + */ + +import { l } from "@atproto/lex"; + +const $nsid = "org.hypercerts.context.getCommentCounts"; + +type $nsid = typeof $nsid; + +export { $nsid }; + +export const $params = /*#__PURE__*/ l.params({"subjects":/*#__PURE__*/ l.array(/*#__PURE__*/ l.string({"format":"at-uri","maxLength":8192}), {"minLength":1,"maxLength":100})}); + +export type $Params = l.InferOutput; + +export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ l.ref((() => output) as any)); + +export type $Output = l.InferPayload; +export type $OutputBody = l.InferPayloadBody; + +/** Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType "comment" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed. */ +const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","CommentCountQueryFailed"]); + +export { main }; + +const $lxm = $nsid; + +type $lxm = typeof $lxm; + +export { $lxm }; + +type SubjectCommentCount = { $type?: "org.hypercerts.context.getCommentCounts#subjectCommentCount"; + + /** + * Subject AT-URI requested by the caller; matching ignores the subject CID. + */ + "subject":l.AtUriString; + + /** + * Number of current indexed comment attachments whose subjects include this URI; zero when none are indexed. + */ + "commentCount":number }; + +export type { SubjectCommentCount }; + +const subjectCommentCount = /*#__PURE__*/ l.typedObject($nsid, "subjectCommentCount", /*#__PURE__*/ l.object({"subject":/*#__PURE__*/ l.string({"format":"at-uri"}),"commentCount":/*#__PURE__*/ l.integer({"minimum":0})})); + +export { subjectCommentCount }; + +type Output = { $type?: "org.hypercerts.context.getCommentCounts#output"; + + /** + * One entry for each requested subject, in request order. Repeated input URIs produce repeated entries. Counts reflect current indexed state and may change between requests. + */ + "counts":(SubjectCommentCount)[] }; + +export type { Output }; + +const output = /*#__PURE__*/ l.typedObject($nsid, "output", /*#__PURE__*/ l.object({"counts":/*#__PURE__*/ l.array(/*#__PURE__*/ l.ref((() => subjectCommentCount) as any), )})); + +export { output }; diff --git a/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.ts b/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.ts new file mode 100644 index 00000000..5e66e2e1 --- /dev/null +++ b/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.ts @@ -0,0 +1,6 @@ +/* + * THIS FILE WAS GENERATED BY scripts/generate.js WITH "@atproto/lex". DO NOT EDIT. + */ + +export * from "./getCommentCounts.defs.js"; +export { main as default } from "./getCommentCounts.defs.js"; From 78b1c514e6fb58575a150347c17dfe094ae8a01f Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 10 Nov 2026 19:33:00 +0600 Subject: [PATCH 2/3] context: reject malformed DID percent escapes in subject URIs valid_did allows a bare or non-hex % escape, so getCommentCounts, getEntityFollows and getActorReactions accepted subjects such as at://did:web:example%GG/... while getReactionCounts rejected them. Move getReactionCounts' escape check into the shared valid_subject_record_uri and use that helper from all four endpoints. --- .changeset/batch-comment-counts.md | 2 ++ .../lua/endpoints/getActorReactions.lua | 18 +++++++++++++++-- .../lua/endpoints/getCommentCounts.lua | 18 +++++++++++++++-- .../lua/endpoints/getEntityFollows.lua | 18 +++++++++++++++-- .../lua/endpoints/getReactionCounts.lua | 20 ++++++++++++------- .../lua/shared/subjectRecordUri.lua | 18 +++++++++++++++-- .../lua/src/getReactionCounts.lua | 17 +--------------- .../modules/feed-reactions/manifest.json | 3 ++- .../tests/unit/tooling/comment-counts.test.js | 1 + 9 files changed, 83 insertions(+), 32 deletions(-) diff --git a/.changeset/batch-comment-counts.md b/.changeset/batch-comment-counts.md index 79999d67..a8a2121f 100644 --- a/.changeset/batch-comment-counts.md +++ b/.changeset/batch-comment-counts.md @@ -4,3 +4,5 @@ --- Add `org.hypercerts.context.getCommentCounts`, which returns comment counts for up to 100 subject records in one call. + +`getEntityFollows` and `getActorReactions` now reject subject URIs whose DID has a malformed `%` escape, as `getReactionCounts` already did, instead of returning null for them. diff --git a/happyview-modules/lua/endpoints/getActorReactions.lua b/happyview-modules/lua/endpoints/getActorReactions.lua index c4d66f00..f227a2f7 100644 --- a/happyview-modules/lua/endpoints/getActorReactions.lua +++ b/happyview-modules/lua/endpoints/getActorReactions.lua @@ -68,11 +68,25 @@ local function valid_nsid(value) return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil end +-- valid_did allows "%" in a DID; the DID syntax requires two hex digits after it. +local function valid_did_percent_escapes(authority) + local index = 1 + while index <= #authority do + if authority:sub(index, index) == "%" then + if not authority:sub(index + 1, index + 2):match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + -- A record subject: a full DID-authority AT-URI with a valid collection NSID. local function valid_subject_record_uri(value) if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) + local valid, collection, authority = valid_record_uri(value) + return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end local ACTOR_REACTION_NULL = json.decode("null") diff --git a/happyview-modules/lua/endpoints/getCommentCounts.lua b/happyview-modules/lua/endpoints/getCommentCounts.lua index ea1ca7d2..b917e585 100644 --- a/happyview-modules/lua/endpoints/getCommentCounts.lua +++ b/happyview-modules/lua/endpoints/getCommentCounts.lua @@ -56,11 +56,25 @@ local function valid_nsid(value) return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil end +-- valid_did allows "%" in a DID; the DID syntax requires two hex digits after it. +local function valid_did_percent_escapes(authority) + local index = 1 + while index <= #authority do + if authority:sub(index, index) == "%" then + if not authority:sub(index + 1, index + 2):match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + -- A record subject: a full DID-authority AT-URI with a valid collection NSID. local function valid_subject_record_uri(value) if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) + local valid, collection, authority = valid_record_uri(value) + return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end local FOLLOW_BATCH_MAX_TARGETS = 100 diff --git a/happyview-modules/lua/endpoints/getEntityFollows.lua b/happyview-modules/lua/endpoints/getEntityFollows.lua index ae88a0a5..808ac719 100644 --- a/happyview-modules/lua/endpoints/getEntityFollows.lua +++ b/happyview-modules/lua/endpoints/getEntityFollows.lua @@ -164,11 +164,25 @@ local function valid_nsid(value) return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil end +-- valid_did allows "%" in a DID; the DID syntax requires two hex digits after it. +local function valid_did_percent_escapes(authority) + local index = 1 + while index <= #authority do + if authority:sub(index, index) == "%" then + if not authority:sub(index + 1, index + 2):match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + -- A record subject: a full DID-authority AT-URI with a valid collection NSID. local function valid_subject_record_uri(value) if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) + local valid, collection, authority = valid_record_uri(value) + return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end local function get_entity_follows() diff --git a/happyview-modules/lua/endpoints/getReactionCounts.lua b/happyview-modules/lua/endpoints/getReactionCounts.lua index 311b2728..83e8ad9f 100644 --- a/happyview-modules/lua/endpoints/getReactionCounts.lua +++ b/happyview-modules/lua/endpoints/getReactionCounts.lua @@ -56,10 +56,8 @@ local function valid_nsid(value) return #name <= 63 and name:match("^[A-Za-z][A-Za-z0-9]*$") ~= nil end -local LIKE = "app.certified.feed.like" -local REPOST = "app.certified.feed.repost" - -local function reaction_count_valid_did_percent_escapes(authority) +-- valid_did allows "%" in a DID; the DID syntax requires two hex digits after it. +local function valid_did_percent_escapes(authority) local index = 1 while index <= #authority do if authority:sub(index, index) == "%" then @@ -72,6 +70,16 @@ local function reaction_count_valid_did_percent_escapes(authority) return true end +-- A record subject: a full DID-authority AT-URI with a valid collection NSID. +local function valid_subject_record_uri(value) + if #value > 8192 then return false end + local valid, collection, authority = valid_record_uri(value) + return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) +end + +local LIKE = "app.certified.feed.like" +local REPOST = "app.certified.feed.repost" + local function reaction_count_subjects() local value = params.subjects if value == nil then invalid("subjects is required") end @@ -103,9 +111,7 @@ local function reaction_count_subjects() local unique, seen = {}, {} for _, subject in ipairs(supplied) do - local valid, collection, authority = valid_record_uri(subject) - if not valid or not reaction_count_valid_did_percent_escapes(authority) - or not valid_nsid(collection) then + if not valid_subject_record_uri(subject) then invalid("each subjects value must be a full AT-URI with a valid DID authority and collection NSID") end if not seen[subject] then diff --git a/happyview-modules/lua/shared/subjectRecordUri.lua b/happyview-modules/lua/shared/subjectRecordUri.lua index 2d9d955e..cf113c9c 100644 --- a/happyview-modules/lua/shared/subjectRecordUri.lua +++ b/happyview-modules/lua/shared/subjectRecordUri.lua @@ -1,6 +1,20 @@ +-- valid_did allows "%" in a DID; the DID syntax requires two hex digits after it. +local function valid_did_percent_escapes(authority) + local index = 1 + while index <= #authority do + if authority:sub(index, index) == "%" then + if not authority:sub(index + 1, index + 2):match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + -- A record subject: a full DID-authority AT-URI with a valid collection NSID. local function valid_subject_record_uri(value) if #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - return valid and valid_nsid(collection) + local valid, collection, authority = valid_record_uri(value) + return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end diff --git a/happyview-modules/lua/src/getReactionCounts.lua b/happyview-modules/lua/src/getReactionCounts.lua index 8187425e..fdd400d9 100644 --- a/happyview-modules/lua/src/getReactionCounts.lua +++ b/happyview-modules/lua/src/getReactionCounts.lua @@ -1,19 +1,6 @@ local LIKE = "app.certified.feed.like" local REPOST = "app.certified.feed.repost" -local function reaction_count_valid_did_percent_escapes(authority) - local index = 1 - while index <= #authority do - if authority:sub(index, index) == "%" then - if not authority:sub(index + 1, index + 2):match("^%x%x$") then return false end - index = index + 3 - else - index = index + 1 - end - end - return true -end - local function reaction_count_subjects() local value = params.subjects if value == nil then invalid("subjects is required") end @@ -45,9 +32,7 @@ local function reaction_count_subjects() local unique, seen = {}, {} for _, subject in ipairs(supplied) do - local valid, collection, authority = valid_record_uri(subject) - if not valid or not reaction_count_valid_did_percent_escapes(authority) - or not valid_nsid(collection) then + if not valid_subject_record_uri(subject) then invalid("each subjects value must be a full AT-URI with a valid DID authority and collection NSID") end if not seen[subject] then diff --git a/happyview-modules/modules/feed-reactions/manifest.json b/happyview-modules/modules/feed-reactions/manifest.json index d8039815..b9c6f8e5 100644 --- a/happyview-modules/modules/feed-reactions/manifest.json +++ b/happyview-modules/modules/feed-reactions/manifest.json @@ -158,7 +158,8 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", - "../../lua/shared/nsidValidation.lua" + "../../lua/shared/nsidValidation.lua", + "../../lua/shared/subjectRecordUri.lua" ], "config": { "script_type": "lua", diff --git a/happyview-modules/tests/unit/tooling/comment-counts.test.js b/happyview-modules/tests/unit/tooling/comment-counts.test.js index 60931daa..ae271fd0 100644 --- a/happyview-modules/tests/unit/tooling/comment-counts.test.js +++ b/happyview-modules/tests/unit/tooling/comment-counts.test.js @@ -90,6 +90,7 @@ test("getCommentCounts rejects subjects that are not full record AT-URIs before ["at://alice.example/org.hypercerts.context.attachment/post"], ["at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/invalid/key"], [`${post}?query`], + ["at://did:web:example%GG/org.hypercerts.context.attachment/post"], Array(15).fill(longPost), ]) { runLua({ params: { subjects }, expectError: "InvalidRequest:", expectedCalls: 0 }); From 5a1c82f0c2c456cc839b8b61d9e520cfecb382f9 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 11 Nov 2026 14:18:02 +0600 Subject: [PATCH 3/3] lexicon: simplify comment counts description --- .agents/skills/hypercerts-api/references/openapi.json | 2 +- docs/openapi.json | 2 +- .../lexicons/org.hypercerts.context.getCommentCounts.json | 2 +- .../lexicons/org.hypercerts.context.getCommentCounts.json | 2 +- .../lexicons/org/hypercerts/context/getCommentCounts.defs.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 2032c6c5..f8d03a67 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -3075,7 +3075,7 @@ "get": { "operationId": "org_hypercerts_context_getCommentCounts", "tags": ["org.hypercerts.context"], - "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "description": "Returns indexed comment counts for a list of subject records, with no authentication required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; every comment record counts, even several by the same author, and matching uses subjects[].uri, ignoring CID and whether the subject is indexed.", "parameters": [ { "name": "subjects", diff --git a/docs/openapi.json b/docs/openapi.json index 3edc1407..1fddbc47 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3939,7 +3939,7 @@ "tags": [ "org.hypercerts.context" ], - "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "description": "Returns indexed comment counts for a list of subject records, with no authentication required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; every comment record counts, even several by the same author, and matching uses subjects[].uri, ignoring CID and whether the subject is indexed.", "parameters": [ { "name": "subjects", diff --git a/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json b/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json index ee3359e8..1782c77b 100644 --- a/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json +++ b/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "description": "Returns indexed comment counts for a list of subject records, with no authentication required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; every comment record counts, even several by the same author, and matching uses subjects[].uri, ignoring CID and whether the subject is indexed.", "parameters": { "type": "params", "required": [ diff --git a/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json b/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json index 921b3bdc..3dbe218c 100644 --- a/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json +++ b/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed.", + "description": "Returns indexed comment counts for a list of subject records, with no authentication required. A comment is an org.hypercerts.context.attachment with contentType \"comment\" whose subjects include the record; every comment record counts, even several by the same author, and matching uses subjects[].uri, ignoring CID and whether the subject is indexed.", "parameters": { "type": "params", "required": ["subjects"], diff --git a/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts b/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts index 77237b63..840ac687 100644 --- a/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts +++ b/packages/api/src/lexicons/org/hypercerts/context/getCommentCounts.defs.ts @@ -19,7 +19,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** Returns current indexed comment counts for a list of subject records. No authentication is required. A comment is an org.hypercerts.context.attachment with contentType "comment" whose subjects include the record; each comment record counts once, so several comments by one author each count. Matching uses subjects[].uri regardless of CID or whether the subject is indexed. */ +/** Returns indexed comment counts for a list of subject records, with no authentication required. A comment is an org.hypercerts.context.attachment with contentType "comment" whose subjects include the record; every comment record counts, even several by the same author, and matching uses subjects[].uri, ignoring CID and whether the subject is indexed. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","CommentCountQueryFailed"]); export { main };