diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79bd953..00d28bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ pnpm build ## HTTP runtime tests -`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, acknowledgement, collection, context-measurement, context attachment and evaluation, contributor-information, funding, location, profile, organization, feature, work-scope-tag, vocabulary-tag, graph, and contribution query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. +`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, EVM-link, acknowledgement, collection, context-measurement, context attachment and evaluation, contributor-information, funding, location, profile, organization, feature, work-scope-tag, vocabulary-tag, graph, and contribution query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. The local runner requires a local Docker Compose daemon, `psql`, and the pinned PostgreSQL and HappyView images already cached locally. Set `PSQL_PATH` to the absolute path of a trusted `psql` executable: @@ -33,6 +33,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Funding record retrieval, repeated filters, 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. +- EVM-link lookup and listing, actor/address filters, `createdAt`/URI pagination ties in both directions, nullable sidecar hydration, and named error responses. - Feature-query coverage in `api/tests/http/features.http.test.js` exercises exact retrieval and hydrated or absent author sidecars, author/type and organization-presence filters, bidirectional `createdAt`/URI pagination ties, and named request errors. - Contributor-information retrieval by exact AT-URI and listing with repeated-author filters, cursor pagination, hydrated and missing author sidecars, and named runtime errors. - Work-scope-tag exact-URI retrieval, repeated-author filtering with an empty-result case, hydrated and null publisher sidecars, middle-position `indexedAt` fallback, tied pagination in both directions, and named error responses. @@ -48,7 +49,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Contribution exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. - Badge and contribution fixtures with CBOR-derived record CIDs; contribution DIDs are distinct from baseline fixture identities. - Location retrieval with nullable sidecars, repeated author/URI/location-type filters, unsupported-search errors, tied pagination in both directions, malformed/absent `createdAt` handling, and named errors. -- HTTP fixtures for activity, collection, funding, badge-definition, badge-query, acknowledgement, feature, work-scope-tag, contribution, contributor-information, actor, context-measurement, context attachment and evaluation, graph, location, measurement, and vocabulary-tag records use CBOR-derived CIDs. +- HTTP fixtures for activity, collection, funding, badge-definition, badge-query, EVM-link, acknowledgement, feature, work-scope-tag, contribution, contributor-information, actor, context-measurement, context attachment and evaluation, graph, location, measurement, and vocabulary-tag records use CBOR-derived CIDs. 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/README.md b/README.md index 24f6693..b1e27e0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hypercerts API workspace -This repository combines the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, and independently owned capability modules. This composed checkout includes public query modules for actor profiles and organizations, activity, badge definitions and queries, collections, context measurements, attachments and evaluations, actor and entity follows, recent follows, funding receipts, locations, features, work-scope tags, contribution records, contributor information, vocabulary tags, and acknowledgements. The `org.hypercerts.vocab.getVocabTag` and `org.hypercerts.vocab.listVocabTags` handlers are bundled and registered through `api/modules/vocab/manifest.json`. +This repository combines the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, and independently owned capability modules. This composed checkout includes public query modules for actor profiles and organizations, activity, badge definitions and queries, collections, context measurements, attachments and evaluations, actor and entity follows, recent follows, funding receipts, Certified EVM links, locations, features, work-scope tags, contribution records, contributor information, vocabulary tags, and acknowledgements. The `org.hypercerts.vocab.getVocabTag` and `org.hypercerts.vocab.listVocabTags` handlers are bundled and registered through `api/modules/vocab/manifest.json`. This checkout is one of the additive local sibling branches used to compose the API: @@ -8,6 +8,7 @@ Included query endpoints: - Badge definitions: `app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions` - Badge queries: `app.certified.badge.searchBadgeDefinitions`, `app.certified.badge.getBadgeAward`, `app.certified.badge.listBadgeAwards`, `app.certified.badge.getBadgeResponse`, `app.certified.badge.listBadgeResponses` +- Certified EVM links: `app.certified.link.getEvmLink`, `app.certified.link.listEvmLinks` - Funding receipts: `org.hypercerts.funding.getReceipt`, `org.hypercerts.funding.listReceipts` - Acknowledgements: `org.hypercerts.context.getAcknowledgement`, `org.hypercerts.context.listAcknowledgements` - Contributions: `org.hypercerts.claim.getContribution`, `org.hypercerts.claim.listContributions` @@ -33,6 +34,7 @@ pnpm build | Funding receipts | `org.hypercerts.funding.getReceipt`, `org.hypercerts.funding.listReceipts` | Record retrieval, repeated filters, stable pagination, and named runtime errors | | Badge definitions | `app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions` | Record/CID retrieval, publisher sidecars, filters, tied pagination, and named runtime errors | | Badge queries | `app.certified.badge.searchBadgeDefinitions`, `app.certified.badge.getBadgeAward`, `app.certified.badge.listBadgeAwards`, `app.certified.badge.getBadgeResponse`, `app.certified.badge.listBadgeResponses` | Baseline-aware definition search, exact-version award/response lookups, recipient status, raw response history, filters, and cursor pagination | +| Certified EVM links | `app.certified.link.getEvmLink`, `app.certified.link.listEvmLinks` | Exact retrieval, actor/address filters, tied pagination in both directions, nullable sidecars, and named errors | | Features | `org.hypercerts.entity.getFeature`, `org.hypercerts.entity.listFeatures` | Exact retrieval, author sidecars, author/type and organization-presence filters, tied pagination, and named errors | | Contributor information | `org.hypercerts.claim.getContributorInformation`, `org.hypercerts.claim.listContributorInformation` | Exact-URI retrieval, repeated-author filters, cursor pagination, hydrated and missing author sidecars, and named runtime errors | | Work-scope tags | `org.hypercerts.workscope.getWorkscopeTag`, `org.hypercerts.workscope.listWorkscopeTags` | Exact-URI retrieval, author filters, hydrated and null sidecars, middle-position `indexedAt` fallback, tied pagination in both directions, and named runtime errors | diff --git a/api/README.md b/api/README.md index 81d1cb5..aea8a7a 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,14 @@ # HappyView API toolkit and query modules -This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, offline fixture/test utilities, and the feature, contribution, badge-query, vocabulary-tag, and acknowledgement modules. The `modules/shared/manifest.json` contains record schemas and shared query/view Lexicons; it contains no Lua endpoint scripts. A foundation-only install does not implement those queries. Capability modules listed in the root manifest register their endpoint Lexicons and Lua handlers separately; the `badge-queries` module installs five public badge query Lexicons and their Lua handlers. +This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, offline fixture/test utilities, and the EVM-link, feature, contribution, badge-query, vocabulary-tag, and acknowledgement modules. The `modules/shared/manifest.json` contains record schemas and shared query/view Lexicons; it contains no Lua endpoint scripts. A foundation-only install does not implement those queries. Capability modules listed in the root manifest register their endpoint Lexicons and Lua handlers separately; the `badge-queries` module installs five public badge query Lexicons and their Lua handlers. + +## EVM-link queries + +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. + +EVM-link HTTP coverage exercises `getEvmLink` record retrieval, combined actor/address filters, tied pagination in both directions, nullable sidecar hydration, and named errors. Its fixtures use CBOR-derived record CIDs. See the root [CONTRIBUTING.md](../CONTRIBUTING.md) for local validation commands and HTTP runtime requirements. ## Local checks @@ -20,7 +28,7 @@ PSQL_PATH="$(command -v psql)" pnpm test:http ## HTTP runtime tests -HTTP suites live in `api/tests/http` and exercise the funding, badge-definition, badge-query, feature, work-scope-tag, contribution, context-measurement, profile, organization, context attachment and evaluation, activity, collection, acknowledgement, vocabulary-tag, graph, and location XRPC endpoints installed from the current checkout. `pnpm test:http` discovers `*.http.test.js` suites and fixture modules named `*.fixture.js`, then creates a random Compose project with loopback-only dynamic ports, PostgreSQL data on tmpfs, and a task-owned default bridge network. Bridge networking permits container egress. HappyView receives loopback placeholder upstream URLs and proxy variables pointing to `127.0.0.1:9`; these are application-level settings, not network-hard egress isolation. The installer and HTTP suites target only the task-owned loopback service. The runner installs this checkout's manifest, seeds shared and HTTP fixtures, runs the suites, and tears down only that generated Compose project and its temporary credentials. Locally, Compose uses `--pull never`; missing cached images fail before service startup. +HTTP suites live in `api/tests/http` and exercise the funding, badge-definition, badge-query, EVM-link, feature, work-scope-tag, contribution, context-measurement, profile, organization, context attachment and evaluation, activity, collection, acknowledgement, vocabulary-tag, graph, and location XRPC endpoints installed from the current checkout. `pnpm test:http` discovers `*.http.test.js` suites and fixture modules named `*.fixture.js`, then creates a random Compose project with loopback-only dynamic ports, PostgreSQL data on tmpfs, and a task-owned default bridge network. Bridge networking permits container egress. HappyView receives loopback placeholder upstream URLs and proxy variables pointing to `127.0.0.1:9`; these are application-level settings, not network-hard egress isolation. The installer and HTTP suites target only the task-owned loopback service. The runner installs this checkout's manifest, seeds shared and HTTP fixtures, runs the suites, and tears down only that generated Compose project and its temporary credentials. Locally, Compose uses `--pull never`; missing cached images fail before service startup. The HTTP gate fails when it discovers zero suites, executes zero `node:test` cases, or runs only skipped cases. These checks cover real HTTP behavior against PostgreSQL, not just Lua handlers with a fake database. Funding coverage exercises record retrieval, repeated filters, and pagination. Badge-definition coverage exercises retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, createdAt/URI pagination ties, and named error responses. Badge-query coverage exercises baseline-aware definition feeds and discriminating filters, exact-version award/response lookups, recipient status, raw response history, bidirectional tied pagination, nullable sidecars, and named runtime errors. Vocabulary-tag coverage exercises exact retrieval, author filters, hydrated and nullable sidecars, tied pagination, and named errors. Acknowledgement coverage exercises exact retrieval, hydrated and absent publisher sidecars, repeated author/subject filters, tied pagination in both directions, and named errors. Feature coverage exercises exact retrieval and author hydration, list filters and sidecars, tied createdAt/URI pagination, and named errors. Contribution coverage exercises exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. Fixtures use CBOR-derived record CIDs and are seeded only into the task-owned disposable database. 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..1199ed5 --- /dev/null +++ b/api/lua/endpoints/getEvmLink.lua @@ -0,0 +1,169 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local 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..58e2c1d --- /dev/null +++ b/api/lua/endpoints/listEvmLinks.lua @@ -0,0 +1,332 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local 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 8ca60e0..4a6b502 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -55,7 +55,9 @@ "getFeature": "implemented", "listFeatures": "implemented", "getMeasurement": "implemented", - "listMeasurements": "implemented" + "listMeasurements": "implemented", + "getEvmLink": "implemented", + "listEvmLinks": "implemented" }, "authentication": { "decision": "public", @@ -478,6 +480,18 @@ { "id": "org.hypercerts.context.listMeasurements", "path": "lexicons/org.hypercerts.context.listMeasurements.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" } ], "modules": [ @@ -501,7 +515,8 @@ "modules/vocab/manifest.json", "modules/location/manifest.json", "modules/features/manifest.json", - "modules/context-measurements/manifest.json" + "modules/context-measurements/manifest.json", + "modules/evm-links/manifest.json" ], "luaBuild": { "endpointSources": [ diff --git a/api/modules/evm-links/manifest.json b/api/modules/evm-links/manifest.json new file mode 100644 index 0000000..512890e --- /dev/null +++ b/api/modules/evm-links/manifest.json @@ -0,0 +1,65 @@ +{ + "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/didValidation.lua", + "../../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/didValidation.lua", + "../../lua/shared/query.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" + ], + "config": { + "script_type": "lua", + "description": "Certified EVM-link listing handler" + }, + "dependsOn": ["app.certified.link.listEvmLinks"] + } + ] +} 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/tests/unit/tooling/evm-links.test.js b/api/tests/unit/tooling/evm-links.test.js new file mode 100644 index 0000000..f332a76 --- /dev/null +++ b/api/tests/unit/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/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index 1b3ee88..8390bd6 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -29,6 +29,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', 'com.atproto.repo.strongRef',