diff --git a/.changeset/contributor-information-view-ref.md b/.changeset/contributor-information-view-ref.md new file mode 100644 index 0000000..ca489c3 --- /dev/null +++ b/.changeset/contributor-information-view-ref.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Contributor-information query schemas now share their response-view definition from `getContributorInformation`. Response JSON is unchanged. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2523787..170fd2d 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, 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, 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. +- 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. - Badge-query 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. @@ -45,7 +46,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, 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, 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 a59dc98..a6cbeb3 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # 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, 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, 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: -- `tooling/api-foundation` owns the shared installer, pinned schemas, projections, fixtures, and offline checks. -- Capability branches add their independently owned endpoint modules. -- `tooling/docs` adds the endpoint explorer and full schema snapshots. +Included query endpoints: -`api/manifest.json` is authoritative for the API modules included in this checkout. The docs explorer is present on the docs branch. +- Badge definitions: `app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions` +- Badge queries: `app.certified.badge.searchBadgeDefinitions`, `app.certified.badge.getBadgeAward`, `app.certified.badge.listBadgeAwards`, `app.certified.badge.getBadgeResponse`, `app.certified.badge.listBadgeResponses` +- Funding receipts: `org.hypercerts.funding.getReceipt`, `org.hypercerts.funding.listReceipts` +- Acknowledgements: `org.hypercerts.context.getAcknowledgement`, `org.hypercerts.context.listAcknowledgements` +- Contributions: `org.hypercerts.claim.getContribution`, `org.hypercerts.claim.listContributions` +- Contributor information: `org.hypercerts.claim.getContributorInformation`, `org.hypercerts.claim.listContributorInformation` +- Vocabulary tags: `org.hypercerts.vocab.getVocabTag`, `org.hypercerts.vocab.listVocabTags` + +`api/manifest.json` is authoritative for the modules and validation Lexicons included in this checkout. The `docs/` workspace contains the endpoint explorer and full schema snapshots. ```sh pnpm install --frozen-lockfile @@ -27,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 | +| 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 | | Vocabulary tags | `org.hypercerts.vocab.getVocabTag`, `org.hypercerts.vocab.listVocabTags` | Exact-URI retrieval, publisher filters, hydrated and nullable sidecars, timestamp/URI pagination, and named errors | diff --git a/api/README.md b/api/README.md index c7e486b..b43b7f6 100644 --- a/api/README.md +++ b/api/README.md @@ -39,6 +39,32 @@ The shared module registers the contribution record Lexicon with backfill enable See [CONTRIBUTING.md](../CONTRIBUTING.md) for local validation commands, HTTP test prerequisites and safety boundaries, endpoint coverage, and task-owned resource cleanup. +## 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 runtime coverage and shared runner requirements are documented in [CONTRIBUTING.md](../CONTRIBUTING.md). + +HTTP suites live in `api/tests/http` and call the installed activity, collection, funding (`org.hypercerts.funding.getReceipt`, `org.hypercerts.funding.listReceipts`), badge-definition (`app.certified.badge.getBadgeDefinition`, `app.certified.badge.listBadgeDefinitions`), badge-query, acknowledgement (`org.hypercerts.context.getAcknowledgement`, `org.hypercerts.context.listAcknowledgements`), profile, organization, contributor-information (`org.hypercerts.claim.getContributorInformation`, `org.hypercerts.claim.listContributorInformation`), contribution, context attachment and evaluation, vocabulary-tag, graph, and location XRPC endpoints. `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. Contributor-information coverage exercises both endpoints: exact AT-URI retrieval with its CBOR-derived CID returned but not supplied in the request; repeated-author filtering; tied createdAt/URI pagination in both ascending and descending directions; hydrated and null author sidecars; and named `RecordNotFound` and `InvalidRequest` runtime errors. Contributor fixtures 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`. + ## Install a released API bundle Releases version the installable API bundle in this package; they do not publish to npm or deploy to a HappyView instance. This public repository's GitHub Releases and tagged source archives are the distribution channel. Choose an `@hypercerts-org/hypercerts-api@X.Y.Z` release tag, clone that snapshot, install its pinned workspace dependencies from the repository root, then run the installer from `api/`: @@ -55,6 +81,23 @@ Review that release's notes and target only an explicitly approved HappyView ins 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. + +## Contributor-information queries + +The shared module provides two public queries for `org.hypercerts.claim.contributorInformation` records: + +- `org.hypercerts.claim.getContributorInformation({ uri })` returns `{ contributorInformation }` for the exact full record AT-URI. The authority must be a DID. An unindexed URI returns `RecordNotFound`. +- `org.hypercerts.claim.listContributorInformation({ authors?, sortDirection?, limit?, cursor? })` returns `{ contributorInformation, cursor? }`. `authors` filters publisher repository DIDs (OR semantics), supplied as repeated unbracketed query keys, with at most 100 values. + +Both responses use the `contributorInformationView` definition declared by `org.hypercerts.claim.getContributorInformation`: record metadata (`uri`, `cid`, nullable `indexedAt`, and publisher `did`), an `author` actor view, and the original full indexed `record`. The activity endpoint keeps its separate exact-reference contributor-information view. The older proposal snippet that omits `nullable` for `indexedAt` is stale; the accepted API design requires this field to remain present as JSON `null` when the database timestamp is null. The author contains the publisher's current Certified profile and raw organization sidecar; each is `null` when missing. The record's contributor identifier is not resolved, and referencing activities are not expanded. Operational query or required hydration failures return errors rather than empty or partial results. + +Listings default to 25 records and accept limits from 1 through 100. They sort by `(createdAt, uri)` in descending order unless `sortDirection` is `asc`; cursors are opaque and direction-bound. Keep filters and direction unchanged when continuing a page. The `cursor` property is omitted when there is no next page. A singular `RecordNotFound` means HappyView has no indexed row at that URI; it does not prove the record is absent or deleted from its PDS. + +### Operator notes + +Both queries require the HappyView PostgreSQL backend, including the profile and organization lookups used to hydrate publishers. A missing sidecar is normal and returned as `null`; a backend or lookup failure is an endpoint error. Build and validate locally with `pnpm build:lua` and `pnpm check`. Register these assets through the existing `pnpm install:api` workflow only after confirming the intended HappyView target and credentials; the installer performs external admin writes. + ## Vocabulary tag queries The default API bundle registers two public, unauthenticated endpoints for indexed `org.hypercerts.vocab.tag` records: diff --git a/api/lexicons/org.hypercerts.claim.getContributorInformation.json b/api/lexicons/org.hypercerts.claim.getContributorInformation.json new file mode 100644 index 0000000..2934729 --- /dev/null +++ b/api/lexicons/org.hypercerts.claim.getContributorInformation.json @@ -0,0 +1,54 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.getContributorInformation", + "defs": { + "main": { + "type": "query", + "description": "Publicly looks up one indexed contributor-information record by exact AT-URI and includes its publisher.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Full contributor-information record AT-URI with a DID authority." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "The URI is invalid or is not a contributor-information record AT-URI." }, + { "name": "RecordNotFound", "description": "No indexed contributor-information record exists at this AT-URI." }, + { "name": "ContributorInformationQueryFailed", "description": "The record or required publisher hydration lookup failed." } + ] + }, + "output": { + "type": "object", + "required": ["contributorInformation"], + "properties": { + "contributorInformation": { + "type": "ref", + "ref": "#contributorInformationView" + } + } + }, + "contributorInformationView": { + "type": "object", + "description": "Contributor-information record with its full source payload and hydrated publishing 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.claim.contributorInformation" } + } + } + } +} diff --git a/api/lexicons/org.hypercerts.claim.listContributorInformation.json b/api/lexicons/org.hypercerts.claim.listContributorInformation.json new file mode 100644 index 0000000..f10c058 --- /dev/null +++ b/api/lexicons/org.hypercerts.claim.listContributorInformation.json @@ -0,0 +1,58 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.claim.listContributorInformation", + "defs": { + "main": { + "type": "query", + "description": "Publicly lists indexed contributor-information records with an optional publisher-DID filter and stable createdAt ordering.", + "parameters": { + "type": "params", + "properties": { + "authors": { + "type": "array", + "maxLength": 100, + "description": "Publisher repository DIDs; values use OR.", + "items": { "type": "string", "format": "did" } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Direction for (createdAt, uri); defaults to desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum records in the page; defaults to 25." + }, + "cursor": { + "type": "string", + "description": "Opaque cursor bound to sortDirection; keep other parameters unchanged between pages." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "An author DID, sort direction, page bound, cursor, repeated scalar, or query parameter is invalid." }, + { "name": "ContributorInformationQueryFailed", "description": "The record query or required publisher hydration lookup failed." } + ] + }, + "output": { + "type": "object", + "required": ["contributorInformation"], + "properties": { + "contributorInformation": { + "type": "array", + "items": { "type": "ref", "ref": "org.hypercerts.claim.getContributorInformation#contributorInformationView" } + }, + "cursor": { + "type": "string", + "description": "Opaque cursor for the next page; omitted when there is no next page." + } + } + } + } +} diff --git a/api/lua/endpoints/getContributorInformation.lua b/api/lua/endpoints/getContributorInformation.lua new file mode 100644 index 0000000..112181d --- /dev/null +++ b/api/lua/endpoints/getContributorInformation.lua @@ -0,0 +1,145 @@ +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_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 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 CONTRIBUTOR_INFORMATION_COLLECTION = "org.hypercerts.claim.contributorInformation" + +local function contributor_information_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local percent = value:find("%", position, true) + if not percent then return true end + local escape = value:sub(percent + 1, percent + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + position = percent + 3 + end +end + +local function contributor_information_uri(value) + local valid, collection, authority = valid_record_uri(value) + if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end + return contributor_information_did(authority) +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information lookup failed", 0) + end + return result +end + +local function preserve_contributor_author_timestamps(author) + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if type(sidecar) == "table" and sidecar.uri ~= nil and sidecar.indexedAt == nil then + sidecar.indexedAt = json.decode("null") + end + end +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not contributor_information_uri(uri) then + invalid("uri must be a full org.hypercerts.claim.contributorInformation AT-URI with a DID authority") + end + + local rows = contributor_information_query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND uri = $2 LIMIT 1", + { CONTRIBUTOR_INFORMATION_COLLECTION, uri }) + if #rows == 0 then error("RecordNotFound: contributor-information record is not indexed", 0) end + + local view = record_view(rows[1]) + if rows[1].indexed_at == nil then view.indexedAt = json.decode("null") end + local author = { did = rows[1].did } + hydrate_actor_views({ author }, contributor_information_query) + preserve_contributor_author_timestamps(author) + view.author = author + return { contributorInformation = view } +end diff --git a/api/lua/endpoints/listContributorInformation.lua b/api/lua/endpoints/listContributorInformation.lua new file mode 100644 index 0000000..4e3d486 --- /dev/null +++ b/api/lua/endpoints/listContributorInformation.lua @@ -0,0 +1,314 @@ +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_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 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 CONTRIBUTOR_INFORMATION_COLLECTION = "org.hypercerts.claim.contributorInformation" + +local function contributor_information_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local percent = value:find("%", position, true) + if not percent then return true end + local escape = value:sub(percent + 1, percent + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + position = percent + 3 + end +end + +local function contributor_information_uri(value) + local valid, collection, authority = valid_record_uri(value) + if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end + return contributor_information_did(authority) +end + +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and NULL or row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" + +local function hydrate_actor_views(actors, run_query) + if #actors == 0 then return end + local dids, seen = {}, {} + for _, actor in ipairs(actors) do + if not seen[actor.did] then + seen[actor.did] = true + dids[#dids + 1] = actor.did + end + end + local profiles, organizations = {}, {} + local function load(collection, target) + local params, marks = { collection }, {} + for _, did in ipairs(dids) do + params[#params + 1] = did + marks[#marks + 1] = "$" .. #params + end + local rows = run_query("SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params) + for _, row in ipairs(rows) do target[row.did] = row end + end + load(PROFILE, profiles) + load(ORGANIZATION, organizations) + for _, actor in ipairs(actors) do + actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL + actor.organization = organizations[actor.did] and record_view(organizations[actor.did]) or NULL + end +end + +local 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 function contributor_information_datetime(value) + return valid_datetime(value) and tonumber(value:sub(1, 4)) > 0 +end + +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information query failed", 0) + end + return result +end + +local function contributor_information_array(params, key, validate, description) + local value = params[key] + if value == nil then return nil end + + local supplied = {} + if type(value) == "string" then + supplied[1] = value + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #supplied > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for _, item in ipairs(supplied) do + if validate and not validate(item) then + invalid("each " .. key .. " value must be " .. description .. "; resolve handles to DIDs first") + end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function contributor_information_bind_in(where, values, expression, items) + if not items then return end + if #items == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = expression .. " IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function contributor_information_cursor_decode(token, direction) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not contributor_information_uri(value.u) or not contributor_information_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function preserve_contributor_author_timestamps(author) + local null = json.decode("null") + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if sidecar ~= null and type(sidecar) == "table" and sidecar.indexedAt == nil then + sidecar.indexedAt = null + end + end +end + +local function contributor_information_sort_expression() + local created = "contributor.record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(contributor.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(contributor.indexed_at::timestamptz, contributor.created_at::timestamptz) END" +end + +local function query_contributor_information(authors, limit, cursor, direction) + local where, values = { "contributor.collection = $1" }, { CONTRIBUTOR_INFORMATION_COLLECTION } + contributor_information_bind_in(where, values, "contributor.did", authors) + + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, contributor.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sort_key = contributor_information_sort_expression() + local sql = "SELECT contributor.uri, contributor.did, contributor.cid, " .. + "contributor.indexed_at::text AS indexed_at, contributor.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 contributor CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", contributor.uri " .. ordering .. " LIMIT $" .. #values + local rows = contributor_information_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views, authors_to_hydrate = {}, {} + for _, row in ipairs(rows) do + local view = record_view(row) + if row.indexed_at == nil then view.indexedAt = json.decode("null") end + view.author = { did = row.did } + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + hydrate_actor_views(authors_to_hydrate, contributor_information_query) + for _, author in ipairs(authors_to_hydrate) do preserve_contributor_author_timestamps(author) end + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +function handle() + keys_only(params, { + authors = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local authors = contributor_information_array(params, "authors", contributor_information_did, "valid DIDs") + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = contributor_information_cursor_decode(scalar(params, "cursor"), direction) + local views, next_cursor = query_contributor_information(authors, limit, cursor, direction) + local response = { contributorInformation = toarray(views) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/api/lua/shared/contributorInformationValidation.lua b/api/lua/shared/contributorInformationValidation.lua new file mode 100644 index 0000000..9a8058c --- /dev/null +++ b/api/lua/shared/contributorInformationValidation.lua @@ -0,0 +1,19 @@ +local CONTRIBUTOR_INFORMATION_COLLECTION = "org.hypercerts.claim.contributorInformation" + +local function contributor_information_did(value) + if not valid_did(value) then return false end + local position = 1 + while true do + local percent = value:find("%", position, true) + if not percent then return true end + local escape = value:sub(percent + 1, percent + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + position = percent + 3 + end +end + +local function contributor_information_uri(value) + local valid, collection, authority = valid_record_uri(value) + if not valid or collection ~= CONTRIBUTOR_INFORMATION_COLLECTION then return false end + return contributor_information_did(authority) +end diff --git a/api/lua/src/getContributorInformation.lua b/api/lua/src/getContributorInformation.lua new file mode 100644 index 0000000..2aa64bd --- /dev/null +++ b/api/lua/src/getContributorInformation.lua @@ -0,0 +1,42 @@ +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information lookup failed", 0) + end + return result +end + +local function preserve_contributor_author_timestamps(author) + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if type(sidecar) == "table" and sidecar.uri ~= nil and sidecar.indexedAt == nil then + sidecar.indexedAt = json.decode("null") + end + end +end + +function handle() + keys_only(params, { uri = true }) + local uri = scalar(params, "uri") + if not contributor_information_uri(uri) then + invalid("uri must be a full org.hypercerts.claim.contributorInformation AT-URI with a DID authority") + end + + local rows = contributor_information_query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " .. + "FROM happyview_records WHERE collection = $1 AND uri = $2 LIMIT 1", + { CONTRIBUTOR_INFORMATION_COLLECTION, uri }) + if #rows == 0 then error("RecordNotFound: contributor-information record is not indexed", 0) end + + local view = record_view(rows[1]) + if rows[1].indexed_at == nil then view.indexedAt = json.decode("null") end + local author = { did = rows[1].did } + hydrate_actor_views({ author }, contributor_information_query) + preserve_contributor_author_timestamps(author) + view.author = author + return { contributorInformation = view } +end diff --git a/api/lua/src/listContributorInformation.lua b/api/lua/src/listContributorInformation.lua new file mode 100644 index 0000000..71e83e9 --- /dev/null +++ b/api/lua/src/listContributorInformation.lua @@ -0,0 +1,169 @@ +local function contributor_information_datetime(value) + return valid_datetime(value) and tonumber(value:sub(1, 4)) > 0 +end + +local function contributor_information_query(sql, values) + local backend_ok, backend = pcall(db.backend) + if not backend_ok or backend ~= "postgres" then + error("ContributorInformationQueryFailed: contributor-information queries require PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("ContributorInformationQueryFailed: contributor-information query failed", 0) + end + return result +end + +local function contributor_information_array(params, key, validate, description) + local value = params[key] + if value == nil then return nil end + + local supplied = {} + if type(value) == "string" then + supplied[1] = value + elseif type(value) == "table" then + local count = 0 + for index in pairs(value) do + if type(index) ~= "number" or index < 1 or index % 1 ~= 0 then + invalid(key .. " must use repeated query values") + end + count = count + 1 + end + if count ~= #value then invalid(key .. " must use repeated query values") end + for index = 1, count do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + supplied[#supplied + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #supplied > 100 then invalid(key .. " accepts at most 100 values") end + + local unique, seen = {}, {} + for _, item in ipairs(supplied) do + if validate and not validate(item) then + invalid("each " .. key .. " value must be " .. description .. "; resolve handles to DIDs first") + end + if not seen[item] then + seen[item] = true + unique[#unique + 1] = item + end + end + return unique +end + +local function contributor_information_bind_in(where, values, expression, items) + if not items then return end + if #items == 0 then + where[#where + 1] = "FALSE" + return + end + local placeholders = {} + for _, item in ipairs(items) do + values[#values + 1] = item + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = expression .. " IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function contributor_information_cursor_decode(token, direction) + if token == nil then return nil end + if #token == 0 or #token > 8192 or #token % 2 ~= 0 or token:find("[^0-9a-f]") then + invalid("cursor is malformed") + end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not contributor_information_uri(value.u) or not contributor_information_datetime(value.t) then + invalid("cursor is malformed") + end + return value +end + +local function preserve_contributor_author_timestamps(author) + local null = json.decode("null") + for _, field in ipairs({ "profile", "organization" }) do + local sidecar = author[field] + if sidecar ~= null and type(sidecar) == "table" and sidecar.indexedAt == nil then + sidecar.indexedAt = null + end + end +end + +local function contributor_information_sort_expression() + local created = "contributor.record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + return "CASE WHEN jsonb_typeof(contributor.record::jsonb->'createdAt') = 'string' AND " .. created .. + " ~ '" .. zoned .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. + ", 'timestamptz') THEN (" .. created .. ")::timestamptz ELSE " .. + "COALESCE(contributor.indexed_at::timestamptz, contributor.created_at::timestamptz) END" +end + +local function query_contributor_information(authors, limit, cursor, direction) + local where, values = { "contributor.collection = $1" }, { CONTRIBUTOR_INFORMATION_COLLECTION } + contributor_information_bind_in(where, values, "contributor.did", authors) + + if cursor then + values[#values + 1] = cursor.t + local timestamp = "$" .. #values + values[#values + 1] = cursor.u + local uri = "$" .. #values + local operator = direction == "asc" and ">" or "<" + where[#where + 1] = "(sorted.sort_at, contributor.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local sort_key = contributor_information_sort_expression() + local sql = "SELECT contributor.uri, contributor.did, contributor.cid, " .. + "contributor.indexed_at::text AS indexed_at, contributor.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 contributor CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) AS sorted " .. + "WHERE " .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. + ", contributor.uri " .. ordering .. " LIMIT $" .. #values + local rows = contributor_information_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views, authors_to_hydrate = {}, {} + for _, row in ipairs(rows) do + local view = record_view(row) + if row.indexed_at == nil then view.indexedAt = json.decode("null") end + view.author = { did = row.did } + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + hydrate_actor_views(authors_to_hydrate, contributor_information_query) + for _, author in ipairs(authors_to_hydrate) do preserve_contributor_author_timestamps(author) end + + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return views, next_cursor +end + +function handle() + keys_only(params, { + authors = true, + sortDirection = true, + limit = true, + cursor = true, + }) + local authors = contributor_information_array(params, "authors", contributor_information_did, "valid DIDs") + local limit = parse_list_limit(params) + local direction = parse_sort_direction(params) + local cursor = contributor_information_cursor_decode(scalar(params, "cursor"), direction) + local views, next_cursor = query_contributor_information(authors, limit, cursor, direction) + local response = { contributorInformation = toarray(views) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/api/manifest.json b/api/manifest.json index 3ccb4a4..7d62ab0 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -41,6 +41,8 @@ "listWorkscopeTags": "implemented", "getContribution": "implemented", "listContributions": "implemented", + "getContributorInformation": "implemented", + "listContributorInformation": "implemented", "getVocabTag": "implemented", "listVocabTags": "implemented", "getLocation": "implemented", @@ -141,6 +143,14 @@ "id": "org.hypercerts.claim.getActivity", "path": "lexicons/org.hypercerts.claim.getActivity.json" }, + { + "id": "org.hypercerts.claim.getContributorInformation", + "path": "lexicons/org.hypercerts.claim.getContributorInformation.json" + }, + { + "id": "org.hypercerts.claim.listContributorInformation", + "path": "lexicons/org.hypercerts.claim.listContributorInformation.json" + }, { "id": "org.hypercerts.workscope.getWorkscopeTag", "path": "lexicons/org.hypercerts.workscope.getWorkscopeTag.json" @@ -455,6 +465,7 @@ "modules/recent-follows/manifest.json", "modules/context-evaluation/manifest.json", "modules/context-attachments/manifest.json", + "modules/contributor-information/manifest.json", "modules/acknowledgements/manifest.json", "modules/funding/manifest.json", "modules/workscope-tags/manifest.json", diff --git a/api/modules/contributor-information/manifest.json b/api/modules/contributor-information/manifest.json new file mode 100644 index 0000000..553d8cd --- /dev/null +++ b/api/modules/contributor-information/manifest.json @@ -0,0 +1,58 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "org.hypercerts.claim.getContributorInformation", + "path": "../../lexicons/org.hypercerts.claim.getContributorInformation.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.contributorInformation" }, + "dependsOn": ["org.hypercerts.claim.contributorInformation", "org.hypercerts.api.defs"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.claim.listContributorInformation", + "path": "../../lexicons/org.hypercerts.claim.listContributorInformation.json", + "config": { "backfill": false, "target_collection": "org.hypercerts.claim.contributorInformation" }, + "dependsOn": ["org.hypercerts.claim.contributorInformation", "org.hypercerts.claim.getContributorInformation", "org.hypercerts.api.defs"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.getContributorInformation", + "path": "../../lua/endpoints/getContributorInformation.lua", + "sourcePath": "../../lua/src/getContributorInformation.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/didValidation.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/contributorInformationValidation.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua" + ], + "config": { + "script_type": "lua", + "description": "Look up a contributor-information record and its publisher." + }, + "dependsOn": ["org.hypercerts.claim.getContributorInformation"] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.claim.listContributorInformation", + "path": "../../lua/endpoints/listContributorInformation.lua", + "sourcePath": "../../lua/src/listContributorInformation.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/didValidation.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/contributorInformationValidation.lua", + "../../lua/shared/recordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua" + ], + "config": { + "script_type": "lua", + "description": "List contributor-information records with publisher hydration." + }, + "dependsOn": ["org.hypercerts.claim.listContributorInformation"] + } + ] +} diff --git a/api/tests/http/fixtures/contributor-information.fixture.js b/api/tests/http/fixtures/contributor-information.fixture.js new file mode 100644 index 0000000..1373133 --- /dev/null +++ b/api/tests/http/fixtures/contributor-information.fixture.js @@ -0,0 +1,51 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const contributorCollection = 'org.hypercerts.claim.contributorInformation'; +const profileCollection = 'app.certified.actor.profile'; +const organizationCollection = 'app.certified.actor.organization'; +const authorA = 'did:web:contributor-http-author-a.invalid'; +const authorB = 'did:web:contributor-http-author-b.invalid'; +const authorC = 'did:web:contributor-http-author-c.invalid'; +const createdAt = '2025-03-01T00:00:00.000Z'; +const indexedAt = '2025-03-02T00:00:00.000Z'; + +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([ + seedRow(contributorCollection, authorA, '3jzfcijpj2z2a', { + identifier: 'manual:cedar-restorer', + displayName: 'Cedar Restorer', + createdAt, + }), + seedRow(contributorCollection, authorB, '3jzfcijpj2z2b', { + identifier: 'manual:river-monitor', + displayName: 'River Monitor', + createdAt, + }), + seedRow(contributorCollection, authorC, '3jzfcijpj2z2c', { + identifier: 'manual:forest-keeper', + displayName: 'Forest Keeper', + createdAt, + }), + seedRow(profileCollection, authorA, 'self', { + displayName: 'Cedar Watershed Group', + createdAt: indexedAt, + }), + seedRow(organizationCollection, authorA, 'self', { + organizationType: ['nonprofit'], + visibility: 'public', + createdAt: indexedAt, + }), +]); diff --git a/api/tests/http/getContributorInformation.http.test.js b/api/tests/http/getContributorInformation.http.test.js new file mode 100644 index 0000000..78674b9 --- /dev/null +++ b/api/tests/http/getContributorInformation.http.test.js @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const contributorUri = 'at://did:web:contributor-http-author-a.invalid/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; +const collection = 'org.hypercerts.claim.contributorInformation'; + +async function getContributorInformation(uri) { + const url = contractUrl(requireContractTarget(), 'org.hypercerts.claim.getContributorInformation', { uri }); + const response = await fetch(url, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { response, body }; +} + +test('getContributorInformation fetches by AT-URI without supplying its pinned CID and hydrates publisher records', async () => { + const { response, body } = await getContributorInformation(contributorUri); + assert.equal(response.status, 200, JSON.stringify(body)); + + const view = body.contributorInformation; + assert.equal(view.uri, contributorUri); + assert.equal(view.did, 'did:web:contributor-http-author-a.invalid'); + assert.equal(view.record.$type, collection); + assert.equal(view.record.identifier, 'manual:cedar-restorer'); + assert.equal(view.record.displayName, 'Cedar Restorer'); + assert.equal( + view.cid, + 'bafyreicjb36r5phxdx46gn5m75zk7csjqs6j47xnstvxoerdfy7jvkajxy', + 'the AT-URI lookup returns its known CBOR-derived CID without supplying that CID in the request', + ); + assert.equal(view.author.did, 'did:web:contributor-http-author-a.invalid'); + assert.equal(view.author.profile.record.displayName, 'Cedar Watershed Group'); + assert.deepEqual(view.author.organization.record.organizationType, ['nonprofit']); +}); + +test('getContributorInformation returns null for absent publisher sidecars', async () => { + const missingSidecarUri = 'at://did:web:contributor-http-author-b.invalid/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2b'; + const { response, body } = await getContributorInformation(missingSidecarUri); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.contributorInformation.author.profile, null); + assert.equal(body.contributorInformation.author.organization, null); +}); + +test('getContributorInformation exposes RecordNotFound as the pinned HappyView runtime error', async () => { + const missingUri = 'at://did:web:contributor-http-author-a.invalid/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2d'; + const { response, body } = await getContributorInformation(missingUri); + assert.equal(response.status, 500, JSON.stringify(body)); + assert.equal(body.error, 'script_error'); + assert.equal(body.errorType, 'runtime'); + assert.equal(body.method, 'org.hypercerts.claim.getContributorInformation'); + assert.match(body.message, /RecordNotFound/); +}); + +test('getContributorInformation exposes InvalidRequest as the pinned HappyView runtime error', async () => { + const malformedUri = 'at://did:plc:mm%GG/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a'; + const { response, body } = await getContributorInformation(malformedUri); + assert.equal(response.status, 500, JSON.stringify(body)); + assert.equal(body.error, 'script_error'); + assert.equal(body.errorType, 'runtime'); + assert.equal(body.method, 'org.hypercerts.claim.getContributorInformation'); + assert.match(body.message, /InvalidRequest/); +}); diff --git a/api/tests/http/listContributorInformation.http.test.js b/api/tests/http/listContributorInformation.http.test.js new file mode 100644 index 0000000..9113a43 --- /dev/null +++ b/api/tests/http/listContributorInformation.http.test.js @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const endpoint = 'org.hypercerts.claim.listContributorInformation'; +const authorA = 'did:web:contributor-http-author-a.invalid'; +const authorB = 'did:web:contributor-http-author-b.invalid'; +const authorC = 'did:web:contributor-http-author-c.invalid'; +const uris = { + baseline: 'at://did:plc:gggggggggggggggggggggggg/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2h', + a: `at://${authorA}/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2a`, + b: `at://${authorB}/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2b`, + c: `at://${authorC}/org.hypercerts.claim.contributorInformation/3jzfcijpj2z2c`, +}; + +async function listContributorInformation(params = {}) { + const url = contractUrl(requireContractTarget(), endpoint, params); + const response = await fetch(url, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { response, body }; +} + +test('listContributorInformation applies repeated author filters and excludes other publishers', async () => { + const { response, body } = await listContributorInformation({ + authors: [authorA, authorC], + sortDirection: 'asc', + limit: 10, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.contributorInformation.map(({ uri }) => uri), [uris.a, uris.c]); + assert.equal(body.contributorInformation[0].author.profile.record.displayName, 'Cedar Watershed Group'); + assert.deepEqual(body.contributorInformation[0].author.organization.record.organizationType, ['nonprofit']); + assert.equal(body.contributorInformation[1].author.profile, null); + assert.equal(body.contributorInformation[1].author.organization, null); + assert.equal(Object.hasOwn(body, 'cursor'), false); +}); + +test('listContributorInformation paginates tied createdAt values in both directions and retains baseline rows', async () => { + for (const { direction, firstUris, secondUris, allUris } of [ + { + direction: 'asc', + firstUris: [uris.baseline, uris.a], + secondUris: [uris.b, uris.c], + allUris: [uris.baseline, uris.a, uris.b, uris.c], + }, + { + direction: 'desc', + firstUris: [uris.c, uris.b], + secondUris: [uris.a, uris.baseline], + allUris: [uris.c, uris.b, uris.a, uris.baseline], + }, + ]) { + const first = await listContributorInformation({ sortDirection: direction, limit: 2 }); + assert.equal(first.response.status, 200, JSON.stringify(first.body)); + assert.deepEqual(first.body.contributorInformation.map(({ uri }) => uri), firstUris); + assert.equal(typeof first.body.cursor, 'string'); + assert.ok(first.body.cursor.length > 0); + + const second = await listContributorInformation({ sortDirection: direction, limit: 2, cursor: first.body.cursor }); + assert.equal(second.response.status, 200, JSON.stringify(second.body)); + assert.deepEqual(second.body.contributorInformation.map(({ uri }) => uri), secondUris); + assert.equal(Object.hasOwn(second.body, 'cursor'), false); + assert.deepEqual( + [...first.body.contributorInformation, ...second.body.contributorInformation].map(({ uri }) => uri), + allUris, + ); + + const allRows = [...first.body.contributorInformation, ...second.body.contributorInformation]; + const missingSidecar = allRows.find(({ uri }) => uri === uris.b); + assert.ok(missingSidecar); + assert.equal(missingSidecar.author.profile, null); + assert.equal(missingSidecar.author.organization, null); + const baseline = allRows.find(({ uri }) => uri === uris.baseline); + assert.equal(baseline.author.profile.record.displayName, 'Activity fixture author'); + } +}); + +test('listContributorInformation exposes InvalidRequest as the pinned HappyView runtime error', async () => { + const { response, body } = await listContributorInformation({ limit: 101 }); + assert.equal(response.status, 500, JSON.stringify(body)); + assert.equal(body.error, 'script_error'); + assert.equal(body.errorType, 'runtime'); + assert.equal(body.method, endpoint); + assert.match(body.message, /InvalidRequest/); +}); diff --git a/api/tests/unit/fixtures/http-fixtures.test.js b/api/tests/unit/fixtures/http-fixtures.test.js index f1796b6..73f1b8b 100644 --- a/api/tests/unit/fixtures/http-fixtures.test.js +++ b/api/tests/unit/fixtures/http-fixtures.test.js @@ -10,44 +10,57 @@ import { actorFollowRecords, actorFollowProfileRecords, actorFollowOrganizationR import { activityFixtureRows } from '../../fixtures/activities.js'; const httpFixtureRoot = fileURLToPath(new URL('../../http/fixtures/', import.meta.url)); -const sharedRows = [ - ...locationRecords, ...profileRecords, ...organizationRecords, - ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, +const sharedSeedRows = [ + ...locationRecords, + ...profileRecords, + ...organizationRecords, + ...actorFollowRecords, + ...actorFollowProfileRecords, + ...actorFollowOrganizationRecords, ...activityFixtureRows, ]; +function compareEntryNames(left, right) { + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; +} + async function findFixtureModules(directory) { const entries = await readdir(directory, { withFileTypes: true }); - entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); - const fileGroups = await Promise.all(entries.map(async (entry) => { + entries.sort(compareEntryNames); + const groups = await Promise.all(entries.map(async (entry) => { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) return findFixtureModules(entryPath); - if (entry.isFile() && entry.name.endsWith('.fixture.js')) return [entryPath]; - return []; + return entry.isFile() && entry.name.endsWith('.fixture.js') ? [entryPath] : []; })); - return fileGroups.flat(); + return groups.flat(); } -test('shared and discovered HTTP fixture records have unique identities and CBOR-derived CIDs', async () => { - const records = sharedRows.map((row) => ({ source: 'shared fixtures', row })); +test('shared and recursively discovered HTTP seed rows have consistent URIs, unique identities, and matching CBOR CIDs', async () => { const fixtureModules = await findFixtureModules(httpFixtureRoot); - assert.ok(fixtureModules.length > 0, 'expected discovered HTTP fixture modules'); + assert.ok(fixtureModules.length > 0, 'expected recursively discovered HTTP fixture modules'); + const records = sharedSeedRows.map((row) => ({ source: 'shared fixtures', row })); for (const file of fixtureModules) { - const { seedRows } = await import(pathToFileURL(file).href); - assert.ok(Array.isArray(seedRows) && seedRows.length > 0, `${file} must export nonempty seedRows`); - records.push(...seedRows.map((row) => ({ source: file, row }))); + const fixture = await import(pathToFileURL(file).href); + assert.ok(Array.isArray(fixture.seedRows) && fixture.seedRows.length > 0, `${file} must export nonempty seedRows`); + records.push(...fixture.seedRows.map((row) => ({ source: file, row }))); } const uris = new Map(); const identities = new Map(); + const identityTuples = new Set(); for (const { source, row } of records) { const identity = `${row.did}/${row.collection}/${row.rkey}`; + const identityTuple = JSON.stringify([row.did, row.collection, row.rkey]); assert.equal(row.uri, `at://${identity}`, `${source} has an inconsistent URI for ${identity}`); assert.equal(uris.has(row.uri), false, `${row.uri} is seeded by both ${uris.get(row.uri)} and ${source}`); assert.equal(identities.has(identity), false, `${identity} is seeded by both ${identities.get(identity)} and ${source}`); + assert.equal(identityTuples.has(identityTuple), false, `Duplicate fixture identity: ${identityTuple}`); uris.set(row.uri, source); identities.set(identity, source); + identityTuples.add(identityTuple); assert.equal(CID.toString(await CID.create(0x71, encode(row.record))), row.cid, `${source} has a non-CBOR-derived CID for ${row.uri}`); } }); diff --git a/api/tests/unit/tooling/getContributorInformation.test.js b/api/tests/unit/tooling/getContributorInformation.test.js new file mode 100644 index 0000000..9975f30 --- /dev/null +++ b/api/tests/unit/tooling/getContributorInformation.test.js @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); + +test('getContributorInformation preserves the exact record and hydrates its publisher sidecars', async () => { + const sources = await Promise.all([ + read('lua/shared/query.lua'), + read('lua/shared/didValidation.lua'), + read('lua/shared/recordIdentifier.lua'), + read('lua/shared/contributorInformationValidation.lua'), + read('lua/shared/recordView.lua'), + read('lua/shared/actorView.lua'), + read('lua/src/getContributorInformation.lua'), + ]); + const program = `JSON_NULL = {} +json = { decode = function(value) + if value == "null" then return JSON_NULL end + error("unexpected JSON decode before test fixtures") +end } +${sources.join('\n\n')} + +local null = JSON_NULL +local decoded = { + original = { ["$type"] = "org.hypercerts.claim.contributorInformation", identifier = "manual:ada", displayName = "Ada", image = { ref = "blob" }, extension = "preserved" }, + profile = { ["$type"] = "app.certified.actor.profile", displayName = "Publisher" }, +} +json = { decode = function(value) + if value == "null" then return null end + assert(decoded[value], "unexpected JSON fixture: " .. tostring(value)) + return decoded[value] +end } + +local uri = "at://did:plc:publisher/org.hypercerts.claim.contributorInformation/tid" +local recordRow = { uri = uri, did = "did:plc:publisher", cid = "bafyreirecord", indexed_at = "2026-01-02T03:04:05Z", record = "original" } +local profileRow = { uri = "at://did:plc:publisher/app.certified.actor.profile/self", did = "did:plc:publisher", cid = "bafyreiprofile", indexed_at = nil, record = "profile" } +local calls = {} +local record_missing = false +local fail_profile_lookup = false +db = { + backend = function() return "postgres" end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if values[1] == "org.hypercerts.claim.contributorInformation" then + if record_missing then return {} end + return { recordRow } + end + if values[1] == "app.certified.actor.profile" then + if fail_profile_lookup then error("database unavailable") end + return { profileRow } + end + if values[1] == "app.certified.actor.organization" then return {} end + error("unexpected query collection: " .. tostring(values[1])) + end, +} +params = { uri = uri } +local result = handle() +local view = result.contributorInformation +assert(view.uri == uri and view.cid == "bafyreirecord" and view.did == "did:plc:publisher") +assert(view.record["$type"] == "org.hypercerts.claim.contributorInformation") +assert(view.record.identifier == "manual:ada" and view.record.displayName == "Ada") +assert(view.record.image.ref == "blob" and view.record.extension == "preserved") +assert(view.author.did == "did:plc:publisher") +assert(view.author.profile.record.displayName == "Publisher") +assert(view.author.profile.indexedAt == JSON_NULL, "a missing profile indexed_at must serialize as JSON null") +assert(view.author.organization == null) +assert(#calls == 3) +assert(calls[1].sql:find("collection = %$1 AND uri = %$2", 1) ~= nil) +assert(calls[1].values[1] == "org.hypercerts.claim.contributorInformation" and calls[1].values[2] == uri) +local bad_uri_ok, bad_uri_error = pcall(function() + params = { uri = "at://did:plc:publisher%GG/org.hypercerts.claim.contributorInformation/tid" } + return handle() +end) +assert(not bad_uri_ok and tostring(bad_uri_error):find("InvalidRequest:", 1, true) ~= nil) +assert(#calls == 3, "invalid DID authority must be rejected before querying") +local wrong_collection_ok, wrong_collection_error = pcall(function() + params = { uri = "at://did:plc:publisher/org.hypercerts.claim.activity/tid" } + return handle() +end) +assert(not wrong_collection_ok and tostring(wrong_collection_error) == "InvalidRequest: uri must be a full org.hypercerts.claim.contributorInformation AT-URI with a DID authority") +assert(#calls == 3, "a different collection must be rejected before querying") +local unknown_parameter_ok, unknown_parameter_error = pcall(function() + params = { uri = uri, extra = "unsupported" } + return handle() +end) +assert(not unknown_parameter_ok and tostring(unknown_parameter_error) == "InvalidRequest: unknown query parameter") +local repeated_uri_ok, repeated_uri_error = pcall(function() + params = { uri = { uri, uri } } + return handle() +end) +assert(not repeated_uri_ok and tostring(repeated_uri_error) == "InvalidRequest: uri must occur once") +assert(#calls == 3, "invalid query parameters must be rejected before querying") +recordRow.indexed_at = nil +params = { uri = uri } +local null_timestamp = handle().contributorInformation.indexedAt +assert(null_timestamp == JSON_NULL, "a missing indexed_at must serialize as JSON null") +record_missing = true +local missing_ok, missing_error = pcall(function() return handle() end) +assert(not missing_ok and tostring(missing_error):find("RecordNotFound:", 1, true) ~= nil) +record_missing = false +fail_profile_lookup = true +local lookup_ok, lookup_error = pcall(function() return handle() end) +assert(not lookup_ok and tostring(lookup_error):find("ContributorInformationQueryFailed:", 1, true) ~= nil) +`; + const result = spawnSync('lua', ['-e', program], { encoding: 'utf8' }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); diff --git a/api/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index de0f2eb..013cec9 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -174,6 +174,30 @@ if (hasModule('modules/actor-follow/manifest.json')) test('follow query Lexicons }); }); +test('contributor-information view is owned by getContributorInformation and keeps nullable indexedAt required', async () => { + const { lexicons, documents } = await validatePackageLexicons(); + const byId = new Map(documents.map((document) => [document.id, document])); + const shared = byId.get('org.hypercerts.api.defs'); + const getContributorInformation = byId.get('org.hypercerts.claim.getContributorInformation'); + const listContributorInformation = byId.get('org.hypercerts.claim.listContributorInformation'); + assert.ok(shared && getContributorInformation && listContributorInformation); + assert.equal(Object.hasOwn(shared.defs, 'contributorInformationView'), false); + assert.equal( + getContributorInformation.defs.output.properties.contributorInformation.ref, + 'lex:org.hypercerts.claim.getContributorInformation#contributorInformationView', + ); + assert.equal( + listContributorInformation.defs.output.properties.contributorInformation.items.ref, + 'lex:org.hypercerts.claim.getContributorInformation#contributorInformationView', + ); + + const view = lexicons.getDefOrThrow('org.hypercerts.claim.getContributorInformation#contributorInformationView'); + assert.ok(view.required.includes('indexedAt')); + assert.ok(view.nullable.includes('indexedAt')); + assert.equal(view.properties.author.ref, 'lex:org.hypercerts.api.defs#actorView'); + assert.equal(view.properties.record.ref, 'lex:org.hypercerts.claim.contributorInformation'); +}); + test('contribution query Lexicons expose exact lookup and publisher-based paging contracts', async () => { const { lexicons, documents } = await validatePackageLexicons(); const byId = new Map(documents.map((document) => [document.id, document])); diff --git a/api/tests/unit/tooling/listContributorInformation.test.js b/api/tests/unit/tooling/listContributorInformation.test.js new file mode 100644 index 0000000..a71f71b --- /dev/null +++ b/api/tests/unit/tooling/listContributorInformation.test.js @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const read = (relative) => readFile(path.resolve(root, relative), 'utf8'); + +test('listContributorInformation filters repeated authors and resumes in the selected order', async () => { + const sources = await Promise.all([ + read('lua/shared/query.lua'), + read('lua/shared/didValidation.lua'), + read('lua/shared/recordIdentifier.lua'), + read('lua/shared/contributorInformationValidation.lua'), + read('lua/shared/recordView.lua'), + read('lua/shared/actorView.lua'), + read('lua/shared/listValidation.lua'), + read('lua/shared/listQuery.lua'), + read('lua/src/listContributorInformation.lua'), + ]); + const program = `JSON_NULL = {} +JSON_VALUES = {} +json = { + decode = function(value) + if value == "null" then return JSON_NULL end + assert(JSON_VALUES[value], "unexpected JSON fixture: " .. tostring(value)) + return JSON_VALUES[value] + end, + encode = function(value) + assert(type(value) == "table" and value.v == 1, "unexpected JSON value to encode") + JSON_VALUES.cursor = value + return "cursor" + end, +} +${sources.join('\n\n')} + +JSON_VALUES.record1 = { ["$type"] = "org.hypercerts.claim.contributorInformation", identifier = "manual:ada", displayName = "Ada", image = { ref = "blob" } } +JSON_VALUES.record2 = { ["$type"] = "org.hypercerts.claim.contributorInformation", identifier = "manual:grace", displayName = "Grace" } +JSON_VALUES.profile = { ["$type"] = "app.certified.actor.profile", displayName = "Publisher" } +local first_uri = "at://did:plc:publisher/org.hypercerts.claim.contributorInformation/tid1" +local second_uri = "at://did:plc:publisher/org.hypercerts.claim.contributorInformation/tid2" +local first = { uri = first_uri, did = "did:plc:publisher", cid = "bafyreione", indexed_at = "2026-01-01T00:00:00Z", record = "record1", sort_timestamp = "2026-01-01T00:00:00.000000Z" } +local second = { uri = second_uri, did = "did:plc:publisher", cid = "bafyreitwo", indexed_at = "2026-01-02T00:00:00Z", record = "record2", sort_timestamp = "2026-01-02T00:00:00.000000Z" } +local profile = { uri = "at://did:plc:publisher/app.certified.actor.profile/self", did = "did:plc:publisher", cid = "bafyreiprofile", indexed_at = nil, record = "profile" } +local page = 0 +local record_queries = {} +db = { + backend = function() return "postgres" end, + raw = function(sql, values) + if values[1] == "org.hypercerts.claim.contributorInformation" then + page = page + 1 + record_queries[page] = { sql = sql, values = values } + if page == 1 then return { first, second } end + if page == 2 then return { second } end + return {} + end + if values[1] == "app.certified.actor.profile" then return { profile } end + if values[1] == "app.certified.actor.organization" then return {} end + error("unexpected query collection: " .. tostring(values[1])) + end, +} +toarray = function(values) return values end +params = { authors = { "did:plc:publisher", "did:plc:other" }, sortDirection = "asc", limit = "1" } +local first_page = handle() +assert(#first_page.contributorInformation == 1) +local first_view = first_page.contributorInformation[1] +assert(first_view.uri == first_uri and first_view.did == "did:plc:publisher") +assert(first_view.record.identifier == "manual:ada" and first_view.record.displayName == "Ada") +assert(first_view.author.did == "did:plc:publisher" and first_view.author.profile.record.displayName == "Publisher") +assert(first_view.author.profile.indexedAt == JSON_NULL, "a missing profile indexed_at must serialize as JSON null") +assert(first_view.author.organization == JSON_NULL) +assert(type(first_page.cursor) == "string" and #first_page.cursor > 0) +local first_query = record_queries[1] +assert(first_query.values[1] == "org.hypercerts.claim.contributorInformation") +assert(first_query.values[2] == "did:plc:publisher" and first_query.values[3] == "did:plc:other") +assert(first_query.values[4] == 2) +assert(first_query.sql:find("contributor.did IN ($2, $3)", 1, true) ~= nil) +assert(first_query.sql:find("ORDER BY sorted.sort_at ASC, contributor.uri ASC", 1, true) ~= nil) +assert(first_query.sql:find("LIMIT $4", 1, true) ~= nil) + +params = { authors = { "did:plc:publisher", "did:plc:other" }, sortDirection = "asc", limit = "1", cursor = first_page.cursor } +local second_page = handle() +assert(#second_page.contributorInformation == 1 and second_page.contributorInformation[1].uri == second_uri, "second page returned " .. tostring(#second_page.contributorInformation) .. " row(s): " .. tostring(second_page.contributorInformation[1] and second_page.contributorInformation[1].uri)) +assert(second_page.cursor == nil) +local second_query = record_queries[2] +assert(second_query.values[4] == "2026-01-01T00:00:00.000000Z" and second_query.values[5] == first_uri) +assert(second_query.values[6] == 2) +assert(second_query.sql:find("(sorted.sort_at, contributor.uri) > (($4)::timestamptz, $5)", 1, true) ~= nil) +assert(first_query.sql:find("COALESCE(contributor.indexed_at::timestamptz, contributor.created_at::timestamptz)", 1, true) ~= nil) +local completed_pages = page +params = { authors = { "did:plc:publisher%GG" } } +local bad_author_ok, bad_author_error = pcall(function() return handle() end) +assert(not bad_author_ok and tostring(bad_author_error):find("InvalidRequest:", 1, true) ~= nil) +assert(page == completed_pages, "invalid author DID must be rejected before querying") +params = { authors = { "did:plc:publisher%2Fid" } } +local escaped_author_result = handle() +assert(#escaped_author_result.contributorInformation == 0) +assert(page == completed_pages + 1 and record_queries[page].values[2] == "did:plc:publisher%2Fid", + "a well-formed percent escape in a DID must remain accepted") +completed_pages = page +params = { limit = "101" } +local invalid_limit_ok, invalid_limit_error = pcall(function() return handle() end) +assert(not invalid_limit_ok and tostring(invalid_limit_error) == "InvalidRequest: limit must be an integer from 1 through 100") +params = { limit = { "1", "2" } } +local repeated_limit_ok, repeated_limit_error = pcall(function() return handle() end) +assert(not repeated_limit_ok and tostring(repeated_limit_error) == "InvalidRequest: limit must occur once") +assert(page == completed_pages, "invalid list limits must be rejected before querying") +JSON_VALUES.cursor = { v = 1, d = "asc", t = "2026-01-01T00:00:00Z", u = "at://did:plc:publisher/org.hypercerts.claim.activity/tid1" } +params = { sortDirection = "asc", limit = "1", cursor = "637572736f72" } +local wrong_collection_cursor_ok, wrong_collection_cursor_error = pcall(function() return handle() end) +assert(not wrong_collection_cursor_ok and tostring(wrong_collection_cursor_error) == "InvalidRequest: cursor is malformed") +assert(page == completed_pages, "a cursor for another collection must be rejected before querying") +JSON_VALUES.cursor = { v = 1, d = "asc", t = "0000-01-01T00:00:00Z", u = first_uri } +params = { sortDirection = "asc", limit = "1", cursor = "637572736f72" } +local bad_cursor_ok, bad_cursor_error = pcall(function() return handle() end) +assert(not bad_cursor_ok and tostring(bad_cursor_error) == "InvalidRequest: cursor is malformed") +assert(page == completed_pages, "year-zero cursor must be rejected before PostgreSQL sees it") +local oversized_decoded_cursor = string.rep(" ", 4097) +JSON_VALUES[oversized_decoded_cursor] = { v = 1, d = "asc", t = "2026-01-01T00:00:00Z", u = first_uri } +params = { sortDirection = "asc", limit = "1", cursor = string.rep("20", 4097) } +local oversized_cursor_ok, oversized_cursor_error = pcall(function() return handle() end) +assert(not oversized_cursor_ok and tostring(oversized_cursor_error) == "InvalidRequest: cursor is malformed") +assert(page == completed_pages, "a cursor larger than 8192 hex characters must be rejected before decoding or querying") +`; + const result = spawnSync('lua', ['-e', program], { encoding: 'utf8' }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); diff --git a/api/tests/unit/tooling/seed.test.js b/api/tests/unit/tooling/seed.test.js index 2d1a171..7ab5ccc 100644 --- a/api/tests/unit/tooling/seed.test.js +++ b/api/tests/unit/tooling/seed.test.js @@ -101,7 +101,7 @@ async function withFakeExecutables(run) { const selected = path.join(root, 'selected'); const competing = path.join(root, 'psql'); try { - await writeFile(selected, '#!/bin/sh\nprintf "selected executable\\n" >&2\n', { mode: 0o700 }); + await writeFile(selected, '#!/bin/sh\nwhile IFS= read -r line; do :; done\nprintf "selected executable\\n" >&2\n', { mode: 0o700 }); await writeFile(competing, '#!/bin/sh\nprintf "PATH executable\\n" >&2\n', { mode: 0o700 }); return await run({ selected, competing, root }); } finally {