diff --git a/.changeset/feature-query-endpoints.md b/.changeset/feature-query-endpoints.md new file mode 100644 index 0000000..d70bd0e --- /dev/null +++ b/.changeset/feature-query-endpoints.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': minor +--- + +Add `org.hypercerts.entity.getFeature` to retrieve an indexed feature by its exact record URI, with available author profile and organization details. Add `org.hypercerts.entity.listFeatures` to filter indexed features by author, type, and organization-record presence, and page results by creation time and URI. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 170fd2d..4cdb733 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ pnpm build ## HTTP runtime tests -`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, acknowledgement, collection, context attachment and evaluation, contributor-information, funding, location, profile, organization, work-scope-tag, vocabulary-tag, graph, and contribution query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. +`pnpm test:http` runs the suites in `api/tests/http` against the activity, badge-definition, badge-query, acknowledgement, collection, context attachment and evaluation, contributor-information, funding, location, profile, organization, feature, work-scope-tag, vocabulary-tag, graph, and contribution query XRPC endpoints installed from this checkout. These tests exercise real HTTP behavior against PostgreSQL, not only Lua handlers with a fake database. The local runner requires a local Docker Compose daemon, `psql`, and the pinned PostgreSQL and HappyView images already cached locally. Set `PSQL_PATH` to the absolute path of a trusted `psql` executable: @@ -33,6 +33,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Funding record retrieval, repeated filters, and pagination. - Badge-definition retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, `createdAt`/URI pagination ties, and named error responses. +- Feature-query coverage in `api/tests/http/features.http.test.js` exercises exact retrieval and hydrated or absent author sidecars, author/type and organization-presence filters, bidirectional `createdAt`/URI pagination ties, and named request errors. - Contributor-information retrieval by exact AT-URI and listing with repeated-author filters, cursor pagination, hydrated and missing author sidecars, and named runtime errors. - Work-scope-tag exact-URI retrieval, repeated-author filtering with an empty-result case, hydrated and null publisher sidecars, middle-position `indexedAt` fallback, tied pagination in both directions, and named error responses. - Acknowledgement exact retrieval and full-record preservation, hydrated and absent publisher sidecars, repeated author/subject filters, ascending and descending pagination across timestamp ties, and named errors. @@ -46,7 +47,7 @@ The HTTP gate fails if it discovers no suites, executes no `node:test` cases, or - Contribution exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. - Badge and contribution fixtures with CBOR-derived record CIDs; contribution DIDs are distinct from baseline fixture identities. - Location retrieval with nullable sidecars, repeated author/URI/location-type filters, unsupported-search errors, tied pagination in both directions, malformed/absent `createdAt` handling, and named errors. -- HTTP fixtures for activity, collection, funding, badge-definition, badge-query, acknowledgement, work-scope-tag, contribution, contributor-information, actor, context attachment and evaluation, graph, location, and vocabulary-tag records use CBOR-derived CIDs. +- HTTP fixtures for activity, collection, funding, badge-definition, badge-query, acknowledgement, feature, work-scope-tag, contribution, contributor-information, actor, context attachment and evaluation, graph, location, and vocabulary-tag records use CBOR-derived CIDs. For the pinned HappyView release, ordinary Lua `error()` exceptions return HTTP 500 JSON with `error: "script_error"` and `errorType: "runtime"`; the error name appears in `message`. Negative HTTP tests assert this observed behavior. It is not a statement of the ideal public HTTP status contract, and does not guarantee 4xx mapping for `RecordNotFound` or `InvalidRequest`. diff --git a/README.md b/README.md index a6cbeb3..76ab522 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hypercerts API workspace -This repository combines the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, and independently owned capability modules. This composed checkout includes public query modules for actor profiles and organizations, activity, badge definitions and queries, collections, context attachments and evaluations, actor and entity follows, recent follows, funding receipts, locations, work-scope tags, contribution records, contributor information, vocabulary tags, and acknowledgements. The `org.hypercerts.vocab.getVocabTag` and `org.hypercerts.vocab.listVocabTags` handlers are bundled and registered through `api/modules/vocab/manifest.json`. +This repository combines the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, and independently owned capability modules. This composed checkout includes public query modules for actor profiles and organizations, activity, badge definitions and queries, collections, context attachments and evaluations, actor and entity follows, recent follows, funding receipts, locations, features, work-scope tags, contribution records, contributor information, vocabulary tags, and acknowledgements. The `org.hypercerts.vocab.getVocabTag` and `org.hypercerts.vocab.listVocabTags` handlers are bundled and registered through `api/modules/vocab/manifest.json`. This checkout is one of the additive local sibling branches used to compose the API: @@ -33,6 +33,7 @@ pnpm build | Funding receipts | `org.hypercerts.funding.getReceipt`, `org.hypercerts.funding.listReceipts` | Record retrieval, repeated filters, stable pagination, and named runtime errors | | Badge definitions | `app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions` | Record/CID retrieval, publisher sidecars, filters, tied pagination, and named runtime errors | | Badge queries | `app.certified.badge.searchBadgeDefinitions`, `app.certified.badge.getBadgeAward`, `app.certified.badge.listBadgeAwards`, `app.certified.badge.getBadgeResponse`, `app.certified.badge.listBadgeResponses` | Baseline-aware definition search, exact-version award/response lookups, recipient status, raw response history, filters, and cursor pagination | +| Features | `org.hypercerts.entity.getFeature`, `org.hypercerts.entity.listFeatures` | Exact retrieval, author sidecars, author/type and organization-presence filters, tied pagination, and named errors | | Contributor information | `org.hypercerts.claim.getContributorInformation`, `org.hypercerts.claim.listContributorInformation` | Exact-URI retrieval, repeated-author filters, cursor pagination, hydrated and missing author sidecars, and named runtime errors | | Work-scope tags | `org.hypercerts.workscope.getWorkscopeTag`, `org.hypercerts.workscope.listWorkscopeTags` | Exact-URI retrieval, author filters, hydrated and null sidecars, middle-position `indexedAt` fallback, tied pagination in both directions, and named runtime errors | | Contributions | `org.hypercerts.claim.getContribution`, `org.hypercerts.claim.listContributions` | Exact-record retrieval, publisher filters, hydrated and null sidecars, createdAt/indexedAt fallback, tied pagination in both directions, and named runtime errors | diff --git a/api/README.md b/api/README.md index b43b7f6..fa626d3 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,40 @@ -# HappyView API toolkit foundation +# HappyView API toolkit and query modules -This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, 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. Capability modules listed in the root manifest register their endpoint Lexicons and handlers separately. The `workscope-tags` module adds public lookup and listing queries for indexed `org.hypercerts.workscope.tag` records; the `contribution` module adds public queries for contribution records; the `badge-queries` module installs five public badge query Lexicons and their Lua handlers. +This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, offline fixture/test utilities, and the feature, contribution, badge-query, vocabulary-tag, and acknowledgement modules. The `modules/shared/manifest.json` contains record schemas and shared query/view Lexicons; it contains no Lua endpoint scripts. A foundation-only install does not implement those queries. Capability modules listed in the root manifest register their endpoint Lexicons and Lua handlers separately; the `badge-queries` module installs five public badge query Lexicons and their Lua handlers. + +## Local checks + +From the repository root: + +```sh +pnpm install --frozen-lockfile +pnpm test:unit +pnpm check +pnpm build +PSQL_PATH="$(command -v psql)" pnpm test:http +``` + +`pnpm test:unit` and `pnpm check` run the recursively discovered tests under `api/tests/unit`; unit tests do not require HappyView or PostgreSQL. `pnpm check` also validates generated-source freshness, JavaScript/Lua lint, and types. `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. + +`pnpm test:http` requires a local Docker Compose daemon, `psql`, and the pinned PostgreSQL and HappyView images already present in the local image cache. The local runner never pulls images. The CI workflow explicitly pulls only the two digest-pinned test images before running the same command. Set `PSQL_PATH` to the absolute path returned by `command -v psql`. + +## HTTP runtime tests + +HTTP suites live in `api/tests/http` and exercise the funding, badge-definition, badge-query, feature, work-scope-tag, contribution, profile, organization, context attachment and evaluation, activity, collection, acknowledgement, vocabulary-tag, graph, and location XRPC endpoints installed from the current checkout. `pnpm test:http` discovers `*.http.test.js` suites and fixture modules named `*.fixture.js`, then creates a random Compose project with loopback-only dynamic ports, PostgreSQL data on tmpfs, and a task-owned default bridge network. Bridge networking permits container egress. HappyView receives loopback placeholder upstream URLs and proxy variables pointing to `127.0.0.1:9`; these are application-level settings, not network-hard egress isolation. The installer and HTTP suites target only the task-owned loopback service. The runner installs this checkout's manifest, seeds shared and HTTP fixtures, runs the suites, and tears down only that generated Compose project and its temporary credentials. Locally, Compose uses `--pull never`; missing cached images fail before service startup. + +The HTTP gate fails when it discovers zero suites, executes zero `node:test` cases, or runs only skipped cases. These checks cover real HTTP behavior against PostgreSQL, not just Lua handlers with a fake database. Funding coverage exercises record retrieval, repeated filters, and pagination. Badge-definition coverage exercises retrieval with an icon and allowed-issuer list, publisher-sidecar hydration, author and badge-type filters, createdAt/URI pagination ties, and named error responses. Badge-query coverage exercises baseline-aware definition feeds and discriminating filters, exact-version award/response lookups, recipient status, raw response history, bidirectional tied pagination, nullable sidecars, and named runtime errors. Vocabulary-tag coverage exercises exact retrieval, author filters, hydrated and nullable sidecars, tied pagination, and named errors. Acknowledgement coverage exercises exact retrieval, hydrated and absent publisher sidecars, repeated author/subject filters, tied pagination in both directions, and named errors. Feature coverage exercises exact retrieval and author hydration, list filters and sidecars, tied createdAt/URI pagination, and named errors. Contribution coverage exercises exact-record retrieval, repeated publisher filters, tied ascending/descending cursor pagination, nullable publisher sidecars, and named errors. Fixtures use CBOR-derived record CIDs and are seeded only into the task-owned disposable database. + +For the pinned HappyView release, ordinary Lua `error()` exceptions are returned as HTTP 500 JSON with `error: "script_error"` and `errorType: "runtime"`; the error name appears in `message`. The negative HTTP tests assert this observed runtime behavior. They do not define an ideal public HTTP status contract or guarantee 4xx mapping for `RecordNotFound` and `InvalidRequest`. + +## 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`. ## Work-scope tag queries 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/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 new file mode 100644 index 0000000..5e74d29 --- /dev/null +++ b/api/lua/endpoints/getFeature.lua @@ -0,0 +1,169 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local 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") + +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_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +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/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 new file mode 100644 index 0000000..4044785 --- /dev/null +++ b/api/lua/endpoints/listFeatures.lua @@ -0,0 +1,367 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed, unknown_message_prefix) + for key in pairs(values) do + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + +local function valid_datetime(value) + if type(value) ~= "string" then return false end + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local 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") + +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_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +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/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 new file mode 100644 index 0000000..058211a --- /dev/null +++ b/api/lua/src/getFeature.lua @@ -0,0 +1,37 @@ +local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +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/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 new file mode 100644 index 0000000..ff57985 --- /dev/null +++ b/api/lua/src/listFeatures.lua @@ -0,0 +1,193 @@ +local FEATURE_VIEW_TYPE = "org.hypercerts.entity.defs#featureView" + +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 7d62ab0..ad911b8 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -51,7 +51,9 @@ "getBadgeAward": "implemented", "listBadgeAwards": "implemented", "getBadgeResponse": "implemented", - "listBadgeResponses": "implemented" + "listBadgeResponses": "implemented", + "getFeature": "implemented", + "listFeatures": "implemented" }, "authentication": { "decision": "public", @@ -450,6 +452,18 @@ { "id": "app.certified.badge.listBadgeResponses", "path": "lexicons/app.certified.badge.listBadgeResponses.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" } ], "modules": [ @@ -471,6 +485,13 @@ "modules/workscope-tags/manifest.json", "modules/contribution/manifest.json", "modules/vocab/manifest.json", - "modules/location/manifest.json" - ] + "modules/location/manifest.json", + "modules/features/manifest.json" + ], + "luaBuild": { + "endpointSources": [ + "lua/src/getFeature.lua", + "lua/src/listFeatures.lua" + ] + } } 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 new file mode 100644 index 0000000..58ba1bc --- /dev/null +++ b/api/modules/features/manifest.json @@ -0,0 +1,57 @@ +{ + "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/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" }, + "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/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" }, + "dependsOn": ["org.hypercerts.entity.listFeatures"] + } + ] +} diff --git a/api/tests/http/features.http.test.js b/api/tests/http/features.http.test.js new file mode 100644 index 0000000..34aee99 --- /dev/null +++ b/api/tests/http/features.http.test.js @@ -0,0 +1,172 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const getEndpoint = 'org.hypercerts.entity.getFeature'; +const listEndpoint = 'org.hypercerts.entity.listFeatures'; +const featureCollection = 'org.hypercerts.entity.feature'; +const featureAuthor = 'did:plc:777777777777777777777777'; +const profileOnlyAuthor = 'did:plc:yyyyyyyyyyyyyyyyyyyyyyyy'; +const unhydratedAuthor = 'did:plc:zzzzzzzzzzzzzzzzzzzzzzzz'; +const featureUris = { + bog: `at://${profileOnlyAuthor}/${featureCollection}/feature-bog`, + older: `at://${profileOnlyAuthor}/${featureCollection}/feature-older`, + alpha: `at://${featureAuthor}/${featureCollection}/feature-alpha`, + beta: `at://${featureAuthor}/${featureCollection}/feature-beta`, + gamma: `at://${profileOnlyAuthor}/${featureCollection}/feature-gamma`, + newer: `at://${unhydratedAuthor}/${featureCollection}/feature-newer`, +}; + +async function request(endpoint, params) { + const url = contractUrl(requireContractTarget(), endpoint, params); + const response = await fetch(url, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { response, body }; +} + +function assertNamedRuntimeError(result, endpoint, name) { + assert.equal(result.response.status, 500, JSON.stringify(result.body)); + assert.equal(result.body.error, 'script_error'); + assert.equal(result.body.errorType, 'runtime'); + assert.equal(result.body.method, endpoint); + assert.match(result.body.message, new RegExp(name)); +} + +test('getFeature returns the indexed feature and hydrated author sidecars over HTTP', async () => { + const { response, body } = await request(getEndpoint, { uri: featureUris.alpha }); + assert.equal(response.status, 200, JSON.stringify(body)); + + const { feature } = body; + assert.equal(feature.$type, 'org.hypercerts.entity.defs#featureView'); + assert.equal(feature.uri, featureUris.alpha); + assert.equal(feature.cid, 'bafyreihxfwqj46nvd4ene4of7sbfoxq7n2v3aa5r4z6oxmuk6bh54637wm'); + assert.equal(feature.indexedAt, '2025-03-03T04:05:06.000Z'); + assert.equal(feature.did, featureAuthor); + assert.deepEqual(feature.record, { + $type: featureCollection, + type: 'wetland', + title: 'Wang Chhu floodplain', + createdAt: '2025-03-01T00:00:00.000Z', + locations: [], + tags: [], + sameAs: [], + }); + + assert.equal(feature.author.did, featureAuthor); + assert.equal(feature.author.profile.uri, `at://${featureAuthor}/app.certified.actor.profile/self`); + assert.equal(feature.author.profile.did, featureAuthor); + assert.deepEqual(feature.author.profile.record, { + $type: 'app.certified.actor.profile', + displayName: 'River Stewardship Alliance', + description: 'Community wetland monitoring and restoration.', + createdAt: '2025-03-03T04:05:06.000Z', + }); + assert.equal(feature.author.organization.uri, `at://${featureAuthor}/app.certified.actor.organization/self`); + assert.equal(feature.author.organization.did, featureAuthor); + assert.deepEqual(feature.author.organization.record, { + $type: 'app.certified.actor.organization', + organizationType: ['community'], + visibility: 'public', + createdAt: '2025-03-03T04:05:06.000Z', + }); +}); + +test('getFeature returns null profile and organization sidecars when their records are absent', async () => { + const { response, body } = await request(getEndpoint, { uri: featureUris.newer }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.feature.uri, featureUris.newer); + assert.equal(body.feature.author.did, unhydratedAuthor); + assert.equal(body.feature.author.profile, null); + assert.equal(body.feature.author.organization, null); +}); + +test('listFeatures combines repeated author and exact type filters without false positives', async () => { + const { response, body } = await request(listEndpoint, { + authors: [featureAuthor, profileOnlyAuthor], + types: ['wetland', 'forest'], + sortDirection: 'asc', + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.features.map(({ uri }) => uri), [ + featureUris.older, + featureUris.alpha, + featureUris.beta, + featureUris.gamma, + ]); + assert.deepEqual(body.features.map(({ record }) => record.type), ['forest', 'wetland', 'wetland', 'wetland']); + assert.equal(Object.hasOwn(body, 'cursor'), false); +}); + +test('listFeatures distinguishes authors with and without organization records', async () => { + const filters = { + authors: [featureAuthor, profileOnlyAuthor], + types: ['wetland'], + sortDirection: 'asc', + }; + + const withOrganization = await request(listEndpoint, { ...filters, hasOrganizationRecord: true }); + assert.equal(withOrganization.response.status, 200, JSON.stringify(withOrganization.body)); + assert.deepEqual(withOrganization.body.features.map(({ uri }) => uri), [featureUris.alpha, featureUris.beta]); + assert.ok(withOrganization.body.features.every(({ author }) => author.organization?.uri === `at://${featureAuthor}/app.certified.actor.organization/self`)); + + const withoutOrganization = await request(listEndpoint, { ...filters, hasOrganizationRecord: false }); + assert.equal(withoutOrganization.response.status, 200, JSON.stringify(withoutOrganization.body)); + assert.deepEqual(withoutOrganization.body.features.map(({ uri }) => uri), [featureUris.gamma]); + assert.equal(withoutOrganization.body.features[0].author.profile.record.displayName, 'Forest Stewards'); + assert.equal(withoutOrganization.body.features[0].author.organization, null); +}); + +test('listFeatures paginates createdAt and URI ties in both directions without omissions', async () => { + for (const [sortDirection, expected] of [ + ['asc', [featureUris.bog, featureUris.older, featureUris.alpha, featureUris.beta, featureUris.gamma, featureUris.newer]], + ['desc', [featureUris.newer, featureUris.gamma, featureUris.beta, featureUris.alpha, featureUris.older, featureUris.bog]], + ]) { + let cursor; + const collected = []; + for (let offset = 0; offset < expected.length; offset += 2) { + 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); + assert.deepEqual(body.features.map(({ uri }) => uri), page); + collected.push(...body.features.map(({ uri }) => uri)); + + const hasNextPage = offset + page.length < expected.length; + assert.equal(Object.hasOwn(body, 'cursor'), hasNextPage); + if (hasNextPage) { + assert.equal(typeof body.cursor, 'string'); + cursor = body.cursor; + } + } + assert.deepEqual(collected, expected); + } +}); + +test('feature endpoints expose named request errors using the pinned HappyView runtime response', async () => { + const invalidUri = await request(getEndpoint, { + uri: 'at://alice.example/org.hypercerts.entity.feature/feature-missing', + }); + assertNamedRuntimeError(invalidUri, getEndpoint, 'InvalidRequest'); + + const missingFeature = await request(getEndpoint, { + uri: `at://${featureAuthor}/${featureCollection}/feature-missing`, + }); + assertNamedRuntimeError(missingFeature, getEndpoint, 'RecordNotFound'); + + const invalidListRequest = await request(listEndpoint, { limit: 101 }); + assertNamedRuntimeError(invalidListRequest, listEndpoint, 'InvalidRequest'); +}); diff --git a/api/tests/http/fixtures/features.fixture.js b/api/tests/http/fixtures/features.fixture.js new file mode 100644 index 0000000..d3f5b30 --- /dev/null +++ b/api/tests/http/fixtures/features.fixture.js @@ -0,0 +1,116 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const featureCollection = 'org.hypercerts.entity.feature'; +const profileCollection = 'app.certified.actor.profile'; +const organizationCollection = 'app.certified.actor.organization'; +const featureAuthor = 'did:plc:777777777777777777777777'; +const profileOnlyAuthor = 'did:plc:yyyyyyyyyyyyyyyyyyyyyyyy'; +const unhydratedAuthor = 'did:plc:zzzzzzzzzzzzzzzzzzzzzzzz'; +const indexedAt = '2025-03-03T04:05:06.000Z'; + +const features = [ + { + did: profileOnlyAuthor, + rkey: 'feature-bog', + record: { + type: 'bog', + title: 'Restored peatland', + createdAt: '2025-02-27T00:00:00.000Z', + locations: [], + tags: [], + sameAs: [], + }, + }, + { + did: profileOnlyAuthor, + rkey: 'feature-older', + record: { + type: 'forest', + title: 'Older canopy', + createdAt: '2025-02-28T00:00:00.000Z', + locations: [], + tags: [], + sameAs: [], + }, + }, + { + did: featureAuthor, + rkey: 'feature-alpha', + record: { + type: 'wetland', + title: 'Wang Chhu floodplain', + createdAt: '2025-03-01T00:00:00.000Z', + locations: [], + tags: [], + sameAs: [], + }, + }, + { + did: featureAuthor, + rkey: 'feature-beta', + record: { + type: 'wetland', + title: 'Northern marsh', + createdAt: '2025-03-01T00:00:00.000Z', + locations: [], + tags: [], + sameAs: [], + }, + }, + { + did: profileOnlyAuthor, + rkey: 'feature-gamma', + record: { + type: 'wetland', + title: 'Community wetland', + createdAt: '2025-03-01T00:00:00.000Z', + locations: [], + tags: [], + sameAs: [], + }, + }, + { + did: unhydratedAuthor, + rkey: 'feature-newer', + record: { + type: 'wetland', + title: 'New growth', + createdAt: '2025-03-02T00:00:00.000Z', + locations: [], + tags: [], + sameAs: [], + }, + }, +]; + +async function seedRow(collection, did, rkey, fields) { + const record = { $type: collection, ...fields }; + return { + uri: `at://${did}/${collection}/${rkey}`, + did, + collection, + rkey, + cid: CID.toString(await CID.create(0x71, encode(record))), + indexedAt, + record, + }; +} + +export const seedRows = await Promise.all([ + ...features.map(({ did, rkey, record }) => seedRow(featureCollection, did, rkey, record)), + seedRow(profileCollection, featureAuthor, 'self', { + displayName: 'River Stewardship Alliance', + description: 'Community wetland monitoring and restoration.', + createdAt: indexedAt, + }), + seedRow(organizationCollection, featureAuthor, 'self', { + organizationType: ['community'], + visibility: 'public', + createdAt: indexedAt, + }), + seedRow(profileCollection, profileOnlyAuthor, 'self', { + displayName: 'Forest Stewards', + createdAt: indexedAt, + }), +]); 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/feature.test.js b/api/tests/unit/feature.test.js new file mode 100644 index 0000000..8901189 --- /dev/null +++ b/api/tests/unit/feature.test.js @@ -0,0 +1,322 @@ +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/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/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' }], + [{ 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/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/src/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 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, + })).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}`); + } +}); diff --git a/api/tests/unit/fixtures/http-seed-rows.test.js b/api/tests/unit/fixtures/http-seed-rows.test.js index 61418a6..1c991a3 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 discovered HTTP seed rows preserve acknowledgement sidecar isolation, 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 acknowledgementRows = rows.filter(({ row }) => row.collection === 'org.hypercerts.context.acknowledgement'); @@ -110,3 +112,14 @@ test('shared and discovered HTTP seed rows preserve acknowledgement sidecar isol '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), []); +}); diff --git a/api/tests/unit/tooling/badge-definition.contract.test.js b/api/tests/unit/tooling/badge-definition.contract.test.js index b0134b7..dbbaaf3 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;