diff --git a/hypercerts-api/.luacheckrc b/hypercerts-api/.luacheckrc index aa1f3dda..51fef993 100644 --- a/hypercerts-api/.luacheckrc +++ b/hypercerts-api/.luacheckrc @@ -1,6 +1,6 @@ std = "lua54" globals = { "handle" } -read_globals = { "db", "json", "params", "toarray" } +read_globals = { "db", "env", "http", "json", "params", "toarray" } not_globals = { "io", "debug", "package", "require", "dofile", "loadfile", "load", "collectgarbage", "os.execute", "os.exit", "os.getenv", "os.remove", "os.rename", "os.setlocale", "os.tmpname", diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md index 404bcd04..95ec5d31 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -2,10 +2,12 @@ This directory contains the tooling for building and testing a Hypercerts XRPC API on HappyView. It provides a reusable installer for HappyView admin assets (Lexicons and Lua scripts), pinned Hypercerts Lexicon dependencies, and test fixtures for checking record behavior. The installer lets API modules declare their assets together, install missing assets in dependency order, and refuse to overwrite assets that differ from what is already installed. -**Current status:** The root `manifest.json` includes the location and actor-follow query slices. Build their Lua handlers and run the offline checks before installing the bundle on an approved HappyView target. +**Current status:** The root `manifest.json` includes the location, actor-follow, and profile query slices. Build their Lua handlers and run the offline checks before installing the bundle on an approved HappyView target. Only the location and actor-follow slices have runtime validation at the target revision; profile query HTTP proof remains unrun. The actor-follow slice exposes `app.certified.graph.getFollow`, `app.certified.graph.listActorFollowers`, and `app.certified.graph.listActorFollowing`, backed by the pinned `app.certified.graph.follow` record Lexicon. Both listing queries return a required `totalCount` for the complete deduplicated relationship set, independent of cursor and limit. Lookup and listings sort follows by `createdAt`; if it is missing or malformed, they use `indexed_at` (or the database insertion time when `indexed_at` is absent) so those follows remain visible and counted. +The profile slice exposes `app.certified.actor.getProfile`, `app.certified.actor.getProfiles`, `app.certified.actor.listProfiles`, and `app.certified.actor.searchProfiles`. `getProfiles` requires 1–100 supplied actor DIDs, counting duplicates before deduplication; it preserves input order and duplicates, returns an explicit `null` profile for missing actors, does not resolve handles or make network requests, and does not paginate. `listProfiles` accepts actor DIDs and pagination only; use `searchProfiles` for a required profile-text query over `displayName` and `description`. Both list and search preserve the original profile record and do not attach organization sidecars. `getProfile(actor=DID)` performs a direct indexed lookup without a resolver request. For `getProfile(actor=handle)`, set the HappyView instance script variable `HYPERCERTS_HANDLE_RESOLVER_URL` to an HTTPS resolver origin. Each Lua execution reads that setting and calls `com.atproto.identity.resolveHandle` at the configured resolver's `/xrpc/` endpoint; no per-request override or bsky.social default is used. A syntactically valid DID in that resolver response is accepted as identity confirmation: the operator trusts the selected resolver, and HappyView does not independently fetch or verify a DID document. Removing arbitrary `did:web` document fetching removes that specific SSRF path; this change does not secure resolver redirects or broader outbound egress. Use `listProfiles` when no text restriction is intended. + ## Get started From `hypercerts-api/`: @@ -16,15 +18,15 @@ pnpm build:lua pnpm check ``` -These checks run offline; they do not require a running HappyView instance or database. `build:lua` builds the standalone location and actor-follow handlers in `lua/endpoints/`. `pnpm check` verifies the generated Lua bundles are current, runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests; run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. Run `pnpm check:generated` to check bundle freshness on its own; it does not modify files. If it fails, run `pnpm build:lua` and review the generated changes. The bundle combines local API Lexicons and scripts with schemas from the pinned `@hypercerts-org/lexicon` package. HTTP contract tests require an installed bundle and a separately approved disposable target. +These checks run offline; they do not require a running HappyView instance or database. `build:lua` builds the standalone location, actor-follow, and profile handlers in `lua/endpoints/`. `pnpm check` verifies the generated Lua bundles are current, runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests; run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. Run `pnpm check:generated` to check bundle freshness on its own; it does not modify files. If it fails, run `pnpm build:lua` and review the generated changes. The bundle combines local API Lexicons and scripts with schemas from the pinned `@hypercerts-org/lexicon` package. HTTP contract tests require an installed bundle and a separately approved disposable target. Only location and actor-follow slices have runtime validation at the target revision; profile query HTTP proof remains unrun. -ESLint is installed with the package dependencies. It uses ESLint's recommended checks plus strict equality, no implicit coercion, no shadowed names, no reassigned parameters, no `var`, and `const` where possible. It also rejects direct `eval`, implied evaluation (such as string-based timers), and `Function` constructors. Console output is allowed only in CLI tooling, and unused ESLint disable directives fail lint. When a branch contains Lua scripts, Luacheck checks the generated handlers in `lua/endpoints/`; it rejects unknown globals, sandbox-removed Lua APIs, and unused arguments. It rejects `io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`, `collectgarbage`, and unsafe `os` functions; only `os.time`, `os.date`, `os.difftime`, and `os.clock` are allowed from `os`. Only the HappyView `handle` global is writable; `db`, `json`, `params`, and `toarray` are read-only. It targets Lua 5.4 and skips line-length checks for long SQL expressions. Install Lua 5.4 and LuaRocks, then run `luarocks --lua-version=5.4 --local install luacheck 1.2.0` to enable that check locally. The lint runner also finds the default `~/.luarocks/bin` install if it is not on `PATH`. Branches without Lua files skip Luacheck. If Lua sources exist but generated handlers are missing, run `pnpm build:lua` first. +ESLint is installed with the package dependencies. It uses ESLint's recommended checks plus strict equality, no implicit coercion, no shadowed names, no reassigned parameters, no `var`, and `const` where possible. It also rejects direct `eval`, implied evaluation (such as string-based timers), and `Function` constructors. Console output is allowed only in CLI tooling, and unused ESLint disable directives fail lint. When a branch contains Lua scripts, Luacheck checks the generated handlers in `lua/endpoints/`; it rejects unknown globals, sandbox-removed Lua APIs, and unused arguments. It rejects `io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`, `collectgarbage`, and unsafe `os` functions; only `os.time`, `os.date`, `os.difftime`, and `os.clock` are allowed from `os`. Only the HappyView `handle` global is writable; `db`, `http`, `json`, `params`, and `toarray` are read-only. It targets Lua 5.4 and skips line-length checks for long SQL expressions. Install Lua 5.4 and LuaRocks, then run `luarocks --lua-version=5.4 --local install luacheck 1.2.0` to enable that check locally. The lint runner also finds the default `~/.luarocks/bin` install if it is not on `PATH`. Branches without Lua files skip Luacheck. If Lua sources exist but generated handlers are missing, run `pnpm build:lua` first. ## Bootstrap a running HappyView instance After those offline checks pass, run this from `hypercerts-api/` to install the bundle on an approved, running HappyView instance. Unlike the checks above, this command contacts the instance and uploads assets. -For an interactive run, start the installer and enter the requested URL and admin token when prompted. Token input is hidden. Any nonblank values already set in the environment are used, and the installer prompts only for missing values. +For an interactive run, start the installer and enter the HappyView URL and admin token when prompted. Token input is hidden. If the bundle contains `getProfile` and `HYPERCERTS_HANDLE_RESOLVER_URL` is absent from HappyView, the installer also prompts for the resolver HTTPS base URL after checking existing assets and script variables. A pre-existing resolver variable is not prompted for or changed; HappyView only exposes its key and masked preview, so the installer cannot verify its actual value. Any nonblank HappyView URL/token values already set in the environment are used. ```sh pnpm install:api @@ -35,6 +37,7 @@ For a noninteractive run, provide both values in the environment: ```sh HAPPYVIEW_BASE_URL='https://your-happyview.example' \ HAPPYVIEW_ADMIN_TOKEN='' \ +HYPERCERTS_HANDLE_RESOLVER_URL='https://your-resolver.example' \ pnpm install:api ``` @@ -94,7 +97,7 @@ The root manifest's `validationLexicons` is the complete local schema closure us The bootstrap command above should target only an explicitly approved HappyView instance. It sends `Authorization: Bearer `; session-cookie authentication is not supported. -The token must have `lexicons:read` and `lexicons:create` for lexicon assets. If the bundle includes scripts, it also needs `scripts:read` and `scripts:manage`. If a manifest requests backfill for a new record lexicon, `backfill:create` is additionally needed to start that job; without it, the lexicon is still uploaded but no backfill starts. Remote targets must use HTTPS; HTTP is allowed only on `localhost`, `127.0.0.1`, or `::1`. URL credentials and HTTP redirects are rejected. Resolve installed-asset conflicts manually. The location and actor-follow handlers, and the fixtures, require PostgreSQL. +The token must have `lexicons:read` and `lexicons:create` for lexicon assets. If the bundle includes scripts, it also needs `scripts:read` and `scripts:manage`. For a bundle containing `getProfile`, it needs `script-variables:read` to check whether `HYPERCERTS_HANDLE_RESOLVER_URL` exists, and `script-variables:create` if that key is absent and must be set. HappyView's list response contains only keys and masked previews. The installer lists keys before prompting or creating the resolver variable and never deletes or intentionally overwrites an existing key. The HappyView POST endpoint is an upsert, so a concurrent change after the list preflight can still race with creation and overwrite the setting; avoid changing this key concurrently during installation. If a later asset write fails after setting creation, the installer reports that the setting remains and does not attempt rollback. If a manifest requests backfill for a new record lexicon, `backfill:create` is additionally needed to start that job; without it, the lexicon is still uploaded but no backfill starts. Remote admin targets must use HTTPS; HTTP is allowed only on `localhost`, `127.0.0.1`, or `::1`. Admin URL credentials and redirects are rejected. Resolver URLs must be an HTTPS origin with no credentials, path, query, or fragment. Hosts may be DNS names, IPv4 addresses, or syntactically valid bracketed IPv6 addresses; optional ports must be between 1 and 65535. Resolve installed-asset conflicts manually. The location, actor-follow, and profile handlers, and the fixtures, require PostgreSQL. ## Test fixtures (disposable databases only) diff --git a/hypercerts-api/lexicons/app.certified.actor.getProfile.json b/hypercerts-api/lexicons/app.certified.actor.getProfile.json new file mode 100644 index 00000000..3fd4dbdb --- /dev/null +++ b/hypercerts-api/lexicons/app.certified.actor.getProfile.json @@ -0,0 +1,43 @@ +{ + "lexicon": 1, + "id": "app.certified.actor.getProfile", + "defs": { + "main": { + "type": "query", + "description": "Public lookup of one indexed Certified profile by DID or handle. Handles are resolved through the configured resolver's com.atproto.identity.resolveHandle response; the response DID is validated, but no DID document is fetched for independent verification. Operators trust the resolver configured for their HappyView instance. Authentication is not required.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { + "type": "string", + "format": "at-identifier", + "description": "DID or handle of the profile actor." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "The actor is neither a valid DID nor a valid handle, or an unknown parameter was supplied." }, + { "name": "HandleResolverConfigError", "description": "HYPERCERTS_HANDLE_RESOLVER_URL is missing or invalid; configure an HTTPS resolver base URL for this HappyView instance." }, + { "name": "HandleResolutionFailed", "description": "The configured resolver request failed or returned a response without a valid DID." }, + { "name": "RecordNotFound", "description": "The actor was identified, but no indexed Certified profile exists." }, + { "name": "ProfileQueryFailed", "description": "The indexed profile could not be queried." } + ] + }, + "output": { + "type": "object", + "required": ["profile"], + "properties": { + "profile": { + "type": "ref", + "ref": "org.hypercerts.api.defs#profileView", + "description": "The complete indexed Certified profile record with record metadata and its repository DID." + } + } + } + } +} diff --git a/hypercerts-api/lexicons/app.certified.actor.getProfiles.json b/hypercerts-api/lexicons/app.certified.actor.getProfiles.json new file mode 100644 index 00000000..3d949f50 --- /dev/null +++ b/hypercerts-api/lexicons/app.certified.actor.getProfiles.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "app.certified.actor.getProfiles", + "defs": { + "main": { + "type": "query", + "description": "Returns one profile result for each supplied actor DID, preserving input order and duplicates. Authentication is not required. actors is a required nonempty array of 1..100 DIDs, counted before deduplication, encoded as repeated unbracketed query keys. Handles are not resolved. Missing indexed profiles are returned as null. This endpoint does not paginate.", + "parameters": { + "type": "params", + "required": ["actors"], + "properties": { + "actors": { + "type": "array", + "description": "Actor DIDs to look up; repeated unbracketed values preserve their original order and duplicates.", + "minLength": 1, + "maxLength": 100, + "items": { "type": "string", "format": "did" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "actors must contain 1 through 100 valid DIDs; unknown query parameters are not accepted." }, + { "name": "ProfileQueryFailed", "description": "The indexed profiles could not be queried." } + ] + }, + "output": { + "type": "object", + "required": ["profiles"], + "properties": { + "profiles": { + "type": "array", + "description": "One result per supplied actor occurrence, in input order. The profile is null when no indexed profile exists.", + "items": { "type": "ref", "ref": "#profileResult" } + } + } + }, + "profileResult": { + "type": "object", + "required": ["actor", "profile"], + "nullable": ["profile"], + "properties": { + "actor": { "type": "string", "format": "did" }, + "profile": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" } + } + } + } +} diff --git a/hypercerts-api/lexicons/app.certified.actor.listProfiles.json b/hypercerts-api/lexicons/app.certified.actor.listProfiles.json new file mode 100644 index 00000000..d1e1cbaa --- /dev/null +++ b/hypercerts-api/lexicons/app.certified.actor.listProfiles.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "app.certified.actor.listProfiles", + "defs": { + "main": { + "type": "query", + "description": "Public listing of indexed Certified profiles. Authentication is not required. actors uses repeated unbracketed keys, accepts at most 100 supplied DIDs before deduplication, and ORs them together. This listing has no text-search parameter; use searchProfiles for profile text discovery. The handler applies limit=25 and sortDirection=desc when omitted; limit is 1..100. Results sort by a valid zoned createdAt, otherwise indexedAt (or stored row creation time when indexedAt is absent), then URI, both in the requested direction. The opaque cursor is direction-bound and provides no snapshot guarantee; keep other parameters unchanged between pages. Unknown parameters and repeated scalar keys are rejected.", + "parameters": { + "type": "params", + "properties": { + "sortDirection": { "type": "string", "description": "Sort direction; handler default is desc.", "enum": ["asc", "desc"] }, + "actors": { "type": "array", "description": "DIDs whose indexed profiles should be returned. Repeated values are ORed; handles are not resolved.", "maxLength": 100, "items": { "type": "string", "format": "did" } }, + "limit": { "type": "integer", "description": "Page size; handler default is 25.", "minimum": 1, "maximum": 100 }, + "cursor": { "type": "string", "description": "Opaque versioned keyset cursor bound to sortDirection." } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "A parameter is invalid, repeated where scalar, or unknown." }, + { "name": "ProfileQueryFailed", "description": "The indexed profiles could not be queried." } + ] + }, + "output": { + "type": "object", + "required": ["profiles"], + "properties": { + "profiles": { + "type": "array", + "items": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" } + }, + "cursor": { "type": "string", "description": "Opaque cursor for the next page; omitted when pagination is complete." } + } + } + } +} diff --git a/hypercerts-api/lexicons/app.certified.actor.searchProfiles.json b/hypercerts-api/lexicons/app.certified.actor.searchProfiles.json new file mode 100644 index 00000000..df63acb2 --- /dev/null +++ b/hypercerts-api/lexicons/app.certified.actor.searchProfiles.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "app.certified.actor.searchProfiles", + "defs": { + "main": { + "type": "query", + "description": "Public text search over indexed Certified profiles. Authentication is not required. search is trimmed and matched as a case-insensitive literal substring in displayName or description; blank-after-trim search imposes no text restriction, though listProfiles is preferred for unrestricted listing. actors uses repeated unbracketed keys, accepts at most 100 supplied DIDs before deduplication, and ORs them together; actor and search filters combine with AND before pagination. The handler applies limit=25 and sortDirection=desc when omitted; limit is 1..100. Results sort by a valid zoned createdAt, otherwise indexedAt (or stored row creation time when indexedAt is absent), then URI, both in the requested direction. The opaque cursor is direction-bound and provides no snapshot guarantee; keep other parameters unchanged between pages. Unknown parameters and repeated scalar keys are rejected.", + "parameters": { + "type": "params", + "required": ["search"], + "properties": { + "search": { "type": "string", "description": "Required search text; after trimming, matched case-insensitively as a literal substring in displayName or description. Blank is unrestricted." }, + "sortDirection": { "type": "string", "description": "Sort direction; handler default is desc.", "enum": ["asc", "desc"] }, + "actors": { "type": "array", "description": "DIDs whose indexed profiles may be returned. Repeated values are ORed; handles are not resolved.", "maxLength": 100, "items": { "type": "string", "format": "did" } }, + "limit": { "type": "integer", "description": "Page size; handler default is 25.", "minimum": 1, "maximum": 100 }, + "cursor": { "type": "string", "description": "Opaque versioned keyset cursor bound to sortDirection." } + } + }, + "output": { + "encoding": "application/json", + "schema": { "type": "ref", "ref": "#output" } + }, + "errors": [ + { "name": "InvalidRequest", "description": "A parameter is invalid, repeated where scalar, or unknown." }, + { "name": "ProfileQueryFailed", "description": "The indexed profiles could not be queried." } + ] + }, + "output": { + "type": "object", + "required": ["profiles"], + "properties": { + "profiles": { + "type": "array", + "items": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" } + }, + "cursor": { "type": "string", "description": "Opaque cursor for the next page; omitted when pagination is complete." } + } + } + } +} diff --git a/hypercerts-api/lua/endpoints/getProfile.lua b/hypercerts-api/lua/endpoints/getProfile.lua new file mode 100644 index 00000000..7014a00b --- /dev/null +++ b/hypercerts-api/lua/endpoints/getProfile.lua @@ -0,0 +1,219 @@ +local PROFILE = "app.certified.actor.profile" + +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(values, key) + local value = values[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + +local function row_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function valid_handle(value) + if type(value) ~= "string" or #value > 253 or value:find("[^%w%.%-]") + or value:find("%.%.") or value:sub(1, 1) == "." or value:sub(-1) == "." then + return false + end + local labels = 0 + for label in value:gmatch("[^%.]+") do + labels = labels + 1 + if #label > 63 or label:sub(1, 1) == "-" or label:sub(-1) == "-" then return false end + end + return labels >= 2 +end + +local function encode_query_value(value) + return (value:gsub("([^%w%.%-_~])", function(char) + return string.format("%%%02X", string.byte(char)) + end)) +end + +local function fail_resolution() + error("HandleResolutionFailed: handle could not be resolved", 0) +end + +local function fail_resolver_config() + error("HandleResolverConfigError: HYPERCERTS_HANDLE_RESOLVER_URL must be an HTTPS resolver base URL with no credentials, path, query, or fragment, a valid host, and a port from 1 through 65535 if specified; configure this HappyView script variable before handle lookups", 0) +end + +local function fetch_json(url) + local ok, response = pcall(http.get, url) + if not ok or type(response) ~= "table" or type(response.status) ~= "number" + or response.status < 200 or response.status >= 300 or type(response.body) ~= "string" then + fail_resolution() + end + local decoded, value = pcall(json.decode, response.body) + if not decoded or type(value) ~= "table" then fail_resolution() end + return value +end + +local function valid_port(value) + local port = tonumber(value) + return port and port >= 1 and port <= 65535 +end + +local function valid_ipv4(value) + if value:sub(1, 1) == "." or value:sub(-1) == "." or value:find("..", 1, true) then return false end + local count = 0 + for octet in value:gmatch("[^%.]+") do + local number = tonumber(octet) + if not octet:match("^%d+$") or #octet > 3 or (#octet > 1 and octet:sub(1, 1) == "0") + or not number or number > 255 then return false end + count = count + 1 + end + return count == 4 +end + +local function ipv6_group_count(part) + if part == "" then return 0 end + if part:sub(1, 1) == ":" or part:sub(-1) == ":" or part:find("::", 1, true) then return nil end + local groups, count = {}, 0 + for group in part:gmatch("[^:]+") do groups[#groups + 1] = group end + for index, group in ipairs(groups) do + if group:find(".", 1, true) then + if index ~= #groups or not valid_ipv4(group) then return nil end + count = count + 2 + else + if #group > 4 or not group:match("^%x+$") then return nil end + count = count + 1 + end + end + return count +end + +local function valid_ipv6(address) + if not address:find(":", 1, true) then return false end + local compression = address:find("::", 1, true) + if compression then + if address:find("::", compression + 2, true) or address:sub(1, compression - 1):find(".", 1, true) then + return false + end + local left = ipv6_group_count(address:sub(1, compression - 1)) + local right = ipv6_group_count(address:sub(compression + 2)) + return left ~= nil and right ~= nil and left + right < 8 + end + return ipv6_group_count(address) == 8 +end + +local function valid_resolver_authority(authority) + if authority:find("@", 1, true) or authority:find("[^%w%.:%-%[%]]") then return false end + + if authority:sub(1, 1) == "[" then + local address, port = authority:match("^%[([^%]]+)%]:(%d+)$") + if not address then address = authority:match("^%[([^%]]+)%]$") end + return address ~= nil and valid_ipv6(address) and (port == nil or valid_port(port)) + end + + local host, port = authority:match("^([%w%.%-]+):(%d+)$") + if not host then + host = authority + if host:find(":", 1, true) then return false end + end + if port and not valid_port(port) then return false end + if host:match("^[%d%.]+$") then return valid_ipv4(host) end + if host == "" or #host > 253 or host:find("%.%.") or host:sub(1, 1) == "." or host:sub(-1) == "." then + return false + end + for label in host:gmatch("[^%.]+") do + if #label > 63 or label:sub(1, 1) == "-" or label:sub(-1) == "-" then return false end + end + return true +end + +local function resolver_base_url() + local configured = type(env) == "table" and env.HYPERCERTS_HANDLE_RESOLVER_URL or nil + if type(configured) ~= "string" or #configured > 2048 + or configured:find("[%c%s]") or configured:find("\\", 1, true) or configured:find("%", 1, true) then + fail_resolver_config() + end + + local scheme, authority, suffix = configured:match("^([%a]+)://([^/?#]+)(.*)$") + if not scheme or scheme:lower() ~= "https" or not authority + or (suffix ~= "" and suffix ~= "/") or not valid_resolver_authority(authority) then + fail_resolver_config() + end + return "https://" .. authority +end + +local function valid_resolver_did(value) + if not valid_did(value) then return false end + local index = 1 + while index <= #value do + if value:sub(index, index) == "%" then + local escape = value:sub(index + 1, index + 2) + if #escape ~= 2 or not escape:match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + +local function resolve_handle(handle) + local url = resolver_base_url() .. "/xrpc/com.atproto.identity.resolveHandle?handle=" .. encode_query_value(handle) + local resolved = fetch_json(url) + if type(resolved.did) ~= "string" or not valid_resolver_did(resolved.did) then fail_resolution() end + return resolved.did +end + +local function get_profile() + keys_only(params, { actor = true }) + local actor = scalar(params, "actor") + if not actor then invalid("actor must be a DID or handle") end + + local did + if actor:sub(1, 4) == "did:" then + if not valid_did(actor) then invalid("actor must be a valid DID or handle") end + did = actor + else + local handle = actor:lower() + if not valid_handle(handle) then invalid("actor must be a valid DID or handle") end + did = resolve_handle(handle) + end + + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND did = $2 AND rkey = 'self' LIMIT 1", + { PROFILE, did }) + if #rows == 0 then error("RecordNotFound: profile is not indexed", 0) end + return { profile = row_view(rows[1]) } +end + +function handle() + return get_profile() +end diff --git a/hypercerts-api/lua/endpoints/getProfiles.lua b/hypercerts-api/lua/endpoints/getProfiles.lua new file mode 100644 index 00000000..8d1802a7 --- /dev/null +++ b/hypercerts-api/lua/endpoints/getProfiles.lua @@ -0,0 +1,114 @@ +local PROFILE = "app.certified.actor.profile" + +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(values, key) + local value = values[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + +local function row_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function valid_profile_batch_did(value) + if not valid_did(value) then return false end + + local percent = value:find("%", 1, true) + while percent do + local escape = value:sub(percent + 1, percent + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + percent = value:find("%", percent + 3, true) + end + return true +end + +local function requested_actors(value) + if value == nil then invalid("actors is required") end + + local actors = {} + if type(value) == "string" then + actors[1] = scalar(params, "actors") + elseif type(value) == "table" then + for index = 1, #value do + if type(value[index]) ~= "string" then invalid("actors entries must be strings") end + actors[#actors + 1] = value[index] + end + else + invalid("actors must be a string or repeated string parameter") + end + + if #actors < 1 or #actors > 100 then invalid("actors must contain 1 through 100 DIDs") end + + local unique, seen = {}, {} + for _, did in ipairs(actors) do + if not valid_profile_batch_did(did) then invalid("each actors value must be a valid DID") end + if not seen[did] then + seen[did] = true + unique[#unique + 1] = did + end + end + return actors, unique +end + +local function get_profiles() + keys_only(params, { actors = true }) + local actors, unique = requested_actors(params.actors) + + local values, placeholders = { PROFILE }, {} + for _, did in ipairs(unique) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " + .. "FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" + .. table.concat(placeholders, ", ") .. ")" + local rows = query(sql, values) + + local profiles_by_did = {} + for _, row in ipairs(rows) do profiles_by_did[row.did] = row_view(row) end + + local null = json.decode("null") + local profiles = {} + for _, did in ipairs(actors) do + profiles[#profiles + 1] = { actor = did, profile = profiles_by_did[did] or null } + end + return { profiles = toarray(profiles) } +end + +function handle() + return get_profiles() +end diff --git a/hypercerts-api/lua/endpoints/listProfiles.lua b/hypercerts-api/lua/endpoints/listProfiles.lua new file mode 100644 index 00000000..395e4d03 --- /dev/null +++ b/hypercerts-api/lua/endpoints/listProfiles.lua @@ -0,0 +1,201 @@ +local PROFILE = "app.certified.actor.profile" + +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(values, key) + local value = values[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + +local function row_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function valid_profile_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + return authority ~= nil and valid_did(authority) and collection == PROFILE and rkey == "self" +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 array(params, key) + local value = params[key] + if value == nil then return nil end + local values = {} + if type(value) == "string" then values[1] = value + elseif type(value) == "table" then + for index = 1, #value do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + values[#values + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #values > 100 then invalid(key .. " accepts at most 100 values") end + local unique, seen = {}, {} + for _, item in ipairs(values) do + if not valid_did(item) then invalid("each " .. key .. " value must be a valid DID") end + if not seen[item] then seen[item] = true; unique[#unique + 1] = item end + end + return unique +end + +local function add_actors(where, values, actors) + if not actors then return end + if #actors == 0 then where[#where + 1] = "FALSE"; return end + local placeholders = {} + for _, did in ipairs(actors) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = "did IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local function cursor_decode(token, direction) + if not token then return nil end + if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not valid_profile_uri(value.u) or not valid_datetime(value.t) then invalid("cursor is malformed") end + return value +end + +local function query_profiles(actors, search, limit, cursor, direction) + local where, values = { "collection = $1", "rkey = 'self'" }, { PROFILE } + add_actors(where, values, actors) + if search then + values[#values + 1] = search + local term = "$" .. #values + where[#where + 1] = "(strpos(lower(COALESCE(record::jsonb->>'displayName', '')), lower(" .. term + .. ")) > 0 OR strpos(lower(COALESCE(record::jsonb->>'description', '')), lower(" .. term .. ")) > 0)" + 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, uri) " .. operator .. " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + local sort_key = "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned + .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created + .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" + local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, 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 CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted WHERE " + .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values + local rows = query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local profiles = {} + for _, row in ipairs(rows) do profiles[#profiles + 1] = row_view(row) end + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return profiles, next_cursor +end + +local function profiles_response(search_enabled) + local allowed = { actors = true, limit = true, cursor = true, sortDirection = true } + if search_enabled then allowed.search = true end + keys_only(params, allowed) + local actors = array(params, "actors") + local search + if search_enabled then + search = scalar(params, "search") + if search == nil then invalid("search is required") end + search = search:match("^%s*(.-)%s*$") + if search == "" then search = nil else search = search:lower() end + end + + 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 + + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local cursor = cursor_decode(scalar(params, "cursor"), direction) + local profiles, next_cursor = query_profiles(actors, search, limit, cursor, direction) + local response = { profiles = toarray(profiles) } + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return profiles_response(false) +end diff --git a/hypercerts-api/lua/endpoints/searchProfiles.lua b/hypercerts-api/lua/endpoints/searchProfiles.lua new file mode 100644 index 00000000..72ca8f85 --- /dev/null +++ b/hypercerts-api/lua/endpoints/searchProfiles.lua @@ -0,0 +1,201 @@ +local PROFILE = "app.certified.actor.profile" + +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(values, key) + local value = values[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + +local function row_view(row) + return { + uri = row.uri, + cid = row.cid, + indexedAt = row.indexed_at, + did = row.did, + record = json.decode(row.record), + } +end + +local function valid_profile_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + return authority ~= nil and valid_did(authority) and collection == PROFILE and rkey == "self" +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 array(params, key) + local value = params[key] + if value == nil then return nil end + local values = {} + if type(value) == "string" then values[1] = value + elseif type(value) == "table" then + for index = 1, #value do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + values[#values + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #values > 100 then invalid(key .. " accepts at most 100 values") end + local unique, seen = {}, {} + for _, item in ipairs(values) do + if not valid_did(item) then invalid("each " .. key .. " value must be a valid DID") end + if not seen[item] then seen[item] = true; unique[#unique + 1] = item end + end + return unique +end + +local function add_actors(where, values, actors) + if not actors then return end + if #actors == 0 then where[#where + 1] = "FALSE"; return end + local placeholders = {} + for _, did in ipairs(actors) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = "did IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local function cursor_decode(token, direction) + if not token then return nil end + if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not valid_profile_uri(value.u) or not valid_datetime(value.t) then invalid("cursor is malformed") end + return value +end + +local function query_profiles(actors, search, limit, cursor, direction) + local where, values = { "collection = $1", "rkey = 'self'" }, { PROFILE } + add_actors(where, values, actors) + if search then + values[#values + 1] = search + local term = "$" .. #values + where[#where + 1] = "(strpos(lower(COALESCE(record::jsonb->>'displayName', '')), lower(" .. term + .. ")) > 0 OR strpos(lower(COALESCE(record::jsonb->>'description', '')), lower(" .. term .. ")) > 0)" + 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, uri) " .. operator .. " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + local sort_key = "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned + .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created + .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" + local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, 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 CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted WHERE " + .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values + local rows = query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local profiles = {} + for _, row in ipairs(rows) do profiles[#profiles + 1] = row_view(row) end + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return profiles, next_cursor +end + +local function profiles_response(search_enabled) + local allowed = { actors = true, limit = true, cursor = true, sortDirection = true } + if search_enabled then allowed.search = true end + keys_only(params, allowed) + local actors = array(params, "actors") + local search + if search_enabled then + search = scalar(params, "search") + if search == nil then invalid("search is required") end + search = search:match("^%s*(.-)%s*$") + if search == "" then search = nil else search = search:lower() end + end + + 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 + + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local cursor = cursor_decode(scalar(params, "cursor"), direction) + local profiles, next_cursor = query_profiles(actors, search, limit, cursor, direction) + local response = { profiles = toarray(profiles) } + if next_cursor then response.cursor = next_cursor end + return response +end + +function handle() + return profiles_response(true) +end diff --git a/hypercerts-api/lua/shared/profile.lua b/hypercerts-api/lua/shared/profile.lua new file mode 100644 index 00000000..d4338590 --- /dev/null +++ b/hypercerts-api/lua/shared/profile.lua @@ -0,0 +1,45 @@ +local PROFILE = "app.certified.actor.profile" + +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(values, key) + local value = values[key] + if value == nil then return nil end + if type(value) ~= "string" and type(value) ~= "number" then + invalid(key .. " must occur once") + end + return tostring(value) +end + +local function valid_did(value) + if type(value) ~= "string" or #value > 2048 then return false end + local method, specific = value:match("^did:([a-z]+):(.+)$") + if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%" + or value:find("[^%w%.:_%%%-]") then return false end + return true +end + +local function query(sql, values) + if db.backend() ~= "postgres" then error("ProfileQueryFailed: profile API requires PostgreSQL", 0) end + local ok, result = pcall(db.raw, sql, values) + if not ok then error("ProfileQueryFailed: profile lookup failed", 0) end + return result +end + +local function row_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/hypercerts-api/lua/shared/profileList.lua b/hypercerts-api/lua/shared/profileList.lua new file mode 100644 index 00000000..a583690e --- /dev/null +++ b/hypercerts-api/lua/shared/profileList.lua @@ -0,0 +1,151 @@ +local function valid_profile_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + return authority ~= nil and valid_did(authority) and collection == PROFILE and rkey == "self" +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 array(params, key) + local value = params[key] + if value == nil then return nil end + local values = {} + if type(value) == "string" then values[1] = value + elseif type(value) == "table" then + for index = 1, #value do + if type(value[index]) ~= "string" then invalid(key .. " entries must be strings") end + values[#values + 1] = value[index] + end + else + invalid(key .. " must be a string or repeated string parameter") + end + if #values > 100 then invalid(key .. " accepts at most 100 values") end + local unique, seen = {}, {} + for _, item in ipairs(values) do + if not valid_did(item) then invalid("each " .. key .. " value must be a valid DID") end + if not seen[item] then seen[item] = true; unique[#unique + 1] = item end + end + return unique +end + +local function add_actors(where, values, actors) + if not actors then return end + if #actors == 0 then where[#where + 1] = "FALSE"; return end + local placeholders = {} + for _, did in ipairs(actors) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + where[#where + 1] = "did IN (" .. table.concat(placeholders, ", ") .. ")" +end + +local function cursor_encode(value) + local encoded = json.encode(value) + return (encoded:gsub(".", function(char) return string.format("%02x", string.byte(char)) end)) +end + +local function cursor_decode(token, direction) + if not token then return nil end + if #token % 2 ~= 0 or token:find("[^0-9a-f]") then invalid("cursor is malformed") end + local decoded = token:gsub("..", function(pair) return string.char(tonumber(pair, 16)) end) + local ok, value = pcall(json.decode, decoded) + if not ok or type(value) ~= "table" or value.v ~= 1 or value.d ~= direction + or type(value.t) ~= "string" or type(value.u) ~= "string" then + invalid("cursor is malformed or belongs to another sortDirection") + end + for key in pairs(value) do + if key ~= "v" and key ~= "d" and key ~= "t" and key ~= "u" then invalid("cursor is malformed") end + end + if not valid_profile_uri(value.u) or not valid_datetime(value.t) then invalid("cursor is malformed") end + return value +end + +local function query_profiles(actors, search, limit, cursor, direction) + local where, values = { "collection = $1", "rkey = 'self'" }, { PROFILE } + add_actors(where, values, actors) + if search then + values[#values + 1] = search + local term = "$" .. #values + where[#where + 1] = "(strpos(lower(COALESCE(record::jsonb->>'displayName', '')), lower(" .. term + .. ")) > 0 OR strpos(lower(COALESCE(record::jsonb->>'description', '')), lower(" .. term .. ")) > 0)" + 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, uri) " .. operator .. " ((" .. timestamp .. ")::timestamptz, " .. uri .. ")" + end + + values[#values + 1] = limit + 1 + local ordering = direction == "asc" and "ASC" or "DESC" + local created = "record::jsonb->>'createdAt'" + local zoned = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]([.][0-9]+)?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$" + local sort_key = "CASE WHEN jsonb_typeof(record::jsonb->'createdAt') = 'string' AND " .. created .. " ~ '" .. zoned + .. "' AND " .. created .. " !~ '-00:00$' AND pg_input_is_valid(" .. created .. ", 'timestamptz') THEN (" .. created + .. ")::timestamptz ELSE COALESCE(indexed_at::timestamptz, created_at::timestamptz) END" + local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, 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 CROSS JOIN LATERAL (SELECT " .. sort_key .. " AS sort_at) sorted WHERE " + .. table.concat(where, " AND ") .. " ORDER BY sorted.sort_at " .. ordering .. ", uri " .. ordering .. " LIMIT $" .. #values + local rows = query(sql, values) + local more = #rows > limit + if more then rows[#rows] = nil end + + local profiles = {} + for _, row in ipairs(rows) do profiles[#profiles + 1] = row_view(row) end + local next_cursor + if more then + local last = rows[#rows] + next_cursor = cursor_encode({ v = 1, d = direction, t = last.sort_timestamp, u = last.uri }) + end + return profiles, next_cursor +end + +local function profiles_response(search_enabled) + local allowed = { actors = true, limit = true, cursor = true, sortDirection = true } + if search_enabled then allowed.search = true end + keys_only(params, allowed) + local actors = array(params, "actors") + local search + if search_enabled then + search = scalar(params, "search") + if search == nil then invalid("search is required") end + search = search:match("^%s*(.-)%s*$") + if search == "" then search = nil else search = search:lower() end + end + + 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 + + local direction = scalar(params, "sortDirection") or "desc" + if direction ~= "asc" and direction ~= "desc" then invalid("sortDirection must be 'asc' or 'desc'") end + local cursor = cursor_decode(scalar(params, "cursor"), direction) + local profiles, next_cursor = query_profiles(actors, search, limit, cursor, direction) + local response = { profiles = toarray(profiles) } + if next_cursor then response.cursor = next_cursor end + return response +end diff --git a/hypercerts-api/lua/shared/profileLookup.lua b/hypercerts-api/lua/shared/profileLookup.lua new file mode 100644 index 00000000..603f9db0 --- /dev/null +++ b/hypercerts-api/lua/shared/profileLookup.lua @@ -0,0 +1,169 @@ +local function valid_handle(value) + if type(value) ~= "string" or #value > 253 or value:find("[^%w%.%-]") + or value:find("%.%.") or value:sub(1, 1) == "." or value:sub(-1) == "." then + return false + end + local labels = 0 + for label in value:gmatch("[^%.]+") do + labels = labels + 1 + if #label > 63 or label:sub(1, 1) == "-" or label:sub(-1) == "-" then return false end + end + return labels >= 2 +end + +local function encode_query_value(value) + return (value:gsub("([^%w%.%-_~])", function(char) + return string.format("%%%02X", string.byte(char)) + end)) +end + +local function fail_resolution() + error("HandleResolutionFailed: handle could not be resolved", 0) +end + +local function fail_resolver_config() + error("HandleResolverConfigError: HYPERCERTS_HANDLE_RESOLVER_URL must be an HTTPS resolver base URL with no credentials, path, query, or fragment, a valid host, and a port from 1 through 65535 if specified; configure this HappyView script variable before handle lookups", 0) +end + +local function fetch_json(url) + local ok, response = pcall(http.get, url) + if not ok or type(response) ~= "table" or type(response.status) ~= "number" + or response.status < 200 or response.status >= 300 or type(response.body) ~= "string" then + fail_resolution() + end + local decoded, value = pcall(json.decode, response.body) + if not decoded or type(value) ~= "table" then fail_resolution() end + return value +end + +local function valid_port(value) + local port = tonumber(value) + return port and port >= 1 and port <= 65535 +end + +local function valid_ipv4(value) + if value:sub(1, 1) == "." or value:sub(-1) == "." or value:find("..", 1, true) then return false end + local count = 0 + for octet in value:gmatch("[^%.]+") do + local number = tonumber(octet) + if not octet:match("^%d+$") or #octet > 3 or (#octet > 1 and octet:sub(1, 1) == "0") + or not number or number > 255 then return false end + count = count + 1 + end + return count == 4 +end + +local function ipv6_group_count(part) + if part == "" then return 0 end + if part:sub(1, 1) == ":" or part:sub(-1) == ":" or part:find("::", 1, true) then return nil end + local groups, count = {}, 0 + for group in part:gmatch("[^:]+") do groups[#groups + 1] = group end + for index, group in ipairs(groups) do + if group:find(".", 1, true) then + if index ~= #groups or not valid_ipv4(group) then return nil end + count = count + 2 + else + if #group > 4 or not group:match("^%x+$") then return nil end + count = count + 1 + end + end + return count +end + +local function valid_ipv6(address) + if not address:find(":", 1, true) then return false end + local compression = address:find("::", 1, true) + if compression then + if address:find("::", compression + 2, true) or address:sub(1, compression - 1):find(".", 1, true) then + return false + end + local left = ipv6_group_count(address:sub(1, compression - 1)) + local right = ipv6_group_count(address:sub(compression + 2)) + return left ~= nil and right ~= nil and left + right < 8 + end + return ipv6_group_count(address) == 8 +end + +local function valid_resolver_authority(authority) + if authority:find("@", 1, true) or authority:find("[^%w%.:%-%[%]]") then return false end + + if authority:sub(1, 1) == "[" then + local address, port = authority:match("^%[([^%]]+)%]:(%d+)$") + if not address then address = authority:match("^%[([^%]]+)%]$") end + return address ~= nil and valid_ipv6(address) and (port == nil or valid_port(port)) + end + + local host, port = authority:match("^([%w%.%-]+):(%d+)$") + if not host then + host = authority + if host:find(":", 1, true) then return false end + end + if port and not valid_port(port) then return false end + if host:match("^[%d%.]+$") then return valid_ipv4(host) end + if host == "" or #host > 253 or host:find("%.%.") or host:sub(1, 1) == "." or host:sub(-1) == "." then + return false + end + for label in host:gmatch("[^%.]+") do + if #label > 63 or label:sub(1, 1) == "-" or label:sub(-1) == "-" then return false end + end + return true +end + +local function resolver_base_url() + local configured = type(env) == "table" and env.HYPERCERTS_HANDLE_RESOLVER_URL or nil + if type(configured) ~= "string" or #configured > 2048 + or configured:find("[%c%s]") or configured:find("\\", 1, true) or configured:find("%", 1, true) then + fail_resolver_config() + end + + local scheme, authority, suffix = configured:match("^([%a]+)://([^/?#]+)(.*)$") + if not scheme or scheme:lower() ~= "https" or not authority + or (suffix ~= "" and suffix ~= "/") or not valid_resolver_authority(authority) then + fail_resolver_config() + end + return "https://" .. authority +end + +local function valid_resolver_did(value) + if not valid_did(value) then return false end + local index = 1 + while index <= #value do + if value:sub(index, index) == "%" then + local escape = value:sub(index + 1, index + 2) + if #escape ~= 2 or not escape:match("^%x%x$") then return false end + index = index + 3 + else + index = index + 1 + end + end + return true +end + +local function resolve_handle(handle) + local url = resolver_base_url() .. "/xrpc/com.atproto.identity.resolveHandle?handle=" .. encode_query_value(handle) + local resolved = fetch_json(url) + if type(resolved.did) ~= "string" or not valid_resolver_did(resolved.did) then fail_resolution() end + return resolved.did +end + +local function get_profile() + keys_only(params, { actor = true }) + local actor = scalar(params, "actor") + if not actor then invalid("actor must be a DID or handle") end + + local did + if actor:sub(1, 4) == "did:" then + if not valid_did(actor) then invalid("actor must be a valid DID or handle") end + did = actor + else + local handle = actor:lower() + if not valid_handle(handle) then invalid("actor must be a valid DID or handle") end + did = resolve_handle(handle) + end + + local rows = query( + "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record FROM happyview_records WHERE collection = $1 AND did = $2 AND rkey = 'self' LIMIT 1", + { PROFILE, did }) + if #rows == 0 then error("RecordNotFound: profile is not indexed", 0) end + return { profile = row_view(rows[1]) } +end diff --git a/hypercerts-api/lua/src/getProfile.lua b/hypercerts-api/lua/src/getProfile.lua new file mode 100644 index 00000000..f0415617 --- /dev/null +++ b/hypercerts-api/lua/src/getProfile.lua @@ -0,0 +1,3 @@ +function handle() + return get_profile() +end diff --git a/hypercerts-api/lua/src/getProfiles.lua b/hypercerts-api/lua/src/getProfiles.lua new file mode 100644 index 00000000..58fcde1f --- /dev/null +++ b/hypercerts-api/lua/src/getProfiles.lua @@ -0,0 +1,68 @@ +local function valid_profile_batch_did(value) + if not valid_did(value) then return false end + + local percent = value:find("%", 1, true) + while percent do + local escape = value:sub(percent + 1, percent + 2) + if #escape ~= 2 or escape:find("[^%x]") then return false end + percent = value:find("%", percent + 3, true) + end + return true +end + +local function requested_actors(value) + if value == nil then invalid("actors is required") end + + local actors = {} + if type(value) == "string" then + actors[1] = scalar(params, "actors") + elseif type(value) == "table" then + for index = 1, #value do + if type(value[index]) ~= "string" then invalid("actors entries must be strings") end + actors[#actors + 1] = value[index] + end + else + invalid("actors must be a string or repeated string parameter") + end + + if #actors < 1 or #actors > 100 then invalid("actors must contain 1 through 100 DIDs") end + + local unique, seen = {}, {} + for _, did in ipairs(actors) do + if not valid_profile_batch_did(did) then invalid("each actors value must be a valid DID") end + if not seen[did] then + seen[did] = true + unique[#unique + 1] = did + end + end + return actors, unique +end + +local function get_profiles() + keys_only(params, { actors = true }) + local actors, unique = requested_actors(params.actors) + + local values, placeholders = { PROFILE }, {} + for _, did in ipairs(unique) do + values[#values + 1] = did + placeholders[#placeholders + 1] = "$" .. #values + end + local sql = "SELECT uri, did, cid, indexed_at::text AS indexed_at, record::text AS record " + .. "FROM happyview_records WHERE collection = $1 AND rkey = 'self' AND did IN (" + .. table.concat(placeholders, ", ") .. ")" + local rows = query(sql, values) + + local profiles_by_did = {} + for _, row in ipairs(rows) do profiles_by_did[row.did] = row_view(row) end + + local null = json.decode("null") + local profiles = {} + for _, did in ipairs(actors) do + profiles[#profiles + 1] = { actor = did, profile = profiles_by_did[did] or null } + end + return { profiles = toarray(profiles) } +end + +function handle() + return get_profiles() +end diff --git a/hypercerts-api/lua/src/listProfiles.lua b/hypercerts-api/lua/src/listProfiles.lua new file mode 100644 index 00000000..a5791729 --- /dev/null +++ b/hypercerts-api/lua/src/listProfiles.lua @@ -0,0 +1,3 @@ +function handle() + return profiles_response(false) +end diff --git a/hypercerts-api/lua/src/searchProfiles.lua b/hypercerts-api/lua/src/searchProfiles.lua new file mode 100644 index 00000000..9b774d7d --- /dev/null +++ b/hypercerts-api/lua/src/searchProfiles.lua @@ -0,0 +1,3 @@ +function handle() + return profiles_response(true) +end diff --git a/hypercerts-api/manifest.json b/hypercerts-api/manifest.json index d3e36ad7..907ee7e4 100644 --- a/hypercerts-api/manifest.json +++ b/hypercerts-api/manifest.json @@ -1,20 +1,24 @@ { - "name": "hypercerts-location-actor-follow-api", + "name": "hypercerts-location-actor-follow-profile-api", "collection": "app.certified.location", "targetHappyViewRevision": "a2f347e605d7cd9c11669b320e43c10dbfe982e3", "runtimeCompatibility": "compatible-at-target-revision", - "status": "handlers-implemented-runtime-validation-passed-at-target-revision", + "status": "handlers-implemented; location-and-actor-follow-runtime-validated-at-target-revision", "handlerStatus": { "getLocation": "implemented", "listLocations": "implemented", "getFollow": "implemented", "listActorFollowers": "implemented", - "listActorFollowing": "implemented" + "listActorFollowing": "implemented", + "getProfile": "implemented", + "getProfiles": "implemented", + "listProfiles": "implemented", + "searchProfiles": "implemented" }, "authentication": { "decision": "public", "unresolved": false, - "note": "The location and actor-follow query endpoints expose public record reads and require no caller authentication." + "note": "The location, actor-follow, and actor-profile query endpoints expose public record reads and require no caller authentication." }, "luaBuild": { "command": "pnpm build:lua", @@ -27,14 +31,21 @@ "lua/shared/location.lua", "lua/shared/actorFollow.lua", "lua/shared/actorFollowLookup.lua", - "lua/shared/actorFollowList.lua" + "lua/shared/actorFollowList.lua", + "lua/shared/profile.lua", + "lua/shared/profileLookup.lua", + "lua/shared/profileList.lua" ], "endpointSources": [ "lua/src/getLocation.lua", "lua/src/listLocations.lua", "lua/src/getFollow.lua", "lua/src/listActorFollowers.lua", - "lua/src/listActorFollowing.lua" + "lua/src/listActorFollowing.lua", + "lua/src/getProfile.lua", + "lua/src/getProfiles.lua", + "lua/src/listProfiles.lua", + "lua/src/searchProfiles.lua" ], "installedHandlers": "lua/endpoints/" }, @@ -75,6 +86,22 @@ "id": "app.certified.actor.profile", "packagePath": "lexicons/app/certified/actor/profile.json" }, + { + "id": "app.certified.actor.getProfile", + "path": "lexicons/app.certified.actor.getProfile.json" + }, + { + "id": "app.certified.actor.getProfiles", + "path": "lexicons/app.certified.actor.getProfiles.json" + }, + { + "id": "app.certified.actor.listProfiles", + "path": "lexicons/app.certified.actor.listProfiles.json" + }, + { + "id": "app.certified.actor.searchProfiles", + "path": "lexicons/app.certified.actor.searchProfiles.json" + }, { "id": "app.certified.actor.organization", "packagePath": "lexicons/app/certified/actor/organization.json" @@ -198,6 +225,7 @@ ], "modules": [ "modules/location/manifest.json", - "modules/actor-follow/manifest.json" + "modules/actor-follow/manifest.json", + "modules/profile/manifest.json" ] } diff --git a/hypercerts-api/modules/profile/manifest.json b/hypercerts-api/modules/profile/manifest.json new file mode 100644 index 00000000..b6a927ae --- /dev/null +++ b/hypercerts-api/modules/profile/manifest.json @@ -0,0 +1,123 @@ +{ + "assets": [ + { + "kind": "lexicon", + "id": "app.certified.actor.getProfile", + "path": "../../lexicons/app.certified.actor.getProfile.json", + "config": { + "backfill": false, + "target_collection": "app.certified.actor.profile" + }, + "dependsOn": [ + "app.certified.actor.profile", + "org.hypercerts.api.defs" + ] + }, + { + "kind": "lexicon", + "id": "app.certified.actor.getProfiles", + "path": "../../lexicons/app.certified.actor.getProfiles.json", + "config": { + "backfill": false, + "target_collection": "app.certified.actor.profile" + }, + "dependsOn": [ + "app.certified.actor.profile", + "org.hypercerts.api.defs" + ] + }, + { + "kind": "lexicon", + "id": "app.certified.actor.listProfiles", + "path": "../../lexicons/app.certified.actor.listProfiles.json", + "config": { + "backfill": false, + "target_collection": "app.certified.actor.profile" + }, + "dependsOn": [ + "app.certified.actor.profile", + "org.hypercerts.api.defs" + ] + }, + { + "kind": "lexicon", + "id": "app.certified.actor.searchProfiles", + "path": "../../lexicons/app.certified.actor.searchProfiles.json", + "config": { + "backfill": false, + "target_collection": "app.certified.actor.profile" + }, + "dependsOn": [ + "app.certified.actor.profile", + "org.hypercerts.api.defs" + ] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.actor.getProfile", + "path": "../../lua/endpoints/getProfile.lua", + "sourcePath": "../../lua/src/getProfile.lua", + "sharedSourcePaths": [ + "../../lua/shared/profile.lua", + "../../lua/shared/profileLookup.lua" + ], + "config": { + "script_type": "lua", + "description": "Certified profile lookup handler" + }, + "dependsOn": [ + "app.certified.actor.getProfile" + ] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.actor.getProfiles", + "path": "../../lua/endpoints/getProfiles.lua", + "sourcePath": "../../lua/src/getProfiles.lua", + "sharedSourcePaths": [ + "../../lua/shared/profile.lua" + ], + "config": { + "script_type": "lua", + "description": "Certified profile batch lookup handler" + }, + "dependsOn": [ + "app.certified.actor.getProfiles" + ] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.actor.searchProfiles", + "path": "../../lua/endpoints/searchProfiles.lua", + "sourcePath": "../../lua/src/searchProfiles.lua", + "sharedSourcePaths": [ + "../../lua/shared/profile.lua", + "../../lua/shared/profileList.lua" + ], + "config": { + "script_type": "lua", + "description": "Certified profile search handler" + }, + "dependsOn": [ + "app.certified.actor.searchProfiles" + ] + }, + { + "kind": "script", + "id": "xrpc.query:app.certified.actor.listProfiles", + "path": "../../lua/endpoints/listProfiles.lua", + "sourcePath": "../../lua/src/listProfiles.lua", + "sharedSourcePaths": [ + "../../lua/shared/profile.lua", + "../../lua/shared/profileList.lua" + ], + "config": { + "script_type": "lua", + "description": "Certified profile listing handler" + }, + "dependsOn": [ + "app.certified.actor.listProfiles" + ] + } + ] +} diff --git a/hypercerts-api/tooling/build-lua.test.js b/hypercerts-api/tooling/build-lua.test.js index 49dff267..7e578d52 100644 --- a/hypercerts-api/tooling/build-lua.test.js +++ b/hypercerts-api/tooling/build-lua.test.js @@ -15,11 +15,18 @@ const sources = [ ['lua/shared/actorFollow.lua', 'local function query() return "follow" end\n'], ['lua/shared/actorFollowLookup.lua', 'local function lookup() return true end\n'], ['lua/shared/actorFollowList.lua', 'local function list() return true end\n'], + ['lua/shared/profile.lua', 'local function row_view() return "profile" end\n'], + ['lua/shared/profileLookup.lua', 'local function lookup() return row_view() end\n'], + ['lua/shared/profileList.lua', 'local function profiles_response() return row_view() end\n'], ['lua/src/getLocation.lua', 'function handle() return query() end\n'], ['lua/src/listLocations.lua', 'function handle() return query() end\n'], ['lua/src/getFollow.lua', 'function handle() return query() end\n'], ['lua/src/listActorFollowers.lua', 'function handle() return list() end\n'], ['lua/src/listActorFollowing.lua', 'function handle() return list() end\n'], + ['lua/src/getProfile.lua', 'function handle() return lookup() end\n'], + ['lua/src/getProfiles.lua', 'function handle() return get_profiles() end\n'], + ['lua/src/listProfiles.lua', 'function handle() return profiles_response(false) end\n'], + ['lua/src/searchProfiles.lua', 'function handle() return profiles_response(true) end\n'], ]; async function withLuaRoot(run) { @@ -37,11 +44,33 @@ async function withLuaRoot(run) { } } -test('builds a handler bundle with shared source before its endpoint', async () => { +test('builds a handler bundle from its shared and endpoint sources', async () => { await withLuaRoot(async (root) => { await buildLuaBundles(root); - const built = await readFile(path.join(root, 'lua/endpoints/getLocation.lua'), 'utf8'); - assert.ok(built.endsWith('local function query() return "location" end\n\nfunction handle() return query() end\n')); + assert.equal( + await readFile(path.join(root, 'lua/endpoints/getLocation.lua'), 'utf8'), + [ + 'local function query_common() return "query" end', + 'local function record_identifier_common() return "identifier" end', + 'local function record_view_common() return "record" end', + 'local function actor_view_common() return "actor" end', + 'local function query() return "location" end', + 'function handle() return query() end', + ].join('\n\n') + '\n', + ); + }); +}); + +test('builds getProfiles from the shared profile view and endpoint sources', async () => { + await withLuaRoot(async (root) => { + await buildLuaBundles(root); + assert.equal( + await readFile(path.join(root, 'lua/endpoints/getProfiles.lua'), 'utf8'), + [ + 'local function row_view() return "profile" end', + 'function handle() return get_profiles() end', + ].join('\n\n') + '\n', + ); }); }); @@ -53,6 +82,10 @@ test('checks generated bundles without rewriting stale outputs', async () => { 'lua/endpoints/getFollow.lua', 'lua/endpoints/listActorFollowers.lua', 'lua/endpoints/listActorFollowing.lua', + 'lua/endpoints/getProfile.lua', + 'lua/endpoints/getProfiles.lua', + 'lua/endpoints/listProfiles.lua', + 'lua/endpoints/searchProfiles.lua', ]; for (const relativePath of expectedStale) { await writeFile(path.join(root, relativePath), 'stale bundle\n'); @@ -73,6 +106,10 @@ test('reports missing generated bundles and accepts fresh bundles', async () => 'lua/endpoints/getFollow.lua', 'lua/endpoints/listActorFollowers.lua', 'lua/endpoints/listActorFollowing.lua', + 'lua/endpoints/getProfile.lua', + 'lua/endpoints/getProfiles.lua', + 'lua/endpoints/listProfiles.lua', + 'lua/endpoints/searchProfiles.lua', ]; assert.deepEqual(await checkLuaBundles(root), expected); diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js index 5747b4fa..e11fbf5b 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -23,9 +23,12 @@ import { readLexiconSource } from './lexicon-source.js'; /** @typedef {{ config?: AssetConfig | null; lexicon_json?: unknown; body?: unknown }} InstalledAsset */ /** External GET /admin/lexicons/:id response assertion; response.json() is not runtime-validated. @typedef {{ backfill: boolean; target_collection: string | null; action: string | null; token_cost: number | null; lexicon_json: unknown }} LexiconAdminRow */ /** External GET /admin/scripts/:id response assertion; response.json() is not runtime-validated. @typedef {{ script_type: string; description: string | null; body: string }} ScriptAdminRow */ -/** @typedef {{ read: (asset: LoadedAsset) => Promise; write: (asset: LoadedAsset) => Promise }} AdminClient */ +/** @typedef {{ read: (asset: LoadedAsset) => Promise; write: (asset: LoadedAsset) => Promise; listScriptVariables?: () => Promise; createScriptVariable?: (key: string, value: string) => Promise }} AdminClient */ /** @typedef {{ changed: string[]; unchanged: string[] }} InstallResult */ -/** @typedef {Error & { completed: string[]; remaining: string[] }} PartialInstallError */ +/** @typedef {{ key: string; status: 'exists-unverified' | 'created' }} ResolverSetting */ +/** @typedef {{ env?: Record; isTTY?: boolean; ask?: (prompt: string) => Promise; onNotice?: (message: string) => void }} ApplyAssetsOptions */ +/** @typedef {InstallResult & { resolverSetting?: ResolverSetting }} ApplyAssetsResult */ +/** @typedef {Error & { completed: string[]; remaining: string[]; resolverSetting?: ResolverSetting }} PartialInstallError */ const require = createRequire(new URL('../package.json', import.meta.url)); @@ -140,6 +143,13 @@ export function createAdminClient({ baseUrl, token, fetchImpl = globalThis.fetch await request('POST', '/admin/scripts', { id: asset.id, script_type: asset.config.script_type, description: asset.config.description, body: asset.body }); } }, + async listScriptVariables() { + return request('GET', '/admin/script-variables'); + }, + /** @param {string} key @param {string} value */ + async createScriptVariable(key, value) { + await request('POST', '/admin/script-variables', { key, value }); + }, }; } @@ -209,10 +219,117 @@ async function writeMissingAssets(states, client) { return { changed, unchanged: states.filter((entry) => entry.state === 'unchanged').map(({ asset }) => asset.id) }; } -/** @param {LoadedAsset[]} assets @param {AdminClient} client @returns {Promise} */ -export async function applyAssets(assets, client) { - const states = await preflightAssets(orderAssets(assets), client); - return writeMissingAssets(states, client); +const PROFILE_LOOKUP_ASSET_IDS = new Set([ + 'app.certified.actor.getProfile', + 'xrpc.query:app.certified.actor.getProfile', +]); +const RESOLVER_VARIABLE = 'HYPERCERTS_HANDLE_RESOLVER_URL'; + +/** @param {string} value @returns {boolean} */ +function hasUnsafeResolverCharacter(value) { + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (character === '\\' || code <= 0x20 || code === 0x7f || character.trim() === '') return true; + } + return false; +} + +/** @param {string} value @returns {string} */ +function validateResolverUrl(value) { + const input = typeof value === 'string' ? value.trim() : ''; + let target; + try { + const authority = /^https:\/\/([^/?#]+)\/?$/i.exec(input)?.[1]; + if (!input || input.length > 2048 || hasUnsafeResolverCharacter(input) || input.includes('%') + || !authority || authority.endsWith(':')) throw new Error('invalid resolver URL syntax'); + target = new URL(input); + } catch { + throw new Error(`${RESOLVER_VARIABLE} must be a valid HTTPS resolver base URL with no credentials, path, query, fragment, whitespace, or unsafe syntax; set a resolver origin such as https://resolver.example`); + } + const host = target.hostname.replace(/^\[|\]$/g, ''); + const labels = host.split('.'); + const validHost = target.hostname.startsWith('[') + ? /^[\da-f:.]+$/i.test(host) && host.includes(':') + : host.length <= 253 && labels.every((label) => label.length > 0 && label.length <= 63 + && /^[a-z\d-]+$/i.test(label) && !label.startsWith('-') && !label.endsWith('-')); + const port = Number(target.port); + const validPort = target.port === '' || (port >= 1 && port <= 65535); + if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash + || target.pathname !== '/' || !target.hostname || !validHost || !validPort) { + throw new Error(`${RESOLVER_VARIABLE} must be a valid HTTPS resolver base URL with no credentials, path, query, fragment, whitespace, or unsafe syntax; set a resolver origin such as https://resolver.example`); + } + return target.origin; +} + +/** @param {string} operation @param {string} permission @param {unknown} cause @returns {Error} */ +function resolverAdminFailure(operation, permission, cause) { + const reason = cause instanceof Error ? cause.message : 'unknown admin request failure'; + return new Error(`Unable to ${operation} ${RESOLVER_VARIABLE}: ${reason}. Ensure the HappyView admin token has ${permission} permission and retry.`, { cause }); +} + +/** @param {AdminClient} client @param {{ env: Record; isTTY: boolean; ask: (prompt: string) => Promise; onNotice: (message: string) => void }} options @returns {Promise} */ +async function installResolverSetting(client, { env, isTTY, ask, onNotice }) { + let variables; + try { + if (typeof client.listScriptVariables !== 'function') throw new Error('admin client does not support script-variable listing'); + variables = await client.listScriptVariables(); + } catch (cause) { + throw resolverAdminFailure('inspect', 'script-variables:read', cause); + } + if (!Array.isArray(variables) || variables.some((item) => !item || typeof item.key !== 'string')) { + throw resolverAdminFailure('inspect', 'script-variables:read', new Error('HappyView returned an invalid script-variable list')); + } + if (variables.some(({ key }) => key === RESOLVER_VARIABLE)) { + const message = `${RESOLVER_VARIABLE} already exists in HappyView; its script-variable list exposes only a masked preview, so its actual value cannot be verified.`; + onNotice(message); + return { key: RESOLVER_VARIABLE, status: 'exists-unverified' }; + } + + let value = typeof env[RESOLVER_VARIABLE] === 'string' ? env[RESOLVER_VARIABLE].trim() : ''; + if (!value) { + if (!isTTY) { + throw new Error(`${RESOLVER_VARIABLE} is required for getProfile handle lookups; set an HTTPS resolver base URL in the environment or rerun pnpm install:api in an interactive terminal to enter it (see hypercerts-api/README.md)`); + } + value = await ask('Handle resolver HTTPS base URL'); + } + const resolverUrl = validateResolverUrl(value); + + try { + if (typeof client.createScriptVariable !== 'function') throw new Error('admin client does not support script-variable creation'); + await client.createScriptVariable(RESOLVER_VARIABLE, resolverUrl); + } catch (cause) { + throw resolverAdminFailure('create', 'script-variables:create', cause); + } + return { key: RESOLVER_VARIABLE, status: 'created' }; +} + +/** @param {LoadedAsset[]} assets @param {AdminClient} client @param {ApplyAssetsOptions} [options] @returns {Promise} */ +export async function applyAssets(assets, client, options = {}) { + const ordered = orderAssets(assets); + const states = await preflightAssets(ordered, client); + if (!ordered.some(({ id }) => PROFILE_LOOKUP_ASSET_IDS.has(id))) { + return writeMissingAssets(states, client); + } + + const resolverSetting = await installResolverSetting(client, { + env: options.env ?? process.env, + isTTY: options.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY), + ask: options.ask ?? askOnTerminal, + onNotice: options.onNotice ?? ((message) => console.log(message)), + }); + try { + const result = await writeMissingAssets(states, client); + return { ...result, resolverSetting }; + } catch (cause) { + if (resolverSetting.status !== 'created') throw cause; + const partialFailure = cause instanceof Error ? /** @type {PartialInstallError} */ (cause) : undefined; + const reason = cause instanceof Error ? cause.message : String(cause); + const error = /** @type {PartialInstallError} */ (new Error(`Install partially failed after creating ${RESOLVER_VARIABLE}; the setting remains on HappyView and was not deleted. ${reason}`, { cause })); + error.completed = partialFailure?.completed ?? []; + error.remaining = partialFailure?.remaining ?? []; + error.resolverSetting = resolverSetting; + throw error; + } } // Root manifest (paths are relative to this file): diff --git a/hypercerts-api/tooling/lua-bundles.js b/hypercerts-api/tooling/lua-bundles.js index fd493090..46ee0c12 100644 --- a/hypercerts-api/tooling/lua-bundles.js +++ b/hypercerts-api/tooling/lua-bundles.js @@ -6,6 +6,9 @@ const bundles = [ { shared: ['query', 'recordIdentifier', 'listQuery', 'recordView', 'actorView', 'location'], endpoints: ['listLocations'] }, { shared: ['query', 'recordView', 'actorFollow', 'actorFollowLookup'], endpoints: ['getFollow'] }, { shared: ['query', 'recordIdentifier', 'listQuery', 'recordView', 'actorView', 'actorFollow', 'actorFollowList'], endpoints: ['listActorFollowers', 'listActorFollowing'] }, + { shared: ['profile', 'profileLookup'], endpoints: ['getProfile'] }, + { shared: ['profile'], endpoints: ['getProfiles'] }, + { shared: ['profile', 'profileList'], endpoints: ['listProfiles', 'searchProfiles'] }, ]; async function renderBundles(root) { diff --git a/hypercerts-api/tooling/profile-installer.test.js b/hypercerts-api/tooling/profile-installer.test.js new file mode 100644 index 00000000..da16bbe0 --- /dev/null +++ b/hypercerts-api/tooling/profile-installer.test.js @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as installer from './installer.js'; + +const resolverKey = 'HYPERCERTS_HANDLE_RESOLVER_URL'; +const profileHandlerId = 'xrpc.query:app.certified.actor.getProfile'; + +function assets(ids = [profileHandlerId, 'org.example.other']) { + return ids.map((id) => ({ + id, + kind: 'script', + config: { script_type: 'lua' }, + body: `handler ${id}`, + })); +} + +function fakeAdmin({ variables = [], conflictId, listFailure, createFailure, writeFailure } = {}) { + const events = []; + return { + events, + async read(asset) { + events.push(['read', asset.id]); + return asset.id === conflictId ? { config: asset.config, body: 'different handler' } : null; + }, + async write(asset) { + events.push(['write', asset.id]); + if (asset.id === writeFailure) throw new Error('script write failed'); + }, + async listScriptVariables() { + events.push(['list-script-variables']); + if (listFailure) throw listFailure; + return variables; + }, + async createScriptVariable(key, value) { + events.push(['create-script-variable', key, value]); + if (createFailure) throw createFailure; + }, + }; +} + +function apply(assetsToInstall, admin, options = {}) { + assert.equal(typeof installer.applyAssets, 'function'); + return installer.applyAssets(assetsToInstall, admin, options); +} + +test('profile install prompts for resolver URL after asset preflight and creates it before scripts', async () => { + const admin = fakeAdmin(); + const prompts = []; + const output = []; + const result = await apply(assets(), admin, { + env: {}, + isTTY: true, + ask: async (label) => { prompts.push(label); return 'https://resolver.example'; }, + onNotice: (message) => output.push(message), + }); + + assert.equal(prompts.length, 1); + assert.match(prompts[0], /resolver/i); + const lastAssetRead = Math.max(...admin.events.map((event, index) => event[0] === 'read' ? index : -1)); + const listIndex = admin.events.findIndex(([name]) => name === 'list-script-variables'); + const createIndex = admin.events.findIndex(([name]) => name === 'create-script-variable'); + const profileWriteIndex = admin.events.findIndex(([name, id]) => name === 'write' && id === profileHandlerId); + assert.ok(lastAssetRead < listIndex, 'all asset conflict reads precede the script-variable preflight'); + assert.ok(listIndex < createIndex && createIndex < profileWriteIndex); + assert.deepEqual(admin.events[createIndex], ['create-script-variable', resolverKey, 'https://resolver.example']); + assert.equal(result.resolverSetting.status, 'created'); + assert.deepEqual(output, []); +}); + +test('noninteractive install uses and validates the resolver URL from the environment', async () => { + const admin = fakeAdmin(); + const result = await apply(assets([profileHandlerId]), admin, { + env: { [resolverKey]: 'https://resolver.example/' }, + isTTY: false, + ask: async () => { assert.fail('noninteractive install must not prompt'); }, + }); + + assert.ok(admin.events.some((event) => event[0] === 'create-script-variable' && event[1] === resolverKey && event[2] === 'https://resolver.example')); + assert.equal(result.resolverSetting.status, 'created'); +}); + +test('installer accepts a valid bracketed IPv6 resolver origin', async () => { + const admin = fakeAdmin(); + await apply(assets([profileHandlerId]), admin, { + env: { [resolverKey]: 'https://[2001:db8::1]' }, isTTY: false, + }); + + assert.ok(admin.events.some((event) => event[0] === 'create-script-variable' + && event[1] === resolverKey && event[2] === 'https://[2001:db8::1]')); +}); + +test('profile install rejects unsafe resolver URLs before setting creation or asset writes', async () => { + for (const value of [ + 'http://resolver.example', + 'https://user:secret@resolver.example', + 'https://resolver.example?tenant=one', + 'https://resolver.example/path', + 'https://resolver.example//', + 'https://resolver.example:', + 'https://resolver.example:0', + 'https://resolver..example', + 'https://-resolver.example', + 'https://resolver.example#fragment', + 'https://[:::]', + 'https://resolver.example\\@evil.example', + ]) { + const admin = fakeAdmin(); + await assert.rejects(() => apply(assets(), admin, { + env: { [resolverKey]: value }, isTTY: false, + }), (error) => /HYPERCERTS_HANDLE_RESOLVER_URL/.test(error.message) && /HTTPS|valid/i.test(error.message)); + assert.equal(admin.events.some(([name]) => name === 'create-script-variable' || name === 'write'), false, value); + } +}); + +test('existing resolver setting is preserved without a needless prompt and is reported as unverifiable', async () => { + const admin = fakeAdmin({ variables: [{ key: resolverKey, preview: 'http****' }] }); + const messages = []; + const result = await apply(assets([profileHandlerId]), admin, { + env: {}, isTTY: true, + ask: async () => { assert.fail('existing resolver setting must not prompt'); }, + onNotice: (message) => messages.push(message), + }); + + assert.equal(admin.events.some(([name]) => name === 'create-script-variable'), false); + assert.equal(result.resolverSetting.status, 'exists-unverified'); + assert.match(messages.join('\n'), new RegExp(`${resolverKey}.*already exists.*actual value cannot be verified`, 'i')); +}); + +test('bundles without getProfile do not require or inspect the resolver setting', async () => { + const admin = fakeAdmin(); + const result = await apply(assets(['org.example.other']), admin, { env: {}, isTTY: false }); + + assert.deepEqual(result, { changed: ['org.example.other'], unchanged: [] }); + assert.equal(admin.events.some(([name]) => name === 'list-script-variables' || name === 'create-script-variable'), false); +}); + +test('asset conflicts are all preflighted before resolver setting interaction or writes', async () => { + const admin = fakeAdmin({ conflictId: 'org.example.conflict' }); + const prompts = []; + await assert.rejects(() => apply(assets([profileHandlerId, 'org.example.conflict']), admin, { + env: {}, isTTY: true, + ask: async (label) => { prompts.push(label); return 'https://resolver.example'; }, + }), /Refusing org\.example\.conflict/); + + assert.deepEqual(admin.events.filter(([name]) => name === 'read').map(([, id]) => id), [profileHandlerId, 'org.example.conflict']); + assert.equal(admin.events.some(([name]) => name === 'list-script-variables' || name === 'create-script-variable' || name === 'write'), false); + assert.deepEqual(prompts, []); +}); + +test('resolver setting permission failures explain the required token scopes', async () => { + const readAdmin = fakeAdmin({ listFailure: new Error('HappyView GET /admin/script-variables returned HTTP 403') }); + await assert.rejects(() => apply(assets([profileHandlerId]), readAdmin, { + env: {}, isTTY: false, + }), /script-variables:read/); + + const createAdmin = fakeAdmin({ createFailure: new Error('HappyView POST /admin/script-variables returned HTTP 403') }); + await assert.rejects(() => apply(assets([profileHandlerId]), createAdmin, { + env: { [resolverKey]: 'https://resolver.example' }, isTTY: false, + }), /script-variables:create/); + assert.equal(createAdmin.events.some(([name]) => name === 'write'), false); +}); + +test('failure after resolver creation reports the retained setting and partial installation', async () => { + const admin = fakeAdmin({ writeFailure: profileHandlerId }); + await assert.rejects(() => apply(assets([profileHandlerId]), admin, { + env: { [resolverKey]: 'https://resolver.example' }, isTTY: false, + }), (error) => /partially failed/i.test(error.message) + && error.message.includes(resolverKey) + && /creat(?:ed|ing).*remains|remains.*creat(?:ed|ing)/i.test(error.message)); + assert.ok(admin.events.some(([name]) => name === 'create-script-variable')); +}); + +test('admin client uses the script-variable list and upsert routes with the exact setting body', async () => { + const calls = []; + const responses = [ + new Response(JSON.stringify([{ key: resolverKey, preview: 'http****' }]), { status: 200 }), + new Response(null, { status: 204 }), + ]; + const admin = installer.createAdminClient({ + baseUrl: 'https://happyview.example', + token: 'hv_fake-admin-token', + fetchImpl: async (url, options) => { calls.push({ url: String(url), options }); return responses.shift(); }, + }); + + assert.deepEqual(await admin.listScriptVariables(), [{ key: resolverKey, preview: 'http****' }]); + await admin.createScriptVariable(resolverKey, 'https://resolver.example'); + assert.equal(calls[0].url, 'https://happyview.example/admin/script-variables'); + assert.equal(calls[0].options.method, 'GET'); + assert.equal(calls[1].url, 'https://happyview.example/admin/script-variables'); + assert.equal(calls[1].options.method, 'POST'); + assert.deepEqual(JSON.parse(calls[1].options.body), { key: resolverKey, value: 'https://resolver.example' }); +}); diff --git a/hypercerts-api/tooling/profile.test.js b/hypercerts-api/tooling/profile.test.js new file mode 100644 index 00000000..a06d4eb7 --- /dev/null +++ b/hypercerts-api/tooling/profile.test.js @@ -0,0 +1,467 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { loadAssets } from './installer.js'; +import { validatePackageLexicons } from './validate-lexicons.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const collection = 'app.certified.actor.profile'; +const actor = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'; +const secondActor = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; +const indexedAt = '2025-01-02T03:04:05.000Z'; + +function lua(value) { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `{${value.map(lua).join(',')}}`; + if (value && typeof value === 'object') { + return `{${Object.entries(value).map(([key, item]) => `[${JSON.stringify(key)}]=${lua(item)}`).join(',')}}`; + } + throw new TypeError(`Cannot encode ${typeof value} as a Lua fixture`); +} + +function profileRow(did, id, record, createdAt) { + return { + uri: `at://${did}/${collection}/self`, did, cid: `bafy-${id}`, indexed_at: indexedAt, + record: id, record_json: { $type: collection, ...record }, + sort_timestamp: createdAt.replace('Z', '.000000Z'), + }; +} + +function cursor({ direction = 'desc', timestamp = '2025-01-02T03:04:05.000000Z', uri = `at://${actor}/${collection}/self` } = {}) { + return Buffer.from(JSON.stringify({ v: 1, d: direction, t: timestamp, u: uri }), 'utf8').toString('hex'); +} + +function runLua({ endpoint, params, rows = [], httpResponses = [], resolverUrl, backend = 'postgres', queryFailure = false, expectError, expectedCalls = 0, expectedHttpCalls = 0, errorAssertions = '', captureJson = false, assertions }) { + const endpointPath = `lua/endpoints/${endpoint}.lua`; + assert.ok(existsSync(`${root}/${endpointPath}`), `${endpointPath} must be generated before exercising the handler`); + const storedRecords = Object.fromEntries(rows.map((row) => [row.record, row.record_json])); + const httpJson = Object.fromEntries(httpResponses.filter((response) => response.value !== undefined).map((response) => [response.body, response.value])); + const source = ` +local RECORDS = ${lua(storedRecords)} +local HTTP_JSON = ${lua(httpJson)} +local rows = ${lua(rows.map((row) => Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'record_json'))))} +local HTTP_RESPONSES = ${lua(httpResponses.map((response) => ({ status: response.status, body: response.body })))} +local JSON_NULL = {} +env = ${lua(resolverUrl === undefined ? {} : { HYPERCERTS_HANDLE_RESOLVER_URL: resolverUrl })} +local calls, httpCalls = {}, {} +local function decode_cursor(value) + local version = tonumber(value:match('"v":(%d+)')) + local direction = value:match('"d":"([^"]*)"') + local timestamp = value:match('"t":"([^"]*)"') + local uri = value:match('"u":"([^"]*)"') + if not version or not direction or not timestamp or not uri then error('invalid cursor JSON') end + return { v = version, d = direction, t = timestamp, u = uri } +end +json = { + null = JSON_NULL, + decode = function(value) + if value == 'null' then return JSON_NULL end + if RECORDS[value] then return RECORDS[value] end + if HTTP_JSON[value] then return HTTP_JSON[value] end + if value:sub(1, 1) == '{' then return decode_cursor(value) end + local decoded = value:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) + return decode_cursor(decoded) + end, + encode = function(value) + return string.format('{"v":%d,"d":"%s","t":"%s","u":"%s"}', value.v, value.d, value.t, value.u) + end, +} +toarray = function(value) return value end +local function encode_json(value) + if value == JSON_NULL then return 'null' end + local kind = type(value) + if kind == 'string' then + assert(not value:find('[%c"]'), 'test JSON encoder expects simple strings') + return '"' .. value .. '"' + elseif kind == 'number' or kind == 'boolean' then + return tostring(value) + elseif kind == 'table' then + local is_array, max_index = true, 0 + for key in pairs(value) do + if type(key) == 'number' then max_index = math.max(max_index, key) else is_array = false end + end + local encoded = {} + if is_array then + for index = 1, max_index do + assert(value[index] ~= nil, 'test JSON encoder cannot encode sparse arrays') + encoded[#encoded + 1] = encode_json(value[index]) + end + return '[' .. table.concat(encoded, ',') .. ']' + end + for key, item in pairs(value) do + if item ~= nil then encoded[#encoded + 1] = encode_json(key) .. ':' .. encode_json(item) end + end + return '{' .. table.concat(encoded, ',') .. '}' + end + error('unsupported value in test JSON encoder: ' .. kind) +end +params = ${lua(params)} +http = { get = function(url) + httpCalls[#httpCalls + 1] = url + local response = HTTP_RESPONSES[#httpCalls] + if not response then error('unexpected HTTP request: ' .. url) end + return response +end } +db = { + backend = function() return '${backend}' end, + raw = function(sql, values) + calls[#calls + 1] = { sql = sql, values = values } + if ${queryFailure ? 'true' : 'false'} then error('fixture database failure') end + return rows + end, +} +dofile('${endpointPath}') +local ok, result = pcall(handle) +${expectError + ? `assert(not ok, 'expected handler to reject the request')\nassert(tostring(result):find('${expectError}', 1, true), tostring(result))\n${errorAssertions}\nassert(#calls == ${expectedCalls}, 'unexpected profile query count')\nassert(#httpCalls == ${expectedHttpCalls}, 'unexpected HTTP request count')` + : `assert(ok, tostring(result))\n${assertions}\n${captureJson ? 'print(encode_json(result))' : ''}`} +`; + const result = spawnSync('lua5.4', ['-e', source], { cwd: root, encoding: 'utf8' }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); + return captureJson ? JSON.parse(result.stdout) : result.stdout; +} + +test('profile module installs all four endpoint Lexicons and generated handlers', async () => { + const manifest = JSON.parse(await readFile(`${root}/manifest.json`, 'utf8')); + assert.ok(manifest.modules.includes('modules/profile/manifest.json')); + for (const name of ['getProfile', 'getProfiles', 'listProfiles', 'searchProfiles']) { + assert.equal(manifest.handlerStatus[name], 'implemented'); + } + + const { assets } = await loadAssets(`${root}/manifest.json`); + for (const name of ['getProfile', 'getProfiles', 'listProfiles', 'searchProfiles']) { + const nsid = `app.certified.actor.${name}`; + assert.ok(assets.some((asset) => asset.id === nsid), `missing installed Lexicon ${nsid}`); + assert.ok(assets.some((asset) => asset.id === `xrpc.query:${nsid}`), `missing installed handler ${nsid}`); + } + + const { documents } = await validatePackageLexicons(); + const byId = new Map(documents.map((document) => [document.id, document])); + const getProfile = byId.get('app.certified.actor.getProfile'); + const getProfiles = byId.get('app.certified.actor.getProfiles'); + const listProfiles = byId.get('app.certified.actor.listProfiles'); + const searchProfiles = byId.get('app.certified.actor.searchProfiles'); + assert.ok(getProfile && getProfiles && listProfiles && searchProfiles, 'all profile query Lexicons must be in the validation closure'); + const profileResult = getProfiles.defs.profileResult; + assert.deepEqual(getProfile.defs.main.parameters.required, ['actor']); + assert.equal(getProfile.defs.main.parameters.properties.actor.format, 'at-identifier'); + assert.deepEqual(getProfile.defs.output.required, ['profile']); + assert.equal(getProfile.defs.output.properties.profile.ref, 'lex:org.hypercerts.api.defs#profileView'); + assert.match(getProfile.defs.main.description, /configured resolver/i); + assert.match(getProfile.defs.main.description, /no DID document is fetched for independent verification/i); + assert.doesNotMatch(getProfile.defs.main.description, /claims the handle|alsoKnownAs/i); + assert.ok(getProfile.defs.main.errors.some(({ name, description }) => name === 'HandleResolverConfigError' && /HYPERCERTS_HANDLE_RESOLVER_URL/.test(description))); + assert.equal(getProfile.defs.main.errors.some(({ name }) => name === 'HandleVerificationFailed'), false); + assert.ok(getProfile.defs.main.errors.some(({ name }) => name === 'RecordNotFound')); + + assert.deepEqual(getProfiles.defs.main.parameters.required, ['actors']); + assert.deepEqual(Object.keys(getProfiles.defs.main.parameters.properties), ['actors']); + const actorParameters = getProfiles.defs.main.parameters.properties.actors; + assert.equal(actorParameters.type, 'array'); + assert.equal(actorParameters.minLength, 1); + assert.equal(actorParameters.maxLength, 100); + assert.equal(actorParameters.items.type, 'string'); + assert.equal(actorParameters.items.format, 'did'); + assert.deepEqual(getProfiles.defs.output.required, ['profiles']); + assert.equal(getProfiles.defs.output.properties.profiles.items.ref, 'lex:app.certified.actor.getProfiles#profileResult'); + assert.equal(profileResult.type, 'object'); + assert.deepEqual(profileResult.required, ['actor', 'profile']); + assert.deepEqual(profileResult.nullable, ['profile']); + assert.equal(profileResult.properties.actor.format, 'did'); + assert.equal(profileResult.properties.profile.ref, 'lex:org.hypercerts.api.defs#profileView'); + + const listParameters = listProfiles.defs.main.parameters.properties; + const searchParameters = searchProfiles.defs.main.parameters.properties; + assert.deepEqual(Object.keys(listParameters).sort(), ['actors', 'cursor', 'limit', 'sortDirection']); + assert.equal(listParameters.search, undefined, 'listing must not expose text search'); + assert.deepEqual(searchProfiles.defs.main.parameters.required, ['search']); + assert.deepEqual(Object.keys(searchParameters).sort(), ['actors', 'cursor', 'limit', 'search', 'sortDirection']); + assert.equal(searchParameters.actors.items.format, 'did'); + assert.equal(searchParameters.actors.maxLength, 100); + for (const query of [listProfiles, searchProfiles]) { + assert.equal(query.defs.main.parameters.properties.limit.minimum, 1); + assert.equal(query.defs.main.parameters.properties.limit.maximum, 100); + assert.equal(query.defs.output.properties.profiles.items.ref, 'lex:org.hypercerts.api.defs#profileView'); + } +}); + +test('getProfiles preserves every requested DID occurrence and returns explicit null for missing profiles', () => { + const requested = [secondActor, 'did:plc:cccccccccccccccccccccccc', actor, secondActor]; + const first = profileRow(actor, 'first', { + displayName: 'Forest Commons', createdAt: '2025-01-01T00:00:00Z', + }, '2025-01-01T00:00:00Z'); + const second = profileRow(secondActor, 'second', { + displayName: 'River Commons', createdAt: '2025-01-02T00:00:00Z', + }, '2025-01-02T00:00:00Z'); + const result = runLua({ + endpoint: 'getProfiles', params: { actors: requested }, rows: [first, second], captureJson: true, + assertions: 'assert(#calls == 1 and #httpCalls == 0)', + }); + + assert.deepEqual(result.profiles.map(({ actor: did }) => did), requested); + assert.deepEqual(result.profiles.map(({ profile }) => profile?.did ?? null), [secondActor, null, actor, secondActor]); + assert.equal(Object.hasOwn(result.profiles[1], 'profile'), true); + assert.equal(result.profiles[1].profile, null); + assert.equal(Object.hasOwn(result, 'cursor'), false); + + const single = runLua({ + endpoint: 'getProfiles', params: { actors: actor }, rows: [first], captureJson: true, + assertions: 'assert(#calls == 1 and #httpCalls == 0)', + }); + assert.equal(single.profiles.length, 1); + assert.equal(single.profiles[0].actor, actor); + assert.equal(single.profiles[0].profile.did, actor); +}); + +test('getProfiles requires 1..100 valid DIDs before querying', () => { + for (const params of [ + {}, + { actors: [] }, + { actors: Array(101).fill(actor) }, + { actors: ['alice.example'] }, + { actors: [actor], cursor: 'not-accepted' }, + ]) { + runLua({ endpoint: 'getProfiles', params, expectError: 'InvalidRequest:', expectedCalls: 0, expectedHttpCalls: 0 }); + } +}); + +test('getProfiles rejects malformed DID percent escapes before querying', () => { + runLua({ + endpoint: 'getProfiles', params: { actors: ['did:plc:abcd%ZZ'] }, + expectError: 'InvalidRequest: each actors value must be a valid DID', expectedCalls: 0, expectedHttpCalls: 0, + }); +}); + +test('getProfile returns the complete indexed profile view without sidecar queries', () => { + const record = { displayName: 'Forest Commons', description: 'A profile with its original fields', createdAt: '2025-01-01T00:00:00Z' }; + const row = profileRow(actor, 'one', record, record.createdAt); + runLua({ + endpoint: 'getProfile', params: { actor }, rows: [row], + assertions: ` +assert(result.profile.uri == '${row.uri}' and result.profile.cid == '${row.cid}') +assert(result.profile.indexedAt == '${indexedAt}' and result.profile.did == '${actor}') +assert(result.profile.record['$type'] == '${collection}') +assert(result.profile.record.displayName == 'Forest Commons') +assert(result.profile.record.description == 'A profile with its original fields') +assert(result.profile.organization == nil and #calls == 1 and #httpCalls == 0) +assert(calls[1].values[1] == '${collection}' and calls[1].values[2] == '${actor}') +assert(calls[1].sql:find("rkey = 'self'", 1, true)) +`, + }); +}); + +test('getProfile resolves a handle once through the configured resolver and does not fetch a DID document', () => { + const record = { displayName: 'Alice', createdAt: '2025-01-01T00:00:00Z' }; + const row = profileRow(actor, 'alice', record, record.createdAt); + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, rows: [row], + resolverUrl: 'https://resolver.example', + httpResponses: [{ status: 200, body: 'resolved-handle', value: { did: actor } }], + assertions: ` +assert(result.profile.did == '${actor}' and result.profile.record.displayName == 'Alice') +assert(#httpCalls == 1 and #calls == 1) +assert(httpCalls[1] == 'https://resolver.example/xrpc/com.atproto.identity.resolveHandle?handle=alice.example') +assert(calls[1].values[2] == '${actor}') +`, + }); +}); + +test('getProfile accepts a valid IPv4 resolver base URL', () => { + const record = { displayName: 'Alice', createdAt: '2025-01-01T00:00:00Z' }; + const row = profileRow(actor, 'alice', record, record.createdAt); + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, rows: [row], + resolverUrl: 'https://192.0.2.10:8443', + httpResponses: [{ status: 200, body: 'ipv4-resolver', value: { did: actor } }], + assertions: ` +assert(#httpCalls == 1 and httpCalls[1] == 'https://192.0.2.10:8443/xrpc/com.atproto.identity.resolveHandle?handle=alice.example') +assert(result.profile.did == '${actor}') +`, + }); +}); + +test('getProfile accepts a valid bracketed IPv6 resolver base URL', () => { + const record = { displayName: 'Alice', createdAt: '2025-01-01T00:00:00Z' }; + const row = profileRow(actor, 'alice', record, record.createdAt); + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, rows: [row], + resolverUrl: 'https://[2001:db8::1]', + httpResponses: [{ status: 200, body: 'ipv6-resolver', value: { did: actor } }], + assertions: ` +assert(#httpCalls == 1 and httpCalls[1] == 'https://[2001:db8::1]/xrpc/com.atproto.identity.resolveHandle?handle=alice.example') +assert(result.profile.did == '${actor}') +`, + }); +}); + +test('handle lookup rejects missing or unsafe resolver configuration before HTTP', () => { + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, + expectError: 'HYPERCERTS_HANDLE_RESOLVER_URL', expectedHttpCalls: 0, + errorAssertions: "assert(tostring(result):find('HandleResolverConfigError:', 1, true), tostring(result))", + }); + for (const resolverUrl of [ + 'http://resolver.example', + 'https://user:secret@resolver.example', + 'https://resolver.example?tenant=one', + 'https://resolver.example/path', + 'https://resolver.example#fragment', + 'https://999.999.999.999', + 'https://999.1', + 'https://[:::]', + 'https://resolver.example\\\\@evil.example', + ]) { + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, resolverUrl, + expectError: 'HandleResolverConfigError:', expectedHttpCalls: 0, + errorAssertions: "assert(tostring(result):find('HYPERCERTS_HANDLE_RESOLVER_URL', 1, true), tostring(result))", + }); + } +}); + +test('resolver transport and invalid DID responses stay distinct from indexed RecordNotFound', () => { + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, resolverUrl: 'https://resolver.example', + httpResponses: [{ status: 503, body: 'unavailable' }], + expectError: 'HandleResolutionFailed:', expectedHttpCalls: 1, + }); + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, resolverUrl: 'https://resolver.example', + httpResponses: [{ status: 200, body: 'invalid-did', value: { did: 'not-a-did' } }], + expectError: 'HandleResolutionFailed:', expectedHttpCalls: 1, + }); + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, resolverUrl: 'https://resolver.example', + httpResponses: [{ status: 200, body: 'malformed-escape-did', value: { did: 'did:plc:abc%GG' } }], + expectError: 'HandleResolutionFailed:', expectedCalls: 0, expectedHttpCalls: 1, + }); + runLua({ + endpoint: 'getProfile', params: { actor: 'alice.example' }, resolverUrl: 'https://resolver.example', + httpResponses: [{ status: 200, body: 'resolved-handle', value: { did: actor } }], + expectError: 'RecordNotFound:', expectedCalls: 1, expectedHttpCalls: 1, + }); + runLua({ endpoint: 'getProfile', params: { actor }, expectError: 'RecordNotFound:', expectedCalls: 1, expectedHttpCalls: 0 }); +}); + +test('listProfiles deduplicates DIDs and paginates the shared profile view by createdAt and URI', () => { + const first = profileRow(actor, 'first', { + displayName: '100%_Forest', description: 'Upper river', createdAt: '2025-01-01T00:00:00Z', + }, '2025-01-01T00:00:00Z'); + const lookahead = profileRow(secondActor, 'next', { + displayName: 'Another forest', createdAt: '2025-01-02T00:00:00Z', + }, '2025-01-02T00:00:00Z'); + runLua({ + endpoint: 'listProfiles', + params: { actors: [actor, actor, secondActor], sortDirection: 'asc', limit: '1' }, + rows: [first, lookahead], + assertions: ` +assert(#result.profiles == 1 and result.profiles[1].uri == '${first.uri}') +assert(result.profiles[1].record.displayName == '100%_Forest') +assert(result.profiles[1].did == '${actor}' and result.profiles[1].cid == '${first.cid}') +assert(result.profiles[1].indexedAt == '${indexedAt}' and result.profiles[1].organization == nil) +assert(calls[1].values[1] == '${collection}') +assert(calls[1].values[2] == '${actor}' and calls[1].values[3] == '${secondActor}') +assert(calls[1].values[4] == 2) +local sql = calls[1].sql +assert(sql:find('did IN ($2, $3)', 1, true)) +assert(not sql:find('strpos', 1, true)) +assert(sql:find('ORDER BY sorted.sort_at ASC, uri ASC', 1, true)) +assert(result.cursor ~= nil) +local tokenJson = result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) +local token = json.decode(tokenJson) +assert(token.v == 1 and token.d == 'asc' and token.t == '${first.sort_timestamp}' and token.u == '${first.uri}') +assert(#calls == 1, 'profile results must not trigger sidecar hydration') +`, + }); +}); + +test('searchProfiles requires search and combines trimmed literal search with deduplicated actors', () => { + const first = profileRow(actor, 'first', { + displayName: '100%_Forest', description: 'Upper river', createdAt: '2025-01-01T00:00:00Z', + }, '2025-01-01T00:00:00Z'); + const lookahead = profileRow(secondActor, 'next', { + displayName: 'Another forest', createdAt: '2025-01-02T00:00:00Z', + }, '2025-01-02T00:00:00Z'); + runLua({ endpoint: 'searchProfiles', params: {}, expectError: 'InvalidRequest:' }); + runLua({ + endpoint: 'searchProfiles', + params: { actors: [actor, actor, secondActor], search: ' 100%_FOREST ', sortDirection: 'asc', limit: '1' }, + rows: [first, lookahead], + assertions: ` +assert(#result.profiles == 1 and result.profiles[1].uri == '${first.uri}') +assert(result.profiles[1].record.displayName == '100%_Forest') +assert(result.profiles[1].did == '${actor}' and result.profiles[1].cid == '${first.cid}') +assert(result.profiles[1].indexedAt == '${indexedAt}' and result.profiles[1].organization == nil) +assert(calls[1].values[1] == '${collection}') +assert(calls[1].values[2] == '${actor}' and calls[1].values[3] == '${secondActor}') +assert(calls[1].values[4] == '100%_forest' and calls[1].values[5] == 2) +local sql = calls[1].sql +assert(sql:find('did IN ($2, $3)', 1, true)) +assert(sql:find('AND', 1, true) and sql:find('strpos', 1, true)) +assert(not sql:find('100%%_forest', 1, true), 'search text must stay a bound literal, not SQL pattern syntax') +assert(sql:find('ORDER BY sorted.sort_at ASC, uri ASC', 1, true)) +assert(result.cursor ~= nil) +local tokenJson = result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end) +local token = json.decode(tokenJson) +assert(token.v == 1 and token.d == 'asc' and token.t == '${first.sort_timestamp}' and token.u == '${first.uri}') +assert(#calls == 1, 'profile results must not trigger sidecar hydration') +`, + }); +}); + +test('listProfiles and searchProfiles apply the same direction-bound keyset cursor', () => { + const row = profileRow(secondActor, 'after-cursor', { + displayName: 'Forest group', createdAt: '2025-01-03T00:00:00Z', + }, '2025-01-03T00:00:00Z'); + const before = `at://${actor}/${collection}/self`; + for (const endpoint of ['listProfiles', 'searchProfiles']) { + const searchEnabled = endpoint === 'searchProfiles'; + const params = { + sortDirection: 'asc', limit: '1', + cursor: cursor({ direction: 'asc', timestamp: '2025-01-02T00:00:00.000000Z', uri: before }), + ...(searchEnabled ? { search: 'forest' } : {}), + }; + const cursorIndex = searchEnabled ? 3 : 2; + runLua({ + endpoint, params, rows: [row], + assertions: ` +assert(#result.profiles == 1 and result.profiles[1].uri == '${row.uri}') +assert(result.profiles[1].record.displayName == 'Forest group') +assert(result.cursor == nil) +assert(calls[1].sql:find('(sorted.sort_at, uri) >', 1, true)) +assert(calls[1].sql:find('ORDER BY sorted.sort_at ASC, uri ASC', 1, true)) +assert(calls[1].values[${cursorIndex}] == '2025-01-02T00:00:00.000000Z') +assert(calls[1].values[${cursorIndex + 1}] == '${before}') +assert(calls[1].values[${cursorIndex + 2}] == 2) +`, + }); + } +}); + +test('listProfiles and blank searchProfiles share defaults, views, and terminal pagination behavior', () => { + for (const [endpoint, params] of [['listProfiles', {}], ['searchProfiles', { search: ' ' }]]) { + runLua({ + endpoint, params, rows: [], + assertions: ` +assert(#result.profiles == 0 and result.cursor == nil) +assert(calls[1].values[1] == '${collection}' and calls[1].values[2] == 26) +assert(calls[1].sql:find('ORDER BY sorted.sort_at DESC, uri DESC', 1, true)) +assert(not calls[1].sql:find('strpos', 1, true)) +`, + }); + } +}); + +test('listProfiles rejects search and both listing endpoints reject invalid filters before querying', () => { + runLua({ endpoint: 'listProfiles', params: { search: 'forest' }, expectError: 'InvalidRequest:' }); + for (const endpoint of ['listProfiles', 'searchProfiles']) { + runLua({ endpoint, params: { actors: ['alice.example'], ...(endpoint === 'searchProfiles' ? { search: 'forest' } : {}) }, expectError: 'InvalidRequest:' }); + runLua({ endpoint, params: { actors: Array(101).fill(actor), ...(endpoint === 'searchProfiles' ? { search: 'forest' } : {}) }, expectError: 'InvalidRequest:' }); + runLua({ endpoint, params: { cursor: 'not-hex', ...(endpoint === 'searchProfiles' ? { search: 'forest' } : {}) }, expectError: 'InvalidRequest:' }); + runLua({ endpoint, params: { sortDirection: 'asc', cursor: cursor({ direction: 'desc' }), ...(endpoint === 'searchProfiles' ? { search: 'forest' } : {}) }, expectError: 'InvalidRequest:' }); + } +});