From 9ce9742abc5dbd3801737438cca37fbe83399a7f Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 01:33:19 +0600 Subject: [PATCH 1/4] rights: add public lookup and listing queries --- README.md | 4 +- api/README.md | 12 +- api/lexicons/org.hypercerts.api.defs.json | 14 + .../org.hypercerts.claim.getRights.json | 48 +++ .../org.hypercerts.claim.listRights.json | 69 ++++ api/lua/endpoints/getRights.lua | 150 +++++++++ api/lua/endpoints/listRights.lua | 305 ++++++++++++++++++ api/lua/shared/rightsIdentifier.lua | 19 ++ api/lua/shared/rightsView.lua | 18 ++ api/lua/src/getRights.lua | 30 ++ api/lua/src/listRights.lua | 144 +++++++++ api/manifest.json | 14 +- api/modules/rights/manifest.json | 51 +++ api/modules/shared/manifest.json | 3 +- api/tooling/lexicons.test.js | 25 ++ api/tooling/rights-edge-lua.test.js | 182 +++++++++++ api/tooling/rights-lua.test.js | 254 +++++++++++++++ 17 files changed, 1337 insertions(+), 5 deletions(-) create mode 100644 api/lexicons/org.hypercerts.claim.getRights.json create mode 100644 api/lexicons/org.hypercerts.claim.listRights.json create mode 100644 api/lua/endpoints/getRights.lua create mode 100644 api/lua/endpoints/listRights.lua create mode 100644 api/lua/shared/rightsIdentifier.lua create mode 100644 api/lua/shared/rightsView.lua create mode 100644 api/lua/src/getRights.lua create mode 100644 api/lua/src/listRights.lua create mode 100644 api/modules/rights/manifest.json create mode 100644 api/tooling/rights-edge-lua.test.js create mode 100644 api/tooling/rights-lua.test.js diff --git a/README.md b/README.md index 512021e..e900a8b 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 branch contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, and the public `org.hypercerts.claim.getRights` and `org.hypercerts.claim.listRights` query handlers. ## Checks @@ -10,6 +10,6 @@ pnpm check pnpm build ``` -`pnpm check` validates the pinned Lexicon closure, lint, types, and unit tests. `pnpm build` refreshes declared Lua handler bundles; there are no endpoint handlers in this foundation branch. +`pnpm check` validates the pinned Lexicon closure, lint, types, and unit tests. `pnpm build` refreshes the declared Lua handler bundles, including both rights queries. See [`api/README.md`](api/README.md) for installer and fixture details. The `LICENSE.md` file retains the upstream MIT notice. diff --git a/api/README.md b/api/README.md index 5fdebc7..e5d0858 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # HappyView API toolkit foundation -This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, and offline fixture/test utilities. The `modules/shared/manifest.json` contains record schemas and query Lexicons used as shared view types; it contains no Lua endpoint scripts. A foundation-only install therefore does not implement those queries. +This package contains the shared API installer and tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, and the public `org.hypercerts.claim.getRights` and `org.hypercerts.claim.listRights` query handlers. The rights handlers are registered in `modules/rights/manifest.json`; the shared module registers their record and actor-view dependencies. ## Local checks @@ -14,6 +14,16 @@ pnpm build These checks are offline and do not require a HappyView instance or database. `pnpm check` runs generated-source freshness, JavaScript/Lua lint, typechecking, and unit tests. `pnpm build` emits only Lua handlers declared by the root and module manifests. Shared Lua files are bundled into capability handlers but are not installed independently. +## Rights queries + +- `org.hypercerts.claim.getRights` accepts the exact rights-record AT-URI with a DID authority. An unindexed record returns `RecordNotFound`. +- `org.hypercerts.claim.listRights` accepts up to 100 repeated, unbracketed `authors` keys; values use OR. Pages default to 25 and cap at 100. Ordering uses `(createdAt, uri)`; missing or malformed record timestamps fall back to `indexed_at`, then row creation time. Cursors are opaque and bound to sort direction; a terminal page omits `cursor`. +- Both queries preserve the full indexed record and hydrate the publisher's Certified profile and raw organization sidecar. Missing author records are `null`; SQL `NULL` `indexed_at` is returned as JSON `null` without inventing a timestamp. Query or hydration failures return errors. Attachments and activities referencing rights are not expanded. +- `RightsView.indexedAt` is required but nullable to match indexed rows. Older proposal prose that describes it as non-nullable is stale. +- Rights listing uses PostgreSQL 16+ `pg_input_is_valid` timestamp validation; the canonical HappyView deployment and test configurations default to PostgreSQL 17. + +The shared module registers the pinned rights-record Lexicon with backfill enabled, so an approved install can index existing rights records. + 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. diff --git a/api/lexicons/org.hypercerts.api.defs.json b/api/lexicons/org.hypercerts.api.defs.json index 288c4eb..fe019b5 100644 --- a/api/lexicons/org.hypercerts.api.defs.json +++ b/api/lexicons/org.hypercerts.api.defs.json @@ -83,6 +83,20 @@ }, "follow": { "type": "ref", "ref": "#entityFollowRecordView" } } + }, + "rightsView": { + "type": "object", + "description": "Rights record with its publisher actor; the full record is preserved and attachments are not expanded.", + "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.rights" } + } } } } diff --git a/api/lexicons/org.hypercerts.claim.getRights.json b/api/lexicons/org.hypercerts.claim.getRights.json new file mode 100644 index 0000000..f8a0189 --- /dev/null +++ b/api/lexicons/org.hypercerts.claim.getRights.json @@ -0,0 +1,48 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.getRights", + "defs": { + "main": { + "type": "query", + "description": "Looks up one indexed rights record by exact DID-authority AT-URI. Authentication is not required.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Full AT-URI of a rights record, using a DID authority." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The URI is invalid or does not identify a rights record." + }, + { + "name": "RecordNotFound", + "description": "No indexed rights record exists at this AT-URI." + } + ] + }, + "output": { + "type": "object", + "required": ["rights"], + "properties": { + "rights": { + "type": "ref", + "ref": "org.hypercerts.api.defs#rightsView" + } + } + } + } +} diff --git a/api/lexicons/org.hypercerts.claim.listRights.json b/api/lexicons/org.hypercerts.claim.listRights.json new file mode 100644 index 0000000..e55bf94 --- /dev/null +++ b/api/lexicons/org.hypercerts.claim.listRights.json @@ -0,0 +1,69 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.listRights", + "defs": { + "main": { + "type": "query", + "description": "Lists indexed rights records with an optional publisher-DID filter. Authentication is not required.", + "parameters": { + "type": "params", + "properties": { + "authors": { + "type": "array", + "maxLength": 100, + "description": "Publisher DIDs; repeat the unbracketed key for multiple authors. Values use OR.", + "items": { + "type": "string", + "format": "did" + } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Direction for the (createdAt, uri) order; 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 from a previous page; keep the sort direction unchanged." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#output" + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "An author, sort direction, page bound, cursor, repeated scalar, or query parameter is invalid." + } + ] + }, + "output": { + "type": "object", + "required": ["rights"], + "properties": { + "rights": { + "type": "array", + "items": { + "type": "ref", + "ref": "org.hypercerts.api.defs#rightsView" + } + }, + "cursor": { + "type": "string", + "description": "Opaque cursor for the next page; omitted when there is no next page." + } + } + } + } +} diff --git a/api/lua/endpoints/getRights.lua b/api/lua/endpoints/getRights.lua new file mode 100644 index 0000000..93185fb --- /dev/null +++ b/api/lua/endpoints/getRights.lua @@ -0,0 +1,150 @@ +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 function rights_valid_did(value) + if type(value) ~= "string" or 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 = valid_record_uri(value) + if not valid then return false end + local authority = value:match("^at://([^/]+)/") + 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, + 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..660c63b --- /dev/null +++ b/api/lua/endpoints/listRights.lua @@ -0,0 +1,305 @@ +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 function rights_valid_did(value) + if type(value) ~= "string" or 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 = valid_record_uri(value) + if not valid then return false end + local authority = value:match("^at://([^/]+)/") + 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, + 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) + 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..24a1d79 --- /dev/null +++ b/api/lua/shared/rightsIdentifier.lua @@ -0,0 +1,19 @@ +local function rights_valid_did(value) + if type(value) ~= "string" or 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 = valid_record_uri(value) + if not valid then return false end + local authority = value:match("^at://([^/]+)/") + 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 9436815..7d6b2f0 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -1,5 +1,14 @@ { "name": "hypercerts-api-foundation", + "handlerStatus": { + "getRights": "implemented", + "listRights": "implemented" + }, + "authentication": { + "decision": "public", + "unresolved": false, + "note": "Rights queries expose indexed public records and require no caller authentication." + }, "validationLexicons": [ { "id": "app.certified.location", "packagePath": "lexicons/app/certified/location.json" }, { "id": "org.hypercerts.api.defs", "path": "lexicons/org.hypercerts.api.defs.json" }, @@ -7,6 +16,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.getRights", "path": "lexicons/org.hypercerts.claim.getRights.json" }, + { "id": "org.hypercerts.claim.listRights", "path": "lexicons/org.hypercerts.claim.listRights.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" }, @@ -21,6 +32,7 @@ { "id": "org.hypercerts.defs", "packagePath": "lexicons/org/hypercerts/defs.json" }, { "id": "org.hypercerts.workscope.cel", "packagePath": "lexicons/org/hypercerts/workscope/cel.json" }, { "id": "org.hypercerts.claim.activity", "packagePath": "lexicons/org/hypercerts/claim/activity.json" }, + { "id": "org.hypercerts.claim.rights", "packagePath": "lexicons/org/hypercerts/claim/rights.json" }, { "id": "org.hypercerts.claim.contributorInformation", "packagePath": "lexicons/org/hypercerts/claim/contributorInformation.json" }, { "id": "org.hypercerts.collection", "packagePath": "lexicons/org/hypercerts/collection.json" }, { "id": "org.hypercerts.entity.feature", "packagePath": "lexicons/org/hypercerts/entity/feature.json" }, @@ -51,5 +63,5 @@ { "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/rights/manifest.json"] } diff --git a/api/modules/rights/manifest.json b/api/modules/rights/manifest.json new file mode 100644 index 0000000..69c4377 --- /dev/null +++ b/api/modules/rights/manifest.json @@ -0,0 +1,51 @@ +{ + "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/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.rights", "org.hypercerts.api.defs"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.listRights", + "path": "../../lua/endpoints/listRights.lua", + "sourcePath": "../../lua/src/listRights.lua", + "sharedSourcePaths": [ + "../../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/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 a7ebb95..1750791 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -9,6 +9,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.collection", "packagePath": "lexicons/org/hypercerts/collection.json", "config": { "backfill": true }, "dependsOn": ["org.hypercerts.defs", "app.certified.signature.defs"] }, { "kind": "lexicon", "id": "org.hypercerts.context.attachment", "packagePath": "lexicons/org/hypercerts/context/attachment.json", "config": { "backfill": true }, "dependsOn": [] }, @@ -22,7 +23,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.rights"] }, { "kind": "lexicon", diff --git a/api/tooling/lexicons.test.js b/api/tooling/lexicons.test.js index 127493d..58a934b 100644 --- a/api/tooling/lexicons.test.js +++ b/api/tooling/lexicons.test.js @@ -30,6 +30,7 @@ test('the full validation Lexicon closure resolves locally while only selected p 'app.certified.signature.defs', 'org.hypercerts.claim.activity', 'org.hypercerts.claim.contributorInformation', + 'org.hypercerts.claim.rights', 'org.hypercerts.collection', 'org.hypercerts.context.attachment', 'org.hypercerts.context.evaluation', @@ -123,6 +124,30 @@ 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.api.defs#rightsView'); + 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.api.defs#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.api.defs#rightsView'); +}); + 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'); diff --git a/api/tooling/rights-edge-lua.test.js b/api/tooling/rights-edge-lua.test.js new file mode 100644 index 0000000..5217001 --- /dev/null +++ b/api/tooling/rights-edge-lua.test.js @@ -0,0 +1,182 @@ +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/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/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/tooling/rights-lua.test.js b/api/tooling/rights-lua.test.js new file mode 100644 index 0000000..1b69ff3 --- /dev/null +++ b/api/tooling/rights-lua.test.js @@ -0,0 +1,254 @@ +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 [query, recordIdentifier, rightsIdentifier, recordView, actorView, rightsView, getRights] = await Promise.all([ + 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 + }`, + 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/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/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' }); +}); From 09e8eb1aaad4bdf44a28fa74c58b299f3e9187b3 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 15:52:27 +0600 Subject: [PATCH 2/4] rights: cover installed queries and relocate offline suites --- api/tests/http/fixtures/rights.fixture.js | 72 ++++++++++++++ api/tests/http/getRights.http.test.js | 66 +++++++++++++ api/tests/http/listRights.http.test.js | 93 +++++++++++++++++++ .../unit/endpoints}/rights-edge-lua.test.js | 2 +- .../unit/endpoints}/rights-lua.test.js | 2 +- 5 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 api/tests/http/fixtures/rights.fixture.js create mode 100644 api/tests/http/getRights.http.test.js create mode 100644 api/tests/http/listRights.http.test.js rename api/{tooling => tests/unit/endpoints}/rights-edge-lua.test.js (99%) rename api/{tooling => tests/unit/endpoints}/rights-lua.test.js (99%) 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/tooling/rights-edge-lua.test.js b/api/tests/unit/endpoints/rights-edge-lua.test.js similarity index 99% rename from api/tooling/rights-edge-lua.test.js rename to api/tests/unit/endpoints/rights-edge-lua.test.js index 5217001..57619c5 100644 --- a/api/tooling/rights-edge-lua.test.js +++ b/api/tests/unit/endpoints/rights-edge-lua.test.js @@ -4,7 +4,7 @@ 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 root = fileURLToPath(new URL('../../../', import.meta.url)); const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); async function endpointSources(name) { diff --git a/api/tooling/rights-lua.test.js b/api/tests/unit/endpoints/rights-lua.test.js similarity index 99% rename from api/tooling/rights-lua.test.js rename to api/tests/unit/endpoints/rights-lua.test.js index 1b69ff3..f3ed1f1 100644 --- a/api/tooling/rights-lua.test.js +++ b/api/tests/unit/endpoints/rights-lua.test.js @@ -4,7 +4,7 @@ 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 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 () => { From 861e90ba8e7e1dfe7f52588bcb974c5d2aadd04c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 22:51:15 +0600 Subject: [PATCH 3/4] api-tests: isolate funding publisher fixtures --- CONTRIBUTING.md | 5 +- api/README.md | 2 +- .../http/fixtures/funding-receipts.fixture.js | 2 +- .../http/funding-getReceipt.http.test.js | 8 +- .../http/funding-listReceipts.http.test.js | 2 +- api/tests/unit/fixtures/http-fixtures.test.js | 75 +++++++++++++++++++ 6 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 api/tests/unit/fixtures/http-fixtures.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d404167..b84d6e8 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 funding and badge-definition 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 funding, badge-definition, and rights 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. - Badge fixtures with CBOR-derived record CIDs. For the pinned HappyView release, ordinary Lua `error()` exceptions return HTTP 500 JSON with `error: "script_error"` and `errorType: "runtime"`; the error name appears in `message`. Negative HTTP tests assert this observed behavior. It is not a statement of the ideal public HTTP status contract, and does not guarantee 4xx mapping for `RecordNotFound` or `InvalidRequest`. diff --git a/api/README.md b/api/README.md index 704c361..97f6997 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # HappyView API toolkit foundation -This package contains the shared API installer and tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, and the public `org.hypercerts.claim.getRights` and `org.hypercerts.claim.listRights` query handlers. The rights handlers are registered in `modules/rights/manifest.json`; the shared module registers their record and actor-view dependencies. +This package contains the shared API installer and tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, and the public `org.hypercerts.claim.getRights` and `org.hypercerts.claim.listRights` query handlers. The rights handlers are registered in `modules/rights/manifest.json`; the shared module registers their record and actor-view dependencies. Local validation and HTTP runtime test instructions are in [CONTRIBUTING.md](../CONTRIBUTING.md). ## Install a released API bundle diff --git a/api/tests/http/fixtures/funding-receipts.fixture.js b/api/tests/http/fixtures/funding-receipts.fixture.js index 25081f0..df6b8d9 100644 --- a/api/tests/http/fixtures/funding-receipts.fixture.js +++ b/api/tests/http/fixtures/funding-receipts.fixture.js @@ -3,7 +3,7 @@ import * as CID from '@atcute/cid'; import { locationRecords } from '../../fixtures/records.js'; const collection = 'org.hypercerts.funding.receipt'; -const publisher = 'did:plc:abcdefghijklmnopqrstuvwx'; +const publisher = 'did:web:funding-http-publisher.invalid'; const otherPublisher = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; const sender = 'did:plc:cccccccccccccccccccccccc'; const otherSender = 'did:plc:dddddddddddddddddddddddd'; diff --git a/api/tests/http/funding-getReceipt.http.test.js b/api/tests/http/funding-getReceipt.http.test.js index 3a7ff77..0f699ab 100644 --- a/api/tests/http/funding-getReceipt.http.test.js +++ b/api/tests/http/funding-getReceipt.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 receiptUri = 'at://did:plc:abcdefghijklmnopqrstuvwx/org.hypercerts.funding.receipt/receipt-a'; +const receiptUri = 'at://did:web:funding-http-publisher.invalid/org.hypercerts.funding.receipt/receipt-a'; const collection = 'org.hypercerts.funding.receipt'; async function getReceipt(uri) { @@ -27,7 +27,7 @@ test('getReceipt returns the indexed record and hydrates its publisher sidecars' const { receipt } = body; assert.equal(receipt.uri, receiptUri); - assert.equal(receipt.did, 'did:plc:abcdefghijklmnopqrstuvwx'); + assert.equal(receipt.did, 'did:web:funding-http-publisher.invalid'); assert.equal(receipt.indexedAt, '2025-02-01T00:00:00.000Z'); assert.equal(receipt.record.$type, collection); assert.equal(receipt.record.amount, '0012345678901234567890.00000001'); @@ -41,13 +41,13 @@ test('getReceipt returns the indexed record and hydrates its publisher sidecars' uri: 'at://did:plc:ffffffffffffffffffffffff/org.hypercerts.claim.activity/target', cid: 'bafyreidr6dv5qtbrfubummgssjqfow3eavqwnihvmectr63yalrnu4yqea', }); - assert.equal(receipt.author.did, 'did:plc:abcdefghijklmnopqrstuvwx'); + assert.equal(receipt.author.did, 'did:web:funding-http-publisher.invalid'); assert.equal(receipt.author.profile.record.displayName, 'Test Publisher'); assert.deepEqual(receipt.author.organization.record.organizationType, ['nonprofit']); }); test('getReceipt exposes RecordNotFound using the pinned HappyView runtime error response', async () => { - const missingUri = 'at://did:plc:abcdefghijklmnopqrstuvwx/org.hypercerts.funding.receipt/not-indexed'; + const missingUri = 'at://did:web:funding-http-publisher.invalid/org.hypercerts.funding.receipt/not-indexed'; const { response, body } = await getReceipt(missingUri); // The pinned HappyView release serializes ordinary Lua errors as runtime script_error responses. assert.equal(response.status, 500, JSON.stringify(body)); diff --git a/api/tests/http/funding-listReceipts.http.test.js b/api/tests/http/funding-listReceipts.http.test.js index 4259958..33c13b0 100644 --- a/api/tests/http/funding-listReceipts.http.test.js +++ b/api/tests/http/funding-listReceipts.http.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; const endpoint = 'org.hypercerts.funding.listReceipts'; -const publisherA = 'did:plc:abcdefghijklmnopqrstuvwx'; +const publisherA = 'did:web:funding-http-publisher.invalid'; const publisherB = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; const sender = 'did:plc:cccccccccccccccccccccccc'; const recipient = 'did:plc:eeeeeeeeeeeeeeeeeeeeeeee'; diff --git a/api/tests/unit/fixtures/http-fixtures.test.js b/api/tests/unit/fixtures/http-fixtures.test.js new file mode 100644 index 0000000..ad2af90 --- /dev/null +++ b/api/tests/unit/fixtures/http-fixtures.test.js @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; +import { locationRecords, profileRecords, organizationRecords } from '../../fixtures/records.js'; +import { + actorFollowRecords, + actorFollowProfileRecords, + actorFollowOrganizationRecords, +} from '../../fixtures/actor-follows.js'; +import { activityFixtureRows } from '../../fixtures/activities.js'; + +const httpFixtureRoot = fileURLToPath(new URL('../../http/fixtures/', import.meta.url)); + +function compareEntryNames(left, right) { + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; +} + +async function findFixtureModules(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort(compareEntryNames); + const groups = await Promise.all(entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return findFixtureModules(entryPath); + if (entry.isFile() && entry.name.endsWith('.fixture.js')) return [entryPath]; + return []; + })); + return groups.flat(); +} + +test('shared and recursively discovered HTTP fixture rows have unique identities and CBOR-derived CIDs', async () => { + const fixtureModules = await findFixtureModules(httpFixtureRoot); + assert.ok(fixtureModules.length > 0, 'expected HTTP fixture modules under tests/http/fixtures'); + + const httpRows = []; + for (const file of fixtureModules) { + const fixture = await import(pathToFileURL(file).href); + assert.ok(Array.isArray(fixture.seedRows) && fixture.seedRows.length > 0, + `${path.relative(httpFixtureRoot, file)} must export nonempty seedRows`); + httpRows.push(...fixture.seedRows); + } + + const sharedRows = [ + ...locationRecords, + ...profileRecords, + ...organizationRecords, + ...actorFollowRecords, + ...actorFollowProfileRecords, + ...actorFollowOrganizationRecords, + ...activityFixtureRows, + ]; + const rows = [...sharedRows, ...httpRows]; + const uris = new Set(); + const identities = new Set(); + + for (const row of rows) { + assert.equal(row.uri, `at://${row.did}/${row.collection}/${row.rkey}`); + assert.ok(!uris.has(row.uri), `duplicate fixture URI: ${row.uri}`); + uris.add(row.uri); + + const identity = JSON.stringify([row.did, row.collection, row.rkey]); + assert.ok(!identities.has(identity), `duplicate fixture (DID, collection, rkey): ${identity}`); + identities.add(identity); + } + + for (const row of rows) { + const expectedCid = CID.toString(await CID.create(0x71, encode(row.record))); + assert.equal(row.cid, expectedCid, `fixture CID does not match CBOR record: ${row.uri}`); + } +}); From fe0572ca6ec626706a77470a0056b5652eb6eeaf Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 19:09:23 +0600 Subject: [PATCH 4/4] rights: reuse Lua validators and relocate rights view --- api/README.md | 2 +- api/lexicons/org.hypercerts.api.defs.json | 14 ------ .../org.hypercerts.claim.getRights.json | 16 ++++++- .../org.hypercerts.claim.listRights.json | 2 +- api/lua/endpoints/getRights.lua | 13 ++++-- api/lua/endpoints/listRights.lua | 44 +++++++++++++++++-- api/lua/shared/rightsIdentifier.lua | 5 +-- api/modules/rights/manifest.json | 5 ++- api/modules/shared/manifest.json | 2 +- .../unit/endpoints/rights-edge-lua.test.js | 3 +- api/tests/unit/endpoints/rights-lua.test.js | 6 ++- api/tests/unit/tooling/lexicons.test.js | 7 +-- 12 files changed, 86 insertions(+), 33 deletions(-) diff --git a/api/README.md b/api/README.md index 5b1eebb..b456b35 100644 --- a/api/README.md +++ b/api/README.md @@ -23,7 +23,7 @@ Review that release's notes and target only an explicitly approved HappyView ins - `org.hypercerts.claim.getRights` accepts the exact rights-record AT-URI with a DID authority. An unindexed record returns `RecordNotFound`. - `org.hypercerts.claim.listRights` accepts up to 100 repeated, unbracketed `authors` keys; values use OR. Pages default to 25 and cap at 100. Ordering uses `(createdAt, uri)`; missing or malformed record timestamps fall back to `indexed_at`, then row creation time. Cursors are opaque and bound to sort direction; a terminal page omits `cursor`. - Both queries preserve the full indexed record and hydrate the publisher's Certified profile and raw organization sidecar. Missing author records are `null`; SQL `NULL` `indexed_at` is returned as JSON `null` without inventing a timestamp. Query or hydration failures return errors. Attachments and activities referencing rights are not expanded. -- `RightsView.indexedAt` is required but nullable to match indexed rows. Older proposal prose that describes it as non-nullable is stale. +- The unpublished `rightsView` response definition is local to `org.hypercerts.claim.getRights`; `listRights` references that definition. Its `indexedAt` property is required but nullable to match indexed rows. Older proposal prose that describes it as non-nullable is stale. - Rights listing uses PostgreSQL 16+ `pg_input_is_valid` timestamp validation; the canonical HappyView deployment and test configurations default to PostgreSQL 17. The shared module registers the pinned rights-record Lexicon with backfill enabled, so an approved install can index existing rights records. diff --git a/api/lexicons/org.hypercerts.api.defs.json b/api/lexicons/org.hypercerts.api.defs.json index fe019b5..288c4eb 100644 --- a/api/lexicons/org.hypercerts.api.defs.json +++ b/api/lexicons/org.hypercerts.api.defs.json @@ -83,20 +83,6 @@ }, "follow": { "type": "ref", "ref": "#entityFollowRecordView" } } - }, - "rightsView": { - "type": "object", - "description": "Rights record with its publisher actor; the full record is preserved and attachments are not expanded.", - "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.rights" } - } } } } diff --git a/api/lexicons/org.hypercerts.claim.getRights.json b/api/lexicons/org.hypercerts.claim.getRights.json index f8a0189..b2e0741 100644 --- a/api/lexicons/org.hypercerts.claim.getRights.json +++ b/api/lexicons/org.hypercerts.claim.getRights.json @@ -34,13 +34,27 @@ } ] }, + "rightsView": { + "type": "object", + "description": "Rights record with its publisher actor; the full record is preserved and attachments are not expanded.", + "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.rights" } + } + }, "output": { "type": "object", "required": ["rights"], "properties": { "rights": { "type": "ref", - "ref": "org.hypercerts.api.defs#rightsView" + "ref": "#rightsView" } } } diff --git a/api/lexicons/org.hypercerts.claim.listRights.json b/api/lexicons/org.hypercerts.claim.listRights.json index e55bf94..afb9c2f 100644 --- a/api/lexicons/org.hypercerts.claim.listRights.json +++ b/api/lexicons/org.hypercerts.claim.listRights.json @@ -56,7 +56,7 @@ "type": "array", "items": { "type": "ref", - "ref": "org.hypercerts.api.defs#rightsView" + "ref": "org.hypercerts.claim.getRights#rightsView" } }, "cursor": { diff --git a/api/lua/endpoints/getRights.lua b/api/lua/endpoints/getRights.lua index 7adcd7d..170ff1c 100644 --- a/api/lua/endpoints/getRights.lua +++ b/api/lua/endpoints/getRights.lua @@ -1,3 +1,11 @@ +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 @@ -32,7 +40,7 @@ local function valid_record_uri(value) end local function rights_valid_did(value) - if type(value) ~= "string" or not valid_did(value) then return false end + if not valid_did(value) then return false end local position = 1 while true do local percent = value:find("%", position, true) @@ -45,9 +53,8 @@ local function rights_valid_did(value) end local function rights_valid_record_uri(value) - local valid, collection = valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) if not valid then return false end - local authority = value:match("^at://([^/]+)/") return rights_valid_did(authority), collection end diff --git a/api/lua/endpoints/listRights.lua b/api/lua/endpoints/listRights.lua index 6c0417f..be42f62 100644 --- a/api/lua/endpoints/listRights.lua +++ b/api/lua/endpoints/listRights.lua @@ -1,3 +1,11 @@ +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 @@ -32,7 +40,7 @@ local function valid_record_uri(value) end local function rights_valid_did(value) - if type(value) ~= "string" or not valid_did(value) then return false end + if not valid_did(value) then return false end local position = 1 while true do local percent = value:find("%", position, true) @@ -45,9 +53,8 @@ local function rights_valid_did(value) end local function rights_valid_record_uri(value) - local valid, collection = valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) if not valid then return false end - local authority = value:match("^at://([^/]+)/") return rights_valid_did(authority), collection end @@ -112,6 +119,37 @@ local function rights_hydrate_actor_views(actors, run_query) 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 diff --git a/api/lua/shared/rightsIdentifier.lua b/api/lua/shared/rightsIdentifier.lua index 24a1d79..787e844 100644 --- a/api/lua/shared/rightsIdentifier.lua +++ b/api/lua/shared/rightsIdentifier.lua @@ -1,5 +1,5 @@ local function rights_valid_did(value) - if type(value) ~= "string" or not valid_did(value) then return false end + if not valid_did(value) then return false end local position = 1 while true do local percent = value:find("%", position, true) @@ -12,8 +12,7 @@ local function rights_valid_did(value) end local function rights_valid_record_uri(value) - local valid, collection = valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) if not valid then return false end - local authority = value:match("^at://([^/]+)/") return rights_valid_did(authority), collection end diff --git a/api/modules/rights/manifest.json b/api/modules/rights/manifest.json index 69c4377..4e63cf9 100644 --- a/api/modules/rights/manifest.json +++ b/api/modules/rights/manifest.json @@ -13,6 +13,7 @@ "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", @@ -28,7 +29,7 @@ "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.rights", "org.hypercerts.api.defs"] + "dependsOn": ["org.hypercerts.claim.getRights"] }, { "kind": "script", @@ -36,12 +37,14 @@ "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" }, diff --git a/api/modules/shared/manifest.json b/api/modules/shared/manifest.json index 1750791..bf08aca 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -23,7 +23,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.rights"] + "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile"] }, { "kind": "lexicon", diff --git a/api/tests/unit/endpoints/rights-edge-lua.test.js b/api/tests/unit/endpoints/rights-edge-lua.test.js index 57619c5..cd8a0ef 100644 --- a/api/tests/unit/endpoints/rights-edge-lua.test.js +++ b/api/tests/unit/endpoints/rights-edge-lua.test.js @@ -9,6 +9,7 @@ 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', @@ -16,7 +17,7 @@ async function endpointSources(name) { 'lua/shared/actorView.lua', 'lua/shared/rightsView.lua', ]; - if (name === 'listRights') shared.push('lua/shared/listQuery.lua'); + if (name === 'listRights') shared.push('lua/shared/listValidation.lua', 'lua/shared/listQuery.lua'); return Promise.all([...shared, `lua/src/${name}.lua`].map(read)); } diff --git a/api/tests/unit/endpoints/rights-lua.test.js b/api/tests/unit/endpoints/rights-lua.test.js index f3ed1f1..61f7c4c 100644 --- a/api/tests/unit/endpoints/rights-lua.test.js +++ b/api/tests/unit/endpoints/rights-lua.test.js @@ -8,7 +8,8 @@ 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 [query, recordIdentifier, rightsIdentifier, recordView, actorView, rightsView, getRights] = await Promise.all([ + 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'), @@ -59,6 +60,7 @@ test('getRights returns the full indexed record and a nullable hydrated publishe error("unexpected rights hydration query") end }`, + didValidation, query, recordIdentifier, rightsIdentifier, @@ -85,12 +87,14 @@ test('getRights returns the full indexed record and a nullable hydrated publishe 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'), ]); diff --git a/api/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index d2a740e..d95f99e 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -134,19 +134,20 @@ if (hasModule('modules/rights/manifest.json')) test('rights queries preserve the const listRights = byId.get('org.hypercerts.claim.listRights'); assert.ok(shared && rights && getRights && listRights); - const rightsView = lexicons.getDefOrThrow('org.hypercerts.api.defs#rightsView'); + 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.api.defs#rightsView'); + 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.api.defs#rightsView'); + assert.equal(listRights.defs.output.properties.rights.items.ref, 'lex:org.hypercerts.claim.getRights#rightsView'); }); test('installed ATProto validator accepts package language, transitive refs, and real fixture records', async () => {