From d0db1a3cf7a164d15159a5fa9c0ae1030e66a22c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Nov 2026 17:12:16 +0600 Subject: [PATCH 1/4] lua/batch: raise request-target guard to 16 KiB and apply it to reaction batches 100 typical hypercert AT-URIs encode to ~10.5 KB and were rejected by the 7000-byte guard. getReactionCounts and getActorReactions now share the guard. Overflow stays a 500 script_error: the pinned HappyView 2.13.0 maps every Lua error() to 500 and offers no way for a script to set the status. --- .../lua/endpoints/getActorReactions.lua | 87 ++++++++++++++++++- .../lua/endpoints/getCommentCounts.lua | 33 ++++--- .../lua/endpoints/getEntityFollows.lua | 33 ++++--- .../lua/endpoints/getFollowerCounts.lua | 33 ++++--- .../lua/endpoints/getFollows.lua | 33 ++++--- .../lua/endpoints/getReactionCounts.lua | 83 ++++++++++++++++++ .../lua/shared/feedActorReactions.lua | 5 +- happyview-modules/lua/shared/followBatch.lua | 33 ++++--- .../lua/src/getReactionCounts.lua | 1 + .../modules/feed-reactions/manifest.json | 4 +- .../tests/unit/tooling/comment-counts.test.js | 11 ++- .../tests/unit/tooling/feed-reactions.test.js | 43 +++++++++ 12 files changed, 338 insertions(+), 61 deletions(-) diff --git a/happyview-modules/lua/endpoints/getActorReactions.lua b/happyview-modules/lua/endpoints/getActorReactions.lua index f227a2f..5962c4f 100644 --- a/happyview-modules/lua/endpoints/getActorReactions.lua +++ b/happyview-modules/lua/endpoints/getActorReactions.lua @@ -89,6 +89,88 @@ local function valid_subject_record_uri(value) return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end +local FOLLOW_BATCH_MAX_TARGETS = 100 +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 + +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 + +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + 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 +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 + + 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 + if not seen[target] then + seen[target] = true + unique[#unique + 1] = target + end + end + + assert_batch_request_target(endpoint, actor, key, targets) + return targets, unique +end + local ACTOR_REACTION_NULL = json.decode("null") local ACTOR_REACTION_LIMIT = 100 local ACTOR_REACTION_COLLECTIONS = { @@ -105,7 +187,7 @@ local function actor_reaction_sort_key() ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" end -local function feed_actor_reaction_subjects(value) +local function feed_actor_reaction_subjects(actor, value) if value == nil then invalid("subjects is required") end local subjects = {} @@ -136,6 +218,7 @@ local function feed_actor_reaction_subjects(value) invalid("each subjects value must be a full DID-authority AT-URI with a valid collection NSID") end end + assert_batch_request_target("app.certified.feed.getActorReactions", actor, "subjects", subjects) return subjects end @@ -255,7 +338,7 @@ local function feed_actor_reactions() keys_only(params, { actor = true, subjects = true }) local actor = scalar(params, "actor") if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end - local subjects = feed_actor_reaction_subjects(params.subjects) + local subjects = feed_actor_reaction_subjects(actor, params.subjects) local rows = feed_actor_reactions_query(actor, subjects) local ok, response = pcall(feed_actor_reaction_results, rows, actor, subjects) if not ok then diff --git a/happyview-modules/lua/endpoints/getCommentCounts.lua b/happyview-modules/lua/endpoints/getCommentCounts.lua index b917e58..472d255 100644 --- a/happyview-modules/lua/endpoints/getCommentCounts.lua +++ b/happyview-modules/lua/endpoints/getCommentCounts.lua @@ -78,7 +78,11 @@ local function valid_subject_record_uri(value) end local FOLLOW_BATCH_MAX_TARGETS = 100 -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -94,6 +98,22 @@ local function encoded_query_value_length(value) return length end +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + 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 +end + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end @@ -124,27 +144,18 @@ local function follow_batch_targets(actor, key, value, validate, description, en 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 + assert_batch_request_target(endpoint, actor, key, targets) return targets, unique end diff --git a/happyview-modules/lua/endpoints/getEntityFollows.lua b/happyview-modules/lua/endpoints/getEntityFollows.lua index 808ac71..69e9557 100644 --- a/happyview-modules/lua/endpoints/getEntityFollows.lua +++ b/happyview-modules/lua/endpoints/getEntityFollows.lua @@ -77,7 +77,11 @@ local function entity_follow_record_view(row) end local FOLLOW_BATCH_MAX_TARGETS = 100 -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -93,6 +97,22 @@ local function encoded_query_value_length(value) return length end +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + 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 +end + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end @@ -123,27 +143,18 @@ local function follow_batch_targets(actor, key, value, validate, description, en 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 + assert_batch_request_target(endpoint, actor, key, targets) return targets, unique end diff --git a/happyview-modules/lua/endpoints/getFollowerCounts.lua b/happyview-modules/lua/endpoints/getFollowerCounts.lua index c3aebc7..e9da92e 100644 --- a/happyview-modules/lua/endpoints/getFollowerCounts.lua +++ b/happyview-modules/lua/endpoints/getFollowerCounts.lua @@ -78,7 +78,11 @@ local function valid_subject_record_uri(value) end local FOLLOW_BATCH_MAX_TARGETS = 100 -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -94,6 +98,22 @@ local function encoded_query_value_length(value) return length end +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + 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 +end + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end @@ -124,27 +144,18 @@ local function follow_batch_targets(actor, key, value, validate, description, en 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 + assert_batch_request_target(endpoint, actor, key, targets) return targets, unique end diff --git a/happyview-modules/lua/endpoints/getFollows.lua b/happyview-modules/lua/endpoints/getFollows.lua index 9467185..1c624dc 100644 --- a/happyview-modules/lua/endpoints/getFollows.lua +++ b/happyview-modules/lua/endpoints/getFollows.lua @@ -56,7 +56,11 @@ local function query(sql, values) end local FOLLOW_BATCH_MAX_TARGETS = 100 -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -72,6 +76,22 @@ local function encoded_query_value_length(value) return length end +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + 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 +end + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end @@ -102,27 +122,18 @@ local function follow_batch_targets(actor, key, value, validate, description, en 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 + assert_batch_request_target(endpoint, actor, key, targets) return targets, unique end diff --git a/happyview-modules/lua/endpoints/getReactionCounts.lua b/happyview-modules/lua/endpoints/getReactionCounts.lua index 83e8ad9..3213515 100644 --- a/happyview-modules/lua/endpoints/getReactionCounts.lua +++ b/happyview-modules/lua/endpoints/getReactionCounts.lua @@ -77,6 +77,88 @@ local function valid_subject_record_uri(value) return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end +local FOLLOW_BATCH_MAX_TARGETS = 100 +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 + +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 + +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + 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 +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 + + 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 + if not seen[target] then + seen[target] = true + unique[#unique + 1] = target + end + end + + assert_batch_request_target(endpoint, actor, key, targets) + return targets, unique +end + local LIKE = "app.certified.feed.like" local REPOST = "app.certified.feed.repost" @@ -119,6 +201,7 @@ local function reaction_count_subjects() unique[#unique + 1] = subject end end + assert_batch_request_target("app.certified.feed.getReactionCounts", nil, "subjects", supplied) return supplied, unique end diff --git a/happyview-modules/lua/shared/feedActorReactions.lua b/happyview-modules/lua/shared/feedActorReactions.lua index 44370f4..3141a1d 100644 --- a/happyview-modules/lua/shared/feedActorReactions.lua +++ b/happyview-modules/lua/shared/feedActorReactions.lua @@ -14,7 +14,7 @@ local function actor_reaction_sort_key() ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" end -local function feed_actor_reaction_subjects(value) +local function feed_actor_reaction_subjects(actor, value) if value == nil then invalid("subjects is required") end local subjects = {} @@ -45,6 +45,7 @@ local function feed_actor_reaction_subjects(value) invalid("each subjects value must be a full DID-authority AT-URI with a valid collection NSID") end end + assert_batch_request_target("app.certified.feed.getActorReactions", actor, "subjects", subjects) return subjects end @@ -164,7 +165,7 @@ local function feed_actor_reactions() keys_only(params, { actor = true, subjects = true }) local actor = scalar(params, "actor") if not actor or not valid_did(actor) then invalid("actor must be a valid DID") end - local subjects = feed_actor_reaction_subjects(params.subjects) + local subjects = feed_actor_reaction_subjects(actor, params.subjects) local rows = feed_actor_reactions_query(actor, subjects) local ok, response = pcall(feed_actor_reaction_results, rows, actor, subjects) if not ok then diff --git a/happyview-modules/lua/shared/followBatch.lua b/happyview-modules/lua/shared/followBatch.lua index dd15088..72e501f 100644 --- a/happyview-modules/lua/shared/followBatch.lua +++ b/happyview-modules/lua/shared/followBatch.lua @@ -1,5 +1,9 @@ local FOLLOW_BATCH_MAX_TARGETS = 100 -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 7000 +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -15,6 +19,22 @@ local function encoded_query_value_length(value) return length end +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + 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 +end + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end @@ -45,26 +65,17 @@ local function follow_batch_targets(actor, key, value, validate, description, en 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 + assert_batch_request_target(endpoint, actor, key, targets) return targets, unique end diff --git a/happyview-modules/lua/src/getReactionCounts.lua b/happyview-modules/lua/src/getReactionCounts.lua index fdd400d..62dbd55 100644 --- a/happyview-modules/lua/src/getReactionCounts.lua +++ b/happyview-modules/lua/src/getReactionCounts.lua @@ -40,6 +40,7 @@ local function reaction_count_subjects() unique[#unique + 1] = subject end end + assert_batch_request_target("app.certified.feed.getReactionCounts", nil, "subjects", supplied) return supplied, unique end diff --git a/happyview-modules/modules/feed-reactions/manifest.json b/happyview-modules/modules/feed-reactions/manifest.json index b9c6f8e..d62a1c2 100644 --- a/happyview-modules/modules/feed-reactions/manifest.json +++ b/happyview-modules/modules/feed-reactions/manifest.json @@ -144,6 +144,7 @@ "../../lua/shared/recordView.lua", "../../lua/shared/nsidValidation.lua", "../../lua/shared/subjectRecordUri.lua", + "../../lua/shared/followBatch.lua", "../../lua/shared/feedActorReactions.lua" ], "config": { "script_type": "lua", "description": "Actor feed reaction API handler" }, @@ -159,7 +160,8 @@ "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", "../../lua/shared/nsidValidation.lua", - "../../lua/shared/subjectRecordUri.lua" + "../../lua/shared/subjectRecordUri.lua", + "../../lua/shared/followBatch.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 ae271fd..afeee5f 100644 --- a/happyview-modules/tests/unit/tooling/comment-counts.test.js +++ b/happyview-modules/tests/unit/tooling/comment-counts.test.js @@ -83,6 +83,15 @@ assert(math.type(result.counts[1].commentCount) == 'integer') }); }); +test("getCommentCounts accepts 100 typical hypercert AT-URIs within the request-target guard", () => { + const subjects = Array.from( + { length: 100 }, + (_, index) => + `at://did:plc:abcdefghijklmnopqrstuvwx/org.hypercerts.claim.activity/3lbx${String(index).padStart(9, "0")}`, + ); + runLua({ params: { subjects }, assertions: "assert(#result.counts == 100 and #calls == 1)" }); +}); + 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 [ @@ -91,7 +100,7 @@ test("getCommentCounts rejects subjects that are not full record AT-URIs before ["at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/invalid/key"], [`${post}?query`], ["at://did:web:example%GG/org.hypercerts.context.attachment/post"], - Array(15).fill(longPost), + Array(30).fill(longPost), ]) { runLua({ params: { subjects }, expectError: "InvalidRequest:", expectedCalls: 0 }); } diff --git a/happyview-modules/tests/unit/tooling/feed-reactions.test.js b/happyview-modules/tests/unit/tooling/feed-reactions.test.js index 1cc2ed9..71e8cfa 100644 --- a/happyview-modules/tests/unit/tooling/feed-reactions.test.js +++ b/happyview-modules/tests/unit/tooling/feed-reactions.test.js @@ -432,6 +432,49 @@ assert(calls[1].values[2] == '${firstSubject}' and calls[1].values[3] == '${seco }); }); +const typicalSubjects = Array.from( + { length: 100 }, + (_, index) => + `at://did:plc:abcdefghijklmnopqrstuvwx/org.hypercerts.claim.activity/3lbx${String(index).padStart(9, "0")}`, +); +// 30 URIs with 500-character record keys encode to about 17.4 KB, above the 16 KiB guard. +const oversizeSubjects = Array.from( + { length: 30 }, + (_, index) => + `at://did:plc:dddddddddddddddddddddddd/org.example.thing/${String(index).padStart(500, "k")}`, +); + +test("getReactionCounts accepts 100 typical AT-URIs and rejects an oversize request-target before querying", () => { + runLua({ + endpoint: "getReactionCounts", + params: { subjects: typicalSubjects }, + assertions: "assert(#result.counts == 100 and #calls == 1)", + }); + runLua({ + endpoint: "getReactionCounts", + params: { subjects: oversizeSubjects }, + expectError: "InvalidRequest: subjects makes the encoded GET request-target exceed 16384 bytes", + expectedCalls: 0, + }); +}); + +test("getActorReactions accepts 100 typical AT-URIs and rejects an oversize request-target before querying", () => { + runLua({ + endpoint: "getActorReactions", + params: { actor, subjects: typicalSubjects }, + rows: typicalSubjects.map((subjectUri, index) => + actorReactionResultRow(index + 1, subjectUri, null, null), + ), + assertions: "assert(#result.results == 100 and #calls == 1)", + }); + runLua({ + endpoint: "getActorReactions", + params: { actor, subjects: oversizeSubjects }, + expectError: "InvalidRequest: subjects makes the encoded GET request-target exceed 16384 bytes", + expectedCalls: 0, + }); +}); + test("getActorReactions accepts 100 subject occurrences but rejects larger batches before querying", () => { const subjects = Array.from( { length: 100 }, From 8d139262505b985939e0c20b733940dc03aee40d Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Nov 2026 17:14:52 +0600 Subject: [PATCH 2/4] lua/batch: move request-target guard into its own shared file Reaction bundles only need the byte guard, not follow_batch_targets, which luacheck flags as unused there. Old oversize fixtures for getFollows and getFollowerCounts fit under 16 KiB, so they are enlarged to stay oversize. --- .../lua/endpoints/getActorReactions.lua | 52 ++----------------- .../lua/endpoints/getCommentCounts.lua | 9 ++-- .../lua/endpoints/getEntityFollows.lua | 9 ++-- .../lua/endpoints/getFollowerCounts.lua | 9 ++-- .../lua/endpoints/getFollows.lua | 9 ++-- .../lua/endpoints/getReactionCounts.lua | 52 ++----------------- .../lua/shared/batchRequestTarget.lua | 35 +++++++++++++ happyview-modules/lua/shared/followBatch.lua | 35 ------------- .../modules/actor-follow/manifest.json | 1 + .../modules/context-attachments/manifest.json | 1 + .../modules/entity-follow/manifest.json | 1 + .../modules/feed-reactions/manifest.json | 4 +- .../modules/follower-counts/manifest.json | 1 + .../tests/unit/tooling/actor-follow.test.js | 2 +- .../unit/tooling/follower-counts.test.js | 2 +- 15 files changed, 69 insertions(+), 153 deletions(-) create mode 100644 happyview-modules/lua/shared/batchRequestTarget.lua diff --git a/happyview-modules/lua/endpoints/getActorReactions.lua b/happyview-modules/lua/endpoints/getActorReactions.lua index 5962c4f..5f668d7 100644 --- a/happyview-modules/lua/endpoints/getActorReactions.lua +++ b/happyview-modules/lua/endpoints/getActorReactions.lua @@ -89,12 +89,11 @@ local function valid_subject_record_uri(value) return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end -local FOLLOW_BATCH_MAX_TARGETS = 100 -- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within -- Node's default 16 KiB header limit for downstream callers. The pinned -- HappyView release turns every Lua error() into HTTP 500 script_error, so an -- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 +local BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -121,56 +120,11 @@ local function assert_batch_request_target(endpoint, actor, key, targets) for _, target in ipairs(targets) do request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) 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") + if request_target_length > BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") end 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 - - 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 - if not seen[target] then - seen[target] = true - unique[#unique + 1] = target - end - end - - assert_batch_request_target(endpoint, actor, key, targets) - return targets, unique -end - local ACTOR_REACTION_NULL = json.decode("null") local ACTOR_REACTION_LIMIT = 100 local ACTOR_REACTION_COLLECTIONS = { diff --git a/happyview-modules/lua/endpoints/getCommentCounts.lua b/happyview-modules/lua/endpoints/getCommentCounts.lua index 472d255..5bf3a53 100644 --- a/happyview-modules/lua/endpoints/getCommentCounts.lua +++ b/happyview-modules/lua/endpoints/getCommentCounts.lua @@ -77,12 +77,11 @@ local function valid_subject_record_uri(value) return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end -local FOLLOW_BATCH_MAX_TARGETS = 100 -- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within -- Node's default 16 KiB header limit for downstream callers. The pinned -- HappyView release turns every Lua error() into HTTP 500 script_error, so an -- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 +local BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -109,11 +108,13 @@ local function assert_batch_request_target(endpoint, actor, key, targets) for _, target in ipairs(targets) do request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) 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") + if request_target_length > BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") end end +local FOLLOW_BATCH_MAX_TARGETS = 100 + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end diff --git a/happyview-modules/lua/endpoints/getEntityFollows.lua b/happyview-modules/lua/endpoints/getEntityFollows.lua index 69e9557..7148b21 100644 --- a/happyview-modules/lua/endpoints/getEntityFollows.lua +++ b/happyview-modules/lua/endpoints/getEntityFollows.lua @@ -76,12 +76,11 @@ local function entity_follow_record_view(row) return view end -local FOLLOW_BATCH_MAX_TARGETS = 100 -- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within -- Node's default 16 KiB header limit for downstream callers. The pinned -- HappyView release turns every Lua error() into HTTP 500 script_error, so an -- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 +local BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -108,11 +107,13 @@ local function assert_batch_request_target(endpoint, actor, key, targets) for _, target in ipairs(targets) do request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) 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") + if request_target_length > BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") end end +local FOLLOW_BATCH_MAX_TARGETS = 100 + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end diff --git a/happyview-modules/lua/endpoints/getFollowerCounts.lua b/happyview-modules/lua/endpoints/getFollowerCounts.lua index e9da92e..c80dc73 100644 --- a/happyview-modules/lua/endpoints/getFollowerCounts.lua +++ b/happyview-modules/lua/endpoints/getFollowerCounts.lua @@ -77,12 +77,11 @@ local function valid_subject_record_uri(value) return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end -local FOLLOW_BATCH_MAX_TARGETS = 100 -- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within -- Node's default 16 KiB header limit for downstream callers. The pinned -- HappyView release turns every Lua error() into HTTP 500 script_error, so an -- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 +local BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -109,11 +108,13 @@ local function assert_batch_request_target(endpoint, actor, key, targets) for _, target in ipairs(targets) do request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) 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") + if request_target_length > BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") end end +local FOLLOW_BATCH_MAX_TARGETS = 100 + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end diff --git a/happyview-modules/lua/endpoints/getFollows.lua b/happyview-modules/lua/endpoints/getFollows.lua index 1c624dc..f67e29a 100644 --- a/happyview-modules/lua/endpoints/getFollows.lua +++ b/happyview-modules/lua/endpoints/getFollows.lua @@ -55,12 +55,11 @@ local function query(sql, values) return result end -local FOLLOW_BATCH_MAX_TARGETS = 100 -- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within -- Node's default 16 KiB header limit for downstream callers. The pinned -- HappyView release turns every Lua error() into HTTP 500 script_error, so an -- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 +local BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -87,11 +86,13 @@ local function assert_batch_request_target(endpoint, actor, key, targets) for _, target in ipairs(targets) do request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) 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") + if request_target_length > BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") end end +local FOLLOW_BATCH_MAX_TARGETS = 100 + local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end diff --git a/happyview-modules/lua/endpoints/getReactionCounts.lua b/happyview-modules/lua/endpoints/getReactionCounts.lua index 3213515..0b01ede 100644 --- a/happyview-modules/lua/endpoints/getReactionCounts.lua +++ b/happyview-modules/lua/endpoints/getReactionCounts.lua @@ -77,12 +77,11 @@ local function valid_subject_record_uri(value) return valid and valid_did_percent_escapes(authority) and valid_nsid(collection) end -local FOLLOW_BATCH_MAX_TARGETS = 100 -- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within -- Node's default 16 KiB header limit for downstream callers. The pinned -- HappyView release turns every Lua error() into HTTP 500 script_error, so an -- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 +local BATCH_MAX_REQUEST_TARGET_BYTES = 16384 local function encoded_query_value_length(value) local length = 0 @@ -109,56 +108,11 @@ local function assert_batch_request_target(endpoint, actor, key, targets) for _, target in ipairs(targets) do request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) 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") + if request_target_length > BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") end 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 - - 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 - if not seen[target] then - seen[target] = true - unique[#unique + 1] = target - end - end - - assert_batch_request_target(endpoint, actor, key, targets) - return targets, unique -end - local LIKE = "app.certified.feed.like" local REPOST = "app.certified.feed.repost" diff --git a/happyview-modules/lua/shared/batchRequestTarget.lua b/happyview-modules/lua/shared/batchRequestTarget.lua new file mode 100644 index 0000000..241057e --- /dev/null +++ b/happyview-modules/lua/shared/batchRequestTarget.lua @@ -0,0 +1,35 @@ +-- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within +-- Node's default 16 KiB header limit for downstream callers. The pinned +-- HappyView release turns every Lua error() into HTTP 500 script_error, so an +-- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. +local BATCH_MAX_REQUEST_TARGET_BYTES = 16384 + +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 + +-- Rejects batches whose percent-encoded GET request-target would exceed the +-- shared byte limit. actor is nil for batch queries that take only the target +-- list; the first target's "&" then stands in for the "?". +local function assert_batch_request_target(endpoint, actor, key, targets) + local request_target_length = #("/xrpc/" .. endpoint) + if actor then + request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) + end + for _, target in ipairs(targets) do + request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) + end + if request_target_length > BATCH_MAX_REQUEST_TARGET_BYTES then + invalid(key .. " makes the encoded GET request-target exceed " .. BATCH_MAX_REQUEST_TARGET_BYTES .. " bytes") + end +end diff --git a/happyview-modules/lua/shared/followBatch.lua b/happyview-modules/lua/shared/followBatch.lua index 72e501f..d105f2a 100644 --- a/happyview-modules/lua/shared/followBatch.lua +++ b/happyview-modules/lua/shared/followBatch.lua @@ -1,39 +1,4 @@ local FOLLOW_BATCH_MAX_TARGETS = 100 --- 16 KiB fits 100 typical hypercert AT-URIs (~10.5 KB encoded) and stays within --- Node's default 16 KiB header limit for downstream callers. The pinned --- HappyView release turns every Lua error() into HTTP 500 script_error, so an --- overflow cannot be a 400 yet; the InvalidRequest: prefix names the cause. -local FOLLOW_BATCH_MAX_REQUEST_TARGET_BYTES = 16384 - -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 - --- Rejects batches whose percent-encoded GET request-target would exceed the --- shared byte limit. actor is nil for batch queries that take only the target --- list; the first target's "&" then stands in for the "?". -local function assert_batch_request_target(endpoint, actor, key, targets) - local request_target_length = #("/xrpc/" .. endpoint) - if actor then - request_target_length = request_target_length + #("?actor=") + encoded_query_value_length(actor) - end - for _, target in ipairs(targets) do - request_target_length = request_target_length + #("&" .. key .. "=") + encoded_query_value_length(target) - 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 -end local function follow_batch_targets(actor, key, value, validate, description, endpoint) if value == nil then invalid(key .. " is required") end diff --git a/happyview-modules/modules/actor-follow/manifest.json b/happyview-modules/modules/actor-follow/manifest.json index a6d1cca..aab0e81 100644 --- a/happyview-modules/modules/actor-follow/manifest.json +++ b/happyview-modules/modules/actor-follow/manifest.json @@ -68,6 +68,7 @@ "../../lua/shared/query.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorFollow.lua", + "../../lua/shared/batchRequestTarget.lua", "../../lua/shared/followBatch.lua" ], "config": { diff --git a/happyview-modules/modules/context-attachments/manifest.json b/happyview-modules/modules/context-attachments/manifest.json index db658ef..71e7b82 100644 --- a/happyview-modules/modules/context-attachments/manifest.json +++ b/happyview-modules/modules/context-attachments/manifest.json @@ -82,6 +82,7 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/nsidValidation.lua", "../../lua/shared/subjectRecordUri.lua", + "../../lua/shared/batchRequestTarget.lua", "../../lua/shared/followBatch.lua" ], "config": { diff --git a/happyview-modules/modules/entity-follow/manifest.json b/happyview-modules/modules/entity-follow/manifest.json index d42bfe6..0004535 100644 --- a/happyview-modules/modules/entity-follow/manifest.json +++ b/happyview-modules/modules/entity-follow/manifest.json @@ -73,6 +73,7 @@ "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", "../../lua/shared/entityFollow.lua", + "../../lua/shared/batchRequestTarget.lua", "../../lua/shared/followBatch.lua", "../../lua/shared/nsidValidation.lua", "../../lua/shared/subjectRecordUri.lua" diff --git a/happyview-modules/modules/feed-reactions/manifest.json b/happyview-modules/modules/feed-reactions/manifest.json index d62a1c2..f5be954 100644 --- a/happyview-modules/modules/feed-reactions/manifest.json +++ b/happyview-modules/modules/feed-reactions/manifest.json @@ -144,7 +144,7 @@ "../../lua/shared/recordView.lua", "../../lua/shared/nsidValidation.lua", "../../lua/shared/subjectRecordUri.lua", - "../../lua/shared/followBatch.lua", + "../../lua/shared/batchRequestTarget.lua", "../../lua/shared/feedActorReactions.lua" ], "config": { "script_type": "lua", "description": "Actor feed reaction API handler" }, @@ -161,7 +161,7 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/nsidValidation.lua", "../../lua/shared/subjectRecordUri.lua", - "../../lua/shared/followBatch.lua" + "../../lua/shared/batchRequestTarget.lua" ], "config": { "script_type": "lua", diff --git a/happyview-modules/modules/follower-counts/manifest.json b/happyview-modules/modules/follower-counts/manifest.json index 492462f..2ab187e 100644 --- a/happyview-modules/modules/follower-counts/manifest.json +++ b/happyview-modules/modules/follower-counts/manifest.json @@ -20,6 +20,7 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/nsidValidation.lua", "../../lua/shared/subjectRecordUri.lua", + "../../lua/shared/batchRequestTarget.lua", "../../lua/shared/followBatch.lua" ], "config": { diff --git a/happyview-modules/tests/unit/tooling/actor-follow.test.js b/happyview-modules/tests/unit/tooling/actor-follow.test.js index 3ee0631..c3e89f0 100644 --- a/happyview-modules/tests/unit/tooling/actor-follow.test.js +++ b/happyview-modules/tests/unit/tooling/actor-follow.test.js @@ -633,7 +633,7 @@ assert(calls[1].sql:find('ORDER BY requested.target, sorted.sort_at ASC, uri ASC test("getFollows rejects invalid or oversized target arrays before querying and surfaces database failures", () => { const oversizedUrlTargets = Array.from( - { length: 4 }, + { length: 9 }, (_, index) => `did:plc:${"a".repeat(2039)}${index}`, ); for (const params of [ diff --git a/happyview-modules/tests/unit/tooling/follower-counts.test.js b/happyview-modules/tests/unit/tooling/follower-counts.test.js index 12bd59e..26876a2 100644 --- a/happyview-modules/tests/unit/tooling/follower-counts.test.js +++ b/happyview-modules/tests/unit/tooling/follower-counts.test.js @@ -124,7 +124,7 @@ test("getFollowerCounts accepts 100 subjects and rejects invalid batches before { subjects: ["at://alice.example/org.hypercerts.collection/project"] }, { subjects: ["at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/invalid/key"] }, { subjects: [`${entity}?query`] }, - { subjects: Array(15).fill(longEntity) }, + { subjects: Array(30).fill(longEntity) }, ]) { runLua({ params, expectError: "InvalidRequest:", expectedCalls: 0 }); } From 949f4be82320216be3059d9f9ee4347fe8276136 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Nov 2026 17:14:53 +0600 Subject: [PATCH 3/4] lexicons, docs: document 16,384-byte batch request-target limit Covers getFollows, getEntityFollows, getFollowerCounts and getCommentCounts, and adds the limit to getReactionCounts and getActorReactions. Regenerates the explorer snapshots and API client; updates the skill references. --- .../hypercerts-api/references/endpoint-map.md | 6 +++--- .../hypercerts-api/references/openapi.json | 16 ++++++++-------- .../references/request-patterns.md | 10 +++++----- docs/openapi.json | 16 ++++++++-------- .../app.certified.feed.getActorReactions.json | 4 ++-- .../app.certified.feed.getReactionCounts.json | 4 ++-- .../app.certified.graph.getEntityFollows.json | 2 +- .../app.certified.graph.getFollowerCounts.json | 2 +- .../lexicons/app.certified.graph.getFollows.json | 2 +- .../org.hypercerts.context.getCommentCounts.json | 2 +- happyview-modules/README.md | 4 ++-- .../app.certified.feed.getActorReactions.json | 4 ++-- .../app.certified.feed.getReactionCounts.json | 4 ++-- .../app.certified.graph.getEntityFollows.json | 2 +- .../app.certified.graph.getFollowerCounts.json | 2 +- .../lexicons/app.certified.graph.getFollows.json | 2 +- .../org.hypercerts.context.getCommentCounts.json | 2 +- .../app/certified/graph/getEntityFollows.defs.ts | 2 +- .../app/certified/graph/getFollows.defs.ts | 2 +- 19 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 7144dc0..72a853d 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -81,7 +81,7 @@ An `unanswered` award is a successful indexed award result, not a not-found resp | List an actor's followers | `app.certified.graph.listActorFollowers` | `actor` is the followed actor DID. | | List actors an actor follows | `app.certified.graph.listActorFollowing` | `actor` is the follower DID. | | Check one actor-to-entity follow | `app.certified.graph.getEntityFollow` | Supply actor DID and entity AT-URI; check its nullable/error contract. | -| Check one actor against many entity AT-URIs | `app.certified.graph.getEntityFollows` | Supply 1–100 repeated `entities` values; matching is by URI, each `{entity, follow}` preserves input order and duplicates, and the encoded GET request-target is capped at 7,000 bytes. | +| Check one actor against many entity AT-URIs | `app.certified.graph.getEntityFollows` | Supply 1–100 repeated `entities` values; matching is by URI, each `{entity, follow}` preserves input order and duplicates, and the encoded GET request-target is capped at 16,384 bytes. | | List actors following an entity | `app.certified.graph.listEntityFollowers` | `entity` is an entity AT-URI; `totalCount` reports distinct relationships before cursor and limit, including on empty pages. | | List entities an actor follows | `app.certified.graph.listEntityFollowing` | `actor` is an actor DID; targets are hydrated from indexed records. | | Browse recent raw follow records | `app.certified.graph.listRecentFollows` | Beta feed across publishers; `before` and pagination have distinct feed semantics. Not a per-actor relationship check. | @@ -96,8 +96,8 @@ Do not confuse actor follows with entity follows; their identifiers and list out | 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. | -| Check an actor's reactions for known subjects | `app.certified.feed.getActorReactions` | Supply `actor` as a DID and `subjects` as 1–100 repeated full record AT-URIs. Returns one result per input occurrence in order, including duplicates; matching ignores CID, and missing indexed likes/reposts are null. Raw views retain reaction URIs. | -| Get reaction counts for subjects | `app.certified.feed.getReactionCounts` | Pass 1–100 full subject record AT-URIs with DID authorities, not handle authorities, as repeated, unbracketed `subjects` parameters. Results preserve input order and duplicates; counts are distinct indexed publisher DIDs matching subject URI regardless of CID or subject-record indexing. | +| Check an actor's reactions for known subjects | `app.certified.feed.getActorReactions` | Supply `actor` as a DID and `subjects` as 1–100 repeated full record AT-URIs. Returns one result per input occurrence in order, including duplicates; matching ignores CID, and missing indexed likes/reposts are null. Raw views retain reaction URIs. The encoded GET request-target is capped at 16,384 bytes. | +| Get reaction counts for subjects | `app.certified.feed.getReactionCounts` | Pass 1–100 full subject record AT-URIs with DID authorities, not handle authorities, as repeated, unbracketed `subjects` parameters. Results preserve input order and duplicates; counts are distinct indexed publisher DIDs matching subject URI regardless of CID or subject-record indexing. The encoded GET request-target is capped at 16,384 bytes. | 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. diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index f7a7897..5cc21f2 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -41,7 +41,7 @@ "name": "subjects", "in": "query", "required": true, - "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.", + "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order. The encoded GET request-target must not exceed 16,384 bytes.", "schema": { "type": "array", "minItems": 1, @@ -68,7 +68,7 @@ "x-lexicon-errors": [ { "name": "InvalidRequest", - "description": "The actor DID, subjects array, subject AT-URI, or another query parameter is invalid." + "description": "The actor DID, subjects array, subject AT-URI, request-target length, or another query parameter is invalid." }, { "name": "ActorReactionsQueryFailed", @@ -1232,7 +1232,7 @@ "name": "subjects", "in": "query", "required": true, - "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.", + "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 16,384 bytes.", "schema": { "type": "array", "minItems": 1, @@ -1259,7 +1259,7 @@ "x-lexicon-errors": [ { "name": "InvalidRequest", - "description": "The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry." + "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": "ReactionCountQueryFailed", @@ -1376,7 +1376,7 @@ "get": { "operationId": "app_certified_graph_getEntityFollows", "tags": ["app.certified.graph"], - "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": [ { "name": "actor", @@ -1476,7 +1476,7 @@ "name": "subjects", "in": "query", "required": true, - "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 16,384 bytes.", "schema": { "type": "array", "minItems": 1, @@ -1520,7 +1520,7 @@ "get": { "operationId": "app_certified_graph_getFollows", "tags": ["app.certified.graph"], - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": [ { "name": "actor", @@ -3138,7 +3138,7 @@ "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.", + "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 16,384 bytes.", "schema": { "type": "array", "minItems": 1, diff --git a/.agents/skills/hypercerts-api/references/request-patterns.md b/.agents/skills/hypercerts-api/references/request-patterns.md index 2c519c5..ae4f797 100644 --- a/.agents/skills/hypercerts-api/references/request-patterns.md +++ b/.agents/skills/hypercerts-api/references/request-patterns.md @@ -37,13 +37,13 @@ Do not use `authors[]=` or comma-joined values unless that exact endpoint contra A useful starting distinction: arrays often mean “any of these” for a filter, while separate filters may combine with AND, but this is **not a universal rule**. For example, the bundled collection contract says `tagUris` requires every supplied tag URI, while `itemUris` matches URI without CID. Confirm the exact operation before relying on filter logic. -The batch relationship checks `app.certified.graph.getFollows` and `app.certified.graph.getEntityFollows` each take one `actor` and 1–100 repeated, unbracketed `subjects` or `entities` values. Keep duplicates if the caller supplied them: each output `results` entry echoes its target and preserves its position. The encoded GET request-target must stay within 7,000 bytes. A null `follow` means only that no indexed relationship was found; database failures are errors, not nulls. +The batch relationship checks `app.certified.graph.getFollows` and `app.certified.graph.getEntityFollows` each take one `actor` and 1–100 repeated, unbracketed `subjects` or `entities` values. Keep duplicates if the caller supplied them: each output `results` entry echoes its target and preserves its position. The encoded GET request-target must stay within 16,384 bytes. A null `follow` means only that no indexed relationship was found; database failures are errors, not nulls. -`app.certified.graph.getFollowerCounts` takes no `actor`: append 1–100 account DIDs and full DID-authority entity AT-URIs under the same unbracketed `subjects` key, in any mix. A value starting with `did:` is counted as an account; any other value must be an entity AT-URI. The response has one `{subject, followerCount}` entry per input occurrence in the same order, including duplicates. The same 7,000-byte request-target limit applies, and a zero count means no indexed followers were found at query time. +`app.certified.graph.getFollowerCounts` takes no `actor`: append 1–100 account DIDs and full DID-authority entity AT-URIs under the same unbracketed `subjects` key, in any mix. A value starting with `did:` is counted as an account; any other value must be an entity AT-URI. The response has one `{subject, followerCount}` entry per input occurrence in the same order, including duplicates. The same 16,384-byte request-target limit applies, and a zero count means no indexed followers were found at query time. ### Batch an actor's reactions for known subjects -`app.certified.feed.getActorReactions` requires one actor DID and 1–100 full record AT-URIs with DID authorities, not handle authorities. Encode `subjects` as repeated, unbracketed query parameters; keep input order and duplicates if each occurrence matters: +`app.certified.feed.getActorReactions` requires one actor DID and 1–100 full record AT-URIs with DID authorities, not handle authorities, within a 16,384-byte encoded request-target. Encode `subjects` as repeated, unbracketed query parameters; keep input order and duplicates if each occurrence matters: ```ts const params = new URLSearchParams(); @@ -56,9 +56,9 @@ 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. +`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. The encoded request-target must stay within 16,384 bytes. 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. +`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 16,384-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 diff --git a/docs/openapi.json b/docs/openapi.json index 3e5c0f9..5516ea1 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1378,7 +1378,7 @@ "name": "subjects", "in": "query", "required": true, - "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.", + "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order. The encoded GET request-target must not exceed 16,384 bytes.", "schema": { "type": "array", "minItems": 1, @@ -1412,7 +1412,7 @@ "x-lexicon-errors": [ { "name": "InvalidRequest", - "description": "The actor DID, subjects array, subject AT-URI, or another query parameter is invalid." + "description": "The actor DID, subjects array, subject AT-URI, request-target length, or another query parameter is invalid." }, { "name": "ActorReactionsQueryFailed", @@ -1595,7 +1595,7 @@ "name": "subjects", "in": "query", "required": true, - "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.", + "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 16,384 bytes.", "schema": { "type": "array", "minItems": 1, @@ -1629,7 +1629,7 @@ "x-lexicon-errors": [ { "name": "InvalidRequest", - "description": "The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry." + "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": "ReactionCountQueryFailed", @@ -1779,7 +1779,7 @@ "tags": [ "app.certified.graph" ], - "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": [ { "name": "actor", @@ -1906,7 +1906,7 @@ "name": "subjects", "in": "query", "required": true, - "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 16,384 bytes.", "schema": { "type": "array", "minItems": 1, @@ -1956,7 +1956,7 @@ "tags": [ "app.certified.graph" ], - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": [ { "name": "actor", @@ -4011,7 +4011,7 @@ "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.", + "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 16,384 bytes.", "schema": { "type": "array", "minItems": 1, diff --git a/docs/sources/lexicons/app.certified.feed.getActorReactions.json b/docs/sources/lexicons/app.certified.feed.getActorReactions.json index 99c8460..c42ad5c 100644 --- a/docs/sources/lexicons/app.certified.feed.getActorReactions.json +++ b/docs/sources/lexicons/app.certified.feed.getActorReactions.json @@ -19,7 +19,7 @@ }, "subjects": { "type": "array", - "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.", + "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order. The encoded GET request-target must not exceed 16,384 bytes.", "minLength": 1, "maxLength": 100, "items": { @@ -39,7 +39,7 @@ "errors": [ { "name": "InvalidRequest", - "description": "The actor DID, subjects array, subject AT-URI, or another query parameter is invalid." + "description": "The actor DID, subjects array, subject AT-URI, request-target length, or another query parameter is invalid." }, { "name": "ActorReactionsQueryFailed", diff --git a/docs/sources/lexicons/app.certified.feed.getReactionCounts.json b/docs/sources/lexicons/app.certified.feed.getReactionCounts.json index d669fa1..3070a4f 100644 --- a/docs/sources/lexicons/app.certified.feed.getReactionCounts.json +++ b/docs/sources/lexicons/app.certified.feed.getReactionCounts.json @@ -15,7 +15,7 @@ "type": "array", "minLength": 1, "maxLength": 100, - "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.", + "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 16,384 bytes.", "items": { "type": "string", "format": "at-uri" @@ -33,7 +33,7 @@ "errors": [ { "name": "InvalidRequest", - "description": "The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry." + "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": "ReactionCountQueryFailed", diff --git a/docs/sources/lexicons/app.certified.graph.getEntityFollows.json b/docs/sources/lexicons/app.certified.graph.getEntityFollows.json index 02695b0..7ea9cbe 100644 --- a/docs/sources/lexicons/app.certified.graph.getEntityFollows.json +++ b/docs/sources/lexicons/app.certified.graph.getEntityFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": { "type": "params", "required": [ diff --git a/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json b/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json index 2eb2e09..f974d59 100644 --- a/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json +++ b/docs/sources/lexicons/app.certified.graph.getFollowerCounts.json @@ -15,7 +15,7 @@ "type": "array", "minLength": 1, "maxLength": 100, - "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 16,384 bytes.", "items": { "type": "string", "format": "uri", diff --git a/docs/sources/lexicons/app.certified.graph.getFollows.json b/docs/sources/lexicons/app.certified.graph.getFollows.json index 311cbf4..b6cb125 100644 --- a/docs/sources/lexicons/app.certified.graph.getFollows.json +++ b/docs/sources/lexicons/app.certified.graph.getFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": { "type": "params", "required": [ diff --git a/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json b/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json index 1782c77..d05920e 100644 --- a/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json +++ b/docs/sources/lexicons/org.hypercerts.context.getCommentCounts.json @@ -15,7 +15,7 @@ "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.", + "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 16,384 bytes.", "items": { "type": "string", "format": "at-uri", diff --git a/happyview-modules/README.md b/happyview-modules/README.md index a6f1a81..3244632 100644 --- a/happyview-modules/README.md +++ b/happyview-modules/README.md @@ -8,7 +8,7 @@ Use the [`docs/` endpoint explorer](../docs/) and generated [OpenAPI reference]( ## Graph follow capability -`modules/actor-follow/manifest.json` provides `app.certified.graph.getFollows` for checking up to 100 subject DIDs against one actor. `modules/entity-follow/manifest.json` provides `app.certified.graph.getEntityFollows` for checking up to 100 entity AT-URIs against one actor. Supply targets as repeated query parameters; both queries preserve every input position and duplicate, return the original target beside its raw nullable representative, and reject the whole request when any target is invalid. The encoded GET request-target is bounded to 7,000 bytes. A null follow means no indexed relationship; database failures remain errors. +`modules/actor-follow/manifest.json` provides `app.certified.graph.getFollows` for checking up to 100 subject DIDs against one actor. `modules/entity-follow/manifest.json` provides `app.certified.graph.getEntityFollows` for checking up to 100 entity AT-URIs against one actor. Supply targets as repeated query parameters; both queries preserve every input position and duplicate, return the original target beside its raw nullable representative, and reject the whole request when any target is invalid. The encoded GET request-target is bounded to 16,384 bytes. A null follow means no indexed relationship; database failures remain errors. `modules/follower-counts/manifest.json` provides `app.certified.graph.getFollowerCounts`, which counts followers for 1–100 subjects in one statement. Subjects mix account DIDs, counted from `app.certified.graph.follow`, and entity AT-URIs, counted from `app.certified.graph.entityFollow`. Each count is the number of distinct indexed publishers, which equals the `totalCount` of `listActorFollowers` or `listEntityFollowers` for that subject. Results keep input order and duplicates, and unfollowed subjects count zero. @@ -18,7 +18,7 @@ Use the [`docs/` endpoint explorer](../docs/) and generated [OpenAPI reference]( 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 list queries return raw reaction records without subject hydration. `getActorReactions` looks up one actor's like and repost across 1–100 subject AT-URIs, preserving input order and duplicates; each raw reaction view retains its record URI, and a missing indexed reaction is null. Query failures return errors, not nulls. The four list queries use bounded `createdAt`/URI cursor pagination; the batch lookup is not paginated. -`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. +`getReactionCounts` and `getActorReactions` accept 1–100 repeated, unbracketed `subjects` values within the same 16,384-byte encoded request-target bound. `getReactionCounts` 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 diff --git a/happyview-modules/lexicons/app.certified.feed.getActorReactions.json b/happyview-modules/lexicons/app.certified.feed.getActorReactions.json index ef78589..0a266d3 100644 --- a/happyview-modules/lexicons/app.certified.feed.getActorReactions.json +++ b/happyview-modules/lexicons/app.certified.feed.getActorReactions.json @@ -12,7 +12,7 @@ "actor": { "type": "string", "format": "did", "description": "Repository-owner DID of the actor whose reactions to look up." }, "subjects": { "type": "array", - "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order.", + "description": "One or more full record AT-URIs with DID authorities, not handle authorities, supplied as repeated unbracketed query values. Results preserve each occurrence and its input order. The encoded GET request-target must not exceed 16,384 bytes.", "minLength": 1, "maxLength": 100, "items": { "type": "string", "format": "at-uri" } @@ -21,7 +21,7 @@ }, "output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } }, "errors": [ - { "name": "InvalidRequest", "description": "The actor DID, subjects array, subject AT-URI, or another query parameter is invalid." }, + { "name": "InvalidRequest", "description": "The actor DID, subjects array, subject AT-URI, request-target length, or another query parameter is invalid." }, { "name": "ActorReactionsQueryFailed", "description": "The indexed actor reactions could not be queried or returned a malformed result; retry the request." } ] }, diff --git a/happyview-modules/lexicons/app.certified.feed.getReactionCounts.json b/happyview-modules/lexicons/app.certified.feed.getReactionCounts.json index cc93887..437ccc9 100644 --- a/happyview-modules/lexicons/app.certified.feed.getReactionCounts.json +++ b/happyview-modules/lexicons/app.certified.feed.getReactionCounts.json @@ -13,7 +13,7 @@ "type": "array", "minLength": 1, "maxLength": 100, - "description": "Pass 1–100 full DID-authority subject record AT-URIs as repeated, unbracketed subjects query parameters.", + "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 16,384 bytes.", "items": { "type": "string", "format": "at-uri" } } } @@ -25,7 +25,7 @@ "errors": [ { "name": "InvalidRequest", - "description": "The subjects list is empty, has more than 100 entries, or contains an invalid subject AT-URI; correct the list and retry." + "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": "ReactionCountQueryFailed", diff --git a/happyview-modules/lexicons/app.certified.graph.getEntityFollows.json b/happyview-modules/lexicons/app.certified.graph.getEntityFollows.json index 6dfc8d3..9f59013 100644 --- a/happyview-modules/lexicons/app.certified.graph.getEntityFollows.json +++ b/happyview-modules/lexicons/app.certified.graph.getEntityFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": { "type": "params", "required": ["actor", "entities"], diff --git a/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json b/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json index a866c71..4cf04f1 100644 --- a/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json +++ b/happyview-modules/lexicons/app.certified.graph.getFollowerCounts.json @@ -13,7 +13,7 @@ "type": "array", "minLength": 1, "maxLength": 100, - "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 7,000 bytes.", + "description": "Pass 1–100 account DIDs or full DID-authority entity AT-URIs, mixed freely, as repeated unbracketed subjects query parameters. The encoded GET request-target must not exceed 16,384 bytes.", "items": { "type": "string", "format": "uri", "maxLength": 8192 } } } diff --git a/happyview-modules/lexicons/app.certified.graph.getFollows.json b/happyview-modules/lexicons/app.certified.graph.getFollows.json index ce7b8c1..c7906aa 100644 --- a/happyview-modules/lexicons/app.certified.graph.getFollows.json +++ b/happyview-modules/lexicons/app.certified.graph.getFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes.", + "description": "Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 16,384 bytes.", "parameters": { "type": "params", "required": ["actor", "subjects"], diff --git a/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json b/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json index 3dbe218..d1a49e6 100644 --- a/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json +++ b/happyview-modules/lexicons/org.hypercerts.context.getCommentCounts.json @@ -13,7 +13,7 @@ "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.", + "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 16,384 bytes.", "items": { "type": "string", "format": "at-uri", "maxLength": 8192 } } } diff --git a/packages/api/src/lexicons/app/certified/graph/getEntityFollows.defs.ts b/packages/api/src/lexicons/app/certified/graph/getEntityFollows.defs.ts index 3a9a72a..007b9a1 100644 --- a/packages/api/src/lexicons/app/certified/graph/getEntityFollows.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/getEntityFollows.defs.ts @@ -20,7 +20,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 7,000 bytes. */ +/** Returns the representative raw entity-follow record, or null, for each requested actor-to-entity relationship. Authentication is not required. Supply 1–100 repeated entities values; the encoded GET request-target must not exceed 16,384 bytes. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","EntityFollowQueryFailed"]); export { main }; diff --git a/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts b/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts index 50923d5..79f5bdc 100644 --- a/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts +++ b/packages/api/src/lexicons/app/certified/graph/getFollows.defs.ts @@ -20,7 +20,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 7,000 bytes. */ +/** Returns the representative raw follow record, or null, for each requested actor-to-actor relationship. Authentication is not required. Supply 1–100 repeated subjects values; the encoded GET request-target must not exceed 16,384 bytes. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","ActorFollowQueryFailed"]); export { main }; From 7ad8bbafbd53a05a3c19f91015cd00e30b0c0242 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Nov 2026 17:14:54 +0600 Subject: [PATCH 4/4] changeset: batch request-target limit raised to 16 KiB --- .changeset/batch-request-target-16kb.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/batch-request-target-16kb.md diff --git a/.changeset/batch-request-target-16kb.md b/.changeset/batch-request-target-16kb.md new file mode 100644 index 0000000..d16d01f --- /dev/null +++ b/.changeset/batch-request-target-16kb.md @@ -0,0 +1,8 @@ +--- +'@hypercerts-org/happyview-modules': patch +'@hypercerts-org/api': patch +--- + +Batch queries now accept 100 typical record links in one request. + +The limit on the encoded request URL path and query for `getFollows`, `getEntityFollows`, `getFollowerCounts` and `getCommentCounts` rises from 7,000 to 16,384 bytes. `getReactionCounts` and `getActorReactions` now enforce the same limit. Requests over the limit are still rejected with an `InvalidRequest` message, returned as HTTP 500 by the current HappyView release.