From 51f60c04b2b7da2b9c9b899f9f3ac56a35ceb49c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 01:29:36 +0600 Subject: [PATCH 1/3] link: add EVM-link query endpoints --- README.md | 4 +- api/README.md | 8 +- .../app.certified.link.getEvmLink.json | 54 +++ .../app.certified.link.listEvmLinks.json | 65 +++ api/lua/endpoints/getEvmLink.lua | 167 ++++++++ api/lua/endpoints/listEvmLinks.lua | 329 +++++++++++++++ api/lua/shared/evmLinkIdentifier.lua | 54 +++ api/lua/src/getEvmLink.lua | 31 ++ api/lua/src/listEvmLinks.lua | 152 +++++++ api/manifest.json | 14 +- api/modules/evm-links/manifest.json | 62 +++ api/tooling/evm-links.test.js | 378 ++++++++++++++++++ api/tooling/lexicons.test.js | 1 + 13 files changed, 1315 insertions(+), 4 deletions(-) create mode 100644 api/lexicons/app.certified.link.getEvmLink.json create mode 100644 api/lexicons/app.certified.link.listEvmLinks.json create mode 100644 api/lua/endpoints/getEvmLink.lua create mode 100644 api/lua/endpoints/listEvmLinks.lua create mode 100644 api/lua/shared/evmLinkIdentifier.lua create mode 100644 api/lua/src/getEvmLink.lua create mode 100644 api/lua/src/listEvmLinks.lua create mode 100644 api/modules/evm-links/manifest.json create mode 100644 api/tooling/evm-links.test.js diff --git a/README.md b/README.md index 512021e..ecd0c0f 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 checkout contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, and the standalone Certified EVM-link query module. ## 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 EVM-link Lua handler bundles. 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..ebaa619 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,12 @@ # 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 owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, offline fixtures/tests, and a standalone Certified EVM-link query module. The `modules/shared/manifest.json` contains shared record schemas and view Lexicons; the `modules/evm-links/manifest.json` installs `app.certified.link.getEvmLink` and `app.certified.link.listEvmLinks` with their handlers. + +## EVM-link queries + +Both endpoints are public and read only `app.certified.link.evm` records. `getEvmLink` requires the exact record AT-URI with a DID authority and returns `RecordNotFound` when it is not indexed. `listEvmLinks` can list globally or accept repeated unbracketed `actors` and `addresses` parameters (up to 100 values each); values within a filter use OR, while both filters combine with AND. Addresses must be `0x` followed by 40 hexadecimal digits and are compared case-insensitively; returned records keep their original address and proof. Lists default to 25 records, cap at 100, sort by `(createdAt, uri)` descending by default, and use a direction-bound opaque cursor. Actor profile and organization sidecars are hydrated when present and returned as null when missing. No wallet balances, transactions, chain data, or legacy Gainforest records are queried. + +The root manifest declares the EVM-link record Lexicon and handlers, so `pnpm build` and `pnpm check` cover the standalone module. `pnpm install:api` installs the declared assets and starts backfill for the record collection; it contacts HappyView and requires an explicitly approved target and token. ## Local checks diff --git a/api/lexicons/app.certified.link.getEvmLink.json b/api/lexicons/app.certified.link.getEvmLink.json new file mode 100644 index 0000000..4f3b552 --- /dev/null +++ b/api/lexicons/app.certified.link.getEvmLink.json @@ -0,0 +1,54 @@ +{ + "lexicon": 1, + "id": "app.certified.link.getEvmLink", + "defs": { + "main": { + "type": "query", + "description": "Looks up an indexed EVM-link record by exact AT-URI and hydrates its actor. Authentication is not required.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Full AT-URI of an app.certified.link.evm record with a DID authority." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "The URI is invalid or is not an app.certified.link.evm record AT-URI." }, + { "name": "RecordNotFound", "description": "No indexed EVM-link record exists at this AT-URI." } + ] + }, + "output": { + "type": "object", + "required": ["evmLink"], + "properties": { + "evmLink": { "type": "ref", "ref": "#evmLinkView" } + } + }, + "evmLinkView": { + "type": "object", + "description": "Indexed EVM-link record with its original address and proof, and the linked actor's hydrated Certified records.", + "required": ["uri", "cid", "indexedAt", "did", "actor", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { + "type": "string", + "format": "datetime", + "description": "Index timestamp, or null when the indexed row has no timestamp." + }, + "did": { "type": "string", "format": "did" }, + "actor": { "type": "ref", "ref": "org.hypercerts.api.defs#actorView" }, + "record": { "type": "ref", "ref": "app.certified.link.evm" } + } + } + } +} diff --git a/api/lexicons/app.certified.link.listEvmLinks.json b/api/lexicons/app.certified.link.listEvmLinks.json new file mode 100644 index 0000000..d9e075b --- /dev/null +++ b/api/lexicons/app.certified.link.listEvmLinks.json @@ -0,0 +1,65 @@ +{ + "lexicon": 1, + "id": "app.certified.link.listEvmLinks", + "defs": { + "main": { + "type": "query", + "description": "Lists indexed EVM-link records, optionally filtered by actor DID and wallet address. Authentication is not required.", + "parameters": { + "type": "params", + "properties": { + "actors": { + "type": "array", + "maxLength": 100, + "description": "Actor DIDs that own matching records; values within this filter use OR.", + "items": { "type": "string", "format": "did" } + }, + "addresses": { + "type": "array", + "maxLength": 100, + "description": "EVM wallet addresses matched case-insensitively; values within this filter use OR.", + "items": { "type": "string", "minLength": 42, "maxLength": 42 } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc", + "description": "Order by the record's createdAt timestamp and then AT-URI." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Maximum number of links to return." + }, + "cursor": { + "type": "string", + "description": "Opaque cursor from a previous page, bound to sortDirection." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "An actor DID, EVM address, page bound, cursor, repeated scalar, or query parameter is invalid." } + ] + }, + "output": { + "type": "object", + "required": ["evmLinks"], + "properties": { + "evmLinks": { + "type": "array", + "items": { "type": "ref", "ref": "app.certified.link.getEvmLink#evmLinkView" } + }, + "cursor": { + "type": "string", + "description": "Opaque cursor for the next page; omitted when there is no next page." + } + } + } + } +} diff --git a/api/lua/endpoints/getEvmLink.lua b/api/lua/endpoints/getEvmLink.lua new file mode 100644 index 0000000..1fd66c3 --- /dev/null +++ b/api/lua/endpoints/getEvmLink.lua @@ -0,0 +1,167 @@ +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter") end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local EVMLINK_COLLECTION = "app.certified.link.evm" + +local function valid_evm_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + if not valid_did(value) then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific then return false end + + local segment_length, index = 0, 1 + while index <= #specific do + local char = specific:sub(index, index) + if char == ":" then + if segment_length == 0 then return false end + segment_length = 0 + index = index + 1 + elseif char == "%" then + local escape = specific:sub(index + 1, index + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + segment_length = segment_length + 1 + index = index + 3 + elseif char:match("^[%w._%-]$") then + segment_length = segment_length + 1 + index = index + 1 + else + return false + end + end + return segment_length > 0 +end + +local function valid_evm_link_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local valid, collection = valid_record_uri(value) + if not valid or collection ~= EVMLINK_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return valid_evm_did(authority) +end + +local function evm_link_record_view(row) + local view = record_view(row) + if view.indexedAt == nil then view.indexedAt = NULL end + return view +end + +local function normalize_evm_actor_indexed_at(actors) + for _, actor in ipairs(actors) do + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = actor[field] + if sidecar ~= nil and sidecar ~= NULL and sidecar.indexedAt == nil then + sidecar.indexedAt = NULL + end + end + end +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 EVMLINK = "app.certified.link.evm" + +local function evm_link_query(sql, values) + if db.backend() ~= "postgres" then + error("EvmLinkQueryFailed: EVM-link API requires PostgreSQL", 0) + end + local ok, rows = pcall(db.raw, sql, values) + if not ok then error("EvmLinkQueryFailed: EVM-link lookup failed", 0) end + return rows +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not uri or not valid_evm_link_uri(uri) then + invalid("uri must be a full app.certified.link.evm AT-URI with a DID authority") + end + + local rows = evm_link_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", + { EVMLINK, uri }) + if #rows == 0 then error("RecordNotFound: EVM-link record is not indexed", 0) end + + local view = evm_link_record_view(rows[1]) + view.actor = { did = view.did } + local hydrated_actors = { view.actor } + hydrate_actor_views(hydrated_actors, evm_link_query) + normalize_evm_actor_indexed_at(hydrated_actors) + return { evmLink = view } +end diff --git a/api/lua/endpoints/listEvmLinks.lua b/api/lua/endpoints/listEvmLinks.lua new file mode 100644 index 0000000..d76a8ae --- /dev/null +++ b/api/lua/endpoints/listEvmLinks.lua @@ -0,0 +1,329 @@ +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_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 function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local EVMLINK_COLLECTION = "app.certified.link.evm" + +local function valid_evm_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + if not valid_did(value) then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific then return false end + + local segment_length, index = 0, 1 + while index <= #specific do + local char = specific:sub(index, index) + if char == ":" then + if segment_length == 0 then return false end + segment_length = 0 + index = index + 1 + elseif char == "%" then + local escape = specific:sub(index + 1, index + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + segment_length = segment_length + 1 + index = index + 3 + elseif char:match("^[%w._%-]$") then + segment_length = segment_length + 1 + index = index + 1 + else + return false + end + end + return segment_length > 0 +end + +local function valid_evm_link_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local valid, collection = valid_record_uri(value) + if not valid or collection ~= EVMLINK_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return valid_evm_did(authority) +end + +local function evm_link_record_view(row) + local view = record_view(row) + if view.indexedAt == nil then view.indexedAt = NULL end + return view +end + +local function normalize_evm_actor_indexed_at(actors) + for _, actor in ipairs(actors) do + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = actor[field] + if sidecar ~= nil and sidecar ~= NULL and sidecar.indexedAt == nil then + sidecar.indexedAt = NULL + end + end + end +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 EVMLINK = "app.certified.link.evm" + +local function evm_link_query(sql, values) + if db.backend() ~= "postgres" then + error("EvmLinkQueryFailed: EVM-link API requires PostgreSQL", 0) + end + local ok, rows = pcall(db.raw, sql, values) + if not ok then error("EvmLinkQueryFailed: EVM-link listing failed", 0) end + return rows +end + +local function valid_evm_address(value) + return #value == 42 and value:match("^0x%x+$") ~= nil +end + +local function array_values(key, validate, description, normalize) + local value = params[key] + if value == nil then return nil end + local values = {} + if type(value) == "string" then + values[1] = value + elseif type(value) == "table" then + for index = 1, #value do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + values[#values + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #values > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for index, item in ipairs(values) do + if not validate(item) then + invalid(key .. "[" .. index .. "] must be " .. description) + end + local normalized = normalize and normalize(item) or item + if not seen[normalized] then + seen[normalized] = true + unique[#unique + 1] = normalized + end + end + return unique +end + +local function add_filter(where, values, expression, items) + if not items then return end + if #items == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = expression .. " IN (" .. table.concat(placeholders, ",") .. ")" +end + +local function decode_evm_link_cursor(token, direction) + if not token then return nil end + if #token == 0 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not valid_datetime(value.t) or not valid_evm_link_uri(value.u) then + invalid("cursor is malformed") + end + return value +end + +local function evm_link_sort_key() + -- CASE guards the cast from malformed publisher-controlled timestamps. + local created = "link.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(link.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(link.indexed_at::timestamptz, link.created_at::timestamptz) END" +end + +local function list_evm_links(actors, addresses, limit, cursor, direction) + local where, values = { "link.collection = $1" }, { EVMLINK } + add_filter(where, values, "link.did", actors) + add_filter(where, values, "lower(link.record::jsonb->>'address')", addresses) + + 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, link.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sql = "SELECT link.uri, link.did, link.cid, link.indexed_at::text AS indexed_at, " .. + "link.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 link CROSS JOIN LATERAL (SELECT " .. evm_link_sort_key() .. " AS sort_at) sorted WHERE " .. + table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", link.uri " .. ordering .. " LIMIT $" .. #values + local rows = evm_link_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views, actors_to_hydrate = {}, {} + for _, row in ipairs(rows) do + local view = evm_link_record_view(row) + view.actor = { did = view.did } + views[#views + 1] = view + actors_to_hydrate[#actors_to_hydrate + 1] = view.actor + end + hydrate_actor_views(actors_to_hydrate, evm_link_query) + normalize_evm_actor_indexed_at(actors_to_hydrate) + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +function handle() + keys_only(params, { + actors = true, + addresses = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local actors = array_values("actors", valid_evm_did, "valid DIDs") + local addresses = array_values("addresses", valid_evm_address, "a 0x-prefixed 40-digit hexadecimal EVM address", string.lower) + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = decode_evm_link_cursor(scalar(params, "cursor"), direction) + local views, next_cursor = list_evm_links(actors, addresses, limit, cursor, direction) + local response = { evmLinks = toarray(views) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/api/lua/shared/evmLinkIdentifier.lua b/api/lua/shared/evmLinkIdentifier.lua new file mode 100644 index 0000000..34f1639 --- /dev/null +++ b/api/lua/shared/evmLinkIdentifier.lua @@ -0,0 +1,54 @@ +local EVMLINK_COLLECTION = "app.certified.link.evm" + +local function valid_evm_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + if not valid_did(value) then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific then return false end + + local segment_length, index = 0, 1 + while index <= #specific do + local char = specific:sub(index, index) + if char == ":" then + if segment_length == 0 then return false end + segment_length = 0 + index = index + 1 + elseif char == "%" then + local escape = specific:sub(index + 1, index + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + segment_length = segment_length + 1 + index = index + 3 + elseif char:match("^[%w._%-]$") then + segment_length = segment_length + 1 + index = index + 1 + else + return false + end + end + return segment_length > 0 +end + +local function valid_evm_link_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local valid, collection = valid_record_uri(value) + if not valid or collection ~= EVMLINK_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return valid_evm_did(authority) +end + +local function evm_link_record_view(row) + local view = record_view(row) + if view.indexedAt == nil then view.indexedAt = NULL end + return view +end + +local function normalize_evm_actor_indexed_at(actors) + for _, actor in ipairs(actors) do + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = actor[field] + if sidecar ~= nil and sidecar ~= NULL and sidecar.indexedAt == nil then + sidecar.indexedAt = NULL + end + end + end +end diff --git a/api/lua/src/getEvmLink.lua b/api/lua/src/getEvmLink.lua new file mode 100644 index 0000000..026e9f8 --- /dev/null +++ b/api/lua/src/getEvmLink.lua @@ -0,0 +1,31 @@ +local EVMLINK = "app.certified.link.evm" + +local function evm_link_query(sql, values) + if db.backend() ~= "postgres" then + error("EvmLinkQueryFailed: EVM-link API requires PostgreSQL", 0) + end + local ok, rows = pcall(db.raw, sql, values) + if not ok then error("EvmLinkQueryFailed: EVM-link lookup failed", 0) end + return rows +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not uri or not valid_evm_link_uri(uri) then + invalid("uri must be a full app.certified.link.evm AT-URI with a DID authority") + end + + local rows = evm_link_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", + { EVMLINK, uri }) + if #rows == 0 then error("RecordNotFound: EVM-link record is not indexed", 0) end + + local view = evm_link_record_view(rows[1]) + view.actor = { did = view.did } + local hydrated_actors = { view.actor } + hydrate_actor_views(hydrated_actors, evm_link_query) + normalize_evm_actor_indexed_at(hydrated_actors) + return { evmLink = view } +end diff --git a/api/lua/src/listEvmLinks.lua b/api/lua/src/listEvmLinks.lua new file mode 100644 index 0000000..301016b --- /dev/null +++ b/api/lua/src/listEvmLinks.lua @@ -0,0 +1,152 @@ +local EVMLINK = "app.certified.link.evm" + +local function evm_link_query(sql, values) + if db.backend() ~= "postgres" then + error("EvmLinkQueryFailed: EVM-link API requires PostgreSQL", 0) + end + local ok, rows = pcall(db.raw, sql, values) + if not ok then error("EvmLinkQueryFailed: EVM-link listing failed", 0) end + return rows +end + +local function valid_evm_address(value) + return #value == 42 and value:match("^0x%x+$") ~= nil +end + +local function array_values(key, validate, description, normalize) + local value = params[key] + if value == nil then return nil end + local values = {} + if type(value) == "string" then + values[1] = value + elseif type(value) == "table" then + for index = 1, #value do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + values[#values + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #values > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for index, item in ipairs(values) do + if not validate(item) then + invalid(key .. "[" .. index .. "] must be " .. description) + end + local normalized = normalize and normalize(item) or item + if not seen[normalized] then + seen[normalized] = true + unique[#unique + 1] = normalized + end + end + return unique +end + +local function add_filter(where, values, expression, items) + if not items then return end + if #items == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = expression .. " IN (" .. table.concat(placeholders, ",") .. ")" +end + +local function decode_evm_link_cursor(token, direction) + if not token then return nil end + if #token == 0 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not valid_datetime(value.t) or not valid_evm_link_uri(value.u) then + invalid("cursor is malformed") + end + return value +end + +local function evm_link_sort_key() + -- CASE guards the cast from malformed publisher-controlled timestamps. + local created = "link.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(link.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(link.indexed_at::timestamptz, link.created_at::timestamptz) END" +end + +local function list_evm_links(actors, addresses, limit, cursor, direction) + local where, values = { "link.collection = $1" }, { EVMLINK } + add_filter(where, values, "link.did", actors) + add_filter(where, values, "lower(link.record::jsonb->>'address')", addresses) + + 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, link.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sql = "SELECT link.uri, link.did, link.cid, link.indexed_at::text AS indexed_at, " .. + "link.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 link CROSS JOIN LATERAL (SELECT " .. evm_link_sort_key() .. " AS sort_at) sorted WHERE " .. + table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", link.uri " .. ordering .. " LIMIT $" .. #values + local rows = evm_link_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views, actors_to_hydrate = {}, {} + for _, row in ipairs(rows) do + local view = evm_link_record_view(row) + view.actor = { did = view.did } + views[#views + 1] = view + actors_to_hydrate[#actors_to_hydrate + 1] = view.actor + end + hydrate_actor_views(actors_to_hydrate, evm_link_query) + normalize_evm_actor_indexed_at(actors_to_hydrate) + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +function handle() + keys_only(params, { + actors = true, + addresses = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local actors = array_values("actors", valid_evm_did, "valid DIDs") + local addresses = array_values("addresses", valid_evm_address, "a 0x-prefixed 40-digit hexadecimal EVM address", string.lower) + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = decode_evm_link_cursor(scalar(params, "cursor"), direction) + local views, next_cursor = list_evm_links(actors, addresses, limit, cursor, direction) + local response = { evmLinks = toarray(views) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/api/manifest.json b/api/manifest.json index 9436815..7026390 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -1,7 +1,19 @@ { "name": "hypercerts-api-foundation", + "handlerStatus": { + "getEvmLink": "implemented", + "listEvmLinks": "implemented" + }, + "authentication": { + "decision": "public", + "unresolved": false, + "note": "The EVM-link query endpoints expose public indexed reads and require no caller authentication." + }, "validationLexicons": [ { "id": "app.certified.location", "packagePath": "lexicons/app/certified/location.json" }, + { "id": "app.certified.link.evm", "packagePath": "lexicons/app/certified/link/evm.json" }, + { "id": "app.certified.link.getEvmLink", "path": "lexicons/app.certified.link.getEvmLink.json" }, + { "id": "app.certified.link.listEvmLinks", "path": "lexicons/app.certified.link.listEvmLinks.json" }, { "id": "org.hypercerts.api.defs", "path": "lexicons/org.hypercerts.api.defs.json" }, { "id": "app.certified.graph.follow", "packagePath": "lexicons/app/certified/graph/follow.json" }, { "id": "app.certified.graph.entityFollow", "packagePath": "lexicons/app/certified/graph/entityFollow.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/evm-links/manifest.json"] } diff --git a/api/modules/evm-links/manifest.json b/api/modules/evm-links/manifest.json new file mode 100644 index 0000000..d792f5a --- /dev/null +++ b/api/modules/evm-links/manifest.json @@ -0,0 +1,62 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "app.certified.link.evm", + "packagePath": "lexicons/app/certified/link/evm.json", + "config": { "backfill": true }, + "dependsOn": ["app.certified.signature.defs"] + }, + { + "kind": "lexicon", + "id": "app.certified.link.getEvmLink", + "path": "../../lexicons/app.certified.link.getEvmLink.json", + "config": { "backfill": false, "target_collection": "app.certified.link.evm" }, + "dependsOn": ["app.certified.link.evm", "org.hypercerts.api.defs"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.link.getEvmLink", + "path": "../../lua/endpoints/getEvmLink.lua", + "sourcePath": "../../lua/src/getEvmLink.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/evmLinkIdentifier.lua", + "../../lua/shared/actorView.lua" + ], + "config": { + "script_type": "lua", + "description": "Certified EVM-link lookup handler" + }, + "dependsOn": ["app.certified.link.getEvmLink"] + }, + { + "kind": "lexicon", + "id": "app.certified.link.listEvmLinks", + "path": "../../lexicons/app.certified.link.listEvmLinks.json", + "config": { "backfill": false, "target_collection": "app.certified.link.evm" }, + "dependsOn": ["app.certified.link.getEvmLink"] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.link.listEvmLinks", + "path": "../../lua/endpoints/listEvmLinks.lua", + "sourcePath": "../../lua/src/listEvmLinks.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/evmLinkIdentifier.lua", + "../../lua/shared/actorView.lua" + ], + "config": { + "script_type": "lua", + "description": "Certified EVM-link listing handler" + }, + "dependsOn": ["app.certified.link.listEvmLinks"] + } + ] +} diff --git a/api/tooling/evm-links.test.js b/api/tooling/evm-links.test.js new file mode 100644 index 0000000..024ecd3 --- /dev/null +++ b/api/tooling/evm-links.test.js @@ -0,0 +1,378 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const moduleFile = path.join(root, 'modules/evm-links/manifest.json'); +const did = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'; +const uri = `at://${did}/app.certified.link.evm/wallet-1`; + +async function handlerSource(id) { + const module = JSON.parse(await readFile(moduleFile, 'utf8')); + const handler = module.assets.find((asset) => asset.id === `xrpc.query:app.certified.link.${id}`); + assert.ok(handler, `the EVM-link ${id} query must be declared as an installable handler`); + const moduleRoot = path.dirname(moduleFile); + const files = [...handler.sharedSourcePaths, handler.sourcePath].map((file) => path.resolve(moduleRoot, file)); + return (await Promise.all(files.map((file) => readFile(file, 'utf8')))).join('\n\n'); +} + +async function getEvmLinkSource() { + return handlerSource('getEvmLink'); +} + +async function listEvmLinksSource() { + return handlerSource('listEvmLinks'); +} + +function luaLiteral(value) { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `{ ${value.map(luaLiteral).join(', ')} }`; + if (value && typeof value === 'object') { + return `{ ${Object.entries(value).map(([key, entry]) => `[${JSON.stringify(key)}] = ${luaLiteral(entry)}`).join(', ')} }`; + } + throw new TypeError(`Unsupported Lua fixture value: ${String(value)}`); +} + +async function runListLua({ params: request, pages = [], extraDecoded = [], assertions }) { + const source = await listEvmLinksSource(); + const normalizedPages = pages.map((page) => page.map((row) => ({ + ...row, + record: JSON.stringify(row.record), + }))); + const decodedRecords = {}; + for (const page of pages) for (const row of page) decodedRecords[JSON.stringify(row.record)] = row.record; + const decodedMap = Object.fromEntries(extraDecoded.map(({ json, value }) => [json, value])); + const lua = ` +local null = {} +local did = ${JSON.stringify(did)} +local uri = ${JSON.stringify(uri)} +local page_rows = ${luaLiteral(normalizedPages)} +local decoded_records = ${luaLiteral(decodedRecords)} +local extra_decoded = ${luaLiteral(decodedMap)} +local list_calls = 0 +local list_queries = {} +local encoded_cursor_text +local encoded_cursor_value +json = { + decode = function(value) + if value == 'null' then return null end + if decoded_records[value] then return decoded_records[value] end + if extra_decoded[value] then return extra_decoded[value] end + if value == encoded_cursor_text then return encoded_cursor_value end + error('unexpected JSON fixture: ' .. value) + end, + encode = function(value) + encoded_cursor_value = value + encoded_cursor_text = string.format('{"v":%d,"d":"%s","t":"%s","u":"%s"}', value.v, value.d, value.t, value.u) + return encoded_cursor_text + end, +} +params = ${luaLiteral(request)} +toarray = function(value) return value end +db = { + backend = function() return 'postgres' end, + raw = function(sql, values) + if values[1] == 'app.certified.link.evm' then + list_calls = list_calls + 1 + list_queries[list_calls] = { sql = sql, values = values } + return page_rows[list_calls] or {} + end + return {} + end, +} +${source} +${assertions} +`; + const execution = spawnSync('lua5.4', ['-'], { input: lua, encoding: 'utf8' }); + assert.equal(execution.status, 0, execution.stderr || execution.stdout); +} + +test('getEvmLink rejects malformed DID authorities but accepts percent-encoded DID characters', async () => { + const source = await getEvmLinkSource(); + const malformedUris = [ + 'at://did:plc:abc%GG/app.certified.link.evm/record', + 'at://did:plc:abc::def/app.certified.link.evm/record', + 'at://did:plc::abc/app.certified.link.evm/record', + 'at://did:plc1:abc/app.certified.link.evm/record', + ]; + const lua = ` +local null = {} +json = { decode = function(value) if value == 'null' then return null end error('unexpected JSON') end } +local lookup_count = 0 +params = {} +db = { + backend = function() return 'postgres' end, + raw = function() lookup_count = lookup_count + 1; return {} end, +} +${source} +for _, malformed_uri in ipairs(${luaLiteral(malformedUris)}) do + params = { uri = malformed_uri } + local ok, err = pcall(handle) + assert(not ok and tostring(err):find('InvalidRequest:', 1, true), 'malformed DID authorities must be rejected') +end +params = { uri = 'at://did:web:example.com%3A3000:users:alice/app.certified.link.evm/record' } +local ok, err = pcall(handle) +assert(not ok and tostring(err):find('RecordNotFound:', 1, true), 'a valid percent-encoded DID must reach exact lookup') +assert(lookup_count == 1, 'invalid DID authorities must be rejected before querying') +`; + const execution = spawnSync('lua5.4', ['-'], { input: lua, encoding: 'utf8' }); + assert.equal(execution.status, 0, execution.stderr || execution.stdout); +}); + +test('getEvmLink returns the exact record with its original proof and hydrated nullable actor', async () => { + const source = await getEvmLinkSource(); + const linkRaw = JSON.stringify({ + address: '0xAa00000000000000000000000000000000000001', + proof: { + $type: 'app.certified.link.evm#eip712Proof', + signature: `0x${'1'.repeat(128)}`, + message: { + $type: 'app.certified.link.evm#eip712Message', + did, + evmAddress: '0xAa00000000000000000000000000000000000001', + chainId: '1', + timestamp: '1700000000', + nonce: '7', + }, + }, + createdAt: '2024-01-02T03:04:05.000Z', + }); + const profileRaw = JSON.stringify({ displayName: 'Ada' }); + const lua = ` +local did = ${JSON.stringify(did)} +local uri = ${JSON.stringify(uri)} +local link_raw = ${JSON.stringify(linkRaw)} +local profile_raw = ${JSON.stringify(profileRaw)} +local null = {} +local link_record = { + address = '0xAa00000000000000000000000000000000000001', + proof = { + ['$type'] = 'app.certified.link.evm#eip712Proof', + signature = '0x${'1'.repeat(128)}', + message = { ['$type'] = 'app.certified.link.evm#eip712Message', did = did, evmAddress = '0xAa00000000000000000000000000000000000001', chainId = '1', timestamp = '1700000000', nonce = '7' }, + }, + createdAt = '2024-01-02T03:04:05.000Z', +} +json = { + decode = function(value) + if value == 'null' then return null end + if value == link_raw then return link_record end + if value == profile_raw then return { displayName = 'Ada' } end + error('unexpected JSON fixture: ' .. value) + end, + encode = function() error('cursor encoding is not used by this lookup test') end, +} +params = { uri = uri } +local direct_lookup +local profile_lookup +local organization_lookup +db = { + backend = function() return 'postgres' end, + raw = function(sql, values) + if values[1] == 'app.certified.link.evm' then + direct_lookup = { sql = sql, values = values } + return {{ uri = uri, did = did, cid = 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', indexed_at = '2024-01-02T03:05:00Z', record = link_raw }} + end + if values[1] == 'app.certified.actor.profile' then + profile_lookup = { sql = sql, values = values } + return {{ uri = 'at://' .. did .. '/app.certified.actor.profile/self', did = did, cid = 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', indexed_at = '2024-01-02T03:06:00Z', record = profile_raw }} + end + if values[1] == 'app.certified.actor.organization' then + organization_lookup = { sql = sql, values = values } + return {} + end + error('unexpected collection: ' .. tostring(values[1])) + end, +} +${source} +local result = handle() +assert(direct_lookup and direct_lookup.values[2] == uri, 'lookup must bind the exact requested AT-URI') +assert(direct_lookup.sql:find('WHERE collection = $1 AND uri = $2', 1, true), 'lookup must use exact URI equality') +assert(result.evmLink.uri == uri and result.evmLink.did == did) +assert(result.evmLink.record.address == link_record.address) +assert(result.evmLink.record.proof.signature == link_record.proof.signature) +assert(result.evmLink.record.proof.message.nonce == '7') +assert(result.evmLink.actor.did == did) +assert(result.evmLink.actor.profile.record.displayName == 'Ada') +assert(result.evmLink.actor.organization == null, 'missing organization must be JSON null') +assert(profile_lookup and organization_lookup, 'actor hydration must check both Certified sidecars') +`; + const execution = spawnSync('lua5.4', ['-'], { input: lua, encoding: 'utf8' }); + assert.equal(execution.status, 0, execution.stderr || execution.stdout); +}); + +test('getEvmLink emits explicit null indexedAt values for the link and hydrated sidecars', async () => { + const source = await getEvmLinkSource(); + const linkRecord = { address: '0xAa00000000000000000000000000000000000001', proof: { signature: 'unchanged' }, createdAt: '2024-01-02T03:04:05Z' }; + const profileRecord = { displayName: 'Ada' }; + const organizationRecord = { name: 'Example' }; + const rawRecords = { + [JSON.stringify(linkRecord)]: linkRecord, + [JSON.stringify(profileRecord)]: profileRecord, + [JSON.stringify(organizationRecord)]: organizationRecord, + }; + const lua = ` +local did = ${JSON.stringify(did)} +local uri = ${JSON.stringify(uri)} +local link_raw = ${JSON.stringify(JSON.stringify(linkRecord))} +local profile_raw = ${JSON.stringify(JSON.stringify(profileRecord))} +local organization_raw = ${JSON.stringify(JSON.stringify(organizationRecord))} +local records = ${luaLiteral(rawRecords)} +local null = {} +json = { decode = function(value) if value == 'null' then return null end return records[value] end } +params = { uri = uri } +db = { + backend = function() return 'postgres' end, + raw = function(_, values) + if values[1] == 'app.certified.link.evm' then + return {{ uri = uri, did = did, cid = 'cid-link', indexed_at = nil, record = link_raw }} + end + if values[1] == 'app.certified.actor.profile' then + return {{ uri = 'at://' .. did .. '/app.certified.actor.profile/self', did = did, cid = 'cid-profile', indexed_at = nil, record = profile_raw }} + end + if values[1] == 'app.certified.actor.organization' then + return {{ uri = 'at://' .. did .. '/app.certified.actor.organization/self', did = did, cid = 'cid-org', indexed_at = nil, record = organization_raw }} + end + error('unexpected collection: ' .. tostring(values[1])) + end, +} +${source} +local result = handle() +assert(result.evmLink.indexedAt == null, 'SQL NULL on the link must serialize as JSON null') +assert(result.evmLink.actor.profile.indexedAt == null, 'SQL NULL on the profile sidecar must serialize as JSON null') +assert(result.evmLink.actor.organization.indexedAt == null, 'SQL NULL on the organization sidecar must serialize as JSON null') +assert(result.evmLink.record.proof.signature == 'unchanged') +`; + const execution = spawnSync('lua5.4', ['-'], { input: lua, encoding: 'utf8' }); + assert.equal(execution.status, 0, execution.stderr || execution.stdout); +}); + +test('listEvmLinks keeps malformed and missing createdAt records visible and uses fallback sort timestamps', async () => { + const invalidRecord = { + address: '0xAa00000000000000000000000000000000000001', + proof: { signature: 'preserve-invalid-time' }, + createdAt: 'not-a-datetime', + }; + const missingRecord = { + address: '0xBb00000000000000000000000000000000000002', + proof: { signature: 'preserve-missing-time' }, + }; + const rows = [ + { uri: `at://${did}/app.certified.link.evm/invalid-time`, did, cid: 'cid-invalid', indexed_at: '2024-01-01T00:00:00Z', sort_timestamp: '2024-01-01T00:00:00.000000Z', record: invalidRecord }, + { uri: `at://${did}/app.certified.link.evm/missing-time`, did, cid: 'cid-missing', sort_timestamp: '2024-01-02T00:00:00.000000Z', record: missingRecord }, + ]; + await runListLua({ params: { sortDirection: 'asc', limit: '2' }, pages: [rows], assertions: ` +local result = handle() +assert(#result.evmLinks == 2, 'invalid and missing createdAt values must not hide records') +assert(result.evmLinks[1].record.createdAt == 'not-a-datetime') +assert(result.evmLinks[1].record.proof.signature == 'preserve-invalid-time') +assert(result.evmLinks[2].record.createdAt == nil, 'the source record must not be rewritten') +assert(result.evmLinks[2].record.proof.signature == 'preserve-missing-time') +assert(result.evmLinks[2].indexedAt == null, 'SQL NULL indexed_at must be explicit JSON null') +local sql = list_queries[1].sql +assert(sql:find("jsonb_typeof(link.record::jsonb->'createdAt') = 'string'", 1, true)) +assert(sql:find('pg_input_is_valid', 1, true), 'untrusted timestamp casts must be guarded') +assert(sql:find('COALESCE(link.indexed_at::timestamptz, link.created_at::timestamptz)', 1, true), 'missing or invalid timestamps must fall back to index then row creation time') +assert(sql:find('ORDER BY sorted.sort_at ASC, link.uri ASC', 1, true)) +` }); +}); + +test('listEvmLinks combines actor/address filters, normalizes matching only, and continues with a stable opaque cursor', async () => { + const did2 = 'did:web:example.com'; + const firstUri = `at://${did}/app.certified.link.evm/wallet-1`; + const address = '0xAa00000000000000000000000000000000000001'; + const address2 = '0xBB00000000000000000000000000000000000002'; + const cursorTime = '2024-01-02T03:04:05.123456Z'; + const record = (account) => ({ + address: account, + proof: { $type: 'app.certified.link.evm#eip712Proof', signature: 'original-proof' }, + createdAt: '2024-01-02T03:04:05.123456Z', + }); + const rows = [ + { uri: firstUri, did, cid: 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', indexed_at: '2024-01-02T03:05:00Z', sort_timestamp: cursorTime, record: record(address) }, + { uri: `at://${did2}/app.certified.link.evm/wallet-2`, did: did2, cid: 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', indexed_at: '2024-01-02T03:05:00Z', sort_timestamp: '2024-01-03T03:04:05.123456Z', record: record('0xBb00000000000000000000000000000000000002') }, + ]; + const cursorJSON = JSON.stringify({ v: 1, d: 'asc', t: cursorTime, u: firstUri }); + const request = { actors: [did, did2], addresses: [address.toLowerCase(), address2], sortDirection: 'asc', limit: '1' }; + const resumeRequest = { ...request, cursor: Buffer.from(cursorJSON).toString('hex') }; + + await runListLua({ params: request, pages: [rows, []], assertions: ` +local first_page = handle() +assert(#first_page.evmLinks == 1) +assert(first_page.evmLinks[1].uri == uri) +assert(first_page.evmLinks[1].record.address == '${address}', 'the response must preserve the original address casing') +assert(first_page.evmLinks[1].record.proof.signature == 'original-proof') +assert(first_page.evmLinks[1].actor.did == did) +assert(first_page.evmLinks[1].actor.profile == null and first_page.evmLinks[1].actor.organization == null) +assert(type(first_page.cursor) == 'string' and #first_page.cursor > 0) +assert(encoded_cursor_value.v == 1 and encoded_cursor_value.d == 'asc') +assert(encoded_cursor_value.t == '${cursorTime}' and encoded_cursor_value.u == uri) +local first_query = list_queries[1] +assert(first_query.values[1] == 'app.certified.link.evm') +assert(first_query.values[2] == '${did}' and first_query.values[3] == '${did2}') +assert(first_query.values[4] == '${address.toLowerCase()}' and first_query.values[5] == '${address2.toLowerCase()}') +assert(first_query.values[6] == 2) +assert(first_query.sql:find('link.did IN ($2,$3)', 1, true), 'actor values must use OR within their filter') +assert(first_query.sql:find("lower(link.record::jsonb->>'address') IN ($4,$5)", 1, true), 'address values must use OR within their filter') +assert(first_query.sql:find("link.did IN ($2,$3) AND lower(link.record::jsonb->>'address') IN ($4,$5)", 1, true), 'distinct filters must combine with AND') +assert(first_query.sql:find('ORDER BY sorted.sort_at ASC, link.uri ASC', 1, true)) +assert(first_query.sql:find('LIMIT $6', 1, true)) +params = ${luaLiteral(resumeRequest)} +local second_page = handle() +assert(#second_page.evmLinks == 0 and second_page.cursor == nil) +local second_query = list_queries[2] +assert(second_query.values[6] == '${cursorTime}' and second_query.values[7] == '${firstUri}') +assert(second_query.values[8] == 2) +assert(second_query.sql:find(') > (($6)::timestamptz, $7)', 1, true), 'cursor must resume strictly after the last key') +` }); +}); + +test('listEvmLinks rejects malformed filters, page bounds, unknown parameters, and cursors before querying', async () => { + const cursorObject = { v: 1, d: 'asc', t: '2024-01-02T03:04:05Z', u: uri }; + const cursorJSON = JSON.stringify(cursorObject); + const methodDid = 'did:plc1:abc'; + const methodUri = `at://${methodDid}/app.certified.link.evm/key`; + const methodCursor = { v: 1, d: 'asc', t: '2024-01-02T03:04:05Z', u: methodUri }; + const methodCursorJSON = JSON.stringify(methodCursor); + const escapedDid = 'did:web:example.com%3A3000:users:alice'; + const invalidRequests = [ + { addresses: ['0xnot-an-address'] }, + { actors: ['not-a-did'] }, + { actors: ['did:plc:abc%GG'] }, + { actors: ['did:plc:abc::def'] }, + { actors: ['did:plc::abc'] }, + { actors: [methodDid] }, + { actors: Array.from({ length: 101 }, (_, index) => `did:plc:actor${index}`) }, + { addresses: Array(101).fill('0xAa00000000000000000000000000000000000001') }, + { limit: '0' }, + { sortDirection: 'sideways' }, + { unexpected: 'value' }, + { sortDirection: 'desc', cursor: Buffer.from(cursorJSON).toString('hex') }, + { sortDirection: 'asc', cursor: Buffer.from(methodCursorJSON).toString('hex') }, + { cursor: 'not-hex' }, + ]; + await runListLua({ + params: {}, + pages: [[]], + extraDecoded: [ + { json: cursorJSON, value: cursorObject }, + { json: methodCursorJSON, value: methodCursor }, + ], + assertions: ` +local invalid_requests = ${luaLiteral(invalidRequests)} +for _, request in ipairs(invalid_requests) do + params = request + local ok, err = pcall(handle) + assert(not ok and tostring(err):find('InvalidRequest:', 1, true), 'expected InvalidRequest for malformed query') +end +params = { actors = { '${escapedDid}' } } +local valid_result = handle() +assert(#valid_result.evmLinks == 0) +assert(list_calls == 1 and list_queries[1].values[2] == '${escapedDid}', 'valid percent-encoded DIDs must reach the query') +`, + }); +}); diff --git a/api/tooling/lexicons.test.js b/api/tooling/lexicons.test.js index 127493d..6dca2cf 100644 --- a/api/tooling/lexicons.test.js +++ b/api/tooling/lexicons.test.js @@ -26,6 +26,7 @@ test('the full validation Lexicon closure resolves locally while only selected p 'app.certified.defs', 'app.certified.graph.entityFollow', 'app.certified.graph.follow', + 'app.certified.link.evm', 'app.certified.location', 'app.certified.signature.defs', 'org.hypercerts.claim.activity', From 3aa33eef17c62f413e5e1f4122f2b6368712851f Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 15:53:08 +0600 Subject: [PATCH 2/3] evm-links: validate installed queries over HTTP --- api/README.md | 4 +- api/tests/http/evm-links.http.test.js | 141 ++++++++++++++++++ api/tests/http/fixtures/evm-links.fixture.js | 93 ++++++++++++ .../unit}/tooling/evm-links.test.js | 2 +- 4 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 api/tests/http/evm-links.http.test.js create mode 100644 api/tests/http/fixtures/evm-links.fixture.js rename api/{ => tests/unit}/tooling/evm-links.test.js (99%) diff --git a/api/README.md b/api/README.md index 4ea739d..4784c9b 100644 --- a/api/README.md +++ b/api/README.md @@ -26,9 +26,9 @@ PSQL_PATH="$(command -v psql)" pnpm test:http ## HTTP runtime tests -HTTP suites live in `api/tests/http` and call the two funding and two badge-definition XRPC endpoints installed from the current checkout. `pnpm test:http` discovers `*.http.test.js` suites and fixture modules named `*.fixture.js`, then creates a random Compose project with loopback-only dynamic ports, PostgreSQL data on tmpfs, and a task-owned default bridge network. Bridge networking permits container egress. HappyView receives loopback placeholder upstream URLs and proxy variables pointing to `127.0.0.1:9`; these are application-level settings, not network-hard egress isolation. The installer and HTTP suites target only the task-owned loopback service. The runner installs this checkout's manifest, seeds shared and HTTP fixtures, runs the suites, and tears down only that generated Compose project and its temporary credentials. Locally, Compose uses `--pull never`; missing cached images fail before service startup. +HTTP suites live in `api/tests/http` and call the two funding, two badge-definition, and two EVM-link XRPC endpoints installed from the current checkout. `pnpm test:http` discovers `*.http.test.js` suites and fixture modules named `*.fixture.js`, then creates a random Compose project with loopback-only dynamic ports, PostgreSQL data on tmpfs, and a task-owned default bridge network. Bridge networking permits container egress. HappyView receives loopback placeholder upstream URLs and proxy variables pointing to `127.0.0.1:9`; these are application-level settings, not network-hard egress isolation. The installer and HTTP suites target only the task-owned loopback service. The runner installs this checkout's manifest, seeds shared and HTTP fixtures, runs the suites, and tears down only that generated Compose project and its temporary credentials. Locally, Compose uses `--pull never`; missing cached images fail before service startup. -The HTTP gate fails when it discovers zero suites, executes zero `node:test` cases, or runs only skipped cases. These checks cover real HTTP behavior against PostgreSQL, not just Lua handlers with a fake database. Funding coverage exercises record retrieval, repeated filters, and pagination. Badge-definition coverage exercises retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, createdAt/URI pagination ties, and named error responses. Badge fixtures use CBOR-derived record CIDs and are seeded only into the task-owned disposable database. +The HTTP gate fails when it discovers zero suites, executes zero `node:test` cases, or runs only skipped cases. These checks cover real HTTP behavior against PostgreSQL, not just Lua handlers with a fake database. Funding coverage exercises record retrieval, repeated filters, and pagination. Badge-definition coverage exercises retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, createdAt/URI pagination ties, and named error responses. EVM-link coverage exercises `getEvmLink` CID/record retrieval, combined actor/address filters, tied pagination in ascending and descending order, nullable sidecar hydration, and named error responses. Badge and EVM-link fixtures use CBOR-derived record CIDs and are seeded only into the task-owned disposable database. For the pinned HappyView release, ordinary Lua `error()` exceptions are returned as HTTP 500 JSON with `error: "script_error"` and `errorType: "runtime"`; the error name appears in `message`. The negative HTTP tests assert this observed runtime behavior. They do not define an ideal public HTTP status contract or guarantee 4xx mapping for `RecordNotFound` and `InvalidRequest`. diff --git a/api/tests/http/evm-links.http.test.js b/api/tests/http/evm-links.http.test.js new file mode 100644 index 0000000..4832dc2 --- /dev/null +++ b/api/tests/http/evm-links.http.test.js @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; +import { seedRows } from './fixtures/evm-links.fixture.js'; + +const getEndpoint = 'app.certified.link.getEvmLink'; +const listEndpoint = 'app.certified.link.listEvmLinks'; +const primaryDid = 'did:web:evm-links-primary.invalid'; +const secondaryDid = 'did:web:evm-links-secondary.invalid'; +const outsideDid = 'did:web:evm-links-outside.invalid'; +const primaryUri = `at://${primaryDid}/app.certified.link.evm/wallet-a`; +const addressA = '0xAa00000000000000000000000000000000000001'; +const addressB = '0xBb00000000000000000000000000000000000002'; +const linkUris = { + outsideTie: `at://${outsideDid}/app.certified.link.evm/wallet-e`, + primaryA: primaryUri, + primaryB: `at://${primaryDid}/app.certified.link.evm/wallet-b`, + secondaryC: `at://${secondaryDid}/app.certified.link.evm/wallet-c`, + secondaryD: `at://${secondaryDid}/app.certified.link.evm/wallet-d`, + outsideLater: `at://${outsideDid}/app.certified.link.evm/wallet-f`, +}; + +async function requestEvmLink(endpoint, 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 }; +} + +function assertRuntimeError({ response, body }, endpoint, errorName) { + 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, new RegExp(errorName)); +} + +test('getEvmLink returns the indexed record with its original CID and hydrated nullable sidecars', async () => { + const { response, body } = await requestEvmLink(getEndpoint, { uri: primaryUri }); + assert.equal(response.status, 200, JSON.stringify(body)); + + const { evmLink } = body; + assert.equal(evmLink.uri, primaryUri); + assert.equal(evmLink.cid, 'bafyreihg3evnlzsrtyctc7azoz6qcfngdglqmcimmxl6nqr2abf25c5ggy'); + assert.equal(evmLink.indexedAt, '2025-03-01T12:00:00.000Z'); + assert.equal(evmLink.did, primaryDid); + assert.deepEqual(evmLink.record, seedRows[0].record); + assert.equal(evmLink.actor.did, primaryDid); + assert.equal(evmLink.actor.profile.uri, `at://${primaryDid}/app.certified.actor.profile/self`); + assert.equal(evmLink.actor.profile.record.displayName, 'EVM Link Primary Actor'); + assert.equal(evmLink.actor.organization, null); +}); + +test('getEvmLink exposes InvalidRequest for a URI in another collection', async () => { + const result = await requestEvmLink(getEndpoint, { + uri: `at://${primaryDid}/app.certified.badge.definition/wallet-a`, + }); + assertRuntimeError(result, getEndpoint, 'InvalidRequest'); +}); + +test('getEvmLink exposes RecordNotFound for an unindexed EVM-link URI', async () => { + const result = await requestEvmLink(getEndpoint, { + uri: `at://${primaryDid}/app.certified.link.evm/not-indexed`, + }); + assertRuntimeError(result, getEndpoint, 'RecordNotFound'); +}); + +test('listEvmLinks ORs repeated actor and normalized address filters, then intersects them', async () => { + const { response, body } = await requestEvmLink(listEndpoint, { + actors: [primaryDid, secondaryDid], + addresses: [addressA.toLowerCase(), `0x${addressB.slice(2).toUpperCase()}`], + sortDirection: 'asc', + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.evmLinks.map(({ uri }) => uri), [ + linkUris.primaryA, + linkUris.primaryB, + linkUris.secondaryC, + ]); + assert.equal(body.evmLinks[0].record.address, addressA, 'matching must not rewrite the stored address'); + assert.equal(body.evmLinks[1].record.address, addressB); + assert.equal(body.evmLinks[0].actor.profile.record.displayName, 'EVM Link Primary Actor'); + assert.equal(body.evmLinks[0].actor.organization, null); + assert.equal(body.evmLinks[2].actor.profile, null); + assert.equal(body.evmLinks[2].actor.organization, null); + assert.equal(Object.hasOwn(body, 'cursor'), false); +}); + +test('listEvmLinks paginates tied timestamps in both sort directions without repeats or omissions', async () => { + const directions = [ + { + direction: 'asc', + expectedPages: [ + [linkUris.outsideTie, linkUris.primaryA], + [linkUris.primaryB, linkUris.secondaryC], + [linkUris.secondaryD, linkUris.outsideLater], + ], + }, + { + direction: 'desc', + expectedPages: [ + [linkUris.outsideLater, linkUris.secondaryD], + [linkUris.secondaryC, linkUris.primaryB], + [linkUris.primaryA, linkUris.outsideTie], + ], + }, + ]; + + for (const { direction, expectedPages } of directions) { + let cursor; + let pageIndex = 0; + do { + const { response, body } = await requestEvmLink(listEndpoint, { + sortDirection: direction, + limit: 2, + cursor, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.evmLinks.map(({ uri }) => uri), expectedPages[pageIndex]); + assert.equal(typeof body.cursor, pageIndex < expectedPages.length - 1 ? 'string' : 'undefined'); + cursor = body.cursor; + pageIndex += 1; + assert.ok(pageIndex <= expectedPages.length, 'pagination must terminate after the fixture rows'); + } while (cursor !== undefined); + assert.equal(pageIndex, expectedPages.length); + } +}); + +test('listEvmLinks exposes InvalidRequest for a malformed EVM address filter', async () => { + const result = await requestEvmLink(listEndpoint, { addresses: 'not-an-address' }); + assertRuntimeError(result, listEndpoint, 'InvalidRequest'); +}); diff --git a/api/tests/http/fixtures/evm-links.fixture.js b/api/tests/http/fixtures/evm-links.fixture.js new file mode 100644 index 0000000..332f15b --- /dev/null +++ b/api/tests/http/fixtures/evm-links.fixture.js @@ -0,0 +1,93 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const collection = 'app.certified.link.evm'; +const profileCollection = 'app.certified.actor.profile'; +const primaryDid = 'did:web:evm-links-primary.invalid'; +const secondaryDid = 'did:web:evm-links-secondary.invalid'; +const outsideDid = 'did:web:evm-links-outside.invalid'; +const indexedAt = '2025-03-01T12:00:00.000Z'; +const tiedCreatedAt = '2025-02-01T00:00:00.000Z'; + +const linkSpecs = [ + { + did: primaryDid, + rkey: 'wallet-a', + address: '0xAa00000000000000000000000000000000000001', + createdAt: tiedCreatedAt, + nonce: '1', + }, + { + did: primaryDid, + rkey: 'wallet-b', + address: '0xBb00000000000000000000000000000000000002', + createdAt: tiedCreatedAt, + nonce: '2', + }, + { + did: secondaryDid, + rkey: 'wallet-c', + address: '0xAa00000000000000000000000000000000000001', + createdAt: tiedCreatedAt, + nonce: '3', + }, + { + did: secondaryDid, + rkey: 'wallet-d', + address: '0xCc00000000000000000000000000000000000003', + createdAt: tiedCreatedAt, + nonce: '4', + }, + { + did: outsideDid, + rkey: 'wallet-e', + address: '0xBb00000000000000000000000000000000000002', + createdAt: tiedCreatedAt, + nonce: '5', + }, + { + did: outsideDid, + rkey: 'wallet-f', + address: '0xCc00000000000000000000000000000000000003', + createdAt: '2025-02-02T00:00:00.000Z', + nonce: '6', + }, +]; + +async function makeSeedRow(did, targetCollection, rkey, fields) { + const record = { $type: targetCollection, ...fields }; + const uri = `at://${did}/${targetCollection}/${rkey}`; + return { + uri, + did, + collection: targetCollection, + rkey, + cid: CID.toString(await CID.create(0x71, encode(record))), + indexedAt, + record, + }; +} + +export const seedRows = await Promise.all([ + ...linkSpecs.map(({ did, rkey, address, createdAt, nonce }) => makeSeedRow(did, collection, rkey, { + address, + proof: { + $type: `${collection}#eip712Proof`, + signature: `0x${nonce.repeat(128)}`, + message: { + $type: `${collection}#eip712Message`, + did, + evmAddress: address, + chainId: '1', + timestamp: '1738368000', + nonce, + }, + }, + createdAt, + })), + makeSeedRow(primaryDid, profileCollection, 'self', { + displayName: 'EVM Link Primary Actor', + description: 'Hydration fixture for the EVM-link query.', + createdAt: indexedAt, + }), +]); diff --git a/api/tooling/evm-links.test.js b/api/tests/unit/tooling/evm-links.test.js similarity index 99% rename from api/tooling/evm-links.test.js rename to api/tests/unit/tooling/evm-links.test.js index 024ecd3..f332a76 100644 --- a/api/tooling/evm-links.test.js +++ b/api/tests/unit/tooling/evm-links.test.js @@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises'; 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 moduleFile = path.join(root, 'modules/evm-links/manifest.json'); const did = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'; const uri = `at://${did}/app.certified.link.evm/wallet-1`; From 5abaffdd6b042637aa92faa453a98b41b56d33ac Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 19:09:23 +0600 Subject: [PATCH 3/3] evm-links: integrate shared Lua validation dependencies --- api/lua/endpoints/getEvmLink.lua | 26 +++++++++-------- api/lua/endpoints/listEvmLinks.lua | 45 +++++++++++++++-------------- api/modules/evm-links/manifest.json | 5 +++- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/api/lua/endpoints/getEvmLink.lua b/api/lua/endpoints/getEvmLink.lua index 1fd66c3..1199ed5 100644 --- a/api/lua/endpoints/getEvmLink.lua +++ b/api/lua/endpoints/getEvmLink.lua @@ -1,10 +1,20 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + local function invalid(message) error("InvalidRequest: " .. message, 0) end -local function keys_only(values, allowed) +local function keys_only(values, allowed, unknown_message_prefix) for key in pairs(values) do - if not allowed[key] then invalid("unknown query parameter") end + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end end end @@ -17,14 +27,6 @@ local function scalar(params, key) 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_~%.:%-]") @@ -34,7 +36,7 @@ local function valid_record_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end - return true, collection + return true, collection, authority end local NULL = json.decode("null") @@ -43,7 +45,7 @@ local function record_view(row) return { uri = row.uri, cid = row.cid, - indexedAt = row.indexed_at, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, did = row.did, record = json.decode(row.record), } diff --git a/api/lua/endpoints/listEvmLinks.lua b/api/lua/endpoints/listEvmLinks.lua index d76a8ae..58e2c1d 100644 --- a/api/lua/endpoints/listEvmLinks.lua +++ b/api/lua/endpoints/listEvmLinks.lua @@ -1,10 +1,20 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + local function invalid(message) error("InvalidRequest: " .. message, 0) end -local function keys_only(values, allowed) +local function keys_only(values, allowed, unknown_message_prefix) for key in pairs(values) do - if not allowed[key] then invalid("unknown query parameter") end + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end end end @@ -17,15 +27,20 @@ local function scalar(params, key) 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 +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority end local function 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 @@ -66,25 +81,13 @@ local function cursor_encode(value) return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) end -local function valid_record_key(value) - return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." - and not value:find("[^%w_~%.:%-]") -end - -local function valid_record_uri(value) - if type(value) ~= "string" or value:find("[?#]") then return false end - local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") - if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end - return true, collection -end - local NULL = json.decode("null") local function record_view(row) return { uri = row.uri, cid = row.cid, - indexedAt = row.indexed_at, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, did = row.did, record = json.decode(row.record), } diff --git a/api/modules/evm-links/manifest.json b/api/modules/evm-links/manifest.json index d792f5a..512890e 100644 --- a/api/modules/evm-links/manifest.json +++ b/api/modules/evm-links/manifest.json @@ -20,6 +20,7 @@ "path": "../../lua/endpoints/getEvmLink.lua", "sourcePath": "../../lua/src/getEvmLink.lua", "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", "../../lua/shared/recordView.lua", @@ -45,9 +46,11 @@ "path": "../../lua/endpoints/listEvmLinks.lua", "sourcePath": "../../lua/src/listEvmLinks.lua", "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", - "../../lua/shared/listQuery.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", "../../lua/shared/evmLinkIdentifier.lua", "../../lua/shared/actorView.lua"