diff --git a/.changeset/workscope-tag-sort-fallback-fix.md b/.changeset/workscope-tag-sort-fallback-fix.md new file mode 100644 index 0000000..168ed6d --- /dev/null +++ b/.changeset/workscope-tag-sort-fallback-fix.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Work-scope tag listing now falls back to valid index or row timestamps when a record's `createdAt` cannot be used for sorting. diff --git a/.changeset/workscope-tag-view-owner.md b/.changeset/workscope-tag-view-owner.md new file mode 100644 index 0000000..9aa02ae --- /dev/null +++ b/.changeset/workscope-tag-view-owner.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Work-scope tag query schemas now define their result view alongside the exact-record lookup; the returned JSON is unchanged. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d62b9a4..2523787 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, collection, funding, badge-definition, badge-query, acknowledgement, profile, organization, context attachment and evaluation, vocabulary-tag, location, graph, and contribution 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, 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. +- 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. - Profile and organization queries across all four endpoints for each record type, including batch null results, profile-sidecar hydration, filters, `createdAt`/URI pagination ties, and named errors. @@ -44,7 +45,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, 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, 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 8228f16..a59dc98 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hypercerts API workspace -This repository contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, public badge query endpoints, and the vocabulary-tag query capability. 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, 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: @@ -16,4 +16,29 @@ pnpm check pnpm build ``` -These checks do not deploy or contact a HappyView instance. See [CONTRIBUTING.md](CONTRIBUTING.md) for local development and HTTP test guidance, and [api/README.md](api/README.md) for API bundle, badge-query, and release installation details. `LICENSE.md` retains the MIT notice. +`pnpm check` validates generated handlers, lint, types, and unit tests. `pnpm build` refreshes declared Lua handler bundles. These checks do not deploy or contact a HappyView instance. `pnpm install:api` sends admin requests and requires an explicitly approved target and token. See [CONTRIBUTING.md](CONTRIBUTING.md) for local development and HTTP test guidance, and [api/README.md](api/README.md) for API bundle, badge-query, and release installation details. + +## Installed HTTP endpoint inventory + +`pnpm test:http` runs installed XRPC handlers against a task-owned disposable local HappyView and PostgreSQL project. It covers: + +| Capability | XRPC endpoints | HTTP contracts | +| --- | --- | --- | +| 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 | +| 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 | +| Acknowledgements | `org.hypercerts.context.getAcknowledgement`, `org.hypercerts.context.listAcknowledgements` | Exact-record retrieval, publisher/subject filters, hydrated or absent sidecars, tied pagination, and named errors | +| Actor profiles | `app.certified.actor.getProfile`, `app.certified.actor.getProfiles`, `app.certified.actor.listProfiles`, `app.certified.actor.searchProfiles` | Single and batch retrieval, batch null results, profile-sidecar hydration, filters, `createdAt`/URI pagination ties, and named errors | +| Actor organizations | `app.certified.actor.getOrganization`, `app.certified.actor.getOrganizations`, `app.certified.actor.listOrganizations`, `app.certified.actor.searchOrganizations` | Single and batch retrieval, batch null results, profile-sidecar hydration, filters, `createdAt`/URI pagination ties, and named errors | +| Activity | `org.hypercerts.claim.getActivity`, `org.hypercerts.claim.listActivities`, `org.hypercerts.claim.searchActivities` | Contributor-sidecar hydration, author/organization/contributor/URI filters, tied timestamp pagination, and literal wildcard search | +| Collections | `org.hypercerts.collection.getCollection`, `org.hypercerts.collection.listCollections`, `org.hypercerts.collection.searchCollections`, `org.hypercerts.collection.listCollectionItems` | CBOR-derived CIDs, location/tag projections, author, organization, item and tag filters, title/shortDescription search, tied pagination, and source-order item pagination with exact-version resolution | +| Context attachments and evaluations | `org.hypercerts.context.getAttachment`, `org.hypercerts.context.listAttachments`, `org.hypercerts.context.getEvaluation`, `org.hypercerts.context.listEvaluations` | Publisher/evaluator sidecars, list filters, pagination across tied `createdAt` values, and named runtime errors | +| Actor follows | `app.certified.graph.getFollow`, `app.certified.graph.listActorFollowers`, `app.certified.graph.listActorFollowing` | Actor lookup and lists, tied-key pagination, and nullable profile/organization sidecars | +| Entity follows | `app.certified.graph.getEntityFollow`, `app.certified.graph.listEntityFollowers`, `app.certified.graph.listEntityFollowing` | Entity lookup and lists, tied-key pagination, nullable sidecars, and entity target resolution | +| Recent follows | `app.certified.graph.listRecentFollows` | Global recent-follows `before` filter | +| Locations | `app.certified.location.getLocation`, `app.certified.location.listLocations` | Nullable sidecars, repeated author/URI/location-type filters, unsupported-search errors, tied pagination in both directions, malformed/absent `createdAt` handling, and named errors | + +The HTTP runner uses cached, digest-pinned images only; it does not pull images. `LICENSE.md` retains the upstream MIT notice. diff --git a/api/README.md b/api/README.md index efac5c2..c7e486b 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,19 @@ # HappyView API toolkit foundation -This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, and offline fixture/test utilities. The `modules/shared/manifest.json` contains record schemas and query Lexicons used as shared view types; it contains no Lua endpoint scripts. A foundation-only install therefore does not implement those queries. Capability modules listed in the root manifest register their endpoint Lexicons and handlers separately. The `badge-queries` module installs five public badge query Lexicons and their Lua handlers. +This package owns the shared API installer and build tooling, pinned upstream Lexicons, common view definitions, reusable Lua projections, 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. + +## Work-scope tag queries + +Both queries are public and require no authentication. Lookup uses the exact record AT-URI and returns `RecordNotFound` when that URI is not indexed: + +```text +/xrpc/org.hypercerts.workscope.getWorkscopeTag?uri=at%3A%2F%2Fdid%3Aweb%3Apublisher.example%2Forg.hypercerts.workscope.tag%2F3jzfcijpj2z2a +``` + +Listing accepts repeated, unbracketed `authors` DID parameters with OR matching (up to 100 values), `sortDirection=asc|desc` (default `desc`), and `limit=1..100` (default `25`). Results use stable timestamp-and-URI order: a valid zoned record `createdAt`, then the index timestamp, then the row creation timestamp. Pass the opaque response cursor unchanged with the same filters and direction to fetch the next page. Each result includes the unchanged record and a hydrated publisher actor; a missing `indexedAt`, profile, or organization sidecar is `null`, while query/hydration failures are returned as errors. Parent and other record references are not expanded. + +The handlers require the PostgreSQL HappyView records backend. The shared module registers the tag record Lexicon for backfill; the workscope-tags module registers both query Lexicons and generated Lua scripts. `pnpm build` refreshes the checked-in handler bundles. Installing assets with `pnpm install:api` contacts a HappyView service; use it only with an explicitly approved target and token. + ## Acknowledgement queries @@ -22,7 +35,9 @@ The current design requires `indexedAt` to be present but nullable; the older `c The shared module registers the contribution record Lexicon with backfill enabled; the contribution module registers both query Lexicons and Lua handlers. Installing the bundle through `pnpm install:api` writes these declarations and scripts to the configured HappyView instance, so use the existing approved-target and admin-token procedure before running it. -For local development checks and HTTP runtime test requirements, see [CONTRIBUTING.md](../CONTRIBUTING.md). +## Local validation and HTTP coverage + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for local validation commands, HTTP test prerequisites and safety boundaries, endpoint coverage, and task-owned resource cleanup. ## Install a released API bundle diff --git a/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json new file mode 100644 index 0000000..5ddb1a7 --- /dev/null +++ b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json @@ -0,0 +1,63 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.workscope.getWorkscopeTag", + "defs": { + "main": { + "type": "query", + "description": "Returns an indexed work-scope tag and its hydrated publisher for an exact AT-URI; authentication is not required.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "Full work-scope-tag 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 work-scope-tag AT-URI." + }, + { + "name": "RecordNotFound", + "description": "No indexed work-scope tag exists at this AT-URI." + }, + { + "name": "WorkscopeTagQueryFailed", + "description": "The record lookup or publisher hydration failed." + } + ] + }, + "workscopeTagView": { + "type": "object", + "description": "Work-scope tag view with its hydrated publisher, unchanged record, and nullable index timestamp.", + "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.workscope.tag" } + } + }, + "output": { + "type": "object", + "required": ["workscopeTag"], + "properties": { + "workscopeTag": { + "type": "ref", + "ref": "#workscopeTagView" + } + } + } + } +} diff --git a/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json b/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json new file mode 100644 index 0000000..5543888 --- /dev/null +++ b/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json @@ -0,0 +1,74 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.workscope.listWorkscopeTags", + "defs": { + "main": { + "type": "query", + "description": "Lists indexed work-scope tags by publisher DID with stable timestamp-and-URI ordering; authentication is not required.", + "parameters": { + "type": "params", + "properties": { + "authors": { + "type": "array", + "maxLength": 100, + "description": "Publisher repository DIDs; repeat this key for OR matching, up to 100 values.", + "items": { + "type": "string", + "format": "did" + } + }, + "sortDirection": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc", + "description": "Sort direction for timestamp-and-URI ordering; defaults to desc." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Maximum page size from 1 to 100; defaults to 25." + }, + "cursor": { + "type": "string", + "maxLength": 8192, + "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": "A query parameter is invalid." + }, + { + "name": "WorkscopeTagQueryFailed", + "description": "The record query or publisher hydration failed." + } + ] + }, + "output": { + "type": "object", + "required": ["workscopeTags"], + "properties": { + "workscopeTags": { + "type": "array", + "maxLength": 100, + "items": { + "type": "ref", + "ref": "org.hypercerts.workscope.getWorkscopeTag#workscopeTagView" + } + }, + "cursor": { + "type": "string", + "description": "Opaque cursor for the next page; omitted when no next page exists." + } + } + } + } +} diff --git a/api/lua/endpoints/getWorkscopeTag.lua b/api/lua/endpoints/getWorkscopeTag.lua new file mode 100644 index 0000000..76b50ac --- /dev/null +++ b/api/lua/endpoints/getWorkscopeTag.lua @@ -0,0 +1,147 @@ +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 + +-- Workscope sidecars omit missing indexedAt; the work-scope tag view encodes it as JSON null separately. +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local 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 WORKSCOPE_TAG = "org.hypercerts.workscope.tag" +local WORKSCOPE_TAG_NULL = json.decode("null") + +local function workscope_tag_valid_did(value) + if not valid_did(value) then return false end + local offset = 1 + while true do + local percent = value:find("%", offset, true) + if not percent then return true end + local escape = value:sub(percent + 1, percent + 2) + if not escape:match("^[0-9A-Fa-f][0-9A-Fa-f]$") then return false end + offset = percent + 3 + end +end + +local function workscope_tag_valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) + return valid and workscope_tag_valid_did(authority), collection +end + +local function workscope_tag_query(sql, values) + if db.backend() ~= "postgres" then + error("WorkscopeTagQueryFailed: workscope-tag API requires PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("WorkscopeTagQueryFailed: work-scope tag query failed", 0) + end + return result +end + +local function workscope_tag_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and WORKSCOPE_TAG_NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = json.decode(row.record), + } +end + +local function workscope_tag_get(uri) + keys_only(params, { uri = true }) + local valid, collection = workscope_tag_valid_record_uri(uri) + if not uri or not valid or collection ~= WORKSCOPE_TAG then + invalid("uri must be a full org.hypercerts.workscope.tag AT-URI with a DID authority") + end + + local rows = workscope_tag_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", + { WORKSCOPE_TAG, uri }) + if #rows == 0 then error("RecordNotFound: work-scope tag is not indexed", 0) end + + local view = workscope_tag_view(rows[1]) + hydrate_actor_views({ view.author }, workscope_tag_query) + return { workscopeTag = view } +end + +function handle() + return workscope_tag_get(scalar(params, "uri")) +end diff --git a/api/lua/endpoints/listWorkscopeTags.lua b/api/lua/endpoints/listWorkscopeTags.lua new file mode 100644 index 0000000..642d014 --- /dev/null +++ b/api/lua/endpoints/listWorkscopeTags.lua @@ -0,0 +1,302 @@ +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 + +-- Workscope sidecars omit missing indexedAt; the work-scope tag view encodes it as JSON null separately. +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local 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 WORKSCOPE_TAG = "org.hypercerts.workscope.tag" +local WORKSCOPE_TAG_NULL = json.decode("null") + +local function workscope_tag_valid_did(value) + if not valid_did(value) then return false end + local offset = 1 + while true do + local percent = value:find("%", offset, true) + if not percent then return true end + local escape = value:sub(percent + 1, percent + 2) + if not escape:match("^[0-9A-Fa-f][0-9A-Fa-f]$") then return false end + offset = percent + 3 + end +end + +local function workscope_tag_valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) + return valid and workscope_tag_valid_did(authority), collection +end + +local function workscope_tag_query(sql, values) + if db.backend() ~= "postgres" then + error("WorkscopeTagQueryFailed: workscope-tag API requires PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("WorkscopeTagQueryFailed: work-scope tag query failed", 0) + end + return result +end + +local function workscope_tag_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and WORKSCOPE_TAG_NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = json.decode(row.record), + } +end + +local function workscope_tag_array(key) + 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 _, did in ipairs(supplied) do + if not workscope_tag_valid_did(did) then + invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") + end + if not seen[did] then + seen[did] = true + unique[#unique + 1] = did + end + end + return unique +end + +local function workscope_tag_decode_cursor(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 + local valid, collection = workscope_tag_valid_record_uri(value.u) + if not valid_datetime(value.t) or value.t:sub(1, 4) == "0000" or not valid or collection ~= WORKSCOPE_TAG then + invalid("cursor is malformed") + end + return value +end + +local function workscope_tag_list() + keys_only(params, { authors = true, sortDirection = true, limit = true, cursor = true }) + local authors = workscope_tag_array("authors") + local direction = parse_sort_direction(params) + local limit = parse_list_limit(params) + local cursor = workscope_tag_decode_cursor(scalar(params, "cursor"), direction) + + local where, values = { "workscope_tag.collection = $1" }, { WORKSCOPE_TAG } + if authors then + if #authors == 0 then + where[#where + 1] = "FALSE" + else + local placeholders = {} + for _, did in ipairs(authors) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = "workscope_tag.did IN (" .. table.concat(placeholders, ", ") .. ")" + end + end + 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, workscope_tag.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + local ordering = direction == "asc" and "ASC" or "DESC" + values[#values + 1] = limit + 1 + local created_at = "workscope_tag.record::jsonb->>'createdAt'" + local valid_zoned_created_at = created_at .. " ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' AND " .. + created_at .. " !~ '-00:00$' AND pg_input_is_valid(" .. created_at .. ", 'timestamp with time zone')" + local indexed_at = "workscope_tag.indexed_at" + local row_created_at = "workscope_tag.created_at" + local function timestamp_fallback(column) + return "CASE WHEN pg_input_is_valid(" .. column .. ", 'timestamp with time zone') THEN " .. column .. "::timestamptz END" + end + local sort_at = "COALESCE(CASE WHEN " .. valid_zoned_created_at .. " THEN (" .. created_at .. + ")::timestamptz END, " .. timestamp_fallback(indexed_at) .. ", " .. timestamp_fallback(row_created_at) .. ")" + local sql = "SELECT workscope_tag.uri, workscope_tag.did, workscope_tag.cid, " .. + "workscope_tag.indexed_at::text AS indexed_at, workscope_tag.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 workscope_tag CROSS JOIN LATERAL (SELECT " .. sort_at .. + " AS sort_at) AS sorted WHERE " .. table.concat(where, " AND ") .. + " ORDER BY sorted.sort_at " .. ordering .. ", workscope_tag.uri " .. ordering .. " LIMIT $" .. #values + local rows = workscope_tag_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views = {} + local authors_to_hydrate = {} + for _, row in ipairs(rows) do + local view = workscope_tag_view(row) + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + hydrate_actor_views(authors_to_hydrate, workscope_tag_query) + + 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() + local workscope_tags, cursor = workscope_tag_list() + local response = { workscopeTags = toarray(workscope_tags) } + if cursor then response.cursor = cursor end + return response +end diff --git a/api/lua/shared/workscopeRecordView.lua b/api/lua/shared/workscopeRecordView.lua new file mode 100644 index 0000000..6ed2623 --- /dev/null +++ b/api/lua/shared/workscopeRecordView.lua @@ -0,0 +1,12 @@ +-- Workscope sidecars omit missing indexedAt; the work-scope tag view encodes it as JSON null separately. +local NULL = json.decode("null") + +local function record_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end diff --git a/api/lua/shared/workscopeTag.lua b/api/lua/shared/workscopeTag.lua new file mode 100644 index 0000000..7fc1218 --- /dev/null +++ b/api/lua/shared/workscopeTag.lua @@ -0,0 +1,41 @@ +local WORKSCOPE_TAG = "org.hypercerts.workscope.tag" +local WORKSCOPE_TAG_NULL = json.decode("null") + +local function workscope_tag_valid_did(value) + if not valid_did(value) then return false end + local offset = 1 + while true do + local percent = value:find("%", offset, true) + if not percent then return true end + local escape = value:sub(percent + 1, percent + 2) + if not escape:match("^[0-9A-Fa-f][0-9A-Fa-f]$") then return false end + offset = percent + 3 + end +end + +local function workscope_tag_valid_record_uri(value) + local valid, collection, authority = valid_record_uri(value) + return valid and workscope_tag_valid_did(authority), collection +end + +local function workscope_tag_query(sql, values) + if db.backend() ~= "postgres" then + error("WorkscopeTagQueryFailed: workscope-tag API requires PostgreSQL", 0) + end + local ok, result = pcall(db.raw, sql, values) + if not ok or type(result) ~= "table" then + error("WorkscopeTagQueryFailed: work-scope tag query failed", 0) + end + return result +end + +local function workscope_tag_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at == nil and WORKSCOPE_TAG_NULL or row.indexed_at, + did = row.did, + author = { did = row.did }, + record = json.decode(row.record), + } +end diff --git a/api/lua/shared/workscopeTagList.lua b/api/lua/shared/workscopeTagList.lua new file mode 100644 index 0000000..d690bdd --- /dev/null +++ b/api/lua/shared/workscopeTagList.lua @@ -0,0 +1,127 @@ +local function workscope_tag_array(key) + 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 _, did in ipairs(supplied) do + if not workscope_tag_valid_did(did) then + invalid("each " .. key .. " value must be a valid DID; resolve handles to DIDs first") + end + if not seen[did] then + seen[did] = true + unique[#unique + 1] = did + end + end + return unique +end + +local function workscope_tag_decode_cursor(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 + local valid, collection = workscope_tag_valid_record_uri(value.u) + if not valid_datetime(value.t) or value.t:sub(1, 4) == "0000" or not valid or collection ~= WORKSCOPE_TAG then + invalid("cursor is malformed") + end + return value +end + +local function workscope_tag_list() + keys_only(params, { authors = true, sortDirection = true, limit = true, cursor = true }) + local authors = workscope_tag_array("authors") + local direction = parse_sort_direction(params) + local limit = parse_list_limit(params) + local cursor = workscope_tag_decode_cursor(scalar(params, "cursor"), direction) + + local where, values = { "workscope_tag.collection = $1" }, { WORKSCOPE_TAG } + if authors then + if #authors == 0 then + where[#where + 1] = "FALSE" + else + local placeholders = {} + for _, did in ipairs(authors) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = "workscope_tag.did IN (" .. table.concat(placeholders, ", ") .. ")" + end + end + 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, workscope_tag.uri) " .. operator .. + " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + local ordering = direction == "asc" and "ASC" or "DESC" + values[#values + 1] = limit + 1 + local created_at = "workscope_tag.record::jsonb->>'createdAt'" + local valid_zoned_created_at = created_at .. " ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' AND " .. + created_at .. " !~ '-00:00$' AND pg_input_is_valid(" .. created_at .. ", 'timestamp with time zone')" + local indexed_at = "workscope_tag.indexed_at" + local row_created_at = "workscope_tag.created_at" + local function timestamp_fallback(column) + return "CASE WHEN pg_input_is_valid(" .. column .. ", 'timestamp with time zone') THEN " .. column .. "::timestamptz END" + end + local sort_at = "COALESCE(CASE WHEN " .. valid_zoned_created_at .. " THEN (" .. created_at .. + ")::timestamptz END, " .. timestamp_fallback(indexed_at) .. ", " .. timestamp_fallback(row_created_at) .. ")" + local sql = "SELECT workscope_tag.uri, workscope_tag.did, workscope_tag.cid, " .. + "workscope_tag.indexed_at::text AS indexed_at, workscope_tag.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 workscope_tag CROSS JOIN LATERAL (SELECT " .. sort_at .. + " AS sort_at) AS sorted WHERE " .. table.concat(where, " AND ") .. + " ORDER BY sorted.sort_at " .. ordering .. ", workscope_tag.uri " .. ordering .. " LIMIT $" .. #values + local rows = workscope_tag_query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local views = {} + local authors_to_hydrate = {} + for _, row in ipairs(rows) do + local view = workscope_tag_view(row) + views[#views + 1] = view + authors_to_hydrate[#authors_to_hydrate + 1] = view.author + end + hydrate_actor_views(authors_to_hydrate, workscope_tag_query) + + 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 diff --git a/api/lua/src/getWorkscopeTag.lua b/api/lua/src/getWorkscopeTag.lua new file mode 100644 index 0000000..00ff767 --- /dev/null +++ b/api/lua/src/getWorkscopeTag.lua @@ -0,0 +1,21 @@ +local function workscope_tag_get(uri) + keys_only(params, { uri = true }) + local valid, collection = workscope_tag_valid_record_uri(uri) + if not uri or not valid or collection ~= WORKSCOPE_TAG then + invalid("uri must be a full org.hypercerts.workscope.tag AT-URI with a DID authority") + end + + local rows = workscope_tag_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", + { WORKSCOPE_TAG, uri }) + if #rows == 0 then error("RecordNotFound: work-scope tag is not indexed", 0) end + + local view = workscope_tag_view(rows[1]) + hydrate_actor_views({ view.author }, workscope_tag_query) + return { workscopeTag = view } +end + +function handle() + return workscope_tag_get(scalar(params, "uri")) +end diff --git a/api/lua/src/listWorkscopeTags.lua b/api/lua/src/listWorkscopeTags.lua new file mode 100644 index 0000000..463b415 --- /dev/null +++ b/api/lua/src/listWorkscopeTags.lua @@ -0,0 +1,6 @@ +function handle() + local workscope_tags, cursor = workscope_tag_list() + local response = { workscopeTags = toarray(workscope_tags) } + if cursor then response.cursor = cursor end + return response +end diff --git a/api/manifest.json b/api/manifest.json index 9ecbcc9..3ccb4a4 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -1,82 +1,210 @@ { "name": "hypercerts-api-foundation", + "collection": "app.certified.location", + "targetHappyViewRevision": "a2f347e605d7cd9c11669b320e43c10dbfe982e3", + "runtimeCompatibility": "compatible-at-target-revision", + "status": "handlers-implemented; location-and-actor-follow-runtime-validated-at-target-revision; other-query-runtime-validation-pending", + "handlerStatus": { + "getProfile": "implemented", + "getProfiles": "implemented", + "searchProfiles": "implemented", + "listProfiles": "implemented", + "getOrganization": "implemented", + "getOrganizations": "implemented", + "listOrganizations": "implemented", + "searchOrganizations": "implemented", + "getEvaluation": "implemented", + "listEvaluations": "implemented", + "getAcknowledgement": "implemented", + "listAcknowledgements": "implemented", + "getAttachment": "implemented", + "listAttachments": "implemented", + "getActivity": "implemented", + "listActivities": "implemented", + "searchActivities": "implemented", + "getCollection": "implemented", + "listCollections": "implemented", + "searchCollections": "implemented", + "listCollectionItems": "implemented", + "getFollow": "implemented", + "listActorFollowers": "implemented", + "listActorFollowing": "implemented", + "getEntityFollow": "implemented", + "listEntityFollowers": "implemented", + "listEntityFollowing": "implemented", + "listRecentFollows": "implemented", + "getReceipt": "implemented", + "listReceipts": "implemented", + "getBadgeDefinition": "implemented", + "listBadgeDefinitions": "implemented", + "getWorkscopeTag": "implemented", + "listWorkscopeTags": "implemented", + "getContribution": "implemented", + "listContributions": "implemented", + "getVocabTag": "implemented", + "listVocabTags": "implemented", + "getLocation": "implemented", + "listLocations": "implemented", + "searchBadgeDefinitions": "implemented", + "getBadgeAward": "implemented", + "listBadgeAwards": "implemented", + "getBadgeResponse": "implemented", + "listBadgeResponses": "implemented" + }, + "authentication": { + "decision": "public", + "unresolved": false, + "note": "Badge query endpoints expose public indexed record reads; all query endpoints expose public record reads and require no caller authentication." + }, "validationLexicons": [ { - "id": "app.certified.badge.award", - "packagePath": "lexicons/app/certified/badge/award.json" + "id": "app.certified.location", + "packagePath": "lexicons/app/certified/location.json" }, { - "id": "app.certified.badge.response", - "packagePath": "lexicons/app/certified/badge/response.json" + "id": "org.hypercerts.api.defs", + "path": "lexicons/org.hypercerts.api.defs.json" }, { - "id": "app.certified.badge.searchBadgeDefinitions", - "path": "lexicons/app.certified.badge.searchBadgeDefinitions.json" + "id": "app.certified.graph.follow", + "packagePath": "lexicons/app/certified/graph/follow.json" }, { - "id": "app.certified.badge.getBadgeAward", - "path": "lexicons/app.certified.badge.getBadgeAward.json" + "id": "app.certified.graph.entityFollow", + "packagePath": "lexicons/app/certified/graph/entityFollow.json" }, { - "id": "app.certified.badge.listBadgeAwards", - "path": "lexicons/app.certified.badge.listBadgeAwards.json" + "id": "app.certified.graph.getEntityFollow", + "path": "lexicons/app.certified.graph.getEntityFollow.json" }, { - "id": "app.certified.badge.getBadgeResponse", - "path": "lexicons/app.certified.badge.getBadgeResponse.json" + "id": "app.certified.graph.listEntityFollowers", + "path": "lexicons/app.certified.graph.listEntityFollowers.json" }, { - "id": "app.certified.badge.listBadgeResponses", - "path": "lexicons/app.certified.badge.listBadgeResponses.json" + "id": "app.certified.graph.listEntityFollowing", + "path": "lexicons/app.certified.graph.listEntityFollowing.json" }, { - "id": "org.hypercerts.claim.contribution", - "packagePath": "lexicons/org/hypercerts/claim/contribution.json" + "id": "app.certified.graph.getFollow", + "path": "lexicons/app.certified.graph.getFollow.json" }, { - "id": "org.hypercerts.claim.getContribution", - "path": "lexicons/org.hypercerts.claim.getContribution.json" + "id": "app.certified.graph.listActorFollowers", + "path": "lexicons/app.certified.graph.listActorFollowers.json" }, { - "id": "org.hypercerts.claim.listContributions", - "path": "lexicons/org.hypercerts.claim.listContributions.json" + "id": "app.certified.graph.listActorFollowing", + "path": "lexicons/app.certified.graph.listActorFollowing.json" }, { - "id": "app.certified.location", - "packagePath": "lexicons/app/certified/location.json" + "id": "app.certified.graph.listRecentFollows", + "path": "lexicons/app.certified.graph.listRecentFollows.json" }, { - "id": "org.hypercerts.api.defs", - "path": "lexicons/org.hypercerts.api.defs.json" + "id": "app.certified.actor.profile", + "packagePath": "lexicons/app/certified/actor/profile.json" }, { - "id": "app.certified.graph.follow", - "packagePath": "lexicons/app/certified/graph/follow.json" + "id": "app.certified.actor.getProfile", + "path": "lexicons/app.certified.actor.getProfile.json" }, { - "id": "app.certified.graph.entityFollow", - "packagePath": "lexicons/app/certified/graph/entityFollow.json" + "id": "app.certified.actor.getProfiles", + "path": "lexicons/app.certified.actor.getProfiles.json" }, { - "id": "app.certified.actor.profile", - "packagePath": "lexicons/app/certified/actor/profile.json" + "id": "app.certified.actor.listProfiles", + "path": "lexicons/app.certified.actor.listProfiles.json" + }, + { + "id": "app.certified.actor.searchProfiles", + "path": "lexicons/app.certified.actor.searchProfiles.json" + }, + { + "id": "app.certified.actor.getOrganization", + "path": "lexicons/app.certified.actor.getOrganization.json" + }, + { + "id": "app.certified.actor.getOrganizations", + "path": "lexicons/app.certified.actor.getOrganizations.json" + }, + { + "id": "app.certified.actor.listOrganizations", + "path": "lexicons/app.certified.actor.listOrganizations.json" + }, + { + "id": "app.certified.actor.searchOrganizations", + "path": "lexicons/app.certified.actor.searchOrganizations.json" }, { "id": "org.hypercerts.claim.getActivity", "path": "lexicons/org.hypercerts.claim.getActivity.json" }, + { + "id": "org.hypercerts.workscope.getWorkscopeTag", + "path": "lexicons/org.hypercerts.workscope.getWorkscopeTag.json" + }, + { + "id": "org.hypercerts.workscope.listWorkscopeTags", + "path": "lexicons/org.hypercerts.workscope.listWorkscopeTags.json" + }, { "id": "org.hypercerts.context.evaluation", "packagePath": "lexicons/org/hypercerts/context/evaluation.json" }, + { + "id": "org.hypercerts.context.getEvaluation", + "path": "lexicons/org.hypercerts.context.getEvaluation.json" + }, + { + "id": "org.hypercerts.context.listEvaluations", + "path": "lexicons/org.hypercerts.context.listEvaluations.json" + }, + { + "id": "org.hypercerts.context.acknowledgement", + "packagePath": "lexicons/org/hypercerts/context/acknowledgement.json" + }, + { + "id": "org.hypercerts.context.getAcknowledgement", + "path": "lexicons/org.hypercerts.context.getAcknowledgement.json" + }, + { + "id": "org.hypercerts.context.listAcknowledgements", + "path": "lexicons/org.hypercerts.context.listAcknowledgements.json" + }, + { + "id": "org.hypercerts.claim.listActivities", + "path": "lexicons/org.hypercerts.claim.listActivities.json" + }, + { + "id": "org.hypercerts.claim.searchActivities", + "path": "lexicons/org.hypercerts.claim.searchActivities.json" + }, { "id": "org.hypercerts.collection.getCollection", "path": "lexicons/org.hypercerts.collection.getCollection.json" }, + { + "id": "org.hypercerts.collection.listCollections", + "path": "lexicons/org.hypercerts.collection.listCollections.json" + }, + { + "id": "org.hypercerts.collection.searchCollections", + "path": "lexicons/org.hypercerts.collection.searchCollections.json" + }, { "id": "org.hypercerts.collection.listCollectionItems", "path": "lexicons/org.hypercerts.collection.listCollectionItems.json" }, + { + "id": "org.hypercerts.context.getAttachment", + "path": "lexicons/org.hypercerts.context.getAttachment.json" + }, + { + "id": "org.hypercerts.context.listAttachments", + "path": "lexicons/org.hypercerts.context.listAttachments.json" + }, { "id": "org.hypercerts.context.attachment", "packagePath": "lexicons/org/hypercerts/context/attachment.json" @@ -85,10 +213,26 @@ "id": "app.certified.badge.definition", "packagePath": "lexicons/app/certified/badge/definition.json" }, + { + "id": "app.certified.badge.getBadgeDefinition", + "path": "lexicons/app.certified.badge.getBadgeDefinition.json" + }, + { + "id": "app.certified.badge.listBadgeDefinitions", + "path": "lexicons/app.certified.badge.listBadgeDefinitions.json" + }, { "id": "org.hypercerts.funding.receipt", "packagePath": "lexicons/org/hypercerts/funding/receipt.json" }, + { + "id": "org.hypercerts.funding.getReceipt", + "path": "lexicons/org.hypercerts.funding.getReceipt.json" + }, + { + "id": "org.hypercerts.funding.listReceipts", + "path": "lexicons/org.hypercerts.funding.listReceipts.json" + }, { "id": "app.certified.actor.organization", "packagePath": "lexicons/app/certified/actor/organization.json" @@ -117,6 +261,10 @@ "id": "org.hypercerts.workscope.cel", "packagePath": "lexicons/org/hypercerts/workscope/cel.json" }, + { + "id": "org.hypercerts.workscope.tag", + "packagePath": "lexicons/org/hypercerts/workscope/tag.json" + }, { "id": "org.hypercerts.claim.activity", "packagePath": "lexicons/org/hypercerts/claim/activity.json" @@ -137,14 +285,6 @@ "id": "org.hypercerts.vocab.tag", "packagePath": "lexicons/org/hypercerts/vocab/tag.json" }, - { - "id": "org.hypercerts.vocab.getVocabTag", - "path": "lexicons/org.hypercerts.vocab.getVocabTag.json" - }, - { - "id": "org.hypercerts.vocab.listVocabTags", - "path": "lexicons/org.hypercerts.vocab.listVocabTags.json" - }, { "id": "pub.leaflet.blocks.blockquote", "packagePath": "lexicons/pub/leaflet/blocks/blockquote.json" @@ -246,20 +386,24 @@ "packagePath": "lexicons/pub/leaflet/theme/color.json" }, { - "id": "app.certified.badge.getBadgeDefinition", - "path": "lexicons/app.certified.badge.getBadgeDefinition.json" + "id": "org.hypercerts.claim.getContribution", + "path": "lexicons/org.hypercerts.claim.getContribution.json" }, { - "id": "app.certified.badge.listBadgeDefinitions", - "path": "lexicons/app.certified.badge.listBadgeDefinitions.json" + "id": "org.hypercerts.claim.listContributions", + "path": "lexicons/org.hypercerts.claim.listContributions.json" }, { - "id": "org.hypercerts.funding.getReceipt", - "path": "lexicons/org.hypercerts.funding.getReceipt.json" + "id": "org.hypercerts.claim.contribution", + "packagePath": "lexicons/org/hypercerts/claim/contribution.json" }, { - "id": "org.hypercerts.funding.listReceipts", - "path": "lexicons/org.hypercerts.funding.listReceipts.json" + "id": "org.hypercerts.vocab.getVocabTag", + "path": "lexicons/org.hypercerts.vocab.getVocabTag.json" + }, + { + "id": "org.hypercerts.vocab.listVocabTags", + "path": "lexicons/org.hypercerts.vocab.listVocabTags.json" }, { "id": "app.certified.location.getLocation", @@ -270,113 +414,36 @@ "path": "lexicons/app.certified.location.listLocations.json" }, { - "id": "org.hypercerts.context.acknowledgement", - "packagePath": "lexicons/org/hypercerts/context/acknowledgement.json" - }, - { - "id": "org.hypercerts.context.getAcknowledgement", - "path": "lexicons/org.hypercerts.context.getAcknowledgement.json" - }, - { - "id": "org.hypercerts.context.listAcknowledgements", - "path": "lexicons/org.hypercerts.context.listAcknowledgements.json" - }, - { - "id": "app.certified.graph.getEntityFollow", - "path": "lexicons/app.certified.graph.getEntityFollow.json" - }, - { - "id": "app.certified.graph.listEntityFollowers", - "path": "lexicons/app.certified.graph.listEntityFollowers.json" - }, - { - "id": "app.certified.graph.listEntityFollowing", - "path": "lexicons/app.certified.graph.listEntityFollowing.json" - }, - { - "id": "app.certified.graph.getFollow", - "path": "lexicons/app.certified.graph.getFollow.json" - }, - { - "id": "app.certified.graph.listActorFollowers", - "path": "lexicons/app.certified.graph.listActorFollowers.json" - }, - { - "id": "app.certified.graph.listActorFollowing", - "path": "lexicons/app.certified.graph.listActorFollowing.json" - }, - { - "id": "app.certified.graph.listRecentFollows", - "path": "lexicons/app.certified.graph.listRecentFollows.json" - }, - { - "id": "org.hypercerts.context.getEvaluation", - "path": "lexicons/org.hypercerts.context.getEvaluation.json" - }, - { - "id": "org.hypercerts.context.listEvaluations", - "path": "lexicons/org.hypercerts.context.listEvaluations.json" - }, - { - "id": "org.hypercerts.claim.listActivities", - "path": "lexicons/org.hypercerts.claim.listActivities.json" - }, - { - "id": "org.hypercerts.claim.searchActivities", - "path": "lexicons/org.hypercerts.claim.searchActivities.json" - }, - { - "id": "org.hypercerts.context.getAttachment", - "path": "lexicons/org.hypercerts.context.getAttachment.json" - }, - { - "id": "org.hypercerts.context.listAttachments", - "path": "lexicons/org.hypercerts.context.listAttachments.json" - }, - { - "id": "app.certified.actor.getProfile", - "path": "lexicons/app.certified.actor.getProfile.json" - }, - { - "id": "app.certified.actor.getProfiles", - "path": "lexicons/app.certified.actor.getProfiles.json" - }, - { - "id": "app.certified.actor.listProfiles", - "path": "lexicons/app.certified.actor.listProfiles.json" - }, - { - "id": "app.certified.actor.searchProfiles", - "path": "lexicons/app.certified.actor.searchProfiles.json" + "id": "app.certified.badge.award", + "packagePath": "lexicons/app/certified/badge/award.json" }, { - "id": "app.certified.actor.getOrganization", - "path": "lexicons/app.certified.actor.getOrganization.json" + "id": "app.certified.badge.response", + "packagePath": "lexicons/app/certified/badge/response.json" }, { - "id": "app.certified.actor.getOrganizations", - "path": "lexicons/app.certified.actor.getOrganizations.json" + "id": "app.certified.badge.searchBadgeDefinitions", + "path": "lexicons/app.certified.badge.searchBadgeDefinitions.json" }, { - "id": "app.certified.actor.listOrganizations", - "path": "lexicons/app.certified.actor.listOrganizations.json" + "id": "app.certified.badge.getBadgeAward", + "path": "lexicons/app.certified.badge.getBadgeAward.json" }, { - "id": "app.certified.actor.searchOrganizations", - "path": "lexicons/app.certified.actor.searchOrganizations.json" + "id": "app.certified.badge.listBadgeAwards", + "path": "lexicons/app.certified.badge.listBadgeAwards.json" }, { - "id": "org.hypercerts.collection.listCollections", - "path": "lexicons/org.hypercerts.collection.listCollections.json" + "id": "app.certified.badge.getBadgeResponse", + "path": "lexicons/app.certified.badge.getBadgeResponse.json" }, { - "id": "org.hypercerts.collection.searchCollections", - "path": "lexicons/org.hypercerts.collection.searchCollections.json" + "id": "app.certified.badge.listBadgeResponses", + "path": "lexicons/app.certified.badge.listBadgeResponses.json" } ], "modules": [ "modules/shared/manifest.json", - "modules/vocab/manifest.json", "modules/badge-definitions/manifest.json", "modules/badge-queries/manifest.json", "modules/profile/manifest.json", @@ -388,63 +455,11 @@ "modules/recent-follows/manifest.json", "modules/context-evaluation/manifest.json", "modules/context-attachments/manifest.json", + "modules/acknowledgements/manifest.json", "modules/funding/manifest.json", + "modules/workscope-tags/manifest.json", "modules/contribution/manifest.json", - "modules/acknowledgements/manifest.json", + "modules/vocab/manifest.json", "modules/location/manifest.json" - ], - "authentication": { - "unresolved": false, - "decision": "public", - "note": "Badge query endpoints expose public indexed record reads; all query endpoints expose public record reads and require no caller authentication." - }, - "handlerStatus": { - "getAcknowledgement": "implemented", - "listAcknowledgements": "implemented", - "searchBadgeDefinitions": "implemented", - "getBadgeAward": "implemented", - "listBadgeAwards": "implemented", - "getBadgeResponse": "implemented", - "listBadgeResponses": "implemented", - "getContribution": "implemented", - "listContributions": "implemented", - "getVocabTag": "implemented", - "listVocabTags": "implemented", - "getBadgeDefinition": "implemented", - "listBadgeDefinitions": "implemented", - "getReceipt": "implemented", - "listReceipts": "implemented", - "getLocation": "implemented", - "listLocations": "implemented", - "getEvaluation": "implemented", - "listEvaluations": "implemented", - "getAttachment": "implemented", - "listAttachments": "implemented", - "getActivity": "implemented", - "listActivities": "implemented", - "searchActivities": "implemented", - "getFollow": "implemented", - "listActorFollowers": "implemented", - "listActorFollowing": "implemented", - "getEntityFollow": "implemented", - "listEntityFollowers": "implemented", - "listEntityFollowing": "implemented", - "listRecentFollows": "implemented", - "getProfile": "implemented", - "getProfiles": "implemented", - "searchProfiles": "implemented", - "listProfiles": "implemented", - "getOrganization": "implemented", - "getOrganizations": "implemented", - "listOrganizations": "implemented", - "searchOrganizations": "implemented", - "getCollection": "implemented", - "listCollections": "implemented", - "searchCollections": "implemented", - "listCollectionItems": "implemented" - }, - "collection": "app.certified.location", - "targetHappyViewRevision": "a2f347e605d7cd9c11669b320e43c10dbfe982e3", - "runtimeCompatibility": "compatible-at-target-revision", - "status": "handlers-implemented; location-and-actor-follow-runtime-validated-at-target-revision; other-query-runtime-validation-pending" + ] } diff --git a/api/modules/shared/manifest.json b/api/modules/shared/manifest.json index 07c3e45..fa2fb48 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -20,6 +20,7 @@ { "kind": "lexicon", "id": "org.hypercerts.entity.feature", "packagePath": "lexicons/org/hypercerts/entity/feature.json", "config": { "backfill": true }, "dependsOn": ["org.hypercerts.defs", "app.certified.signature.defs"] }, { "kind": "lexicon", "id": "org.hypercerts.funding.receipt", "packagePath": "lexicons/org/hypercerts/funding/receipt.json", "config": { "backfill": true }, "dependsOn": [] }, { "kind": "lexicon", "id": "org.hypercerts.vocab.tag", "packagePath": "lexicons/org/hypercerts/vocab/tag.json", "config": { "backfill": true }, "dependsOn": ["org.hypercerts.defs", "app.certified.signature.defs"] }, + { "kind": "lexicon", "id": "org.hypercerts.workscope.tag", "packagePath": "lexicons/org/hypercerts/workscope/tag.json", "config": { "backfill": true }, "dependsOn": ["org.hypercerts.defs", "app.certified.signature.defs"] }, { "kind": "lexicon", "id": "org.hypercerts.api.defs", diff --git a/api/modules/workscope-tags/manifest.json b/api/modules/workscope-tags/manifest.json new file mode 100644 index 0000000..f576254 --- /dev/null +++ b/api/modules/workscope-tags/manifest.json @@ -0,0 +1,70 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "org.hypercerts.workscope.getWorkscopeTag", + "path": "../../lexicons/org.hypercerts.workscope.getWorkscopeTag.json", + "config": { + "backfill": false, + "target_collection": "org.hypercerts.workscope.tag" + }, + "dependsOn": [ + "org.hypercerts.workscope.tag", + "org.hypercerts.api.defs" + ] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.workscope.getWorkscopeTag", + "path": "../../lua/endpoints/getWorkscopeTag.lua", + "sourcePath": "../../lua/src/getWorkscopeTag.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/workscopeRecordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/workscopeTag.lua" + ], + "config": { + "script_type": "lua", + "description": "Work-scope tag lookup handler" + }, + "dependsOn": ["org.hypercerts.workscope.getWorkscopeTag"] + }, + { + "kind": "lexicon", + "id": "org.hypercerts.workscope.listWorkscopeTags", + "path": "../../lexicons/org.hypercerts.workscope.listWorkscopeTags.json", + "config": { + "backfill": false, + "target_collection": "org.hypercerts.workscope.tag" + }, + "dependsOn": [ + "org.hypercerts.workscope.getWorkscopeTag" + ] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.workscope.listWorkscopeTags", + "path": "../../lua/endpoints/listWorkscopeTags.lua", + "sourcePath": "../../lua/src/listWorkscopeTags.lua", + "sharedSourcePaths": [ + "../../lua/shared/didValidation.lua", + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/listValidation.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/workscopeRecordView.lua", + "../../lua/shared/actorView.lua", + "../../lua/shared/workscopeTag.lua", + "../../lua/shared/workscopeTagList.lua" + ], + "config": { + "script_type": "lua", + "description": "Work-scope tag listing handler" + }, + "dependsOn": ["org.hypercerts.workscope.listWorkscopeTags"] + } + ] +} diff --git a/api/tests/http/acknowledgements.http.test.js b/api/tests/http/acknowledgements.http.test.js index 315e2aa..b74af80 100644 --- a/api/tests/http/acknowledgements.http.test.js +++ b/api/tests/http/acknowledgements.http.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; -const publisher = 'did:plc:llllllllllllllllllllllll'; +const publisher = 'did:web:acknowledgements-primary.invalid'; const secondPublisher = 'did:web:acknowledgements-secondary.example'; const thirdPublisher = 'did:web:acknowledgements-tertiary.example'; const subjectUri = 'at://did:plc:mmmmmmmmmmmmmmmmmmmmmmmm/org.hypercerts.claim.activity/ack-subject'; @@ -12,12 +12,12 @@ const acknowledgementCollection = 'org.hypercerts.context.acknowledgement'; const getMethod = 'org.hypercerts.context.getAcknowledgement'; const listMethod = 'org.hypercerts.context.listAcknowledgements'; const uris = { - one: 'at://did:plc:llllllllllllllllllllllll/org.hypercerts.context.acknowledgement/ack-one', - middleA: 'at://did:plc:llllllllllllllllllllllll/org.hypercerts.context.acknowledgement/ack-middle-a', + one: 'at://did:web:acknowledgements-primary.invalid/org.hypercerts.context.acknowledgement/ack-one', + middleA: 'at://did:web:acknowledgements-primary.invalid/org.hypercerts.context.acknowledgement/ack-middle-a', middleB: 'at://did:web:acknowledgements-secondary.example/org.hypercerts.context.acknowledgement/ack-middle-b', late: 'at://did:web:acknowledgements-secondary.example/org.hypercerts.context.acknowledgement/ack-late', authorNegative: 'at://did:web:acknowledgements-tertiary.example/org.hypercerts.context.acknowledgement/ack-author-negative', - subjectNegative: 'at://did:plc:llllllllllllllllllllllll/org.hypercerts.context.acknowledgement/ack-subject-negative', + subjectNegative: 'at://did:web:acknowledgements-primary.invalid/org.hypercerts.context.acknowledgement/ack-subject-negative', }; async function query(method, params = {}) { @@ -67,7 +67,7 @@ test('getAcknowledgement returns the indexed record and hydrates its publisher s }); test('getAcknowledgement reports a missing record as a named pinned-runtime error', async () => { - const missingUri = 'at://did:plc:llllllllllllllllllllllll/org.hypercerts.context.acknowledgement/not-indexed'; + const missingUri = 'at://did:web:acknowledgements-primary.invalid/org.hypercerts.context.acknowledgement/not-indexed'; const { response, body } = await query(getMethod, { uri: missingUri }); assert.equal(response.status, 500, JSON.stringify(body)); assert.equal(body.error, 'script_error'); diff --git a/api/tests/http/fixtures/acknowledgements.fixture.js b/api/tests/http/fixtures/acknowledgements.fixture.js index 918df9f..47e0be2 100644 --- a/api/tests/http/fixtures/acknowledgements.fixture.js +++ b/api/tests/http/fixtures/acknowledgements.fixture.js @@ -4,7 +4,7 @@ import * as CID from '@atcute/cid'; const acknowledgementCollection = 'org.hypercerts.context.acknowledgement'; const profileCollection = 'app.certified.actor.profile'; const organizationCollection = 'app.certified.actor.organization'; -const publisher = 'did:plc:llllllllllllllllllllllll'; +const publisher = 'did:web:acknowledgements-primary.invalid'; const secondPublisher = 'did:web:acknowledgements-secondary.example'; const thirdPublisher = 'did:web:acknowledgements-tertiary.example'; const subjectUri = 'at://did:plc:mmmmmmmmmmmmmmmmmmmmmmmm/org.hypercerts.claim.activity/ack-subject'; diff --git a/api/tests/http/fixtures/workscope-tags.fixture.js b/api/tests/http/fixtures/workscope-tags.fixture.js new file mode 100644 index 0000000..1404a75 --- /dev/null +++ b/api/tests/http/fixtures/workscope-tags.fixture.js @@ -0,0 +1,71 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const workscopeTag = 'org.hypercerts.workscope.tag'; +const profile = 'app.certified.actor.profile'; +const organization = 'app.certified.actor.organization'; +const publisherA = 'did:web:workscope-a.example'; +const publisherB = 'did:web:workscope-b.example'; +const publisherC = 'did:web:workscope-c.example'; +const indexedAt = '2025-03-10T12:00:00.000Z'; + +const tagSpecs = [ + { + did: publisherA, + rkey: 'tag-tie-a', + record: { key: 'shared_key', name: 'Shared key from A', createdAt: '2025-03-01T00:00:00Z' }, + }, + { + did: publisherA, + rkey: 'tag-tie-z', + record: { key: 'shared_key', name: 'Shared key from A, later URI', createdAt: '2025-03-01T00:00:00Z' }, + }, + { + did: publisherB, + rkey: 'tag-tie-b', + record: { key: 'shared_key', name: 'Shared key from B', createdAt: '2025-03-01T00:00:00Z' }, + }, + { + did: publisherA, + rkey: 'tag-older', + record: { key: 'older_key', name: 'Older tag', createdAt: '2025-02-28T00:00:00Z' }, + }, + { + did: publisherA, + rkey: 'tag-unknown-offset', + indexedAt: '2025-02-28T12:00:00.000Z', + record: { key: 'unknown_offset_key', name: 'Unknown offset timestamp', createdAt: '2025-03-02T00:00:00-00:00' }, + }, + { + did: publisherC, + rkey: 'tag-filter-c', + record: { key: 'filter_key', name: 'Filter-only tag from C', createdAt: '2025-03-02T00:00:00Z' }, + }, +]; + +async function seedRow(collection, did, rkey, fields, rowIndexedAt = indexedAt) { + const record = { $type: collection, ...fields }; + return { + uri: `at://${did}/${collection}/${rkey}`, + did, + collection, + rkey, + cid: CID.toString(await CID.create(0x71, encode(record))), + indexedAt: rowIndexedAt, + record, + }; +} + +export const seedRows = await Promise.all([ + ...tagSpecs.map(({ did, rkey, record, indexedAt: rowIndexedAt }) => seedRow(workscopeTag, did, rkey, record, rowIndexedAt)), + seedRow(profile, publisherA, 'self', { + displayName: 'Workscope Test Publisher', + description: 'Publisher profile for workscope-tag HTTP contracts.', + createdAt: indexedAt, + }), + seedRow(organization, publisherA, 'self', { + organizationType: ['community'], + visibility: 'public', + createdAt: indexedAt, + }), +]); diff --git a/api/tests/http/workscope-tags.http.test.js b/api/tests/http/workscope-tags.http.test.js new file mode 100644 index 0000000..0c62d68 --- /dev/null +++ b/api/tests/http/workscope-tags.http.test.js @@ -0,0 +1,216 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +const getEndpoint = 'org.hypercerts.workscope.getWorkscopeTag'; +const listEndpoint = 'org.hypercerts.workscope.listWorkscopeTags'; +const collection = 'org.hypercerts.workscope.tag'; +const publisherA = 'did:web:workscope-a.example'; +const publisherB = 'did:web:workscope-b.example'; +const publisherC = 'did:web:workscope-c.example'; +const tagUris = { + tieA: `at://${publisherA}/${collection}/tag-tie-a`, + tieZ: `at://${publisherA}/${collection}/tag-tie-z`, + tieB: `at://${publisherB}/${collection}/tag-tie-b`, + older: `at://${publisherA}/${collection}/tag-older`, + unknownOffset: `at://${publisherA}/${collection}/tag-unknown-offset`, + filterC: `at://${publisherC}/${collection}/tag-filter-c`, +}; + +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 assertRuntimeScriptError({ response, body }, method, name) { + assert.equal(response.status, 500, JSON.stringify(body)); + assert.equal(body.error, 'script_error'); + assert.equal(body.errorType, 'runtime'); + assert.equal(body.method, method); + assert.ok(body.message.includes(name), `expected named ${name} error, got ${body.message}`); +} + +test('getWorkscopeTag returns its CBOR-addressed record and hydrated publisher sidecars', async () => { + const { response, body } = await request(getEndpoint, { uri: tagUris.tieA }); + assert.equal(response.status, 200, JSON.stringify(body)); + + const { workscopeTag } = body; + assert.equal(workscopeTag.uri, tagUris.tieA); + assert.equal(workscopeTag.cid, 'bafyreihehozumc7xc5hwq2run4vez7757nw54zot7mfvozvr5v54pcug5u'); + assert.equal(workscopeTag.indexedAt, '2025-03-10T12:00:00.000Z'); + assert.equal(workscopeTag.did, publisherA); + assert.deepEqual(workscopeTag.record, { + $type: collection, + key: 'shared_key', + name: 'Shared key from A', + createdAt: '2025-03-01T00:00:00Z', + }); + assert.equal(workscopeTag.author.did, publisherA); + assert.equal(workscopeTag.author.profile.uri, `at://${publisherA}/app.certified.actor.profile/self`); + assert.deepEqual(workscopeTag.author.profile.record, { + $type: 'app.certified.actor.profile', + displayName: 'Workscope Test Publisher', + description: 'Publisher profile for workscope-tag HTTP contracts.', + createdAt: '2025-03-10T12:00:00.000Z', + }); + assert.deepEqual(workscopeTag.author.organization.record, { + $type: 'app.certified.actor.organization', + organizationType: ['community'], + visibility: 'public', + createdAt: '2025-03-10T12:00:00.000Z', + }); +}); + +test('listWorkscopeTags ORs repeated author filters and omits unselected publishers', async () => { + const { response, body } = await request(listEndpoint, { + authors: [publisherA, publisherB], + sortDirection: 'desc', + limit: 10, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.workscopeTags.map(({ uri }) => uri), [ + tagUris.tieB, + tagUris.tieZ, + tagUris.tieA, + tagUris.unknownOffset, + tagUris.older, + ]); + const unknownOffset = body.workscopeTags.find(({ uri }) => uri === tagUris.unknownOffset); + const tieB = body.workscopeTags.find(({ uri }) => uri === tagUris.tieB); + assert.ok(unknownOffset, 'the fallback record is included in the ordered results'); + assert.ok(tieB, 'the publisher with absent sidecars remains in the ordered results'); + assert.equal(unknownOffset.author.profile.record.displayName, 'Workscope Test Publisher'); + assert.deepEqual(unknownOffset.author.organization.record.organizationType, ['community']); + assert.equal(tieB.author.profile, null); + assert.equal(tieB.author.organization, null); + assert.equal(Object.hasOwn(body, 'cursor'), false); +}); + +test('listWorkscopeTags falls back to indexedAt for an unknown-offset createdAt', async () => { + const authors = [publisherA]; + const descending = await request(listEndpoint, { authors, limit: 10 }); + assert.equal(descending.response.status, 200, JSON.stringify(descending.body)); + assert.deepEqual(descending.body.workscopeTags.map(({ uri }) => uri), [ + tagUris.tieZ, + tagUris.tieA, + tagUris.unknownOffset, + tagUris.older, + ]); + + const ascending = await request(listEndpoint, { authors, sortDirection: 'asc', limit: 10 }); + assert.equal(ascending.response.status, 200, JSON.stringify(ascending.body)); + assert.deepEqual(ascending.body.workscopeTags.map(({ uri }) => uri), [ + tagUris.older, + tagUris.unknownOffset, + tagUris.tieA, + tagUris.tieZ, + ]); + const unknownOffset = descending.body.workscopeTags.find(({ uri }) => uri === tagUris.unknownOffset); + assert.ok(unknownOffset); + assert.equal(unknownOffset.record.createdAt, '2025-03-02T00:00:00-00:00'); + assert.equal(unknownOffset.indexedAt, '2025-02-28T12:00:00.000Z'); +}); + +test('getWorkscopeTag returns null sidecars when the publisher has no indexed profile or organization', async () => { + const { response, body } = await request(getEndpoint, { uri: tagUris.tieB }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.workscopeTag.indexedAt, '2025-03-10T12:00:00.000Z'); + assert.equal(body.workscopeTag.author.did, publisherB); + assert.equal(body.workscopeTag.author.profile, null); + assert.equal(body.workscopeTag.author.organization, null); +}); + +test('getWorkscopeTag returns RecordNotFound as a named pinned-runtime error', async () => { + const missingUri = `at://${publisherA}/${collection}/not-indexed`; + assertRuntimeScriptError(await request(getEndpoint, { uri: missingUri }), getEndpoint, 'RecordNotFound'); +}); + +test('getWorkscopeTag returns InvalidRequest for a different collection as a named pinned-runtime error', async () => { + const wrongCollectionUri = `at://${publisherA}/app.certified.actor.profile/self`; + assertRuntimeScriptError(await request(getEndpoint, { uri: wrongCollectionUri }), getEndpoint, 'InvalidRequest'); +}); + +test('listWorkscopeTags returns no rows for an unmatched author filter', async () => { + const { response, body } = await request(listEndpoint, { + authors: ['did:web:workscope-no-match.example'], + limit: 10, + }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.deepEqual(body.workscopeTags, []); + assert.equal(Object.hasOwn(body, 'cursor'), false); +}); + +test('listWorkscopeTags paginates tied timestamps in descending timestamp-and-URI order', async () => { + const authors = [publisherA, publisherB]; + const first = await request(listEndpoint, { authors, limit: 2 }); + assert.equal(first.response.status, 200, JSON.stringify(first.body)); + assert.deepEqual(first.body.workscopeTags.map(({ uri }) => uri), [tagUris.tieB, tagUris.tieZ]); + assert.equal(typeof first.body.cursor, 'string'); + + const second = await request(listEndpoint, { authors, limit: 2, cursor: first.body.cursor }); + assert.equal(second.response.status, 200, JSON.stringify(second.body)); + assert.deepEqual(second.body.workscopeTags.map(({ uri }) => uri), [tagUris.tieA, tagUris.unknownOffset]); + assert.equal(typeof second.body.cursor, 'string'); + + const third = await request(listEndpoint, { authors, limit: 2, cursor: second.body.cursor }); + assert.equal(third.response.status, 200, JSON.stringify(third.body)); + assert.deepEqual(third.body.workscopeTags.map(({ uri }) => uri), [tagUris.older]); + assert.equal(Object.hasOwn(third.body, 'cursor'), false); + assert.deepEqual([...first.body.workscopeTags, ...second.body.workscopeTags, ...third.body.workscopeTags].map(({ uri }) => uri), [ + tagUris.tieB, + tagUris.tieZ, + tagUris.tieA, + tagUris.unknownOffset, + tagUris.older, + ]); +}); + +test('listWorkscopeTags paginates tied timestamps in ascending timestamp-and-URI order', async () => { + const authors = [publisherA, publisherB]; + const first = await request(listEndpoint, { authors, sortDirection: 'asc', limit: 2 }); + assert.equal(first.response.status, 200, JSON.stringify(first.body)); + assert.deepEqual(first.body.workscopeTags.map(({ uri }) => uri), [tagUris.older, tagUris.unknownOffset]); + assert.equal(typeof first.body.cursor, 'string'); + + const second = await request(listEndpoint, { + authors, + sortDirection: 'asc', + limit: 2, + cursor: first.body.cursor, + }); + assert.equal(second.response.status, 200, JSON.stringify(second.body)); + assert.deepEqual(second.body.workscopeTags.map(({ uri }) => uri), [tagUris.tieA, tagUris.tieZ]); + assert.equal(typeof second.body.cursor, 'string'); + + const third = await request(listEndpoint, { + authors, + sortDirection: 'asc', + limit: 2, + cursor: second.body.cursor, + }); + assert.equal(third.response.status, 200, JSON.stringify(third.body)); + assert.deepEqual(third.body.workscopeTags.map(({ uri }) => uri), [tagUris.tieB]); + assert.equal(Object.hasOwn(third.body, 'cursor'), false); + assert.deepEqual([...first.body.workscopeTags, ...second.body.workscopeTags, ...third.body.workscopeTags].map(({ uri }) => uri), [ + tagUris.older, + tagUris.unknownOffset, + tagUris.tieA, + tagUris.tieZ, + tagUris.tieB, + ]); +}); + +test('listWorkscopeTags returns InvalidRequest as a named pinned-runtime error', async () => { + assertRuntimeScriptError(await request(listEndpoint, { limit: 101 }), listEndpoint, 'InvalidRequest'); +}); diff --git a/api/tests/unit/fixtures/fixtures.test.js b/api/tests/unit/fixtures/fixtures.test.js index 939b65b..b233874 100644 --- a/api/tests/unit/fixtures/fixtures.test.js +++ b/api/tests/unit/fixtures/fixtures.test.js @@ -9,15 +9,6 @@ import { isValidDid, isValidTid } from '@atproto/syntax'; import { locationRecords, profileRecords, organizationRecords, seedSql } from '../../fixtures/records.js'; import { activityContributorInformationVersions, activityFixtureRows } from '../../fixtures/activities.js'; import { seedRows as activityHttpFixtureRows } from '../../http/fixtures/activity.fixture.js'; -import { seedRows as acknowledgementHttpFixtureRows } from '../../http/fixtures/acknowledgements.fixture.js'; -import { seedRows as actorsHttpFixtureRows } from '../../http/fixtures/actors.fixture.js'; -import { seedRows as badgeHttpFixtureRows } from '../../http/fixtures/badge-definitions.fixture.js'; -import { seedRows as collectionHttpFixtureRows } from '../../http/fixtures/collections.fixture.js'; -import { seedRows as contextAttachmentHttpFixtureRows } from '../../http/fixtures/context-attachments.fixture.js'; -import { seedRows as contextEvaluationHttpFixtureRows } from '../../http/fixtures/context-evaluations.fixture.js'; -import { seedRows as fundingHttpFixtureRows } from '../../http/fixtures/funding-receipts.fixture.js'; -import { seedRows as graphFollowHttpFixtureRows } from '../../http/fixtures/graph-follows.fixture.js'; -import { seedRows as locationHttpFixtureRows } from '../../http/fixtures/location.fixture.js'; import { actorFollowDids, actorFollowOrganizationRecords, @@ -45,17 +36,6 @@ async function findHttpFixtureFiles(directory) { return groups.flat(); } -function duplicateKeys(rows, keyFor) { - const seen = new Set(); - const duplicates = new Set(); - for (const row of rows) { - const key = keyFor(row); - if (seen.has(key)) duplicates.add(key); - seen.add(key); - } - return [...duplicates].sort(); -} - test('fixtures have consistent full AT-URIs, valid DID/TID/CID identifiers, types, and fixed timestamps', () => { const records = [ ...locationRecords, ...profileRecords, ...organizationRecords, @@ -76,56 +56,21 @@ test('fixtures have consistent full AT-URIs, valid DID/TID/CID identifiers, type } }); -test('shared and recursively discovered HTTP seed rows are nonempty, unique against shared fixtures, and use CBOR-derived CIDs', async () => { - const sharedRows = [ - ...locationRecords, ...profileRecords, ...organizationRecords, - ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, - ...activityFixtureRows, - ]; - const fixtureFiles = await findHttpFixtureFiles(httpFixtureRoot); - assert.ok(fixtureFiles.length > 0, 'expected recursively discovered HTTP fixture modules'); - - const httpRows = []; - for (const file of fixtureFiles) { - const fixture = await import(pathToFileURL(file).href); - assert.ok(Array.isArray(fixture.seedRows) && fixture.seedRows.length > 0, `${path.relative(httpFixtureRoot, file)} must export nonempty seedRows`); - httpRows.push(...fixture.seedRows); - } - - const allRows = [...sharedRows, ...httpRows]; - for (const rkey of ['ack-middle-b', 'ack-author-negative']) { - const acknowledgement = acknowledgementHttpFixtureRows.find((row) => row.rkey === rkey); - assert.ok(acknowledgement, `expected acknowledgement fixture ${rkey}`); - const sidecars = allRows.filter(({ did, collection }) => did === acknowledgement.did - && ['app.certified.actor.profile', 'app.certified.actor.organization'].includes(collection)); - assert.deepEqual(sidecars.map(({ uri }) => uri), [], - `${rkey} publisher must have no profile or organization sidecars in shared or HTTP fixtures`); +test('activity HTTP fixture repositories do not collide with any discovered fixture repositories', async () => { + const otherHttpRows = []; + const activityFixturePath = path.join(httpFixtureRoot, 'activity.fixture.js'); + for (const file of await findHttpFixtureFiles(httpFixtureRoot)) { + if (file === activityFixturePath) continue; + const { seedRows } = await import(pathToFileURL(file).href); + otherHttpRows.push(...seedRows); } - for (const row of allRows) { - assert.equal(row.uri, `at://${row.did}/${row.collection}/${row.rkey}`); - } - assert.deepEqual(duplicateKeys(allRows, ({ uri }) => uri), [], 'seed rows must not overwrite another record URI'); - assert.deepEqual( - duplicateKeys(allRows, ({ did, collection, rkey }) => JSON.stringify([did, collection, rkey])), - [], - 'seed rows must not reuse another record identity', - ); - for (const row of allRows) { - assert.equal(row.record.$type, row.collection); - assert.equal(CID.toString(await CID.create(0x71, encode(row.record))), row.cid, `CBOR-derived CID mismatch for ${row.uri}`); - } -}); - -test('activity HTTP fixture repositories do not collide with other fixture repositories', () => { - const existingRows = [ + const sharedRows = [ ...locationRecords, ...profileRecords, ...organizationRecords, ...actorFollowRecords, ...actorFollowProfileRecords, ...actorFollowOrganizationRecords, - ...activityFixtureRows, ...contextAttachmentHttpFixtureRows, ...contextEvaluationHttpFixtureRows, - ...graphFollowHttpFixtureRows, ...fundingHttpFixtureRows, ...badgeHttpFixtureRows, - ...actorsHttpFixtureRows, ...collectionHttpFixtureRows, ...locationHttpFixtureRows, + ...activityFixtureRows, ]; - const existingDids = new Set(existingRows.map(({ did }) => did)); + const existingDids = new Set([...sharedRows, ...otherHttpRows].map(({ did }) => did)); const activityHttpDids = new Set(activityHttpFixtureRows.map(({ did }) => did)); assert.deepEqual([...activityHttpDids].filter((did) => existingDids.has(did)), []); assert.equal(new Set(activityHttpFixtureRows.map(({ uri }) => uri)).size, activityHttpFixtureRows.length); diff --git a/api/tests/unit/fixtures/http-seed-rows.test.js b/api/tests/unit/fixtures/http-seed-rows.test.js index 01444c2..61418a6 100644 --- a/api/tests/unit/fixtures/http-seed-rows.test.js +++ b/api/tests/unit/fixtures/http-seed-rows.test.js @@ -54,7 +54,7 @@ function duplicateDetails(previous, current) { }; } -test('shared and recursively discovered HTTP seed rows have unique identities and CBOR-derived CIDs', async () => { +test('shared and discovered HTTP seed rows preserve acknowledgement sidecar isolation, unique identities, and CBOR-derived CIDs', async () => { const rows = [ ...[ ...locationRecords, ...profileRecords, ...organizationRecords, @@ -63,6 +63,27 @@ test('shared and recursively discovered HTTP seed rows have unique identities an ].map((row) => ({ row, source: 'shared seed rows' })), ...await loadHttpSeedRows(), ]; + const acknowledgementRows = rows.filter(({ row }) => row.collection === 'org.hypercerts.context.acknowledgement'); + const acknowledgementDids = new Set(acknowledgementRows.map(({ row }) => row.did)); + const acknowledgementSources = new Set(acknowledgementRows.map(({ source }) => source)); + const sharedAcknowledgementSidecars = rows + .filter(({ row, source }) => acknowledgementDids.has(row.did) + && !acknowledgementSources.has(source) + && ['app.certified.actor.profile', 'app.certified.actor.organization'].includes(row.collection)) + .map(({ row, source }) => ({ did: row.did, collection: row.collection, uri: row.uri, source })); + assert.deepEqual(sharedAcknowledgementSidecars, [], + 'acknowledgement publishers must not share actor-sidecar DIDs with other fixture sources because seed upserts can overwrite those rows'); + + for (const rkey of ['ack-middle-b', 'ack-author-negative']) { + const acknowledgement = rows.find(({ row }) => + row.collection === 'org.hypercerts.context.acknowledgement' && row.rkey === rkey); + assert.ok(acknowledgement, `expected acknowledgement fixture ${rkey}`); + const sidecars = rows.filter(({ row }) => row.did === acknowledgement.row.did + && ['app.certified.actor.profile', 'app.certified.actor.organization'].includes(row.collection)); + assert.deepEqual(sidecars.map(({ row }) => row.uri), [], + `${rkey} publisher must have no profile or organization sidecars in shared or HTTP fixtures`); + } + const byUri = new Map(); const byIdentity = new Map(); const duplicateUris = []; diff --git a/api/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index 0db0403..de0f2eb 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -43,6 +43,7 @@ test('the full validation Lexicon closure resolves locally while only selected p 'org.hypercerts.entity.feature', 'org.hypercerts.funding.receipt', 'org.hypercerts.vocab.tag', + 'org.hypercerts.workscope.tag', ]); for (const asset of deployedPackageAssets) { const source = validationSources.get(asset.id); @@ -130,6 +131,35 @@ if (hasModule('modules/organization/manifest.json')) test('organization query Le assert.equal(lexicons.getDefOrThrow(searchOrganizations.defs.output.properties.actors.items.ref).type, 'object'); }); +if (hasModule('modules/workscope-tags/manifest.json')) test('work-scope tag queries keep their result view with getWorkscopeTag and declare pagination bounds', 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 tag = byId.get('org.hypercerts.workscope.tag'); + const get = byId.get('org.hypercerts.workscope.getWorkscopeTag'); + const list = byId.get('org.hypercerts.workscope.listWorkscopeTags'); + assert.ok(shared && tag && get && list); + assert.equal(shared.defs.workscopeTagView, undefined, 'the workscope-specific view is not part of shared API defs'); + + const view = lexicons.getDefOrThrow('org.hypercerts.workscope.getWorkscopeTag#workscopeTagView'); + assert.deepEqual(view.required, ['uri', 'cid', 'indexedAt', 'did', 'author', 'record']); + assert.deepEqual(view.nullable, ['indexedAt']); + assert.equal(view.properties.author.ref, 'lex:org.hypercerts.api.defs#actorView'); + assert.equal(view.properties.record.ref, 'lex:org.hypercerts.workscope.tag'); + assert.deepEqual(get.defs.main.parameters.required, ['uri']); + assert.equal(get.defs.main.parameters.properties.uri.format, 'at-uri'); + assert.equal(get.defs.output.properties.workscopeTag.ref, 'lex:org.hypercerts.workscope.getWorkscopeTag#workscopeTagView'); + assert.equal(list.defs.main.parameters.properties.authors.maxLength, 100); + assert.equal(list.defs.main.parameters.properties.authors.items.format, 'did'); + assert.equal(list.defs.main.parameters.properties.sortDirection.default, 'desc'); + assert.equal(list.defs.main.parameters.properties.limit.default, 25); + assert.equal(list.defs.main.parameters.properties.limit.minimum, 1); + assert.equal(list.defs.main.parameters.properties.limit.maximum, 100); + assert.equal(list.defs.output.properties.workscopeTags.items.ref, 'lex:org.hypercerts.workscope.getWorkscopeTag#workscopeTagView'); + assert.deepEqual(get.defs.main.errors.map(({ name }) => name), ['InvalidRequest', 'RecordNotFound', 'WorkscopeTagQueryFailed']); + assert.deepEqual(list.defs.main.errors.map(({ name }) => name), ['InvalidRequest', 'WorkscopeTagQueryFailed']); +}); + if (hasModule('modules/actor-follow/manifest.json')) test('follow query Lexicons declare all required DID parameters', async () => { const { documents } = await validatePackageLexicons(); const byId = new Map(documents.map((document) => [document.id, document])); diff --git a/api/tests/unit/workscopeTags.test.js b/api/tests/unit/workscopeTags.test.js new file mode 100644 index 0000000..efde9b8 --- /dev/null +++ b/api/tests/unit/workscopeTags.test.js @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const apiRoot = fileURLToPath(new URL('../../', import.meta.url)); + +test('workscope-tag offline Lua suite executes all nine cases', (t) => { + const result = spawnSync('lua5.4', ['tests/unit/workscopeTags.test.lua'], { + cwd: apiRoot, + encoding: 'utf8', + }); + + assert.ifError(result.error); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + + const cases = result.stdout.match(/^ok - .+$/gm) ?? []; + assert.ok(cases.length >= 9, `expected all nine workscope-tag Lua cases to execute, got ${cases.length}`); + for (const line of cases) t.diagnostic(line); + t.diagnostic(`Executed ${cases.length} workscope-tag Lua cases.`); +}); diff --git a/api/tests/unit/workscopeTags.test.lua b/api/tests/unit/workscopeTags.test.lua new file mode 100644 index 0000000..893d670 --- /dev/null +++ b/api/tests/unit/workscopeTags.test.lua @@ -0,0 +1,319 @@ +local TAG = "org.hypercerts.workscope.tag" +local PROFILE = "app.certified.actor.profile" +local ORGANIZATION = "app.certified.actor.organization" +local NULL = { is_json_null = true } +local decoded_records = {} + +json = { + decode = function(value) + if value == "null" then return NULL end + if decoded_records[value] then return decoded_records[value] end + if value:sub(1, 1) == "{" then + local decoded = { + v = tonumber(value:match('"v":(%d+)')), + d = value:match('"d":"([^"]*)"'), + t = value:match('"t":"([^"]*)"'), + u = value:match('"u":"([^"]*)"'), + } + if decoded.v and decoded.d and decoded.t and decoded.u then return decoded end + end + error("unexpected test JSON: " .. value) + end, + encode = function(value) + return '{"v":' .. tostring(value.v) .. ',"d":"' .. value.d .. + '","t":"' .. value.t .. '","u":"' .. value.u .. '"}' + end, +} + +toarray = function(values) return values end +params = {} +db = { backend = function() return "postgres" end } + +local did_a = "did:web:tag-a.example" +local did_b = "did:web:tag-b.example" +local author_profile_record = { ["$type"] = PROFILE, displayName = "Publisher A" } +local author_organization_record = { ["$type"] = ORGANIZATION, organizationType = { "community" } } +local tag_a_record = { + ["$type"] = TAG, + key = "shared_key", + name = "Shared key from A", + createdAt = "2025-01-02T00:00:00Z", + parent = { uri = "at://did:web:parent.example/org.hypercerts.workscope.tag/parent", cid = "bafyreiparent" }, +} +local tag_b_record = { + ["$type"] = TAG, + key = "shared_key", + name = "Shared key from B", + createdAt = "2025-01-02T00:00:00Z", + supersededBy = { uri = "at://did:web:replacement.example/org.hypercerts.workscope.tag/next", cid = "bafyreireplacement" }, +} +local older_record = { + ["$type"] = TAG, + key = "older_key", + name = "Older tag", + createdAt = "2024-12-31T23:59:59Z", +} + +decoded_records["tag-a"] = tag_a_record +decoded_records["tag-b"] = tag_b_record +decoded_records["tag-older"] = older_record +decoded_records["profile-a"] = author_profile_record +decoded_records["organization-a"] = author_organization_record + +local function row(uri, did, cid, record_json, indexed_at, sort_timestamp) + return { + uri = uri, + did = did, + cid = cid, + record = record_json, + indexed_at = indexed_at, + sort_timestamp = sort_timestamp, + } +end + +local uri_a = "at://" .. did_a .. "/" .. TAG .. "/same-rkey" +local uri_b = "at://" .. did_b .. "/" .. TAG .. "/same-rkey" +local uri_older = "at://" .. did_a .. "/" .. TAG .. "/older" +local tag_a = row(uri_a, did_a, "bafyreitag-a", "tag-a", "2025-01-03T00:00:00Z", "2025-01-02T00:00:00.000000Z") +local tag_b = row(uri_b, did_b, "bafyreitag-b", "tag-b", "2025-01-03T00:00:00Z", "2025-01-02T00:00:00.000000Z") +local tag_older = row(uri_older, did_a, "bafyreitag-old", "tag-older", "2025-01-01T00:00:00Z", "2024-12-31T23:59:59.000000Z") +local profile_a = row("at://" .. did_a .. "/" .. PROFILE .. "/self", did_a, "bafyreiprofile", "profile-a", "2025-01-03T00:00:00Z") +local organization_a = row("at://" .. did_a .. "/" .. ORGANIZATION .. "/self", did_a, "bafyreiorganization", "organization-a", "2025-01-03T00:00:00Z") + +local function reset_database() + db.calls = {} + db.lookup_rows = { [uri_a] = tag_a, [uri_b] = tag_b } + db.list_rows = {} + db.actor_rows = { [PROFILE] = { profile_a }, [ORGANIZATION] = { organization_a } } + db.fail_collection = nil + db.backend = function() return "postgres" end + db.raw = function(sql, values) + db.calls[#db.calls + 1] = { sql = sql, values = values } + if db.fail_collection == values[1] then error("simulated database outage") end + if sql:find("FROM happyview_records WHERE collection = $1 AND uri = $2", 1, true) then + assert(values[1] == TAG, "exact lookup must constrain the collection") + local found = db.lookup_rows[values[2]] + return found and { found } or {} + end + if sql:find("FROM happyview_records AS workscope_tag", 1, true) then + return db.list_rows + end + if sql:find("WHERE collection = $1 AND rkey = 'self'", 1, true) then + local matches, wanted = {}, {} + for index = 2, #values do wanted[values[index]] = true end + for _, actor_row in ipairs(db.actor_rows[values[1]] or {}) do + if wanted[actor_row.did] then matches[#matches + 1] = actor_row end + end + return matches + end + error("unexpected SQL: " .. sql) + end +end + +local function assert_equal(actual, expected, message) + if actual ~= expected then + error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) + end +end + +local function assert_contains(value, expected, message) + if not value:find(expected, 1, true) then error((message or "text did not contain expected value") .. ": " .. value) end +end + +local function assert_error(callback, expected) + local ok, result = pcall(callback) + if ok then error("expected an error containing " .. expected) end + assert_contains(tostring(result), expected) +end + +local function cursor_token(timestamp, uri) + local payload = '{"v":1,"d":"desc","t":"' .. timestamp .. '","u":"' .. uri .. '"}' + return (payload:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local function test(name, callback) + local ok, message = pcall(callback) + if not ok then error(name .. ": " .. tostring(message), 0) end + print("ok - " .. name) +end + +dofile("lua/endpoints/getWorkscopeTag.lua") +local get_workscope_tag = handle +dofile("lua/endpoints/listWorkscopeTags.lua") +local list_workscope_tags = handle + +test("exact lookup preserves the record and hydrates nullable publisher sidecars", function() + reset_database() + params = { uri = uri_b } + local response = get_workscope_tag() + local view = response.workscopeTag + assert_equal(view.uri, uri_b) + assert_equal(view.did, did_b) + assert_equal(view.record, tag_b_record, "record must be returned unchanged") + assert_equal(view.record.supersededBy.uri, tag_b_record.supersededBy.uri, "references remain unexpanded") + assert_equal(view.author.did, did_b) + assert_equal(view.author.profile, NULL, "missing profile is JSON null") + assert_equal(view.author.organization, NULL, "missing organization is JSON null") + assert_contains(db.calls[1].sql, "collection = $1 AND uri = $2", "lookup must use the full URI") + assert_equal(db.calls[1].values[2], uri_b) +end) + +test("a missing exact URI returns RecordNotFound and malformed or non-tag URIs return InvalidRequest", function() + reset_database() + params = { uri = "at://did:web:missing.example/" .. TAG .. "/missing" } + assert_error(get_workscope_tag, "RecordNotFound") + params = { uri = "at://alice.example/" .. TAG .. "/same-rkey" } + assert_error(get_workscope_tag, "InvalidRequest") + params = { uri = "at://" .. did_a .. "/org.hypercerts.claim.activity/same-rkey" } + assert_error(get_workscope_tag, "InvalidRequest") +end) + +test("author hydration database failures surface instead of becoming null sidecars", function() + reset_database() + db.fail_collection = PROFILE + params = { uri = uri_a } + assert_error(get_workscope_tag, "WorkscopeTagQueryFailed") +end) + +test("listing applies publisher OR values and stable descending keyset pagination without merging equal keys", function() + reset_database() + db.list_rows = { tag_b, tag_a, tag_older } + params = { authors = { did_a, did_b, did_a }, limit = "2" } + local first = list_workscope_tags() + assert_equal(#first.workscopeTags, 2) + assert_equal(first.workscopeTags[1].uri, uri_b) + assert_equal(first.workscopeTags[2].uri, uri_a) + assert_equal(first.workscopeTags[1].record.key, first.workscopeTags[2].record.key) + assert_equal(first.workscopeTags[1].record, tag_b_record, "listing must preserve the full record") + assert_equal(first.workscopeTags[1].uri == first.workscopeTags[2].uri, false, "equal keys from separate repos stay distinct") + assert_equal(first.workscopeTags[1].author.profile, NULL) + assert_equal(first.workscopeTags[2].author.profile.record, author_profile_record) + assert_equal(first.workscopeTags[2].author.organization.record, author_organization_record) + assert_equal(type(first.cursor), "string", "a next page has an opaque cursor") + + local query = db.calls[1] + assert_contains(query.sql, "workscope_tag.did IN ($2, $3)", "authors must use OR-compatible IN filtering") + assert_contains(query.sql, "workscope_tag.record::jsonb->>'createdAt'", "the primary sort key is the record timestamp") + assert_contains(query.sql, "ORDER BY sorted.sort_at DESC, workscope_tag.uri DESC", "descending order uses both stable keys") + assert_equal(query.values[2], did_a) + assert_equal(query.values[3], did_b) + assert_equal(query.values[4], 3, "limit+1 detects another page") + + db.calls = {} + db.list_rows = { tag_older } + params = { authors = { did_a, did_b }, limit = "2", cursor = first.cursor } + local second = list_workscope_tags() + assert_equal(#second.workscopeTags, 1) + assert_equal(second.workscopeTags[1].uri, uri_older) + assert_equal(second.cursor, nil, "the final page omits its cursor") + query = db.calls[1] + assert_contains(query.sql, ") < ((", "descending continuation uses a strict keyset boundary") + assert_contains(query.sql, "(sorted.sort_at, workscope_tag.uri)", "cursor includes the URI tie-breaker") + assert_equal(query.values[4], "2025-01-02T00:00:00.000000Z") + assert_equal(query.values[5], uri_a) +end) + +test("listing safely casts only valid zoned createdAt before timestamp fallbacks", function() + reset_database() + db.list_rows = { tag_a } + params = { limit = "1" } + list_workscope_tags() + + local sql = db.calls[1].sql + assert_contains(sql, "(Z|[+-][0-9]{2}:[0-9]{2})$'", + "createdAt must carry an explicit timezone to be used for ordering") + assert_contains(sql, " !~ '-00:00$'", "unknown local-offset timestamps are not valid zoned createdAt values") + assert_contains(sql, "pg_input_is_valid(", "malformed record timestamps must not be cast directly") + assert_contains(sql, "THEN (workscope_tag.record::jsonb->>'createdAt')::timestamptz END, CASE WHEN pg_input_is_valid(workscope_tag.indexed_at, 'timestamp with time zone') THEN workscope_tag.indexed_at::timestamptz END, CASE WHEN pg_input_is_valid(workscope_tag.created_at, 'timestamp with time zone') THEN workscope_tag.created_at::timestamptz END)", + "valid createdAt sorts first, followed by guarded index time and row creation time") + assert_contains(sql, "ORDER BY sorted.sort_at DESC, workscope_tag.uri DESC", + "fallback timestamps retain stable timestamp-and-URI ordering") +end) + +test("ascending order and defaults are honored and cursors are direction-bound", function() + reset_database() + db.list_rows = { tag_older, tag_a } + params = { sortDirection = "asc", limit = "1" } + local first = list_workscope_tags() + assert_equal(first.workscopeTags[1].uri, uri_older) + local query = db.calls[1] + assert_contains(query.sql, "ORDER BY sorted.sort_at ASC, workscope_tag.uri ASC") + assert_equal(query.values[#query.values], 2) + + db.calls = {} + db.list_rows = { tag_a, tag_b } + params = { sortDirection = "asc", limit = "1", cursor = first.cursor } + local second = list_workscope_tags() + assert_equal(second.workscopeTags[1].uri, uri_a) + assert_contains(db.calls[1].sql, ") > ((", "ascending continuation uses a strict keyset boundary") + assert_equal(db.calls[1].values[2], "2024-12-31T23:59:59.000000Z") + assert_equal(db.calls[1].values[3], uri_older) + + db.calls = {} + params = { sortDirection = "desc", cursor = first.cursor } + assert_error(list_workscope_tags, "sortDirection") + assert_equal(#db.calls, 0, "a direction-mismatched cursor is rejected before querying") + + params = {} + db.list_rows = {} + list_workscope_tags() + query = db.calls[1] + assert_contains(query.sql, "ORDER BY sorted.sort_at DESC, workscope_tag.uri DESC") + assert_equal(query.values[#query.values], 26, "default page size is 25 with one lookahead") +end) + +test("invalid list parameters are rejected before querying", function() + reset_database() + params = { authors = { "not-a-did" } } + assert_error(list_workscope_tags, "InvalidRequest") + params = { authors = {} } + for index = 1, 101 do params.authors[index] = "did:web:author" .. index .. ".example" end + assert_error(list_workscope_tags, "at most 100") + params = { limit = "0" } + assert_error(list_workscope_tags, "InvalidRequest") + params = { extra = "no" } + assert_error(list_workscope_tags, "unknown query parameter") + params = { authors = { did_a }, cursor = "not-hex" } + assert_error(list_workscope_tags, "cursor is malformed") + assert_equal(#db.calls, 0, "invalid requests do not reach the database") +end) + +test("tag timestamps stay explicit null while missing sidecar timestamps stay omitted", function() + reset_database() + db.actor_rows[PROFILE] = { row(profile_a.uri, did_a, profile_a.cid, "profile-a", nil) } + db.lookup_rows[uri_a] = row(uri_a, did_a, "bafyreitag-a", "tag-a", nil) + params = { uri = uri_a } + local exact = get_workscope_tag().workscopeTag + assert_equal(exact.indexedAt, NULL) + assert_equal(exact.author.profile.indexedAt, nil, "missing sidecar indexedAt must remain omitted") + assert_equal(exact.author.organization.indexedAt, "2025-01-03T00:00:00Z", "present sidecar indexedAt must remain unchanged") + + db.calls = {} + db.list_rows = { row(uri_a, did_a, "bafyreitag-a", "tag-a", nil, "2025-01-02T00:00:00.000000Z") } + params = {} + local listed = list_workscope_tags().workscopeTags[1] + assert_equal(listed.indexedAt, NULL) + assert_equal(listed.author.profile.indexedAt, nil, "list sidecar indexedAt must remain omitted") + assert_equal(listed.author.organization.indexedAt, "2025-01-03T00:00:00Z", "list sidecar timestamp must remain unchanged") +end) + +test("malformed percent DIDs and year-zero cursors are rejected before querying", function() + reset_database() + params = { authors = { "did:plc:publisher%GG" } } + assert_error(list_workscope_tags, "InvalidRequest") + assert_equal(#db.calls, 0, "malformed author DIDs do not reach the database") + + params = { uri = "at://did:plc:publisher%GG/" .. TAG .. "/tag" } + assert_error(get_workscope_tag, "InvalidRequest") + assert_equal(#db.calls, 0, "malformed exact-lookup DIDs do not reach the database") + + params = { cursor = cursor_token("2025-01-02T00:00:00Z", "at://did:plc:publisher%GG/" .. TAG .. "/tag") } + assert_error(list_workscope_tags, "InvalidRequest") + assert_equal(#db.calls, 0, "malformed cursor URIs do not reach the database") + + params = { cursor = cursor_token("0000-01-01T00:00:00Z", uri_a) } + assert_error(list_workscope_tags, "InvalidRequest") + assert_equal(#db.calls, 0, "year-zero cursor timestamps do not reach the database") +end) + +print("workscope tag offline behavior tests passed")