diff --git a/.agents/skills/hypercerts-api/references/reliability.md b/.agents/skills/hypercerts-api/references/reliability.md index 5536f82..1672e69 100644 --- a/.agents/skills/hypercerts-api/references/reliability.md +++ b/.agents/skills/hypercerts-api/references/reliability.md @@ -8,6 +8,8 @@ A handle is a resolvable actor identifier, not a DID or record URI. `app.certified.actor.getProfile` accepts a DID or handle and uses the configured handle resolver; the declared contract does not claim independent DID-document verification. Batch profile/organization lookups take DIDs. +The API's shared DID syntax check permits `%` without requiring a following pair of hexadecimal digits, so a value such as `did:example:abc%ZZ` passes DID syntax validation. When sending such a DID in a query string, use `URLSearchParams` to encode it as query data; see [request construction](request-patterns.md#build-one-public-xrpc-get). The shared syntax also permits empty internal colon-separated segments (for example, `did:example:a::b`), including for EVM-link identifiers; a trailing colon remains invalid. + ### Exact collection item versions `org.hypercerts.collection.listCollectionItems` resolves each embedded item's strong reference by exact URI **and** CID. If the requested CID is unavailable, the item's `record` can be null; do not silently substitute a newer record at the same URI. Nested collections are summaries, not recursive expansion. By contrast, collection discovery filters such as `itemUris` and `tagUris` currently match AT-URIs without selecting a CID version. See [recipes.md](recipes.md#load-exact-collection-item-versions) and the operation's details in the bundled OpenAPI contract before relying on exact-version behavior. diff --git a/.changeset/broad-did-validation.md b/.changeset/broad-did-validation.md new file mode 100644 index 0000000..eb5cf3a --- /dev/null +++ b/.changeset/broad-did-validation.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Query endpoints now accept DID values consistently, including percent characters that are not followed by two hexadecimal digits. EVM-link queries also accept otherwise-valid DIDs with empty colon-separated segments. diff --git a/api/lua/endpoints/getAcknowledgement.lua b/api/lua/endpoints/getAcknowledgement.lua index dd78256..753566a 100644 --- a/api/lua/endpoints/getAcknowledgement.lua +++ b/api/lua/endpoints/getAcknowledgement.lua @@ -44,28 +44,6 @@ local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" local NULL = json.decode("null") -local generic_valid_did = valid_did -local generic_valid_record_uri = valid_record_uri - -local function acknowledgement_valid_did(value) - if not generic_valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then break end - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^0-9A-Fa-f]") then return false end - position = percent + 3 - end - return true -end - -valid_record_uri = function(value) - local valid, collection, authority = generic_valid_record_uri(value) - if not valid or not acknowledgement_valid_did(authority) then return false end - return true, collection -end - local function acknowledgement_query(sql, values) if db.backend() ~= "postgres" then error("AcknowledgementQueryFailed: acknowledgement API requires PostgreSQL", 0) diff --git a/api/lua/endpoints/getContribution.lua b/api/lua/endpoints/getContribution.lua index f497413..27d505f 100644 --- a/api/lua/endpoints/getContribution.lua +++ b/api/lua/endpoints/getContribution.lua @@ -83,25 +83,10 @@ end local CONTRIBUTION = "org.hypercerts.claim.contribution" -local function valid_contribution_did(value) - if type(value) ~= "string" or not valid_did(value) then return false end - local index = 1 - while index <= #value do - if value:sub(index, index) == "%" then - local escape = value:sub(index + 1, index + 2) - if #escape ~= 2 or escape:find("[^0-9A-Fa-f]") then return false end - index = index + 3 - else - index = index + 1 - end - end - return true -end - local function valid_contribution_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= CONTRIBUTION then return false end - return valid_contribution_did(authority) + return valid_did(authority) end local function contribution_query(sql, values) diff --git a/api/lua/endpoints/getContributorInformation.lua b/api/lua/endpoints/getContributorInformation.lua index 112181d..f17d8b9 100644 --- a/api/lua/endpoints/getContributorInformation.lua +++ b/api/lua/endpoints/getContributorInformation.lua @@ -41,22 +41,10 @@ end local CONTRIBUTOR_INFORMATION_COLLECTION = "org.hypercerts.claim.contributorInformation" -local function contributor_information_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - position = percent + 3 - end -end - local function contributor_information_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end - return contributor_information_did(authority) + return valid_did(authority) end local NULL = json.decode("null") diff --git a/api/lua/endpoints/getEvmLink.lua b/api/lua/endpoints/getEvmLink.lua index 1199ed5..9f8e759 100644 --- a/api/lua/endpoints/getEvmLink.lua +++ b/api/lua/endpoints/getEvmLink.lua @@ -53,40 +53,12 @@ end local EVMLINK_COLLECTION = "app.certified.link.evm" -local function valid_evm_did(value) - if type(value) ~= "string" or #value > 2048 then return false end - if not valid_did(value) then return false end - local method, specific = value:match("^did:([a-z]+):(.+)$") - if not method or not specific then return false end - - local segment_length, index = 0, 1 - while index <= #specific do - local char = specific:sub(index, index) - if char == ":" then - if segment_length == 0 then return false end - segment_length = 0 - index = index + 1 - elseif char == "%" then - local escape = specific:sub(index + 1, index + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - segment_length = segment_length + 1 - index = index + 3 - elseif char:match("^[%w._%-]$") then - segment_length = segment_length + 1 - index = index + 1 - else - return false - end - end - return segment_length > 0 -end - local function valid_evm_link_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local valid, collection = valid_record_uri(value) if not valid or collection ~= EVMLINK_COLLECTION then return false end local authority = value:match("^at://([^/]+)/") - return valid_evm_did(authority) + return valid_did(authority) end local function evm_link_record_view(row) diff --git a/api/lua/endpoints/getFeature.lua b/api/lua/endpoints/getFeature.lua index 5e74d29..0f81b04 100644 --- a/api/lua/endpoints/getFeature.lua +++ b/api/lua/endpoints/getFeature.lua @@ -41,23 +41,11 @@ end local FEATURE_COLLECTION = "org.hypercerts.entity.feature" -local function feature_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local escape_start = value:find("%", position, true) - if not escape_start then return true end - local escape = value:sub(escape_start + 1, escape_start + 2) - if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end - position = escape_start + 3 - end -end - local function feature_valid_uri(value) if type(value) ~= "string" or #value > 8192 then return false end local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= FEATURE_COLLECTION then return false end - return feature_valid_did(authority) + return valid_did(authority) end local FEATURE_PROJECTION_PROFILE = "app.certified.actor.profile" diff --git a/api/lua/endpoints/getMeasurement.lua b/api/lua/endpoints/getMeasurement.lua index 835b0a3..4513a0e 100644 --- a/api/lua/endpoints/getMeasurement.lua +++ b/api/lua/endpoints/getMeasurement.lua @@ -84,22 +84,8 @@ end local MEASUREMENT = "org.hypercerts.context.measurement" local MEASUREMENT_NULL = json.decode("null") -local function measurement_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local hex = value:sub(percent + 1, percent + 2) - if #hex ~= 2 or hex:find("[^0-9A-Fa-f]") then return false end - position = percent + 3 - end -end - local function measurement_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - if not valid then return false end - return measurement_valid_did(authority), collection + return valid_record_uri(value) end local function measurement_query(sql, values) diff --git a/api/lua/endpoints/getOrganization.lua b/api/lua/endpoints/getOrganization.lua index 17bc393..6a3c467 100644 --- a/api/lua/endpoints/getOrganization.lua +++ b/api/lua/endpoints/getOrganization.lua @@ -27,19 +27,8 @@ local function scalar(params, key) return tostring(value) end -local PROFILE = "app.certified.actor.profile" -local ORGANIZATION = "app.certified.actor.organization" local NULL = json.decode("null") -local function query(sql, values) - if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok or type(result) ~= "table" then - error("OrganizationQueryFailed: organization lookup failed", 0) - end - return result -end - local function record_view(row) return { uri = row.uri, @@ -50,6 +39,18 @@ local function record_view(row) } end +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function query(sql, values) + if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("OrganizationQueryFailed: organization lookup failed", 0) + end + return result +end + local function organization_actor_view(row) return { did = row.did, diff --git a/api/lua/endpoints/getOrganizations.lua b/api/lua/endpoints/getOrganizations.lua index 64a3f16..21b9539 100644 --- a/api/lua/endpoints/getOrganizations.lua +++ b/api/lua/endpoints/getOrganizations.lua @@ -27,19 +27,8 @@ local function scalar(params, key) return tostring(value) end -local PROFILE = "app.certified.actor.profile" -local ORGANIZATION = "app.certified.actor.organization" local NULL = json.decode("null") -local function query(sql, values) - if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok or type(result) ~= "table" then - error("OrganizationQueryFailed: organization lookup failed", 0) - end - return result -end - local function record_view(row) return { uri = row.uri, @@ -50,6 +39,18 @@ local function record_view(row) } end +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function query(sql, values) + if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("OrganizationQueryFailed: organization lookup failed", 0) + end + return result +end + local function organization_actor_view(row) return { did = row.did, @@ -85,18 +86,6 @@ local function hydrate_organization_actors(actors) end end -local function valid_organization_batch_did(value) - if not valid_did(value) then return false end - - local percent = value:find("%", 1, true) - while percent do - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - percent = value:find("%", percent + 3, true) - end - return true -end - local function requested_organization_actors(value) if value == nil then invalid("actors is required") end @@ -124,7 +113,7 @@ local function requested_organization_actors(value) local unique, seen = {}, {} for _, did in ipairs(actors) do - if not valid_organization_batch_did(did) then invalid("each actors value must be a valid DID") end + if not valid_did(did) then invalid("each actors value must be a valid DID") end if not seen[did] then seen[did] = true unique[#unique + 1] = did diff --git a/api/lua/endpoints/getProfile.lua b/api/lua/endpoints/getProfile.lua index 5f23ab3..53a1714 100644 --- a/api/lua/endpoints/getProfile.lua +++ b/api/lua/endpoints/getProfile.lua @@ -27,17 +27,9 @@ local function scalar(params, key) return tostring(value) end -local PROFILE = "app.certified.actor.profile" local NULL = json.decode("null") -local function query(sql, values) - if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end - return result -end - -local function row_view(row) +local function record_view(row) return { uri = row.uri, cid = row.cid, @@ -47,6 +39,15 @@ local function row_view(row) } end +local PROFILE = "app.certified.actor.profile" + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + local function valid_handle(value) if type(value) ~= "string" or #value > 253 or value:find("[^%w%.%-]") or value:find("%.%.") or value:sub(1, 1) == "." or value:sub(-1) == "." then @@ -173,25 +174,10 @@ local function resolver_base_url() return "https://" .. authority end -local function valid_resolver_did(value) - if not valid_did(value) then return false end - local index = 1 - while index <= #value do - if value:sub(index, index) == "%" then - local escape = value:sub(index + 1, index + 2) - if #escape ~= 2 or not escape:match("^%x%x$") then return false end - index = index + 3 - else - index = index + 1 - end - end - return true -end - local function resolve_handle(handle) local url = resolver_base_url() .. "/xrpc/com.atproto.identity.resolveHandle?handle=" .. encode_query_value(handle) local resolved = fetch_json(url) - if type(resolved.did) ~= "string" or not valid_resolver_did(resolved.did) then fail_resolution() end + if type(resolved.did) ~= "string" or not valid_did(resolved.did) then fail_resolution() end return resolved.did end @@ -214,7 +200,7 @@ local function get_profile() "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND did = $2 AND rkey = 'self' LIMIT 1", { PROFILE, did }) if #rows == 0 then error("RecordNotFound: profile is not indexed", 0) end - return { profile = row_view(rows[1]) } + return { profile = record_view(rows[1]) } end function handle() diff --git a/api/lua/endpoints/getProfiles.lua b/api/lua/endpoints/getProfiles.lua index 0fd2bf4..2d1dc78 100644 --- a/api/lua/endpoints/getProfiles.lua +++ b/api/lua/endpoints/getProfiles.lua @@ -27,17 +27,9 @@ local function scalar(params, key) return tostring(value) end -local PROFILE = "app.certified.actor.profile" local NULL = json.decode("null") -local function query(sql, values) - if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end - return result -end - -local function row_view(row) +local function record_view(row) return { uri = row.uri, cid = row.cid, @@ -47,16 +39,13 @@ local function row_view(row) } end -local function valid_profile_batch_did(value) - if not valid_did(value) then return false end +local PROFILE = "app.certified.actor.profile" - local percent = value:find("%", 1, true) - while percent do - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - percent = value:find("%", percent + 3, true) - end - return true +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result end local function requested_actors(value) @@ -78,7 +67,7 @@ local function requested_actors(value) local unique, seen = {}, {} for _, did in ipairs(actors) do - if not valid_profile_batch_did(did) then invalid("each actors value must be a valid DID") end + if not valid_did(did) then invalid("each actors value must be a valid DID") end if not seen[did] then seen[did] = true unique[#unique + 1] = did @@ -102,7 +91,7 @@ local function get_profiles() local rows = query(sql, values) local profiles_by_did = {} - for _, row in ipairs(rows) do profiles_by_did[row.did] = row_view(row) end + for _, row in ipairs(rows) do profiles_by_did[row.did] = record_view(row) end local null = json.decode("null") local profiles = {} diff --git a/api/lua/endpoints/getRights.lua b/api/lua/endpoints/getRights.lua index 170ff1c..ddf40e1 100644 --- a/api/lua/endpoints/getRights.lua +++ b/api/lua/endpoints/getRights.lua @@ -39,23 +39,8 @@ local function valid_record_uri(value) return true, collection, authority end -local function rights_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local first = value:sub(percent + 1, percent + 1) - local second = value:sub(percent + 2, percent + 2) - if not first:match("^[0-9A-Fa-f]$") or not second:match("^[0-9A-Fa-f]$") then return false end - position = percent + 3 - end -end - local function rights_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - if not valid then return false end - return rights_valid_did(authority), collection + return valid_record_uri(value) end local NULL = json.decode("null") diff --git a/api/lua/endpoints/getVocabTag.lua b/api/lua/endpoints/getVocabTag.lua index e1e4519..5bf9c23 100644 --- a/api/lua/endpoints/getVocabTag.lua +++ b/api/lua/endpoints/getVocabTag.lua @@ -83,21 +83,10 @@ end local VOCAB_TAG = "org.hypercerts.vocab.tag" -local function valid_vocab_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - if not value:sub(percent + 1, percent + 2):match("^%x%x$") then return false end - position = percent + 3 - end -end - local function valid_vocab_tag_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= VOCAB_TAG then return false end - return valid_vocab_did(authority) + return valid_did(authority) end local function hydrate_vocab_actor_views(actors, run_query) diff --git a/api/lua/endpoints/getWorkscopeTag.lua b/api/lua/endpoints/getWorkscopeTag.lua index 76b50ac..a8b9cd9 100644 --- a/api/lua/endpoints/getWorkscopeTag.lua +++ b/api/lua/endpoints/getWorkscopeTag.lua @@ -85,21 +85,8 @@ end local WORKSCOPE_TAG = "org.hypercerts.workscope.tag" local WORKSCOPE_TAG_NULL = json.decode("null") -local function workscope_tag_valid_did(value) - if not valid_did(value) then return false end - local offset = 1 - while true do - local percent = value:find("%", offset, true) - if not percent then return true end - local escape = value:sub(percent + 1, percent + 2) - if not escape:match("^[0-9A-Fa-f][0-9A-Fa-f]$") then return false end - offset = percent + 3 - end -end - local function workscope_tag_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - return valid and workscope_tag_valid_did(authority), collection + return valid_record_uri(value) end local function workscope_tag_query(sql, values) diff --git a/api/lua/endpoints/listAcknowledgements.lua b/api/lua/endpoints/listAcknowledgements.lua index 27e3e13..3da756e 100644 --- a/api/lua/endpoints/listAcknowledgements.lua +++ b/api/lua/endpoints/listAcknowledgements.lua @@ -75,28 +75,6 @@ local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" local NULL = json.decode("null") -local generic_valid_did = valid_did -local generic_valid_record_uri = valid_record_uri - -local function acknowledgement_valid_did(value) - if not generic_valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then break end - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^0-9A-Fa-f]") then return false end - position = percent + 3 - end - return true -end - -valid_record_uri = function(value) - local valid, collection, authority = generic_valid_record_uri(value) - if not valid or not acknowledgement_valid_did(authority) then return false end - return true, collection -end - local function acknowledgement_query(sql, values) if db.backend() ~= "postgres" then error("AcknowledgementQueryFailed: acknowledgement API requires PostgreSQL", 0) @@ -210,7 +188,7 @@ local function acknowledgement_array(key, format) local unique, seen = {}, {} for _, item in ipairs(supplied) do - if format == "did" and not acknowledgement_valid_did(item) then + if format == "did" and not valid_did(item) then invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") elseif format == "at-uri" then local valid, collection = valid_record_uri(item) diff --git a/api/lua/endpoints/listActivities.lua b/api/lua/endpoints/listActivities.lua index 6e46bd6..0023c12 100644 --- a/api/lua/endpoints/listActivities.lua +++ b/api/lua/endpoints/listActivities.lua @@ -234,6 +234,70 @@ local function hydrate_activity_views(views) return activity_projection_hydrate_views(views) end +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function activity_scalar(values, key) if key == "hasOrganizationRecord" and type(values[key]) == "boolean" then return tostring(values[key]) @@ -283,40 +347,6 @@ local function activity_array(key, format) return unique end -local function integer_limit() - local value = scalar(params, "limit") - if value == nil then return 25 end - if not value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end - local number = tonumber(value) - if not number or number < 1 or number > 100 then - invalid("limit must be an integer from 1 through 100") - end - return number -end - -local function valid_activity_datetime(value) - if type(value) ~= "string" then return false end - local year, month, day, hour, minute, second, suffix = value:match( - "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if not year then return false end - year, month, day = tonumber(year), tonumber(month), tonumber(day) - hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) - if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end - local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) - local days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } - if day < 1 or day > days[month] then return false end - local fraction, zone = suffix:match("^(%.%d+)(Z)$") - if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end - if not fraction then zone = suffix:match("^(Z)$") end - if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end - if not zone or zone == "-00:00" then return false end - if zone ~= "Z" then - local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") - if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end - end - return true -end - local function decode_activity_cursor(token, direction) if token == nil then return nil end if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then @@ -332,7 +362,7 @@ local function decode_activity_cursor(token, direction) if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end end local valid, collection = valid_record_uri(value.u) - if not valid_activity_datetime(value.t) or not valid or collection ~= ACTIVITY then + if not valid_datetime(value.t) or not valid or collection ~= ACTIVITY then invalid("cursor is malformed") end return value @@ -370,15 +400,6 @@ local function activity_contributor_match(values, dids) "OR substring(" .. information_identifier .. " from '^at://([^/]+)/') IN (" .. set .. ")))" end -local function activity_sort_expression() - local created = "activity.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - return "CASE WHEN jsonb_typeof(activity.record::jsonb->'createdAt') = 'string' AND " .. created .. - " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. - ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. - "COALESCE(activity.indexed_at::timestamptz, activity.created_at::timestamptz) END" -end - local function activity_query(authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) local where, values = { "activity.collection = $1" }, { ACTIVITY } if authors then @@ -432,7 +453,7 @@ local function activity_query(authors, has_organization_record, contributors, in local sql = "SELECT activity.uri, activity.did, activity.cid, activity.indexed_at::text AS indexed_at, " .. "activity.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. - "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. activity_sort_expression() .. " AS sort_at) AS sorted " .. + "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. created_at_sort_expression("activity") .. " AS sort_at) AS sorted " .. "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. ", activity.uri " .. ordering .. " LIMIT $" .. #values local rows = query(sql, values) @@ -488,7 +509,7 @@ local function activity_list_response(search_enabled) end local direction = scalar(params, "sortDirection") or "desc" if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end - local limit = integer_limit() + local limit = parse_list_limit(params) local cursor = decode_activity_cursor(scalar(params, "cursor"), direction) local activities, next_cursor = activity_query( authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) diff --git a/api/lua/endpoints/listCollections.lua b/api/lua/endpoints/listCollections.lua index 5dcfcc8..a222f76 100644 --- a/api/lua/endpoints/listCollections.lua +++ b/api/lua/endpoints/listCollections.lua @@ -252,6 +252,62 @@ local function collection_hydrate(views, omit_invalid) return collection_projection_hydrate(views, omit_invalid) end +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function collection_array(key, kind) local value = params[key] if value == nil then return nil end @@ -317,29 +373,6 @@ local function collection_list_limit() return limit end -local function collection_valid_datetime(value) - if type(value) ~= "string" then return false end - local year, month, day, hour, minute, second, suffix = value:match( - "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if not year then return false end - year, month, day = tonumber(year), tonumber(month), tonumber(day) - hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) - if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end - local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) - local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } - if day < 1 or day > month_days[month] then return false end - local fraction, zone = suffix:match("^(%.%d+)(Z)$") - if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end - if not fraction then zone = suffix:match("^(Z)$") end - if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end - if not zone or zone == "-00:00" then return false end - if zone ~= "Z" then - local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") - if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end - end - return true -end - local function collection_list_cursor_decode(token, direction) if token == nil then return nil end if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then @@ -357,7 +390,7 @@ local function collection_list_cursor_decode(token, direction) end end local valid, collection = collection_valid_record_uri(value.u) - if not collection_valid_datetime(value.t) or not valid or collection ~= COLLECTION then + if not valid_datetime(value.t) or not valid or collection ~= COLLECTION then collection_invalid("cursor is malformed") end return value @@ -454,12 +487,7 @@ end local function collection_list_sql(where, values, limit, direction) values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "collection.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(collection.record::jsonb->'createdAt') = 'string' AND " .. created .. - " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. - ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. - "COALESCE(collection.indexed_at::timestamptz, collection.created_at::timestamptz) END" + local sort_key = created_at_sort_expression("collection") return "SELECT collection.uri, collection.did, collection.cid, collection.indexed_at::text AS indexed_at, " .. "collection.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. diff --git a/api/lua/endpoints/listContributions.lua b/api/lua/endpoints/listContributions.lua index 9cc61a2..e87fb11 100644 --- a/api/lua/endpoints/listContributions.lua +++ b/api/lua/endpoints/listContributions.lua @@ -125,25 +125,10 @@ end local CONTRIBUTION = "org.hypercerts.claim.contribution" -local function valid_contribution_did(value) - if type(value) ~= "string" or not valid_did(value) then return false end - local index = 1 - while index <= #value do - if value:sub(index, index) == "%" then - local escape = value:sub(index + 1, index + 2) - if #escape ~= 2 or escape:find("[^0-9A-Fa-f]") then return false end - index = index + 3 - else - index = index + 1 - end - end - return true -end - local function valid_contribution_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= CONTRIBUTION then return false end - return valid_contribution_did(authority) + return valid_did(authority) end local function contribution_query(sql, values) @@ -192,7 +177,7 @@ local function contribution_authors() local authors, seen = {}, {} for _, did in ipairs(supplied) do - if not valid_contribution_did(did) then invalid("each authors value must be a valid DID; resolve handles to DIDs first") end + if not valid_did(did) then invalid("each authors value must be a valid DID; resolve handles to DIDs first") end if not seen[did] then seen[did] = true authors[#authors + 1] = did diff --git a/api/lua/endpoints/listContributorInformation.lua b/api/lua/endpoints/listContributorInformation.lua index 4e3d486..235bdd3 100644 --- a/api/lua/endpoints/listContributorInformation.lua +++ b/api/lua/endpoints/listContributorInformation.lua @@ -41,22 +41,10 @@ end local CONTRIBUTOR_INFORMATION_COLLECTION = "org.hypercerts.claim.contributorInformation" -local function contributor_information_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - position = percent + 3 - end -end - local function contributor_information_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end - return contributor_information_did(authority) + return valid_did(authority) end local NULL = json.decode("null") @@ -303,7 +291,7 @@ function handle() limit = true, cursor = true, }) - local authors = contributor_information_array(params, "authors", contributor_information_did, "valid DIDs") + local authors = contributor_information_array(params, "authors", valid_did, "valid DIDs") local limit = parse_list_limit(params) local direction = parse_sort_direction(params) local cursor = contributor_information_cursor_decode(scalar(params, "cursor"), direction) diff --git a/api/lua/endpoints/listEvmLinks.lua b/api/lua/endpoints/listEvmLinks.lua index 58e2c1d..b190ba8 100644 --- a/api/lua/endpoints/listEvmLinks.lua +++ b/api/lua/endpoints/listEvmLinks.lua @@ -95,40 +95,12 @@ end local EVMLINK_COLLECTION = "app.certified.link.evm" -local function valid_evm_did(value) - if type(value) ~= "string" or #value > 2048 then return false end - if not valid_did(value) then return false end - local method, specific = value:match("^did:([a-z]+):(.+)$") - if not method or not specific then return false end - - local segment_length, index = 0, 1 - while index <= #specific do - local char = specific:sub(index, index) - if char == ":" then - if segment_length == 0 then return false end - segment_length = 0 - index = index + 1 - elseif char == "%" then - local escape = specific:sub(index + 1, index + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - segment_length = segment_length + 1 - index = index + 3 - elseif char:match("^[%w._%-]$") then - segment_length = segment_length + 1 - index = index + 1 - else - return false - end - end - return segment_length > 0 -end - local function valid_evm_link_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local valid, collection = valid_record_uri(value) if not valid or collection ~= EVMLINK_COLLECTION then return false end local authority = value:match("^at://([^/]+)/") - return valid_evm_did(authority) + return valid_did(authority) end local function evm_link_record_view(row) @@ -320,7 +292,7 @@ function handle() limit = true, cursor = true, }) - local actors = array_values("actors", valid_evm_did, "valid DIDs") + local actors = array_values("actors", valid_did, "valid DIDs") local addresses = array_values("addresses", valid_evm_address, "a 0x-prefixed 40-digit hexadecimal EVM address", string.lower) local limit = parse_list_limit(params) local direction = parse_sort_direction(params) diff --git a/api/lua/endpoints/listFeatures.lua b/api/lua/endpoints/listFeatures.lua index 4044785..7362e5b 100644 --- a/api/lua/endpoints/listFeatures.lua +++ b/api/lua/endpoints/listFeatures.lua @@ -83,23 +83,11 @@ end local FEATURE_COLLECTION = "org.hypercerts.entity.feature" -local function feature_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local escape_start = value:find("%", position, true) - if not escape_start then return true end - local escape = value:sub(escape_start + 1, escape_start + 2) - if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end - position = escape_start + 3 - end -end - local function feature_valid_uri(value) if type(value) ~= "string" or #value > 8192 then return false end local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= FEATURE_COLLECTION then return false end - return feature_valid_did(authority) + return valid_did(authority) end local FEATURE_PROJECTION_PROFILE = "app.certified.actor.profile" @@ -334,7 +322,7 @@ local function list_features() limit = true, cursor = true, }) - local authors = feature_array(params, "authors", feature_valid_did, "valid DIDs") + local authors = feature_array(params, "authors", valid_did, "valid DIDs") local types = feature_array(params, "types", nil, nil, 64) local has_organization_record = feature_parse_organization_filter(params) local limit = parse_list_limit(params) diff --git a/api/lua/endpoints/listMeasurements.lua b/api/lua/endpoints/listMeasurements.lua index c59f7d3..85f0d62 100644 --- a/api/lua/endpoints/listMeasurements.lua +++ b/api/lua/endpoints/listMeasurements.lua @@ -126,22 +126,8 @@ end local MEASUREMENT = "org.hypercerts.context.measurement" local MEASUREMENT_NULL = json.decode("null") -local function measurement_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local hex = value:sub(percent + 1, percent + 2) - if #hex ~= 2 or hex:find("[^0-9A-Fa-f]") then return false end - position = percent + 3 - end -end - local function measurement_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - if not valid then return false end - return measurement_valid_did(authority), collection + return valid_record_uri(value) end local function measurement_query(sql, values) @@ -226,7 +212,7 @@ local function measurement_array(key, format) local unique, seen = {}, {} for _, item in ipairs(supplied) do - if format == "did" and not measurement_valid_did(item) then + if format == "did" and not valid_did(item) then invalid("each " .. key .. " value must be a valid DID") elseif format == "at-uri" then local valid, collection = measurement_valid_record_uri(item) diff --git a/api/lua/endpoints/listOrganizations.lua b/api/lua/endpoints/listOrganizations.lua index f3e1df4..6a44f5b 100644 --- a/api/lua/endpoints/listOrganizations.lua +++ b/api/lua/endpoints/listOrganizations.lua @@ -58,19 +58,19 @@ local function parse_list_limit(params) return limit end -local PROFILE = "app.certified.actor.profile" -local ORGANIZATION = "app.certified.actor.organization" -local NULL = json.decode("null") +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end -local function query(sql, values) - if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok or type(result) ~= "table" then - error("OrganizationQueryFailed: organization lookup failed", 0) - end - return result +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) end +local NULL = json.decode("null") + local function record_view(row) return { uri = row.uri, @@ -81,6 +81,18 @@ local function record_view(row) } end +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function query(sql, values) + if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("OrganizationQueryFailed: organization lookup failed", 0) + end + return result +end + local function organization_actor_view(row) return { did = row.did, @@ -116,6 +128,39 @@ local function hydrate_organization_actors(actors) end end +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function valid_organization_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") @@ -184,12 +229,6 @@ local function add_organization_types(where, values, organization_types) ") AS organization_type(value) WHERE organization_type.value IN (" .. table.concat(placeholders, ", ") .. "))" end -local function cursor_encode(value) - return (json.encode(value):gsub(".", function(char) - return string.format("%02x", string.byte(char)) - end)) -end - local function cursor_decode(token, direction) if token == nil then return nil end if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end @@ -236,11 +275,7 @@ local function query_organizations(actors, organization_types, visibility, searc values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "organization.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(organization.record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. - "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. - ")::timestamptz ELSE COALESCE(organization.indexed_at::timestamptz, organization.created_at::timestamptz) END" + local sort_key = created_at_sort_expression("organization") local sql = "SELECT organization.uri, organization.did, organization.cid, organization.indexed_at::text AS indexed_at, " .. "organization.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM happyview_records AS organization CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted WHERE " .. @@ -290,8 +325,7 @@ local function organizations_response(search_enabled) local limit = parse_list_limit(params) - local direction = scalar(params, "sortDirection") or "desc" - if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local direction = parse_sort_direction(params) local cursor = cursor_decode(scalar(params, "cursor"), direction) local actors_result, next_cursor = query_organizations(actors, organization_types, visibility, search, limit, cursor, direction) local response = { actors = toarray(actors_result) } diff --git a/api/lua/endpoints/listProfiles.lua b/api/lua/endpoints/listProfiles.lua index dc0cb01..63e2177 100644 --- a/api/lua/endpoints/listProfiles.lua +++ b/api/lua/endpoints/listProfiles.lua @@ -58,17 +58,20 @@ local function parse_list_limit(params) return limit end -local PROFILE = "app.certified.actor.profile" -local NULL = json.decode("null") +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end -local function query(sql, values) - if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end - return result +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) end -local function row_view(row) +local NULL = json.decode("null") + +local function record_view(row) return { uri = row.uri, cid = row.cid, @@ -78,6 +81,48 @@ local function row_view(row) } end +local PROFILE = "app.certified.actor.profile" + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function valid_profile_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") @@ -117,11 +162,6 @@ local function add_actors(where, values, actors) where[#where + 1] = "did IN (" .. table.concat(placeholders, ", ") .. ")" end -local function cursor_encode(value) - local encoded = json.encode(value) - return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) -end - local function cursor_decode(token, direction) if not token then return nil end if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end @@ -158,11 +198,7 @@ local function query_profiles(actors, search, limit, cursor, direction) values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned - .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created - .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" + local sort_key = created_at_sort_expression("profile") local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, " .. "to_char(sorted.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM happyview_records CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted WHERE " @@ -172,7 +208,7 @@ local function query_profiles(actors, search, limit, cursor, direction) if more then rows[#rows] = nil end local profiles = {} - for _, row in ipairs(rows) do profiles[#profiles + 1] = row_view(row) end + for _, row in ipairs(rows) do profiles[#profiles + 1] = record_view(row) end local next_cursor if more then local last = rows[#rows] @@ -200,8 +236,7 @@ local function profiles_response(search_enabled) local limit = parse_list_limit(params) - local direction = scalar(params, "sortDirection") or "desc" - if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local direction = parse_sort_direction(params) local cursor = cursor_decode(scalar(params, "cursor"), direction) local profiles, next_cursor = query_profiles(actors, search, limit, cursor, direction) local response = { profiles = toarray(profiles) } diff --git a/api/lua/endpoints/listRights.lua b/api/lua/endpoints/listRights.lua index be42f62..f3c9599 100644 --- a/api/lua/endpoints/listRights.lua +++ b/api/lua/endpoints/listRights.lua @@ -39,23 +39,8 @@ local function valid_record_uri(value) return true, collection, authority end -local function rights_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local first = value:sub(percent + 1, percent + 1) - local second = value:sub(percent + 2, percent + 2) - if not first:match("^[0-9A-Fa-f]$") or not second:match("^[0-9A-Fa-f]$") then return false end - position = percent + 3 - end -end - local function rights_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - if not valid then return false end - return rights_valid_did(authority), collection + return valid_record_uri(value) end local NULL = json.decode("null") @@ -195,7 +180,7 @@ local function rights_list_array(value) local unique, seen = {}, {} for _, did in ipairs(supplied) do - if not rights_valid_did(did) then invalid("each authors value must be a valid DID") end + if not valid_did(did) then invalid("each authors value must be a valid DID") end if not seen[did] then seen[did] = true unique[#unique + 1] = did diff --git a/api/lua/endpoints/listVocabTags.lua b/api/lua/endpoints/listVocabTags.lua index 2364228..f977072 100644 --- a/api/lua/endpoints/listVocabTags.lua +++ b/api/lua/endpoints/listVocabTags.lua @@ -125,21 +125,10 @@ end local VOCAB_TAG = "org.hypercerts.vocab.tag" -local function valid_vocab_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - if not value:sub(percent + 1, percent + 2):match("^%x%x$") then return false end - position = percent + 3 - end -end - local function valid_vocab_tag_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= VOCAB_TAG then return false end - return valid_vocab_did(authority) + return valid_did(authority) end local function hydrate_vocab_actor_views(actors, run_query) @@ -184,7 +173,7 @@ local function parse_authors(value) local unique, seen = {}, {} for _, author in ipairs(supplied) do - if not valid_vocab_did(author) then invalid("each authors value must be a valid DID") end + if not valid_did(author) then invalid("each authors value must be a valid DID") end if not seen[author] then seen[author] = true unique[#unique + 1] = author diff --git a/api/lua/endpoints/listWorkscopeTags.lua b/api/lua/endpoints/listWorkscopeTags.lua index 642d014..d772fc3 100644 --- a/api/lua/endpoints/listWorkscopeTags.lua +++ b/api/lua/endpoints/listWorkscopeTags.lua @@ -127,21 +127,8 @@ end local WORKSCOPE_TAG = "org.hypercerts.workscope.tag" local WORKSCOPE_TAG_NULL = json.decode("null") -local function workscope_tag_valid_did(value) - if not valid_did(value) then return false end - local offset = 1 - while true do - local percent = value:find("%", offset, true) - if not percent then return true end - local escape = value:sub(percent + 1, percent + 2) - if not escape:match("^[0-9A-Fa-f][0-9A-Fa-f]$") then return false end - offset = percent + 3 - end -end - local function workscope_tag_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - return valid and workscope_tag_valid_did(authority), collection + return valid_record_uri(value) end local function workscope_tag_query(sql, values) @@ -193,7 +180,7 @@ local function workscope_tag_array(key) local unique, seen = {}, {} for _, did in ipairs(supplied) do - if not workscope_tag_valid_did(did) then + if not valid_did(did) then invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") end if not seen[did] then diff --git a/api/lua/endpoints/searchActivities.lua b/api/lua/endpoints/searchActivities.lua index 35731d3..58e4fcc 100644 --- a/api/lua/endpoints/searchActivities.lua +++ b/api/lua/endpoints/searchActivities.lua @@ -234,6 +234,70 @@ local function hydrate_activity_views(views) return activity_projection_hydrate_views(views) end +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function activity_scalar(values, key) if key == "hasOrganizationRecord" and type(values[key]) == "boolean" then return tostring(values[key]) @@ -283,40 +347,6 @@ local function activity_array(key, format) return unique end -local function integer_limit() - local value = scalar(params, "limit") - if value == nil then return 25 end - if not value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end - local number = tonumber(value) - if not number or number < 1 or number > 100 then - invalid("limit must be an integer from 1 through 100") - end - return number -end - -local function valid_activity_datetime(value) - if type(value) ~= "string" then return false end - local year, month, day, hour, minute, second, suffix = value:match( - "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if not year then return false end - year, month, day = tonumber(year), tonumber(month), tonumber(day) - hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) - if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end - local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) - local days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } - if day < 1 or day > days[month] then return false end - local fraction, zone = suffix:match("^(%.%d+)(Z)$") - if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end - if not fraction then zone = suffix:match("^(Z)$") end - if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end - if not zone or zone == "-00:00" then return false end - if zone ~= "Z" then - local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") - if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end - end - return true -end - local function decode_activity_cursor(token, direction) if token == nil then return nil end if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then @@ -332,7 +362,7 @@ local function decode_activity_cursor(token, direction) if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end end local valid, collection = valid_record_uri(value.u) - if not valid_activity_datetime(value.t) or not valid or collection ~= ACTIVITY then + if not valid_datetime(value.t) or not valid or collection ~= ACTIVITY then invalid("cursor is malformed") end return value @@ -370,15 +400,6 @@ local function activity_contributor_match(values, dids) "OR substring(" .. information_identifier .. " from '^at://([^/]+)/') IN (" .. set .. ")))" end -local function activity_sort_expression() - local created = "activity.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - return "CASE WHEN jsonb_typeof(activity.record::jsonb->'createdAt') = 'string' AND " .. created .. - " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. - ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. - "COALESCE(activity.indexed_at::timestamptz, activity.created_at::timestamptz) END" -end - local function activity_query(authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) local where, values = { "activity.collection = $1" }, { ACTIVITY } if authors then @@ -432,7 +453,7 @@ local function activity_query(authors, has_organization_record, contributors, in local sql = "SELECT activity.uri, activity.did, activity.cid, activity.indexed_at::text AS indexed_at, " .. "activity.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. - "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. activity_sort_expression() .. " AS sort_at) AS sorted " .. + "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. created_at_sort_expression("activity") .. " AS sort_at) AS sorted " .. "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. ", activity.uri " .. ordering .. " LIMIT $" .. #values local rows = query(sql, values) @@ -488,7 +509,7 @@ local function activity_list_response(search_enabled) end local direction = scalar(params, "sortDirection") or "desc" if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end - local limit = integer_limit() + local limit = parse_list_limit(params) local cursor = decode_activity_cursor(scalar(params, "cursor"), direction) local activities, next_cursor = activity_query( authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) diff --git a/api/lua/endpoints/searchCollections.lua b/api/lua/endpoints/searchCollections.lua index 2ce65e5..3d58454 100644 --- a/api/lua/endpoints/searchCollections.lua +++ b/api/lua/endpoints/searchCollections.lua @@ -252,6 +252,62 @@ local function collection_hydrate(views, omit_invalid) return collection_projection_hydrate(views, omit_invalid) end +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function collection_array(key, kind) local value = params[key] if value == nil then return nil end @@ -317,29 +373,6 @@ local function collection_list_limit() return limit end -local function collection_valid_datetime(value) - if type(value) ~= "string" then return false end - local year, month, day, hour, minute, second, suffix = value:match( - "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if not year then return false end - year, month, day = tonumber(year), tonumber(month), tonumber(day) - hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) - if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end - local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) - local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } - if day < 1 or day > month_days[month] then return false end - local fraction, zone = suffix:match("^(%.%d+)(Z)$") - if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end - if not fraction then zone = suffix:match("^(Z)$") end - if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end - if not zone or zone == "-00:00" then return false end - if zone ~= "Z" then - local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") - if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end - end - return true -end - local function collection_list_cursor_decode(token, direction) if token == nil then return nil end if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then @@ -357,7 +390,7 @@ local function collection_list_cursor_decode(token, direction) end end local valid, collection = collection_valid_record_uri(value.u) - if not collection_valid_datetime(value.t) or not valid or collection ~= COLLECTION then + if not valid_datetime(value.t) or not valid or collection ~= COLLECTION then collection_invalid("cursor is malformed") end return value @@ -454,12 +487,7 @@ end local function collection_list_sql(where, values, limit, direction) values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "collection.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(collection.record::jsonb->'createdAt') = 'string' AND " .. created .. - " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. - ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. - "COALESCE(collection.indexed_at::timestamptz, collection.created_at::timestamptz) END" + local sort_key = created_at_sort_expression("collection") return "SELECT collection.uri, collection.did, collection.cid, collection.indexed_at::text AS indexed_at, " .. "collection.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. diff --git a/api/lua/endpoints/searchOrganizations.lua b/api/lua/endpoints/searchOrganizations.lua index 791525a..634b80a 100644 --- a/api/lua/endpoints/searchOrganizations.lua +++ b/api/lua/endpoints/searchOrganizations.lua @@ -58,19 +58,19 @@ local function parse_list_limit(params) return limit end -local PROFILE = "app.certified.actor.profile" -local ORGANIZATION = "app.certified.actor.organization" -local NULL = json.decode("null") +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end -local function query(sql, values) - if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok or type(result) ~= "table" then - error("OrganizationQueryFailed: organization lookup failed", 0) - end - return result +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) end +local NULL = json.decode("null") + local function record_view(row) return { uri = row.uri, @@ -81,6 +81,18 @@ local function record_view(row) } end +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function query(sql, values) + if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("OrganizationQueryFailed: organization lookup failed", 0) + end + return result +end + local function organization_actor_view(row) return { did = row.did, @@ -116,6 +128,39 @@ local function hydrate_organization_actors(actors) end end +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function valid_organization_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") @@ -184,12 +229,6 @@ local function add_organization_types(where, values, organization_types) ") AS organization_type(value) WHERE organization_type.value IN (" .. table.concat(placeholders, ", ") .. "))" end -local function cursor_encode(value) - return (json.encode(value):gsub(".", function(char) - return string.format("%02x", string.byte(char)) - end)) -end - local function cursor_decode(token, direction) if token == nil then return nil end if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end @@ -236,11 +275,7 @@ local function query_organizations(actors, organization_types, visibility, searc values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "organization.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(organization.record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. - "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. - ")::timestamptz ELSE COALESCE(organization.indexed_at::timestamptz, organization.created_at::timestamptz) END" + local sort_key = created_at_sort_expression("organization") local sql = "SELECT organization.uri, organization.did, organization.cid, organization.indexed_at::text AS indexed_at, " .. "organization.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM happyview_records AS organization CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted WHERE " .. @@ -290,8 +325,7 @@ local function organizations_response(search_enabled) local limit = parse_list_limit(params) - local direction = scalar(params, "sortDirection") or "desc" - if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local direction = parse_sort_direction(params) local cursor = cursor_decode(scalar(params, "cursor"), direction) local actors_result, next_cursor = query_organizations(actors, organization_types, visibility, search, limit, cursor, direction) local response = { actors = toarray(actors_result) } diff --git a/api/lua/endpoints/searchProfiles.lua b/api/lua/endpoints/searchProfiles.lua index 45c553c..f1c8dd3 100644 --- a/api/lua/endpoints/searchProfiles.lua +++ b/api/lua/endpoints/searchProfiles.lua @@ -58,17 +58,20 @@ local function parse_list_limit(params) return limit end -local PROFILE = "app.certified.actor.profile" -local NULL = json.decode("null") +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end -local function query(sql, values) - if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end - local ok, result = pcall(db.raw, sql, values) - if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end - return result +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) end -local function row_view(row) +local NULL = json.decode("null") + +local function record_view(row) return { uri = row.uri, cid = row.cid, @@ -78,6 +81,48 @@ local function row_view(row) } end +local PROFILE = "app.certified.actor.profile" + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end + local function valid_profile_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") @@ -117,11 +162,6 @@ local function add_actors(where, values, actors) where[#where + 1] = "did IN (" .. table.concat(placeholders, ", ") .. ")" end -local function cursor_encode(value) - local encoded = json.encode(value) - return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) -end - local function cursor_decode(token, direction) if not token then return nil end if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end @@ -158,11 +198,7 @@ local function query_profiles(actors, search, limit, cursor, direction) values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned - .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created - .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" + local sort_key = created_at_sort_expression("profile") local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, " .. "to_char(sorted.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM happyview_records CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted WHERE " @@ -172,7 +208,7 @@ local function query_profiles(actors, search, limit, cursor, direction) if more then rows[#rows] = nil end local profiles = {} - for _, row in ipairs(rows) do profiles[#profiles + 1] = row_view(row) end + for _, row in ipairs(rows) do profiles[#profiles + 1] = record_view(row) end local next_cursor if more then local last = rows[#rows] @@ -200,8 +236,7 @@ local function profiles_response(search_enabled) local limit = parse_list_limit(params) - local direction = scalar(params, "sortDirection") or "desc" - if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local direction = parse_sort_direction(params) local cursor = cursor_decode(scalar(params, "cursor"), direction) local profiles, next_cursor = query_profiles(actors, search, limit, cursor, direction) local response = { profiles = toarray(profiles) } diff --git a/api/lua/shared/acknowledgement.lua b/api/lua/shared/acknowledgement.lua index 78b9c4e..361409d 100644 --- a/api/lua/shared/acknowledgement.lua +++ b/api/lua/shared/acknowledgement.lua @@ -3,28 +3,6 @@ local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" local NULL = json.decode("null") -local generic_valid_did = valid_did -local generic_valid_record_uri = valid_record_uri - -local function acknowledgement_valid_did(value) - if not generic_valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then break end - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^0-9A-Fa-f]") then return false end - position = percent + 3 - end - return true -end - -valid_record_uri = function(value) - local valid, collection, authority = generic_valid_record_uri(value) - if not valid or not acknowledgement_valid_did(authority) then return false end - return true, collection -end - local function acknowledgement_query(sql, values) if db.backend() ~= "postgres" then error("AcknowledgementQueryFailed: acknowledgement API requires PostgreSQL", 0) diff --git a/api/lua/shared/acknowledgementList.lua b/api/lua/shared/acknowledgementList.lua index d929e2d..d57fec0 100644 --- a/api/lua/shared/acknowledgementList.lua +++ b/api/lua/shared/acknowledgementList.lua @@ -56,7 +56,7 @@ local function acknowledgement_array(key, format) local unique, seen = {}, {} for _, item in ipairs(supplied) do - if format == "did" and not acknowledgement_valid_did(item) then + if format == "did" and not valid_did(item) then invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") elseif format == "at-uri" then local valid, collection = valid_record_uri(item) diff --git a/api/lua/shared/activityList.lua b/api/lua/shared/activityList.lua index a9984d8..6814477 100644 --- a/api/lua/shared/activityList.lua +++ b/api/lua/shared/activityList.lua @@ -47,40 +47,6 @@ local function activity_array(key, format) return unique end -local function integer_limit() - local value = scalar(params, "limit") - if value == nil then return 25 end - if not value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end - local number = tonumber(value) - if not number or number < 1 or number > 100 then - invalid("limit must be an integer from 1 through 100") - end - return number -end - -local function valid_activity_datetime(value) - if type(value) ~= "string" then return false end - local year, month, day, hour, minute, second, suffix = value:match( - "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if not year then return false end - year, month, day = tonumber(year), tonumber(month), tonumber(day) - hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) - if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end - local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) - local days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } - if day < 1 or day > days[month] then return false end - local fraction, zone = suffix:match("^(%.%d+)(Z)$") - if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end - if not fraction then zone = suffix:match("^(Z)$") end - if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end - if not zone or zone == "-00:00" then return false end - if zone ~= "Z" then - local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") - if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end - end - return true -end - local function decode_activity_cursor(token, direction) if token == nil then return nil end if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then @@ -96,7 +62,7 @@ local function decode_activity_cursor(token, direction) if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end end local valid, collection = valid_record_uri(value.u) - if not valid_activity_datetime(value.t) or not valid or collection ~= ACTIVITY then + if not valid_datetime(value.t) or not valid or collection ~= ACTIVITY then invalid("cursor is malformed") end return value @@ -134,15 +100,6 @@ local function activity_contributor_match(values, dids) "OR substring(" .. information_identifier .. " from '^at://([^/]+)/') IN (" .. set .. ")))" end -local function activity_sort_expression() - local created = "activity.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - return "CASE WHEN jsonb_typeof(activity.record::jsonb->'createdAt') = 'string' AND " .. created .. - " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. - ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. - "COALESCE(activity.indexed_at::timestamptz, activity.created_at::timestamptz) END" -end - local function activity_query(authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) local where, values = { "activity.collection = $1" }, { ACTIVITY } if authors then @@ -196,7 +153,7 @@ local function activity_query(authors, has_organization_record, contributors, in local sql = "SELECT activity.uri, activity.did, activity.cid, activity.indexed_at::text AS indexed_at, " .. "activity.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. - "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. activity_sort_expression() .. " AS sort_at) AS sorted " .. + "FROM happyview_records AS activity CROSS JOIN LATERAL (SELECT " .. created_at_sort_expression("activity") .. " AS sort_at) AS sorted " .. "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. ", activity.uri " .. ordering .. " LIMIT $" .. #values local rows = query(sql, values) @@ -252,7 +209,7 @@ local function activity_list_response(search_enabled) end local direction = scalar(params, "sortDirection") or "desc" if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end - local limit = integer_limit() + local limit = parse_list_limit(params) local cursor = decode_activity_cursor(scalar(params, "cursor"), direction) local activities, next_cursor = activity_query( authors, has_organization_record, contributors, involved_actors, uris, search, limit, cursor, direction) diff --git a/api/lua/shared/collectionList.lua b/api/lua/shared/collectionList.lua index 2e847dc..d629527 100644 --- a/api/lua/shared/collectionList.lua +++ b/api/lua/shared/collectionList.lua @@ -63,29 +63,6 @@ local function collection_list_limit() return limit end -local function collection_valid_datetime(value) - if type(value) ~= "string" then return false end - local year, month, day, hour, minute, second, suffix = value:match( - "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if not year then return false end - year, month, day = tonumber(year), tonumber(month), tonumber(day) - hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) - if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end - local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) - local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } - if day < 1 or day > month_days[month] then return false end - local fraction, zone = suffix:match("^(%.%d+)(Z)$") - if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end - if not fraction then zone = suffix:match("^(Z)$") end - if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end - if not zone or zone == "-00:00" then return false end - if zone ~= "Z" then - local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") - if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end - end - return true -end - local function collection_list_cursor_decode(token, direction) if token == nil then return nil end if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then @@ -103,7 +80,7 @@ local function collection_list_cursor_decode(token, direction) end end local valid, collection = collection_valid_record_uri(value.u) - if not collection_valid_datetime(value.t) or not valid or collection ~= COLLECTION then + if not valid_datetime(value.t) or not valid or collection ~= COLLECTION then collection_invalid("cursor is malformed") end return value @@ -200,12 +177,7 @@ end local function collection_list_sql(where, values, limit, direction) values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "collection.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(collection.record::jsonb->'createdAt') = 'string' AND " .. created .. - " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. - ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. - "COALESCE(collection.indexed_at::timestamptz, collection.created_at::timestamptz) END" + local sort_key = created_at_sort_expression("collection") return "SELECT collection.uri, collection.did, collection.cid, collection.indexed_at::text AS indexed_at, " .. "collection.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', " .. "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. diff --git a/api/lua/shared/contribution.lua b/api/lua/shared/contribution.lua index 8447131..2379ad9 100644 --- a/api/lua/shared/contribution.lua +++ b/api/lua/shared/contribution.lua @@ -1,24 +1,9 @@ local CONTRIBUTION = "org.hypercerts.claim.contribution" -local function valid_contribution_did(value) - if type(value) ~= "string" or not valid_did(value) then return false end - local index = 1 - while index <= #value do - if value:sub(index, index) == "%" then - local escape = value:sub(index + 1, index + 2) - if #escape ~= 2 or escape:find("[^0-9A-Fa-f]") then return false end - index = index + 3 - else - index = index + 1 - end - end - return true -end - local function valid_contribution_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= CONTRIBUTION then return false end - return valid_contribution_did(authority) + return valid_did(authority) end local function contribution_query(sql, values) diff --git a/api/lua/shared/contributorInformationValidation.lua b/api/lua/shared/contributorInformationValidation.lua index 9a8058c..47487ba 100644 --- a/api/lua/shared/contributorInformationValidation.lua +++ b/api/lua/shared/contributorInformationValidation.lua @@ -1,19 +1,7 @@ local CONTRIBUTOR_INFORMATION_COLLECTION = "org.hypercerts.claim.contributorInformation" -local function contributor_information_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - position = percent + 3 - end -end - local function contributor_information_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end - return contributor_information_did(authority) + return valid_did(authority) end diff --git a/api/lua/shared/createdAtSort.lua b/api/lua/shared/createdAtSort.lua new file mode 100644 index 0000000..171bef7 --- /dev/null +++ b/api/lua/shared/createdAtSort.lua @@ -0,0 +1,32 @@ +local CREATED_AT_SORT_COLUMNS = { + profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, + organization = { + record = "organization.record", + indexed_at = "organization.indexed_at", + created_at = "organization.created_at", + }, + activity = { + record = "activity.record", + indexed_at = "activity.indexed_at", + created_at = "activity.created_at", + }, + collection = { + record = "collection.record", + indexed_at = "collection.indexed_at", + created_at = "collection.created_at", + }, +} +local CREATED_AT_SORT_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + +local function created_at_sort_expression(source) + local columns = CREATED_AT_SORT_COLUMNS[source] + if not columns then + error("CreatedAtSortExpressionError: source must be 'profile', 'organization', 'activity', or 'collection'", 0) + end + + local created = columns.record .. "::jsonb->>'createdAt'" + return "CASE WHEN jsonb_typeof(" .. columns.record .. "::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. CREATED_AT_SORT_PATTERN .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE COALESCE(" .. columns.indexed_at .. + "::timestamptz, " .. columns.created_at .. "::timestamptz) END" +end diff --git a/api/lua/shared/datetimeValidation.lua b/api/lua/shared/datetimeValidation.lua new file mode 100644 index 0000000..598169b --- /dev/null +++ b/api/lua/shared/datetimeValidation.lua @@ -0,0 +1,22 @@ +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end diff --git a/api/lua/shared/evmLinkIdentifier.lua b/api/lua/shared/evmLinkIdentifier.lua index 34f1639..89cd72a 100644 --- a/api/lua/shared/evmLinkIdentifier.lua +++ b/api/lua/shared/evmLinkIdentifier.lua @@ -1,39 +1,11 @@ local EVMLINK_COLLECTION = "app.certified.link.evm" -local function valid_evm_did(value) - if type(value) ~= "string" or #value > 2048 then return false end - if not valid_did(value) then return false end - local method, specific = value:match("^did:([a-z]+):(.+)$") - if not method or not specific then return false end - - local segment_length, index = 0, 1 - while index <= #specific do - local char = specific:sub(index, index) - if char == ":" then - if segment_length == 0 then return false end - segment_length = 0 - index = index + 1 - elseif char == "%" then - local escape = specific:sub(index + 1, index + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - segment_length = segment_length + 1 - index = index + 3 - elseif char:match("^[%w._%-]$") then - segment_length = segment_length + 1 - index = index + 1 - else - return false - end - end - return segment_length > 0 -end - local function valid_evm_link_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local valid, collection = valid_record_uri(value) if not valid or collection ~= EVMLINK_COLLECTION then return false end local authority = value:match("^at://([^/]+)/") - return valid_evm_did(authority) + return valid_did(authority) end local function evm_link_record_view(row) diff --git a/api/lua/shared/featureValidation.lua b/api/lua/shared/featureValidation.lua index 2228988..617f252 100644 --- a/api/lua/shared/featureValidation.lua +++ b/api/lua/shared/featureValidation.lua @@ -1,20 +1,8 @@ local FEATURE_COLLECTION = "org.hypercerts.entity.feature" -local function feature_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local escape_start = value:find("%", position, true) - if not escape_start then return true end - local escape = value:sub(escape_start + 1, escape_start + 2) - if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end - position = escape_start + 3 - end -end - local function feature_valid_uri(value) if type(value) ~= "string" or #value > 8192 then return false end local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= FEATURE_COLLECTION then return false end - return feature_valid_did(authority) + return valid_did(authority) end diff --git a/api/lua/shared/listValidation.lua b/api/lua/shared/listValidation.lua index 7fb234d..1ca793e 100644 --- a/api/lua/shared/listValidation.lua +++ b/api/lua/shared/listValidation.lua @@ -1,26 +1,3 @@ -local function valid_datetime(value) - if type(value) ~= "string" then return false end - local year, month, day, hour, minute, second, suffix = value:match( - "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if not year then return false end - year, month, day = tonumber(year), tonumber(month), tonumber(day) - hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) - if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end - local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) - local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } - if day < 1 or day > month_days[month] then return false end - local fraction, zone = suffix:match("^(%.%d+)(Z)$") - if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end - if not fraction then zone = suffix:match("^(Z)$") end - if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end - if not zone or zone == "-00:00" then return false end - if zone ~= "Z" then - local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") - if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end - end - return true -end - local function parse_list_limit(params) local limit_value = scalar(params, "limit") if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end diff --git a/api/lua/shared/measurementQuery.lua b/api/lua/shared/measurementQuery.lua index 01b165d..3408b7b 100644 --- a/api/lua/shared/measurementQuery.lua +++ b/api/lua/shared/measurementQuery.lua @@ -1,22 +1,8 @@ local MEASUREMENT = "org.hypercerts.context.measurement" local MEASUREMENT_NULL = json.decode("null") -local function measurement_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local hex = value:sub(percent + 1, percent + 2) - if #hex ~= 2 or hex:find("[^0-9A-Fa-f]") then return false end - position = percent + 3 - end -end - local function measurement_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - if not valid then return false end - return measurement_valid_did(authority), collection + return valid_record_uri(value) end local function measurement_query(sql, values) diff --git a/api/lua/shared/organization.lua b/api/lua/shared/organization.lua index 8ccc4e4..46acd95 100644 --- a/api/lua/shared/organization.lua +++ b/api/lua/shared/organization.lua @@ -1,6 +1,5 @@ local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" -local NULL = json.decode("null") local function query(sql, values) if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end @@ -11,16 +10,6 @@ local function query(sql, values) return result end -local function record_view(row) - return { - uri = row.uri, - cid = row.cid, - indexedAt = row.indexed_at == nil and NULL or row.indexed_at, - did = row.did, - record = json.decode(row.record), - } -end - local function organization_actor_view(row) return { did = row.did, diff --git a/api/lua/shared/organizationList.lua b/api/lua/shared/organizationList.lua index f582d58..c161520 100644 --- a/api/lua/shared/organizationList.lua +++ b/api/lua/shared/organizationList.lua @@ -66,12 +66,6 @@ local function add_organization_types(where, values, organization_types) ") AS organization_type(value) WHERE organization_type.value IN (" .. table.concat(placeholders, ", ") .. "))" end -local function cursor_encode(value) - return (json.encode(value):gsub(".", function(char) - return string.format("%02x", string.byte(char)) - end)) -end - local function cursor_decode(token, direction) if token == nil then return nil end if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end @@ -118,11 +112,7 @@ local function query_organizations(actors, organization_types, visibility, searc values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "organization.record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(organization.record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned .. - "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created .. - ")::timestamptz ELSE COALESCE(organization.indexed_at::timestamptz, organization.created_at::timestamptz) END" + local sort_key = created_at_sort_expression("organization") local sql = "SELECT organization.uri, organization.did, organization.cid, organization.indexed_at::text AS indexed_at, " .. "organization.record::text AS record, to_char(sorted.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM happyview_records AS organization CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted WHERE " .. @@ -172,8 +162,7 @@ local function organizations_response(search_enabled) local limit = parse_list_limit(params) - local direction = scalar(params, "sortDirection") or "desc" - if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local direction = parse_sort_direction(params) local cursor = cursor_decode(scalar(params, "cursor"), direction) local actors_result, next_cursor = query_organizations(actors, organization_types, visibility, search, limit, cursor, direction) local response = { actors = toarray(actors_result) } diff --git a/api/lua/shared/profile.lua b/api/lua/shared/profile.lua index 8b5abd2..59439d5 100644 --- a/api/lua/shared/profile.lua +++ b/api/lua/shared/profile.lua @@ -1,5 +1,4 @@ local PROFILE = "app.certified.actor.profile" -local NULL = json.decode("null") local function query(sql, values) if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end @@ -7,13 +6,3 @@ local function query(sql, values) if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end return result end - -local function row_view(row) - return { - uri = row.uri, - cid = row.cid, - indexedAt = row.indexed_at == nil and NULL or row.indexed_at, - did = row.did, - record = json.decode(row.record), - } -end diff --git a/api/lua/shared/profileList.lua b/api/lua/shared/profileList.lua index 9d75ef1..efb2f15 100644 --- a/api/lua/shared/profileList.lua +++ b/api/lua/shared/profileList.lua @@ -37,11 +37,6 @@ local function add_actors(where, values, actors) where[#where + 1] = "did IN (" .. table.concat(placeholders, ", ") .. ")" end -local function cursor_encode(value) - local encoded = json.encode(value) - return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) -end - local function cursor_decode(token, direction) if not token then return nil end if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end @@ -78,11 +73,7 @@ local function query_profiles(actors, search, limit, cursor, direction) values[#values + 1] = limit + 1 local ordering = direction == "asc" and "ASC" or "DESC" - local created = "record::jsonb->>'createdAt'" - local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" - local sort_key = "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned - .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created - .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" + local sort_key = created_at_sort_expression("profile") local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record, " .. "to_char(sorted.sort_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS sort_timestamp " .. "FROM happyview_records CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted WHERE " @@ -92,7 +83,7 @@ local function query_profiles(actors, search, limit, cursor, direction) if more then rows[#rows] = nil end local profiles = {} - for _, row in ipairs(rows) do profiles[#profiles + 1] = row_view(row) end + for _, row in ipairs(rows) do profiles[#profiles + 1] = record_view(row) end local next_cursor if more then local last = rows[#rows] @@ -120,8 +111,7 @@ local function profiles_response(search_enabled) local limit = parse_list_limit(params) - local direction = scalar(params, "sortDirection") or "desc" - if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local direction = parse_sort_direction(params) local cursor = cursor_decode(scalar(params, "cursor"), direction) local profiles, next_cursor = query_profiles(actors, search, limit, cursor, direction) local response = { profiles = toarray(profiles) } diff --git a/api/lua/shared/profileLookup.lua b/api/lua/shared/profileLookup.lua index 603f9db..75820ef 100644 --- a/api/lua/shared/profileLookup.lua +++ b/api/lua/shared/profileLookup.lua @@ -124,25 +124,10 @@ local function resolver_base_url() return "https://" .. authority end -local function valid_resolver_did(value) - if not valid_did(value) then return false end - local index = 1 - while index <= #value do - if value:sub(index, index) == "%" then - local escape = value:sub(index + 1, index + 2) - if #escape ~= 2 or not escape:match("^%x%x$") then return false end - index = index + 3 - else - index = index + 1 - end - end - return true -end - local function resolve_handle(handle) local url = resolver_base_url() .. "/xrpc/com.atproto.identity.resolveHandle?handle=" .. encode_query_value(handle) local resolved = fetch_json(url) - if type(resolved.did) ~= "string" or not valid_resolver_did(resolved.did) then fail_resolution() end + if type(resolved.did) ~= "string" or not valid_did(resolved.did) then fail_resolution() end return resolved.did end @@ -165,5 +150,5 @@ local function get_profile() "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND did = $2 AND rkey = 'self' LIMIT 1", { PROFILE, did }) if #rows == 0 then error("RecordNotFound: profile is not indexed", 0) end - return { profile = row_view(rows[1]) } + return { profile = record_view(rows[1]) } end diff --git a/api/lua/shared/rightsIdentifier.lua b/api/lua/shared/rightsIdentifier.lua index 787e844..e2dd2d1 100644 --- a/api/lua/shared/rightsIdentifier.lua +++ b/api/lua/shared/rightsIdentifier.lua @@ -1,18 +1,3 @@ -local function rights_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - local first = value:sub(percent + 1, percent + 1) - local second = value:sub(percent + 2, percent + 2) - if not first:match("^[0-9A-Fa-f]$") or not second:match("^[0-9A-Fa-f]$") then return false end - position = percent + 3 - end -end - local function rights_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - if not valid then return false end - return rights_valid_did(authority), collection + return valid_record_uri(value) end diff --git a/api/lua/shared/vocabTagValidation.lua b/api/lua/shared/vocabTagValidation.lua index bd83c85..9864ed0 100644 --- a/api/lua/shared/vocabTagValidation.lua +++ b/api/lua/shared/vocabTagValidation.lua @@ -1,20 +1,9 @@ local VOCAB_TAG = "org.hypercerts.vocab.tag" -local function valid_vocab_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local percent = value:find("%", position, true) - if not percent then return true end - if not value:sub(percent + 1, percent + 2):match("^%x%x$") then return false end - position = percent + 3 - end -end - local function valid_vocab_tag_uri(value) local valid, collection, authority = valid_record_uri(value) if not valid or collection ~= VOCAB_TAG then return false end - return valid_vocab_did(authority) + return valid_did(authority) end local function hydrate_vocab_actor_views(actors, run_query) diff --git a/api/lua/shared/workscopeTag.lua b/api/lua/shared/workscopeTag.lua index 7fc1218..3063ab7 100644 --- a/api/lua/shared/workscopeTag.lua +++ b/api/lua/shared/workscopeTag.lua @@ -1,21 +1,8 @@ local WORKSCOPE_TAG = "org.hypercerts.workscope.tag" local WORKSCOPE_TAG_NULL = json.decode("null") -local function workscope_tag_valid_did(value) - if not valid_did(value) then return false end - local offset = 1 - while true do - local percent = value:find("%", offset, true) - if not percent then return true end - local escape = value:sub(percent + 1, percent + 2) - if not escape:match("^[0-9A-Fa-f][0-9A-Fa-f]$") then return false end - offset = percent + 3 - end -end - local function workscope_tag_valid_record_uri(value) - local valid, collection, authority = valid_record_uri(value) - return valid and workscope_tag_valid_did(authority), collection + return valid_record_uri(value) end local function workscope_tag_query(sql, values) diff --git a/api/lua/shared/workscopeTagList.lua b/api/lua/shared/workscopeTagList.lua index d690bdd..99064f2 100644 --- a/api/lua/shared/workscopeTagList.lua +++ b/api/lua/shared/workscopeTagList.lua @@ -25,7 +25,7 @@ local function workscope_tag_array(key) local unique, seen = {}, {} for _, did in ipairs(supplied) do - if not workscope_tag_valid_did(did) then + if not valid_did(did) then invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") end if not seen[did] then diff --git a/api/lua/src/getOrganizations.lua b/api/lua/src/getOrganizations.lua index d6f876c..16dbf0b 100644 --- a/api/lua/src/getOrganizations.lua +++ b/api/lua/src/getOrganizations.lua @@ -1,15 +1,3 @@ -local function valid_organization_batch_did(value) - if not valid_did(value) then return false end - - local percent = value:find("%", 1, true) - while percent do - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - percent = value:find("%", percent + 3, true) - end - return true -end - local function requested_organization_actors(value) if value == nil then invalid("actors is required") end @@ -37,7 +25,7 @@ local function requested_organization_actors(value) local unique, seen = {}, {} for _, did in ipairs(actors) do - if not valid_organization_batch_did(did) then invalid("each actors value must be a valid DID") end + if not valid_did(did) then invalid("each actors value must be a valid DID") end if not seen[did] then seen[did] = true unique[#unique + 1] = did diff --git a/api/lua/src/getProfiles.lua b/api/lua/src/getProfiles.lua index 58fcde1..792cb41 100644 --- a/api/lua/src/getProfiles.lua +++ b/api/lua/src/getProfiles.lua @@ -1,15 +1,3 @@ -local function valid_profile_batch_did(value) - if not valid_did(value) then return false end - - local percent = value:find("%", 1, true) - while percent do - local escape = value:sub(percent + 1, percent + 2) - if #escape ~= 2 or escape:find("[^%x]") then return false end - percent = value:find("%", percent + 3, true) - end - return true -end - local function requested_actors(value) if value == nil then invalid("actors is required") end @@ -29,7 +17,7 @@ local function requested_actors(value) local unique, seen = {}, {} for _, did in ipairs(actors) do - if not valid_profile_batch_did(did) then invalid("each actors value must be a valid DID") end + if not valid_did(did) then invalid("each actors value must be a valid DID") end if not seen[did] then seen[did] = true unique[#unique + 1] = did @@ -53,7 +41,7 @@ local function get_profiles() local rows = query(sql, values) local profiles_by_did = {} - for _, row in ipairs(rows) do profiles_by_did[row.did] = row_view(row) end + for _, row in ipairs(rows) do profiles_by_did[row.did] = record_view(row) end local null = json.decode("null") local profiles = {} diff --git a/api/lua/src/listContributions.lua b/api/lua/src/listContributions.lua index fa4b73c..6770016 100644 --- a/api/lua/src/listContributions.lua +++ b/api/lua/src/listContributions.lua @@ -25,7 +25,7 @@ local function contribution_authors() local authors, seen = {}, {} for _, did in ipairs(supplied) do - if not valid_contribution_did(did) then invalid("each authors value must be a valid DID; resolve handles to DIDs first") end + if not valid_did(did) then invalid("each authors value must be a valid DID; resolve handles to DIDs first") end if not seen[did] then seen[did] = true authors[#authors + 1] = did diff --git a/api/lua/src/listContributorInformation.lua b/api/lua/src/listContributorInformation.lua index 71e83e9..4c83a0e 100644 --- a/api/lua/src/listContributorInformation.lua +++ b/api/lua/src/listContributorInformation.lua @@ -158,7 +158,7 @@ function handle() limit = true, cursor = true, }) - local authors = contributor_information_array(params, "authors", contributor_information_did, "valid DIDs") + local authors = contributor_information_array(params, "authors", valid_did, "valid DIDs") local limit = parse_list_limit(params) local direction = parse_sort_direction(params) local cursor = contributor_information_cursor_decode(scalar(params, "cursor"), direction) diff --git a/api/lua/src/listEvmLinks.lua b/api/lua/src/listEvmLinks.lua index 301016b..b6d70fa 100644 --- a/api/lua/src/listEvmLinks.lua +++ b/api/lua/src/listEvmLinks.lua @@ -140,7 +140,7 @@ function handle() limit = true, cursor = true, }) - local actors = array_values("actors", valid_evm_did, "valid DIDs") + local actors = array_values("actors", valid_did, "valid DIDs") local addresses = array_values("addresses", valid_evm_address, "a 0x-prefixed 40-digit hexadecimal EVM address", string.lower) local limit = parse_list_limit(params) local direction = parse_sort_direction(params) diff --git a/api/lua/src/listFeatures.lua b/api/lua/src/listFeatures.lua index ff57985..23281b0 100644 --- a/api/lua/src/listFeatures.lua +++ b/api/lua/src/listFeatures.lua @@ -160,7 +160,7 @@ local function list_features() limit = true, cursor = true, }) - local authors = feature_array(params, "authors", feature_valid_did, "valid DIDs") + local authors = feature_array(params, "authors", valid_did, "valid DIDs") local types = feature_array(params, "types", nil, nil, 64) local has_organization_record = feature_parse_organization_filter(params) local limit = parse_list_limit(params) diff --git a/api/lua/src/listMeasurements.lua b/api/lua/src/listMeasurements.lua index 1d142e7..053dc79 100644 --- a/api/lua/src/listMeasurements.lua +++ b/api/lua/src/listMeasurements.lua @@ -45,7 +45,7 @@ local function measurement_array(key, format) local unique, seen = {}, {} for _, item in ipairs(supplied) do - if format == "did" and not measurement_valid_did(item) then + if format == "did" and not valid_did(item) then invalid("each " .. key .. " value must be a valid DID") elseif format == "at-uri" then local valid, collection = measurement_valid_record_uri(item) diff --git a/api/lua/src/listRights.lua b/api/lua/src/listRights.lua index 91f6da3..811b7fb 100644 --- a/api/lua/src/listRights.lua +++ b/api/lua/src/listRights.lua @@ -32,7 +32,7 @@ local function rights_list_array(value) local unique, seen = {}, {} for _, did in ipairs(supplied) do - if not rights_valid_did(did) then invalid("each authors value must be a valid DID") end + if not valid_did(did) then invalid("each authors value must be a valid DID") end if not seen[did] then seen[did] = true unique[#unique + 1] = did diff --git a/api/lua/src/listVocabTags.lua b/api/lua/src/listVocabTags.lua index 79eb9d4..8a670da 100644 --- a/api/lua/src/listVocabTags.lua +++ b/api/lua/src/listVocabTags.lua @@ -31,7 +31,7 @@ local function parse_authors(value) local unique, seen = {}, {} for _, author in ipairs(supplied) do - if not valid_vocab_did(author) then invalid("each authors value must be a valid DID") end + if not valid_did(author) then invalid("each authors value must be a valid DID") end if not seen[author] then seen[author] = true unique[#unique + 1] = author diff --git a/api/modules/acknowledgements/manifest.json b/api/modules/acknowledgements/manifest.json index a578d42..7744398 100644 --- a/api/modules/acknowledgements/manifest.json +++ b/api/modules/acknowledgements/manifest.json @@ -70,6 +70,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/acknowledgement.lua", "../../lua/shared/acknowledgementList.lua" diff --git a/api/modules/activity/manifest.json b/api/modules/activity/manifest.json index 75d6fb8..74627d6 100644 --- a/api/modules/activity/manifest.json +++ b/api/modules/activity/manifest.json @@ -55,6 +55,9 @@ "../../lua/shared/activityProjection.lua", "../../lua/shared/query.lua", "../../lua/shared/activity.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/activityList.lua" ], "config": { @@ -76,6 +79,9 @@ "../../lua/shared/activityProjection.lua", "../../lua/shared/query.lua", "../../lua/shared/activity.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/activityList.lua" ], "config": { diff --git a/api/modules/actor-follow/manifest.json b/api/modules/actor-follow/manifest.json index 978211f..068d8fd 100644 --- a/api/modules/actor-follow/manifest.json +++ b/api/modules/actor-follow/manifest.json @@ -66,6 +66,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", @@ -90,6 +91,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/badge-definitions/manifest.json b/api/modules/badge-definitions/manifest.json index e93b1a2..d22a931 100644 --- a/api/modules/badge-definitions/manifest.json +++ b/api/modules/badge-definitions/manifest.json @@ -58,6 +58,7 @@ "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", "../../lua/shared/badgeDefinitionValidation.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/badge-queries/manifest.json b/api/modules/badge-queries/manifest.json index 74ca9c6..270df27 100644 --- a/api/modules/badge-queries/manifest.json +++ b/api/modules/badge-queries/manifest.json @@ -70,6 +70,7 @@ "../../lua/shared/query.lua", "../../lua/shared/didValidation.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", @@ -108,6 +109,7 @@ "../../lua/shared/query.lua", "../../lua/shared/didValidation.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", @@ -146,6 +148,7 @@ "../../lua/shared/query.lua", "../../lua/shared/didValidation.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/collection/manifest.json b/api/modules/collection/manifest.json index b0aeaef..96cd4bb 100644 --- a/api/modules/collection/manifest.json +++ b/api/modules/collection/manifest.json @@ -41,6 +41,8 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/collectionProjection.lua", "../../lua/shared/collection.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/collectionList.lua" ], "config": { @@ -73,6 +75,8 @@ "../../lua/shared/recordIdentifier.lua", "../../lua/shared/collectionProjection.lua", "../../lua/shared/collection.lua", + "../../lua/shared/datetimeValidation.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/collectionList.lua" ], "config": { diff --git a/api/modules/context-attachments/manifest.json b/api/modules/context-attachments/manifest.json index b4a2b88..60cf5b7 100644 --- a/api/modules/context-attachments/manifest.json +++ b/api/modules/context-attachments/manifest.json @@ -55,6 +55,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/context-evaluation/manifest.json b/api/modules/context-evaluation/manifest.json index ecf3d78..238c085 100644 --- a/api/modules/context-evaluation/manifest.json +++ b/api/modules/context-evaluation/manifest.json @@ -55,6 +55,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/context-measurements/manifest.json b/api/modules/context-measurements/manifest.json index e4a2b56..b640aec 100644 --- a/api/modules/context-measurements/manifest.json +++ b/api/modules/context-measurements/manifest.json @@ -54,6 +54,7 @@ "../../lua/shared/query.lua", "../../lua/shared/didValidation.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/contribution/manifest.json b/api/modules/contribution/manifest.json index dd226d9..ebf4e04 100644 --- a/api/modules/contribution/manifest.json +++ b/api/modules/contribution/manifest.json @@ -39,6 +39,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/contributor-information/manifest.json b/api/modules/contributor-information/manifest.json index 553d8cd..016ae9a 100644 --- a/api/modules/contributor-information/manifest.json +++ b/api/modules/contributor-information/manifest.json @@ -45,6 +45,7 @@ "../../lua/shared/contributorInformationValidation.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua" ], diff --git a/api/modules/entity-follow/manifest.json b/api/modules/entity-follow/manifest.json index fd645b1..0df42f6 100644 --- a/api/modules/entity-follow/manifest.json +++ b/api/modules/entity-follow/manifest.json @@ -69,6 +69,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/entityFollow.lua", @@ -92,6 +93,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/activityProjection.lua", diff --git a/api/modules/evm-links/manifest.json b/api/modules/evm-links/manifest.json index 512890e..56afbfa 100644 --- a/api/modules/evm-links/manifest.json +++ b/api/modules/evm-links/manifest.json @@ -49,6 +49,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/features/manifest.json b/api/modules/features/manifest.json index 58ba1bc..414d9e3 100644 --- a/api/modules/features/manifest.json +++ b/api/modules/features/manifest.json @@ -45,6 +45,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/featureValidation.lua", diff --git a/api/modules/funding/manifest.json b/api/modules/funding/manifest.json index 7513324..343833f 100644 --- a/api/modules/funding/manifest.json +++ b/api/modules/funding/manifest.json @@ -55,6 +55,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua", diff --git a/api/modules/location/manifest.json b/api/modules/location/manifest.json index 982f847..517962c 100644 --- a/api/modules/location/manifest.json +++ b/api/modules/location/manifest.json @@ -55,6 +55,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/organization/manifest.json b/api/modules/organization/manifest.json index 897e034..2b8a802 100644 --- a/api/modules/organization/manifest.json +++ b/api/modules/organization/manifest.json @@ -58,6 +58,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/organization.lua" ], "config": { @@ -76,6 +77,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/organization.lua" ], "config": { @@ -94,8 +96,12 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/organization.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/organizationList.lua" ], "config": { @@ -114,8 +120,12 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/organization.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/organizationList.lua" ], "config": { diff --git a/api/modules/profile/manifest.json b/api/modules/profile/manifest.json index f819a2e..d06a720 100644 --- a/api/modules/profile/manifest.json +++ b/api/modules/profile/manifest.json @@ -60,6 +60,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/profile.lua", "../../lua/shared/profileLookup.lua" ], @@ -79,6 +80,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/profile.lua" ], "config": { @@ -97,8 +99,12 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/profile.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/profileList.lua" ], "config": { @@ -117,8 +123,12 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.lua", "../../lua/shared/profile.lua", + "../../lua/shared/createdAtSort.lua", "../../lua/shared/profileList.lua" ], "config": { diff --git a/api/modules/recent-follows/manifest.json b/api/modules/recent-follows/manifest.json index f86ae00..81f72c5 100644 --- a/api/modules/recent-follows/manifest.json +++ b/api/modules/recent-follows/manifest.json @@ -21,6 +21,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/recentFollows.lua" ], diff --git a/api/modules/rights/manifest.json b/api/modules/rights/manifest.json index 4e63cf9..82d77df 100644 --- a/api/modules/rights/manifest.json +++ b/api/modules/rights/manifest.json @@ -44,6 +44,7 @@ "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua", "../../lua/shared/rightsView.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua" ], diff --git a/api/modules/vocab/manifest.json b/api/modules/vocab/manifest.json index b553416..ff39ec5 100644 --- a/api/modules/vocab/manifest.json +++ b/api/modules/vocab/manifest.json @@ -55,6 +55,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/workscope-tags/manifest.json b/api/modules/workscope-tags/manifest.json index f576254..ddf1048 100644 --- a/api/modules/workscope-tags/manifest.json +++ b/api/modules/workscope-tags/manifest.json @@ -53,6 +53,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/workscopeRecordView.lua", diff --git a/api/tests/http/getContributorInformation.http.test.js b/api/tests/http/getContributorInformation.http.test.js index 78674b9..59f28cc 100644 --- a/api/tests/http/getContributorInformation.http.test.js +++ b/api/tests/http/getContributorInformation.http.test.js @@ -59,12 +59,20 @@ test('getContributorInformation exposes RecordNotFound as the pinned HappyView r assert.match(body.message, /RecordNotFound/); }); -test('getContributorInformation exposes InvalidRequest as the pinned HappyView runtime error', async () => { - const malformedUri = 'at://did:plc:mm%GG/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; - const { response, body } = await getContributorInformation(malformedUri); - assert.equal(response.status, 500, JSON.stringify(body)); - assert.equal(body.error, 'script_error'); - assert.equal(body.errorType, 'runtime'); - assert.equal(body.method, 'org.hypercerts.claim.getContributorInformation'); - assert.match(body.message, /InvalidRequest/); +test('getContributorInformation accepts opaque percent sequences in DIDs and retains DID boundaries', async () => { + const broadUri = 'at://did:plc:mm%GG/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; + const broad = await getContributorInformation(broadUri); + assert.equal(broad.response.status, 500, JSON.stringify(broad.body)); + assert.equal(broad.body.error, 'script_error'); + assert.equal(broad.body.errorType, 'runtime'); + assert.equal(broad.body.method, 'org.hypercerts.claim.getContributorInformation'); + assert.match(broad.body.message, /RecordNotFound/, 'the broad DID must reach exact indexed lookup'); + + const trailingPercentUri = 'at://did:plc:mm%/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; + const invalid = await getContributorInformation(trailingPercentUri); + assert.equal(invalid.response.status, 500, JSON.stringify(invalid.body)); + assert.equal(invalid.body.error, 'script_error'); + assert.equal(invalid.body.errorType, 'runtime'); + assert.equal(invalid.body.method, 'org.hypercerts.claim.getContributorInformation'); + assert.match(invalid.body.message, /InvalidRequest/); }); diff --git a/api/tests/unit/context-measurements/measurements.lua b/api/tests/unit/context-measurements/measurements.lua index 9fbe36a..16b6fb1 100644 --- a/api/tests/unit/context-measurements/measurements.lua +++ b/api/tests/unit/context-measurements/measurements.lua @@ -240,15 +240,19 @@ reset_db() expect_error(function() call_get({ uri = "at://publisher.example/org.hypercerts.context.measurement/3jzfcijpj2z2a" }) end, "InvalidRequest:") expect_error(function() call_get({ uri = "at://did:plc:measurements.test/org.hypercerts.context.evaluation/3jzfcijpj2z2a" }) end, "InvalidRequest:") expect_error(function() call_get({ uri = GET_URI, extra = "not allowed" }) end, "InvalidRequest:") -for _, malformed_did in ipairs({ "did:plc:publisher%ZZ", "did:plc:publisher%A" }) do +for _, broad_did in ipairs({ "did:plc:publisher%ZZ", "did:plc:publisher%A" }) do reset_db() - db.exactRows = { row_a } - expect_error(function() - call_get({ uri = "at://" .. malformed_did .. "/org.hypercerts.context.measurement/3jzfcijpj2z2a" }) - end, "InvalidRequest:") - assert(#db.calls == 0, "malformed DID escapes in a get URI must fail before SQL") + db.exactRows = {} + local broad_uri = "at://" .. broad_did .. "/org.hypercerts.context.measurement/3jzfcijpj2z2a" + expect_error(function() call_get({ uri = broad_uri }) end, "RecordNotFound:") + assert(#db.calls == 1 and db.calls[1].values[2] == broad_uri, "broad DID syntax must reach exact lookup unchanged") end reset_db() +expect_error(function() + call_get({ uri = "at://did:plc:publisher%/org.hypercerts.context.measurement/3jzfcijpj2z2a" }) +end, "InvalidRequest:") +assert(#db.calls == 0, "a trailing percent remains an invalid DID boundary") +reset_db() expect_error(function() call_get({ uri = GET_URI }) end, "RecordNotFound:") reset_db() db.exactRows = { row_a } @@ -275,14 +279,18 @@ assert(rawget(list_with_nil_sidecar_timestamps.author.profile, "indexedAt") == n assert(rawget(list_with_nil_sidecar_timestamps.author.organization, "indexedAt") == nil, "listMeasurements must omit SQL-NULL organization indexedAt") profile_row.indexed_at, organization_row.indexed_at = profile_indexed_at, organization_indexed_at -for _, malformed_did in ipairs({ "did:plc:publisher%ZZ", "did:plc:publisher%A" }) do +for _, broad_did in ipairs({ "did:plc:publisher%ZZ", "did:plc:publisher%A" }) do for _, filters in ipairs({ - { authors = { malformed_did } }, - { subjects = { "at://" .. malformed_did .. "/org.hypercerts.claim.activity/3jzfcijpj2z2d" } }, + { authors = { broad_did } }, + { subjects = { "at://" .. broad_did .. "/org.hypercerts.claim.activity/3jzfcijpj2z2d" } }, }) do reset_db() - expect_error(function() call_list(filters) end, "InvalidRequest:") - assert(#db.calls == 0, "malformed DID escapes in a list filter must fail before SQL") + db.listRows = {} + assert(#call_list(filters).measurements == 0) + local query = db.calls[1] + assert(query ~= nil, "broad DID list filters must reach SQL") + assert(query.values[2] == (filters.authors and broad_did or filters.subjects[1]), + "broad DID filter values must reach SQL unchanged") end end @@ -403,18 +411,22 @@ local year_zero_json = json.encode({ v = 1, d = "asc", t = "0000-01-01T00:00:00. local year_zero_cursor = year_zero_json:gsub(".", function(char) return string.format("%02x", string.byte(char)) end) expect_error(function() call_list({ sortDirection = "asc", cursor = year_zero_cursor }) end, "InvalidRequest:") assert(#db.calls == 0, "year-zero cursor must be rejected before SQL") -for _, malformed_did in ipairs({ "did:plc:publisher%ZZ", "did:plc:publisher%A" }) do - local cursor_json = json.encode({ - v = 1, - d = "asc", - t = "2025-01-01T00:00:00.000000Z", - u = "at://" .. malformed_did .. "/org.hypercerts.context.measurement/3jzfcijpj2z2a", - }) - local cursor = cursor_json:gsub(".", function(char) return string.format("%02x", string.byte(char)) end) - reset_db() - expect_error(function() call_list({ sortDirection = "asc", cursor = cursor }) end, "InvalidRequest:") - assert(#db.calls == 0, "cursor with malformed DID escapes must be rejected before SQL") -end -assert(#db.calls == 0, "invalid list requests must not reach the database") +local broad_cursor_uri = "at://did:plc:publisher%ZZ/org.hypercerts.context.measurement/3jzfcijpj2z2a" +local broad_cursor_json = json.encode({ + v = 1, + d = "asc", + t = "2025-01-01T00:00:00.000000Z", + u = broad_cursor_uri, +}) +local broad_cursor = broad_cursor_json:gsub(".", function(char) return string.format("%02x", string.byte(char)) end) +reset_db() +db.listRows = {} +call_list({ sortDirection = "asc", cursor = broad_cursor }) +assert(#db.calls == 1 and db.calls[1].values[3] == broad_cursor_uri, + "cursor URI with a broad DID must reach the list query unchanged") + +reset_db() +expect_error(function() call_list({ authors = { "did:plc:publisher%" } }) end, "InvalidRequest:") +assert(#db.calls == 0, "trailing percent remains invalid before SQL") print("measurement behavior contracts passed") diff --git a/api/tests/unit/endpoints/rights-edge-lua.test.js b/api/tests/unit/endpoints/rights-edge-lua.test.js index cd8a0ef..004e9cd 100644 --- a/api/tests/unit/endpoints/rights-edge-lua.test.js +++ b/api/tests/unit/endpoints/rights-edge-lua.test.js @@ -17,7 +17,7 @@ async function endpointSources(name) { 'lua/shared/actorView.lua', 'lua/shared/rightsView.lua', ]; - if (name === 'listRights') shared.push('lua/shared/listValidation.lua', 'lua/shared/listQuery.lua'); + if (name === 'listRights') shared.push('lua/shared/datetimeValidation.lua', 'lua/shared/listValidation.lua', 'lua/shared/listQuery.lua'); return Promise.all([...shared, `lua/src/${name}.lua`].map(read)); } @@ -25,7 +25,7 @@ function runLua(program) { execFileSync('lua', ['-'], { input: program, encoding: 'utf8' }); } -test('getRights rejects malformed DID escapes, serializes nullable indexedAt, and reports missing records', async () => { +test('getRights accepts opaque DID percent sequences and retains invalid-DID boundaries', async () => { const sources = await endpointSources('getRights'); const program = [ `local null_value = {} @@ -35,13 +35,16 @@ test('getRights rejects malformed DID escapes, serializes nullable indexedAt, an if value == "rights-record" then return rights_record end error("unexpected JSON input") end }`, - `params = { uri = "at://did:plc:publisher%GG/org.hypercerts.claim.rights/key" }`, - `local query_count = 0 + `local broad_uri = "at://did:plc:publisher%GG/org.hypercerts.claim.rights/key" + params = { uri = broad_uri } + local query_count = 0 + local query_values = {} local record_exists = true db = { backend = function() return "postgres" end, raw = function(sql, values) query_count = query_count + 1 + query_values[query_count] = values if values[1] == "org.hypercerts.claim.rights" then if not record_exists then return {} end return {{ uri = params.uri, did = "did:plc:publisher", cid = "cid", indexed_at = nil, record = "rights-record" }} @@ -50,15 +53,19 @@ test('getRights rejects malformed DID escapes, serializes nullable indexedAt, an end }`, ...sources, - `local ok, err = pcall(handle) - assert(not ok and tostring(err):find("InvalidRequest", 1, true)) - assert(query_count == 0, "malformed percent escapes must fail before DB access") - params.uri = "at://did:plc:publisher/org.hypercerts.claim.rights/key" - local response = handle() + `local response = handle() + assert(response.rights.uri == broad_uri and query_values[1][2] == broad_uri, + "opaque percent sequences in the DID must reach exact lookup unchanged") assert(response.rights.indexedAt == null_value, "SQL NULL is returned as JSON null") assert(response.rights.record.rightsName == "Terms") assert(response.rights.author.profile == null_value) assert(response.rights.author.organization == null_value) + assert(query_count == 3, "exact lookup and sidecar hydration should run") + params.uri = "at://did:plc:publisher%/org.hypercerts.claim.rights/key" + local invalid_ok, invalid_error = pcall(handle) + assert(not invalid_ok and tostring(invalid_error):find("InvalidRequest", 1, true)) + assert(query_count == 3, "trailing percent remains an invalid DID boundary") + params.uri = "at://did:plc:publisher/org.hypercerts.claim.rights/key" record_exists = false local missing_ok, missing_error = pcall(handle) assert(not missing_ok and tostring(missing_error):find("RecordNotFound", 1, true))`, @@ -111,7 +118,7 @@ test('listRights orders invalid or absent createdAt by indexed_at then row creat runLua(program); }); -test('listRights rejects malformed DID escapes and PostgreSQL year zero cursors before querying', async () => { +test('listRights accepts broad DIDs in filters and cursors but rejects invalid DID and timestamp boundaries', async () => { const sources = await endpointSources('listRights'); const program = [ `local null_value = {}`, @@ -130,20 +137,32 @@ test('listRights rejects malformed DID escapes and PostgreSQL year zero cursors return hex('{"v":1,"d":"desc","t":"' .. timestamp .. '","u":"' .. uri .. '"}') end`, `local query_count = 0 + local query_values = {} db = { backend = function() return "postgres" end, - raw = function() query_count = query_count + 1; return {} end + raw = function(sql, values) + query_count = query_count + 1 + query_values[query_count] = values + return {} + end } toarray = function(value) return value end`, ...sources, `local function invalid_request() + local previous_queries = query_count local ok, err = pcall(handle) assert(not ok and tostring(err):find("InvalidRequest", 1, true)) - assert(query_count == 0, "invalid inputs must fail before database access") + assert(query_count == previous_queries, "invalid inputs must fail before database access") end params = { authors = { "did:plc:publisher%GG" } } - invalid_request() - params = { cursor = cursor("2025-01-01T00:00:00Z", "at://did:plc:publisher%GG/org.hypercerts.claim.rights/key") } + local filtered = handle() + assert(#filtered.rights == 0 and query_values[1][2] == "did:plc:publisher%GG", + "opaque percent sequences in author DIDs must reach the list query") + local broad_cursor_uri = "at://did:plc:publisher%GG/org.hypercerts.claim.rights/key" + params = { cursor = cursor("2025-01-01T00:00:00Z", broad_cursor_uri) } + handle() + assert(query_values[2][3] == broad_cursor_uri, "broad DID cursor URI must reach the list query") + params = { authors = { "did:plc:publisher%" } } invalid_request() params = { cursor = cursor("0000-01-01T00:00:00Z", "at://did:plc:publisher/org.hypercerts.claim.rights/key") } invalid_request()`, diff --git a/api/tests/unit/endpoints/rights-lua.test.js b/api/tests/unit/endpoints/rights-lua.test.js index 61f7c4c..764abb3 100644 --- a/api/tests/unit/endpoints/rights-lua.test.js +++ b/api/tests/unit/endpoints/rights-lua.test.js @@ -94,6 +94,7 @@ async function listRightsSources() { read('lua/shared/recordView.lua'), read('lua/shared/actorView.lua'), read('lua/shared/rightsView.lua'), + read('lua/shared/datetimeValidation.lua'), read('lua/shared/listValidation.lua'), read('lua/shared/listQuery.lua'), read('lua/src/listRights.lua'), diff --git a/api/tests/unit/feature.test.js b/api/tests/unit/feature.test.js index 8901189..7c45519 100644 --- a/api/tests/unit/feature.test.js +++ b/api/tests/unit/feature.test.js @@ -88,6 +88,7 @@ async function runListFeatures(params, queryResults, records = { 'feature-record 'lua/shared/didValidation.lua', 'lua/shared/query.lua', 'lua/shared/recordIdentifier.lua', + 'lua/shared/datetimeValidation.lua', 'lua/shared/listValidation.lua', 'lua/shared/listQuery.lua', 'lua/shared/featureValidation.lua', @@ -165,12 +166,18 @@ assert(calls[1].values[1] == '${FEATURE}' and calls[1].values[2] == '${FEATURE_U test('getFeature distinguishes invalid feature URIs from unindexed records', async () => { for (const uri of [ 'at://alice.example/org.hypercerts.entity.feature/forest-zone', - 'at://did:web:example.org%GG/org.hypercerts.entity.feature/forest-zone', + 'at://did:web:example.org%/org.hypercerts.entity.feature/forest-zone', `at://${FEATURE_DID}/app.certified.location/location-one`, ]) { const invalid = await runGetFeature({ params: { uri }, queryResults: [], expectedError: 'InvalidRequest:', expectedCalls: 0 }); assert.equal(invalid.status, 0, `${invalid.stderr}${invalid.stdout}`); } + const broadUri = 'at://did:web:example.org%GG/org.hypercerts.entity.feature/forest-zone'; + const broadMissing = await runGetFeature({ params: { uri: broadUri }, queryResults: [[]], expectedError: 'RecordNotFound:', expectedCalls: 1, + assertions: `assert(calls[1].values[2] == '${broadUri}', 'opaque percent sequence must reach exact lookup unchanged')`, + }); + assert.equal(broadMissing.status, 0, `${broadMissing.stderr}${broadMissing.stdout}`); + const missing = await runGetFeature({ params: { uri: FEATURE_URI }, queryResults: [[]], expectedError: 'RecordNotFound:', expectedCalls: 1 }); assert.equal(missing.status, 0, `${missing.stderr}${missing.stdout}`); }); @@ -274,13 +281,22 @@ assert(not calls[1].sql:find('app.certified.actor.profile', 1, true)) assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); }); -test('listFeatures accepts well-formed percent escapes in DID identifiers', async () => { - const author = 'did:web:example.org%2Fteam'; +test('listFeatures accepts opaque percent sequences in DID filters and cursor URIs', async () => { + const author = 'did:web:example.org%GG'; const result = await runListFeatures({ authors: [author] }, [[]], undefined, ` assert(#result.features == 0 and result.cursor == nil and #calls == 1) assert(calls[1].values[1] == '${FEATURE}' and calls[1].values[2] == '${author}') `); assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + + const cursorUri = `at://${author}/${FEATURE}/cursor-key`; + const cursor = Buffer.from(JSON.stringify({ + v: 1, d: 'asc', t: '2025-01-02T03:04:05Z', u: cursorUri, + })).toString('hex'); + const resumed = await runListFeatures({ sortDirection: 'asc', cursor }, [[]], undefined, ` +assert(#result.features == 0 and calls[1].values[3] == '${cursorUri}') +`); + assert.equal(resumed.status, 0, `${resumed.stderr}${resumed.stdout}`); }); test('listFeatures rejects unknown, malformed, repeated, and out-of-range inputs before querying', async () => { @@ -289,7 +305,7 @@ test('listFeatures rejects unknown, malformed, repeated, and out-of-range inputs })).toString('hex'); const malformedAuthorityCursor = Buffer.from(JSON.stringify({ v: 1, d: 'desc', t: '2025-01-02T03:04:05Z', - u: 'at://did:web:example.org%GG/org.hypercerts.entity.feature/forest-zone', + u: 'at://did:web:example.org%/org.hypercerts.entity.feature/forest-zone', })).toString('hex'); const yearZeroCursor = Buffer.from(JSON.stringify({ v: 1, d: 'desc', t: '0000-01-01T00:00:00Z', u: FEATURE_URI, @@ -298,7 +314,7 @@ test('listFeatures rejects unknown, malformed, repeated, and out-of-range inputs { unknown: 'value' }, { authors: ['alice.example'] }, { authors: ['did:plc:aaaaaaaaaaaaaaaaaaaaaaaa:'] }, - { authors: ['did:web:example.org%GG'] }, + { authors: ['did:web:example.org%'] }, { authors: [42] }, { authors: Array(101).fill(FEATURE_DID) }, { types: ['t'.repeat(65)] }, diff --git a/api/tests/unit/lua/recentFollows.test.lua b/api/tests/unit/lua/recentFollows.test.lua index 9a18256..53ba24e 100644 --- a/api/tests/unit/lua/recentFollows.test.lua +++ b/api/tests/unit/lua/recentFollows.test.lua @@ -93,6 +93,7 @@ local handler = table.concat({ source("lua/shared/didValidation.lua"), source("lua/shared/query.lua"), source("lua/shared/recordIdentifier.lua"), + source("lua/shared/datetimeValidation.lua"), source("lua/shared/listValidation.lua"), source("lua/shared/recentFollows.lua"), source("lua/src/listRecentFollows.lua"), diff --git a/api/tests/unit/tooling/acknowledgement.test.js b/api/tests/unit/tooling/acknowledgement.test.js index cbe052e..6639719 100644 --- a/api/tests/unit/tooling/acknowledgement.test.js +++ b/api/tests/unit/tooling/acknowledgement.test.js @@ -184,15 +184,17 @@ function runGetErrorCases() { const source = ` local NULL = {} local calls, fail_at, return_record = 0, 0, false +local query_values = {} json = { decode = function(value) if value == 'null' then return NULL end return {} end } toarray = function(value) return value end params = { uri = 'at://author.example/${ACKNOWLEDGEMENT}/ack-one' } db = { backend = function() return 'postgres' end, - raw = function() + raw = function(_, values) calls = calls + 1 + query_values[calls] = values if calls == fail_at then error('fixture database failure') end - if return_record and calls == 2 then + if return_record and values[1] == '${ACKNOWLEDGEMENT}' then return {{ uri = '${acknowledgementUri}', did = '${authorDid}', cid = '${acknowledgementRow.cid}', indexed_at = '${createdAt}', record = 'acknowledgement-record' }} end return {} @@ -201,19 +203,24 @@ db = { dofile('lua/endpoints/getAcknowledgement.lua') local ok, result = pcall(handle) assert(not ok and tostring(result):find('InvalidRequest:', 1, true)) -params = { uri = 'at://did:plc:publisher%GG/${ACKNOWLEDGEMENT}/ack-one' } +local broad_uri = 'at://did:plc:publisher%GG/${ACKNOWLEDGEMENT}/ack-one' +params = { uri = broad_uri } ok, result = pcall(handle) -assert(not ok and tostring(result):find('InvalidRequest:', 1, true), 'malformed DID percent escape is rejected') -assert(calls == 0, 'invalid URIs are rejected before querying') +assert(not ok and tostring(result):find('RecordNotFound:', 1, true)) +assert(calls == 1 and query_values[1][2] == broad_uri, 'opaque percent sequences must reach exact lookup unchanged') +params = { uri = 'at://did:plc:publisher%/${ACKNOWLEDGEMENT}/ack-one' } +ok, result = pcall(handle) +assert(not ok and tostring(result):find('InvalidRequest:', 1, true), 'trailing percent remains invalid') +assert(calls == 1, 'invalid DID boundary must fail before querying') params = { uri = '${acknowledgementUri}' } ok, result = pcall(handle) assert(not ok and tostring(result):find('RecordNotFound:', 1, true)) -assert(calls == 1, 'valid missing URI performs one indexed lookup') -return_record, fail_at = true, 3 +assert(calls == 2, 'valid missing URI performs one indexed lookup') +return_record, fail_at = true, 4 ok, result = pcall(handle) assert(not ok and tostring(result):find('AcknowledgementQueryFailed:', 1, true)) assert(not tostring(result):find('RecordNotFound:', 1, true), 'hydration failure is not reported as a missing record') -assert(calls == 3, 'publisher hydration failure reaches the PostgreSQL lookup') +assert(calls == 4, 'publisher hydration failure reaches the PostgreSQL lookup') `; return spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); } @@ -225,14 +232,18 @@ function runInvalidListParams() { const yearZeroCursor = Buffer.from(JSON.stringify({ v: 1, d: 'desc', t: '0000-01-01T00:00:00.000000Z', u: acknowledgementUri, })).toString('hex'); + const broadCursorUri = 'at://did:plc:publisher%GG/org.hypercerts.context.acknowledgement/ack-one'; + const broadCursor = Buffer.from(JSON.stringify({ + v: 1, d: 'desc', t: '2025-01-01T00:00:00.000000Z', u: broadCursorUri, + })).toString('hex'); const invalidCases = [ { unknown: 'x' }, { authors: ['alice.example'] }, - { authors: ['did:plc:publisher%GG'] }, + { authors: ['did:plc:publisher%'] }, { authors: Array(101).fill(authorDid) }, { authors: [authorDid, 42] }, { subjects: ['at://alice.example/org.hypercerts.claim.activity/activity-one'] }, - { subjects: ['at://did:plc:publisher%GG/org.hypercerts.claim.activity/activity-one'] }, + { subjects: ['at://did:plc:publisher%/org.hypercerts.claim.activity/activity-one'] }, { subjects: ['at://did:plc:bbbbbbbbbbbbbbbb/org/activity-one'] }, { limit: '0' }, { limit: '101' }, @@ -245,6 +256,7 @@ function runInvalidListParams() { const source = ` local NULL = {} local calls = 0 +local query_values = {} local function decode_cursor(value) return { v = tonumber(value:match('"v":(%d+)')), @@ -262,7 +274,14 @@ json = { encode = function(value) return '{}' end, } toarray = function(value) return value end -db = { backend = function() return 'postgres' end, raw = function() calls = calls + 1; return {} end } +db = { + backend = function() return 'postgres' end, + raw = function(_, values) + calls = calls + 1 + query_values[calls] = values + return {} + end, +} dofile('lua/endpoints/listAcknowledgements.lua') local cases = ${lua(invalidCases)} for _, query in ipairs(cases) do @@ -271,6 +290,17 @@ for _, query in ipairs(cases) do assert(not ok and tostring(result):find('InvalidRequest:', 1, true), 'invalid parameters must be rejected') end assert(calls == 0, 'invalid parameters are rejected before querying') +local broad_did = 'did:plc:publisher%GG' +local broad_subject = 'at://' .. broad_did .. '/org.hypercerts.claim.activity/activity-one' +params = { authors = { broad_did }, subjects = { broad_subject } } +local filtered = handle() +assert(#filtered.acknowledgements == 0) +assert(calls == 1 and query_values[1][2] == broad_did and query_values[1][3] == broad_subject, + 'broad author and subject DIDs must reach the list query unchanged') +params = { cursor = '${broadCursor}' } +local paged = handle() +assert(#paged.acknowledgements == 0 and calls == 2 and query_values[2][3] == '${broadCursorUri}', + 'broad DID cursor URI must reach the list query unchanged') `; return spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); } @@ -309,7 +339,7 @@ assert(not ok and tostring(failure):find('AcknowledgementQueryFailed:', 1, true) return spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); } -test('getAcknowledgement rejects a non-DID URI, distinguishes a missing record, and reports query failures', () => { +test('getAcknowledgement accepts broad DID syntax and preserves invalid-URI and query failures', () => { const result = runGetErrorCases(); assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); }); diff --git a/api/tests/unit/tooling/activity.test.js b/api/tests/unit/tooling/activity.test.js index 0b4557c..fcdb083 100644 --- a/api/tests/unit/tooling/activity.test.js +++ b/api/tests/unit/tooling/activity.test.js @@ -68,11 +68,6 @@ function withoutRecordJson(row) { test('activity API manifests and Lexicons declare the implemented endpoint contracts', async () => { const manifest = JSON.parse(await readFile(new URL('../../../manifest.json', import.meta.url), 'utf8')); const module = JSON.parse(await readFile(new URL('../../../modules/activity/manifest.json', import.meta.url), 'utf8')); - assert.deepEqual({ - getActivity: manifest.handlerStatus.getActivity, - listActivities: manifest.handlerStatus.listActivities, - searchActivities: manifest.handlerStatus.searchActivities, - }, { getActivity: 'implemented', listActivities: 'implemented', searchActivities: 'implemented' }); assert.ok(manifest.modules.includes('modules/activity/manifest.json')); for (const nsid of ['org.hypercerts.claim.activity', 'org.hypercerts.claim.contributorInformation']) { assert.ok(manifest.validationLexicons.some(({ id, packagePath }) => id === nsid && packagePath)); @@ -465,6 +460,8 @@ assert(result.activities[1].author.profile.record.displayName == 'List author') assert(result.activities[1].contributors[1].contributorInformation.record.displayName == 'Exact contributor') assert(result.activities[1].contributors[1].actor.profile.record.displayName == 'List contributor') local sql = calls[1].sql +assert(sql:find("activity.record::jsonb->'createdAt'", 1, true)) +assert(sql:find('COALESCE(activity.indexed_at::timestamptz, activity.created_at::timestamptz)', 1, true)) assert(sql:find('activity.did IN ($2)', 1, true), 'duplicate author DIDs must be removed before binding') assert(sql:find('activity.uri IN ($6, $7)', 1, true), 'duplicate URIs must be removed before binding') assert(sql:find("NOT EXISTS (SELECT 1 FROM happyview_records AS organization", 1, true), 'false must require no organization self record') diff --git a/api/tests/unit/tooling/collection.test.js b/api/tests/unit/tooling/collection.test.js index 36be53c..addc194 100644 --- a/api/tests/unit/tooling/collection.test.js +++ b/api/tests/unit/tooling/collection.test.js @@ -64,6 +64,8 @@ db = { raw = function(sql, values) calls[#calls + 1] = { sql = sql, values = values } if values[1] ~= '${COLLECTION}' then return {} end + assert(sql:find("collection.record::jsonb->'createdAt'", 1, true)) + assert(sql:find('COALESCE(collection.indexed_at::timestamptz, collection.created_at::timestamptz)', 1, true)) for _, value in ipairs({ ${[...itemUris, ...tagUris].map((uri) => JSON.stringify(uri)).join(', ')} }) do local bound, placeholder = false, false diff --git a/api/tests/unit/tooling/contributions.test.js b/api/tests/unit/tooling/contributions.test.js index 8f55952..f70d757 100644 --- a/api/tests/unit/tooling/contributions.test.js +++ b/api/tests/unit/tooling/contributions.test.js @@ -157,7 +157,7 @@ assert(rawget(result.contribution.author.organization, 'indexedAt') == NULL) test('getContribution rejects non-exact URIs before querying and distinguishes missing records', () => { const invalidParams = [ { uri: `at://alice.example/${CONTRIBUTION}/3jzfcijpj2z2a` }, - { uri: `at://did:plc:%GG/${CONTRIBUTION}/3jzfcijpj2z2a` }, + { uri: `at://did:plc:publisher%/${CONTRIBUTION}/3jzfcijpj2z2a` }, { uri: `at://${publisherDid}/${PROFILE}/self` }, { uri: [contributionUri, contributionUri] }, { uri: contributionUri, extra: 'not-accepted' }, @@ -171,6 +171,16 @@ test('getContribution rejects non-exact URIs before querying and distinguishes m assert.equal(result.status, 0, `${JSON.stringify(params)}\n${result.stderr}${result.stdout}`); } + const broadDid = 'did:plc:%GG'; + const broadUri = `at://${broadDid}/${CONTRIBUTION}/broad-did`; + const broadLookup = runLuaEndpoint({ + endpoint: 'getContribution', params: { uri: broadUri }, queryResults: [[]], + expectError: 'RecordNotFound:', assertions: ` +assert(#calls == 1 and calls[1].values[2] == ${lua(broadUri)}, 'opaque percent sequences must reach exact lookup unchanged') +`, + }); + assert.equal(broadLookup.status, 0, `${broadLookup.stderr}${broadLookup.stdout}`); + const missing = runLuaEndpoint({ endpoint: 'getContribution', params: { uri: contributionUri }, queryResults: [[]], expectError: 'RecordNotFound:', assertions: "assert(#calls == 1, 'missing exact record needs one indexed lookup')", @@ -315,7 +325,7 @@ test('listContributions rejects malformed filters and sort-bound cursors before const wrongDirectionJson = JSON.stringify(wrongDirection); const invalidParams = [ { authors: ['alice.example'] }, - { authors: ['did:plc:%GG'] }, + { authors: ['did:plc:publisher%'] }, { authors: Array(101).fill(publisherDid) }, { authors: [{ did: publisherDid }] }, { limit: '0' }, @@ -334,21 +344,36 @@ test('listContributions rejects malformed filters and sort-bound cursors before }); assert.equal(result.status, 0, `${JSON.stringify(params)}\n${result.stderr}${result.stdout}`); } + + const broadDid = 'did:plc:%GG'; + const broadFilter = runLuaEndpoint({ + endpoint: 'listContributions', params: { authors: [broadDid] }, queryResults: [[]], + assertions: `assert(#result.contributions == 0 and calls[1].values[2] == ${lua(broadDid)})`, + }); + assert.equal(broadFilter.status, 0, `${broadFilter.stderr}${broadFilter.stdout}`); }); -test('listContributions rejects cursor URIs with malformed DID percent escapes before querying', () => { - const cursor = { - v: 1, d: 'asc', t: '2025-01-01T00:00:00Z', - u: `at://did:plc:%GG/${CONTRIBUTION}/3jzfcijpj2z2a`, - }; +test('listContributions accepts opaque percent sequences in cursor DIDs and queries them', () => { + const broadUri = `at://did:plc:%GG/${CONTRIBUTION}/3jzfcijpj2z2a`; + const cursor = { v: 1, d: 'asc', t: '2025-01-01T00:00:00Z', u: broadUri }; const cursorJson = JSON.stringify(cursor); const result = runLuaEndpoint({ endpoint: 'listContributions', params: { cursor: Buffer.from(cursorJson).toString('hex'), sortDirection: 'asc' }, - cursorFixtures: { [cursorJson]: cursor }, expectError: 'InvalidRequest:', - assertions: "assert(#calls == 0, 'malformed cursor authority must fail before querying')", + queryResults: [[]], cursorFixtures: { [cursorJson]: cursor }, + assertions: `assert(#result.contributions == 0 and calls[1].values[3] == ${lua(broadUri)})`, }); assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + + const invalidCursor = { ...cursor, u: `at://did:plc:publisher%/${CONTRIBUTION}/3jzfcijpj2z2a` }; + const invalidJson = JSON.stringify(invalidCursor); + const invalid = runLuaEndpoint({ + endpoint: 'listContributions', + params: { cursor: Buffer.from(invalidJson).toString('hex'), sortDirection: 'asc' }, + cursorFixtures: { [invalidJson]: invalidCursor }, expectError: 'InvalidRequest:', + assertions: "assert(#calls == 0, 'trailing percent must fail before querying')", + }); + assert.equal(invalid.status, 0, `${invalid.stderr}${invalid.stdout}`); }); test('listContributions rejects year-zero cursor timestamps before querying', () => { diff --git a/api/tests/unit/tooling/evm-links.test.js b/api/tests/unit/tooling/evm-links.test.js index f332a76..801cb30 100644 --- a/api/tests/unit/tooling/evm-links.test.js +++ b/api/tests/unit/tooling/evm-links.test.js @@ -91,33 +91,49 @@ ${assertions} assert.equal(execution.status, 0, execution.stderr || execution.stdout); } -test('getEvmLink rejects malformed DID authorities but accepts percent-encoded DID characters', async () => { +test('getEvmLink uses shared DID validation and preserves exact-URI constraints', async () => { const source = await getEvmLinkSource(); - const malformedUris = [ + const invalidUris = [ + 'at://did:plc1:abc/app.certified.link.evm/record', + 'at://did:plc:abc:/app.certified.link.evm/record', + 'at://did:plc:abc/app.certified.link.evm/record?version=1', + 'at://did:plc:abc/app.certified.link.evm/record#fragment', + ]; + const acceptedUris = [ 'at://did:plc:abc%GG/app.certified.link.evm/record', 'at://did:plc:abc::def/app.certified.link.evm/record', 'at://did:plc::abc/app.certified.link.evm/record', - 'at://did:plc1:abc/app.certified.link.evm/record', + 'at://did:web:example.com%3A3000:users:alice/app.certified.link.evm/record', ]; const lua = ` local null = {} json = { decode = function(value) if value == 'null' then return null end error('unexpected JSON') end } local lookup_count = 0 +local lookup_queries = {} params = {} db = { backend = function() return 'postgres' end, - raw = function() lookup_count = lookup_count + 1; return {} end, + raw = function(sql, values) + lookup_count = lookup_count + 1 + lookup_queries[lookup_count] = { sql = sql, values = values } + return {} + end, } ${source} -for _, malformed_uri in ipairs(${luaLiteral(malformedUris)}) do - params = { uri = malformed_uri } +for _, invalid_uri in ipairs(${luaLiteral(invalidUris)}) do + params = { uri = invalid_uri } + local ok, err = pcall(handle) + assert(not ok and tostring(err):find('InvalidRequest:', 1, true), 'invalid DID or URI constraints must be rejected') +end +assert(lookup_count == 0, 'invalid DID and query/fragment URIs must not reach exact lookup') +local accepted_uris = ${luaLiteral(acceptedUris)} +for index, accepted_uri in ipairs(accepted_uris) do + params = { uri = accepted_uri } local ok, err = pcall(handle) - assert(not ok and tostring(err):find('InvalidRequest:', 1, true), 'malformed DID authorities must be rejected') + assert(not ok and tostring(err):find('RecordNotFound:', 1, true), 'shared-valid DIDs must reach exact lookup') + assert(lookup_queries[index].values[2] == accepted_uri, 'exact lookup must bind each accepted URI unchanged') end -params = { uri = 'at://did:web:example.com%3A3000:users:alice/app.certified.link.evm/record' } -local ok, err = pcall(handle) -assert(not ok and tostring(err):find('RecordNotFound:', 1, true), 'a valid percent-encoded DID must reach exact lookup') -assert(lookup_count == 1, 'invalid DID authorities must be rejected before querying') +assert(lookup_count == #accepted_uris, 'each shared-valid DID must issue an exact lookup') `; const execution = spawnSync('lua5.4', ['-'], { input: lua, encoding: 'utf8' }); assert.equal(execution.status, 0, execution.stderr || execution.stdout); @@ -331,7 +347,7 @@ assert(second_query.sql:find(') > (($6)::timestamptz, $7)', 1, true), 'cursor mu ` }); }); -test('listEvmLinks rejects malformed filters, page bounds, unknown parameters, and cursors before querying', async () => { +test('listEvmLinks rejects invalid filters but accepts broad DIDs in actor filters and cursors', async () => { const cursorObject = { v: 1, d: 'asc', t: '2024-01-02T03:04:05Z', u: uri }; const cursorJSON = JSON.stringify(cursorObject); const methodDid = 'did:plc1:abc'; @@ -339,12 +355,14 @@ test('listEvmLinks rejects malformed filters, page bounds, unknown parameters, a const methodCursor = { v: 1, d: 'asc', t: '2024-01-02T03:04:05Z', u: methodUri }; const methodCursorJSON = JSON.stringify(methodCursor); const escapedDid = 'did:web:example.com%3A3000:users:alice'; + const broadDids = ['did:plc:abc%GG', 'did:plc:abc::def', 'did:plc::abc']; + const broadCursorUri = 'at://did:plc:cursor%GG/app.certified.link.evm/key'; + const broadCursor = { v: 1, d: 'asc', t: '2024-01-02T03:04:05Z', u: broadCursorUri }; + const broadCursorJSON = JSON.stringify(broadCursor); const invalidRequests = [ { addresses: ['0xnot-an-address'] }, { actors: ['not-a-did'] }, - { actors: ['did:plc:abc%GG'] }, - { actors: ['did:plc:abc::def'] }, - { actors: ['did:plc::abc'] }, + { actors: ['did:plc:abc%'] }, { actors: [methodDid] }, { actors: Array.from({ length: 101 }, (_, index) => `did:plc:actor${index}`) }, { addresses: Array(101).fill('0xAa00000000000000000000000000000000000001') }, @@ -361,6 +379,7 @@ test('listEvmLinks rejects malformed filters, page bounds, unknown parameters, a extraDecoded: [ { json: cursorJSON, value: cursorObject }, { json: methodCursorJSON, value: methodCursor }, + { json: broadCursorJSON, value: broadCursor }, ], assertions: ` local invalid_requests = ${luaLiteral(invalidRequests)} @@ -369,10 +388,24 @@ for _, request in ipairs(invalid_requests) do local ok, err = pcall(handle) assert(not ok and tostring(err):find('InvalidRequest:', 1, true), 'expected InvalidRequest for malformed query') end +local broad_dids = ${luaLiteral(broadDids)} +for index, broad_did in ipairs(broad_dids) do + params = { actors = { broad_did } } + local result = handle() + assert(#result.evmLinks == 0) + assert(list_calls == index and list_queries[index].values[2] == broad_did, + 'broad DID actor filters must reach the query unchanged') +end params = { actors = { '${escapedDid}' } } local valid_result = handle() assert(#valid_result.evmLinks == 0) -assert(list_calls == 1 and list_queries[1].values[2] == '${escapedDid}', 'valid percent-encoded DIDs must reach the query') +assert(list_calls == #broad_dids + 1 and list_queries[list_calls].values[2] == '${escapedDid}', + 'percent-encoded DIDs must reach the query') +params = { sortDirection = 'asc', cursor = '${Buffer.from(broadCursorJSON).toString('hex')}' } +local cursor_result = handle() +assert(#cursor_result.evmLinks == 0) +assert(list_calls == #broad_dids + 2 and list_queries[list_calls].values[3] == '${broadCursorUri}', + 'broad DID cursor URI must reach the query unchanged') `, }); }); diff --git a/api/tests/unit/tooling/getContributorInformation.test.js b/api/tests/unit/tooling/getContributorInformation.test.js index 9975f30..07cf03b 100644 --- a/api/tests/unit/tooling/getContributorInformation.test.js +++ b/api/tests/unit/tooling/getContributorInformation.test.js @@ -72,18 +72,25 @@ assert(view.author.organization == null) assert(#calls == 3) assert(calls[1].sql:find("collection = %$1 AND uri = %$2", 1) ~= nil) assert(calls[1].values[1] == "org.hypercerts.claim.contributorInformation" and calls[1].values[2] == uri) +record_missing = true +local broad_uri = "at://did:plc:publisher%GG/org.hypercerts.claim.contributorInformation/tid" +params = { uri = broad_uri } +local broad_ok, broad_error = pcall(handle) +assert(not broad_ok and tostring(broad_error):find("RecordNotFound:", 1, true) ~= nil) +assert(#calls == 4 and calls[4].values[2] == broad_uri, "broad DID syntax must reach exact lookup unchanged") +record_missing = false local bad_uri_ok, bad_uri_error = pcall(function() - params = { uri = "at://did:plc:publisher%GG/org.hypercerts.claim.contributorInformation/tid" } + params = { uri = "at://did:plc:publisher%/org.hypercerts.claim.contributorInformation/tid" } return handle() end) assert(not bad_uri_ok and tostring(bad_uri_error):find("InvalidRequest:", 1, true) ~= nil) -assert(#calls == 3, "invalid DID authority must be rejected before querying") +assert(#calls == 4, "trailing percent must remain an invalid DID boundary") local wrong_collection_ok, wrong_collection_error = pcall(function() params = { uri = "at://did:plc:publisher/org.hypercerts.claim.activity/tid" } return handle() end) assert(not wrong_collection_ok and tostring(wrong_collection_error) == "InvalidRequest: uri must be a full org.hypercerts.claim.contributorInformation AT-URI with a DID authority") -assert(#calls == 3, "a different collection must be rejected before querying") +assert(#calls == 4, "a different collection must be rejected before querying") local unknown_parameter_ok, unknown_parameter_error = pcall(function() params = { uri = uri, extra = "unsupported" } return handle() @@ -94,7 +101,7 @@ local repeated_uri_ok, repeated_uri_error = pcall(function() return handle() end) assert(not repeated_uri_ok and tostring(repeated_uri_error) == "InvalidRequest: uri must occur once") -assert(#calls == 3, "invalid query parameters must be rejected before querying") +assert(#calls == 4, "invalid query parameters must be rejected before querying") recordRow.indexed_at = nil params = { uri = uri } local null_timestamp = handle().contributorInformation.indexedAt diff --git a/api/tests/unit/tooling/listContributorInformation.test.js b/api/tests/unit/tooling/listContributorInformation.test.js index a71f71b..885ba00 100644 --- a/api/tests/unit/tooling/listContributorInformation.test.js +++ b/api/tests/unit/tooling/listContributorInformation.test.js @@ -16,6 +16,7 @@ test('listContributorInformation filters repeated authors and resumes in the sel read('lua/shared/contributorInformationValidation.lua'), read('lua/shared/recordView.lua'), read('lua/shared/actorView.lua'), + read('lua/shared/datetimeValidation.lua'), read('lua/shared/listValidation.lua'), read('lua/shared/listQuery.lua'), read('lua/src/listContributorInformation.lua'), @@ -91,14 +92,30 @@ assert(second_query.sql:find("(sorted.sort_at, contributor.uri) > (($4)::timesta assert(first_query.sql:find("COALESCE(contributor.indexed_at::timestamptz, contributor.created_at::timestamptz)", 1, true) ~= nil) local completed_pages = page params = { authors = { "did:plc:publisher%GG" } } -local bad_author_ok, bad_author_error = pcall(function() return handle() end) -assert(not bad_author_ok and tostring(bad_author_error):find("InvalidRequest:", 1, true) ~= nil) -assert(page == completed_pages, "invalid author DID must be rejected before querying") +local broad_author_result = handle() +assert(#broad_author_result.contributorInformation == 0) +assert(page == completed_pages + 1 and record_queries[page].values[2] == "did:plc:publisher%GG", + "opaque percent sequences in a DID must reach the list query unchanged") +completed_pages = page params = { authors = { "did:plc:publisher%2Fid" } } local escaped_author_result = handle() assert(#escaped_author_result.contributorInformation == 0) assert(page == completed_pages + 1 and record_queries[page].values[2] == "did:plc:publisher%2Fid", - "a well-formed percent escape in a DID must remain accepted") + "percent-escaped characters in a DID must remain accepted") +completed_pages = page +for _, invalid_author in ipairs({ "alice.example", "did:plc:publisher%" }) do + params = { authors = { invalid_author } } + local invalid_author_ok, invalid_author_error = pcall(function() return handle() end) + assert(not invalid_author_ok and tostring(invalid_author_error):find("InvalidRequest:", 1, true) ~= nil) + assert(page == completed_pages, "invalid DIDs must be rejected before querying") +end +local broad_cursor_uri = "at://did:plc:publisher%GG/org.hypercerts.claim.contributorInformation/cursor" +JSON_VALUES.cursor = { v = 1, d = "asc", t = "2026-01-03T00:00:00Z", u = broad_cursor_uri } +params = { sortDirection = "asc", limit = "1", cursor = "637572736f72" } +local broad_cursor_result = handle() +assert(#broad_cursor_result.contributorInformation == 0) +assert(page == completed_pages + 1 and record_queries[page].values[3] == broad_cursor_uri, + "broad DID cursor URI must reach the list query unchanged") completed_pages = page params = { limit = "101" } local invalid_limit_ok, invalid_limit_error = pcall(function() return handle() end) diff --git a/api/tests/unit/tooling/organization.test.js b/api/tests/unit/tooling/organization.test.js index 2377437..5619112 100644 --- a/api/tests/unit/tooling/organization.test.js +++ b/api/tests/unit/tooling/organization.test.js @@ -96,15 +96,6 @@ test('organization module registers get, batch, list, and search queries with a const manifest = JSON.parse(await readFile(`${root}/manifest.json`, 'utf8')); const modulePath = 'modules/organization/manifest.json'; assert.ok(manifest.modules.includes(modulePath)); - assert.deepEqual({ - getOrganization: manifest.handlerStatus.getOrganization, - getOrganizations: manifest.handlerStatus.getOrganizations, - listOrganizations: manifest.handlerStatus.listOrganizations, - searchOrganizations: manifest.handlerStatus.searchOrganizations, - }, { - getOrganization: 'implemented', getOrganizations: 'implemented', listOrganizations: 'implemented', searchOrganizations: 'implemented', - }); - const module = JSON.parse(await readFile(`${root}/${modulePath}`, 'utf8')); const queries = module.assets.filter(({ kind, id }) => kind === 'lexicon' && id.startsWith('app.certified.actor.')); assert.deepEqual(queries.map(({ id }) => id).sort(), [ @@ -236,7 +227,7 @@ test('getOrganizations validates 1..100 actor occurrences before querying', () = {}, { actors: [] }, { actors: Array(101).fill(actor) }, - { actors: ['did:plc:abcd%ZZ'] }, + { actors: ['did:plc:abcd%'] }, { actors: [actor], unknown: 'value' }, ]) { runLua({ endpoint: 'getOrganizations', params, expectError: 'InvalidRequest:', expectedCalls: 0 }); @@ -254,6 +245,16 @@ test('getOrganizations validates 1..100 actor occurrences before querying', () = assert(#result.organizations == 100) assert(result.organizations[100].actor == '${actor}') assert(#calls == 2 and #calls[1].values == 2 and #calls[2].values == 2) +`, + }); + + const broadDid = 'did:plc:abcd%ZZ'; + runLua({ + endpoint: 'getOrganizations', params: { actors: [broadDid] }, queryResults: [[]], + assertions: ` +assert(#result.organizations == 1 and result.organizations[1].actor == '${broadDid}') +assert(result.organizations[1].organization == NULL) +assert(#calls == 1 and calls[1].values[2] == '${broadDid}') `, }); }); @@ -277,6 +278,8 @@ assert(result.actors[1].organization.uri == '${first.uri}' and result.actors[1]. assert(calls[1].values[1] == '${organizationCollection}') assert(calls[1].sql:find("organization.rkey = 'self'", 1, true)) assert(calls[1].sql:find('organizationType', 1, true) and calls[1].sql:find('visibility', 1, true)) +assert(calls[1].sql:find("organization.record::jsonb->'createdAt'", 1, true)) +assert(calls[1].sql:find('COALESCE(organization.indexed_at::timestamptz, organization.created_at::timestamptz)', 1, true)) assert(not calls[1].sql:find('organization.did IN', 1, true), 'list has no actor request filter') assert(calls[1].sql:find('ORDER BY sorted.sort_at ASC, uri ASC', 1, true)) assert(calls[1].values[2] == 'nonprofit' and calls[1].values[3] == 'community' and calls[1].values[4] == 'unlisted') diff --git a/api/tests/unit/tooling/profile.test.js b/api/tests/unit/tooling/profile.test.js index 149e2cf..2c90d15 100644 --- a/api/tests/unit/tooling/profile.test.js +++ b/api/tests/unit/tooling/profile.test.js @@ -128,10 +128,6 @@ ${expectError test('profile module installs all four endpoint Lexicons and generated handlers', async () => { const manifest = JSON.parse(await readFile(`${root}/manifest.json`, 'utf8')); assert.ok(manifest.modules.includes('modules/profile/manifest.json')); - for (const name of ['getProfile', 'getProfiles', 'listProfiles', 'searchProfiles']) { - assert.equal(manifest.handlerStatus[name], 'implemented'); - } - const { assets } = await loadAssets(`${root}/manifest.json`); for (const name of ['getProfile', 'getProfiles', 'listProfiles', 'searchProfiles']) { const nsid = `app.certified.actor.${name}`; @@ -224,16 +220,22 @@ test('getProfiles requires 1..100 valid DIDs before querying', () => { { actors: [] }, { actors: Array(101).fill(actor) }, { actors: ['alice.example'] }, + { actors: ['did:plc:abcd%'] }, { actors: [actor], cursor: 'not-accepted' }, ]) { runLua({ endpoint: 'getProfiles', params, expectError: 'InvalidRequest:', expectedCalls: 0, expectedHttpCalls: 0 }); } }); -test('getProfiles rejects malformed DID percent escapes before querying', () => { +test('getProfiles accepts opaque percent sequences in DIDs and queries them unchanged', () => { + const broadDid = 'did:plc:abcd%ZZ'; runLua({ - endpoint: 'getProfiles', params: { actors: ['did:plc:abcd%ZZ'] }, - expectError: 'InvalidRequest: each actors value must be a valid DID', expectedCalls: 0, expectedHttpCalls: 0, + endpoint: 'getProfiles', params: { actors: [broadDid] }, + assertions: ` +assert(#result.profiles == 1 and result.profiles[1].actor == '${broadDid}') +assert(result.profiles[1].profile == JSON_NULL) +assert(#calls == 1 and calls[1].values[2] == '${broadDid}') +`, }); }); @@ -267,6 +269,22 @@ test('getProfile serializes SQL NULL indexedAt as required JSON null', () => { assert.equal(result.profile.indexedAt, null); }); +test('profile record views preserve false indexedAt and propagate record decoding failures', () => { + const row = profileRow(actor, 'false-indexed-at', { displayName: 'False index time' }, '2025-01-01T00:00:00Z'); + row.indexed_at = false; + const result = runLua({ + endpoint: 'getProfile', params: { actor }, rows: [row], captureJson: true, + assertions: 'assert(#calls == 1 and #httpCalls == 0)', + }); + assert.equal(result.profile.indexedAt, false); + + runLua({ + endpoint: 'getProfile', params: { actor }, + rows: [{ ...row, record: 'x', record_json: false }], + expectError: 'invalid cursor JSON', expectedCalls: 1, + }); +}); + test('getProfile resolves a handle once through the configured resolver and does not fetch a DID document', () => { const record = { displayName: 'Alice', createdAt: '2025-01-01T00:00:00Z' }; const row = profileRow(actor, 'alice', record, record.createdAt); @@ -349,7 +367,13 @@ test('resolver transport and invalid DID responses stay distinct from indexed Re }); runLua({ endpoint: 'getProfile', params: { actor: 'alice.example' }, resolverUrl: 'https://resolver.example', - httpResponses: [{ status: 200, body: 'malformed-escape-did', value: { did: 'did:plc:abc%GG' } }], + httpResponses: [{ status: 200, body: 'broad-did', value: { did: 'did:plc:abc%GG' } }], + expectError: 'RecordNotFound:', expectedCalls: 1, expectedHttpCalls: 1, + errorAssertions: "assert(calls[1].values[2] == 'did:plc:abc%GG', 'resolver DID must reach profile lookup unchanged')", + }); + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, resolverUrl: 'https://resolver.example', + httpResponses: [{ status: 200, body: 'trailing-percent-did', value: { did: 'did:plc:abc%' } }], expectError: 'HandleResolutionFailed:', expectedCalls: 0, expectedHttpCalls: 1, }); runLua({ @@ -380,6 +404,11 @@ assert(calls[1].values[1] == '${collection}' and calls[1].values[2] == 2) local sql = calls[1].sql assert(not sql:find('did IN', 1, true), 'discovery listing must not restrict actor DIDs') assert(not sql:find('strpos', 1, true)) +assert(sql:find("jsonb_typeof(record::jsonb->'createdAt') = 'string'", 1, true)) +assert(sql:find("record::jsonb->>'createdAt' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$'", 1, true)) +assert(sql:find("record::jsonb->>'createdAt' !~ '-00:00$'", 1, true)) +assert(sql:find("pg_input_is_valid(record::jsonb->>'createdAt', 'timestamptz')", 1, true)) +assert(sql:find('COALESCE(indexed_at::timestamptz, created_at::timestamptz)', 1, true)) assert(sql:find('ORDER BY sorted.sort_at ASC, uri ASC', 1, true)) assert(result.cursor ~= nil) local tokenJson = result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) diff --git a/api/tests/unit/tooling/vocab-tags.test.js b/api/tests/unit/tooling/vocab-tags.test.js index c5595b9..7976b6e 100644 --- a/api/tests/unit/tooling/vocab-tags.test.js +++ b/api/tests/unit/tooling/vocab-tags.test.js @@ -12,6 +12,7 @@ const sharedSources = [ 'lua/shared/didValidation.lua', 'lua/shared/query.lua', 'lua/shared/recordIdentifier.lua', + 'lua/shared/datetimeValidation.lua', 'lua/shared/listValidation.lua', 'lua/shared/listQuery.lua', 'lua/shared/recordView.lua', @@ -215,12 +216,11 @@ test('getVocabTag reports RecordNotFound when the exact indexed URI is absent', }); }); -test('getVocabTag rejects wrong collections, handle authorities, and malformed percent escapes before querying', async () => { +test('getVocabTag preserves collection and DID boundary checks while broad DIDs reach lookup', async () => { for (const invalidUri of [ 'at://did:plc:abcdefghijklmnopqrstuvwx/org.hypercerts.collection/3jzfcijpj2z2z', 'at://climate.example/org.hypercerts.vocab.tag/3jzfcijpj2z2z', - 'at://did:plc:abcdefghijklmnopqrstuvwx%ZZ/org.hypercerts.vocab.tag/3jzfcijpj2z2z', - 'at://did:plc:abcdefghijklmnopqrstuvwx%2/org.hypercerts.vocab.tag/3jzfcijpj2z2z', + 'at://did:plc:abcdefghijklmnopqrstuvwx%/org.hypercerts.vocab.tag/3jzfcijpj2z2z', ]) { await runLua({ params: { uri: invalidUri }, @@ -228,6 +228,14 @@ test('getVocabTag rejects wrong collections, handle authorities, and malformed p assertions: 'assert(#calls == 0)', }); } + + const broadUri = 'at://did:plc:publisher%ZZ/org.hypercerts.vocab.tag/3jzfcijpj2z2z'; + await runLua({ + params: { uri: broadUri }, + queryResults: [[]], + expectError: 'RecordNotFound:', + assertions: `assert(#calls == 1 and calls[1].values[2] == '${broadUri}')`, + }); }); test('listVocabTags defaults to 25 descending and omits a terminal cursor', async () => { @@ -330,14 +338,13 @@ assert(calls[1].values[6] == 2) }); }); -test('listVocabTags rejects malformed-percent authors and cursor URIs, repeated scalars, and changed filters before querying', async () => { +test('listVocabTags rejects invalid authors and cursor URIs, repeated scalars, and changed filters before querying', async () => { const mismatchedFilterCursor = cursor({ authors: [firstAuthor] }); const directionCursor = cursor({ direction: 'desc', authors: [firstAuthor] }); - const malformedUriCursor = cursor({ recordUri: 'at://did:plc:abcdefghijklmnopqrstuvwx%ZZ/org.hypercerts.vocab.tag/rkey' }); + const malformedUriCursor = cursor({ recordUri: 'at://did:plc:abcdefghijklmnopqrstuvwx%/org.hypercerts.vocab.tag/rkey' }); for (const params of [ { authors: ['climate.example'] }, - { authors: ['did:plc:bad%ZZ'] }, - { authors: ['did:plc:bad%2'] }, + { authors: ['did:plc:bad%'] }, { authors: Array(101).fill(firstAuthor) }, { limit: ['1', '2'] }, { sortDirection: ['asc', 'desc'] }, @@ -356,8 +363,8 @@ test('listVocabTags rejects malformed-percent authors and cursor URIs, repeated } }); -test('vocabulary tag queries preserve valid percent-escaped DIDs', async () => { - const escapedDid = 'did:plc:abcdefghijklmnopqrst%2Fuvwxyz'; +test('vocabulary tag queries accept opaque DID percent sequences in records, filters, and cursors', async () => { + const escapedDid = 'did:plc:abcdefghijklmnopqrst%ZZ'; const escapedUri = `at://${escapedDid}/org.hypercerts.vocab.tag/escaped`; const escapedRow = { ...tagRow, uri: escapedUri, did: escapedDid }; await runLua({ @@ -375,6 +382,17 @@ assert(calls[1].values[2] == '${escapedUri}') assertions: ` assert(#result.vocabTags == 0) assert(calls[1].values[2] == '${escapedDid}') +`, + }); + + const broadCursorUri = `at://${escapedDid}/org.hypercerts.vocab.tag/prior`; + await runLua({ + endpoint: 'listVocabTags', + params: { sortDirection: 'asc', cursor: cursor({ direction: 'asc', recordUri: broadCursorUri }) }, + queryResults: [[]], + assertions: ` +assert(#result.vocabTags == 0) +assert(calls[1].values[3] == '${broadCursorUri}') `, }); }); diff --git a/api/tests/unit/workscopeTags.test.lua b/api/tests/unit/workscopeTags.test.lua index 893d670..48e6a06 100644 --- a/api/tests/unit/workscopeTags.test.lua +++ b/api/tests/unit/workscopeTags.test.lua @@ -297,19 +297,32 @@ test("tag timestamps stay explicit null while missing sidecar timestamps stay om assert_equal(listed.author.organization.indexedAt, "2025-01-03T00:00:00Z", "list sidecar timestamp must remain unchanged") end) -test("malformed percent DIDs and year-zero cursors are rejected before querying", function() +test("opaque DID percent sequences reach tag queries while DID and cursor boundaries remain", function() + local broad_did = "did:plc:publisher%GG" reset_database() - params = { authors = { "did:plc:publisher%GG" } } - assert_error(list_workscope_tags, "InvalidRequest") - assert_equal(#db.calls, 0, "malformed author DIDs do not reach the database") + params = { authors = { broad_did } } + local listed = list_workscope_tags() + assert_equal(#listed.workscopeTags, 0) + assert_equal(db.calls[1].values[2], broad_did, "author DID must reach the list query unchanged") - params = { uri = "at://did:plc:publisher%GG/" .. TAG .. "/tag" } - assert_error(get_workscope_tag, "InvalidRequest") - assert_equal(#db.calls, 0, "malformed exact-lookup DIDs do not reach the database") + reset_database() + local broad_uri = "at://" .. broad_did .. "/" .. TAG .. "/tag" + db.lookup_rows[broad_uri] = row(broad_uri, broad_did, "bafyreitag-broad", "tag-a", "2025-01-03T00:00:00Z") + params = { uri = broad_uri } + local exact = get_workscope_tag() + assert_equal(exact.workscopeTag.uri, broad_uri) + assert_equal(db.calls[1].values[2], broad_uri, "exact URI must reach the lookup unchanged") - params = { cursor = cursor_token("2025-01-02T00:00:00Z", "at://did:plc:publisher%GG/" .. TAG .. "/tag") } + reset_database() + local cursor_uri = "at://did:plc:cursor%ZZ/" .. TAG .. "/tag" + params = { cursor = cursor_token("2025-01-02T00:00:00Z", cursor_uri) } + list_workscope_tags() + assert_equal(db.calls[1].values[3], cursor_uri, "broad DID in cursor URI must reach the list query") + + reset_database() + params = { authors = { "did:plc:publisher%" } } assert_error(list_workscope_tags, "InvalidRequest") - assert_equal(#db.calls, 0, "malformed cursor URIs do not reach the database") + assert_equal(#db.calls, 0, "trailing percent remains an invalid DID boundary") params = { cursor = cursor_token("0000-01-01T00:00:00Z", uri_a) } assert_error(list_workscope_tags, "InvalidRequest")