diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00d28bc..2441a0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ pnpm build ## HTTP runtime tests -`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, EVM-link, acknowledgement, collection, context-measurement, context attachment and evaluation, contributor-information, funding, location, profile, organization, feature, work-scope-tag, vocabulary-tag, graph, and contribution query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. +`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, EVM-link, acknowledgement, collection, context-measurement, context attachment and evaluation, contributor-information, funding, location, profile, organization, feature, work-scope-tag, rights, vocabulary-tag, graph, and contribution query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. The local runner requires a local Docker Compose daemon, `psql`, and the pinned PostgreSQL and HappyView images already cached locally. Set `PSQL_PATH` to the absolute path of a trusted `psql` executable: @@ -31,8 +31,9 @@ The bridge network permits container egress. HappyView receives loopback placeho The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or runs only skipped cases. Current coverage includes: -- Funding record retrieval, repeated filters, and pagination. +- Funding record retrieval, repeated filters, publisher-sidecar hydration, and pagination. - Badge-definition retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, `createdAt`/URI pagination ties, and named error responses. +- Rights record retrieval and author-filtered listing, nullable publisher sidecars, repeated-author OR filtering, and stable `createdAt`/URI cursor pagination. - EVM-link lookup and listing, actor/address filters, `createdAt`/URI pagination ties in both directions, nullable sidecar hydration, and named error responses. - Feature-query coverage in `api/tests/http/features.http.test.js` exercises exact retrieval and hydrated or absent author sidecars, author/type and organization-presence filters, bidirectional `createdAt`/URI pagination ties, and named request errors. - Contributor-information retrieval by exact AT-URI and listing with repeated-author filters, cursor pagination, hydrated and missing author sidecars, and named runtime errors. @@ -47,7 +48,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Collection retrieval with CBOR-derived CIDs, location/tag projections, author, organization, item and tag filters, title/shortDescription search, `createdAt`/URI pagination ties, and source-order item pagination with exact-version resolution. - Graph actor/entity lookups and lists, tied-key pagination, nullable profile/organization sidecars, entity target resolution, and the global recent-follows `before` filter. - Contribution exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. -- Badge and contribution fixtures with CBOR-derived record CIDs; contribution DIDs are distinct from baseline fixture identities. +- Badge, badge-query, acknowledgement, contribution, and rights HTTP fixtures with CBOR-derived record CIDs; contribution DIDs are distinct from baseline fixture identities. - Location retrieval with nullable sidecars, repeated author/URI/location-type filters, unsupported-search errors, tied pagination in both directions, malformed/absent `createdAt` handling, and named errors. - HTTP fixtures for activity, collection, funding, badge-definition, badge-query, EVM-link, acknowledgement, feature, work-scope-tag, contribution, contributor-information, actor, context-measurement, context attachment and evaluation, graph, location, measurement, and vocabulary-tag records use CBOR-derived CIDs. diff --git a/README.md b/README.md index b1e27e0..19a5656 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This checkout is one of the additive local sibling branches used to compose the Included query endpoints: +- Rights: `org.hypercerts.claim.getRights`, `org.hypercerts.claim.listRights` + - Badge definitions: `app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions` - Badge queries: `app.certified.badge.searchBadgeDefinitions`, `app.certified.badge.getBadgeAward`, `app.certified.badge.listBadgeAwards`, `app.certified.badge.getBadgeResponse`, `app.certified.badge.listBadgeResponses` - Certified EVM links: `app.certified.link.getEvmLink`, `app.certified.link.listEvmLinks` @@ -17,6 +19,8 @@ Included query endpoints: `api/manifest.json` is authoritative for the modules and validation Lexicons included in this checkout. The `docs/` workspace contains the endpoint explorer and full schema snapshots. +## Checks + ```sh pnpm install --frozen-lockfile pnpm check @@ -31,6 +35,7 @@ pnpm build | Capability | XRPC endpoints | HTTP contracts | | --- | --- | --- | +| Rights | `org.hypercerts.claim.getRights`, `org.hypercerts.claim.listRights` | Exact retrieval, author filters, nullable sidecars, stable pagination, and named errors | | Funding receipts | `org.hypercerts.funding.getReceipt`, `org.hypercerts.funding.listReceipts` | Record retrieval, repeated filters, stable pagination, and named runtime errors | | Badge definitions | `app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions` | Record/CID retrieval, publisher sidecars, filters, tied pagination, and named runtime errors | | Badge queries | `app.certified.badge.searchBadgeDefinitions`, `app.certified.badge.getBadgeAward`, `app.certified.badge.listBadgeAwards`, `app.certified.badge.getBadgeResponse`, `app.certified.badge.listBadgeResponses` | Baseline-aware definition search, exact-version award/response lookups, recipient status, raw response history, filters, and cursor pagination | diff --git a/api/README.md b/api/README.md index aea8a7a..1ff0a29 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # HappyView API toolkit and query modules -This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, offline fixture/test utilities, and the EVM-link, feature, contribution, badge-query, vocabulary-tag, and acknowledgement modules. The `modules/shared/manifest.json` contains record schemas and shared query/view Lexicons; it contains no Lua endpoint scripts. A foundation-only install does not implement those queries. Capability modules listed in the root manifest register their endpoint Lexicons and Lua handlers separately; the `badge-queries` module installs five public badge query Lexicons and their Lua handlers. +This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, offline fixture/test utilities, and the EVM-link, feature, contribution, rights, badge-query, vocabulary-tag, and acknowledgement modules. The `modules/shared/manifest.json` contains record schemas and shared query/view Lexicons; it contains no Lua endpoint scripts. A foundation-only install does not implement those queries. Capability modules listed in the root manifest register their endpoint Lexicons and Lua handlers separately; the `badge-queries` module installs five public badge query Lexicons and their Lua handlers. ## EVM-link queries @@ -28,7 +28,7 @@ PSQL_PATH="$(command -v psql)" pnpm test:http ## HTTP runtime tests -HTTP suites live in `api/tests/http` and exercise the funding, badge-definition, badge-query, EVM-link, feature, work-scope-tag, contribution, context-measurement, profile, organization, context attachment and evaluation, activity, collection, acknowledgement, vocabulary-tag, graph, and location 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 exercise the funding, badge-definition, badge-query, EVM-link, feature, work-scope-tag, contribution, rights, context-measurement, profile, organization, context attachment and evaluation, activity, collection, acknowledgement, vocabulary-tag, graph, and location 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. 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-query coverage exercises baseline-aware definition feeds and discriminating filters, exact-version award/response lookups, recipient status, raw response history, bidirectional tied pagination, nullable sidecars, and named runtime errors. Vocabulary-tag coverage exercises exact retrieval, author filters, hydrated and nullable sidecars, tied pagination, and named errors. Acknowledgement coverage exercises exact retrieval, hydrated and absent publisher sidecars, repeated author/subject filters, tied pagination in both directions, and named errors. Feature coverage exercises exact retrieval and author hydration, list filters and sidecars, tied createdAt/URI pagination, and named errors. Contribution coverage exercises exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. Fixtures use CBOR-derived record CIDs and are seeded only into the task-owned disposable database. @@ -140,6 +140,16 @@ HAPPYVIEW_BASE_URL='https://your-happyview.example' HAPPYVIEW_ADMIN_TOKEN=' 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function rights_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local percent = value:find("%", position, true) + if not percent then return true end + local first = value:sub(percent + 1, percent + 1) + local second = value:sub(percent + 2, percent + 2) + if not first:match("^[0-9A-Fa-f]$") or not second:match("^[0-9A-Fa-f]$") then return false end + position = percent + 3 + end +end + +local function rights_valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) + if not valid then return false end + return rights_valid_did(authority), collection +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local RIGHTS_NULL = json.decode("null") + +local function rights_record_view(row) + local view = record_view(row) + if row.indexed_at == nil then view.indexedAt = RIGHTS_NULL end + return view +end + +local function rights_hydrate_actor_views(actors, run_query) + hydrate_actor_views(actors, run_query) + for _, actor in ipairs(actors) do + for _, sidecar in ipairs({ actor.profile, actor.organization }) do + if type(sidecar) == "table" and sidecar.uri ~= nil and sidecar.indexedAt == nil then + sidecar.indexedAt = RIGHTS_NULL + end + end + end +end + +local RIGHTS = "org.hypercerts.claim.rights" + +local function rights_query(sql, values) + if db.backend() ~= "postgres" then error("RightsQueryFailed: rights API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("RightsQueryFailed: rights lookup failed", 0) + end + return result +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + local valid, collection = rights_valid_record_uri(uri) + if not uri or not valid or collection ~= RIGHTS then + invalid("uri must be a full org.hypercerts.claim.rights AT-URI with a DID authority") + end + + local rows = rights_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", + { RIGHTS, uri }) + if #rows == 0 then error("RecordNotFound: rights record is not indexed", 0) end + + local view = rights_record_view(rows[1]) + view.author = { did = view.did } + rights_hydrate_actor_views({ view.author }, rights_query) + return { rights = view } +end diff --git a/api/lua/endpoints/listRights.lua b/api/lua/endpoints/listRights.lua new file mode 100644 index 0000000..be42f62 --- /dev/null +++ b/api/lua/endpoints/listRights.lua @@ -0,0 +1,307 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function rights_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local percent = value:find("%", position, true) + if not percent then return true end + local first = value:sub(percent + 1, percent + 1) + local second = value:sub(percent + 2, percent + 2) + if not first:match("^[0-9A-Fa-f]$") or not second:match("^[0-9A-Fa-f]$") then return false end + position = percent + 3 + end +end + +local function rights_valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) + if not valid then return false end + return rights_valid_did(authority), collection +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local RIGHTS_NULL = json.decode("null") + +local function rights_record_view(row) + local view = record_view(row) + if row.indexed_at == nil then view.indexedAt = RIGHTS_NULL end + return view +end + +local function rights_hydrate_actor_views(actors, run_query) + hydrate_actor_views(actors, run_query) + for _, actor in ipairs(actors) do + for _, sidecar in ipairs({ actor.profile, actor.organization }) do + if type(sidecar) == "table" and sidecar.uri ~= nil and sidecar.indexedAt == nil then + sidecar.indexedAt = RIGHTS_NULL + end + end + end +end + +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local RIGHTS = "org.hypercerts.claim.rights" + +local function rights_list_run_query(sql, values) + if db.backend() ~= "postgres" then error("RightsQueryFailed: rights API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then error("RightsQueryFailed: rights lookup failed", 0) end + return result +end + +local function rights_list_array(value) + 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("authors must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid("authors must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid("authors entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid("authors must be a string or repeated string parameter") + end + if #supplied > 100 then invalid("authors accepts at most 100 values") end + + local unique, seen = {}, {} + for _, did in ipairs(supplied) do + if not rights_valid_did(did) then invalid("each authors value must be a valid DID") end + if not seen[did] then + seen[did] = true + unique[#unique + 1] = did + end + end + return unique +end + +local function rights_list_cursor_decode(token, direction) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, collection = rights_valid_record_uri(value.u) + local year = tonumber(value.t:sub(1, 4)) + if not valid_datetime(value.t) or not year or year < 1 or not valid or collection ~= RIGHTS then + invalid("cursor is malformed") + end + return value +end + +local function rights_sort_expression() + local created = "rights.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(rights.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(rights.indexed_at::timestamptz, rights.created_at::timestamptz) END" +end + +local function rights_list_query(authors, limit, cursor, direction) + local where, values = { "rights.collection = $1" }, { RIGHTS } + + if authors then + if #authors == 0 then + where[#where + 1] = "FALSE" + else + local marks = {} + for _, did in ipairs(authors) do + values[#values + 1] = did + marks[#marks + 1] = "$" .. #values + end + where[#where + 1] = "rights.did IN (" .. table.concat(marks, ", ") .. ")" + end + end + + 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, rights.uri) " .. operator .. " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sql = "SELECT rights.uri, rights.did, rights.cid, rights.indexed_at::text AS indexed_at, " .. + "rights.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 rights CROSS JOIN LATERAL (SELECT " .. rights_sort_expression() .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", rights.uri " .. ordering .. " LIMIT $" .. #values + local rows = rights_list_run_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 = rights_record_view(row) + view.author = { did = view.did } + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + rights_hydrate_actor_views(authors_to_hydrate, rights_list_run_query) + + 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 + +local function rights_list_response() + keys_only(params, { authors = true, sortDirection = true, limit = true, cursor = true }) + local authors = rights_list_array(params.authors) + local direction = parse_sort_direction(params) + local limit = parse_list_limit(params) + local cursor = rights_list_cursor_decode(scalar(params, "cursor"), direction) + local rights, next_cursor = rights_list_query(authors, limit, cursor, direction) + local response = { rights = toarray(rights) } + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return rights_list_response() +end diff --git a/api/lua/shared/rightsIdentifier.lua b/api/lua/shared/rightsIdentifier.lua new file mode 100644 index 0000000..787e844 --- /dev/null +++ b/api/lua/shared/rightsIdentifier.lua @@ -0,0 +1,18 @@ +local function rights_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local percent = value:find("%", position, true) + if not percent then return true end + local first = value:sub(percent + 1, percent + 1) + local second = value:sub(percent + 2, percent + 2) + if not first:match("^[0-9A-Fa-f]$") or not second:match("^[0-9A-Fa-f]$") then return false end + position = percent + 3 + end +end + +local function rights_valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) + if not valid then return false end + return rights_valid_did(authority), collection +end diff --git a/api/lua/shared/rightsView.lua b/api/lua/shared/rightsView.lua new file mode 100644 index 0000000..768bbe2 --- /dev/null +++ b/api/lua/shared/rightsView.lua @@ -0,0 +1,18 @@ +local RIGHTS_NULL = json.decode("null") + +local function rights_record_view(row) + local view = record_view(row) + if row.indexed_at == nil then view.indexedAt = RIGHTS_NULL end + return view +end + +local function rights_hydrate_actor_views(actors, run_query) + hydrate_actor_views(actors, run_query) + for _, actor in ipairs(actors) do + for _, sidecar in ipairs({ actor.profile, actor.organization }) do + if type(sidecar) == "table" and sidecar.uri ~= nil and sidecar.indexedAt == nil then + sidecar.indexedAt = RIGHTS_NULL + end + end + end +end diff --git a/api/lua/src/getRights.lua b/api/lua/src/getRights.lua new file mode 100644 index 0000000..5f1d583 --- /dev/null +++ b/api/lua/src/getRights.lua @@ -0,0 +1,30 @@ +local RIGHTS = "org.hypercerts.claim.rights" + +local function rights_query(sql, values) + if db.backend() ~= "postgres" then error("RightsQueryFailed: rights API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("RightsQueryFailed: rights lookup failed", 0) + end + return result +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + local valid, collection = rights_valid_record_uri(uri) + if not uri or not valid or collection ~= RIGHTS then + invalid("uri must be a full org.hypercerts.claim.rights AT-URI with a DID authority") + end + + local rows = rights_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", + { RIGHTS, uri }) + if #rows == 0 then error("RecordNotFound: rights record is not indexed", 0) end + + local view = rights_record_view(rows[1]) + view.author = { did = view.did } + rights_hydrate_actor_views({ view.author }, rights_query) + return { rights = view } +end diff --git a/api/lua/src/listRights.lua b/api/lua/src/listRights.lua new file mode 100644 index 0000000..91f6da3 --- /dev/null +++ b/api/lua/src/listRights.lua @@ -0,0 +1,144 @@ +local RIGHTS = "org.hypercerts.claim.rights" + +local function rights_list_run_query(sql, values) + if db.backend() ~= "postgres" then error("RightsQueryFailed: rights API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then error("RightsQueryFailed: rights lookup failed", 0) end + return result +end + +local function rights_list_array(value) + 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("authors must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid("authors must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid("authors entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid("authors must be a string or repeated string parameter") + end + if #supplied > 100 then invalid("authors accepts at most 100 values") end + + local unique, seen = {}, {} + for _, did in ipairs(supplied) do + if not rights_valid_did(did) then invalid("each authors value must be a valid DID") end + if not seen[did] then + seen[did] = true + unique[#unique + 1] = did + end + end + return unique +end + +local function rights_list_cursor_decode(token, direction) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + local valid, collection = rights_valid_record_uri(value.u) + local year = tonumber(value.t:sub(1, 4)) + if not valid_datetime(value.t) or not year or year < 1 or not valid or collection ~= RIGHTS then + invalid("cursor is malformed") + end + return value +end + +local function rights_sort_expression() + local created = "rights.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(rights.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(rights.indexed_at::timestamptz, rights.created_at::timestamptz) END" +end + +local function rights_list_query(authors, limit, cursor, direction) + local where, values = { "rights.collection = $1" }, { RIGHTS } + + if authors then + if #authors == 0 then + where[#where + 1] = "FALSE" + else + local marks = {} + for _, did in ipairs(authors) do + values[#values + 1] = did + marks[#marks + 1] = "$" .. #values + end + where[#where + 1] = "rights.did IN (" .. table.concat(marks, ", ") .. ")" + end + end + + 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, rights.uri) " .. operator .. " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sql = "SELECT rights.uri, rights.did, rights.cid, rights.indexed_at::text AS indexed_at, " .. + "rights.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 rights CROSS JOIN LATERAL (SELECT " .. rights_sort_expression() .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", rights.uri " .. ordering .. " LIMIT $" .. #values + local rows = rights_list_run_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 = rights_record_view(row) + view.author = { did = view.did } + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + rights_hydrate_actor_views(authors_to_hydrate, rights_list_run_query) + + 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 + +local function rights_list_response() + keys_only(params, { authors = true, sortDirection = true, limit = true, cursor = true }) + local authors = rights_list_array(params.authors) + local direction = parse_sort_direction(params) + local limit = parse_list_limit(params) + local cursor = rights_list_cursor_decode(scalar(params, "cursor"), direction) + local rights, next_cursor = rights_list_query(authors, limit, cursor, direction) + local response = { rights = toarray(rights) } + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return rights_list_response() +end diff --git a/api/manifest.json b/api/manifest.json index 4a6b502..a51addc 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -57,7 +57,9 @@ "getMeasurement": "implemented", "listMeasurements": "implemented", "getEvmLink": "implemented", - "listEvmLinks": "implemented" + "listEvmLinks": "implemented", + "getRights": "implemented", + "listRights": "implemented" }, "authentication": { "decision": "public", @@ -492,6 +494,18 @@ { "id": "app.certified.link.listEvmLinks", "path": "lexicons/app.certified.link.listEvmLinks.json" + }, + { + "id": "org.hypercerts.claim.getRights", + "path": "lexicons/org.hypercerts.claim.getRights.json" + }, + { + "id": "org.hypercerts.claim.listRights", + "path": "lexicons/org.hypercerts.claim.listRights.json" + }, + { + "id": "org.hypercerts.claim.rights", + "packagePath": "lexicons/org/hypercerts/claim/rights.json" } ], "modules": [ @@ -516,7 +530,8 @@ "modules/location/manifest.json", "modules/features/manifest.json", "modules/context-measurements/manifest.json", - "modules/evm-links/manifest.json" + "modules/evm-links/manifest.json", + "modules/rights/manifest.json" ], "luaBuild": { "endpointSources": [ diff --git a/api/modules/rights/manifest.json b/api/modules/rights/manifest.json new file mode 100644 index 0000000..4e63cf9 --- /dev/null +++ b/api/modules/rights/manifest.json @@ -0,0 +1,54 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "org.hypercerts.claim.getRights", + "path": "../../lexicons/org.hypercerts.claim.getRights.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.rights" }, + "dependsOn": ["org.hypercerts.claim.rights", "org.hypercerts.api.defs"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.getRights", + "path": "../../lua/endpoints/getRights.lua", + "sourcePath": "../../lua/src/getRights.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/rightsIdentifier.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/rightsView.lua" + ], + "config": { "script_type": "lua", "description": "Look up one rights record" }, + "dependsOn": ["org.hypercerts.claim.getRights"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.claim.listRights", + "path": "../../lexicons/org.hypercerts.claim.listRights.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.rights" }, + "dependsOn": ["org.hypercerts.claim.getRights"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.listRights", + "path": "../../lua/endpoints/listRights.lua", + "sourcePath": "../../lua/src/listRights.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/rightsIdentifier.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/rightsView.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua" + ], + "config": { "script_type": "lua", "description": "List rights records" }, + "dependsOn": ["org.hypercerts.claim.listRights"] + } + ] +} diff --git a/api/modules/shared/manifest.json b/api/modules/shared/manifest.json index 5395dde..58280fe 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -11,6 +11,7 @@ { "kind": "lexicon", "id": "app.certified.location", "packagePath": "lexicons/app/certified/location.json", "config": { "backfill": true }, "dependsOn": [], "registrationGroup": "location-record-schema-sources" }, { "kind": "lexicon", "id": "app.certified.signature.defs", "packagePath": "lexicons/app/certified/signature/defs.json", "config": { "backfill": false }, "dependsOn": [], "registrationGroup": "location-record-schema-sources" }, { "kind": "lexicon", "id": "org.hypercerts.claim.activity", "packagePath": "lexicons/org/hypercerts/claim/activity.json", "config": { "backfill": true }, "dependsOn": [] }, + { "kind": "lexicon", "id": "org.hypercerts.claim.rights", "packagePath": "lexicons/org/hypercerts/claim/rights.json", "config": { "backfill": true }, "dependsOn": ["org.hypercerts.defs", "app.certified.signature.defs"] }, { "kind": "lexicon", "id": "org.hypercerts.claim.contributorInformation", "packagePath": "lexicons/org/hypercerts/claim/contributorInformation.json", "config": { "backfill": true }, "dependsOn": [] }, { "kind": "lexicon", "id": "org.hypercerts.claim.contribution", "packagePath": "lexicons/org/hypercerts/claim/contribution.json", "config": { "backfill": true }, "dependsOn": ["app.certified.signature.defs"] }, { "kind": "lexicon", "id": "org.hypercerts.collection", "packagePath": "lexicons/org/hypercerts/collection.json", "config": { "backfill": true }, "dependsOn": ["org.hypercerts.defs", "app.certified.signature.defs"] }, diff --git a/api/tests/http/fixtures/rights.fixture.js b/api/tests/http/fixtures/rights.fixture.js new file mode 100644 index 0000000..12d2b21 --- /dev/null +++ b/api/tests/http/fixtures/rights.fixture.js @@ -0,0 +1,72 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const collection = 'org.hypercerts.claim.rights'; +const indexedAt = '2025-02-03T04:06:00.000Z'; +const createdAt = '2025-02-01T00:00:00.000Z'; + +const rightsSpecs = [ + { + did: 'did:web:rights-alpha.example', + rkey: '3jzfcijpj2z2a', + record: { + rightsName: 'All Rights Reserved', + rightsType: 'ARR', + rightsDescription: 'Permission terms are retained in full.', + createdAt, + }, + }, + { + did: 'did:web:rights-alpha.example', + rkey: '3jzfcijpj2z2b', + record: { + rightsName: 'Attribution Rights', + rightsType: 'ARR', + rightsDescription: 'Attribution is required when reusing this work.', + createdAt, + }, + }, + { + did: 'did:web:rights-bravo.example', + rkey: '3jzfcijpj2z2c', + record: { + rightsName: 'Community Use', + rightsType: 'ARR', + rightsDescription: 'Community use is permitted under these terms.', + createdAt, + }, + }, + { + did: 'did:web:rights-charlie.example', + rkey: '3jzfcijpj2z2d', + record: { + rightsName: 'Research Use', + rightsType: 'ARR', + rightsDescription: 'Research use is permitted under these terms.', + createdAt, + }, + }, + { + did: 'did:web:rights-outsider.example', + rkey: '3jzfcijpj2z2e', + record: { + rightsName: 'Outside Filter', + rightsType: 'ARR', + rightsDescription: 'This record distinguishes author filtering.', + createdAt, + }, + }, +]; + +export const seedRows = await Promise.all(rightsSpecs.map(async ({ did, rkey, record }) => { + const storedRecord = { $type: collection, ...record }; + return { + uri: `at://${did}/${collection}/${rkey}`, + did, + collection, + rkey, + cid: CID.toString(await CID.create(0x71, encode(storedRecord))), + indexedAt, + record: storedRecord, + }; +})); diff --git a/api/tests/http/getRights.http.test.js b/api/tests/http/getRights.http.test.js new file mode 100644 index 0000000..a560a67 --- /dev/null +++ b/api/tests/http/getRights.http.test.js @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const endpoint = 'org.hypercerts.claim.getRights'; +const rightsUri = 'at://did:web:rights-alpha.example/org.hypercerts.claim.rights/3jzfcijpj2z2a'; +const rightsCid = 'bafyreiewrg2yr37zuby3cxg5qywyfu5zucz2dc3tgru6yfrms53h4ckite'; + +async function getRights(uri) { + const url = contractUrl(requireContractTarget(), endpoint, { 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('getRights returns the requested CBOR-addressed record and nullable missing sidecars', async () => { + const { response, body } = await getRights(rightsUri); + assert.equal(response.status, 200, JSON.stringify(body)); + + const { rights } = body; + assert.equal(rights.uri, rightsUri); + assert.equal(rights.cid, rightsCid); + assert.equal(rights.indexedAt, '2025-02-03T04:06:00.000Z'); + assert.equal(rights.did, 'did:web:rights-alpha.example'); + assert.deepEqual(rights.record, { + $type: 'org.hypercerts.claim.rights', + rightsName: 'All Rights Reserved', + rightsType: 'ARR', + rightsDescription: 'Permission terms are retained in full.', + createdAt: '2025-02-01T00:00:00.000Z', + }); + assert.equal(rights.author.did, 'did:web:rights-alpha.example'); + assert.equal(rights.author.profile, null); + assert.equal(rights.author.organization, null); +}); + +test('getRights exposes RecordNotFound using the pinned HappyView runtime error response', async () => { + const missingUri = 'at://did:web:rights-alpha.example/org.hypercerts.claim.rights/3jzfcijpj2z2z'; + const { response, body } = await getRights(missingUri); + + 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, /RecordNotFound/); +}); + +test('getRights exposes InvalidRequest using the pinned HappyView runtime error response', async () => { + const wrongCollection = 'at://did:web:rights-alpha.example/app.certified.location/3jzfcijpj2z2a'; + const { response, body } = await getRights(wrongCollection); + + 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/http/listRights.http.test.js b/api/tests/http/listRights.http.test.js new file mode 100644 index 0000000..8b693f0 --- /dev/null +++ b/api/tests/http/listRights.http.test.js @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const endpoint = 'org.hypercerts.claim.listRights'; +const authorAlpha = 'did:web:rights-alpha.example'; +const authorBravo = 'did:web:rights-bravo.example'; +const authorCharlie = 'did:web:rights-charlie.example'; +const rightsUris = { + alphaFirst: `at://${authorAlpha}/org.hypercerts.claim.rights/3jzfcijpj2z2a`, + alphaSecond: `at://${authorAlpha}/org.hypercerts.claim.rights/3jzfcijpj2z2b`, + bravo: `at://${authorBravo}/org.hypercerts.claim.rights/3jzfcijpj2z2c`, + charlie: `at://${authorCharlie}/org.hypercerts.claim.rights/3jzfcijpj2z2d`, + outside: 'at://did:web:rights-outsider.example/org.hypercerts.claim.rights/3jzfcijpj2z2e', +}; + +async function listRights(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('listRights ORs repeated authors and excludes records outside the requested publishers', async () => { + const { response, body } = await listRights({ + authors: [authorAlpha, authorBravo], + sortDirection: 'asc', + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.rights.map(({ uri }) => uri), [ + rightsUris.alphaFirst, + rightsUris.alphaSecond, + rightsUris.bravo, + ]); + assert.equal(body.rights[0].record.rightsName, 'All Rights Reserved'); + assert.equal(Object.hasOwn(body, 'cursor'), false); + + const absent = await listRights({ authors: ['did:web:rights-absent.example'] }); + assert.equal(absent.response.status, 200, JSON.stringify(absent.body)); + assert.deepEqual(absent.body.rights, []); + assert.equal(Object.hasOwn(absent.body, 'cursor'), false); +}); + +test('listRights paginates createdAt ties by URI without repeats or omissions in both directions', async () => { + const firstAsc = await listRights({ sortDirection: 'asc', limit: 2 }); + assert.equal(firstAsc.response.status, 200, JSON.stringify(firstAsc.body)); + assert.deepEqual(firstAsc.body.rights.map(({ uri }) => uri), [rightsUris.alphaFirst, rightsUris.alphaSecond]); + assert.equal(typeof firstAsc.body.cursor, 'string'); + + const secondAsc = await listRights({ sortDirection: 'asc', limit: 2, cursor: firstAsc.body.cursor }); + assert.equal(secondAsc.response.status, 200, JSON.stringify(secondAsc.body)); + assert.deepEqual(secondAsc.body.rights.map(({ uri }) => uri), [rightsUris.bravo, rightsUris.charlie]); + assert.equal(typeof secondAsc.body.cursor, 'string'); + + const thirdAsc = await listRights({ sortDirection: 'asc', limit: 2, cursor: secondAsc.body.cursor }); + assert.equal(thirdAsc.response.status, 200, JSON.stringify(thirdAsc.body)); + assert.deepEqual(thirdAsc.body.rights.map(({ uri }) => uri), [rightsUris.outside]); + assert.equal(Object.hasOwn(thirdAsc.body, 'cursor'), false); + + const firstDesc = await listRights({ sortDirection: 'desc', limit: 2 }); + assert.equal(firstDesc.response.status, 200, JSON.stringify(firstDesc.body)); + assert.deepEqual(firstDesc.body.rights.map(({ uri }) => uri), [rightsUris.outside, rightsUris.charlie]); + assert.equal(typeof firstDesc.body.cursor, 'string'); + + const secondDesc = await listRights({ sortDirection: 'desc', limit: 2, cursor: firstDesc.body.cursor }); + assert.equal(secondDesc.response.status, 200, JSON.stringify(secondDesc.body)); + assert.deepEqual(secondDesc.body.rights.map(({ uri }) => uri), [rightsUris.bravo, rightsUris.alphaSecond]); + assert.equal(typeof secondDesc.body.cursor, 'string'); + + const thirdDesc = await listRights({ sortDirection: 'desc', limit: 2, cursor: secondDesc.body.cursor }); + assert.equal(thirdDesc.response.status, 200, JSON.stringify(thirdDesc.body)); + assert.deepEqual(thirdDesc.body.rights.map(({ uri }) => uri), [rightsUris.alphaFirst]); + assert.equal(Object.hasOwn(thirdDesc.body, 'cursor'), false); +}); + +test('listRights exposes InvalidRequest using the pinned HappyView runtime error response', async () => { + const { response, body } = await listRights({ 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/unit/endpoints/rights-edge-lua.test.js b/api/tests/unit/endpoints/rights-edge-lua.test.js new file mode 100644 index 0000000..cd8a0ef --- /dev/null +++ b/api/tests/unit/endpoints/rights-edge-lua.test.js @@ -0,0 +1,183 @@ +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { execFileSync } 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'); + +async function endpointSources(name) { + const shared = [ + 'lua/shared/didValidation.lua', + 'lua/shared/query.lua', + 'lua/shared/recordIdentifier.lua', + 'lua/shared/rightsIdentifier.lua', + 'lua/shared/recordView.lua', + 'lua/shared/actorView.lua', + 'lua/shared/rightsView.lua', + ]; + if (name === 'listRights') shared.push('lua/shared/listValidation.lua', 'lua/shared/listQuery.lua'); + return Promise.all([...shared, `lua/src/${name}.lua`].map(read)); +} + +function runLua(program) { + execFileSync('lua', ['-'], { input: program, encoding: 'utf8' }); +} + +test('getRights rejects malformed DID escapes, serializes nullable indexedAt, and reports missing records', async () => { + const sources = await endpointSources('getRights'); + const program = [ + `local null_value = {} + local rights_record = { rightsName = "Terms" }`, + `json = { decode = function(value) + if value == "null" then return null_value end + if value == "rights-record" then return rights_record end + error("unexpected JSON input") + end }`, + `params = { uri = "at://did:plc:publisher%GG/org.hypercerts.claim.rights/key" }`, + `local query_count = 0 + local record_exists = true + db = { + backend = function() return "postgres" end, + raw = function(sql, values) + query_count = query_count + 1 + if values[1] == "org.hypercerts.claim.rights" then + if not record_exists then return {} end + return {{ uri = params.uri, did = "did:plc:publisher", cid = "cid", indexed_at = nil, record = "rights-record" }} + end + return {} + end + }`, + ...sources, + `local ok, err = pcall(handle) + assert(not ok and tostring(err):find("InvalidRequest", 1, true)) + assert(query_count == 0, "malformed percent escapes must fail before DB access") + params.uri = "at://did:plc:publisher/org.hypercerts.claim.rights/key" + local response = handle() + assert(response.rights.indexedAt == null_value, "SQL NULL is returned as JSON null") + assert(response.rights.record.rightsName == "Terms") + assert(response.rights.author.profile == null_value) + assert(response.rights.author.organization == null_value) + record_exists = false + local missing_ok, missing_error = pcall(handle) + assert(not missing_ok and tostring(missing_error):find("RecordNotFound", 1, true))`, + ].join('\n\n'); + + runLua(program); +}); + +test('listRights orders invalid or absent createdAt by indexed_at then row creation without rewriting records', async () => { + const sources = await endpointSources('listRights'); + const program = [ + `local null_value = {} + local records = { + created = { rightsName = "CreatedAt", createdAt = "2024-01-01T00:00:00Z" }, + indexed = { rightsName = "Indexed fallback" }, + stored = { rightsName = "Stored fallback", createdAt = "not-a-datetime" } + }`, + `json = { decode = function(value) + if value == "null" then return null_value end + return assert(records[value], "unexpected JSON input") + end }`, + `params = { sortDirection = "asc", limit = "10" }`, + `local rows = { + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/created", did = "did:plc:alice", cid = "cid-a", indexed_at = "2024-01-04T00:00:00Z", created_at = "2024-01-04T00:00:00Z", record = "created", sort_timestamp = "2024-01-01T00:00:00.000000Z" }, + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/indexed", did = "did:plc:alice", cid = "cid-b", indexed_at = "2024-01-02T00:00:00Z", created_at = "2024-01-03T00:00:00Z", record = "indexed", sort_timestamp = "2024-01-02T00:00:00.000000Z" }, + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/stored", did = "did:plc:alice", cid = "cid-c", indexed_at = nil, created_at = "2024-01-03T00:00:00Z", record = "stored", sort_timestamp = "2024-01-03T00:00:00.000000Z" } + }`, + `db = { + backend = function() return "postgres" end, + raw = function(sql, values) + if values[1] == "app.certified.actor.profile" or values[1] == "app.certified.actor.organization" then return {} end + assert(sql:find("COALESCE(rights.indexed_at::timestamptz, rights.created_at::timestamptz)", 1, true), "sort must fall back to indexed_at and row creation") + local result = {} + for _, row in ipairs(rows) do result[#result + 1] = row end + return result + end + } + toarray = function(value) return value end`, + ...sources, + `local response = handle() + assert(#response.rights == 3, "missing or malformed createdAt does not exclude rights") + assert(response.rights[1].record.rightsName == "CreatedAt") + assert(response.rights[2].record.rightsName == "Indexed fallback") + assert(response.rights[3].record.rightsName == "Stored fallback") + assert(response.rights[2].record.createdAt == nil, "the source record is not given a synthetic timestamp") + assert(response.rights[3].record.createdAt == "not-a-datetime", "the malformed source value remains unchanged") + assert(response.rights[3].indexedAt == null_value, "nullable indexedAt is retained as JSON null")`, + ].join('\n\n'); + + runLua(program); +}); + +test('listRights rejects malformed DID escapes and PostgreSQL year zero cursors before querying', async () => { + const sources = await endpointSources('listRights'); + const program = [ + `local null_value = {}`, + `json = { + decode = function(value) + if value == "null" then return null_value end + local version, direction, timestamp, uri = value:match('^{"v":(%d+),"d":"([^"]+)","t":"([^"]+)","u":"([^"]+)"}$') + if version then return { v = tonumber(version), d = direction, t = timestamp, u = uri } end + error("unexpected JSON input") + end + }`, + `local function hex(value) + return (value:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) + end + local function cursor(timestamp, uri) + return hex('{"v":1,"d":"desc","t":"' .. timestamp .. '","u":"' .. uri .. '"}') + end`, + `local query_count = 0 + db = { + backend = function() return "postgres" end, + raw = function() query_count = query_count + 1; return {} end + } + toarray = function(value) return value end`, + ...sources, + `local function invalid_request() + local ok, err = pcall(handle) + assert(not ok and tostring(err):find("InvalidRequest", 1, true)) + assert(query_count == 0, "invalid inputs must fail before database access") + end + params = { authors = { "did:plc:publisher%GG" } } + invalid_request() + params = { cursor = cursor("2025-01-01T00:00:00Z", "at://did:plc:publisher%GG/org.hypercerts.claim.rights/key") } + invalid_request() + params = { cursor = cursor("0000-01-01T00:00:00Z", "at://did:plc:publisher/org.hypercerts.claim.rights/key") } + invalid_request()`, + ].join('\n\n'); + + runLua(program); +}); + +test('listRights rejects repeated scalar, unknown, and oversized author parameters before querying', async () => { + const sources = await endpointSources('listRights'); + const program = [ + `local null_value = {}`, + `json = { decode = function(value) assert(value == "null"); return null_value end }`, + `local query_count = 0 + db = { + backend = function() return "postgres" end, + raw = function() query_count = query_count + 1; return {} end + } + toarray = function(value) return value end`, + ...sources, + `local function invalid_request() + local ok, err = pcall(handle) + assert(not ok and tostring(err):find("InvalidRequest", 1, true)) + end + params = { limit = { "2", "3" } } + invalid_request() + params = { unexpected = "value" } + invalid_request() + local authors = {} + for index = 1, 101 do authors[index] = "did:plc:author" .. index end + params = { authors = authors } + invalid_request() + assert(query_count == 0, "invalid parameters must fail before database access")`, + ].join('\n\n'); + + runLua(program); +}); diff --git a/api/tests/unit/endpoints/rights-lua.test.js b/api/tests/unit/endpoints/rights-lua.test.js new file mode 100644 index 0000000..61f7c4c --- /dev/null +++ b/api/tests/unit/endpoints/rights-lua.test.js @@ -0,0 +1,258 @@ +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { execFileSync } 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('getRights returns the full indexed record and a nullable hydrated publisher', async () => { + const [didValidation, query, recordIdentifier, rightsIdentifier, recordView, actorView, rightsView, getRights] = await Promise.all([ + read('lua/shared/didValidation.lua'), + read('lua/shared/query.lua'), + read('lua/shared/recordIdentifier.lua'), + read('lua/shared/rightsIdentifier.lua'), + read('lua/shared/recordView.lua'), + read('lua/shared/actorView.lua'), + read('lua/shared/rightsView.lua'), + read('lua/src/getRights.lua'), + ]); + const program = [ + `local null_value = {}`, + `local rights_record = { + ["$type"] = "org.hypercerts.claim.rights", + rightsName = "All Rights Reserved", + rightsType = "ARR", + rightsDescription = "Permission terms are retained in full.", + attachment = { uri = "https://example.test/terms.pdf" }, + createdAt = "2025-02-03T04:05:06Z" + }`, + `json = { + decode = function(value) + if value == "null" then return null_value end + if value == "rights-record" then return rights_record end + if value == "profile-record" then return { displayName = "Rights publisher" } end + if value == "organization-record" then return { name = "Rights publisher organization" } end + error("unexpected JSON input: " .. tostring(value)) + end + }`, + `params = { uri = "at://did:plc:publisher/org.hypercerts.claim.rights/3jzfcijpj2z2a" }`, + `db = { + backend = function() return "postgres" end, + raw = function(sql, values) + if values[1] == "org.hypercerts.claim.rights" then + assert(values[2] == params.uri, "lookup must use the exact supplied AT-URI") + return {{ + uri = params.uri, + did = "did:plc:publisher", + cid = "bafyreifullrecord", + indexed_at = "2025-02-03T04:06:00Z", + record = "rights-record" + }} + end + if values[1] == "app.certified.actor.profile" then + return {{ uri = "at://did:plc:publisher/app.certified.actor.profile/self", did = "did:plc:publisher", cid = "profile-cid", indexed_at = nil, record = "profile-record" }} + end + if values[1] == "app.certified.actor.organization" then + return {{ uri = "at://did:plc:publisher/app.certified.actor.organization/self", did = "did:plc:publisher", cid = "organization-cid", indexed_at = nil, record = "organization-record" }} + end + error("unexpected rights hydration query") + end + }`, + didValidation, + query, + recordIdentifier, + rightsIdentifier, + recordView, + actorView, + rightsView, + getRights, + `local response = handle() + assert(response.rights.uri == params.uri) + assert(response.rights.cid == "bafyreifullrecord") + assert(response.rights.indexedAt == "2025-02-03T04:06:00Z") + assert(response.rights.did == "did:plc:publisher") + assert(response.rights.record.rightsDescription == "Permission terms are retained in full.") + assert(response.rights.record.attachment.uri == "https://example.test/terms.pdf") + assert(response.rights.author.did == "did:plc:publisher") + assert(response.rights.author.profile.indexedAt == null_value) + assert(response.rights.author.profile.record.displayName == "Rights publisher") + assert(response.rights.author.organization.indexedAt == null_value) + assert(response.rights.author.organization.record.name == "Rights publisher organization")`, + ].join('\n\n'); + + execFileSync('lua', ['-'], { input: program, encoding: 'utf8' }); +}); + +async function listRightsSources() { + return Promise.all([ + read('lua/shared/didValidation.lua'), + read('lua/shared/query.lua'), + read('lua/shared/recordIdentifier.lua'), + read('lua/shared/rightsIdentifier.lua'), + read('lua/shared/recordView.lua'), + read('lua/shared/actorView.lua'), + read('lua/shared/rightsView.lua'), + read('lua/shared/listValidation.lua'), + read('lua/shared/listQuery.lua'), + read('lua/src/listRights.lua'), + ]); +} + +test('listRights treats repeated authors as an OR filter', async () => { + const sources = await listRightsSources(); + const program = [ + `local null_value = {}`, + `local records = { + one = { rightsName = "First" }, + two = { rightsName = "Second" }, + three = { rightsName = "Other publisher" }, + profile = { displayName = "Publisher" }, + organization = { name = "Publisher organization" } + }`, + `json = { decode = function(value) + if value == "null" then return null_value end + return assert(records[value], "unexpected JSON input") + end }`, + `params = { authors = { "did:plc:alice", "did:plc:bob" } }`, + `local rows = { + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/a", did = "did:plc:alice", cid = "cid-a", indexed_at = "2025-01-01T00:00:00Z", record = "one", sort_timestamp = "2025-01-01T00:00:00.000000Z" }, + { uri = "at://did:plc:carol/org.hypercerts.claim.rights/c", did = "did:plc:carol", cid = "cid-c", indexed_at = "2025-01-03T00:00:00Z", record = "three", sort_timestamp = "2025-01-03T00:00:00.000000Z" }, + { uri = "at://did:plc:bob/org.hypercerts.claim.rights/b", did = "did:plc:bob", cid = "cid-b", indexed_at = "2025-01-02T00:00:00Z", record = "two", sort_timestamp = "2025-01-02T00:00:00.000000Z" } + }`, + `db = { + backend = function() return "postgres" end, + raw = function(sql, values) + if values[1] == "app.certified.actor.profile" then + return { + { uri = "at://did:plc:alice/app.certified.actor.profile/self", did = "did:plc:alice", cid = "profile-a", indexed_at = nil, record = "profile" }, + { uri = "at://did:plc:bob/app.certified.actor.profile/self", did = "did:plc:bob", cid = "profile-b", indexed_at = nil, record = "profile" } + } + end + if values[1] == "app.certified.actor.organization" then + return { + { uri = "at://did:plc:alice/app.certified.actor.organization/self", did = "did:plc:alice", cid = "organization-a", indexed_at = nil, record = "organization" }, + { uri = "at://did:plc:bob/app.certified.actor.organization/self", did = "did:plc:bob", cid = "organization-b", indexed_at = nil, record = "organization" } + } + end + assert(values[1] == "org.hypercerts.claim.rights") + assert(sql:find("rights.did IN", 1, true), "query must apply the author filter") + assert(values[2] == "did:plc:alice" and values[3] == "did:plc:bob", "both authors must be bound") + local result = {} + for _, row in ipairs(rows) do + if row.did == values[2] or row.did == values[3] then result[#result + 1] = row end + end + return result + end + }`, + `toarray = function(value) return value end`, + ...sources, + `local response = handle() + assert(#response.rights == 2, "both matching publishers should be returned") + assert(response.rights[1].did == "did:plc:alice") + assert(response.rights[2].did == "did:plc:bob") + assert(response.rights[1].record.rightsName == "First") + assert(response.rights[2].record.rightsName == "Second") + assert(response.rights[1].author.profile.indexedAt == null_value) + assert(response.rights[1].author.organization.indexedAt == null_value) + assert(response.rights[2].author.profile.indexedAt == null_value) + assert(response.rights[2].author.organization.indexedAt == null_value)`, + ].join('\n\n'); + + execFileSync('lua', ['-'], { input: program, encoding: 'utf8' }); +}); + +test('listRights paginates in both directions and rejects a cursor used with another direction', async () => { + const sources = await listRightsSources(); + const program = [ + `local null_value = {}`, + `local records = { + a = { rightsName = "A", createdAt = "2024-01-01T00:00:00Z" }, + b = { rightsName = "B", createdAt = "2024-01-01T00:00:00Z" }, + c = { rightsName = "C", createdAt = "2024-01-02T00:00:00Z" }, + d = { rightsName = "D", createdAt = "2024-01-03T00:00:00Z" } + }`, + `json = { + decode = function(value) + if value == "null" then return null_value end + local version, direction, timestamp, uri = value:match('^{"v":(%d+),"d":"([^"]+)","t":"([^"]+)","u":"([^"]+)"}$') + if version then return { v = tonumber(version), d = direction, t = timestamp, u = uri } end + return assert(records[value], "unexpected JSON input") + end, + encode = function(value) + return '{"v":' .. value.v .. ',"d":"' .. value.d .. '","t":"' .. value.t .. '","u":"' .. value.u .. '"}' + end + }`, + `params = { sortDirection = "asc", limit = "2" }`, + `local rows = { + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/a", did = "did:plc:alice", cid = "cid-a", indexed_at = "2024-01-01T00:01:00Z", record = "a", sort_timestamp = "2024-01-01T00:00:00.000000Z" }, + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/b", did = "did:plc:alice", cid = "cid-b", indexed_at = "2024-01-01T00:01:00Z", record = "b", sort_timestamp = "2024-01-01T00:00:00.000000Z" }, + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/c", did = "did:plc:alice", cid = "cid-c", indexed_at = "2024-01-02T00:01:00Z", record = "c", sort_timestamp = "2024-01-02T00:00:00.000000Z" }, + { uri = "at://did:plc:alice/org.hypercerts.claim.rights/d", did = "did:plc:alice", cid = "cid-d", indexed_at = "2024-01-03T00:01:00Z", record = "d", sort_timestamp = "2024-01-03T00:00:00.000000Z" } + }`, + `local query_count = 0 + db = { + backend = function() return "postgres" end, + raw = function(sql, values) + if values[1] == "app.certified.actor.profile" or values[1] == "app.certified.actor.organization" then return {} end + assert(values[1] == "org.hypercerts.claim.rights") + query_count = query_count + 1 + local ascending = sql:find("ORDER BY sorted.sort_at ASC", 1, true) ~= nil + local direction = ascending and "asc" or "desc" + local matches = {} + local has_cursor = sql:find("(sorted.sort_at, rights.uri)", 1, true) ~= nil + local cursor_time, cursor_uri + if has_cursor then + cursor_time, cursor_uri = values[#values - 2], values[#values - 1] + end + for _, row in ipairs(rows) do + local include = true + if has_cursor then + local later = row.sort_timestamp > cursor_time or (row.sort_timestamp == cursor_time and row.uri > cursor_uri) + include = ascending and later or (not ascending and not later and (row.sort_timestamp ~= cursor_time or row.uri ~= cursor_uri)) + end + if include then matches[#matches + 1] = row end + end + table.sort(matches, function(left, right) + local less = left.sort_timestamp < right.sort_timestamp or + (left.sort_timestamp == right.sort_timestamp and left.uri < right.uri) + return ascending and less or (not ascending and not less and + (left.sort_timestamp ~= right.sort_timestamp or left.uri ~= right.uri)) + end) + local result = {} + for index = 1, math.min(values[#values], #matches) do result[index] = matches[index] end + return result + end + }`, + `toarray = function(value) return value end`, + ...sources, + `local first_asc = handle() + assert(#first_asc.rights == 2) + assert(first_asc.rights[1].record.rightsName == "A" and first_asc.rights[2].record.rightsName == "B") + assert(type(first_asc.cursor) == "string") + local asc_cursor = first_asc.cursor + params.cursor = asc_cursor + local second_asc = handle() + assert(#second_asc.rights == 2) + assert(second_asc.rights[1].record.rightsName == "C" and second_asc.rights[2].record.rightsName == "D") + assert(second_asc.cursor == nil, "terminal pages omit the cursor") + local before_mismatch = query_count + params.sortDirection = "desc" + local mismatch_ok, mismatch_error = pcall(handle) + assert(not mismatch_ok and tostring(mismatch_error):find("InvalidRequest", 1, true)) + assert(query_count == before_mismatch, "direction mismatch must fail before querying") + params.cursor = nil + local first_desc = handle() + assert(#first_desc.rights == 2) + assert(first_desc.rights[1].record.rightsName == "D" and first_desc.rights[2].record.rightsName == "C") + assert(type(first_desc.cursor) == "string") + params.cursor = first_desc.cursor + local second_desc = handle() + assert(#second_desc.rights == 2) + assert(second_desc.rights[1].record.rightsName == "B" and second_desc.rights[2].record.rightsName == "A") + assert(second_desc.cursor == nil)`, + ].join('\n\n'); + + execFileSync('lua', ['-'], { input: program, encoding: 'utf8' }); +}); diff --git a/api/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index 8390bd6..23a2ce3 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -36,6 +36,7 @@ test('the full validation Lexicon closure resolves locally while only selected p 'org.hypercerts.claim.activity', 'org.hypercerts.claim.contribution', 'org.hypercerts.claim.contributorInformation', + 'org.hypercerts.claim.rights', 'org.hypercerts.collection', 'org.hypercerts.context.acknowledgement', 'org.hypercerts.context.attachment', @@ -176,6 +177,31 @@ if (hasModule('modules/actor-follow/manifest.json')) test('follow query Lexicons }); }); +if (hasModule('modules/rights/manifest.json')) test('rights queries preserve the indexed record and declare bounded author pagination', 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 rights = byId.get('org.hypercerts.claim.rights'); + const getRights = byId.get('org.hypercerts.claim.getRights'); + const listRights = byId.get('org.hypercerts.claim.listRights'); + assert.ok(shared && rights && getRights && listRights); + + const rightsView = lexicons.getDefOrThrow('org.hypercerts.claim.getRights#rightsView'); + assert.equal(shared.defs.rightsView, undefined, 'rightsView is owned by getRights, not shared API defs'); + assert.deepEqual(rightsView.required, ['uri', 'cid', 'indexedAt', 'did', 'author', 'record']); + assert.deepEqual(rightsView.nullable, ['indexedAt']); + assert.equal(rightsView.properties.author.ref, 'lex:org.hypercerts.api.defs#actorView'); + assert.equal(rightsView.properties.record.ref, 'lex:org.hypercerts.claim.rights'); + assert.equal(getRights.defs.output.properties.rights.ref, 'lex:org.hypercerts.claim.getRights#rightsView'); + assert.deepEqual(getRights.defs.main.errors.map(({ name }) => name), ['InvalidRequest', 'RecordNotFound']); + assert.equal(listRights.defs.main.parameters.properties.authors.type, 'array'); + assert.equal(listRights.defs.main.parameters.properties.authors.maxLength, 100); + assert.equal(listRights.defs.main.parameters.properties.authors.items.format, 'did'); + assert.equal(listRights.defs.main.parameters.properties.limit.minimum, 1); + assert.equal(listRights.defs.main.parameters.properties.limit.maximum, 100); + assert.equal(listRights.defs.output.properties.rights.items.ref, 'lex:org.hypercerts.claim.getRights#rightsView'); +}); + if (hasModule('modules/context-measurements/manifest.json')) test('measurement query Lexicons preserve the typed publisher and full-record contract', async () => { const { lexicons, documents } = await validatePackageLexicons(); const byId = new Map(documents.map((document) => [document.id, document]));