From 541972ad63e4250ef465dca7e12ca3a1ad524258 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 00:40:01 +0600 Subject: [PATCH 1/5] workscope: add tag lookup and listing queries --- README.md | 4 +- api/README.md | 14 +- api/lexicons/org.hypercerts.api.defs.json | 13 + ....hypercerts.workscope.getWorkscopeTag.json | 49 ++++ ...ypercerts.workscope.listWorkscopeTags.json | 74 +++++ api/lua/endpoints/getWorkscopeTag.lua | 129 +++++++++ api/lua/endpoints/listWorkscopeTags.lua | 274 ++++++++++++++++++ api/lua/shared/workscopeTag.lua | 26 ++ api/lua/shared/workscopeTagList.lua | 118 ++++++++ api/lua/src/getWorkscopeTag.lua | 21 ++ api/lua/src/listWorkscopeTags.lua | 6 + api/lua/tests/workscopeTags.test.lua | 262 +++++++++++++++++ api/manifest.json | 5 +- api/modules/shared/manifest.json | 3 +- api/modules/workscope-tags/manifest.json | 68 +++++ api/package.json | 3 +- api/tooling/lexicons.test.js | 28 ++ 17 files changed, 1091 insertions(+), 6 deletions(-) create mode 100644 api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json create mode 100644 api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json create mode 100644 api/lua/endpoints/getWorkscopeTag.lua create mode 100644 api/lua/endpoints/listWorkscopeTags.lua create mode 100644 api/lua/shared/workscopeTag.lua create mode 100644 api/lua/shared/workscopeTagList.lua create mode 100644 api/lua/src/getWorkscopeTag.lua create mode 100644 api/lua/src/listWorkscopeTags.lua create mode 100644 api/lua/tests/workscopeTags.test.lua create mode 100644 api/modules/workscope-tags/manifest.json diff --git a/README.md b/README.md index 512021e..2a55bbc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hypercerts API toolkit foundation -This repository branch contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, and offline checks. It contains shared view Lexicons only; endpoint-specific Lua handlers are added by capability branches. +This repository contains the shared HappyView installer, pinned Lexicon dependencies, reusable Lua projections, fixtures, offline checks, and public work-scope-tag lookup/listing handlers. Other endpoint handlers are added by capability modules. ## Checks @@ -10,6 +10,6 @@ pnpm check pnpm build ``` -`pnpm check` validates the pinned Lexicon closure, lint, types, and unit tests. `pnpm build` refreshes declared Lua handler bundles; there are no endpoint handlers in this foundation branch. +`pnpm check` validates the pinned Lexicon closure, lint, types, and unit tests. `pnpm build` refreshes declared Lua handler bundles, including the work-scope-tag queries. See [`api/README.md`](api/README.md) for installer and fixture details. The `LICENSE.md` file retains the upstream MIT notice. diff --git a/api/README.md b/api/README.md index 5fdebc7..fbc685b 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,18 @@ # 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. +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. The `workscope-tags` module adds public lookup and listing queries for indexed `org.hypercerts.workscope.tag` records. + +## 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 `(createdAt, uri)` order; 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 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. ## Local checks diff --git a/api/lexicons/org.hypercerts.api.defs.json b/api/lexicons/org.hypercerts.api.defs.json index 288c4eb..476cbf9 100644 --- a/api/lexicons/org.hypercerts.api.defs.json +++ b/api/lexicons/org.hypercerts.api.defs.json @@ -40,6 +40,19 @@ "organization": { "type": "ref", "ref": "#organizationView" } } }, + "workscopeTagView": { + "type": "object", + "description": "Work-scope tag view with its hydrated publisher and unchanged record.", + "required": ["uri", "cid", "indexedAt", "did", "author", "record"], + "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": "#actorView" }, + "record": { "type": "ref", "ref": "org.hypercerts.workscope.tag" } + } + }, "entityFollowRecordView": { "type": "object", "description": "Raw indexed entity-follow record view, including the DID that published the relationship.", diff --git a/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json new file mode 100644 index 0000000..cf3b453 --- /dev/null +++ b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json @@ -0,0 +1,49 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.workscope.getWorkscopeTag", + "defs": { + "main": { + "type": "query", + "description": "Looks up a work-scope tag by exact AT-URI and returns its publisher actor; 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." + } + ] + }, + "output": { + "type": "object", + "required": ["workscopeTag"], + "properties": { + "workscopeTag": { + "type": "ref", + "ref": "org.hypercerts.api.defs#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..22df10c --- /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 work-scope tags by publisher DID in (createdAt, uri) order; 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 (createdAt, uri); 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.api.defs#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..0268c07 --- /dev/null +++ b/api/lua/endpoints/getWorkscopeTag.lua @@ -0,0 +1,129 @@ +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter") end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection +end + +local 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 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) + if type(row.indexed_at) ~= "string" then + error("WorkscopeTagQueryFailed: indexed work-scope tag has no indexedAt timestamp", 0) + end + return { + uri = row.uri, + cid = row.cid, + indexedAt = 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 = 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..7a450df --- /dev/null +++ b/api/lua/endpoints/listWorkscopeTags.lua @@ -0,0 +1,274 @@ +local function invalid(message) + error("InvalidRequest: " .. message, 0) +end + +local function keys_only(values, allowed) + for key in pairs(values) do + if not allowed[key] then invalid("unknown query parameter") end + end +end + +local function scalar(params, key) + local value = params[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection +end + +local function valid_datetime(value) + local year, month, day, hour, minute, second, suffix = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return false end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local month_days = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if day < 1 or day > month_days[month] then return false end + local fraction, zone = suffix:match("^(%.%d+)(Z)$") + if not fraction then fraction, zone = suffix:match("^(%.%d+)([+-]%d%d:%d%d)$") end + if not fraction then zone = suffix:match("^(Z)$") end + if not zone then zone = suffix:match("^([+-]%d%d:%d%d)$") end + if not zone or zone == "-00:00" then return false end + if zone ~= "Z" then + local zh, zm = zone:match("^[+-](%d%d):(%d%d)$") + if not zh or tonumber(zh) > 23 or tonumber(zm) > 59 then return false end + end + return true +end + +local function parse_list_limit(params) + local limit_value = scalar(params, "limit") + if limit_value and not limit_value:match("^%d+$") then invalid("limit must be an integer from 1 through 100") end + local limit = limit_value and tonumber(limit_value) or 25 + if not limit or limit % 1 ~= 0 or limit < 1 or limit > 100 then invalid("limit must be an integer from 1 through 100") end + return limit +end + +local function parse_sort_direction(params) + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + return direction +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local 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 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) + if type(row.indexed_at) ~= "string" then + error("WorkscopeTagQueryFailed: indexed work-scope tag has no indexedAt timestamp", 0) + end + return { + uri = row.uri, + cid = row.cid, + indexedAt = 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 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 = valid_record_uri(value.u) + if not valid_datetime(value.t) 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 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 (" .. created_at .. + ")::timestamptz 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/workscopeTag.lua b/api/lua/shared/workscopeTag.lua new file mode 100644 index 0000000..6b9f070 --- /dev/null +++ b/api/lua/shared/workscopeTag.lua @@ -0,0 +1,26 @@ +local WORKSCOPE_TAG = "org.hypercerts.workscope.tag" + +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) + if type(row.indexed_at) ~= "string" then + error("WorkscopeTagQueryFailed: indexed work-scope tag has no indexedAt timestamp", 0) + end + return { + uri = row.uri, + cid = row.cid, + indexedAt = 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..543535a --- /dev/null +++ b/api/lua/shared/workscopeTagList.lua @@ -0,0 +1,118 @@ +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 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 = valid_record_uri(value.u) + if not valid_datetime(value.t) 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 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 (" .. created_at .. + ")::timestamptz 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..67304bb --- /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 = 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/lua/tests/workscopeTags.test.lua b/api/lua/tests/workscopeTags.test.lua new file mode 100644 index 0000000..be0d24a --- /dev/null +++ b/api/lua/tests/workscopeTags.test.lua @@ -0,0 +1,262 @@ +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 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") + db.lookup_rows[uri_a] = row(uri_a, did_a, "bafyreitag-a", "tag-a", nil) + params = { uri = uri_a } + assert_error(get_workscope_tag, "WorkscopeTagQueryFailed") +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("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) + +print("workscope tag offline behavior tests passed") diff --git a/api/manifest.json b/api/manifest.json index 9436815..aa7177a 100644 --- a/api/manifest.json +++ b/api/manifest.json @@ -7,6 +7,8 @@ { "id": "app.certified.graph.entityFollow", "packagePath": "lexicons/app/certified/graph/entityFollow.json" }, { "id": "app.certified.actor.profile", "packagePath": "lexicons/app/certified/actor/profile.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.collection.getCollection", "path": "lexicons/org.hypercerts.collection.getCollection.json" }, { "id": "org.hypercerts.collection.listCollectionItems", "path": "lexicons/org.hypercerts.collection.listCollectionItems.json" }, @@ -20,6 +22,7 @@ { "id": "com.atproto.repo.strongRef", "packagePath": "lexicons/com/atproto/repo/strongRef.json" }, { "id": "org.hypercerts.defs", "packagePath": "lexicons/org/hypercerts/defs.json" }, { "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" }, { "id": "org.hypercerts.claim.contributorInformation", "packagePath": "lexicons/org/hypercerts/claim/contributorInformation.json" }, { "id": "org.hypercerts.collection", "packagePath": "lexicons/org/hypercerts/collection.json" }, @@ -51,5 +54,5 @@ { "id": "pub.leaflet.richtext.facet", "packagePath": "lexicons/pub/leaflet/richtext/facet.json" }, { "id": "pub.leaflet.theme.color", "packagePath": "lexicons/pub/leaflet/theme/color.json" } ], - "modules": ["modules/shared/manifest.json"] + "modules": ["modules/shared/manifest.json", "modules/workscope-tags/manifest.json"] } diff --git a/api/modules/shared/manifest.json b/api/modules/shared/manifest.json index a7ebb95..98791a0 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -17,12 +17,13 @@ { "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", "path": "../../lexicons/org.hypercerts.api.defs.json", "config": { "backfill": false }, - "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile"] + "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile", "org.hypercerts.workscope.tag"] }, { "kind": "lexicon", diff --git a/api/modules/workscope-tags/manifest.json b/api/modules/workscope-tags/manifest.json new file mode 100644 index 0000000..53dbd98 --- /dev/null +++ b/api/modules/workscope-tags/manifest.json @@ -0,0 +1,68 @@ +{ + "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/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/recordView.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.tag", + "org.hypercerts.api.defs" + ] + }, + { + "kind": "script", + "id": "xrpc.query:org.hypercerts.workscope.listWorkscopeTags", + "path": "../../lua/endpoints/listWorkscopeTags.lua", + "sourcePath": "../../lua/src/listWorkscopeTags.lua", + "sharedSourcePaths": [ + "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", + "../../lua/shared/listQuery.lua", + "../../lua/shared/recordView.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/package.json b/api/package.json index 8fb1d5b..8db2891 100644 --- a/api/package.json +++ b/api/package.json @@ -17,7 +17,8 @@ "check:generated": "node tooling/build-lua.js --check", "check": "pnpm run check:generated && pnpm run lint && pnpm run typecheck && pnpm run test:unit", "build:lua": "node tooling/build-lua.js", - "test:unit": "node --test tooling/*.test.js tests/fixtures/*.test.js tests/contracts/helpers.test.js", + "test:unit": "node --test tooling/*.test.js tests/fixtures/*.test.js tests/contracts/helpers.test.js && pnpm run test:lua", + "test:lua": "lua lua/tests/workscopeTags.test.lua", "install:api": "node tooling/installer.js", "seed:test": "node tooling/seed.js", "seed:bad-dates": "node tooling/seed.js --bad-dates" diff --git a/api/tooling/lexicons.test.js b/api/tooling/lexicons.test.js index 127493d..3a0bdcd 100644 --- a/api/tooling/lexicons.test.js +++ b/api/tooling/lexicons.test.js @@ -37,6 +37,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); @@ -109,6 +110,33 @@ 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 share the indexed record view 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); + + const view = lexicons.getDefOrThrow('org.hypercerts.api.defs#workscopeTagView'); + assert.deepEqual(view.required, ['uri', 'cid', 'indexedAt', 'did', 'author', 'record']); + 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.api.defs#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.api.defs#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])); From 0dacd864ac012a8b05c934ef7c1e44d6e41eccc5 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 01:19:26 +0600 Subject: [PATCH 2/5] workscope: handle missing timestamps and reject malformed query inputs --- api/README.md | 2 +- api/lexicons/org.hypercerts.api.defs.json | 3 +- ....hypercerts.workscope.getWorkscopeTag.json | 2 +- ...ypercerts.workscope.listWorkscopeTags.json | 4 +- api/lua/endpoints/getWorkscopeTag.lua | 26 +++++++-- api/lua/endpoints/listWorkscopeTags.lua | 38 +++++++++--- api/lua/shared/workscopeTag.lua | 24 ++++++-- api/lua/shared/workscopeTagList.lua | 14 +++-- api/lua/src/getWorkscopeTag.lua | 2 +- api/lua/tests/workscopeTags.test.lua | 58 ++++++++++++++++++- api/tooling/lexicons.test.js | 1 + 11 files changed, 142 insertions(+), 32 deletions(-) diff --git a/api/README.md b/api/README.md index fbc685b..f43934b 100644 --- a/api/README.md +++ b/api/README.md @@ -10,7 +10,7 @@ Both queries are public and require no authentication. Lookup uses the exact rec /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 `(createdAt, uri)` order; 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 profile or organization sidecar is `null`, while query/hydration failures are returned as errors. Parent and other record references are not expanded. +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. diff --git a/api/lexicons/org.hypercerts.api.defs.json b/api/lexicons/org.hypercerts.api.defs.json index 476cbf9..bdd121c 100644 --- a/api/lexicons/org.hypercerts.api.defs.json +++ b/api/lexicons/org.hypercerts.api.defs.json @@ -42,8 +42,9 @@ }, "workscopeTagView": { "type": "object", - "description": "Work-scope tag view with its hydrated publisher and unchanged record.", + "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" }, diff --git a/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json index cf3b453..2e35d1a 100644 --- a/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json +++ b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Looks up a work-scope tag by exact AT-URI and returns its publisher actor; authentication is not required.", + "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"], diff --git a/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json b/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json index 22df10c..961e5ed 100644 --- a/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json +++ b/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Lists work-scope tags by publisher DID in (createdAt, uri) order; authentication is not required.", + "description": "Lists indexed work-scope tags by publisher DID with stable timestamp-and-URI ordering; authentication is not required.", "parameters": { "type": "params", "properties": { @@ -21,7 +21,7 @@ "type": "string", "enum": ["asc", "desc"], "default": "desc", - "description": "Sort direction for (createdAt, uri); defaults to desc." + "description": "Sort direction for timestamp-and-URI ordering; defaults to desc." }, "limit": { "type": "integer", diff --git a/api/lua/endpoints/getWorkscopeTag.lua b/api/lua/endpoints/getWorkscopeTag.lua index 0268c07..0fbe935 100644 --- a/api/lua/endpoints/getWorkscopeTag.lua +++ b/api/lua/endpoints/getWorkscopeTag.lua @@ -80,6 +80,25 @@ local function hydrate_actor_views(actors, run_query) 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 = valid_record_uri(value) + local authority = type(value) == "string" and value:match("^at://([^/]+)/") + return valid and workscope_tag_valid_did(authority), collection +end local function workscope_tag_query(sql, values) if db.backend() ~= "postgres" then @@ -93,13 +112,10 @@ local function workscope_tag_query(sql, values) end local function workscope_tag_view(row) - if type(row.indexed_at) ~= "string" then - error("WorkscopeTagQueryFailed: indexed work-scope tag has no indexedAt timestamp", 0) - end return { uri = row.uri, cid = row.cid, - indexedAt = row.indexed_at, + 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), @@ -108,7 +124,7 @@ end local function workscope_tag_get(uri) keys_only(params, { uri = true }) - local valid, collection = valid_record_uri(uri) + 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 diff --git a/api/lua/endpoints/listWorkscopeTags.lua b/api/lua/endpoints/listWorkscopeTags.lua index 7a450df..fb49441 100644 --- a/api/lua/endpoints/listWorkscopeTags.lua +++ b/api/lua/endpoints/listWorkscopeTags.lua @@ -121,6 +121,25 @@ local function hydrate_actor_views(actors, run_query) 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 = valid_record_uri(value) + local authority = type(value) == "string" and value:match("^at://([^/]+)/") + return valid and workscope_tag_valid_did(authority), collection +end local function workscope_tag_query(sql, values) if db.backend() ~= "postgres" then @@ -134,13 +153,10 @@ local function workscope_tag_query(sql, values) end local function workscope_tag_view(row) - if type(row.indexed_at) ~= "string" then - error("WorkscopeTagQueryFailed: indexed work-scope tag has no indexedAt timestamp", 0) - end return { uri = row.uri, cid = row.cid, - indexedAt = row.indexed_at, + 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), @@ -174,7 +190,7 @@ local function workscope_tag_array(key) local unique, seen = {}, {} for _, did in ipairs(supplied) do - if not valid_did(did) then + 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 @@ -199,8 +215,8 @@ local function workscope_tag_decode_cursor(token, direction) 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 = valid_record_uri(value.u) - if not valid_datetime(value.t) or not valid or collection ~= WORKSCOPE_TAG then + 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 @@ -239,11 +255,15 @@ local function workscope_tag_list() 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 sort_at = "COALESCE(CASE WHEN " .. valid_zoned_created_at .. " THEN (" .. created_at .. + ")::timestamptz END, workscope_tag.indexed_at, workscope_tag.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 (" .. created_at .. - ")::timestamptz AS sort_at) AS sorted WHERE " .. table.concat(where, " AND ") .. + "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 diff --git a/api/lua/shared/workscopeTag.lua b/api/lua/shared/workscopeTag.lua index 6b9f070..8a6fe5c 100644 --- a/api/lua/shared/workscopeTag.lua +++ b/api/lua/shared/workscopeTag.lua @@ -1,4 +1,23 @@ 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 = valid_record_uri(value) + local authority = type(value) == "string" and value:match("^at://([^/]+)/") + return valid and workscope_tag_valid_did(authority), collection +end local function workscope_tag_query(sql, values) if db.backend() ~= "postgres" then @@ -12,13 +31,10 @@ local function workscope_tag_query(sql, values) end local function workscope_tag_view(row) - if type(row.indexed_at) ~= "string" then - error("WorkscopeTagQueryFailed: indexed work-scope tag has no indexedAt timestamp", 0) - end return { uri = row.uri, cid = row.cid, - indexedAt = row.indexed_at, + 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), diff --git a/api/lua/shared/workscopeTagList.lua b/api/lua/shared/workscopeTagList.lua index 543535a..67e4b7c 100644 --- a/api/lua/shared/workscopeTagList.lua +++ b/api/lua/shared/workscopeTagList.lua @@ -25,7 +25,7 @@ local function workscope_tag_array(key) local unique, seen = {}, {} for _, did in ipairs(supplied) do - if not valid_did(did) then + 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 @@ -50,8 +50,8 @@ local function workscope_tag_decode_cursor(token, direction) 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 = valid_record_uri(value.u) - if not valid_datetime(value.t) or not valid or collection ~= WORKSCOPE_TAG then + 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 @@ -90,11 +90,15 @@ local function workscope_tag_list() 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 sort_at = "COALESCE(CASE WHEN " .. valid_zoned_created_at .. " THEN (" .. created_at .. + ")::timestamptz END, workscope_tag.indexed_at, workscope_tag.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 (" .. created_at .. - ")::timestamptz AS sort_at) AS sorted WHERE " .. table.concat(where, " AND ") .. + "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 diff --git a/api/lua/src/getWorkscopeTag.lua b/api/lua/src/getWorkscopeTag.lua index 67304bb..00ff767 100644 --- a/api/lua/src/getWorkscopeTag.lua +++ b/api/lua/src/getWorkscopeTag.lua @@ -1,6 +1,6 @@ local function workscope_tag_get(uri) keys_only(params, { uri = true }) - local valid, collection = valid_record_uri(uri) + 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 diff --git a/api/lua/tests/workscopeTags.test.lua b/api/lua/tests/workscopeTags.test.lua index be0d24a..9149156 100644 --- a/api/lua/tests/workscopeTags.test.lua +++ b/api/lua/tests/workscopeTags.test.lua @@ -126,6 +126,11 @@ local function assert_error(callback, expected) 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 @@ -161,9 +166,6 @@ test("a missing exact URI returns RecordNotFound and malformed or non-tag URIs r assert_error(get_workscope_tag, "InvalidRequest") params = { uri = "at://" .. did_a .. "/org.hypercerts.claim.activity/same-rkey" } assert_error(get_workscope_tag, "InvalidRequest") - db.lookup_rows[uri_a] = row(uri_a, did_a, "bafyreitag-a", "tag-a", nil) - params = { uri = uri_a } - assert_error(get_workscope_tag, "WorkscopeTagQueryFailed") end) test("author hydration database failures surface instead of becoming null sidecars", function() @@ -211,6 +213,23 @@ test("listing applies publisher OR values and stable descending keyset paginatio 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, workscope_tag.indexed_at, workscope_tag.created_at)", + "valid createdAt sorts first, followed by 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 } @@ -259,4 +278,37 @@ test("invalid list parameters are rejected before querying", function() assert_equal(#db.calls, 0, "invalid requests do not reach the database") end) +test("missing indexedAt remains present as JSON null in exact and list views", function() + reset_database() + 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) + + 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) +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") diff --git a/api/tooling/lexicons.test.js b/api/tooling/lexicons.test.js index 3a0bdcd..5360696 100644 --- a/api/tooling/lexicons.test.js +++ b/api/tooling/lexicons.test.js @@ -121,6 +121,7 @@ if (hasModule('modules/workscope-tags/manifest.json')) test('work-scope tag quer const view = lexicons.getDefOrThrow('org.hypercerts.api.defs#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']); From 37735c7ef23b12b4ab01a5ad6a982a3eae76210c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 5 Oct 2026 19:32:07 +0600 Subject: [PATCH 3/5] workscope-tags: guard timestamp fallbacks in list queries --- .changeset/workscope-tag-sort-fallback-fix.md | 5 ++ api/lua/endpoints/listWorkscopeTags.lua | 7 +- api/lua/shared/workscopeTagList.lua | 7 +- .../http/fixtures/workscope-tags.fixture.js | 12 ++- api/tests/http/workscope-tags.http.test.js | 88 ++++++++++++++----- api/tests/unit/workscopeTags.test.lua | 4 +- 6 files changed, 93 insertions(+), 30 deletions(-) create mode 100644 .changeset/workscope-tag-sort-fallback-fix.md 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/api/lua/endpoints/listWorkscopeTags.lua b/api/lua/endpoints/listWorkscopeTags.lua index fb49441..79f5d25 100644 --- a/api/lua/endpoints/listWorkscopeTags.lua +++ b/api/lua/endpoints/listWorkscopeTags.lua @@ -257,8 +257,13 @@ local function workscope_tag_list() 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, workscope_tag.indexed_at, workscope_tag.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 " .. diff --git a/api/lua/shared/workscopeTagList.lua b/api/lua/shared/workscopeTagList.lua index 67e4b7c..d690bdd 100644 --- a/api/lua/shared/workscopeTagList.lua +++ b/api/lua/shared/workscopeTagList.lua @@ -92,8 +92,13 @@ local function workscope_tag_list() 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, workscope_tag.indexed_at, workscope_tag.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 " .. diff --git a/api/tests/http/fixtures/workscope-tags.fixture.js b/api/tests/http/fixtures/workscope-tags.fixture.js index 76c245a..1404a75 100644 --- a/api/tests/http/fixtures/workscope-tags.fixture.js +++ b/api/tests/http/fixtures/workscope-tags.fixture.js @@ -30,6 +30,12 @@ const tagSpecs = [ 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', @@ -37,7 +43,7 @@ const tagSpecs = [ }, ]; -async function seedRow(collection, did, rkey, fields) { +async function seedRow(collection, did, rkey, fields, rowIndexedAt = indexedAt) { const record = { $type: collection, ...fields }; return { uri: `at://${did}/${collection}/${rkey}`, @@ -45,13 +51,13 @@ async function seedRow(collection, did, rkey, fields) { collection, rkey, cid: CID.toString(await CID.create(0x71, encode(record))), - indexedAt, + indexedAt: rowIndexedAt, record, }; } export const seedRows = await Promise.all([ - ...tagSpecs.map(({ did, rkey, record }) => seedRow(workscopeTag, did, rkey, record)), + ...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.', diff --git a/api/tests/http/workscope-tags.http.test.js b/api/tests/http/workscope-tags.http.test.js index 618dc7d..0c62d68 100644 --- a/api/tests/http/workscope-tags.http.test.js +++ b/api/tests/http/workscope-tags.http.test.js @@ -13,6 +13,7 @@ const tagUris = { 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`, }; @@ -82,15 +83,45 @@ test('listWorkscopeTags ORs repeated author filters and omits unselected publish tagUris.tieB, tagUris.tieZ, tagUris.tieA, + tagUris.unknownOffset, tagUris.older, ]); - assert.equal(body.workscopeTags[0].author.profile, null); - assert.equal(body.workscopeTags[0].author.organization, null); - assert.equal(body.workscopeTags[1].author.profile.record.displayName, 'Workscope Test Publisher'); - assert.deepEqual(body.workscopeTags[1].author.organization.record.organizationType, ['community']); + 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)); @@ -121,48 +152,59 @@ test('listWorkscopeTags returns no rows for an unmatched author filter', async ( }); test('listWorkscopeTags paginates tied timestamps in descending timestamp-and-URI order', async () => { - const first = await request(listEndpoint, { authors: [publisherA, publisherB], limit: 2 }); + 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: [publisherA, publisherB], - limit: 2, - cursor: first.body.cursor, - }); + 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.older]); - assert.equal(Object.hasOwn(second.body, 'cursor'), false); - assert.deepEqual([...first.body.workscopeTags, ...second.body.workscopeTags].map(({ uri }) => uri), [ + 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 first = await request(listEndpoint, { - authors: [publisherA, publisherB], - sortDirection: 'asc', - limit: 2, - }); + 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.tieA]); + 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: [publisherA, publisherB], + 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.tieZ, tagUris.tieB]); - assert.equal(Object.hasOwn(second.body, 'cursor'), false); - assert.deepEqual([...first.body.workscopeTags, ...second.body.workscopeTags].map(({ uri }) => uri), [ + 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, diff --git a/api/tests/unit/workscopeTags.test.lua b/api/tests/unit/workscopeTags.test.lua index 9149156..e5cbbbd 100644 --- a/api/tests/unit/workscopeTags.test.lua +++ b/api/tests/unit/workscopeTags.test.lua @@ -224,8 +224,8 @@ test("listing safely casts only valid zoned createdAt before timestamp fallbacks "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, workscope_tag.indexed_at, workscope_tag.created_at)", - "valid createdAt sorts first, followed by index time and row creation time") + 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) From c07d9d05ec8e060f962504653770514c89ab9ba8 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 19:09:23 +0600 Subject: [PATCH 4/5] workscope-tags: reuse Lua validators and relocate tag view --- .changeset/workscope-tag-view-owner.md | 5 ++++ api/lexicons/org.hypercerts.api.defs.json | 14 --------- ....hypercerts.workscope.getWorkscopeTag.json | 16 +++++++++- ...ypercerts.workscope.listWorkscopeTags.json | 2 +- api/lua/endpoints/getWorkscopeTag.lua | 28 +++++++++--------- api/lua/endpoints/listWorkscopeTags.lua | 29 ++++++++++--------- api/lua/shared/workscopeRecordView.lua | 12 ++++++++ api/lua/shared/workscopeTag.lua | 3 +- api/modules/shared/manifest.json | 2 +- api/modules/workscope-tags/manifest.json | 10 ++++--- api/tests/unit/tooling/lexicons.test.js | 9 +++--- api/tests/unit/workscopeTags.test.lua | 7 ++++- 12 files changed, 83 insertions(+), 54 deletions(-) create mode 100644 .changeset/workscope-tag-view-owner.md create mode 100644 api/lua/shared/workscopeRecordView.lua 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/api/lexicons/org.hypercerts.api.defs.json b/api/lexicons/org.hypercerts.api.defs.json index bdd121c..288c4eb 100644 --- a/api/lexicons/org.hypercerts.api.defs.json +++ b/api/lexicons/org.hypercerts.api.defs.json @@ -40,20 +40,6 @@ "organization": { "type": "ref", "ref": "#organizationView" } } }, - "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": "#actorView" }, - "record": { "type": "ref", "ref": "org.hypercerts.workscope.tag" } - } - }, "entityFollowRecordView": { "type": "object", "description": "Raw indexed entity-follow record view, including the DID that published the relationship.", diff --git a/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json index 2e35d1a..5ddb1a7 100644 --- a/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json +++ b/api/lexicons/org.hypercerts.workscope.getWorkscopeTag.json @@ -35,13 +35,27 @@ } ] }, + "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": "org.hypercerts.api.defs#workscopeTagView" + "ref": "#workscopeTagView" } } } diff --git a/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json b/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json index 961e5ed..5543888 100644 --- a/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json +++ b/api/lexicons/org.hypercerts.workscope.listWorkscopeTags.json @@ -61,7 +61,7 @@ "maxLength": 100, "items": { "type": "ref", - "ref": "org.hypercerts.api.defs#workscopeTagView" + "ref": "org.hypercerts.workscope.getWorkscopeTag#workscopeTagView" } }, "cursor": { diff --git a/api/lua/endpoints/getWorkscopeTag.lua b/api/lua/endpoints/getWorkscopeTag.lua index 0fbe935..76b50ac 100644 --- a/api/lua/endpoints/getWorkscopeTag.lua +++ b/api/lua/endpoints/getWorkscopeTag.lua @@ -1,10 +1,20 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + local function invalid(message) error("InvalidRequest: " .. message, 0) end -local function keys_only(values, allowed) +local function keys_only(values, allowed, unknown_message_prefix) for key in pairs(values) do - if not allowed[key] then invalid("unknown query parameter") end + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end end end @@ -17,14 +27,6 @@ local function scalar(params, key) return tostring(value) end -local function valid_did(value) - if #value > 2048 then return false end - local method, specific = value:match("^did:([a-z]+):(.+)$") - if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" - or value:find("[^%w%.:_%%%-]") then return false end - return true -end - local function valid_record_key(value) return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." and not value:find("[^%w_~%.:%-]") @@ -34,9 +36,10 @@ local function valid_record_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end - return true, collection + return true, collection, authority end +-- 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) @@ -95,8 +98,7 @@ local function workscope_tag_valid_did(value) end local function workscope_tag_valid_record_uri(value) - local valid, collection = valid_record_uri(value) - local authority = type(value) == "string" and value:match("^at://([^/]+)/") + local valid, collection, authority = valid_record_uri(value) return valid and workscope_tag_valid_did(authority), collection end diff --git a/api/lua/endpoints/listWorkscopeTags.lua b/api/lua/endpoints/listWorkscopeTags.lua index 79f5d25..642d014 100644 --- a/api/lua/endpoints/listWorkscopeTags.lua +++ b/api/lua/endpoints/listWorkscopeTags.lua @@ -1,10 +1,20 @@ +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + local function invalid(message) error("InvalidRequest: " .. message, 0) end -local function keys_only(values, allowed) +local function keys_only(values, allowed, unknown_message_prefix) for key in pairs(values) do - if not allowed[key] then invalid("unknown query parameter") end + if not allowed[key] then + invalid(unknown_message_prefix and (unknown_message_prefix .. key) or "unknown query parameter") + end end end @@ -17,14 +27,6 @@ local function scalar(params, key) return tostring(value) end -local function valid_did(value) - if #value > 2048 then return false end - local method, specific = value:match("^did:([a-z]+):(.+)$") - if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" - or value:find("[^%w%.:_%%%-]") then return false end - return true -end - local function valid_record_key(value) return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." and not value:find("[^%w_~%.:%-]") @@ -34,10 +36,11 @@ local function valid_record_uri(value) if type(value) ~= "string" or value:find("[?#]") then return false end local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end - return true, collection + return true, collection, authority end local function valid_datetime(value) + if type(value) ~= "string" then return false end local year, month, day, hour, minute, second, suffix = value:match( "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") if not year then return false end @@ -78,6 +81,7 @@ local function cursor_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) @@ -136,8 +140,7 @@ local function workscope_tag_valid_did(value) end local function workscope_tag_valid_record_uri(value) - local valid, collection = valid_record_uri(value) - local authority = type(value) == "string" and value:match("^at://([^/]+)/") + local valid, collection, authority = valid_record_uri(value) return valid and workscope_tag_valid_did(authority), collection 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 index 8a6fe5c..7fc1218 100644 --- a/api/lua/shared/workscopeTag.lua +++ b/api/lua/shared/workscopeTag.lua @@ -14,8 +14,7 @@ local function workscope_tag_valid_did(value) end local function workscope_tag_valid_record_uri(value) - local valid, collection = valid_record_uri(value) - local authority = type(value) == "string" and value:match("^at://([^/]+)/") + local valid, collection, authority = valid_record_uri(value) return valid and workscope_tag_valid_did(authority), collection end diff --git a/api/modules/shared/manifest.json b/api/modules/shared/manifest.json index 98791a0..0d59751 100644 --- a/api/modules/shared/manifest.json +++ b/api/modules/shared/manifest.json @@ -23,7 +23,7 @@ "id": "org.hypercerts.api.defs", "path": "../../lexicons/org.hypercerts.api.defs.json", "config": { "backfill": false }, - "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile", "org.hypercerts.workscope.tag"] + "dependsOn": ["app.certified.actor.organization", "app.certified.actor.profile"] }, { "kind": "lexicon", diff --git a/api/modules/workscope-tags/manifest.json b/api/modules/workscope-tags/manifest.json index 53dbd98..f576254 100644 --- a/api/modules/workscope-tags/manifest.json +++ b/api/modules/workscope-tags/manifest.json @@ -19,9 +19,10 @@ "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/recordView.lua", + "../../lua/shared/workscopeRecordView.lua", "../../lua/shared/actorView.lua", "../../lua/shared/workscopeTag.lua" ], @@ -40,8 +41,7 @@ "target_collection": "org.hypercerts.workscope.tag" }, "dependsOn": [ - "org.hypercerts.workscope.tag", - "org.hypercerts.api.defs" + "org.hypercerts.workscope.getWorkscopeTag" ] }, { @@ -50,10 +50,12 @@ "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/recordView.lua", + "../../lua/shared/workscopeRecordView.lua", "../../lua/shared/actorView.lua", "../../lua/shared/workscopeTag.lua", "../../lua/shared/workscopeTagList.lua" diff --git a/api/tests/unit/tooling/lexicons.test.js b/api/tests/unit/tooling/lexicons.test.js index 5316167..b702210 100644 --- a/api/tests/unit/tooling/lexicons.test.js +++ b/api/tests/unit/tooling/lexicons.test.js @@ -111,7 +111,7 @@ 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 share the indexed record view and declare pagination bounds', async () => { +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'); @@ -119,22 +119,23 @@ if (hasModule('modules/workscope-tags/manifest.json')) test('work-scope tag quer 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.api.defs#workscopeTagView'); + 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.api.defs#workscopeTagView'); + 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.api.defs#workscopeTagView'); + 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']); }); diff --git a/api/tests/unit/workscopeTags.test.lua b/api/tests/unit/workscopeTags.test.lua index e5cbbbd..893d670 100644 --- a/api/tests/unit/workscopeTags.test.lua +++ b/api/tests/unit/workscopeTags.test.lua @@ -278,18 +278,23 @@ test("invalid list parameters are rejected before querying", function() assert_equal(#db.calls, 0, "invalid requests do not reach the database") end) -test("missing indexedAt remains present as JSON null in exact and list views", function() +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() From 70cf28893c28d9e54585effdf5490f03c70b437c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 6 Oct 2026 20:38:23 +0600 Subject: [PATCH 5/5] api/tests: isolate acknowledgement fixture sidecars --- api/tests/http/acknowledgements.http.test.js | 10 +++++----- api/tests/http/fixtures/acknowledgements.fixture.js | 2 +- api/tests/unit/fixtures/http-seed-rows.test.js | 13 ++++++++++++- 3 files changed, 18 insertions(+), 7 deletions(-) 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/unit/fixtures/http-seed-rows.test.js b/api/tests/unit/fixtures/http-seed-rows.test.js index cac10c0..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 discovered HTTP seed rows have unique identities, isolated acknowledgement sidecars, 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,17 @@ test('shared and discovered HTTP seed rows have unique identities, isolated ackn ].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);