From f5ac8aa13c2e062c75f88d4c0b4879e4484a6721 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 01:04:38 +0600 Subject: [PATCH 1/4] features: add public feature lookup and listing queries --- README.md | 4 +- api/README.md | 18 +- api/lexicons/org.hypercerts.entity.defs.json | 21 + .../org.hypercerts.entity.getFeature.json | 36 ++ .../org.hypercerts.entity.listFeatures.json | 74 ++++ api/lua/endpoints/getFeature.lua | 167 ++++++++ api/lua/endpoints/listFeatures.lua | 364 ++++++++++++++++++ api/lua/src/getFeature.lua | 58 +++ api/lua/src/listFeatures.lua | 214 ++++++++++ api/manifest.json | 13 +- api/modules/features/manifest.json | 52 +++ api/tooling/feature.test.js | 308 +++++++++++++++ 12 files changed, 1323 insertions(+), 6 deletions(-) create mode 100644 api/lexicons/org.hypercerts.entity.defs.json create mode 100644 api/lexicons/org.hypercerts.entity.getFeature.json create mode 100644 api/lexicons/org.hypercerts.entity.listFeatures.json create mode 100644 api/lua/endpoints/getFeature.lua create mode 100644 api/lua/endpoints/listFeatures.lua create mode 100644 api/lua/src/getFeature.lua create mode 100644 api/lua/src/listFeatures.lua create mode 100644 api/modules/features/manifest.json create mode 100644 api/tooling/feature.test.js diff --git a/README.md b/README.md index 512021e..3b7efc1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hypercerts API toolkit foundation -This repository branch contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, and offline checks. It contains shared view Lexicons only; endpoint-specific Lua handlers are added by capability branches. +This repository branch contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, offline checks, and the feature-query capability module. The feature module implements public `org.hypercerts.entity.getFeature` and `org.hypercerts.entity.listFeatures` queries. ## 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 Lua handler bundles declared by the aggregate and module manifests. 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..007361c 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ -# HappyView API toolkit foundation +# HappyView API toolkit and feature queries -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 fixture/test utilities, and the feature-query module. The `modules/shared/manifest.json` contains record schemas and shared view definitions; `modules/features/manifest.json` registers the feature query Lexicons and Lua handlers. ## Local checks @@ -12,8 +12,20 @@ pnpm check pnpm build ``` -These checks are offline and do not require a HappyView instance or database. `pnpm check` runs generated-source freshness, JavaScript/Lua lint, typechecking, and unit tests. `pnpm build` emits only Lua handlers declared by the root and module manifests. Shared Lua files are bundled into capability handlers but are not installed independently. +These checks are offline and do not require a HappyView instance or database. `pnpm check` runs generated-source freshness, JavaScript/Lua lint, typechecking, and unit tests. `pnpm build` emits Lua handlers declared by the aggregate and module manifests. Shared Lua files are bundled into handlers but are not installed independently. The reusable installer validates every local asset and dependency before making admin requests. `pnpm install:api` contacts a HappyView instance and uploads declared assets; do not run it without an explicitly approved target and token. It does not roll back writes if a later asset fails. Fixture SQL helpers require an explicit disposable loopback database opt-in. Unit tests use local fixture data and fake process/network adapters; they do not seed a database or call an external HappyView service. + +## Feature query API + +Both feature queries are public and require no authentication. The aggregate manifest includes the feature module and its validation Lexicons; the handlers read indexed records from PostgreSQL `happyview_records`. + +`org.hypercerts.entity.getFeature` accepts the exact feature record AT-URI, including its DID authority and record key. It returns `InvalidRequest` for malformed or non-feature URIs and `RecordNotFound` when that exact URI is not indexed. The `FeatureView` preserves the indexed record and hydrates only the author's profile and organization sidecar; either actor record may be null, and feature locations, tags, and `sameAs` references remain unexpanded. + +`org.hypercerts.entity.listFeatures` accepts repeated, unbracketed `authors` and `types` query keys, with at most 100 values per array. Different filters combine with AND, while values within either array combine with OR. Authors are repository-owner DIDs; types are exact, case-sensitive open strings of at most 64 UTF-8 bytes. `hasOrganizationRecord=true` requires an `app.certified.actor.organization/self` record, while `false` matches its absence regardless of profile presence. + +Listings sort by `(createdAt, uri)` in the requested direction, defaulting to descending. Pages default to 25 entries and accept limits from 1 through 100. The opaque cursor is bound to `sortDirection`; reuse the same filters when continuing a listing. The response omits `cursor` after the final page. Unknown parameters, repeated scalar parameters, malformed filters, out-of-range limits, and invalid or direction-mismatched cursors return `InvalidRequest`. + +`pnpm build` regenerates the feature handlers in `lua/endpoints/` from their declared `lua/src/` sources and shared projection helpers. Installing the aggregate with `pnpm install:api` contacts the configured HappyView service and uploads assets; it is an external operation and must only be run with an explicitly approved target and token. diff --git a/api/lexicons/org.hypercerts.entity.defs.json b/api/lexicons/org.hypercerts.entity.defs.json new file mode 100644 index 0000000..4a1782e --- /dev/null +++ b/api/lexicons/org.hypercerts.entity.defs.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.entity.defs", + "description": "Shared definitions for Hypercerts entity queries.", + "defs": { + "featureView": { + "type": "object", + "description": "Indexed feature record with its hydrated author actor.", + "required": ["uri", "cid", "indexedAt", "did", "author", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did" }, + "author": { "type": "ref", "ref": "org.hypercerts.api.defs#actorView" }, + "record": { "type": "ref", "ref": "org.hypercerts.entity.feature" } + } + } + } +} diff --git a/api/lexicons/org.hypercerts.entity.getFeature.json b/api/lexicons/org.hypercerts.entity.getFeature.json new file mode 100644 index 0000000..ce056ef --- /dev/null +++ b/api/lexicons/org.hypercerts.entity.getFeature.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.entity.getFeature", + "defs": { + "main": { + "type": "query", + "description": "Looks up one indexed feature by exact AT-URI with author hydration; authentication is not required.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Full feature record AT-URI using a DID authority." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "The URI is invalid or is not a feature record AT-URI." }, + { "name": "RecordNotFound", "description": "No indexed feature exists at this AT-URI." } + ] + }, + "output": { + "type": "object", + "required": ["feature"], + "properties": { + "feature": { "type": "ref", "ref": "org.hypercerts.entity.defs#featureView" } + } + } + } +} diff --git a/api/lexicons/org.hypercerts.entity.listFeatures.json b/api/lexicons/org.hypercerts.entity.listFeatures.json new file mode 100644 index 0000000..541057c --- /dev/null +++ b/api/lexicons/org.hypercerts.entity.listFeatures.json @@ -0,0 +1,74 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.entity.listFeatures", + "defs": { + "main": { + "type": "query", + "description": "Lists indexed features with filters and direction-bound pagination; authentication is not required.", + "parameters": { + "type": "params", + "properties": { + "authors": { + "type": "array", + "maxLength": 100, + "description": "Repository-owner DIDs; values combine with OR.", + "items": { "type": "string", "format": "did" } + }, + "hasOrganizationRecord": { + "type": "boolean", + "description": "Whether the author has an organization self record; false is independent of profile presence." + }, + "types": { + "type": "array", + "maxLength": 100, + "description": "Exact open-string feature types; values combine with OR.", + "items": { + "type": "string", + "maxLength": 64, + "description": "Exact case-sensitive feature type." + } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc", + "description": "Direction for sorting by createdAt and URI." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Maximum number of features in the page." + }, + "cursor": { + "type": "string", + "maxLength": 32768, + "description": "Opaque cursor from the previous page, bound to sortDirection." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "A filter, page bound, cursor, repeated scalar, or query parameter is invalid." } + ] + }, + "output": { + "type": "object", + "required": ["features"], + "properties": { + "features": { + "type": "array", + "items": { "type": "ref", "ref": "org.hypercerts.entity.defs#featureView" } + }, + "cursor": { + "type": "string", + "description": "Opaque cursor for the next page; omitted when there is no next page." + } + } + } + } +} diff --git a/api/lua/endpoints/getFeature.lua b/api/lua/endpoints/getFeature.lua new file mode 100644 index 0000000..8ce1987 --- /dev/null +++ b/api/lua/endpoints/getFeature.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 FEATURE_PROJECTION_PROFILE = "app.certified.actor.profile" +local FEATURE_PROJECTION_ORGANIZATION = "app.certified.actor.organization" +local FEATURE_PROJECTION_NULL = json.decode("null") + +local function feature_projection_query(sql, values) + if db.backend() ~= "postgres" then + error("CollectionQueryFailed: collection API requires PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("CollectionQueryFailed: collection lookup failed", 0) + end + return result +end + +local function feature_projection_load_actor_records(collection, dids) + if #dids == 0 then return {} end + local values, placeholders = { collection }, {} + for _, did in ipairs(dids) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local rows = feature_projection_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(placeholders, ", ") .. ")", + values) + local by_did = {} + for _, row in ipairs(rows) do by_did[row.did] = row end + return by_did +end + +local function feature_projection_record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and FEATURE_PROJECTION_NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function feature_projection_view(row) + return { + ["$type"] = "org.hypercerts.collection.listCollectionItems#featureView", + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and FEATURE_PROJECTION_NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = json.decode(row.record), + } +end + +local function feature_projection_hydrate(views) + if #views == 0 then return end + local dids, seen = {}, {} + for _, view in ipairs(views) do + if not seen[view.did] then + seen[view.did] = true + dids[#dids + 1] = view.did + end + end + local profiles = feature_projection_load_actor_records(FEATURE_PROJECTION_PROFILE, dids) + local organizations = feature_projection_load_actor_records(FEATURE_PROJECTION_ORGANIZATION, dids) + for _, view in ipairs(views) do + view.author.profile = profiles[view.did] and feature_projection_record_view(profiles[view.did]) or FEATURE_PROJECTION_NULL + view.author.organization = organizations[view.did] and feature_projection_record_view(organizations[view.did]) or FEATURE_PROJECTION_NULL + end +end + +local FEATURE_COLLECTION = "org.hypercerts.entity.feature" +local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +local function feature_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local escape_start = value:find("%", position, true) + if not escape_start then return true end + local escape = value:sub(escape_start + 1, escape_start + 2) + if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end + position = escape_start + 3 + end +end + +local function feature_valid_uri(value) + if type(value) ~= "string" or #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + if not valid or collection ~= FEATURE_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return feature_valid_did(authority) +end + +local function feature_lookup(uri) + if db.backend() ~= "postgres" then + error("FeatureQueryFailed: feature API requires PostgreSQL", 0) + end + local ok, rows = pcall(db.raw, + "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", + { FEATURE_COLLECTION, uri }) + if not ok or type(rows) ~= "table" then + error("FeatureQueryFailed: feature lookup failed", 0) + end + local views = {} + for _, row in ipairs(rows) do + local view = feature_projection_view(row) + view["$type"] = FEATURE_VIEW_TYPE + views[#views + 1] = view + end + feature_projection_hydrate(views) + return views +end + +local function get_feature() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not uri or not feature_valid_uri(uri) then + invalid("uri must be a full org.hypercerts.entity.feature AT-URI with a DID authority") + end + local views = feature_lookup(uri) + if #views == 0 then error("RecordNotFound: feature record is not indexed", 0) end + return { feature = views[1] } +end + +function handle() + return get_feature() +end diff --git a/api/lua/endpoints/listFeatures.lua b/api/lua/endpoints/listFeatures.lua new file mode 100644 index 0000000..2902d0b --- /dev/null +++ b/api/lua/endpoints/listFeatures.lua @@ -0,0 +1,364 @@ +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter") end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection +end + +local function 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 FEATURE_PROJECTION_PROFILE = "app.certified.actor.profile" +local FEATURE_PROJECTION_ORGANIZATION = "app.certified.actor.organization" +local FEATURE_PROJECTION_NULL = json.decode("null") + +local function feature_projection_query(sql, values) + if db.backend() ~= "postgres" then + error("CollectionQueryFailed: collection API requires PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("CollectionQueryFailed: collection lookup failed", 0) + end + return result +end + +local function feature_projection_load_actor_records(collection, dids) + if #dids == 0 then return {} end + local values, placeholders = { collection }, {} + for _, did in ipairs(dids) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local rows = feature_projection_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(placeholders, ", ") .. ")", + values) + local by_did = {} + for _, row in ipairs(rows) do by_did[row.did] = row end + return by_did +end + +local function feature_projection_record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and FEATURE_PROJECTION_NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function feature_projection_view(row) + return { + ["$type"] = "org.hypercerts.collection.listCollectionItems#featureView", + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and FEATURE_PROJECTION_NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = json.decode(row.record), + } +end + +local function feature_projection_hydrate(views) + if #views == 0 then return end + local dids, seen = {}, {} + for _, view in ipairs(views) do + if not seen[view.did] then + seen[view.did] = true + dids[#dids + 1] = view.did + end + end + local profiles = feature_projection_load_actor_records(FEATURE_PROJECTION_PROFILE, dids) + local organizations = feature_projection_load_actor_records(FEATURE_PROJECTION_ORGANIZATION, dids) + for _, view in ipairs(views) do + view.author.profile = profiles[view.did] and feature_projection_record_view(profiles[view.did]) or FEATURE_PROJECTION_NULL + view.author.organization = organizations[view.did] and feature_projection_record_view(organizations[view.did]) or FEATURE_PROJECTION_NULL + end +end + +local FEATURE_COLLECTION = "org.hypercerts.entity.feature" +local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +local function feature_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local escape_start = value:find("%", position, true) + if not escape_start then return true end + local escape = value:sub(escape_start + 1, escape_start + 2) + if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end + position = escape_start + 3 + end +end + +local function feature_valid_uri(value) + if type(value) ~= "string" or #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + if not valid or collection ~= FEATURE_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return feature_valid_did(authority) +end + +local function feature_array(params, key, validate, description, max_bytes) + 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 + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated string query parameters") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated string query parameters") end + 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 _, item in ipairs(values) do + if max_bytes and #item > max_bytes then + invalid(key .. " entries must be at most " .. max_bytes .. " UTF-8 bytes") + end + if validate and not validate(item) then invalid("each " .. key .. " value must be " .. description) end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function feature_add_in(where, values, column, binds) + if values == nil then return end + if #values == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, value in ipairs(values) do + binds[#binds + 1] = value + placeholders[#placeholders + 1] = "$" .. #binds + end + where[#where + 1] = column .. " IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function feature_add_types(where, values, binds) + if values == nil then return end + if #values == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, value in ipairs(values) do + binds[#binds + 1] = value + placeholders[#placeholders + 1] = "$" .. #binds + end + where[#where + 1] = "(jsonb_typeof(feature.record::jsonb -> 'type') = 'string' AND " .. + "feature.record::jsonb ->> 'type' IN (" .. table.concat(placeholders, ", ") .. "))" +end + +local function feature_parse_organization_filter(params) + local value = params.hasOrganizationRecord + if value == nil then return nil end + if type(value) == "boolean" then return value end + value = scalar(params, "hasOrganizationRecord") + if value == "true" then return true end + if value == "false" then return false end + invalid("hasOrganizationRecord must be true or false") +end + +local function feature_cursor_decode(token, direction) + if token == nil then return nil end + if type(token) ~= "string" or #token > 32768 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 feature_valid_uri(value.u) or value.t:sub(1, 4) == "0000" or not valid_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function feature_query_rows(sql, binds) + if db.backend() ~= "postgres" then error("FeatureQueryFailed: feature API requires PostgreSQL", 0) end + local ok, rows = pcall(db.raw, sql, binds) + if not ok or type(rows) ~= "table" then error("FeatureQueryFailed: feature lookup failed", 0) end + return rows +end + +local function feature_make_view(row) + local view = feature_projection_view(row) + view["$type"] = FEATURE_VIEW_TYPE + return view +end + +local function feature_hydrate(views) + local ok = pcall(feature_projection_hydrate, views) + if not ok then error("FeatureQueryFailed: author hydration failed", 0) end +end + +local function feature_list_rows(filters, limit, cursor, direction) + local where, binds = { "feature.collection = $1" }, { FEATURE_COLLECTION } + feature_add_in(where, filters.authors, "feature.did", binds) + feature_add_types(where, filters.types, binds) + if filters.hasOrganizationRecord ~= nil then + local predicate = filters.hasOrganizationRecord and "EXISTS" or "NOT EXISTS" + where[#where + 1] = predicate .. " (SELECT 1 FROM happyview_records AS organization " .. + "WHERE organization.collection = 'app.certified.actor.organization' " .. + "AND organization.rkey = 'self' AND organization.did = feature.did)" + end + if cursor then + binds[#binds + 1] = cursor.t + local time = "$" .. #binds + binds[#binds + 1] = cursor.u + local uri = "$" .. #binds + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, feature.uri) " .. operator .. " ((" .. time .. ")::timestamptz, " .. uri .. ")" + end + + binds[#binds + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local created_at = "feature.record::jsonb ->> 'createdAt'" + local zoned_datetime = "^[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])$" + local sort_key = "CASE WHEN jsonb_typeof(feature.record::jsonb -> 'createdAt') = 'string' AND " .. + created_at .. " ~ '" .. zoned_datetime .. "' AND " .. created_at .. " !~ '-00:00$' AND " .. + "pg_input_is_valid(" .. created_at .. ", 'timestamptz') THEN (" .. created_at .. ")::timestamptz " .. + "ELSE COALESCE(feature.indexed_at::timestamptz, feature.created_at::timestamptz) END" + local sql = "SELECT feature.uri, feature.did, feature.cid, feature.indexed_at::text AS indexed_at, " .. + "feature.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 feature CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", feature.uri " .. ordering .. " LIMIT $" .. #binds + return feature_query_rows(sql, binds) +end + +local function list_features() + keys_only(params, { + authors = true, + hasOrganizationRecord = true, + types = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local authors = feature_array(params, "authors", feature_valid_did, "valid DIDs") + local types = feature_array(params, "types", nil, nil, 64) + local has_organization_record = feature_parse_organization_filter(params) + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor_value = params.cursor + if cursor_value ~= nil and type(cursor_value) ~= "string" then invalid("cursor must occur once as a string") end + local cursor = feature_cursor_decode(cursor_value, direction) + local rows = feature_list_rows({ + authors = authors, + hasOrganizationRecord = has_organization_record, + types = types, + }, limit, cursor, direction) + + local more = #rows > limit + if more then rows[#rows] = nil end + local views = {} + for _, row in ipairs(rows) do views[#views + 1] = feature_make_view(row) end + feature_hydrate(views) + + local response = { features = toarray(views) } + if more then + local last = rows[#rows] + response.cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return response +end + +function handle() + return list_features() +end diff --git a/api/lua/src/getFeature.lua b/api/lua/src/getFeature.lua new file mode 100644 index 0000000..89623e0 --- /dev/null +++ b/api/lua/src/getFeature.lua @@ -0,0 +1,58 @@ +local FEATURE_COLLECTION = "org.hypercerts.entity.feature" +local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +local function feature_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local escape_start = value:find("%", position, true) + if not escape_start then return true end + local escape = value:sub(escape_start + 1, escape_start + 2) + if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end + position = escape_start + 3 + end +end + +local function feature_valid_uri(value) + if type(value) ~= "string" or #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + if not valid or collection ~= FEATURE_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return feature_valid_did(authority) +end + +local function feature_lookup(uri) + if db.backend() ~= "postgres" then + error("FeatureQueryFailed: feature API requires PostgreSQL", 0) + end + local ok, rows = pcall(db.raw, + "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", + { FEATURE_COLLECTION, uri }) + if not ok or type(rows) ~= "table" then + error("FeatureQueryFailed: feature lookup failed", 0) + end + local views = {} + for _, row in ipairs(rows) do + local view = feature_projection_view(row) + view["$type"] = FEATURE_VIEW_TYPE + views[#views + 1] = view + end + feature_projection_hydrate(views) + return views +end + +local function get_feature() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not uri or not feature_valid_uri(uri) then + invalid("uri must be a full org.hypercerts.entity.feature AT-URI with a DID authority") + end + local views = feature_lookup(uri) + if #views == 0 then error("RecordNotFound: feature record is not indexed", 0) end + return { feature = views[1] } +end + +function handle() + return get_feature() +end diff --git a/api/lua/src/listFeatures.lua b/api/lua/src/listFeatures.lua new file mode 100644 index 0000000..1576a2a --- /dev/null +++ b/api/lua/src/listFeatures.lua @@ -0,0 +1,214 @@ +local FEATURE_COLLECTION = "org.hypercerts.entity.feature" +local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +local function feature_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local escape_start = value:find("%", position, true) + if not escape_start then return true end + local escape = value:sub(escape_start + 1, escape_start + 2) + if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end + position = escape_start + 3 + end +end + +local function feature_valid_uri(value) + if type(value) ~= "string" or #value > 8192 then return false end + local valid, collection = valid_record_uri(value) + if not valid or collection ~= FEATURE_COLLECTION then return false end + local authority = value:match("^at://([^/]+)/") + return feature_valid_did(authority) +end + +local function feature_array(params, key, validate, description, max_bytes) + 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 + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated string query parameters") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated string query parameters") end + 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 _, item in ipairs(values) do + if max_bytes and #item > max_bytes then + invalid(key .. " entries must be at most " .. max_bytes .. " UTF-8 bytes") + end + if validate and not validate(item) then invalid("each " .. key .. " value must be " .. description) end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function feature_add_in(where, values, column, binds) + if values == nil then return end + if #values == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, value in ipairs(values) do + binds[#binds + 1] = value + placeholders[#placeholders + 1] = "$" .. #binds + end + where[#where + 1] = column .. " IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function feature_add_types(where, values, binds) + if values == nil then return end + if #values == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, value in ipairs(values) do + binds[#binds + 1] = value + placeholders[#placeholders + 1] = "$" .. #binds + end + where[#where + 1] = "(jsonb_typeof(feature.record::jsonb -> 'type') = 'string' AND " .. + "feature.record::jsonb ->> 'type' IN (" .. table.concat(placeholders, ", ") .. "))" +end + +local function feature_parse_organization_filter(params) + local value = params.hasOrganizationRecord + if value == nil then return nil end + if type(value) == "boolean" then return value end + value = scalar(params, "hasOrganizationRecord") + if value == "true" then return true end + if value == "false" then return false end + invalid("hasOrganizationRecord must be true or false") +end + +local function feature_cursor_decode(token, direction) + if token == nil then return nil end + if type(token) ~= "string" or #token > 32768 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 feature_valid_uri(value.u) or value.t:sub(1, 4) == "0000" or not valid_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function feature_query_rows(sql, binds) + if db.backend() ~= "postgres" then error("FeatureQueryFailed: feature API requires PostgreSQL", 0) end + local ok, rows = pcall(db.raw, sql, binds) + if not ok or type(rows) ~= "table" then error("FeatureQueryFailed: feature lookup failed", 0) end + return rows +end + +local function feature_make_view(row) + local view = feature_projection_view(row) + view["$type"] = FEATURE_VIEW_TYPE + return view +end + +local function feature_hydrate(views) + local ok = pcall(feature_projection_hydrate, views) + if not ok then error("FeatureQueryFailed: author hydration failed", 0) end +end + +local function feature_list_rows(filters, limit, cursor, direction) + local where, binds = { "feature.collection = $1" }, { FEATURE_COLLECTION } + feature_add_in(where, filters.authors, "feature.did", binds) + feature_add_types(where, filters.types, binds) + if filters.hasOrganizationRecord ~= nil then + local predicate = filters.hasOrganizationRecord and "EXISTS" or "NOT EXISTS" + where[#where + 1] = predicate .. " (SELECT 1 FROM happyview_records AS organization " .. + "WHERE organization.collection = 'app.certified.actor.organization' " .. + "AND organization.rkey = 'self' AND organization.did = feature.did)" + end + if cursor then + binds[#binds + 1] = cursor.t + local time = "$" .. #binds + binds[#binds + 1] = cursor.u + local uri = "$" .. #binds + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, feature.uri) " .. operator .. " ((" .. time .. ")::timestamptz, " .. uri .. ")" + end + + binds[#binds + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local created_at = "feature.record::jsonb ->> 'createdAt'" + local zoned_datetime = "^[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])$" + local sort_key = "CASE WHEN jsonb_typeof(feature.record::jsonb -> 'createdAt') = 'string' AND " .. + created_at .. " ~ '" .. zoned_datetime .. "' AND " .. created_at .. " !~ '-00:00$' AND " .. + "pg_input_is_valid(" .. created_at .. ", 'timestamptz') THEN (" .. created_at .. ")::timestamptz " .. + "ELSE COALESCE(feature.indexed_at::timestamptz, feature.created_at::timestamptz) END" + local sql = "SELECT feature.uri, feature.did, feature.cid, feature.indexed_at::text AS indexed_at, " .. + "feature.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 feature CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", feature.uri " .. ordering .. " LIMIT $" .. #binds + return feature_query_rows(sql, binds) +end + +local function list_features() + keys_only(params, { + authors = true, + hasOrganizationRecord = true, + types = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local authors = feature_array(params, "authors", feature_valid_did, "valid DIDs") + local types = feature_array(params, "types", nil, nil, 64) + local has_organization_record = feature_parse_organization_filter(params) + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor_value = params.cursor + if cursor_value ~= nil and type(cursor_value) ~= "string" then invalid("cursor must occur once as a string") end + local cursor = feature_cursor_decode(cursor_value, direction) + local rows = feature_list_rows({ + authors = authors, + hasOrganizationRecord = has_organization_record, + types = types, + }, limit, cursor, direction) + + local more = #rows > limit + if more then rows[#rows] = nil end + local views = {} + for _, row in ipairs(rows) do views[#views + 1] = feature_make_view(row) end + feature_hydrate(views) + + local response = { features = toarray(views) } + if more then + local last = rows[#rows] + response.cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return response +end + +function handle() + return list_features() +end diff --git a/api/manifest.json b/api/manifest.json index 9436815..43ad518 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -1,5 +1,13 @@ { "name": "hypercerts-api-foundation", + "authentication": { "unresolved": false }, + "handlerStatus": { + "getFeature": "implemented", + "listFeatures": "implemented" + }, + "luaBuild": { + "endpointSources": ["lua/src/getFeature.lua", "lua/src/listFeatures.lua"] + }, "validationLexicons": [ { "id": "app.certified.location", "packagePath": "lexicons/app/certified/location.json" }, { "id": "org.hypercerts.api.defs", "path": "lexicons/org.hypercerts.api.defs.json" }, @@ -10,6 +18,9 @@ { "id": "org.hypercerts.context.evaluation", "packagePath": "lexicons/org/hypercerts/context/evaluation.json" }, { "id": "org.hypercerts.collection.getCollection", "path": "lexicons/org.hypercerts.collection.getCollection.json" }, { "id": "org.hypercerts.collection.listCollectionItems", "path": "lexicons/org.hypercerts.collection.listCollectionItems.json" }, + { "id": "org.hypercerts.entity.defs", "path": "lexicons/org.hypercerts.entity.defs.json" }, + { "id": "org.hypercerts.entity.getFeature", "path": "lexicons/org.hypercerts.entity.getFeature.json" }, + { "id": "org.hypercerts.entity.listFeatures", "path": "lexicons/org.hypercerts.entity.listFeatures.json" }, { "id": "org.hypercerts.context.attachment", "packagePath": "lexicons/org/hypercerts/context/attachment.json" }, { "id": "app.certified.badge.definition", "packagePath": "lexicons/app/certified/badge/definition.json" }, { "id": "org.hypercerts.funding.receipt", "packagePath": "lexicons/org/hypercerts/funding/receipt.json" }, @@ -51,5 +62,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/features/manifest.json"] } diff --git a/api/modules/features/manifest.json b/api/modules/features/manifest.json new file mode 100644 index 0000000..f8c6983 --- /dev/null +++ b/api/modules/features/manifest.json @@ -0,0 +1,52 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "org.hypercerts.entity.defs", + "path": "../../lexicons/org.hypercerts.entity.defs.json", + "config": { "backfill": false }, + "dependsOn": ["org.hypercerts.api.defs", "org.hypercerts.entity.feature"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.entity.getFeature", + "path": "../../lexicons/org.hypercerts.entity.getFeature.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.entity.feature" }, + "dependsOn": ["org.hypercerts.entity.feature", "org.hypercerts.entity.defs"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.entity.listFeatures", + "path": "../../lexicons/org.hypercerts.entity.listFeatures.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.entity.feature" }, + "dependsOn": ["org.hypercerts.entity.feature", "org.hypercerts.entity.defs"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.entity.getFeature", + "path": "../../lua/endpoints/getFeature.lua", + "sourcePath": "../../lua/src/getFeature.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/featureProjection.lua" + ], + "config": { "script_type": "lua", "description": "Feature lookup API handler" }, + "dependsOn": ["org.hypercerts.entity.getFeature"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.entity.listFeatures", + "path": "../../lua/endpoints/listFeatures.lua", + "sourcePath": "../../lua/src/listFeatures.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/featureProjection.lua" + ], + "config": { "script_type": "lua", "description": "Feature listing API handler" }, + "dependsOn": ["org.hypercerts.entity.listFeatures"] + } + ] +} diff --git a/api/tooling/feature.test.js b/api/tooling/feature.test.js new file mode 100644 index 0000000..6d58f14 --- /dev/null +++ b/api/tooling/feature.test.js @@ -0,0 +1,308 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const FEATURE = 'org.hypercerts.entity.feature'; +const PROFILE = 'app.certified.actor.profile'; +const ORGANIZATION = 'app.certified.actor.organization'; +const FEATURE_DID = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'; +const FEATURE_URI = `at://${FEATURE_DID}/${FEATURE}/forest-zone`; +const PROFILE_URI = `at://${FEATURE_DID}/${PROFILE}/self`; +const ORGANIZATION_URI = `at://${FEATURE_DID}/${ORGANIZATION}/self`; +const FEATURE_RECORD = { + $type: FEATURE, + type: 'zone', + title: 'Wang Chhu floodplain', + createdAt: '2025-01-02T03:04:05Z', + locations: [{ uri: 'at://did:plc:bbbbbbbbbbbbbbbbbbbbbbbb/app.certified.location/location-one', cid: 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }], + tags: [{ uri: 'at://did:plc:bbbbbbbbbbbbbbbbbbbbbbbb/org.hypercerts.vocab.tag/wetland', cid: 'bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab' }], + sameAs: ['https://example.org/features/wang-chhu'], +}; + +function lua(value) { + if (value === null) return 'nil'; + 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(lua).join(',')}}`; + if (value && typeof value === 'object') { + return `{${Object.entries(value).map(([key, item]) => `[${JSON.stringify(key)}]=${lua(item)}`).join(',')}}`; + } + throw new TypeError(`Cannot encode ${typeof value} as a Lua fixture`); +} + +async function runGetFeature({ params = { uri: FEATURE_URI }, queryResults = null, expectedError = null, expectedCalls = 0, assertions = '' } = {}) { + const shared = await Promise.all([ + 'lua/shared/query.lua', + 'lua/shared/recordIdentifier.lua', + 'lua/shared/recordView.lua', + 'lua/shared/featureProjection.lua', + ].map((relative) => readFile(new URL(`../${relative}`, import.meta.url), 'utf8'))); + const endpoint = await readFile(new URL('../lua/endpoints/getFeature.lua', import.meta.url), 'utf8'); + const databaseRows = queryResults ?? [ + [{ uri: FEATURE_URI, did: FEATURE_DID, cid: 'bafyreicccccccccccccccccccccccccccccccccccccccccccccccccccc', indexed_at: null, record: 'feature-record' }], + [{ uri: PROFILE_URI, did: FEATURE_DID, cid: 'bafyreidddddddddddddddddddddddddddddddddddddddddddddddddddd', indexed_at: '2025-01-03T00:00:00Z', record: 'profile-record' }], + [{ uri: ORGANIZATION_URI, did: FEATURE_DID, cid: 'bafyreieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', indexed_at: '2025-01-04T00:00:00Z', record: 'organization-record' }], + ]; + const records = { + 'feature-record': FEATURE_RECORD, + 'profile-record': { $type: PROFILE, displayName: 'Feature author', createdAt: '2025-01-03T00:00:00Z' }, + 'organization-record': { $type: ORGANIZATION, organizationType: ['community'], createdAt: '2025-01-04T00:00:00Z' }, + }; + const source = ` +local NULL = {} +local RESULTS = ${lua(databaseRows)} +local RECORDS = ${lua(records)} +local calls = {} +json = { decode = function(value) if value == 'null' then return NULL end return RECORDS[value] end } +params = ${lua(params)} +toarray = function(value) return value end +db = { + backend = function() return 'postgres' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + return RESULTS[#calls] or {} + end, +} +${shared.join('\n\n')} +${endpoint} +local ok, result = pcall(handle) +if ${lua(expectedError)} ~= nil then + assert(not ok, 'expected request failure') + assert(tostring(result):find(${lua(expectedError)}, 1, true), tostring(result)) + assert(#calls == ${expectedCalls}, 'unexpected query count') +else + assert(ok, tostring(result)) + ${assertions} +end +`; + return spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); +} + +async function runListFeatures(params, queryResults, records = { 'feature-record': FEATURE_RECORD }, assertions = '', expectedError = null, expectedCalls = 0) { + const shared = await Promise.all([ + 'lua/shared/query.lua', + 'lua/shared/recordIdentifier.lua', + 'lua/shared/listQuery.lua', + 'lua/shared/recordView.lua', + 'lua/shared/featureProjection.lua', + ].map((relative) => readFile(new URL(`../${relative}`, import.meta.url), 'utf8'))); + const endpoint = await readFile(new URL('../lua/endpoints/listFeatures.lua', import.meta.url), 'utf8'); + const source = ` +local NULL = {} +local RESULTS = ${lua(queryResults)} +local RECORDS = ${lua(records)} +local calls = {} +json = { + decode = function(value) + if value == 'null' then return NULL end + if RECORDS[value] ~= nil then return RECORDS[value] end + local version = value:match('"v"%s*:%s*(%d+)') + local direction = value:match('"d"%s*:%s*"([^"]+)"') + local timestamp = value:match('"t"%s*:%s*"([^"]+)"') + local uri = value:match('"u"%s*:%s*"([^"]+)"') + if version then return { v = tonumber(version), d = direction, t = timestamp, u = uri } end + error('invalid JSON fixture') + end, + encode = function(value) + return '{"d":"' .. value.d .. '","t":"' .. value.t .. '","u":"' .. value.u .. '","v":' .. value.v .. '}' + end, +} +params = ${lua(params)} +toarray = function(value) return value end +db = { + backend = function() return 'postgres' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + return RESULTS[#calls] or {} + end, +} +${shared.join('\n\n')} +${endpoint} +local ok, result = pcall(handle) +if ${lua(expectedError)} ~= nil then + assert(not ok, 'expected request failure') + assert(tostring(result):find(${lua(expectedError)}, 1, true), tostring(result)) + assert(#calls == ${expectedCalls}, 'unexpected query count') +else + assert(ok, tostring(result)) + ${assertions} +end +`; + return spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); +} + +test('getFeature returns the exact indexed feature with nullable metadata and hydrated author only', async () => { + const result = await runGetFeature({ assertions: ` +assert(result.feature['$type'] == 'org.hypercerts.entity.defs#featureView') +assert(result.feature.uri == '${FEATURE_URI}' and result.feature.did == '${FEATURE_DID}') +assert(result.feature.cid == 'bafyreicccccccccccccccccccccccccccccccccccccccccccccccccccc') +assert(result.feature.indexedAt == NULL, 'missing indexedAt must be JSON null') +assert(result.feature.author.did == '${FEATURE_DID}') +assert(result.feature.author.profile.uri == '${PROFILE_URI}') +assert(result.feature.author.profile.record.displayName == 'Feature author') +assert(result.feature.author.organization.uri == '${ORGANIZATION_URI}') +assert(result.feature.author.organization.record.organizationType[1] == 'community') +assert(result.feature.record.title == 'Wang Chhu floodplain') +assert(result.feature.record.locations[1].uri == '${FEATURE_RECORD.locations[0].uri}') +assert(result.feature.record.tags[1].uri == '${FEATURE_RECORD.tags[0].uri}') +assert(result.feature.record.sameAs[1] == '${FEATURE_RECORD.sameAs[0]}') +assert(result.feature.location == nil and result.feature.tags == nil and result.feature.sameAs == nil, 'feature references must remain unexpanded') +assert(#calls == 3, 'lookup and author hydration should use three queries') +assert(calls[1].sql:find('WHERE collection = $1 AND uri = $2 LIMIT 1', 1, true)) +assert(calls[1].values[1] == '${FEATURE}' and calls[1].values[2] == '${FEATURE_URI}') +` }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('getFeature distinguishes invalid feature URIs from unindexed records', async () => { + for (const uri of [ + 'at://alice.example/org.hypercerts.entity.feature/forest-zone', + 'at://did:web:example.org%GG/org.hypercerts.entity.feature/forest-zone', + `at://${FEATURE_DID}/app.certified.location/location-one`, + ]) { + const invalid = await runGetFeature({ params: { uri }, queryResults: [], expectedError: 'InvalidRequest:', expectedCalls: 0 }); + assert.equal(invalid.status, 0, `${invalid.stderr}${invalid.stdout}`); + } + const missing = await runGetFeature({ params: { uri: FEATURE_URI }, queryResults: [[]], expectedError: 'RecordNotFound:', expectedCalls: 1 }); + assert.equal(missing.status, 0, `${missing.stderr}${missing.stdout}`); +}); + +test('listFeatures combines exact array filters with organization-record absence and stable default paging', async () => { + const row = { + uri: FEATURE_URI, did: FEATURE_DID, + cid: 'bafyreicccccccccccccccccccccccccccccccccccccccccccccccccccc', + indexed_at: '2025-01-02T03:04:05Z', record: 'feature-record', + }; + const result = await runListFeatures({ + authors: [FEATURE_DID, FEATURE_DID], + hasOrganizationRecord: 'false', + types: ['zone', 'future-kind', 'zone'], + }, [[row], [], []], undefined, ` +assert(#result.features == 1 and result.cursor == nil) +local feature = result.features[1] +assert(feature['$type'] == 'org.hypercerts.entity.defs#featureView') +assert(feature.uri == '${FEATURE_URI}' and feature.record.title == 'Wang Chhu floodplain') +assert(feature.author.profile == NULL and feature.author.organization == NULL, 'organization filtering must not hide profile-less authors') +local sql = calls[1].sql +assert(sql:find('feature.did IN ($2)', 1, true), 'authors use an OR filter with one duplicate removed') +assert(sql:find("feature.record::jsonb ->> 'type' IN ($3, $4)", 1, true), 'types use exact OR matching') +assert(sql:find("NOT EXISTS (SELECT 1 FROM happyview_records AS organization", 1, true), 'false requires organization-record absence') +assert(sql:find("organization.collection = 'app.certified.actor.organization'", 1, true)) +assert(sql:find("organization.rkey = 'self'", 1, true) and sql:find('organization.did = feature.did', 1, true)) +assert(not sql:find('app.certified.actor.profile', 1, true), 'organization filtering must not depend on profile presence') +assert(sql:find('ORDER BY sorted.sort_at DESC, feature.uri DESC', 1, true), 'default order is descending by createdAt then URI') +assert(calls[1].values[1] == '${FEATURE}' and calls[1].values[2] == '${FEATURE_DID}') +assert(calls[1].values[3] == 'zone' and calls[1].values[4] == 'future-kind', 'unknown open feature types remain exact filters') +assert(calls[1].values[5] == 26, 'the default page size is 25 plus one lookahead row') +`); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('listFeatures returns a createdAt-and-URI cursor and hydrates only the returned page', async () => { + const lookaheadDid = 'did:web:lookahead.example'; + const lookaheadUri = `at://${lookaheadDid}/${FEATURE}/later-zone`; + const pageRow = { + uri: FEATURE_URI, did: FEATURE_DID, + cid: 'bafyreicccccccccccccccccccccccccccccccccccccccccccccccccccc', + indexed_at: '2025-01-05T00:00:00Z', sort_timestamp: '2025-01-02T03:04:05.123456Z', record: 'page-feature', + }; + const lookaheadRow = { + uri: lookaheadUri, did: lookaheadDid, + cid: 'bafyreidddddddddddddddddddddddddddddddddddddddddddddddddddd', + indexed_at: '2025-01-06T00:00:00Z', sort_timestamp: '2025-01-03T00:00:00.000000Z', record: 'lookahead-feature', + }; + const profileRow = { + uri: PROFILE_URI, did: FEATURE_DID, + cid: 'bafyreieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + indexed_at: '2025-01-07T00:00:00Z', record: 'page-profile', + }; + const result = await runListFeatures({ sortDirection: 'asc', limit: '1' }, + [[pageRow, lookaheadRow], [profileRow], []], { + 'page-feature': FEATURE_RECORD, + 'lookahead-feature': FEATURE_RECORD, + 'page-profile': { $type: PROFILE, displayName: 'Page author', createdAt: '2025-01-07T00:00:00Z' }, + }, ` +assert(#result.features == 1 and result.features[1].uri == '${FEATURE_URI}') +assert(result.features[1].author.profile.record.displayName == 'Page author') +assert(type(result.cursor) == 'string', 'a lookahead row must produce a cursor') +local cursorJson = result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) +local cursor = json.decode(cursorJson) +assert(cursor.v == 1 and cursor.d == 'asc') +assert(cursor.t == '2025-01-02T03:04:05.123456Z' and cursor.u == '${FEATURE_URI}') +assert(calls[1].sql:find('ORDER BY sorted.sort_at ASC, feature.uri ASC', 1, true)) +assert(calls[1].values[1] == '${FEATURE}' and calls[1].values[2] == 2) +assert(calls[2].values[1] == '${PROFILE}' and calls[2].values[2] == '${FEATURE_DID}') +assert(#calls[2].values == 2, 'the lookahead author must not be hydrated') +assert(#calls == 3) +`); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + + const incomingCursor = Buffer.from(JSON.stringify({ + v: 1, d: 'asc', t: '2025-01-02T03:04:05.123456Z', u: FEATURE_URI, + })).toString('hex'); + const nextUri = `at://${FEATURE_DID}/${FEATURE}/next-zone`; + const nextPage = await runListFeatures({ sortDirection: 'asc', limit: '1', cursor: incomingCursor }, [[{ + uri: nextUri, did: FEATURE_DID, + cid: 'bafyreiffffffffffffffffffffffffffffffffffffffffffffffffffff', + indexed_at: '2025-01-08T00:00:00Z', sort_timestamp: '2025-01-04T00:00:00.000000Z', record: 'feature-record', + }], [], []], undefined, ` +assert(#result.features == 1 and result.features[1].uri == '${nextUri}') +assert(result.cursor == nil, 'the final page must omit its cursor') +assert(calls[1].sql:find('(sorted.sort_at, feature.uri) > (($2)::timestamptz, $3)', 1, true)) +assert(calls[1].values[2] == '2025-01-02T03:04:05.123456Z') +assert(calls[1].values[3] == '${FEATURE_URI}' and calls[1].values[4] == 2) +`); + assert.equal(nextPage.status, 0, `${nextPage.stderr}${nextPage.stdout}`); +}); + +test('listFeatures applies the positive organization-record filter without requiring a profile', async () => { + const result = await runListFeatures({ hasOrganizationRecord: 'true' }, [[]], undefined, ` +assert(#result.features == 0 and result.cursor == nil and #calls == 1) +assert(calls[1].sql:find('EXISTS (SELECT 1 FROM happyview_records AS organization', 1, true)) +assert(calls[1].sql:find("organization.rkey = 'self'", 1, true)) +assert(calls[1].sql:find('organization.did = feature.did', 1, true)) +assert(not calls[1].sql:find('app.certified.actor.profile', 1, true)) +`); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('listFeatures rejects unknown, malformed, repeated, and out-of-range inputs before querying', async () => { + const wrongDirectionCursor = Buffer.from(JSON.stringify({ + v: 1, d: 'asc', t: '2025-01-02T03:04:05Z', u: FEATURE_URI, + })).toString('hex'); + const malformedAuthorityCursor = Buffer.from(JSON.stringify({ + v: 1, d: 'desc', t: '2025-01-02T03:04:05Z', + u: 'at://did:web:example.org%GG/org.hypercerts.entity.feature/forest-zone', + })).toString('hex'); + const yearZeroCursor = Buffer.from(JSON.stringify({ + v: 1, d: 'desc', t: '0000-01-01T00:00:00Z', u: FEATURE_URI, + })).toString('hex'); + const invalidRequests = [ + { unknown: 'value' }, + { authors: ['alice.example'] }, + { authors: ['did:plc:aaaaaaaaaaaaaaaaaaaaaaaa:'] }, + { authors: ['did:web:example.org%GG'] }, + { authors: [42] }, + { authors: Array(101).fill(FEATURE_DID) }, + { types: ['t'.repeat(65)] }, + { types: [42] }, + { hasOrganizationRecord: 'sometimes' }, + { hasOrganizationRecord: ['true', 'false'] }, + { limit: '0' }, + { limit: '101' }, + { limit: ['1', '2'] }, + { sortDirection: 'sideways' }, + { cursor: 'not-a-cursor!' }, + { cursor: 123 }, + { sortDirection: 'desc', cursor: malformedAuthorityCursor }, + { sortDirection: 'desc', cursor: yearZeroCursor }, + { sortDirection: 'desc', cursor: wrongDirectionCursor }, + ]; + for (const params of invalidRequests) { + const result = await runListFeatures(params, [], undefined, '', 'InvalidRequest:', 0); + assert.equal(result.status, 0, `${JSON.stringify(params)}\n${result.stderr}${result.stdout}`); + } +}); From f6564951b732597329c329f5d85c5dab928b53f0 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 23:51:01 +0600 Subject: [PATCH 2/4] api-tests: isolate funding publisher fixtures --- CONTRIBUTING.md | 3 +- .../http/fixtures/funding-receipts.fixture.js | 2 +- .../http/funding-getReceipt.http.test.js | 8 +-- .../http/funding-listReceipts.http.test.js | 2 +- api/tests/unit/fixtures/fixtures.test.js | 50 +++++++++++++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d404167..c7b6118 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ pnpm build ## HTTP runtime tests -`pnpm test:http` runs the suites in `api/tests/http` against the funding and badge-definition XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. +`pnpm test:http` runs the suites in `api/tests/http` against the funding, badge-definition, and feature-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. +- 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. - Badge fixtures with CBOR-derived record CIDs. For the pinned HappyView release, ordinary Lua `error()` exceptions return HTTP 500 JSON with `error: "script_error"` and `errorType: "runtime"`; the error name appears in `message`. Negative HTTP tests assert this observed behavior. It is not a statement of the ideal public HTTP status contract, and does not guarantee 4xx mapping for `RecordNotFound` or `InvalidRequest`. diff --git a/api/tests/http/fixtures/funding-receipts.fixture.js b/api/tests/http/fixtures/funding-receipts.fixture.js index 25081f0..df6b8d9 100644 --- a/api/tests/http/fixtures/funding-receipts.fixture.js +++ b/api/tests/http/fixtures/funding-receipts.fixture.js @@ -3,7 +3,7 @@ import * as CID from '@atcute/cid'; import { locationRecords } from '../../fixtures/records.js'; const collection = 'org.hypercerts.funding.receipt'; -const publisher = 'did:plc:abcdefghijklmnopqrstuvwx'; +const publisher = 'did:web:funding-http-publisher.invalid'; const otherPublisher = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; const sender = 'did:plc:cccccccccccccccccccccccc'; const otherSender = 'did:plc:dddddddddddddddddddddddd'; diff --git a/api/tests/http/funding-getReceipt.http.test.js b/api/tests/http/funding-getReceipt.http.test.js index 3a7ff77..0f699ab 100644 --- a/api/tests/http/funding-getReceipt.http.test.js +++ b/api/tests/http/funding-getReceipt.http.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; -const receiptUri = 'at://did:plc:abcdefghijklmnopqrstuvwx/org.hypercerts.funding.receipt/receipt-a'; +const receiptUri = 'at://did:web:funding-http-publisher.invalid/org.hypercerts.funding.receipt/receipt-a'; const collection = 'org.hypercerts.funding.receipt'; async function getReceipt(uri) { @@ -27,7 +27,7 @@ test('getReceipt returns the indexed record and hydrates its publisher sidecars' const { receipt } = body; assert.equal(receipt.uri, receiptUri); - assert.equal(receipt.did, 'did:plc:abcdefghijklmnopqrstuvwx'); + assert.equal(receipt.did, 'did:web:funding-http-publisher.invalid'); assert.equal(receipt.indexedAt, '2025-02-01T00:00:00.000Z'); assert.equal(receipt.record.$type, collection); assert.equal(receipt.record.amount, '0012345678901234567890.00000001'); @@ -41,13 +41,13 @@ test('getReceipt returns the indexed record and hydrates its publisher sidecars' uri: 'at://did:plc:ffffffffffffffffffffffff/org.hypercerts.claim.activity/target', cid: 'bafyreidr6dv5qtbrfubummgssjqfow3eavqwnihvmectr63yalrnu4yqea', }); - assert.equal(receipt.author.did, 'did:plc:abcdefghijklmnopqrstuvwx'); + assert.equal(receipt.author.did, 'did:web:funding-http-publisher.invalid'); assert.equal(receipt.author.profile.record.displayName, 'Test Publisher'); assert.deepEqual(receipt.author.organization.record.organizationType, ['nonprofit']); }); test('getReceipt exposes RecordNotFound using the pinned HappyView runtime error response', async () => { - const missingUri = 'at://did:plc:abcdefghijklmnopqrstuvwx/org.hypercerts.funding.receipt/not-indexed'; + const missingUri = 'at://did:web:funding-http-publisher.invalid/org.hypercerts.funding.receipt/not-indexed'; const { response, body } = await getReceipt(missingUri); // The pinned HappyView release serializes ordinary Lua errors as runtime script_error responses. assert.equal(response.status, 500, JSON.stringify(body)); diff --git a/api/tests/http/funding-listReceipts.http.test.js b/api/tests/http/funding-listReceipts.http.test.js index 4259958..33c13b0 100644 --- a/api/tests/http/funding-listReceipts.http.test.js +++ b/api/tests/http/funding-listReceipts.http.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; const endpoint = 'org.hypercerts.funding.listReceipts'; -const publisherA = 'did:plc:abcdefghijklmnopqrstuvwx'; +const publisherA = 'did:web:funding-http-publisher.invalid'; const publisherB = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; const sender = 'did:plc:cccccccccccccccccccccccc'; const recipient = 'did:plc:eeeeeeeeeeeeeeeeeeeeeeee'; diff --git a/api/tests/unit/fixtures/fixtures.test.js b/api/tests/unit/fixtures/fixtures.test.js index b592fa9..7f4f21e 100644 --- a/api/tests/unit/fixtures/fixtures.test.js +++ b/api/tests/unit/fixtures/fixtures.test.js @@ -1,5 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { encode } from '@atcute/cbor'; import * as CID from '@atcute/cid'; import { isValidDid, isValidTid } from '@atproto/syntax'; @@ -12,6 +15,53 @@ import { actorFollowRecords, } from '../../fixtures/actor-follows.js'; +const httpFixtureRoot = fileURLToPath(new URL('../../http/fixtures/', import.meta.url)); +const sharedSeedRows = [ + ...locationRecords, ...profileRecords, ...organizationRecords, + ...activityFixtureRows, + ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, +]; + +async function findFixtureFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + const files = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...await findFixtureFiles(entryPath)); + else if (entry.isFile() && entry.name.endsWith('.fixture.js')) files.push(entryPath); + } + return files; +} + +function duplicateValues(values) { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return [...counts].filter(([, count]) => count > 1); +} + +test('shared and HTTP fixture seed rows have unique identities and CBOR-derived CIDs', async () => { + const fixtureFiles = await findFixtureFiles(httpFixtureRoot); + assert.ok(fixtureFiles.length > 0, 'expected HTTP fixture modules under tests/http/fixtures'); + const httpSeedRows = []; + for (const fixtureFile of fixtureFiles) { + const fixture = await import(pathToFileURL(fixtureFile).href); + assert.ok(Array.isArray(fixture.seedRows) && fixture.seedRows.length > 0, `${path.relative(httpFixtureRoot, fixtureFile)} must export nonempty seedRows`); + httpSeedRows.push(...fixture.seedRows); + } + + const rows = [...sharedSeedRows, ...httpSeedRows]; + assert.deepEqual(duplicateValues(rows.map(({ uri }) => uri)), [], 'fixture rows must have unique record URIs'); + assert.deepEqual( + duplicateValues(rows.map(({ did, collection, rkey }) => JSON.stringify([did, collection, rkey]))), + [], + 'fixture rows must have unique (DID, collection, rkey) identities', + ); + for (const row of rows) { + assert.equal(CID.toString(await CID.create(0x71, encode(row.record))), row.cid, `fixture ${row.uri} CID must match its CBOR record`); + } +}); + test('fixtures have consistent full AT-URIs, valid DID/TID/CID identifiers, types, and fixed timestamps', () => { const records = [ ...locationRecords, ...profileRecords, ...organizationRecords, From a4b78e7909c0d6ab44e884766cfd9b3ac3bb9bf0 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 19:09:23 +0600 Subject: [PATCH 3/4] features: consolidate feature and badge validation --- api/lua/endpoints/getBadgeDefinition.lua | 14 ++-- api/lua/endpoints/getFeature.lua | 66 +++++++++--------- api/lua/endpoints/listBadgeDefinitions.lua | 14 ++-- api/lua/endpoints/listFeatures.lua | 67 ++++++++++--------- api/lua/shared/badgeDefinitionValidation.lua | 6 ++ api/lua/shared/featureValidation.lua | 20 ++++++ api/lua/src/getBadgeDefinition.lua | 7 +- api/lua/src/getFeature.lua | 21 ------ api/lua/src/listBadgeDefinitions.lua | 7 +- api/lua/src/listFeatures.lua | 21 ------ api/modules/badge-definitions/manifest.json | 2 + api/modules/features/manifest.json | 5 ++ api/tests/unit/feature.test.js | 18 ++++- .../tooling/badge-definition.contract.test.js | 9 +++ 14 files changed, 145 insertions(+), 132 deletions(-) create mode 100644 api/lua/shared/badgeDefinitionValidation.lua create mode 100644 api/lua/shared/featureValidation.lua diff --git a/api/lua/endpoints/getBadgeDefinition.lua b/api/lua/endpoints/getBadgeDefinition.lua index 6e9b21f..f9c5bfe 100644 --- a/api/lua/endpoints/getBadgeDefinition.lua +++ b/api/lua/endpoints/getBadgeDefinition.lua @@ -39,6 +39,13 @@ local function valid_record_uri(value) return true, collection, authority end +local BADGE_DEFINITION_COLLECTION = "app.certified.badge.definition" + +local function valid_badge_definition_uri(value) + local valid, collection = valid_record_uri(value) + return valid and collection == BADGE_DEFINITION_COLLECTION +end + local NULL = json.decode("null") local function record_view(row) @@ -81,12 +88,7 @@ local function hydrate_actor_views(actors, run_query) end end -local COLLECTION = "app.certified.badge.definition" - -local function valid_badge_definition_uri(value) - local valid, collection = valid_record_uri(value) - return valid and collection == COLLECTION -end +local COLLECTION = BADGE_DEFINITION_COLLECTION local function query(sql, values) local ok, result = pcall(db.raw, sql, values) diff --git a/api/lua/endpoints/getFeature.lua b/api/lua/endpoints/getFeature.lua index 8ce1987..5e74d29 100644 --- a/api/lua/endpoints/getFeature.lua +++ b/api/lua/endpoints/getFeature.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,28 @@ 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 FEATURE_COLLECTION = "org.hypercerts.entity.feature" + +local function feature_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local escape_start = value:find("%", position, true) + if not escape_start then return true end + local escape = value:sub(escape_start + 1, escape_start + 2) + if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end + position = escape_start + 3 + end +end + +local function feature_valid_uri(value) + if type(value) ~= "string" or #value > 8192 then return false end + local valid, collection, authority = valid_record_uri(value) + if not valid or collection ~= FEATURE_COLLECTION then return false end + return feature_valid_did(authority) end local FEATURE_PROJECTION_PROFILE = "app.certified.actor.profile" @@ -107,29 +130,8 @@ local function feature_projection_hydrate(views) end end -local FEATURE_COLLECTION = "org.hypercerts.entity.feature" local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" -local function feature_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local escape_start = value:find("%", position, true) - if not escape_start then return true end - local escape = value:sub(escape_start + 1, escape_start + 2) - if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end - position = escape_start + 3 - end -end - -local function feature_valid_uri(value) - if type(value) ~= "string" or #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - if not valid or collection ~= FEATURE_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return feature_valid_did(authority) -end - local function feature_lookup(uri) if db.backend() ~= "postgres" then error("FeatureQueryFailed: feature API requires PostgreSQL", 0) diff --git a/api/lua/endpoints/listBadgeDefinitions.lua b/api/lua/endpoints/listBadgeDefinitions.lua index 0e61fcb..ea51d27 100644 --- a/api/lua/endpoints/listBadgeDefinitions.lua +++ b/api/lua/endpoints/listBadgeDefinitions.lua @@ -39,6 +39,13 @@ local function valid_record_uri(value) return true, collection, authority end +local BADGE_DEFINITION_COLLECTION = "app.certified.badge.definition" + +local function valid_badge_definition_uri(value) + local valid, collection = valid_record_uri(value) + return valid and collection == BADGE_DEFINITION_COLLECTION +end + local function valid_datetime(value) if type(value) ~= "string" then return false end local year, month, day, hour, minute, second, suffix = value:match( @@ -123,12 +130,7 @@ local function hydrate_actor_views(actors, run_query) end end -local COLLECTION = "app.certified.badge.definition" - -local function valid_badge_definition_uri(value) - local valid, collection = valid_record_uri(value) - return valid and collection == COLLECTION -end +local COLLECTION = BADGE_DEFINITION_COLLECTION local function query(sql, values) local ok, result = pcall(db.raw, sql, values) diff --git a/api/lua/endpoints/listFeatures.lua b/api/lua/endpoints/listFeatures.lua index 2902d0b..4044785 100644 --- a/api/lua/endpoints/listFeatures.lua +++ b/api/lua/endpoints/listFeatures.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,10 +36,11 @@ 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 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 @@ -78,6 +81,27 @@ local function cursor_encode(value) return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) end +local FEATURE_COLLECTION = "org.hypercerts.entity.feature" + +local function feature_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local escape_start = value:find("%", position, true) + if not escape_start then return true end + local escape = value:sub(escape_start + 1, escape_start + 2) + if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end + position = escape_start + 3 + end +end + +local function feature_valid_uri(value) + if type(value) ~= "string" or #value > 8192 then return false end + local valid, collection, authority = valid_record_uri(value) + if not valid or collection ~= FEATURE_COLLECTION then return false end + return feature_valid_did(authority) +end + local FEATURE_PROJECTION_PROFILE = "app.certified.actor.profile" local FEATURE_PROJECTION_ORGANIZATION = "app.certified.actor.organization" local FEATURE_PROJECTION_NULL = json.decode("null") @@ -148,29 +172,8 @@ local function feature_projection_hydrate(views) end end -local FEATURE_COLLECTION = "org.hypercerts.entity.feature" local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" -local function feature_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local escape_start = value:find("%", position, true) - if not escape_start then return true end - local escape = value:sub(escape_start + 1, escape_start + 2) - if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end - position = escape_start + 3 - end -end - -local function feature_valid_uri(value) - if type(value) ~= "string" or #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - if not valid or collection ~= FEATURE_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return feature_valid_did(authority) -end - local function feature_array(params, key, validate, description, max_bytes) local value = params[key] if value == nil then return nil end diff --git a/api/lua/shared/badgeDefinitionValidation.lua b/api/lua/shared/badgeDefinitionValidation.lua new file mode 100644 index 0000000..632ce31 --- /dev/null +++ b/api/lua/shared/badgeDefinitionValidation.lua @@ -0,0 +1,6 @@ +local BADGE_DEFINITION_COLLECTION = "app.certified.badge.definition" + +local function valid_badge_definition_uri(value) + local valid, collection = valid_record_uri(value) + return valid and collection == BADGE_DEFINITION_COLLECTION +end diff --git a/api/lua/shared/featureValidation.lua b/api/lua/shared/featureValidation.lua new file mode 100644 index 0000000..2228988 --- /dev/null +++ b/api/lua/shared/featureValidation.lua @@ -0,0 +1,20 @@ +local FEATURE_COLLECTION = "org.hypercerts.entity.feature" + +local function feature_valid_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local escape_start = value:find("%", position, true) + if not escape_start then return true end + local escape = value:sub(escape_start + 1, escape_start + 2) + if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end + position = escape_start + 3 + end +end + +local function feature_valid_uri(value) + if type(value) ~= "string" or #value > 8192 then return false end + local valid, collection, authority = valid_record_uri(value) + if not valid or collection ~= FEATURE_COLLECTION then return false end + return feature_valid_did(authority) +end diff --git a/api/lua/src/getBadgeDefinition.lua b/api/lua/src/getBadgeDefinition.lua index a6df8ed..afa81b6 100644 --- a/api/lua/src/getBadgeDefinition.lua +++ b/api/lua/src/getBadgeDefinition.lua @@ -1,9 +1,4 @@ -local COLLECTION = "app.certified.badge.definition" - -local function valid_badge_definition_uri(value) - local valid, collection = valid_record_uri(value) - return valid and collection == COLLECTION -end +local COLLECTION = BADGE_DEFINITION_COLLECTION local function query(sql, values) local ok, result = pcall(db.raw, sql, values) diff --git a/api/lua/src/getFeature.lua b/api/lua/src/getFeature.lua index 89623e0..058211a 100644 --- a/api/lua/src/getFeature.lua +++ b/api/lua/src/getFeature.lua @@ -1,26 +1,5 @@ -local FEATURE_COLLECTION = "org.hypercerts.entity.feature" local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" -local function feature_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local escape_start = value:find("%", position, true) - if not escape_start then return true end - local escape = value:sub(escape_start + 1, escape_start + 2) - if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end - position = escape_start + 3 - end -end - -local function feature_valid_uri(value) - if type(value) ~= "string" or #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - if not valid or collection ~= FEATURE_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return feature_valid_did(authority) -end - local function feature_lookup(uri) if db.backend() ~= "postgres" then error("FeatureQueryFailed: feature API requires PostgreSQL", 0) diff --git a/api/lua/src/listBadgeDefinitions.lua b/api/lua/src/listBadgeDefinitions.lua index 6ed9032..8888edb 100644 --- a/api/lua/src/listBadgeDefinitions.lua +++ b/api/lua/src/listBadgeDefinitions.lua @@ -1,9 +1,4 @@ -local COLLECTION = "app.certified.badge.definition" - -local function valid_badge_definition_uri(value) - local valid, collection = valid_record_uri(value) - return valid and collection == COLLECTION -end +local COLLECTION = BADGE_DEFINITION_COLLECTION local function query(sql, values) local ok, result = pcall(db.raw, sql, values) diff --git a/api/lua/src/listFeatures.lua b/api/lua/src/listFeatures.lua index 1576a2a..ff57985 100644 --- a/api/lua/src/listFeatures.lua +++ b/api/lua/src/listFeatures.lua @@ -1,26 +1,5 @@ -local FEATURE_COLLECTION = "org.hypercerts.entity.feature" local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" -local function feature_valid_did(value) - if not valid_did(value) then return false end - local position = 1 - while true do - local escape_start = value:find("%", position, true) - if not escape_start then return true end - local escape = value:sub(escape_start + 1, escape_start + 2) - if not escape:match("^[0-9a-fA-F][0-9a-fA-F]$") then return false end - position = escape_start + 3 - end -end - -local function feature_valid_uri(value) - if type(value) ~= "string" or #value > 8192 then return false end - local valid, collection = valid_record_uri(value) - if not valid or collection ~= FEATURE_COLLECTION then return false end - local authority = value:match("^at://([^/]+)/") - return feature_valid_did(authority) -end - local function feature_array(params, key, validate, description, max_bytes) local value = params[key] if value == nil then return nil end diff --git a/api/modules/badge-definitions/manifest.json b/api/modules/badge-definitions/manifest.json index c2e066c..e93b1a2 100644 --- a/api/modules/badge-definitions/manifest.json +++ b/api/modules/badge-definitions/manifest.json @@ -36,6 +36,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/badgeDefinitionValidation.lua", "../../lua/shared/recordView.lua", "../../lua/shared/actorView.lua" ], @@ -56,6 +57,7 @@ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/badgeDefinitionValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", "../../lua/shared/recordView.lua", diff --git a/api/modules/features/manifest.json b/api/modules/features/manifest.json index f8c6983..58ba1bc 100644 --- a/api/modules/features/manifest.json +++ b/api/modules/features/manifest.json @@ -27,8 +27,10 @@ "path": "../../lua/endpoints/getFeature.lua", "sourcePath": "../../lua/src/getFeature.lua", "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/featureValidation.lua", "../../lua/shared/featureProjection.lua" ], "config": { "script_type": "lua", "description": "Feature lookup API handler" }, @@ -40,9 +42,12 @@ "path": "../../lua/endpoints/listFeatures.lua", "sourcePath": "../../lua/src/listFeatures.lua", "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", + "../../lua/shared/featureValidation.lua", "../../lua/shared/featureProjection.lua" ], "config": { "script_type": "lua", "description": "Feature listing API handler" }, diff --git a/api/tests/unit/feature.test.js b/api/tests/unit/feature.test.js index e296ec5..8901189 100644 --- a/api/tests/unit/feature.test.js +++ b/api/tests/unit/feature.test.js @@ -35,12 +35,14 @@ function lua(value) { async function runGetFeature({ params = { uri: FEATURE_URI }, queryResults = null, expectedError = null, expectedCalls = 0, assertions = '' } = {}) { const shared = await Promise.all([ + 'lua/shared/didValidation.lua', 'lua/shared/query.lua', 'lua/shared/recordIdentifier.lua', + 'lua/shared/featureValidation.lua', 'lua/shared/recordView.lua', 'lua/shared/featureProjection.lua', ].map((relative) => readFile(new URL(`../../${relative}`, import.meta.url), 'utf8'))); - const endpoint = await readFile(new URL('../../lua/endpoints/getFeature.lua', import.meta.url), 'utf8'); + const endpoint = await readFile(new URL('../../lua/src/getFeature.lua', import.meta.url), 'utf8'); const databaseRows = queryResults ?? [ [{ uri: FEATURE_URI, did: FEATURE_DID, cid: 'bafyreicccccccccccccccccccccccccccccccccccccccccccccccccccc', indexed_at: null, record: 'feature-record' }], [{ uri: PROFILE_URI, did: FEATURE_DID, cid: 'bafyreidddddddddddddddddddddddddddddddddddddddddddddddddddd', indexed_at: '2025-01-03T00:00:00Z', record: 'profile-record' }], @@ -83,13 +85,16 @@ end async function runListFeatures(params, queryResults, records = { 'feature-record': FEATURE_RECORD }, assertions = '', expectedError = null, expectedCalls = 0) { const shared = await Promise.all([ + 'lua/shared/didValidation.lua', 'lua/shared/query.lua', 'lua/shared/recordIdentifier.lua', + 'lua/shared/listValidation.lua', 'lua/shared/listQuery.lua', + 'lua/shared/featureValidation.lua', 'lua/shared/recordView.lua', 'lua/shared/featureProjection.lua', ].map((relative) => readFile(new URL(`../../${relative}`, import.meta.url), 'utf8'))); - const endpoint = await readFile(new URL('../../lua/endpoints/listFeatures.lua', import.meta.url), 'utf8'); + const endpoint = await readFile(new URL('../../lua/src/listFeatures.lua', import.meta.url), 'utf8'); const source = ` local NULL = {} local RESULTS = ${lua(queryResults)} @@ -269,6 +274,15 @@ assert(not calls[1].sql:find('app.certified.actor.profile', 1, true)) assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); }); +test('listFeatures accepts well-formed percent escapes in DID identifiers', async () => { + const author = 'did:web:example.org%2Fteam'; + const result = await runListFeatures({ authors: [author] }, [[]], undefined, ` +assert(#result.features == 0 and result.cursor == nil and #calls == 1) +assert(calls[1].values[1] == '${FEATURE}' and calls[1].values[2] == '${author}') +`); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + test('listFeatures rejects unknown, malformed, repeated, and out-of-range inputs before querying', async () => { const wrongDirectionCursor = Buffer.from(JSON.stringify({ v: 1, d: 'asc', t: '2025-01-02T03:04:05Z', u: FEATURE_URI, diff --git a/api/tests/unit/tooling/badge-definition.contract.test.js b/api/tests/unit/tooling/badge-definition.contract.test.js index cebac2e..b0c203b 100644 --- a/api/tests/unit/tooling/badge-definition.contract.test.js +++ b/api/tests/unit/tooling/badge-definition.contract.test.js @@ -97,6 +97,15 @@ test('getBadgeDefinition reports RecordNotFound when the exact indexed URI is ab runLua({ endpoint: 'getBadgeDefinition', params: { uri }, expectError: 'RecordNotFound:', expectedCalls: 1 }); }); +test('getBadgeDefinition rejects invalid authorities and other collections before querying', () => { + for (const invalidUri of [ + 'at://alice.example/app.certified.badge.definition/3jzfcijpj2z2z', + 'at://did:plc:abcdefghijklmnopqrstuvwx/app.certified.location/3jzfcijpj2z2z', + ]) { + runLua({ endpoint: 'getBadgeDefinition', params: { uri: invalidUri }, expectError: 'InvalidRequest:', expectedCalls: 0 }); + } +}); + test('getBadgeDefinition emits explicit JSON null when indexed_at is SQL NULL', () => { const definition = definitionRow(author, 'unindexed', { title: 'Unindexed badge' }); delete definition.indexed_at; From 564110484c418088afc75e8e0841279b8d8b46dd Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 20:40:26 +0600 Subject: [PATCH 4/4] tests: isolate HTTP fixture identities and paging --- api/tests/http/features.http.test.js | 7 +++++- .../http/fixtures/graph-follows.fixture.js | 2 +- .../unit/fixtures/http-seed-rows.test.js | 23 +++++++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/api/tests/http/features.http.test.js b/api/tests/http/features.http.test.js index 9844975..34aee99 100644 --- a/api/tests/http/features.http.test.js +++ b/api/tests/http/features.http.test.js @@ -133,7 +133,12 @@ test('listFeatures paginates createdAt and URI ties in both directions without o let cursor; const collected = []; for (let offset = 0; offset < expected.length; offset += 2) { - const params = { sortDirection, limit: 2, ...(cursor === undefined ? {} : { cursor }) }; + const params = { + sortDirection, + limit: 2, + authors: [featureAuthor, profileOnlyAuthor, unhydratedAuthor], + ...(cursor === undefined ? {} : { cursor }), + }; const { response, body } = await request(listEndpoint, params); assert.equal(response.status, 200, JSON.stringify(body)); const page = expected.slice(offset, offset + 2); diff --git a/api/tests/http/fixtures/graph-follows.fixture.js b/api/tests/http/fixtures/graph-follows.fixture.js index b682050..a474730 100644 --- a/api/tests/http/fixtures/graph-follows.fixture.js +++ b/api/tests/http/fixtures/graph-follows.fixture.js @@ -14,7 +14,7 @@ export const graphDids = { secondPublisher: 'did:plc:vvvvvvvvvvvvvvvvvvvvvvvv', primarySubject: 'did:plc:wwwwwwwwwwwwwwwwwwwwwwww', secondSubject: 'did:plc:xxxxxxxxxxxxxxxxxxxxxxxx', - thirdSubject: 'did:plc:yyyyyyyyyyyyyyyyyyyyyyyy', + thirdSubject: 'did:plc:666666666666666666666666', entityAuthor: 'did:plc:rrrrrrrrrrrrrrrrrrrrrrrr', curator: 'did:plc:ssssssssssssssssssssssss', thirdFollower: 'did:plc:tttttttttttttttttttttttt', diff --git a/api/tests/unit/fixtures/http-seed-rows.test.js b/api/tests/unit/fixtures/http-seed-rows.test.js index 01444c2..2ead0c7 100644 --- a/api/tests/unit/fixtures/http-seed-rows.test.js +++ b/api/tests/unit/fixtures/http-seed-rows.test.js @@ -12,9 +12,15 @@ import { actorFollowProfileRecords, actorFollowRecords, } from '../../fixtures/actor-follows.js'; +import { graphDids } from '../../http/fixtures/graph-follows.fixture.js'; const apiRoot = fileURLToPath(new URL('../../../', import.meta.url)); const httpFixtureRoot = path.join(apiRoot, 'tests', 'http', 'fixtures'); +const sharedSeedRows = [ + ...locationRecords, ...profileRecords, ...organizationRecords, + ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, + ...activityFixtureRows, +]; async function findFixtureModules(directory) { const entries = await readdir(directory, { withFileTypes: true }); @@ -56,11 +62,7 @@ function duplicateDetails(previous, current) { test('shared and recursively discovered HTTP seed rows have unique identities and CBOR-derived CIDs', async () => { const rows = [ - ...[ - ...locationRecords, ...profileRecords, ...organizationRecords, - ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, - ...activityFixtureRows, - ].map((row) => ({ row, source: 'shared seed rows' })), + ...sharedSeedRows.map((row) => ({ row, source: 'shared seed rows' })), ...await loadHttpSeedRows(), ]; const byUri = new Map(); @@ -89,3 +91,14 @@ test('shared and recursively discovered HTTP seed rows have unique identities an 'shared and HTTP seed rows must not overwrite one another by canonical URI or repository identity', ); }); + +test('graph fixture third subject stays unhydrated across the complete seed set', async () => { + const rows = [ + ...sharedSeedRows, + ...(await loadHttpSeedRows()).map(({ row }) => row), + ]; + const actorSidecars = rows.filter(({ did, collection }) => did === graphDids.thirdSubject + && ['app.certified.actor.profile', 'app.certified.actor.organization'].includes(collection)); + + assert.deepEqual(actorSidecars.map(({ uri }) => uri), []); +});