From 65abb97212b0a951ca25bc11a65ae18c7190bae0 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 01:21:34 +0600 Subject: [PATCH 1/3] claim: add contributor-information query endpoints --- README.md | 2 +- api/README.md | 15 + api/lexicons/org.hypercerts.api.defs.json | 14 + ...certs.claim.getContributorInformation.json | 40 +++ ...erts.claim.listContributorInformation.json | 58 ++++ .../endpoints/getContributorInformation.lua | 144 ++++++++ .../endpoints/listContributorInformation.lua | 312 ++++++++++++++++++ api/lua/src/getContributorInformation.lua | 63 ++++ api/lua/src/listContributorInformation.lua | 190 +++++++++++ api/manifest.json | 9 +- .../contributor-information/manifest.json | 53 +++ api/modules/shared/manifest.json | 2 +- api/package.json | 2 +- .../getContributorInformation.test.js | 94 ++++++ .../listContributorInformation.test.js | 103 ++++++ api/tooling/lexicons.test.js | 9 + 16 files changed, 1106 insertions(+), 4 deletions(-) create mode 100644 api/lexicons/org.hypercerts.claim.getContributorInformation.json create mode 100644 api/lexicons/org.hypercerts.claim.listContributorInformation.json create mode 100644 api/lua/endpoints/getContributorInformation.lua create mode 100644 api/lua/endpoints/listContributorInformation.lua create mode 100644 api/lua/src/getContributorInformation.lua create mode 100644 api/lua/src/listContributorInformation.lua create mode 100644 api/modules/contributor-information/manifest.json create mode 100644 api/tests/contracts/getContributorInformation.test.js create mode 100644 api/tests/contracts/listContributorInformation.test.js diff --git a/README.md b/README.md index 512021e..fadd3f8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hypercerts API toolkit foundation -This repository branch contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, and offline checks. It contains shared view Lexicons only; endpoint-specific Lua handlers are added by capability branches. +This repository branch contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, and the contributor-information query handlers. ## Checks diff --git a/api/README.md b/api/README.md index 5fdebc7..3396729 100644 --- a/api/README.md +++ b/api/README.md @@ -17,3 +17,18 @@ These checks are offline and do not require a HappyView instance or database. `p The reusable installer validates every local asset and dependency before making admin requests. `pnpm install:api` contacts a HappyView instance and uploads declared assets; do not run it without an explicitly approved target and token. It does not roll back writes if a later asset fails. Fixture SQL helpers require an explicit disposable loopback database opt-in. Unit tests use local fixture data and fake process/network adapters; they do not seed a database or call an external HappyView service. + +## Contributor-information queries + +The shared module provides two public queries for `org.hypercerts.claim.contributorInformation` records: + +- `org.hypercerts.claim.getContributorInformation({ uri })` returns `{ contributorInformation }` for the exact full record AT-URI. The authority must be a DID. An unindexed URI returns `RecordNotFound`. +- `org.hypercerts.claim.listContributorInformation({ authors?, sortDirection?, limit?, cursor? })` returns `{ contributorInformation, cursor? }`. `authors` filters publisher repository DIDs (OR semantics), supplied as repeated unbracketed query keys, with at most 100 values. + +Both responses use the shared `ContributorInformationView`: record metadata (`uri`, `cid`, nullable `indexedAt`, and publisher `did`), an `author` actor view, and the original full indexed `record`. The older proposal snippet that omits `nullable` for `indexedAt` is stale; the accepted API design requires this field to remain present as JSON `null` when the database timestamp is null. The author contains the publisher's current Certified profile and raw organization sidecar; each is `null` when missing. The record's contributor identifier is not resolved, and referencing activities are not expanded. Operational query or required hydration failures return errors rather than empty or partial results. + +Listings default to 25 records and accept limits from 1 through 100. They sort by `(createdAt, uri)` in descending order unless `sortDirection` is `asc`; cursors are opaque and direction-bound. Keep filters and direction unchanged when continuing a page. The `cursor` property is omitted when there is no next page. A singular `RecordNotFound` means HappyView has no indexed row at that URI; it does not prove the record is absent or deleted from its PDS. + +### Operator notes + +Both queries require the HappyView PostgreSQL backend, including the profile and organization lookups used to hydrate publishers. A missing sidecar is normal and returned as `null`; a backend or lookup failure is an endpoint error. Build and validate locally with `pnpm build:lua` and `pnpm check`. Register these assets through the existing `pnpm install:api` workflow only after confirming the intended HappyView target and credentials; the installer performs external admin writes. diff --git a/api/lexicons/org.hypercerts.api.defs.json b/api/lexicons/org.hypercerts.api.defs.json index 288c4eb..2afee11 100644 --- a/api/lexicons/org.hypercerts.api.defs.json +++ b/api/lexicons/org.hypercerts.api.defs.json @@ -40,6 +40,20 @@ "organization": { "type": "ref", "ref": "#organizationView" } } }, + "contributorInformationView": { + "type": "object", + "description": "Contributor-information record with its full source payload and hydrated publishing actor.", + "required": ["uri", "cid", "indexedAt", "did", "author", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did" }, + "author": { "type": "ref", "ref": "#actorView" }, + "record": { "type": "ref", "ref": "org.hypercerts.claim.contributorInformation" } + } + }, "entityFollowRecordView": { "type": "object", "description": "Raw indexed entity-follow record view, including the DID that published the relationship.", diff --git a/api/lexicons/org.hypercerts.claim.getContributorInformation.json b/api/lexicons/org.hypercerts.claim.getContributorInformation.json new file mode 100644 index 0000000..a79c2df --- /dev/null +++ b/api/lexicons/org.hypercerts.claim.getContributorInformation.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.getContributorInformation", + "defs": { + "main": { + "type": "query", + "description": "Publicly looks up one indexed contributor-information record by exact AT-URI and includes its publisher.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Full contributor-information record AT-URI with a DID authority." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "The URI is invalid or is not a contributor-information record AT-URI." }, + { "name": "RecordNotFound", "description": "No indexed contributor-information record exists at this AT-URI." }, + { "name": "ContributorInformationQueryFailed", "description": "The record or required publisher hydration lookup failed." } + ] + }, + "output": { + "type": "object", + "required": ["contributorInformation"], + "properties": { + "contributorInformation": { + "type": "ref", + "ref": "org.hypercerts.api.defs#contributorInformationView" + } + } + } + } +} diff --git a/api/lexicons/org.hypercerts.claim.listContributorInformation.json b/api/lexicons/org.hypercerts.claim.listContributorInformation.json new file mode 100644 index 0000000..7120352 --- /dev/null +++ b/api/lexicons/org.hypercerts.claim.listContributorInformation.json @@ -0,0 +1,58 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.listContributorInformation", + "defs": { + "main": { + "type": "query", + "description": "Publicly lists indexed contributor-information records with an optional publisher-DID filter and stable createdAt ordering.", + "parameters": { + "type": "params", + "properties": { + "authors": { + "type": "array", + "maxLength": 100, + "description": "Publisher repository DIDs; values use OR.", + "items": { "type": "string", "format": "did" } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Direction for (createdAt, uri); defaults to desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum records in the page; defaults to 25." + }, + "cursor": { + "type": "string", + "description": "Opaque cursor bound to sortDirection; keep other parameters unchanged between pages." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "An author DID, sort direction, page bound, cursor, repeated scalar, or query parameter is invalid." }, + { "name": "ContributorInformationQueryFailed", "description": "The record query or required publisher hydration lookup failed." } + ] + }, + "output": { + "type": "object", + "required": ["contributorInformation"], + "properties": { + "contributorInformation": { + "type": "array", + "items": { "type": "ref", "ref": "org.hypercerts.api.defs#contributorInformationView" } + }, + "cursor": { + "type": "string", + "description": "Opaque cursor for the next page; omitted when there is no next page." + } + } + } + } +} diff --git a/api/lua/endpoints/getContributorInformation.lua b/api/lua/endpoints/getContributorInformation.lua new file mode 100644 index 0000000..03e51b2 --- /dev/null +++ b/api/lua/endpoints/getContributorInformation.lua @@ -0,0 +1,144 @@ +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter") end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local 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 = valid_record_uri(value) + if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return contributor_information_did(authority) +end + +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information lookup failed", 0) + end + return result +end + +local function preserve_contributor_author_timestamps(author) + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if type(sidecar) == "table" and sidecar.uri ~= nil and sidecar.indexedAt == nil then + sidecar.indexedAt = json.decode("null") + end + end +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not contributor_information_uri(uri) then + invalid("uri must be a full org.hypercerts.claim.contributorInformation AT-URI with a DID authority") + end + + local rows = contributor_information_query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND uri = $2 LIMIT 1", + { CONTRIBUTOR_INFORMATION_COLLECTION, uri }) + if #rows == 0 then error("RecordNotFound: contributor-information record is not indexed", 0) end + + local view = record_view(rows[1]) + if rows[1].indexed_at == nil then view.indexedAt = json.decode("null") end + local author = { did = rows[1].did } + hydrate_actor_views({ author }, contributor_information_query) + preserve_contributor_author_timestamps(author) + view.author = author + return { contributorInformation = view } +end diff --git a/api/lua/endpoints/listContributorInformation.lua b/api/lua/endpoints/listContributorInformation.lua new file mode 100644 index 0000000..e3f59e7 --- /dev/null +++ b/api/lua/endpoints/listContributorInformation.lua @@ -0,0 +1,312 @@ +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter") end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local function valid_datetime(value) + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local 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 = valid_record_uri(value) + if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return contributor_information_did(authority) +end + +local function contributor_information_datetime(value) + return valid_datetime(value) and tonumber(value:sub(1, 4)) > 0 +end + +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information query failed", 0) + end + return result +end + +local function contributor_information_array(params, key, validate, description) + local value = params[key] + if value == nil then return nil end + + local supplied = {} + if type(value) == "string" then + supplied[1] = value + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #supplied > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for _, item in ipairs(supplied) do + if validate and not validate(item) then + invalid("each " .. key .. " value must be " .. description .. "; resolve handles to DIDs first") + end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function contributor_information_bind_in(where, values, expression, items) + if not items then return end + if #items == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = expression .. " IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function contributor_information_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 + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not contributor_information_uri(value.u) or not contributor_information_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function preserve_contributor_author_timestamps(author) + local null = json.decode("null") + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if sidecar ~= null and type(sidecar) == "table" and sidecar.indexedAt == nil then + sidecar.indexedAt = null + end + end +end + +local function contributor_information_sort_expression() + local created = "contributor.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(contributor.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(contributor.indexed_at::timestamptz, contributor.created_at::timestamptz) END" +end + +local function query_contributor_information(authors, limit, cursor, direction) + local where, values = { "contributor.collection = $1" }, { CONTRIBUTOR_INFORMATION_COLLECTION } + contributor_information_bind_in(where, values, "contributor.did", authors) + + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, contributor.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sort_key = contributor_information_sort_expression() + local sql = "SELECT contributor.uri, contributor.did, contributor.cid, " .. + "contributor.indexed_at::text AS indexed_at, contributor.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 contributor CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", contributor.uri " .. ordering .. " LIMIT $" .. #values + local rows = contributor_information_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views, authors_to_hydrate = {}, {} + for _, row in ipairs(rows) do + local view = record_view(row) + if row.indexed_at == nil then view.indexedAt = json.decode("null") end + view.author = { did = row.did } + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + hydrate_actor_views(authors_to_hydrate, contributor_information_query) + for _, author in ipairs(authors_to_hydrate) do preserve_contributor_author_timestamps(author) end + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +function handle() + keys_only(params, { + authors = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local authors = contributor_information_array(params, "authors", contributor_information_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) + local views, next_cursor = query_contributor_information(authors, limit, cursor, direction) + local response = { contributorInformation = toarray(views) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/api/lua/src/getContributorInformation.lua b/api/lua/src/getContributorInformation.lua new file mode 100644 index 0000000..73f7248 --- /dev/null +++ b/api/lua/src/getContributorInformation.lua @@ -0,0 +1,63 @@ +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 = valid_record_uri(value) + if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return contributor_information_did(authority) +end + +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information lookup failed", 0) + end + return result +end + +local function preserve_contributor_author_timestamps(author) + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if type(sidecar) == "table" and sidecar.uri ~= nil and sidecar.indexedAt == nil then + sidecar.indexedAt = json.decode("null") + end + end +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not contributor_information_uri(uri) then + invalid("uri must be a full org.hypercerts.claim.contributorInformation AT-URI with a DID authority") + end + + local rows = contributor_information_query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND uri = $2 LIMIT 1", + { CONTRIBUTOR_INFORMATION_COLLECTION, uri }) + if #rows == 0 then error("RecordNotFound: contributor-information record is not indexed", 0) end + + local view = record_view(rows[1]) + if rows[1].indexed_at == nil then view.indexedAt = json.decode("null") end + local author = { did = rows[1].did } + hydrate_actor_views({ author }, contributor_information_query) + preserve_contributor_author_timestamps(author) + view.author = author + return { contributorInformation = view } +end diff --git a/api/lua/src/listContributorInformation.lua b/api/lua/src/listContributorInformation.lua new file mode 100644 index 0000000..14126a4 --- /dev/null +++ b/api/lua/src/listContributorInformation.lua @@ -0,0 +1,190 @@ +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 = valid_record_uri(value) + if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return contributor_information_did(authority) +end + +local function contributor_information_datetime(value) + return valid_datetime(value) and tonumber(value:sub(1, 4)) > 0 +end + +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information query failed", 0) + end + return result +end + +local function contributor_information_array(params, key, validate, description) + local value = params[key] + if value == nil then return nil end + + local supplied = {} + if type(value) == "string" then + supplied[1] = value + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #supplied > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for _, item in ipairs(supplied) do + if validate and not validate(item) then + invalid("each " .. key .. " value must be " .. description .. "; resolve handles to DIDs first") + end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function contributor_information_bind_in(where, values, expression, items) + if not items then return end + if #items == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = expression .. " IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function contributor_information_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 + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not contributor_information_uri(value.u) or not contributor_information_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function preserve_contributor_author_timestamps(author) + local null = json.decode("null") + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if sidecar ~= null and type(sidecar) == "table" and sidecar.indexedAt == nil then + sidecar.indexedAt = null + end + end +end + +local function contributor_information_sort_expression() + local created = "contributor.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(contributor.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(contributor.indexed_at::timestamptz, contributor.created_at::timestamptz) END" +end + +local function query_contributor_information(authors, limit, cursor, direction) + local where, values = { "contributor.collection = $1" }, { CONTRIBUTOR_INFORMATION_COLLECTION } + contributor_information_bind_in(where, values, "contributor.did", authors) + + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, contributor.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sort_key = contributor_information_sort_expression() + local sql = "SELECT contributor.uri, contributor.did, contributor.cid, " .. + "contributor.indexed_at::text AS indexed_at, contributor.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 contributor CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", contributor.uri " .. ordering .. " LIMIT $" .. #values + local rows = contributor_information_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views, authors_to_hydrate = {}, {} + for _, row in ipairs(rows) do + local view = record_view(row) + if row.indexed_at == nil then view.indexedAt = json.decode("null") end + view.author = { did = row.did } + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + hydrate_actor_views(authors_to_hydrate, contributor_information_query) + for _, author in ipairs(authors_to_hydrate) do preserve_contributor_author_timestamps(author) end + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +function handle() + keys_only(params, { + authors = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local authors = contributor_information_array(params, "authors", contributor_information_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) + local views, next_cursor = query_contributor_information(authors, limit, cursor, direction) + local response = { contributorInformation = toarray(views) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/api/manifest.json b/api/manifest.json index 9436815..69c4b63 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -7,6 +7,8 @@ { "id": "app.certified.graph.entityFollow", "packagePath": "lexicons/app/certified/graph/entityFollow.json" }, { "id": "app.certified.actor.profile", "packagePath": "lexicons/app/certified/actor/profile.json" }, { "id": "org.hypercerts.claim.getActivity", "path": "lexicons/org.hypercerts.claim.getActivity.json" }, + { "id": "org.hypercerts.claim.getContributorInformation", "path": "lexicons/org.hypercerts.claim.getContributorInformation.json" }, + { "id": "org.hypercerts.claim.listContributorInformation", "path": "lexicons/org.hypercerts.claim.listContributorInformation.json" }, { "id": "org.hypercerts.context.evaluation", "packagePath": "lexicons/org/hypercerts/context/evaluation.json" }, { "id": "org.hypercerts.collection.getCollection", "path": "lexicons/org.hypercerts.collection.getCollection.json" }, { "id": "org.hypercerts.collection.listCollectionItems", "path": "lexicons/org.hypercerts.collection.listCollectionItems.json" }, @@ -51,5 +53,10 @@ { "id": "pub.leaflet.richtext.facet", "packagePath": "lexicons/pub/leaflet/richtext/facet.json" }, { "id": "pub.leaflet.theme.color", "packagePath": "lexicons/pub/leaflet/theme/color.json" } ], - "modules": ["modules/shared/manifest.json"] + "modules": ["modules/shared/manifest.json", "modules/contributor-information/manifest.json"], + "handlerStatus": { + "getContributorInformation": "implemented", + "listContributorInformation": "implemented" + }, + "authentication": { "unresolved": false } } diff --git a/api/modules/contributor-information/manifest.json b/api/modules/contributor-information/manifest.json new file mode 100644 index 0000000..e626c9c --- /dev/null +++ b/api/modules/contributor-information/manifest.json @@ -0,0 +1,53 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "org.hypercerts.claim.getContributorInformation", + "path": "../../lexicons/org.hypercerts.claim.getContributorInformation.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.contributorInformation" }, + "dependsOn": ["org.hypercerts.claim.contributorInformation", "org.hypercerts.api.defs"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.claim.listContributorInformation", + "path": "../../lexicons/org.hypercerts.claim.listContributorInformation.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.contributorInformation" }, + "dependsOn": ["org.hypercerts.claim.contributorInformation", "org.hypercerts.api.defs"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.getContributorInformation", + "path": "../../lua/endpoints/getContributorInformation.lua", + "sourcePath": "../../lua/src/getContributorInformation.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua" + ], + "config": { + "script_type": "lua", + "description": "Look up a contributor-information record and its publisher." + }, + "dependsOn": ["org.hypercerts.claim.getContributorInformation"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.listContributorInformation", + "path": "../../lua/endpoints/listContributorInformation.lua", + "sourcePath": "../../lua/src/listContributorInformation.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/listQuery.lua" + ], + "config": { + "script_type": "lua", + "description": "List contributor-information records with publisher hydration." + }, + "dependsOn": ["org.hypercerts.claim.listContributorInformation"] + } + ] +} diff --git a/api/modules/shared/manifest.json b/api/modules/shared/manifest.json index a7ebb95..260712e 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -22,7 +22,7 @@ "id": "org.hypercerts.api.defs", "path": "../../lexicons/org.hypercerts.api.defs.json", "config": { "backfill": false }, - "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile"] + "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile", "org.hypercerts.claim.contributorInformation"] }, { "kind": "lexicon", diff --git a/api/package.json b/api/package.json index 8fb1d5b..d1642ef 100644 --- a/api/package.json +++ b/api/package.json @@ -17,7 +17,7 @@ "check:generated": "node tooling/build-lua.js --check", "check": "pnpm run check:generated && pnpm run lint && pnpm run typecheck && pnpm run test:unit", "build:lua": "node tooling/build-lua.js", - "test:unit": "node --test tooling/*.test.js tests/fixtures/*.test.js tests/contracts/helpers.test.js", + "test:unit": "node --test tooling/*.test.js tests/fixtures/*.test.js tests/contracts/*.test.js", "install:api": "node tooling/installer.js", "seed:test": "node tooling/seed.js", "seed:bad-dates": "node tooling/seed.js --bad-dates" diff --git a/api/tests/contracts/getContributorInformation.test.js b/api/tests/contracts/getContributorInformation.test.js new file mode 100644 index 0000000..6555f65 --- /dev/null +++ b/api/tests/contracts/getContributorInformation.test.js @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); + +test('getContributorInformation preserves the exact record and hydrates its publisher sidecars', async () => { + const sources = await Promise.all([ + read('lua/shared/query.lua'), + read('lua/shared/recordIdentifier.lua'), + read('lua/shared/recordView.lua'), + read('lua/shared/actorView.lua'), + read('lua/src/getContributorInformation.lua'), + ]); + const program = `JSON_NULL = {} +json = { decode = function(value) + if value == "null" then return JSON_NULL end + error("unexpected JSON decode before test fixtures") +end } +${sources.join('\n\n')} + +local null = JSON_NULL +local decoded = { + original = { ["$type"] = "org.hypercerts.claim.contributorInformation", identifier = "manual:ada", displayName = "Ada", image = { ref = "blob" }, extension = "preserved" }, + profile = { ["$type"] = "app.certified.actor.profile", displayName = "Publisher" }, +} +json = { decode = function(value) + if value == "null" then return null end + assert(decoded[value], "unexpected JSON fixture: " .. tostring(value)) + return decoded[value] +end } + +local uri = "at://did:plc:publisher/org.hypercerts.claim.contributorInformation/tid" +local recordRow = { uri = uri, did = "did:plc:publisher", cid = "bafyreirecord", indexed_at = "2026-01-02T03:04:05Z", record = "original" } +local profileRow = { uri = "at://did:plc:publisher/app.certified.actor.profile/self", did = "did:plc:publisher", cid = "bafyreiprofile", indexed_at = nil, record = "profile" } +local calls = {} +local record_missing = false +local fail_profile_lookup = false +db = { + backend = function() return "postgres" end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if values[1] == "org.hypercerts.claim.contributorInformation" then + if record_missing then return {} end + return { recordRow } + end + if values[1] == "app.certified.actor.profile" then + if fail_profile_lookup then error("database unavailable") end + return { profileRow } + end + if values[1] == "app.certified.actor.organization" then return {} end + error("unexpected query collection: " .. tostring(values[1])) + end, +} +params = { uri = uri } +local result = handle() +local view = result.contributorInformation +assert(view.uri == uri and view.cid == "bafyreirecord" and view.did == "did:plc:publisher") +assert(view.record["$type"] == "org.hypercerts.claim.contributorInformation") +assert(view.record.identifier == "manual:ada" and view.record.displayName == "Ada") +assert(view.record.image.ref == "blob" and view.record.extension == "preserved") +assert(view.author.did == "did:plc:publisher") +assert(view.author.profile.record.displayName == "Publisher") +assert(view.author.profile.indexedAt == JSON_NULL, "a missing profile indexed_at must serialize as JSON null") +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) +local bad_uri_ok, bad_uri_error = pcall(function() + params = { uri = "at://did:plc:publisher%GG/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") +recordRow.indexed_at = nil +params = { uri = uri } +local null_timestamp = handle().contributorInformation.indexedAt +assert(null_timestamp == JSON_NULL, "a missing indexed_at must serialize as JSON null") +record_missing = true +local missing_ok, missing_error = pcall(function() return handle() end) +assert(not missing_ok and tostring(missing_error):find("RecordNotFound:", 1, true) ~= nil) +record_missing = false +fail_profile_lookup = true +local lookup_ok, lookup_error = pcall(function() return handle() end) +assert(not lookup_ok and tostring(lookup_error):find("ContributorInformationQueryFailed:", 1, true) ~= nil) +`; + const result = spawnSync('lua', ['-e', program], { encoding: 'utf8' }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); diff --git a/api/tests/contracts/listContributorInformation.test.js b/api/tests/contracts/listContributorInformation.test.js new file mode 100644 index 0000000..3ed3f0a --- /dev/null +++ b/api/tests/contracts/listContributorInformation.test.js @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); + +test('listContributorInformation filters repeated authors and resumes in the selected order', async () => { + const sources = await Promise.all([ + read('lua/shared/query.lua'), + read('lua/shared/recordIdentifier.lua'), + read('lua/shared/recordView.lua'), + read('lua/shared/actorView.lua'), + read('lua/shared/listQuery.lua'), + read('lua/src/listContributorInformation.lua'), + ]); + const program = `JSON_NULL = {} +JSON_VALUES = {} +json = { + decode = function(value) + if value == "null" then return JSON_NULL end + assert(JSON_VALUES[value], "unexpected JSON fixture: " .. tostring(value)) + return JSON_VALUES[value] + end, + encode = function(value) + assert(type(value) == "table" and value.v == 1, "unexpected JSON value to encode") + JSON_VALUES.cursor = value + return "cursor" + end, +} +${sources.join('\n\n')} + +JSON_VALUES.record1 = { ["$type"] = "org.hypercerts.claim.contributorInformation", identifier = "manual:ada", displayName = "Ada", image = { ref = "blob" } } +JSON_VALUES.record2 = { ["$type"] = "org.hypercerts.claim.contributorInformation", identifier = "manual:grace", displayName = "Grace" } +JSON_VALUES.profile = { ["$type"] = "app.certified.actor.profile", displayName = "Publisher" } +local first_uri = "at://did:plc:publisher/org.hypercerts.claim.contributorInformation/tid1" +local second_uri = "at://did:plc:publisher/org.hypercerts.claim.contributorInformation/tid2" +local first = { uri = first_uri, did = "did:plc:publisher", cid = "bafyreione", indexed_at = "2026-01-01T00:00:00Z", record = "record1", sort_timestamp = "2026-01-01T00:00:00.000000Z" } +local second = { uri = second_uri, did = "did:plc:publisher", cid = "bafyreitwo", indexed_at = "2026-01-02T00:00:00Z", record = "record2", sort_timestamp = "2026-01-02T00:00:00.000000Z" } +local profile = { uri = "at://did:plc:publisher/app.certified.actor.profile/self", did = "did:plc:publisher", cid = "bafyreiprofile", indexed_at = nil, record = "profile" } +local page = 0 +local record_queries = {} +db = { + backend = function() return "postgres" end, + raw = function(sql, values) + if values[1] == "org.hypercerts.claim.contributorInformation" then + page = page + 1 + record_queries[page] = { sql = sql, values = values } + if page == 1 then return { first, second } end + if page == 2 then return { second } end + return {} + end + if values[1] == "app.certified.actor.profile" then return { profile } end + if values[1] == "app.certified.actor.organization" then return {} end + error("unexpected query collection: " .. tostring(values[1])) + end, +} +toarray = function(values) return values end +params = { authors = { "did:plc:publisher", "did:plc:other" }, sortDirection = "asc", limit = "1" } +local first_page = handle() +assert(#first_page.contributorInformation == 1) +local first_view = first_page.contributorInformation[1] +assert(first_view.uri == first_uri and first_view.did == "did:plc:publisher") +assert(first_view.record.identifier == "manual:ada" and first_view.record.displayName == "Ada") +assert(first_view.author.did == "did:plc:publisher" and first_view.author.profile.record.displayName == "Publisher") +assert(first_view.author.profile.indexedAt == JSON_NULL, "a missing profile indexed_at must serialize as JSON null") +assert(first_view.author.organization == JSON_NULL) +assert(type(first_page.cursor) == "string" and #first_page.cursor > 0) +local first_query = record_queries[1] +assert(first_query.values[1] == "org.hypercerts.claim.contributorInformation") +assert(first_query.values[2] == "did:plc:publisher" and first_query.values[3] == "did:plc:other") +assert(first_query.values[4] == 2) +assert(first_query.sql:find("contributor.did IN ($2, $3)", 1, true) ~= nil) +assert(first_query.sql:find("ORDER BY sorted.sort_at ASC, contributor.uri ASC", 1, true) ~= nil) +assert(first_query.sql:find("LIMIT $4", 1, true) ~= nil) + +params = { authors = { "did:plc:publisher", "did:plc:other" }, sortDirection = "asc", limit = "1", cursor = first_page.cursor } +local second_page = handle() +assert(#second_page.contributorInformation == 1 and second_page.contributorInformation[1].uri == second_uri, "second page returned " .. tostring(#second_page.contributorInformation) .. " row(s): " .. tostring(second_page.contributorInformation[1] and second_page.contributorInformation[1].uri)) +assert(second_page.cursor == nil) +local second_query = record_queries[2] +assert(second_query.values[4] == "2026-01-01T00:00:00.000000Z" and second_query.values[5] == first_uri) +assert(second_query.values[6] == 2) +assert(second_query.sql:find("(sorted.sort_at, contributor.uri) > (($4)::timestamptz, $5)", 1, true) ~= nil) +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") +JSON_VALUES.cursor = { v = 1, d = "asc", t = "0000-01-01T00:00:00Z", u = first_uri } +params = { sortDirection = "asc", limit = "1", cursor = "637572736f72" } +local bad_cursor_ok, bad_cursor_error = pcall(function() return handle() end) +assert(not bad_cursor_ok and tostring(bad_cursor_error):find("InvalidRequest:", 1, true) ~= nil) +assert(page == completed_pages, "year-zero cursor must be rejected before PostgreSQL sees it") +`; + const result = spawnSync('lua', ['-e', program], { encoding: 'utf8' }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); diff --git a/api/tooling/lexicons.test.js b/api/tooling/lexicons.test.js index 127493d..c276774 100644 --- a/api/tooling/lexicons.test.js +++ b/api/tooling/lexicons.test.js @@ -123,6 +123,15 @@ if (hasModule('modules/actor-follow/manifest.json')) test('follow query Lexicons }); }); +test('contributor-information view keeps nullable indexedAt required', async () => { + const { lexicons } = await validatePackageLexicons(); + const view = lexicons.getDefOrThrow('org.hypercerts.api.defs#contributorInformationView'); + assert.ok(view.required.includes('indexedAt')); + assert.ok(view.nullable.includes('indexedAt')); + assert.equal(view.properties.author.ref, 'lex:org.hypercerts.api.defs#actorView'); + assert.equal(view.properties.record.ref, 'lex:org.hypercerts.claim.contributorInformation'); +}); + test('installed ATProto validator accepts package language, transitive refs, and real fixture records', async () => { const { lexicons, isValidDid, isValidTid } = await validatePackageLexicons(); const { jsonToLex, lexToJson } = await import('@atproto/lexicon'); From ea3a10e9e82bcd4868814d072eee7c1b770a7f7e Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 16:29:50 +0600 Subject: [PATCH 2/3] contributor-information: cover installed queries over HTTP --- api/README.md | 4 +- .../contributor-information.fixture.js | 51 ++++++++++ .../getContributorInformation.http.test.js | 70 ++++++++++++++ .../listContributorInformation.http.test.js | 94 +++++++++++++++++++ .../getContributorInformation.test.js | 2 +- .../listContributorInformation.test.js | 2 +- 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 api/tests/http/fixtures/contributor-information.fixture.js create mode 100644 api/tests/http/getContributorInformation.http.test.js create mode 100644 api/tests/http/listContributorInformation.http.test.js rename api/tests/{contracts => unit/tooling}/getContributorInformation.test.js (98%) rename api/tests/{contracts => unit/tooling}/listContributorInformation.test.js (98%) diff --git a/api/README.md b/api/README.md index 9a1f2ba..b3f0b04 100644 --- a/api/README.md +++ b/api/README.md @@ -20,9 +20,9 @@ PSQL_PATH="$(command -v psql)" pnpm test:http ## HTTP runtime tests -HTTP suites live in `api/tests/http` and call the two funding and two badge-definition XRPC endpoints installed from the current checkout. `pnpm test:http` discovers `*.http.test.js` suites and fixture modules named `*.fixture.js`, then creates a random Compose project with loopback-only dynamic ports, PostgreSQL data on tmpfs, and a task-owned default bridge network. Bridge networking permits container egress. HappyView receives loopback placeholder upstream URLs and proxy variables pointing to `127.0.0.1:9`; these are application-level settings, not network-hard egress isolation. The installer and HTTP suites target only the task-owned loopback service. The runner installs this checkout's manifest, seeds shared and HTTP fixtures, runs the suites, and tears down only that generated Compose project and its temporary credentials. Locally, Compose uses `--pull never`; missing cached images fail before service startup. +HTTP suites live in `api/tests/http` and call the installed funding (`org.hypercerts.funding.getReceipt`, `org.hypercerts.funding.listReceipts`), badge-definition (`app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions`), and contributor-information (`org.hypercerts.claim.getContributorInformation`, `org.hypercerts.claim.listContributorInformation`) XRPC endpoints. `pnpm test:http` discovers `*.http.test.js` suites and fixture modules named `*.fixture.js`, then creates a random Compose project with loopback-only dynamic ports, PostgreSQL data on tmpfs, and a task-owned default bridge network. Bridge networking permits container egress. HappyView receives loopback placeholder upstream URLs and proxy variables pointing to `127.0.0.1:9`; these are application-level settings, not network-hard egress isolation. The installer and HTTP suites target only the task-owned loopback service. The runner installs this checkout's manifest, seeds shared and HTTP fixtures, runs the suites, and tears down only that generated Compose project and its temporary credentials. Locally, Compose uses `--pull never`; missing cached images fail before service startup. -The HTTP gate fails when it discovers zero suites, executes zero `node:test` cases, or runs only skipped cases. These checks cover real HTTP behavior against PostgreSQL, not just Lua handlers with a fake database. Funding coverage exercises record retrieval, repeated filters, and pagination. Badge-definition coverage exercises retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, createdAt/URI pagination ties, and named error responses. Badge fixtures use CBOR-derived record CIDs and are seeded only into the task-owned disposable database. +The HTTP gate fails when it discovers zero suites, executes zero `node:test` cases, or runs only skipped cases. These checks cover real HTTP behavior against PostgreSQL, not just Lua handlers with a fake database. Funding coverage exercises record retrieval, repeated filters, and pagination. Badge-definition coverage exercises retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, createdAt/URI pagination ties, and named error responses. Contributor-information coverage exercises both endpoints: exact AT-URI retrieval with its CBOR-derived CID returned but not supplied in the request; repeated-author filtering; tied createdAt/URI pagination in both ascending and descending directions; hydrated and null author sidecars; and named `RecordNotFound` and `InvalidRequest` runtime errors. Contributor fixtures are seeded only into the task-owned disposable database. For the pinned HappyView release, ordinary Lua `error()` exceptions are returned as HTTP 500 JSON with `error: "script_error"` and `errorType: "runtime"`; the error name appears in `message`. The negative HTTP tests assert this observed runtime behavior. They do not define an ideal public HTTP status contract or guarantee 4xx mapping for `RecordNotFound` and `InvalidRequest`. diff --git a/api/tests/http/fixtures/contributor-information.fixture.js b/api/tests/http/fixtures/contributor-information.fixture.js new file mode 100644 index 0000000..57e4ddf --- /dev/null +++ b/api/tests/http/fixtures/contributor-information.fixture.js @@ -0,0 +1,51 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const contributorCollection = 'org.hypercerts.claim.contributorInformation'; +const profileCollection = 'app.certified.actor.profile'; +const organizationCollection = 'app.certified.actor.organization'; +const authorA = 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'; +const authorB = 'did:plc:nnnnnnnnnnnnnnnnnnnnnnnn'; +const authorC = 'did:plc:oooooooooooooooooooooooo'; +const createdAt = '2025-03-01T00:00:00.000Z'; +const indexedAt = '2025-03-02T00:00:00.000Z'; + +async function seedRow(collection, did, rkey, fields) { + const record = { $type: collection, ...fields }; + return { + uri: `at://${did}/${collection}/${rkey}`, + did, + collection, + rkey, + cid: CID.toString(await CID.create(0x71, encode(record))), + indexedAt, + record, + }; +} + +export const seedRows = await Promise.all([ + seedRow(contributorCollection, authorA, '3jzfcijpj2z2a', { + identifier: 'manual:cedar-restorer', + displayName: 'Cedar Restorer', + createdAt, + }), + seedRow(contributorCollection, authorB, '3jzfcijpj2z2b', { + identifier: 'manual:river-monitor', + displayName: 'River Monitor', + createdAt, + }), + seedRow(contributorCollection, authorC, '3jzfcijpj2z2c', { + identifier: 'manual:forest-keeper', + displayName: 'Forest Keeper', + createdAt, + }), + seedRow(profileCollection, authorA, 'self', { + displayName: 'Cedar Watershed Group', + createdAt: indexedAt, + }), + seedRow(organizationCollection, authorA, 'self', { + organizationType: ['nonprofit'], + visibility: 'public', + createdAt: indexedAt, + }), +]); diff --git a/api/tests/http/getContributorInformation.http.test.js b/api/tests/http/getContributorInformation.http.test.js new file mode 100644 index 0000000..28f7749 --- /dev/null +++ b/api/tests/http/getContributorInformation.http.test.js @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const contributorUri = 'at://did:plc:mmmmmmmmmmmmmmmmmmmmmmmm/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; +const collection = 'org.hypercerts.claim.contributorInformation'; + +async function getContributorInformation(uri) { + const url = contractUrl(requireContractTarget(), 'org.hypercerts.claim.getContributorInformation', { uri }); + const response = await fetch(url, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { response, body }; +} + +test('getContributorInformation fetches by AT-URI without supplying its pinned CID and hydrates publisher records', async () => { + const { response, body } = await getContributorInformation(contributorUri); + assert.equal(response.status, 200, JSON.stringify(body)); + + const view = body.contributorInformation; + assert.equal(view.uri, contributorUri); + assert.equal(view.did, 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'); + assert.equal(view.record.$type, collection); + assert.equal(view.record.identifier, 'manual:cedar-restorer'); + assert.equal(view.record.displayName, 'Cedar Restorer'); + assert.equal( + view.cid, + 'bafyreicjb36r5phxdx46gn5m75zk7csjqs6j47xnstvxoerdfy7jvkajxy', + 'the AT-URI lookup returns its known CBOR-derived CID without supplying that CID in the request', + ); + assert.equal(view.author.did, 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'); + assert.equal(view.author.profile.record.displayName, 'Cedar Watershed Group'); + assert.deepEqual(view.author.organization.record.organizationType, ['nonprofit']); +}); + +test('getContributorInformation returns null for absent publisher sidecars', async () => { + const missingSidecarUri = 'at://did:plc:nnnnnnnnnnnnnnnnnnnnnnnn/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2b'; + const { response, body } = await getContributorInformation(missingSidecarUri); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.contributorInformation.author.profile, null); + assert.equal(body.contributorInformation.author.organization, null); +}); + +test('getContributorInformation exposes RecordNotFound as the pinned HappyView runtime error', async () => { + const missingUri = 'at://did:plc:mmmmmmmmmmmmmmmmmmmmmmmm/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2d'; + const { response, body } = await getContributorInformation(missingUri); + 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, /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/); +}); diff --git a/api/tests/http/listContributorInformation.http.test.js b/api/tests/http/listContributorInformation.http.test.js new file mode 100644 index 0000000..62d2269 --- /dev/null +++ b/api/tests/http/listContributorInformation.http.test.js @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const endpoint = 'org.hypercerts.claim.listContributorInformation'; +const authorA = 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'; +const authorB = 'did:plc:nnnnnnnnnnnnnnnnnnnnnnnn'; +const authorC = 'did:plc:oooooooooooooooooooooooo'; +const uris = { + baseline: 'at://did:plc:gggggggggggggggggggggggg/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2h', + a: `at://${authorA}/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a`, + b: `at://${authorB}/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2b`, + c: `at://${authorC}/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2c`, +}; + +async function listContributorInformation(params = {}) { + const url = contractUrl(requireContractTarget(), endpoint, params); + const response = await fetch(url, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { response, body }; +} + +test('listContributorInformation applies repeated author filters and excludes other publishers', async () => { + const { response, body } = await listContributorInformation({ + authors: [authorA, authorC], + sortDirection: 'asc', + limit: 10, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.contributorInformation.map(({ uri }) => uri), [uris.a, uris.c]); + assert.equal(body.contributorInformation[0].author.profile.record.displayName, 'Cedar Watershed Group'); + assert.deepEqual(body.contributorInformation[0].author.organization.record.organizationType, ['nonprofit']); + assert.equal(body.contributorInformation[1].author.profile, null); + assert.equal(body.contributorInformation[1].author.organization, null); + assert.equal(Object.hasOwn(body, 'cursor'), false); +}); + +test('listContributorInformation paginates tied createdAt values in both directions and retains baseline rows', async () => { + for (const { direction, firstUris, secondUris, allUris } of [ + { + direction: 'asc', + firstUris: [uris.baseline, uris.a], + secondUris: [uris.b, uris.c], + allUris: [uris.baseline, uris.a, uris.b, uris.c], + }, + { + direction: 'desc', + firstUris: [uris.c, uris.b], + secondUris: [uris.a, uris.baseline], + allUris: [uris.c, uris.b, uris.a, uris.baseline], + }, + ]) { + const first = await listContributorInformation({ sortDirection: direction, limit: 2 }); + assert.equal(first.response.status, 200, JSON.stringify(first.body)); + assert.deepEqual(first.body.contributorInformation.map(({ uri }) => uri), firstUris); + assert.equal(typeof first.body.cursor, 'string'); + assert.ok(first.body.cursor.length > 0); + + const second = await listContributorInformation({ sortDirection: direction, limit: 2, cursor: first.body.cursor }); + assert.equal(second.response.status, 200, JSON.stringify(second.body)); + assert.deepEqual(second.body.contributorInformation.map(({ uri }) => uri), secondUris); + assert.equal(Object.hasOwn(second.body, 'cursor'), false); + assert.deepEqual( + [...first.body.contributorInformation, ...second.body.contributorInformation].map(({ uri }) => uri), + allUris, + ); + + const allRows = [...first.body.contributorInformation, ...second.body.contributorInformation]; + const missingSidecar = allRows.find(({ uri }) => uri === uris.b); + assert.ok(missingSidecar); + assert.equal(missingSidecar.author.profile, null); + assert.equal(missingSidecar.author.organization, null); + const baseline = allRows.find(({ uri }) => uri === uris.baseline); + assert.equal(baseline.author.profile.record.displayName, 'Activity fixture author'); + } +}); + +test('listContributorInformation exposes InvalidRequest as the pinned HappyView runtime error', async () => { + const { response, body } = await listContributorInformation({ limit: 101 }); + assert.equal(response.status, 500, JSON.stringify(body)); + assert.equal(body.error, 'script_error'); + assert.equal(body.errorType, 'runtime'); + assert.equal(body.method, endpoint); + assert.match(body.message, /InvalidRequest/); +}); diff --git a/api/tests/contracts/getContributorInformation.test.js b/api/tests/unit/tooling/getContributorInformation.test.js similarity index 98% rename from api/tests/contracts/getContributorInformation.test.js rename to api/tests/unit/tooling/getContributorInformation.test.js index 6555f65..297b569 100644 --- a/api/tests/contracts/getContributorInformation.test.js +++ b/api/tests/unit/tooling/getContributorInformation.test.js @@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const root = fileURLToPath(new URL('../../', import.meta.url)); +const root = fileURLToPath(new URL('../../../', import.meta.url)); const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); test('getContributorInformation preserves the exact record and hydrates its publisher sidecars', async () => { diff --git a/api/tests/contracts/listContributorInformation.test.js b/api/tests/unit/tooling/listContributorInformation.test.js similarity index 98% rename from api/tests/contracts/listContributorInformation.test.js rename to api/tests/unit/tooling/listContributorInformation.test.js index 3ed3f0a..f1b4c35 100644 --- a/api/tests/contracts/listContributorInformation.test.js +++ b/api/tests/unit/tooling/listContributorInformation.test.js @@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const root = fileURLToPath(new URL('../../', import.meta.url)); +const root = fileURLToPath(new URL('../../../', import.meta.url)); const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); test('listContributorInformation filters repeated authors and resumes in the selected order', async () => { From 74cf9bf3cc54af1842dbfed8c8334bd57a5fcbd3 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 19:09:23 +0600 Subject: [PATCH 3/3] contributor-information: share Lua validation and relocate record view --- .../contributor-information-view-ref.md | 5 ++ api/README.md | 2 +- api/lexicons/org.hypercerts.api.defs.json | 14 ----- ...certs.claim.getContributorInformation.json | 16 +++++- ...erts.claim.listContributorInformation.json | 2 +- .../endpoints/getContributorInformation.lua | 53 +++++++++--------- .../endpoints/listContributorInformation.lua | 54 ++++++++++--------- .../contributorInformationValidation.lua | 19 +++++++ api/lua/src/getContributorInformation.lua | 21 -------- api/lua/src/listContributorInformation.lua | 21 -------- .../contributor-information/manifest.json | 7 ++- api/modules/shared/manifest.json | 2 +- .../contributor-information.fixture.js | 6 +-- .../getContributorInformation.http.test.js | 10 ++-- .../listContributorInformation.http.test.js | 6 +-- .../tooling/getContributorInformation.test.js | 19 +++++++ api/tests/unit/tooling/lexicons.test.js | 21 ++++++-- .../listContributorInformation.test.js | 29 +++++++++- 18 files changed, 179 insertions(+), 128 deletions(-) create mode 100644 .changeset/contributor-information-view-ref.md create mode 100644 api/lua/shared/contributorInformationValidation.lua diff --git a/.changeset/contributor-information-view-ref.md b/.changeset/contributor-information-view-ref.md new file mode 100644 index 0000000..ca489c3 --- /dev/null +++ b/.changeset/contributor-information-view-ref.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Contributor-information query schemas now share their response-view definition from `getContributorInformation`. Response JSON is unchanged. diff --git a/api/README.md b/api/README.md index 9ef6d3f..c4e64e2 100644 --- a/api/README.md +++ b/api/README.md @@ -53,7 +53,7 @@ The shared module provides two public queries for `org.hypercerts.claim.contribu - `org.hypercerts.claim.getContributorInformation({ uri })` returns `{ contributorInformation }` for the exact full record AT-URI. The authority must be a DID. An unindexed URI returns `RecordNotFound`. - `org.hypercerts.claim.listContributorInformation({ authors?, sortDirection?, limit?, cursor? })` returns `{ contributorInformation, cursor? }`. `authors` filters publisher repository DIDs (OR semantics), supplied as repeated unbracketed query keys, with at most 100 values. -Both responses use the shared `ContributorInformationView`: record metadata (`uri`, `cid`, nullable `indexedAt`, and publisher `did`), an `author` actor view, and the original full indexed `record`. The older proposal snippet that omits `nullable` for `indexedAt` is stale; the accepted API design requires this field to remain present as JSON `null` when the database timestamp is null. The author contains the publisher's current Certified profile and raw organization sidecar; each is `null` when missing. The record's contributor identifier is not resolved, and referencing activities are not expanded. Operational query or required hydration failures return errors rather than empty or partial results. +Both responses use the `contributorInformationView` definition declared by `org.hypercerts.claim.getContributorInformation`: record metadata (`uri`, `cid`, nullable `indexedAt`, and publisher `did`), an `author` actor view, and the original full indexed `record`. The activity endpoint keeps its separate exact-reference contributor-information view. The older proposal snippet that omits `nullable` for `indexedAt` is stale; the accepted API design requires this field to remain present as JSON `null` when the database timestamp is null. The author contains the publisher's current Certified profile and raw organization sidecar; each is `null` when missing. The record's contributor identifier is not resolved, and referencing activities are not expanded. Operational query or required hydration failures return errors rather than empty or partial results. Listings default to 25 records and accept limits from 1 through 100. They sort by `(createdAt, uri)` in descending order unless `sortDirection` is `asc`; cursors are opaque and direction-bound. Keep filters and direction unchanged when continuing a page. The `cursor` property is omitted when there is no next page. A singular `RecordNotFound` means HappyView has no indexed row at that URI; it does not prove the record is absent or deleted from its PDS. diff --git a/api/lexicons/org.hypercerts.api.defs.json b/api/lexicons/org.hypercerts.api.defs.json index 2afee11..288c4eb 100644 --- a/api/lexicons/org.hypercerts.api.defs.json +++ b/api/lexicons/org.hypercerts.api.defs.json @@ -40,20 +40,6 @@ "organization": { "type": "ref", "ref": "#organizationView" } } }, - "contributorInformationView": { - "type": "object", - "description": "Contributor-information record with its full source payload and hydrated publishing actor.", - "required": ["uri", "cid", "indexedAt", "did", "author", "record"], - "nullable": ["indexedAt"], - "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" }, - "indexedAt": { "type": "string", "format": "datetime" }, - "did": { "type": "string", "format": "did" }, - "author": { "type": "ref", "ref": "#actorView" }, - "record": { "type": "ref", "ref": "org.hypercerts.claim.contributorInformation" } - } - }, "entityFollowRecordView": { "type": "object", "description": "Raw indexed entity-follow record view, including the DID that published the relationship.", diff --git a/api/lexicons/org.hypercerts.claim.getContributorInformation.json b/api/lexicons/org.hypercerts.claim.getContributorInformation.json index a79c2df..2934729 100644 --- a/api/lexicons/org.hypercerts.claim.getContributorInformation.json +++ b/api/lexicons/org.hypercerts.claim.getContributorInformation.json @@ -32,9 +32,23 @@ "properties": { "contributorInformation": { "type": "ref", - "ref": "org.hypercerts.api.defs#contributorInformationView" + "ref": "#contributorInformationView" } } + }, + "contributorInformationView": { + "type": "object", + "description": "Contributor-information record with its full source payload and hydrated publishing actor.", + "required": ["uri", "cid", "indexedAt", "did", "author", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did" }, + "author": { "type": "ref", "ref": "org.hypercerts.api.defs#actorView" }, + "record": { "type": "ref", "ref": "org.hypercerts.claim.contributorInformation" } + } } } } diff --git a/api/lexicons/org.hypercerts.claim.listContributorInformation.json b/api/lexicons/org.hypercerts.claim.listContributorInformation.json index 7120352..f10c058 100644 --- a/api/lexicons/org.hypercerts.claim.listContributorInformation.json +++ b/api/lexicons/org.hypercerts.claim.listContributorInformation.json @@ -46,7 +46,7 @@ "properties": { "contributorInformation": { "type": "array", - "items": { "type": "ref", "ref": "org.hypercerts.api.defs#contributorInformationView" } + "items": { "type": "ref", "ref": "org.hypercerts.claim.getContributorInformation#contributorInformationView" } }, "cursor": { "type": "string", diff --git a/api/lua/endpoints/getContributorInformation.lua b/api/lua/endpoints/getContributorInformation.lua index 03e51b2..112181d 100644 --- a/api/lua/endpoints/getContributorInformation.lua +++ b/api/lua/endpoints/getContributorInformation.lua @@ -2,9 +2,11 @@ local function invalid(message) error("InvalidRequest: " .. message, 0) end -local function keys_only(values, allowed) +local function keys_only(values, allowed, unknown_message_prefix) for key in pairs(values) do - if not allowed[key] then invalid("unknown query parameter") end + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end end end @@ -18,7 +20,7 @@ local function scalar(params, key) end local function valid_did(value) - if #value > 2048 then return false end + if type(value) ~= "string" or #value > 2048 then return false end local method, specific = value:match("^did:([a-z]+):(.+)$") if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" or value:find("[^%w%.:_%%%-]") then return false end @@ -34,7 +36,27 @@ local function valid_record_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end - return true, collection + return true, collection, authority +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) end local NULL = json.decode("null") @@ -43,7 +65,7 @@ local function record_view(row) return { uri = row.uri, cid = row.cid, - indexedAt = row.indexed_at, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, did = row.did, record = json.decode(row.record), } @@ -79,27 +101,6 @@ local function hydrate_actor_views(actors, run_query) end 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 = valid_record_uri(value) - if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return contributor_information_did(authority) -end - local function contributor_information_query(sql, values) local backend_ok, backend = pcall(db.backend) if not backend_ok or backend ~= "postgres" then diff --git a/api/lua/endpoints/listContributorInformation.lua b/api/lua/endpoints/listContributorInformation.lua index e3f59e7..4e3d486 100644 --- a/api/lua/endpoints/listContributorInformation.lua +++ b/api/lua/endpoints/listContributorInformation.lua @@ -2,9 +2,11 @@ local function invalid(message) error("InvalidRequest: " .. message, 0) end -local function keys_only(values, allowed) +local function keys_only(values, allowed, unknown_message_prefix) for key in pairs(values) do - if not allowed[key] then invalid("unknown query parameter") end + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end end end @@ -18,7 +20,7 @@ local function scalar(params, key) end local function valid_did(value) - if #value > 2048 then return false end + if type(value) ~= "string" or #value > 2048 then return false end local method, specific = value:match("^did:([a-z]+):(.+)$") if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" or value:find("[^%w%.:_%%%-]") then return false end @@ -34,7 +36,27 @@ local function valid_record_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end - return true, collection + return true, collection, authority +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) end local NULL = json.decode("null") @@ -43,7 +65,7 @@ local function record_view(row) return { uri = row.uri, cid = row.cid, - indexedAt = row.indexed_at, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, did = row.did, record = json.decode(row.record), } @@ -80,6 +102,7 @@ local function hydrate_actor_views(actors, run_query) 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 @@ -120,27 +143,6 @@ local function cursor_encode(value) return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) 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 = valid_record_uri(value) - if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return contributor_information_did(authority) -end - local function contributor_information_datetime(value) return valid_datetime(value) and tonumber(value:sub(1, 4)) > 0 end diff --git a/api/lua/shared/contributorInformationValidation.lua b/api/lua/shared/contributorInformationValidation.lua new file mode 100644 index 0000000..9a8058c --- /dev/null +++ b/api/lua/shared/contributorInformationValidation.lua @@ -0,0 +1,19 @@ +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) +end diff --git a/api/lua/src/getContributorInformation.lua b/api/lua/src/getContributorInformation.lua index 73f7248..2aa64bd 100644 --- a/api/lua/src/getContributorInformation.lua +++ b/api/lua/src/getContributorInformation.lua @@ -1,24 +1,3 @@ -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 = valid_record_uri(value) - if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return contributor_information_did(authority) -end - local function contributor_information_query(sql, values) local backend_ok, backend = pcall(db.backend) if not backend_ok or backend ~= "postgres" then diff --git a/api/lua/src/listContributorInformation.lua b/api/lua/src/listContributorInformation.lua index 14126a4..71e83e9 100644 --- a/api/lua/src/listContributorInformation.lua +++ b/api/lua/src/listContributorInformation.lua @@ -1,24 +1,3 @@ -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 = valid_record_uri(value) - if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return contributor_information_did(authority) -end - local function contributor_information_datetime(value) return valid_datetime(value) and tonumber(value:sub(1, 4)) > 0 end diff --git a/api/modules/contributor-information/manifest.json b/api/modules/contributor-information/manifest.json index e626c9c..553d8cd 100644 --- a/api/modules/contributor-information/manifest.json +++ b/api/modules/contributor-information/manifest.json @@ -12,7 +12,7 @@ "id": "org.hypercerts.claim.listContributorInformation", "path": "../../lexicons/org.hypercerts.claim.listContributorInformation.json", "config": { "backfill": false, "target_collection": "org.hypercerts.claim.contributorInformation" }, - "dependsOn": ["org.hypercerts.claim.contributorInformation", "org.hypercerts.api.defs"] + "dependsOn": ["org.hypercerts.claim.contributorInformation", "org.hypercerts.claim.getContributorInformation", "org.hypercerts.api.defs"] }, { "kind": "script", @@ -21,7 +21,9 @@ "sourcePath": "../../lua/src/getContributorInformation.lua", "sharedSourcePaths": [ "../../lua/shared/query.lua", + "../../lua/shared/didValidation.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/contributorInformationValidation.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua" ], @@ -38,9 +40,12 @@ "sourcePath": "../../lua/src/listContributorInformation.lua", "sharedSourcePaths": [ "../../lua/shared/query.lua", + "../../lua/shared/didValidation.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/contributorInformationValidation.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua", + "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua" ], "config": { diff --git a/api/modules/shared/manifest.json b/api/modules/shared/manifest.json index 260712e..a7ebb95 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -22,7 +22,7 @@ "id": "org.hypercerts.api.defs", "path": "../../lexicons/org.hypercerts.api.defs.json", "config": { "backfill": false }, - "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile", "org.hypercerts.claim.contributorInformation"] + "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile"] }, { "kind": "lexicon", diff --git a/api/tests/http/fixtures/contributor-information.fixture.js b/api/tests/http/fixtures/contributor-information.fixture.js index 57e4ddf..1373133 100644 --- a/api/tests/http/fixtures/contributor-information.fixture.js +++ b/api/tests/http/fixtures/contributor-information.fixture.js @@ -4,9 +4,9 @@ import * as CID from '@atcute/cid'; const contributorCollection = 'org.hypercerts.claim.contributorInformation'; const profileCollection = 'app.certified.actor.profile'; const organizationCollection = 'app.certified.actor.organization'; -const authorA = 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'; -const authorB = 'did:plc:nnnnnnnnnnnnnnnnnnnnnnnn'; -const authorC = 'did:plc:oooooooooooooooooooooooo'; +const authorA = 'did:web:contributor-http-author-a.invalid'; +const authorB = 'did:web:contributor-http-author-b.invalid'; +const authorC = 'did:web:contributor-http-author-c.invalid'; const createdAt = '2025-03-01T00:00:00.000Z'; const indexedAt = '2025-03-02T00:00:00.000Z'; diff --git a/api/tests/http/getContributorInformation.http.test.js b/api/tests/http/getContributorInformation.http.test.js index 28f7749..78674b9 100644 --- a/api/tests/http/getContributorInformation.http.test.js +++ b/api/tests/http/getContributorInformation.http.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; -const contributorUri = 'at://did:plc:mmmmmmmmmmmmmmmmmmmmmmmm/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; +const contributorUri = 'at://did:web:contributor-http-author-a.invalid/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; const collection = 'org.hypercerts.claim.contributorInformation'; async function getContributorInformation(uri) { @@ -27,7 +27,7 @@ test('getContributorInformation fetches by AT-URI without supplying its pinned C const view = body.contributorInformation; assert.equal(view.uri, contributorUri); - assert.equal(view.did, 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'); + assert.equal(view.did, 'did:web:contributor-http-author-a.invalid'); assert.equal(view.record.$type, collection); assert.equal(view.record.identifier, 'manual:cedar-restorer'); assert.equal(view.record.displayName, 'Cedar Restorer'); @@ -36,13 +36,13 @@ test('getContributorInformation fetches by AT-URI without supplying its pinned C 'bafyreicjb36r5phxdx46gn5m75zk7csjqs6j47xnstvxoerdfy7jvkajxy', 'the AT-URI lookup returns its known CBOR-derived CID without supplying that CID in the request', ); - assert.equal(view.author.did, 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'); + assert.equal(view.author.did, 'did:web:contributor-http-author-a.invalid'); assert.equal(view.author.profile.record.displayName, 'Cedar Watershed Group'); assert.deepEqual(view.author.organization.record.organizationType, ['nonprofit']); }); test('getContributorInformation returns null for absent publisher sidecars', async () => { - const missingSidecarUri = 'at://did:plc:nnnnnnnnnnnnnnnnnnnnnnnn/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2b'; + const missingSidecarUri = 'at://did:web:contributor-http-author-b.invalid/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2b'; const { response, body } = await getContributorInformation(missingSidecarUri); assert.equal(response.status, 200, JSON.stringify(body)); assert.equal(body.contributorInformation.author.profile, null); @@ -50,7 +50,7 @@ test('getContributorInformation returns null for absent publisher sidecars', asy }); test('getContributorInformation exposes RecordNotFound as the pinned HappyView runtime error', async () => { - const missingUri = 'at://did:plc:mmmmmmmmmmmmmmmmmmmmmmmm/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2d'; + const missingUri = 'at://did:web:contributor-http-author-a.invalid/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2d'; const { response, body } = await getContributorInformation(missingUri); assert.equal(response.status, 500, JSON.stringify(body)); assert.equal(body.error, 'script_error'); diff --git a/api/tests/http/listContributorInformation.http.test.js b/api/tests/http/listContributorInformation.http.test.js index 62d2269..9113a43 100644 --- a/api/tests/http/listContributorInformation.http.test.js +++ b/api/tests/http/listContributorInformation.http.test.js @@ -3,9 +3,9 @@ import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; const endpoint = 'org.hypercerts.claim.listContributorInformation'; -const authorA = 'did:plc:mmmmmmmmmmmmmmmmmmmmmmmm'; -const authorB = 'did:plc:nnnnnnnnnnnnnnnnnnnnnnnn'; -const authorC = 'did:plc:oooooooooooooooooooooooo'; +const authorA = 'did:web:contributor-http-author-a.invalid'; +const authorB = 'did:web:contributor-http-author-b.invalid'; +const authorC = 'did:web:contributor-http-author-c.invalid'; const uris = { baseline: 'at://did:plc:gggggggggggggggggggggggg/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2h', a: `at://${authorA}/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a`, diff --git a/api/tests/unit/tooling/getContributorInformation.test.js b/api/tests/unit/tooling/getContributorInformation.test.js index 297b569..9975f30 100644 --- a/api/tests/unit/tooling/getContributorInformation.test.js +++ b/api/tests/unit/tooling/getContributorInformation.test.js @@ -11,7 +11,9 @@ const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); test('getContributorInformation preserves the exact record and hydrates its publisher sidecars', async () => { const sources = await Promise.all([ read('lua/shared/query.lua'), + read('lua/shared/didValidation.lua'), read('lua/shared/recordIdentifier.lua'), + read('lua/shared/contributorInformationValidation.lua'), read('lua/shared/recordView.lua'), read('lua/shared/actorView.lua'), read('lua/src/getContributorInformation.lua'), @@ -76,6 +78,23 @@ local bad_uri_ok, bad_uri_error = pcall(function() 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") +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") +local unknown_parameter_ok, unknown_parameter_error = pcall(function() + params = { uri = uri, extra = "unsupported" } + return handle() +end) +assert(not unknown_parameter_ok and tostring(unknown_parameter_error) == "InvalidRequest: unknown query parameter") +local repeated_uri_ok, repeated_uri_error = pcall(function() + params = { uri = { uri, uri } } + 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") recordRow.indexed_at = nil params = { uri = uri } local null_timestamp = handle().contributorInformation.indexedAt diff --git a/api/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index d43104a..b537b4d 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -124,9 +124,24 @@ if (hasModule('modules/actor-follow/manifest.json')) test('follow query Lexicons }); }); -test('contributor-information view keeps nullable indexedAt required', async () => { - const { lexicons } = await validatePackageLexicons(); - const view = lexicons.getDefOrThrow('org.hypercerts.api.defs#contributorInformationView'); +test('contributor-information view is owned by getContributorInformation and keeps nullable indexedAt required', async () => { + const { lexicons, documents } = await validatePackageLexicons(); + const byId = new Map(documents.map((document) => [document.id, document])); + const shared = byId.get('org.hypercerts.api.defs'); + const getContributorInformation = byId.get('org.hypercerts.claim.getContributorInformation'); + const listContributorInformation = byId.get('org.hypercerts.claim.listContributorInformation'); + assert.ok(shared && getContributorInformation && listContributorInformation); + assert.equal(Object.hasOwn(shared.defs, 'contributorInformationView'), false); + assert.equal( + getContributorInformation.defs.output.properties.contributorInformation.ref, + 'lex:org.hypercerts.claim.getContributorInformation#contributorInformationView', + ); + assert.equal( + listContributorInformation.defs.output.properties.contributorInformation.items.ref, + 'lex:org.hypercerts.claim.getContributorInformation#contributorInformationView', + ); + + const view = lexicons.getDefOrThrow('org.hypercerts.claim.getContributorInformation#contributorInformationView'); assert.ok(view.required.includes('indexedAt')); assert.ok(view.nullable.includes('indexedAt')); assert.equal(view.properties.author.ref, 'lex:org.hypercerts.api.defs#actorView'); diff --git a/api/tests/unit/tooling/listContributorInformation.test.js b/api/tests/unit/tooling/listContributorInformation.test.js index f1b4c35..a71f71b 100644 --- a/api/tests/unit/tooling/listContributorInformation.test.js +++ b/api/tests/unit/tooling/listContributorInformation.test.js @@ -11,9 +11,12 @@ const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); test('listContributorInformation filters repeated authors and resumes in the selected order', async () => { const sources = await Promise.all([ read('lua/shared/query.lua'), + read('lua/shared/didValidation.lua'), read('lua/shared/recordIdentifier.lua'), + read('lua/shared/contributorInformationValidation.lua'), read('lua/shared/recordView.lua'), read('lua/shared/actorView.lua'), + read('lua/shared/listValidation.lua'), read('lua/shared/listQuery.lua'), read('lua/src/listContributorInformation.lua'), ]); @@ -91,11 +94,35 @@ 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") +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") +completed_pages = page +params = { limit = "101" } +local invalid_limit_ok, invalid_limit_error = pcall(function() return handle() end) +assert(not invalid_limit_ok and tostring(invalid_limit_error) == "InvalidRequest: limit must be an integer from 1 through 100") +params = { limit = { "1", "2" } } +local repeated_limit_ok, repeated_limit_error = pcall(function() return handle() end) +assert(not repeated_limit_ok and tostring(repeated_limit_error) == "InvalidRequest: limit must occur once") +assert(page == completed_pages, "invalid list limits must be rejected before querying") +JSON_VALUES.cursor = { v = 1, d = "asc", t = "2026-01-01T00:00:00Z", u = "at://did:plc:publisher/org.hypercerts.claim.activity/tid1" } +params = { sortDirection = "asc", limit = "1", cursor = "637572736f72" } +local wrong_collection_cursor_ok, wrong_collection_cursor_error = pcall(function() return handle() end) +assert(not wrong_collection_cursor_ok and tostring(wrong_collection_cursor_error) == "InvalidRequest: cursor is malformed") +assert(page == completed_pages, "a cursor for another collection must be rejected before querying") JSON_VALUES.cursor = { v = 1, d = "asc", t = "0000-01-01T00:00:00Z", u = first_uri } params = { sortDirection = "asc", limit = "1", cursor = "637572736f72" } local bad_cursor_ok, bad_cursor_error = pcall(function() return handle() end) -assert(not bad_cursor_ok and tostring(bad_cursor_error):find("InvalidRequest:", 1, true) ~= nil) +assert(not bad_cursor_ok and tostring(bad_cursor_error) == "InvalidRequest: cursor is malformed") assert(page == completed_pages, "year-zero cursor must be rejected before PostgreSQL sees it") +local oversized_decoded_cursor = string.rep(" ", 4097) +JSON_VALUES[oversized_decoded_cursor] = { v = 1, d = "asc", t = "2026-01-01T00:00:00Z", u = first_uri } +params = { sortDirection = "asc", limit = "1", cursor = string.rep("20", 4097) } +local oversized_cursor_ok, oversized_cursor_error = pcall(function() return handle() end) +assert(not oversized_cursor_ok and tostring(oversized_cursor_error) == "InvalidRequest: cursor is malformed") +assert(page == completed_pages, "a cursor larger than 8192 hex characters must be rejected before decoding or querying") `; const result = spawnSync('lua', ['-e', program], { encoding: 'utf8' }); assert.equal(result.error, undefined, result.error?.message);