diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 8713fd4..7144dc0 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -28,7 +28,7 @@ Inspect the exact operation in the bundled OpenAPI contract before constructing | Browse/filter organizations | `app.certified.actor.listOrganizations` | Use for organization-type/visibility filters and pagination. | | Find organizations by text | `app.certified.actor.searchOrganizations` | Searches associated profile text, with optional organization filters. | -Use `getProfiles`/`getOrganizations` for known DID sets; use `list` or `search` for discovery. A missing optional profile inside an organization view is not the same as a missing organization record. +Use `getProfiles`/`getOrganizations` for known DID sets; use `list` or `search` for discovery. A missing optional profile inside an organization view is not the same as a missing organization record. Organization actor views from these four endpoints include `location` (the resolved, raw indexed location record, or null; `organization.record.location` stays the strong reference); use it instead of a second `getLocation` call. Organization views nested in other endpoints' author or actor views do not include it. ## Activities, contributors, contributions, and rights (9) diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index 0188dc7..f7a7897 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -81,7 +81,7 @@ "get": { "operationId": "app_certified_actor_getOrganization", "tags": ["app.certified.actor"], - "description": "Gets an actor's indexed organization sidecar and associated profile by DID. Authentication is not required.", + "description": "Gets an actor's indexed organization sidecar, associated profile and location by DID. Authentication is not required.", "parameters": [ { "name": "actor", @@ -117,7 +117,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -126,7 +126,7 @@ "get": { "operationId": "app_certified_actor_getOrganizations", "tags": ["app.certified.actor"], - "description": "Returns an indexed organization sidecar and associated profile for each supplied actor DID. Authentication is not required.", + "description": "Returns an indexed organization sidecar, associated profile and location for each supplied actor DID. Authentication is not required.", "parameters": [ { "name": "actors", @@ -165,7 +165,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecars or associated profiles could not be queried." + "description": "The indexed organization sidecars, associated profiles or locations could not be queried." } ] } @@ -337,7 +337,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -485,7 +485,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -4197,9 +4197,23 @@ "record": { "$ref": "#/components/schemas/app.certified.actor.organization.main" } } }, + "app.certified.actor.getOrganization.locationRecordView": { + "type": "object", + "description": "Indexed app.certified.location record view preserving the full record exactly as getLocation returns it, but without the hydrated author; indexedAt may be null when the index has no timestamp.", + "required": ["uri", "cid", "indexedAt", "did", "record"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { + "anyOf": [{ "type": "string", "format": "datetime" }, { "type": "null" }] + }, + "did": { "type": "string", "format": "did" }, + "record": { "$ref": "#/components/schemas/app.certified.location.main" } + } + }, "app.certified.actor.getOrganization.organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar and nullable profile view.", + "description": "Actor with an indexed organization sidecar, nullable profile view and nullable location view.", "required": ["did", "profile", "organization"], "properties": { "did": { "type": "string", "format": "did" }, @@ -4211,6 +4225,14 @@ }, "organization": { "$ref": "#/components/schemas/org.hypercerts.api.defs.organizationView" + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/app.certified.actor.getOrganization.locationRecordView" + }, + { "type": "null" } + ] } } }, diff --git a/.agents/skills/hypercerts-api/references/recipes.md b/.agents/skills/hypercerts-api/references/recipes.md index ad1836b..11ec22d 100644 --- a/.agents/skills/hypercerts-api/references/recipes.md +++ b/.agents/skills/hypercerts-api/references/recipes.md @@ -34,7 +34,7 @@ These recipes show endpoint choice and the data to carry between calls. For exac ## Load profile and organization context 1. For one actor profile, call `app.certified.actor.getProfile` with a DID or handle. For many known actor DIDs, use `app.certified.actor.getProfiles` with repeated `actors` values; the returned results correspond to the input occurrences and `profile` can be null. -2. For organization sidecar context, use `app.certified.actor.getOrganization` with an actor DID or batch known DIDs with `app.certified.actor.getOrganizations`. In batch results, an absent organization is represented by a null `organization` for that actor. The organization actor view's `profile` may independently be null. +2. For organization sidecar context, use `app.certified.actor.getOrganization` with an actor DID or batch known DIDs with `app.certified.actor.getOrganizations`. In batch results, an absent organization is represented by a null `organization` for that actor. The organization actor view's `profile` may independently be null. Each organization actor view (`getOrganization`, `getOrganizations`, `listOrganizations`, `searchOrganizations`) also carries `location`: the resolved `app.certified.location` record the sidecar's `location` reference points to, unchanged (a blob payload stays a blob reference, with no URL), or null when the sidecar has no location reference, the reference is not a location record, or that record is not indexed. It is matched by AT-URI, so if its `cid` differs from `organization.record.location.cid` it is a different indexed version, which may be older or newer than the referenced one. No second `getLocation` call is needed. 3. Use `listProfiles` / `listOrganizations` to browse and the corresponding `search...` endpoint for text discovery. Do not try to resolve handles in the batch APIs: their contract is DID input. `getProfile` handle resolution depends on the configured resolver and is not independent DID-document verification. 4. When another endpoint already returns an author/actor view, inspect that view's contract before issuing a second hydration call. Optional profile or organization projections can be null; fetching the full view again may add work without making the indexed data fresher. diff --git a/.changeset/organization-location.md b/.changeset/organization-location.md new file mode 100644 index 0000000..d98885b --- /dev/null +++ b/.changeset/organization-location.md @@ -0,0 +1,6 @@ +--- +'@hypercerts-org/happyview-modules': minor +'@hypercerts-org/api': minor +--- + +Organization queries (`getOrganization`, `getOrganizations`, `listOrganizations`, `searchOrganizations`) now return each organization's location record in `location` (the resolved view of the strong reference at `organization.record.location`, which is unchanged), so you no longer need a second `getLocation` call. It is the location record exactly as stored, or `null` when the organization has no location or the record is not indexed. diff --git a/docs/openapi.json b/docs/openapi.json index f2e3751..3e5c0f9 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -59,7 +59,7 @@ "tags": [ "app.certified.actor" ], - "description": "Gets an actor's indexed organization sidecar and associated profile by DID. Authentication is not required.", + "description": "Gets an actor's indexed organization sidecar, associated profile and location by DID. Authentication is not required.", "parameters": [ { "name": "actor", @@ -100,7 +100,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -111,7 +111,7 @@ "tags": [ "app.certified.actor" ], - "description": "Returns an indexed organization sidecar and associated profile for each supplied actor DID. Authentication is not required.", + "description": "Returns an indexed organization sidecar, associated profile and location for each supplied actor DID. Authentication is not required.", "parameters": [ { "name": "actors", @@ -155,7 +155,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecars or associated profiles could not be queried." + "description": "The indexed organization sidecars, associated profiles or locations could not be queried." } ] } @@ -366,7 +366,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -558,7 +558,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -5354,9 +5354,52 @@ } } }, + "app.certified.location.main": { + "type": "record", + "description": "A location reference" + }, + "app.certified.actor.getOrganization.locationRecordView": { + "type": "object", + "description": "Indexed app.certified.location record view preserving the full record exactly as getLocation returns it, but without the hydrated author; indexedAt may be null when the index has no timestamp.", + "required": [ + "uri", + "cid", + "indexedAt", + "did", + "record" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "indexedAt": { + "anyOf": [ + { + "type": "string", + "format": "datetime" + }, + { + "type": "null" + } + ] + }, + "did": { + "type": "string", + "format": "did" + }, + "record": { + "$ref": "#/components/schemas/app.certified.location.main" + } + } + }, "app.certified.actor.getOrganization.organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar and nullable profile view.", + "description": "Actor with an indexed organization sidecar, nullable profile view and nullable location view.", "required": [ "did", "profile", @@ -5379,6 +5422,16 @@ }, "organization": { "$ref": "#/components/schemas/org.hypercerts.api.defs.organizationView" + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/app.certified.actor.getOrganization.locationRecordView" + }, + { + "type": "null" + } + ] } } }, @@ -7060,10 +7113,6 @@ "type": "record", "description": "A collection/group of items (activities and/or other collections). Collections support recursive nesting." }, - "app.certified.location.main": { - "type": "record", - "description": "A location reference" - }, "org.hypercerts.collection.getCollection.locationRecordView": { "type": "object", "description": "Raw location record view with metadata and no author hydration, used for exact-version collection reference projections.", diff --git a/docs/sources/lexicons/app.certified.actor.getOrganization.json b/docs/sources/lexicons/app.certified.actor.getOrganization.json index 62b8402..ebb89d1 100644 --- a/docs/sources/lexicons/app.certified.actor.getOrganization.json +++ b/docs/sources/lexicons/app.certified.actor.getOrganization.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Gets an actor's indexed organization sidecar and associated profile by DID. Authentication is not required.", + "description": "Gets an actor's indexed organization sidecar, associated profile and location by DID. Authentication is not required.", "parameters": { "type": "params", "required": [ @@ -36,20 +36,57 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, + "locationRecordView": { + "type": "object", + "description": "Indexed app.certified.location record view preserving the full record exactly as getLocation returns it, but without the hydrated author; indexedAt may be null when the index has no timestamp.", + "required": [ + "uri", + "cid", + "indexedAt", + "did", + "record" + ], + "nullable": [ + "indexedAt" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "indexedAt": { + "type": "string", + "format": "datetime" + }, + "did": { + "type": "string", + "format": "did" + }, + "record": { + "type": "ref", + "ref": "app.certified.location" + } + } + }, "organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar and nullable profile view.", + "description": "Actor with an indexed organization sidecar, nullable profile view and nullable location view.", "required": [ "did", "profile", "organization" ], "nullable": [ - "profile" + "profile", + "location" ], "properties": { "did": { @@ -63,6 +100,11 @@ "organization": { "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" + }, + "location": { + "type": "ref", + "ref": "#locationRecordView", + "description": "The organization's location record, as stored (blobs are not resolved). Only returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations. Null if there is no location or it is not indexed. Matched by URI, so it may be a newer version than organization.record.location.cid." } } }, diff --git a/docs/sources/lexicons/app.certified.actor.getOrganizations.json b/docs/sources/lexicons/app.certified.actor.getOrganizations.json index 64b33dc..26c02c6 100644 --- a/docs/sources/lexicons/app.certified.actor.getOrganizations.json +++ b/docs/sources/lexicons/app.certified.actor.getOrganizations.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns an indexed organization sidecar and associated profile for each supplied actor DID. Authentication is not required.", + "description": "Returns an indexed organization sidecar, associated profile and location for each supplied actor DID. Authentication is not required.", "parameters": { "type": "params", "required": [ @@ -37,7 +37,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecars or associated profiles could not be queried." + "description": "The indexed organization sidecars, associated profiles or locations could not be queried." } ] }, diff --git a/docs/sources/lexicons/app.certified.actor.listOrganizations.json b/docs/sources/lexicons/app.certified.actor.listOrganizations.json index 3117f09..2b73be2 100644 --- a/docs/sources/lexicons/app.certified.actor.listOrganizations.json +++ b/docs/sources/lexicons/app.certified.actor.listOrganizations.json @@ -55,7 +55,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, diff --git a/docs/sources/lexicons/app.certified.actor.searchOrganizations.json b/docs/sources/lexicons/app.certified.actor.searchOrganizations.json index 0b8a685..391d11e 100644 --- a/docs/sources/lexicons/app.certified.actor.searchOrganizations.json +++ b/docs/sources/lexicons/app.certified.actor.searchOrganizations.json @@ -71,7 +71,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar or associated profile could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, diff --git a/happyview-modules/lexicons/app.certified.actor.getOrganization.json b/happyview-modules/lexicons/app.certified.actor.getOrganization.json index d638cfd..f3a8e26 100644 --- a/happyview-modules/lexicons/app.certified.actor.getOrganization.json +++ b/happyview-modules/lexicons/app.certified.actor.getOrganization.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Gets an actor's indexed organization sidecar and associated profile by DID. Authentication is not required.", + "description": "Gets an actor's indexed organization sidecar, associated profile and location by DID. Authentication is not required.", "parameters": { "type": "params", "required": ["actor"], @@ -23,18 +23,36 @@ "errors": [ { "name": "InvalidRequest", "description": "The actor DID is invalid or an unsupported parameter was supplied." }, { "name": "RecordNotFound", "description": "No indexed organization sidecar exists for this DID." }, - { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecar or associated profile could not be queried." } + { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, + "locationRecordView": { + "type": "object", + "description": "Indexed app.certified.location record view preserving the full record exactly as getLocation returns it, but without the hydrated author; indexedAt may be null when the index has no timestamp.", + "required": ["uri", "cid", "indexedAt", "did", "record"], + "nullable": ["indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "did": { "type": "string", "format": "did" }, + "record": { "type": "ref", "ref": "app.certified.location" } + } + }, "organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar and nullable profile view.", + "description": "Actor with an indexed organization sidecar, nullable profile view and nullable location view.", "required": ["did", "profile", "organization"], - "nullable": ["profile"], + "nullable": ["profile", "location"], "properties": { "did": { "type": "string", "format": "did" }, "profile": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" }, - "organization": { "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" } + "organization": { "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" }, + "location": { + "type": "ref", + "ref": "#locationRecordView", + "description": "The organization's location record, as stored (blobs are not resolved). Only returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations. Null if there is no location or it is not indexed. Matched by URI, so it may be a newer version than organization.record.location.cid." + } } }, "output": { diff --git a/happyview-modules/lexicons/app.certified.actor.getOrganizations.json b/happyview-modules/lexicons/app.certified.actor.getOrganizations.json index d33c0d9..f9223f4 100644 --- a/happyview-modules/lexicons/app.certified.actor.getOrganizations.json +++ b/happyview-modules/lexicons/app.certified.actor.getOrganizations.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns an indexed organization sidecar and associated profile for each supplied actor DID. Authentication is not required.", + "description": "Returns an indexed organization sidecar, associated profile and location for each supplied actor DID. Authentication is not required.", "parameters": { "type": "params", "required": ["actors"], @@ -24,7 +24,7 @@ }, "errors": [ { "name": "InvalidRequest", "description": "An actor DID or query parameter is invalid." }, - { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecars or associated profiles could not be queried." } + { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecars, associated profiles or locations could not be queried." } ] }, "output": { diff --git a/happyview-modules/lexicons/app.certified.actor.listOrganizations.json b/happyview-modules/lexicons/app.certified.actor.listOrganizations.json index b3d49d2..c24a12c 100644 --- a/happyview-modules/lexicons/app.certified.actor.listOrganizations.json +++ b/happyview-modules/lexicons/app.certified.actor.listOrganizations.json @@ -41,7 +41,7 @@ }, "errors": [ { "name": "InvalidRequest", "description": "A filter or scalar parameter is invalid or repeated, or an unsupported parameter was supplied." }, - { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecar or associated profile could not be queried." } + { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, "output": { diff --git a/happyview-modules/lexicons/app.certified.actor.searchOrganizations.json b/happyview-modules/lexicons/app.certified.actor.searchOrganizations.json index 8094e17..41b61ea 100644 --- a/happyview-modules/lexicons/app.certified.actor.searchOrganizations.json +++ b/happyview-modules/lexicons/app.certified.actor.searchOrganizations.json @@ -52,7 +52,7 @@ }, "errors": [ { "name": "InvalidRequest", "description": "A filter or scalar parameter is invalid or repeated, or an unsupported parameter was supplied." }, - { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecar or associated profile could not be queried." } + { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, "output": { diff --git a/happyview-modules/lua/endpoints/getOrganization.lua b/happyview-modules/lua/endpoints/getOrganization.lua index 6a3c467..7cdcc6f 100644 --- a/happyview-modules/lua/endpoints/getOrganization.lua +++ b/happyview-modules/lua/endpoints/getOrganization.lua @@ -27,6 +27,18 @@ local function scalar(params, key) return tostring(value) end +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + local NULL = json.decode("null") local function record_view(row) @@ -41,6 +53,7 @@ end local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" +local LOCATION = "app.certified.location" local function query(sql, values) if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end @@ -56,12 +69,11 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), + location = NULL, } end -local function hydrate_organization_actors(actors) - if #actors == 0 then return end - +local function hydrate_profiles(actors) local dids, seen = {}, {} for _, actor in ipairs(actors) do if not seen[actor.did] then @@ -86,6 +98,50 @@ local function hydrate_organization_actors(actors) end end +-- The organization's location reference pins a CID, but the lookup matches the URI only, so a newer +-- indexed version is returned as-is. Anything that is not a location record URI yields no lookup. +local function location_uri(organization) + local reference = organization.record.location + if type(reference) ~= "table" or type(reference.uri) ~= "string" then return nil end + local valid, collection = valid_record_uri(reference.uri) + if valid and collection == LOCATION then return reference.uri end + return nil +end + +local function hydrate_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and not seen[uri] then + seen[uri] = true + uris[#uris + 1] = uri + end + end + if #uris == 0 then return end + + local values, marks = { LOCATION }, {} + for _, uri in ipairs(uris) do + values[#values + 1] = uri + marks[#marks + 1] = "$" .. #values + 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 uri IN (" .. table.concat(marks, ", ") .. ")", + values) + local locations = {} + for _, row in ipairs(rows) do locations[row.uri] = row end + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and locations[uri] then actor.location = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_locations(actors) +end + function handle() keys_only(params, { actor = true }) local actor = scalar(params, "actor") diff --git a/happyview-modules/lua/endpoints/getOrganizations.lua b/happyview-modules/lua/endpoints/getOrganizations.lua index 21b9539..c2e0502 100644 --- a/happyview-modules/lua/endpoints/getOrganizations.lua +++ b/happyview-modules/lua/endpoints/getOrganizations.lua @@ -27,6 +27,18 @@ local function scalar(params, key) return tostring(value) end +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + local NULL = json.decode("null") local function record_view(row) @@ -41,6 +53,7 @@ end local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" +local LOCATION = "app.certified.location" local function query(sql, values) if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end @@ -56,12 +69,11 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), + location = NULL, } end -local function hydrate_organization_actors(actors) - if #actors == 0 then return end - +local function hydrate_profiles(actors) local dids, seen = {}, {} for _, actor in ipairs(actors) do if not seen[actor.did] then @@ -86,6 +98,50 @@ local function hydrate_organization_actors(actors) end end +-- The organization's location reference pins a CID, but the lookup matches the URI only, so a newer +-- indexed version is returned as-is. Anything that is not a location record URI yields no lookup. +local function location_uri(organization) + local reference = organization.record.location + if type(reference) ~= "table" or type(reference.uri) ~= "string" then return nil end + local valid, collection = valid_record_uri(reference.uri) + if valid and collection == LOCATION then return reference.uri end + return nil +end + +local function hydrate_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and not seen[uri] then + seen[uri] = true + uris[#uris + 1] = uri + end + end + if #uris == 0 then return end + + local values, marks = { LOCATION }, {} + for _, uri in ipairs(uris) do + values[#values + 1] = uri + marks[#marks + 1] = "$" .. #values + 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 uri IN (" .. table.concat(marks, ", ") .. ")", + values) + local locations = {} + for _, row in ipairs(rows) do locations[row.uri] = row end + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and locations[uri] then actor.location = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_locations(actors) +end + local function requested_organization_actors(value) if value == nil then invalid("actors is required") end diff --git a/happyview-modules/lua/endpoints/listOrganizations.lua b/happyview-modules/lua/endpoints/listOrganizations.lua index 6a44f5b..c7127db 100644 --- a/happyview-modules/lua/endpoints/listOrganizations.lua +++ b/happyview-modules/lua/endpoints/listOrganizations.lua @@ -27,6 +27,18 @@ local function scalar(params, key) return tostring(value) end +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + local function valid_datetime(value) if type(value) ~= "string" then return false end local year, month, day, hour, minute, second, suffix = value:match( @@ -83,6 +95,7 @@ end local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" +local LOCATION = "app.certified.location" local function query(sql, values) if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end @@ -98,12 +111,11 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), + location = NULL, } end -local function hydrate_organization_actors(actors) - if #actors == 0 then return end - +local function hydrate_profiles(actors) local dids, seen = {}, {} for _, actor in ipairs(actors) do if not seen[actor.did] then @@ -128,6 +140,50 @@ local function hydrate_organization_actors(actors) end end +-- The organization's location reference pins a CID, but the lookup matches the URI only, so a newer +-- indexed version is returned as-is. Anything that is not a location record URI yields no lookup. +local function location_uri(organization) + local reference = organization.record.location + if type(reference) ~= "table" or type(reference.uri) ~= "string" then return nil end + local valid, collection = valid_record_uri(reference.uri) + if valid and collection == LOCATION then return reference.uri end + return nil +end + +local function hydrate_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and not seen[uri] then + seen[uri] = true + uris[#uris + 1] = uri + end + end + if #uris == 0 then return end + + local values, marks = { LOCATION }, {} + for _, uri in ipairs(uris) do + values[#values + 1] = uri + marks[#marks + 1] = "$" .. #values + 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 uri IN (" .. table.concat(marks, ", ") .. ")", + values) + local locations = {} + for _, row in ipairs(rows) do locations[row.uri] = row end + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and locations[uri] then actor.location = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_locations(actors) +end + local CREATED_AT_SORT_COLUMNS = { profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, organization = { diff --git a/happyview-modules/lua/endpoints/searchOrganizations.lua b/happyview-modules/lua/endpoints/searchOrganizations.lua index 634b80a..c3973c5 100644 --- a/happyview-modules/lua/endpoints/searchOrganizations.lua +++ b/happyview-modules/lua/endpoints/searchOrganizations.lua @@ -27,6 +27,18 @@ local function scalar(params, key) return tostring(value) end +local function valid_record_key(value) + return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".." + and not value:find("[^%w_~%.:%-]") +end + +local function valid_record_uri(value) + if type(value) ~= "string" or value:find("[?#]") then return false end + local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$") + if not authority or not valid_did(authority) or not valid_record_key(rkey) then return false end + return true, collection, authority +end + local function valid_datetime(value) if type(value) ~= "string" then return false end local year, month, day, hour, minute, second, suffix = value:match( @@ -83,6 +95,7 @@ end local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" +local LOCATION = "app.certified.location" local function query(sql, values) if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end @@ -98,12 +111,11 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), + location = NULL, } end -local function hydrate_organization_actors(actors) - if #actors == 0 then return end - +local function hydrate_profiles(actors) local dids, seen = {}, {} for _, actor in ipairs(actors) do if not seen[actor.did] then @@ -128,6 +140,50 @@ local function hydrate_organization_actors(actors) end end +-- The organization's location reference pins a CID, but the lookup matches the URI only, so a newer +-- indexed version is returned as-is. Anything that is not a location record URI yields no lookup. +local function location_uri(organization) + local reference = organization.record.location + if type(reference) ~= "table" or type(reference.uri) ~= "string" then return nil end + local valid, collection = valid_record_uri(reference.uri) + if valid and collection == LOCATION then return reference.uri end + return nil +end + +local function hydrate_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and not seen[uri] then + seen[uri] = true + uris[#uris + 1] = uri + end + end + if #uris == 0 then return end + + local values, marks = { LOCATION }, {} + for _, uri in ipairs(uris) do + values[#values + 1] = uri + marks[#marks + 1] = "$" .. #values + 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 uri IN (" .. table.concat(marks, ", ") .. ")", + values) + local locations = {} + for _, row in ipairs(rows) do locations[row.uri] = row end + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and locations[uri] then actor.location = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_locations(actors) +end + local CREATED_AT_SORT_COLUMNS = { profile = { record = "record", indexed_at = "indexed_at", created_at = "created_at" }, organization = { diff --git a/happyview-modules/lua/shared/organization.lua b/happyview-modules/lua/shared/organization.lua index 46acd95..667dbe3 100644 --- a/happyview-modules/lua/shared/organization.lua +++ b/happyview-modules/lua/shared/organization.lua @@ -1,5 +1,6 @@ local PROFILE = "app.certified.actor.profile" local ORGANIZATION = "app.certified.actor.organization" +local LOCATION = "app.certified.location" local function query(sql, values) if db.backend() ~= "postgres" then error("OrganizationQueryFailed: organization API requires PostgreSQL", 0) end @@ -15,12 +16,11 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), + location = NULL, } end -local function hydrate_organization_actors(actors) - if #actors == 0 then return end - +local function hydrate_profiles(actors) local dids, seen = {}, {} for _, actor in ipairs(actors) do if not seen[actor.did] then @@ -44,3 +44,47 @@ local function hydrate_organization_actors(actors) actor.profile = profiles[actor.did] and record_view(profiles[actor.did]) or NULL end end + +-- The organization's location reference pins a CID, but the lookup matches the URI only, so a newer +-- indexed version is returned as-is. Anything that is not a location record URI yields no lookup. +local function location_uri(organization) + local reference = organization.record.location + if type(reference) ~= "table" or type(reference.uri) ~= "string" then return nil end + local valid, collection = valid_record_uri(reference.uri) + if valid and collection == LOCATION then return reference.uri end + return nil +end + +local function hydrate_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and not seen[uri] then + seen[uri] = true + uris[#uris + 1] = uri + end + end + if #uris == 0 then return end + + local values, marks = { LOCATION }, {} + for _, uri in ipairs(uris) do + values[#values + 1] = uri + marks[#marks + 1] = "$" .. #values + 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 uri IN (" .. table.concat(marks, ", ") .. ")", + values) + local locations = {} + for _, row in ipairs(rows) do locations[row.uri] = row end + for _, actor in ipairs(actors) do + local uri = location_uri(actor.organization) + if uri and locations[uri] then actor.location = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_locations(actors) +end diff --git a/happyview-modules/modules/organization/manifest.json b/happyview-modules/modules/organization/manifest.json index 3a9c376..dc36d22 100644 --- a/happyview-modules/modules/organization/manifest.json +++ b/happyview-modules/modules/organization/manifest.json @@ -11,6 +11,7 @@ "dependsOn": [ "app.certified.actor.organization", "app.certified.actor.profile", + "app.certified.location", "org.hypercerts.api.defs" ] }, @@ -52,6 +53,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", "../../lua/shared/recordView.lua", "../../lua/shared/organization.lua" ], @@ -69,6 +71,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", "../../lua/shared/recordView.lua", "../../lua/shared/organization.lua" ], @@ -86,6 +89,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", @@ -108,6 +112,7 @@ "sharedSourcePaths": [ "../../lua/shared/didValidation.lua", "../../lua/shared/query.lua", + "../../lua/shared/recordIdentifier.lua", "../../lua/shared/datetimeValidation.lua", "../../lua/shared/listValidation.lua", "../../lua/shared/listQuery.lua", diff --git a/happyview-modules/tests/http/actor-organizations.http.test.js b/happyview-modules/tests/http/actor-organizations.http.test.js index dd0f0a6..4e84a85 100644 --- a/happyview-modules/tests/http/actor-organizations.http.test.js +++ b/happyview-modules/tests/http/actor-organizations.http.test.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { locationRecords } from "../fixtures/records.js"; import { actorFixtureDids, seedRows } from "./fixtures/actors.fixture.js"; import { contractUrl, requireContractTarget } from "./helpers.js"; @@ -16,6 +17,8 @@ const profilelessOrganization = organizationRecords.find( ); const unlistedOrganization = organizationRecords.find(({ did }) => did === actorFixtureDids.forest); const unlistedProfile = profileRecords.find(({ did }) => did === unlistedOrganization.did); +const pointLocation = locationRecords.find(({ rkey }) => rkey === "3jzfcijpj2z2a"); +const blobLocation = locationRecords.find(({ rkey }) => rkey === "3jzfcijpj2z2c"); function recordView(record) { return { @@ -27,6 +30,18 @@ function recordView(record) { }; } +// What each fixture organization's location must be; null for every reference that cannot be +// resolved to an indexed app.certified.location record. +function expectedLocation(did) { + const expected = { + [actorFixtureDids.alpine]: recordView(pointLocation), + [actorFixtureDids.forest]: recordView(blobLocation), + // River pins blobLocation's CID but its URI is pointLocation's: the latest indexed record wins. + [actorFixtureDids.river]: recordView(pointLocation), + }; + return expected[did] ?? null; +} + async function get(nsid, params) { const response = await fetch(contractUrl(baseUrl, nsid, params)); const body = await response.json(); @@ -51,6 +66,7 @@ test("getOrganization returns the original sidecar and explicit nullable profile did: publicOrganization.did, profile: recordView(profileRecords[0]), organization: recordView(publicOrganization), + location: recordView(pointLocation), }); const withoutProfile = await get("app.certified.actor.getOrganization", { @@ -60,6 +76,7 @@ test("getOrganization returns the original sidecar and explicit nullable profile did: profilelessOrganization.did, profile: null, organization: recordView(profilelessOrganization), + location: null, }); }); @@ -109,6 +126,7 @@ test("getOrganizations preserves repeated DID occurrences and serializes a missi did: profilelessOrganization.did, profile: null, organization: recordView(profilelessOrganization), + location: null, }); assert.deepEqual(result.organizations[0], result.organizations[3]); assert.deepEqual(result.organizations[2].organization.profile, recordView(profileRecords[0])); @@ -216,3 +234,51 @@ test("listOrganizations paginates stably in both directions without skipping equ } assert.deepEqual(itemsByDirection.asc, [...itemsByDirection.desc].reverse()); }); + +test("getOrganization returns the raw location record, or null when the reference cannot be resolved", async () => { + const dids = [ + actorFixtureDids.alpine, + actorFixtureDids.forest, + actorFixtureDids.river, + actorFixtureDids.organizationOnly, + actorFixtureDids.unindexedLocation, + actorFixtureDids.wrongCollectionLocation, + ]; + for (const did of dids) { + const { actor } = await get("app.certified.actor.getOrganization", { actor: did }); + assert.deepEqual(actor.location, expectedLocation(did), did); + assert.equal(Object.hasOwn(actor, "location"), true, `${did}: key must be present`); + } +}); + +test("getOrganizations, listOrganizations and searchOrganizations return the same location per organization", async () => { + const fixtureDids = [ + actorFixtureDids.alpine, + actorFixtureDids.forest, + actorFixtureDids.river, + actorFixtureDids.organizationOnly, + actorFixtureDids.unindexedLocation, + actorFixtureDids.wrongCollectionLocation, + ]; + const requested = [...fixtureDids, actorFixtureDids.alpine]; + const batch = await get("app.certified.actor.getOrganizations", { actors: requested }); + assert.deepEqual( + batch.organizations.map(({ organization }) => organization.location), + requested.map(expectedLocation), + ); + + const listed = await get("app.certified.actor.listOrganizations", { limit: 100 }); + const listedByDid = new Map(listed.actors.map((actor) => [actor.did, actor])); + for (const did of fixtureDids) { + assert.deepEqual(listedByDid.get(did).location, expectedLocation(did), did); + } + + const searched = await get("app.certified.actor.searchOrganizations", { + search: "Alpine Forest", + actors: [actorFixtureDids.alpine], + }); + assert.deepEqual( + searched.actors.map(({ location }) => location), + [recordView(pointLocation)], + ); +}); diff --git a/happyview-modules/tests/http/fixtures/actors.fixture.js b/happyview-modules/tests/http/fixtures/actors.fixture.js index ae154f0..c0fbcd9 100644 --- a/happyview-modules/tests/http/fixtures/actors.fixture.js +++ b/happyview-modules/tests/http/fixtures/actors.fixture.js @@ -1,5 +1,6 @@ import { encode } from "@atcute/cbor"; import * as CID from "@atcute/cid"; +import { locationRecords } from "../../fixtures/records.js"; const profileCollection = "app.certified.actor.profile"; const organizationCollection = "app.certified.actor.organization"; @@ -12,6 +13,29 @@ export const actorFixtureDids = { river: "did:plc:oooooooooooooooooooooooo", profileOnly: "did:plc:pppppppppppppppppppppppp", organizationOnly: "did:plc:qqqqqqqqqqqqqqqqqqqqqqqq", + unindexedLocation: "did:web:org-location-unindexed.example", + wrongCollectionLocation: "did:web:org-location-wrong-collection.example", +}; + +const locationCollection = "app.certified.location"; +const pointLocation = locationRecords.find(({ rkey }) => rkey === "3jzfcijpj2z2a"); +const blobLocation = locationRecords.find(({ rkey }) => rkey === "3jzfcijpj2z2c"); + +// Location scenarios: alpine pins the current point location, forest a blob-payload +// location, river a stale CID, and the two sidecar-only actors a never-indexed location and a +// reference to an indexed record in another collection. organizationOnly has no reference. +const locationRefs = { + alpine: { uri: pointLocation.uri, cid: pointLocation.cid }, + forest: { uri: blobLocation.uri, cid: blobLocation.cid }, + river: { uri: pointLocation.uri, cid: blobLocation.cid }, + unindexed: { + uri: `at://${pointLocation.did}/${locationCollection}/3jzfcijpj2z2z`, + cid: pointLocation.cid, + }, + wrongCollection: { + uri: `at://${actorFixtureDids.alpine}/${profileCollection}/self`, + cid: pointLocation.cid, + }, }; const records = [ @@ -61,6 +85,7 @@ const records = [ rkey: "self", record: { organizationType: ["nonprofit", "cooperative"], + location: locationRefs.alpine, visibility: "public", createdAt, }, @@ -71,6 +96,7 @@ const records = [ rkey: "self", record: { organizationType: ["community"], + location: locationRefs.forest, visibility: "unlisted", createdAt, }, @@ -81,6 +107,7 @@ const records = [ rkey: "self", record: { organizationType: ["nonprofit"], + location: locationRefs.river, visibility: "public", createdAt, }, @@ -94,6 +121,26 @@ const records = [ createdAt, }, }, + { + did: actorFixtureDids.unindexedLocation, + collection: organizationCollection, + rkey: "self", + record: { + organizationType: ["location-fixture"], + location: locationRefs.unindexed, + createdAt, + }, + }, + { + did: actorFixtureDids.wrongCollectionLocation, + collection: organizationCollection, + rkey: "self", + record: { + organizationType: ["location-fixture"], + location: locationRefs.wrongCollection, + createdAt, + }, + }, ]; export const seedRows = await Promise.all( diff --git a/happyview-modules/tests/unit/tooling/lexicons.test.js b/happyview-modules/tests/unit/tooling/lexicons.test.js index cbb6937..10c4278 100644 --- a/happyview-modules/tests/unit/tooling/lexicons.test.js +++ b/happyview-modules/tests/unit/tooling/lexicons.test.js @@ -161,8 +161,12 @@ if (hasModule("modules/organization/manifest.json")) "app.certified.actor.getOrganization#organizationActorView", ); assert.deepEqual(actorView.required, ["did", "profile", "organization"]); - assert.deepEqual(actorView.nullable, ["profile"]); + assert.deepEqual(actorView.nullable, ["profile", "location"]); assert.equal(actorView.properties.profile.ref, "lex:org.hypercerts.api.defs#profileView"); + assert.equal( + actorView.properties.location.ref, + "lex:app.certified.actor.getOrganization#locationRecordView", + ); assert.equal( actorView.properties.organization.ref, "lex:org.hypercerts.api.defs#organizationView", @@ -173,7 +177,8 @@ if (hasModule("modules/organization/manifest.json")) ); const organizationQueryFailed = { name: "OrganizationQueryFailed", - description: "The indexed organization sidecar or associated profile could not be queried.", + description: + "The indexed organization sidecar, associated profile or location could not be queried.", }; assert.deepEqual( getOrganization.defs.main.errors.map(({ name }) => name), diff --git a/happyview-modules/tests/unit/tooling/organization.test.js b/happyview-modules/tests/unit/tooling/organization.test.js index f2f3bab..0080a8e 100644 --- a/happyview-modules/tests/unit/tooling/organization.test.js +++ b/happyview-modules/tests/unit/tooling/organization.test.js @@ -8,8 +8,11 @@ import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("../../../", import.meta.url)); const organizationCollection = "app.certified.actor.organization"; const profileCollection = "app.certified.actor.profile"; +const locationCollection = "app.certified.location"; const actor = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; const secondActor = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; +const thirdActor = "did:plc:cccccccccccccccccccccccc"; +const fourthActor = "did:plc:dddddddddddddddddddddddd"; const indexedAt = "2025-01-02T03:04:05.000Z"; function lua(value) { @@ -47,6 +50,29 @@ function profileRow(did, id, record) { }; } +function locationRef(did, rkey, cid = "bafy-pinned") { + return { uri: `at://${did}/${locationCollection}/${rkey}`, cid }; +} + +function locationRow(did, rkey, id, record, cid = `bafy-${id}`) { + return { + uri: `at://${did}/${locationCollection}/${rkey}`, + did, + cid, + indexed_at: indexedAt, + record: id, + record_json: { $type: locationCollection, ...record }, + }; +} + +const pointLocation = { + lpVersion: "1.0.0", + srs: "https://www.opengis.net/def/crs/OGC/1.3/CRS84", + locationType: "geojson-point", + location: { $type: "app.certified.location#string", string: "POINT(89.64 27.47)" }, + createdAt: "2025-01-01T00:00:00Z", +}; + function cursor({ direction = "desc", timestamp = "2025-01-02T03:04:05.000000Z", @@ -180,7 +206,10 @@ test("organization Lexicons declare batch lookup results and separate list outpu assert.equal(getParams.properties.actor.format, "did"); const actorView = getOrganization.defs.organizationActorView; assert.deepEqual(actorView.required, ["did", "profile", "organization"]); - assert.deepEqual(actorView.nullable, ["profile"]); + assert.deepEqual(actorView.nullable, ["profile", "location"]); + const locationView = getOrganization.defs.locationRecordView; + assert.deepEqual(locationView.required, ["uri", "cid", "indexedAt", "did", "record"]); + assert.equal(locationView.properties.record.ref, "app.certified.location"); assert.equal(actorView.properties.organization.ref, "org.hypercerts.api.defs#organizationView"); assert.equal(getOrganization.defs.output.properties.actor.ref, "#organizationActorView"); @@ -616,3 +645,160 @@ test("organization misses and operational database failures remain distinct erro expectedCalls: 2, }); }); + +test("getOrganization returns the indexed location record its reference points to, unchanged", () => { + const organization = organizationRow(actor, "with-location", { + organizationType: ["nonprofit"], + location: locationRef(actor, "3jzfcijpj2z2a", "bafy-location"), + }); + const location = locationRow(actor, "3jzfcijpj2z2a", "location", pointLocation, "bafy-location"); + runLua({ + endpoint: "getOrganization", + params: { actor }, + queryResults: [[organization], [], [location]], + assertions: ` +local primary = result.actor.location +assert(primary.uri == '${location.uri}' and primary.cid == 'bafy-location' and primary.did == '${actor}') +assert(primary.indexedAt == '${indexedAt}') +assert(primary.record['$type'] == '${locationCollection}' and primary.record.locationType == 'geojson-point') +assert(primary.record.location.string == 'POINT(89.64 27.47)') +assert(primary.author == nil, 'the location view carries no hydrated author') +assert(#calls == 3 and calls[3].values[1] == '${locationCollection}' and calls[3].values[2] == '${location.uri}' and #calls[3].values == 2) +assert(calls[3].sql:find('collection = $1 AND uri IN ($2)', 1, true)) +`, + }); +}); + +test("organization views return an explicit null location without querying when the reference cannot name a location record", () => { + const references = { + absent: undefined, + notAnObject: "at://x", + missingUri: { cid: "bafy-pinned" }, + notAtUri: { uri: "https://example.com/location", cid: "bafy-pinned" }, + wrongCollection: { + uri: `at://${actor}/org.hypercerts.claim.activity/3jzfcijpj2z2a`, + cid: "bafy-pinned", + }, + wrongCollectionPrefix: { + uri: `at://${actor}/${locationCollection}.extra/3jzfcijpj2z2a`, + cid: "bafy-pinned", + }, + }; + for (const [name, reference] of Object.entries(references)) { + const organization = organizationRow(actor, `ref-${name}`, { + organizationType: ["nonprofit"], + ...(reference === undefined ? {} : { location: reference }), + }); + runLua({ + endpoint: "getOrganization", + params: { actor }, + queryResults: [[organization], []], + assertions: ` +assert(result.actor.location == NULL, '${name}: location must be explicit null') +assert(result.actor.location ~= nil, '${name}: location must be present') +assert(#calls == 2, '${name}: no location query for a reference that cannot be a location record') +`, + }); + } +}); + +test("location matches by URI and returns the latest indexed version when the pinned cid differs", () => { + const organization = organizationRow(actor, "stale-location", { + location: locationRef(actor, "3jzfcijpj2z2a", "bafy-pinned"), + }); + const newer = locationRow( + actor, + "3jzfcijpj2z2a", + "newer", + { ...pointLocation, name: "Edited name" }, + "bafy-newer", + ); + runLua({ + endpoint: "getOrganization", + params: { actor }, + queryResults: [[organization], [], [newer]], + assertions: ` +assert(result.actor.location.cid == 'bafy-newer') +assert(result.actor.location.record.name == 'Edited name') +assert(result.actor.organization.record.location.cid == 'bafy-pinned') +assert(#calls[3].values == 2 and not calls[3].sql:find('cid =', 1, true), 'lookup must not filter by cid') +`, + }); +}); + +test("getOrganizations hydrates every location with one batched lookup over distinct URIs", () => { + const sharedRkey = "3jzfcijpj2z2a"; + const first = organizationRow(actor, "batch-first", { location: locationRef(actor, sharedRkey) }); + const second = organizationRow(secondActor, "batch-second", { + location: locationRef(actor, sharedRkey), + }); + const third = organizationRow(thirdActor, "batch-third", { + location: locationRef(thirdActor, "3jzfcijpj2z2b"), + }); + const without = organizationRow(fourthActor, "batch-without", {}); + const shared = locationRow(actor, sharedRkey, "shared", pointLocation); + const own = locationRow(thirdActor, "3jzfcijpj2z2b", "own", { ...pointLocation, name: "Own" }); + runLua({ + endpoint: "getOrganizations", + params: { actors: [fourthActor, actor, secondActor, thirdActor, actor] }, + queryResults: [[first, second, third, without], [], [own, shared]], + assertions: ` +local function primary(index) return result.organizations[index].organization.location end +assert(primary(1) == NULL, 'organization without a location reference') +assert(primary(2).uri == '${shared.uri}', 'first organization') +assert(primary(3).uri == '${shared.uri}', 'second organization shares the location') +assert(primary(4).uri == '${own.uri}' and primary(4).record.name == 'Own', 'third organization') +assert(primary(5).uri == '${shared.uri}', 'repeated occurrence') +assert(#calls == 3, 'one organization, one profile and one location query') +assert(#calls[3].values == 3 and calls[3].values[1] == '${locationCollection}', 'distinct location URIs only') +assert(calls[3].sql:find('uri IN ($2, $3)', 1, true)) +`, + }); +}); + +test("listOrganizations and searchOrganizations hydrate location for returned rows only", () => { + const first = organizationRow(actor, "list-first", { + location: locationRef(actor, "3jzfcijpj2z2a"), + }); + const lookahead = organizationRow(secondActor, "list-lookahead", { + location: locationRef(secondActor, "3jzfcijpj2z2b"), + }); + const location = locationRow(actor, "3jzfcijpj2z2a", "list-location", pointLocation); + runLua({ + endpoint: "listOrganizations", + params: { limit: "1" }, + queryResults: [[first, lookahead], [], [location]], + assertions: ` +assert(#result.actors == 1 and result.actors[1].location.uri == '${location.uri}') +assert(#calls == 3 and #calls[3].values == 2, 'the lookahead row must not be hydrated') +assert(calls[3].values[2] == '${location.uri}') +`, + }); + + const withoutLocation = organizationRow(secondActor, "search-without", {}); + runLua({ + endpoint: "searchOrganizations", + params: { search: "forest" }, + queryResults: [[first, withoutLocation], [], [location]], + assertions: ` +assert(#result.actors == 2) +assert(result.actors[1].location.uri == '${location.uri}') +assert(result.actors[2].location == NULL and result.actors[2].location ~= nil) +assert(#calls == 3) +`, + }); +}); + +test("a failed location lookup is reported as an organization query failure, not an empty location", () => { + const organization = organizationRow(actor, "location-error", { + location: locationRef(actor, "3jzfcijpj2z2a"), + }); + runLua({ + endpoint: "getOrganization", + params: { actor }, + queryResults: [[organization], []], + queryFailureAt: 3, + expectError: "OrganizationQueryFailed:", + expectedCalls: 3, + }); +}); diff --git a/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts b/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts index c4fc4d3..1f5d8aa 100644 --- a/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts +++ b/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts @@ -4,6 +4,7 @@ import { l } from "@atproto/lex"; import * as ApiDefs from "../../../org/hypercerts/api/defs.defs.js"; +import * as CertifiedLocation from "@hypercerts-org/lexicon/app/certified/location.defs"; const $nsid = "app.certified.actor.getOrganization"; @@ -20,7 +21,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** Gets an actor's indexed organization sidecar and associated profile by DID. Authentication is not required. */ +/** Gets an actor's indexed organization sidecar, associated profile and location by DID. Authentication is not required. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","RecordNotFound","OrganizationQueryFailed"]); export { main }; @@ -31,13 +32,28 @@ type $lxm = typeof $lxm; export { $lxm }; -/** Actor with an indexed organization sidecar and nullable profile view. */ -type OrganizationActorView = { $type?: "app.certified.actor.getOrganization#organizationActorView";"did":l.DidString;"profile":ApiDefs.ProfileView | null;"organization":ApiDefs.OrganizationView }; +/** Indexed app.certified.location record view preserving the full record exactly as getLocation returns it, but without the hydrated author; indexedAt may be null when the index has no timestamp. */ +type LocationRecordView = { $type?: "app.certified.actor.getOrganization#locationRecordView";"uri":l.AtUriString;"cid":l.CidString;"indexedAt":l.DatetimeString | null;"did":l.DidString;"record":CertifiedLocation.Main }; + +export type { LocationRecordView }; + +/** Indexed app.certified.location record view preserving the full record exactly as getLocation returns it, but without the hydrated author; indexedAt may be null when the index has no timestamp. */ +const locationRecordView = /*#__PURE__*/ l.typedObject($nsid, "locationRecordView", /*#__PURE__*/ l.object({"uri":/*#__PURE__*/ l.string({"format":"at-uri"}),"cid":/*#__PURE__*/ l.string({"format":"cid"}),"indexedAt":/*#__PURE__*/ l.nullable(/*#__PURE__*/ l.string({"format":"datetime"})),"did":/*#__PURE__*/ l.string({"format":"did"}),"record":/*#__PURE__*/ l.ref((() => CertifiedLocation.main) as any)})); + +export { locationRecordView }; + +/** Actor with an indexed organization sidecar, nullable profile view and nullable location view. */ +type OrganizationActorView = { $type?: "app.certified.actor.getOrganization#organizationActorView";"did":l.DidString;"profile":ApiDefs.ProfileView | null;"organization":ApiDefs.OrganizationView; + + /** + * The organization's location record, as stored (blobs are not resolved). Only returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations. Null if there is no location or it is not indexed. Matched by URI, so it may be a newer version than organization.record.location.cid. + */ + "location"?:LocationRecordView | null }; export type { OrganizationActorView }; -/** Actor with an indexed organization sidecar and nullable profile view. */ -const organizationActorView = /*#__PURE__*/ l.typedObject($nsid, "organizationActorView", /*#__PURE__*/ l.object({"did":/*#__PURE__*/ l.string({"format":"did"}),"profile":/*#__PURE__*/ l.nullable(/*#__PURE__*/ l.ref((() => ApiDefs.profileView) as any)),"organization":/*#__PURE__*/ l.ref((() => ApiDefs.organizationView) as any)})); +/** Actor with an indexed organization sidecar, nullable profile view and nullable location view. */ +const organizationActorView = /*#__PURE__*/ l.typedObject($nsid, "organizationActorView", /*#__PURE__*/ l.object({"did":/*#__PURE__*/ l.string({"format":"did"}),"profile":/*#__PURE__*/ l.nullable(/*#__PURE__*/ l.ref((() => ApiDefs.profileView) as any)),"organization":/*#__PURE__*/ l.ref((() => ApiDefs.organizationView) as any),"location":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.nullable(/*#__PURE__*/ l.ref((() => locationRecordView) as any)))})); export { organizationActorView }; diff --git a/packages/api/src/lexicons/app/certified/actor/getOrganizations.defs.ts b/packages/api/src/lexicons/app/certified/actor/getOrganizations.defs.ts index ded1234..44bc910 100644 --- a/packages/api/src/lexicons/app/certified/actor/getOrganizations.defs.ts +++ b/packages/api/src/lexicons/app/certified/actor/getOrganizations.defs.ts @@ -20,7 +20,7 @@ export const $output = /*#__PURE__*/ l.payload("application/json", /*#__PURE__*/ export type $Output = l.InferPayload; export type $OutputBody = l.InferPayloadBody; -/** Returns an indexed organization sidecar and associated profile for each supplied actor DID. Authentication is not required. */ +/** Returns an indexed organization sidecar, associated profile and location for each supplied actor DID. Authentication is not required. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","OrganizationQueryFailed"]); export { main };