From fd80fb908569f0f9bcd7bed4834309ca165b1d85 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 12 Nov 2026 18:11:17 +0600 Subject: [PATCH 1/6] ci: deploy HappyView modules to test on push to main Branch and environment names differ (main -> Test), so a resolve job maps branches to environments explicitly instead of using ref_name. --- .github/workflows/deploy.yml | 36 ++++++++++++++++++++++++++++-------- happyview-modules/README.md | 12 ++++++++++-- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 36500f7..4bdfe82 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,21 +2,41 @@ name: Deploy on: push: - # Each branch deploys to the GitHub environment with the same name. - branches: [staging, production] + branches: [main, staging, production] permissions: contents: read -concurrency: - group: deploy-${{ github.ref_name }} - cancel-in-progress: false - jobs: + # Branch and GitHub environment names differ, so map them explicitly here, + # once; the `environment` and `concurrency` keys cannot read workflow `env`. + resolve: + name: Resolve deploy environment + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.map.outputs.environment }} + steps: + - id: map + env: + BRANCH: ${{ github.ref_name }} + run: | + case "$BRANCH" in + main) environment=Test ;; + staging) environment=Staging ;; + production) environment=Production ;; + *) echo "::error::No deploy environment is mapped to branch '$BRANCH'; add it to deploy.yml." >&2; exit 1 ;; + esac + echo "environment=$environment" >> "$GITHUB_OUTPUT" + deploy: - name: Install HappyView modules (${{ github.ref_name }}) + name: Install HappyView modules (${{ needs.resolve.outputs.environment }}) + needs: resolve runs-on: ubuntu-latest - environment: ${{ github.ref_name }} + environment: ${{ needs.resolve.outputs.environment }} + # Deploys to one environment run one at a time and are never cancelled midway. + concurrency: + group: deploy-${{ needs.resolve.outputs.environment }} + cancel-in-progress: false steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/happyview-modules/README.md b/happyview-modules/README.md index 16d3dd3..a6f1a81 100644 --- a/happyview-modules/README.md +++ b/happyview-modules/README.md @@ -56,10 +56,18 @@ For bundles that register `app.certified.actor.getProfile`, handle lookups requi ## Deploys -Pushing to the `staging` or `production` branch runs [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), which installs this bundle into that environment's HappyView with `pnpm install:api --override`, so assets that differ from this repository are replaced (the repository is the source of truth). Each branch uses the GitHub environment of the same name (`staging` or `production`), and each environment must define its own values; never reuse the production token for staging: +Pushing to `main`, `staging` or `production` runs [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), which installs this bundle into the matching environment's HappyView with `pnpm install:api --override`, so assets that differ from this repository are replaced (the repository is the source of truth). Branches map to GitHub environments explicitly: + +| Branch | GitHub environment | +| ------------ | ------------------ | +| `main` | `Test` | +| `staging` | `Staging` | +| `production` | `Production` | + +Each environment must allow deployments from its branch and define its own values; never reuse a token across environments: - variable `HAPPYVIEW_BASE_URL`: that environment's HappyView origin - secret `HAPPYVIEW_ADMIN_TOKEN`: a scoped admin token for that HappyView, including `script-variables:read` - variable `HYPERCERTS_HANDLE_RESOLVER_URL`: needed only if that HappyView does not already have this setting; the token then also needs `script-variables:create` -Deploys to each environment run one at a time and are never cancelled midway; staging and production deploys do not block each other. +Deploys to each environment run one at a time and are never cancelled midway; deploys to different environments do not block each other. From e863db76a9648453fea3bc60fda3a1f2cbf3c535 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 12 Nov 2026 18:37:20 +0600 Subject: [PATCH 2/6] org: hydrate primaryLocation in organization views getOrganization, getOrganizations, listOrganizations and searchOrganizations now return `primaryLocation` on each organization actor view: the indexed app.certified.location record the sidecar's `location` reference points to, unchanged (blob payloads stay blob references; no URL is resolved). - One batched `uri IN (...)` lookup per request, skipped when no returned organization has a location reference. - Matched by URI, not CID: a newer indexed version is returned as-is. - null when there is no reference, it points at another collection, or the record is not indexed. A failed lookup still fails the request. - The view lives in getOrganization's Lexicon, so the shared defs are unchanged and nested author views do not get the field. TECH-884 --- .../hypercerts-api/references/endpoint-map.md | 2 +- .../hypercerts-api/references/openapi.json | 36 +++- .../hypercerts-api/references/recipes.md | 2 +- .changeset/organization-primary-location.md | 6 + docs/openapi.json | 71 ++++++- .../app.certified.actor.getOrganization.json | 50 ++++- .../app.certified.actor.getOrganizations.json | 4 +- ...app.certified.actor.listOrganizations.json | 2 +- ...p.certified.actor.searchOrganizations.json | 2 +- .../app.certified.actor.getOrganization.json | 28 ++- .../app.certified.actor.getOrganizations.json | 4 +- ...app.certified.actor.listOrganizations.json | 2 +- ...p.certified.actor.searchOrganizations.json | 2 +- .../lua/endpoints/getOrganization.lua | 62 +++++- .../lua/endpoints/getOrganizations.lua | 62 +++++- .../lua/endpoints/listOrganizations.lua | 62 +++++- .../lua/endpoints/searchOrganizations.lua | 62 +++++- happyview-modules/lua/shared/organization.lua | 50 ++++- .../modules/organization/manifest.json | 5 + .../http/actor-organizations.http.test.js | 66 ++++++ .../tests/http/fixtures/actors.fixture.js | 47 +++++ .../tests/unit/tooling/lexicons.test.js | 9 +- .../tests/unit/tooling/organization.test.js | 188 +++++++++++++++++- .../certified/actor/getOrganization.defs.ts | 26 ++- .../certified/actor/getOrganizations.defs.ts | 2 +- 25 files changed, 791 insertions(+), 61 deletions(-) create mode 100644 .changeset/organization-primary-location.md diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 8713fd4..73c948f 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 `primaryLocation` (the raw indexed location record, or null); 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..513a20a 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 primary 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 primary 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 primary 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 primary 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 primary 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 primary 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 primary 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" + }, + "primaryLocation": { + "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..24fcf87 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 `primaryLocation`: the `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 newer indexed version. 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-primary-location.md b/.changeset/organization-primary-location.md new file mode 100644 index 0000000..0737e42 --- /dev/null +++ b/.changeset/organization-primary-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 primary location in `primaryLocation`, 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..4229272 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 primary 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 primary 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 primary 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 primary 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 primary 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 primary 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 primary location view.", "required": [ "did", "profile", @@ -5379,6 +5422,16 @@ }, "organization": { "$ref": "#/components/schemas/org.hypercerts.api.defs.organizationView" + }, + "primaryLocation": { + "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..e3cec7d 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 primary 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 primary 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 primary location view.", "required": [ "did", "profile", "organization" ], "nullable": [ - "profile" + "profile", + "primaryLocation" ], "properties": { "did": { @@ -63,6 +100,11 @@ "organization": { "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" + }, + "primaryLocation": { + "type": "ref", + "ref": "#locationRecordView", + "description": "The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned." } } }, diff --git a/docs/sources/lexicons/app.certified.actor.getOrganizations.json b/docs/sources/lexicons/app.certified.actor.getOrganizations.json index 64b33dc..cd4a607 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 primary 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 primary 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..ad7ceed 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 primary 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..cef6b12 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 primary 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..027cbfb 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 primary 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 primary 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 primary location view.", "required": ["did", "profile", "organization"], - "nullable": ["profile"], + "nullable": ["profile", "primaryLocation"], "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" }, + "primaryLocation": { + "type": "ref", + "ref": "#locationRecordView", + "description": "The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned." + } } }, "output": { diff --git a/happyview-modules/lexicons/app.certified.actor.getOrganizations.json b/happyview-modules/lexicons/app.certified.actor.getOrganizations.json index d33c0d9..95adbe6 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 primary 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 primary 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..a72c23a 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 primary 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..593c6a0 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 primary location could not be queried." } ] }, "output": { diff --git a/happyview-modules/lua/endpoints/getOrganization.lua b/happyview-modules/lua/endpoints/getOrganization.lua index 6a3c467..daeaf2b 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), + primaryLocation = 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 primary_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_primary_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = primary_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 = primary_location_uri(actor.organization) + if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_primary_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..1cefc62 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), + primaryLocation = 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 primary_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_primary_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = primary_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 = primary_location_uri(actor.organization) + if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_primary_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..8f19eaa 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), + primaryLocation = 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 primary_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_primary_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = primary_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 = primary_location_uri(actor.organization) + if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_primary_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..2ed1f8b 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), + primaryLocation = 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 primary_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_primary_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = primary_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 = primary_location_uri(actor.organization) + if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_primary_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..3017eb5 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), + primaryLocation = 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 primary_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_primary_locations(actors) + local uris, seen = {}, {} + for _, actor in ipairs(actors) do + local uri = primary_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 = primary_location_uri(actor.organization) + if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + end +end + +local function hydrate_organization_actors(actors) + if #actors == 0 then return end + hydrate_profiles(actors) + hydrate_primary_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..7271374 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 primaryLocation must be; null for every reference that cannot be +// resolved to an indexed app.certified.location record. +function expectedPrimaryLocation(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), + primaryLocation: 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), + primaryLocation: null, }); }); @@ -109,6 +126,7 @@ test("getOrganizations preserves repeated DID occurrences and serializes a missi did: profilelessOrganization.did, profile: null, organization: recordView(profilelessOrganization), + primaryLocation: 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 primary 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.primaryLocation, expectedPrimaryLocation(did), did); + assert.equal(Object.hasOwn(actor, "primaryLocation"), true, `${did}: key must be present`); + } +}); + +test("getOrganizations, listOrganizations and searchOrganizations return the same primaryLocation 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.primaryLocation), + requested.map(expectedPrimaryLocation), + ); + + 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).primaryLocation, expectedPrimaryLocation(did), did); + } + + const searched = await get("app.certified.actor.searchOrganizations", { + search: "Alpine Forest", + actors: [actorFixtureDids.alpine], + }); + assert.deepEqual( + searched.actors.map(({ primaryLocation }) => primaryLocation), + [recordView(pointLocation)], + ); +}); diff --git a/happyview-modules/tests/http/fixtures/actors.fixture.js b/happyview-modules/tests/http/fixtures/actors.fixture.js index ae154f0..a1bfa63 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"); + +// Primary-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 primaryLocationRefs = { + 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: primaryLocationRefs.alpine, visibility: "public", createdAt, }, @@ -71,6 +96,7 @@ const records = [ rkey: "self", record: { organizationType: ["community"], + location: primaryLocationRefs.forest, visibility: "unlisted", createdAt, }, @@ -81,6 +107,7 @@ const records = [ rkey: "self", record: { organizationType: ["nonprofit"], + location: primaryLocationRefs.river, visibility: "public", createdAt, }, @@ -94,6 +121,26 @@ const records = [ createdAt, }, }, + { + did: actorFixtureDids.unindexedLocation, + collection: organizationCollection, + rkey: "self", + record: { + organizationType: ["location-fixture"], + location: primaryLocationRefs.unindexed, + createdAt, + }, + }, + { + did: actorFixtureDids.wrongCollectionLocation, + collection: organizationCollection, + rkey: "self", + record: { + organizationType: ["location-fixture"], + location: primaryLocationRefs.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..7b21495 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", "primaryLocation"]); assert.equal(actorView.properties.profile.ref, "lex:org.hypercerts.api.defs#profileView"); + assert.equal( + actorView.properties.primaryLocation.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 primary 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..4e741ca 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", "primaryLocation"]); + 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.primaryLocation +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 primaryLocation 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.primaryLocation == NULL, '${name}: primaryLocation must be explicit null') +assert(result.actor.primaryLocation ~= nil, '${name}: primaryLocation must be present') +assert(#calls == 2, '${name}: no location query for a reference that cannot be a location record') +`, + }); + } +}); + +test("primaryLocation 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.primaryLocation.cid == 'bafy-newer') +assert(result.actor.primaryLocation.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 primaryLocation 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.primaryLocation 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 primaryLocation 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].primaryLocation.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].primaryLocation.uri == '${location.uri}') +assert(result.actors[2].primaryLocation == NULL and result.actors[2].primaryLocation ~= nil) +assert(#calls == 3) +`, + }); +}); + +test("a failed primaryLocation 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..82f1eb9 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 primary 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 primary location view. */ +type OrganizationActorView = { $type?: "app.certified.actor.getOrganization#organizationActorView";"did":l.DidString;"profile":ApiDefs.ProfileView | null;"organization":ApiDefs.OrganizationView; + + /** + * The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned. + */ + "primaryLocation"?: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 primary 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),"primaryLocation":/*#__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..22e4243 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 primary location for each supplied actor DID. Authentication is not required. */ const main = /*#__PURE__*/ l.query($nsid, $params, $output, ["InvalidRequest","OrganizationQueryFailed"]); export { main }; From 102293a9dcf86026e38f71356951bada7bd7bf76 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Nov 2026 11:54:59 +0600 Subject: [PATCH 3/6] org: rename primaryLocation to location The hydrated field now matches the lexicon field it resolves: organization.record.location stays the strong reference; actor.location is the resolved record view. --- .../hypercerts-api/references/endpoint-map.md | 2 +- .../hypercerts-api/references/openapi.json | 16 +++++----- .../hypercerts-api/references/recipes.md | 2 +- .changeset/organization-location.md | 6 ++++ .changeset/organization-primary-location.md | 6 ---- docs/openapi.json | 16 +++++----- .../app.certified.actor.getOrganization.json | 10 +++---- .../app.certified.actor.getOrganizations.json | 4 +-- ...app.certified.actor.listOrganizations.json | 2 +- ...p.certified.actor.searchOrganizations.json | 2 +- .../app.certified.actor.getOrganization.json | 10 +++---- .../app.certified.actor.getOrganizations.json | 4 +-- ...app.certified.actor.listOrganizations.json | 2 +- ...p.certified.actor.searchOrganizations.json | 2 +- .../lua/endpoints/getOrganization.lua | 14 ++++----- .../lua/endpoints/getOrganizations.lua | 14 ++++----- .../lua/endpoints/listOrganizations.lua | 14 ++++----- .../lua/endpoints/searchOrganizations.lua | 14 ++++----- happyview-modules/lua/shared/organization.lua | 14 ++++----- .../http/actor-organizations.http.test.js | 26 ++++++++-------- .../tests/http/fixtures/actors.fixture.js | 14 ++++----- .../tests/unit/tooling/lexicons.test.js | 6 ++-- .../tests/unit/tooling/organization.test.js | 30 +++++++++---------- .../certified/actor/getOrganization.defs.ts | 10 +++---- .../certified/actor/getOrganizations.defs.ts | 2 +- 25 files changed, 121 insertions(+), 121 deletions(-) create mode 100644 .changeset/organization-location.md delete mode 100644 .changeset/organization-primary-location.md diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 73c948f..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. Organization actor views from these four endpoints include `primaryLocation` (the raw indexed location record, or null); use it instead of a second `getLocation` call. Organization views nested in other endpoints' author or actor views do not include it. +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 513a20a..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, associated profile and primary location 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, associated profile or primary location 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, associated profile and primary location 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, associated profiles or primary locations 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, associated profile or primary location 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, associated profile or primary location could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -4213,7 +4213,7 @@ }, "app.certified.actor.getOrganization.organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar, nullable profile view and nullable primary location 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" }, @@ -4226,7 +4226,7 @@ "organization": { "$ref": "#/components/schemas/org.hypercerts.api.defs.organizationView" }, - "primaryLocation": { + "location": { "anyOf": [ { "$ref": "#/components/schemas/app.certified.actor.getOrganization.locationRecordView" diff --git a/.agents/skills/hypercerts-api/references/recipes.md b/.agents/skills/hypercerts-api/references/recipes.md index 24fcf87..abf8672 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. Each organization actor view (`getOrganization`, `getOrganizations`, `listOrganizations`, `searchOrganizations`) also carries `primaryLocation`: the `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 newer indexed version. No second `getLocation` call is needed. +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 newer indexed version. 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..b5b3d72 --- /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 `organization.location`, which stays a strong reference), 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/.changeset/organization-primary-location.md b/.changeset/organization-primary-location.md deleted file mode 100644 index 0737e42..0000000 --- a/.changeset/organization-primary-location.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@hypercerts-org/happyview-modules': minor -'@hypercerts-org/api': minor ---- - -Organization queries (`getOrganization`, `getOrganizations`, `listOrganizations`, `searchOrganizations`) now return each organization's primary location in `primaryLocation`, 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 4229272..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, associated profile and primary location 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, associated profile or primary location 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, associated profile and primary location 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, associated profiles or primary locations 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, associated profile or primary location 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, associated profile or primary location could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] } @@ -5399,7 +5399,7 @@ }, "app.certified.actor.getOrganization.organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar, nullable profile view and nullable primary location view.", + "description": "Actor with an indexed organization sidecar, nullable profile view and nullable location view.", "required": [ "did", "profile", @@ -5423,7 +5423,7 @@ "organization": { "$ref": "#/components/schemas/org.hypercerts.api.defs.organizationView" }, - "primaryLocation": { + "location": { "anyOf": [ { "$ref": "#/components/schemas/app.certified.actor.getOrganization.locationRecordView" diff --git a/docs/sources/lexicons/app.certified.actor.getOrganization.json b/docs/sources/lexicons/app.certified.actor.getOrganization.json index e3cec7d..4d9b08e 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, associated profile and primary location 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,7 +36,7 @@ }, { "name": "OrganizationQueryFailed", - "description": "The indexed organization sidecar, associated profile or primary location could not be queried." + "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, @@ -78,7 +78,7 @@ }, "organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar, nullable profile view and nullable primary location view.", + "description": "Actor with an indexed organization sidecar, nullable profile view and nullable location view.", "required": [ "did", "profile", @@ -86,7 +86,7 @@ ], "nullable": [ "profile", - "primaryLocation" + "location" ], "properties": { "did": { @@ -101,7 +101,7 @@ "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" }, - "primaryLocation": { + "location": { "type": "ref", "ref": "#locationRecordView", "description": "The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned." diff --git a/docs/sources/lexicons/app.certified.actor.getOrganizations.json b/docs/sources/lexicons/app.certified.actor.getOrganizations.json index cd4a607..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, associated profile and primary location 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, associated profiles or primary locations 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 ad7ceed..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, associated profile or primary location 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 cef6b12..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, associated profile or primary location 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 027cbfb..db2b618 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, associated profile and primary location 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,7 +23,7 @@ "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, associated profile or primary location could not be queried." } + { "name": "OrganizationQueryFailed", "description": "The indexed organization sidecar, associated profile or location could not be queried." } ] }, "locationRecordView": { @@ -41,14 +41,14 @@ }, "organizationActorView": { "type": "object", - "description": "Actor with an indexed organization sidecar, nullable profile view and nullable primary location view.", + "description": "Actor with an indexed organization sidecar, nullable profile view and nullable location view.", "required": ["did", "profile", "organization"], - "nullable": ["profile", "primaryLocation"], + "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" }, - "primaryLocation": { + "location": { "type": "ref", "ref": "#locationRecordView", "description": "The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned." diff --git a/happyview-modules/lexicons/app.certified.actor.getOrganizations.json b/happyview-modules/lexicons/app.certified.actor.getOrganizations.json index 95adbe6..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, associated profile and primary location 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, associated profiles or primary locations 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 a72c23a..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, associated profile or primary location 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 593c6a0..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, associated profile or primary location 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 daeaf2b..7cdcc6f 100644 --- a/happyview-modules/lua/endpoints/getOrganization.lua +++ b/happyview-modules/lua/endpoints/getOrganization.lua @@ -69,7 +69,7 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), - primaryLocation = NULL, + location = NULL, } end @@ -100,7 +100,7 @@ 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 primary_location_uri(organization) +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) @@ -108,10 +108,10 @@ local function primary_location_uri(organization) return nil end -local function hydrate_primary_locations(actors) +local function hydrate_locations(actors) local uris, seen = {}, {} for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) + local uri = location_uri(actor.organization) if uri and not seen[uri] then seen[uri] = true uris[#uris + 1] = uri @@ -131,15 +131,15 @@ local function hydrate_primary_locations(actors) local locations = {} for _, row in ipairs(rows) do locations[row.uri] = row end for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) - if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + 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_primary_locations(actors) + hydrate_locations(actors) end function handle() diff --git a/happyview-modules/lua/endpoints/getOrganizations.lua b/happyview-modules/lua/endpoints/getOrganizations.lua index 1cefc62..c2e0502 100644 --- a/happyview-modules/lua/endpoints/getOrganizations.lua +++ b/happyview-modules/lua/endpoints/getOrganizations.lua @@ -69,7 +69,7 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), - primaryLocation = NULL, + location = NULL, } end @@ -100,7 +100,7 @@ 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 primary_location_uri(organization) +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) @@ -108,10 +108,10 @@ local function primary_location_uri(organization) return nil end -local function hydrate_primary_locations(actors) +local function hydrate_locations(actors) local uris, seen = {}, {} for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) + local uri = location_uri(actor.organization) if uri and not seen[uri] then seen[uri] = true uris[#uris + 1] = uri @@ -131,15 +131,15 @@ local function hydrate_primary_locations(actors) local locations = {} for _, row in ipairs(rows) do locations[row.uri] = row end for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) - if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + 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_primary_locations(actors) + hydrate_locations(actors) end local function requested_organization_actors(value) diff --git a/happyview-modules/lua/endpoints/listOrganizations.lua b/happyview-modules/lua/endpoints/listOrganizations.lua index 8f19eaa..c7127db 100644 --- a/happyview-modules/lua/endpoints/listOrganizations.lua +++ b/happyview-modules/lua/endpoints/listOrganizations.lua @@ -111,7 +111,7 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), - primaryLocation = NULL, + location = NULL, } end @@ -142,7 +142,7 @@ 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 primary_location_uri(organization) +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) @@ -150,10 +150,10 @@ local function primary_location_uri(organization) return nil end -local function hydrate_primary_locations(actors) +local function hydrate_locations(actors) local uris, seen = {}, {} for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) + local uri = location_uri(actor.organization) if uri and not seen[uri] then seen[uri] = true uris[#uris + 1] = uri @@ -173,15 +173,15 @@ local function hydrate_primary_locations(actors) local locations = {} for _, row in ipairs(rows) do locations[row.uri] = row end for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) - if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + 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_primary_locations(actors) + hydrate_locations(actors) end local CREATED_AT_SORT_COLUMNS = { diff --git a/happyview-modules/lua/endpoints/searchOrganizations.lua b/happyview-modules/lua/endpoints/searchOrganizations.lua index 2ed1f8b..c3973c5 100644 --- a/happyview-modules/lua/endpoints/searchOrganizations.lua +++ b/happyview-modules/lua/endpoints/searchOrganizations.lua @@ -111,7 +111,7 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), - primaryLocation = NULL, + location = NULL, } end @@ -142,7 +142,7 @@ 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 primary_location_uri(organization) +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) @@ -150,10 +150,10 @@ local function primary_location_uri(organization) return nil end -local function hydrate_primary_locations(actors) +local function hydrate_locations(actors) local uris, seen = {}, {} for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) + local uri = location_uri(actor.organization) if uri and not seen[uri] then seen[uri] = true uris[#uris + 1] = uri @@ -173,15 +173,15 @@ local function hydrate_primary_locations(actors) local locations = {} for _, row in ipairs(rows) do locations[row.uri] = row end for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) - if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + 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_primary_locations(actors) + hydrate_locations(actors) end local CREATED_AT_SORT_COLUMNS = { diff --git a/happyview-modules/lua/shared/organization.lua b/happyview-modules/lua/shared/organization.lua index 3017eb5..667dbe3 100644 --- a/happyview-modules/lua/shared/organization.lua +++ b/happyview-modules/lua/shared/organization.lua @@ -16,7 +16,7 @@ local function organization_actor_view(row) did = row.did, profile = NULL, organization = record_view(row), - primaryLocation = NULL, + location = NULL, } end @@ -47,7 +47,7 @@ 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 primary_location_uri(organization) +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) @@ -55,10 +55,10 @@ local function primary_location_uri(organization) return nil end -local function hydrate_primary_locations(actors) +local function hydrate_locations(actors) local uris, seen = {}, {} for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) + local uri = location_uri(actor.organization) if uri and not seen[uri] then seen[uri] = true uris[#uris + 1] = uri @@ -78,13 +78,13 @@ local function hydrate_primary_locations(actors) local locations = {} for _, row in ipairs(rows) do locations[row.uri] = row end for _, actor in ipairs(actors) do - local uri = primary_location_uri(actor.organization) - if uri and locations[uri] then actor.primaryLocation = record_view(locations[uri]) end + 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_primary_locations(actors) + hydrate_locations(actors) end diff --git a/happyview-modules/tests/http/actor-organizations.http.test.js b/happyview-modules/tests/http/actor-organizations.http.test.js index 7271374..4e84a85 100644 --- a/happyview-modules/tests/http/actor-organizations.http.test.js +++ b/happyview-modules/tests/http/actor-organizations.http.test.js @@ -30,9 +30,9 @@ function recordView(record) { }; } -// What each fixture organization's primaryLocation must be; null for every reference that cannot be +// What each fixture organization's location must be; null for every reference that cannot be // resolved to an indexed app.certified.location record. -function expectedPrimaryLocation(did) { +function expectedLocation(did) { const expected = { [actorFixtureDids.alpine]: recordView(pointLocation), [actorFixtureDids.forest]: recordView(blobLocation), @@ -66,7 +66,7 @@ test("getOrganization returns the original sidecar and explicit nullable profile did: publicOrganization.did, profile: recordView(profileRecords[0]), organization: recordView(publicOrganization), - primaryLocation: recordView(pointLocation), + location: recordView(pointLocation), }); const withoutProfile = await get("app.certified.actor.getOrganization", { @@ -76,7 +76,7 @@ test("getOrganization returns the original sidecar and explicit nullable profile did: profilelessOrganization.did, profile: null, organization: recordView(profilelessOrganization), - primaryLocation: null, + location: null, }); }); @@ -126,7 +126,7 @@ test("getOrganizations preserves repeated DID occurrences and serializes a missi did: profilelessOrganization.did, profile: null, organization: recordView(profilelessOrganization), - primaryLocation: null, + location: null, }); assert.deepEqual(result.organizations[0], result.organizations[3]); assert.deepEqual(result.organizations[2].organization.profile, recordView(profileRecords[0])); @@ -235,7 +235,7 @@ test("listOrganizations paginates stably in both directions without skipping equ assert.deepEqual(itemsByDirection.asc, [...itemsByDirection.desc].reverse()); }); -test("getOrganization returns the raw primary location record, or null when the reference cannot be resolved", async () => { +test("getOrganization returns the raw location record, or null when the reference cannot be resolved", async () => { const dids = [ actorFixtureDids.alpine, actorFixtureDids.forest, @@ -246,12 +246,12 @@ test("getOrganization returns the raw primary location record, or null when the ]; for (const did of dids) { const { actor } = await get("app.certified.actor.getOrganization", { actor: did }); - assert.deepEqual(actor.primaryLocation, expectedPrimaryLocation(did), did); - assert.equal(Object.hasOwn(actor, "primaryLocation"), true, `${did}: key must be present`); + 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 primaryLocation per organization", async () => { +test("getOrganizations, listOrganizations and searchOrganizations return the same location per organization", async () => { const fixtureDids = [ actorFixtureDids.alpine, actorFixtureDids.forest, @@ -263,14 +263,14 @@ test("getOrganizations, listOrganizations and searchOrganizations return the sam const requested = [...fixtureDids, actorFixtureDids.alpine]; const batch = await get("app.certified.actor.getOrganizations", { actors: requested }); assert.deepEqual( - batch.organizations.map(({ organization }) => organization.primaryLocation), - requested.map(expectedPrimaryLocation), + 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).primaryLocation, expectedPrimaryLocation(did), did); + assert.deepEqual(listedByDid.get(did).location, expectedLocation(did), did); } const searched = await get("app.certified.actor.searchOrganizations", { @@ -278,7 +278,7 @@ test("getOrganizations, listOrganizations and searchOrganizations return the sam actors: [actorFixtureDids.alpine], }); assert.deepEqual( - searched.actors.map(({ primaryLocation }) => primaryLocation), + 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 a1bfa63..c0fbcd9 100644 --- a/happyview-modules/tests/http/fixtures/actors.fixture.js +++ b/happyview-modules/tests/http/fixtures/actors.fixture.js @@ -21,10 +21,10 @@ const locationCollection = "app.certified.location"; const pointLocation = locationRecords.find(({ rkey }) => rkey === "3jzfcijpj2z2a"); const blobLocation = locationRecords.find(({ rkey }) => rkey === "3jzfcijpj2z2c"); -// Primary-location scenarios: alpine pins the current point location, forest a blob-payload +// 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 primaryLocationRefs = { +const locationRefs = { alpine: { uri: pointLocation.uri, cid: pointLocation.cid }, forest: { uri: blobLocation.uri, cid: blobLocation.cid }, river: { uri: pointLocation.uri, cid: blobLocation.cid }, @@ -85,7 +85,7 @@ const records = [ rkey: "self", record: { organizationType: ["nonprofit", "cooperative"], - location: primaryLocationRefs.alpine, + location: locationRefs.alpine, visibility: "public", createdAt, }, @@ -96,7 +96,7 @@ const records = [ rkey: "self", record: { organizationType: ["community"], - location: primaryLocationRefs.forest, + location: locationRefs.forest, visibility: "unlisted", createdAt, }, @@ -107,7 +107,7 @@ const records = [ rkey: "self", record: { organizationType: ["nonprofit"], - location: primaryLocationRefs.river, + location: locationRefs.river, visibility: "public", createdAt, }, @@ -127,7 +127,7 @@ const records = [ rkey: "self", record: { organizationType: ["location-fixture"], - location: primaryLocationRefs.unindexed, + location: locationRefs.unindexed, createdAt, }, }, @@ -137,7 +137,7 @@ const records = [ rkey: "self", record: { organizationType: ["location-fixture"], - location: primaryLocationRefs.wrongCollection, + location: locationRefs.wrongCollection, createdAt, }, }, diff --git a/happyview-modules/tests/unit/tooling/lexicons.test.js b/happyview-modules/tests/unit/tooling/lexicons.test.js index 7b21495..10c4278 100644 --- a/happyview-modules/tests/unit/tooling/lexicons.test.js +++ b/happyview-modules/tests/unit/tooling/lexicons.test.js @@ -161,10 +161,10 @@ if (hasModule("modules/organization/manifest.json")) "app.certified.actor.getOrganization#organizationActorView", ); assert.deepEqual(actorView.required, ["did", "profile", "organization"]); - assert.deepEqual(actorView.nullable, ["profile", "primaryLocation"]); + assert.deepEqual(actorView.nullable, ["profile", "location"]); assert.equal(actorView.properties.profile.ref, "lex:org.hypercerts.api.defs#profileView"); assert.equal( - actorView.properties.primaryLocation.ref, + actorView.properties.location.ref, "lex:app.certified.actor.getOrganization#locationRecordView", ); assert.equal( @@ -178,7 +178,7 @@ if (hasModule("modules/organization/manifest.json")) const organizationQueryFailed = { name: "OrganizationQueryFailed", description: - "The indexed organization sidecar, associated profile or primary location could not be queried.", + "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 4e741ca..0080a8e 100644 --- a/happyview-modules/tests/unit/tooling/organization.test.js +++ b/happyview-modules/tests/unit/tooling/organization.test.js @@ -206,7 +206,7 @@ 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", "primaryLocation"]); + 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"); @@ -657,7 +657,7 @@ test("getOrganization returns the indexed location record its reference points t params: { actor }, queryResults: [[organization], [], [location]], assertions: ` -local primary = result.actor.primaryLocation +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') @@ -669,7 +669,7 @@ assert(calls[3].sql:find('collection = $1 AND uri IN ($2)', 1, true)) }); }); -test("organization views return an explicit null primaryLocation without querying when the reference cannot name a location record", () => { +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", @@ -694,15 +694,15 @@ test("organization views return an explicit null primaryLocation without queryin params: { actor }, queryResults: [[organization], []], assertions: ` -assert(result.actor.primaryLocation == NULL, '${name}: primaryLocation must be explicit null') -assert(result.actor.primaryLocation ~= nil, '${name}: primaryLocation must be present') +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("primaryLocation matches by URI and returns the latest indexed version when the pinned cid differs", () => { +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"), }); @@ -718,15 +718,15 @@ test("primaryLocation matches by URI and returns the latest indexed version when params: { actor }, queryResults: [[organization], [], [newer]], assertions: ` -assert(result.actor.primaryLocation.cid == 'bafy-newer') -assert(result.actor.primaryLocation.record.name == 'Edited name') +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 primaryLocation with one batched lookup over distinct URIs", () => { +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", { @@ -743,7 +743,7 @@ test("getOrganizations hydrates every primaryLocation with one batched lookup ov params: { actors: [fourthActor, actor, secondActor, thirdActor, actor] }, queryResults: [[first, second, third, without], [], [own, shared]], assertions: ` -local function primary(index) return result.organizations[index].organization.primaryLocation end +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') @@ -756,7 +756,7 @@ assert(calls[3].sql:find('uri IN ($2, $3)', 1, true)) }); }); -test("listOrganizations and searchOrganizations hydrate primaryLocation for returned rows only", () => { +test("listOrganizations and searchOrganizations hydrate location for returned rows only", () => { const first = organizationRow(actor, "list-first", { location: locationRef(actor, "3jzfcijpj2z2a"), }); @@ -769,7 +769,7 @@ test("listOrganizations and searchOrganizations hydrate primaryLocation for retu params: { limit: "1" }, queryResults: [[first, lookahead], [], [location]], assertions: ` -assert(#result.actors == 1 and result.actors[1].primaryLocation.uri == '${location.uri}') +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}') `, @@ -782,14 +782,14 @@ assert(calls[3].values[2] == '${location.uri}') queryResults: [[first, withoutLocation], [], [location]], assertions: ` assert(#result.actors == 2) -assert(result.actors[1].primaryLocation.uri == '${location.uri}') -assert(result.actors[2].primaryLocation == NULL and result.actors[2].primaryLocation ~= nil) +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 primaryLocation lookup is reported as an organization query failure, not an empty location", () => { +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"), }); 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 82f1eb9..ed78531 100644 --- a/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts +++ b/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts @@ -21,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, associated profile and primary location 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 }; @@ -42,18 +42,18 @@ const locationRecordView = /*#__PURE__*/ l.typedObject($nsid export { locationRecordView }; -/** Actor with an indexed organization sidecar, nullable profile view and nullable primary location view. */ +/** 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 location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned. */ - "primaryLocation"?:LocationRecordView | null }; + "location"?:LocationRecordView | null }; export type { OrganizationActorView }; -/** Actor with an indexed organization sidecar, nullable profile view and nullable primary 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),"primaryLocation":/*#__PURE__*/ l.optional(/*#__PURE__*/ l.nullable(/*#__PURE__*/ l.ref((() => locationRecordView) 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 22e4243..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, associated profile and primary location 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 }; From 27b4ce3314a3854b9a8ed04a7996912ac6559e9e Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Nov 2026 12:03:21 +0600 Subject: [PATCH 4/6] skill: do not imply version order from organization location CID --- .agents/skills/hypercerts-api/references/recipes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/hypercerts-api/references/recipes.md b/.agents/skills/hypercerts-api/references/recipes.md index abf8672..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. 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 newer indexed version. No second `getLocation` call is needed. +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. From e0478bdb02acccc540822b9a0e6d2d9c11242c69 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Nov 2026 12:03:22 +0600 Subject: [PATCH 5/6] changeset: correct organization location strong-reference path --- .changeset/organization-location.md | 2 +- docs/sources/lexicons/app.certified.actor.getOrganization.json | 2 +- .../lexicons/app.certified.actor.getOrganization.json | 2 +- .../src/lexicons/app/certified/actor/getOrganization.defs.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/organization-location.md b/.changeset/organization-location.md index b5b3d72..d98885b 100644 --- a/.changeset/organization-location.md +++ b/.changeset/organization-location.md @@ -3,4 +3,4 @@ '@hypercerts-org/api': minor --- -Organization queries (`getOrganization`, `getOrganizations`, `listOrganizations`, `searchOrganizations`) now return each organization's location record in `location` (the resolved view of `organization.location`, which stays a strong reference), 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. +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/sources/lexicons/app.certified.actor.getOrganization.json b/docs/sources/lexicons/app.certified.actor.getOrganization.json index 4d9b08e..ebb89d1 100644 --- a/docs/sources/lexicons/app.certified.actor.getOrganization.json +++ b/docs/sources/lexicons/app.certified.actor.getOrganization.json @@ -104,7 +104,7 @@ "location": { "type": "ref", "ref": "#locationRecordView", - "description": "The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned." + "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/happyview-modules/lexicons/app.certified.actor.getOrganization.json b/happyview-modules/lexicons/app.certified.actor.getOrganization.json index db2b618..f3a8e26 100644 --- a/happyview-modules/lexicons/app.certified.actor.getOrganization.json +++ b/happyview-modules/lexicons/app.certified.actor.getOrganization.json @@ -51,7 +51,7 @@ "location": { "type": "ref", "ref": "#locationRecordView", - "description": "The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned." + "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/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts b/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts index ed78531..1f5d8aa 100644 --- a/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts +++ b/packages/api/src/lexicons/app/certified/actor/getOrganization.defs.ts @@ -46,7 +46,7 @@ export { locationRecordView }; type OrganizationActorView = { $type?: "app.certified.actor.getOrganization#organizationActorView";"did":l.DidString;"profile":ApiDefs.ProfileView | null;"organization":ApiDefs.OrganizationView; /** - * The location record that the organization sidecar's location reference points to, with the record unchanged: a blob payload stays a blob reference and no URL is resolved. Returned by getOrganization, getOrganizations, listOrganizations and searchOrganizations; organization views nested in other endpoints' author or actor views do not include it. Null when the sidecar has no location reference, the reference does not point to an app.certified.location record, or that record is not indexed. The record is matched by AT-URI, not CID: if the indexed cid differs from organization.record.location.cid, the latest indexed record is returned. + * 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 }; From b8a8206dcafd5a909f1bbc7e9894bf887cd00d8f Mon Sep 17 00:00:00 2001 From: "hypercerts-release-bot[bot]" <253337036+hypercerts-release-bot[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:32:48 +0000 Subject: [PATCH 6/6] release: version packages --- .changeset/organization-location.md | 6 ------ happyview-modules/CHANGELOG.md | 6 ++++++ happyview-modules/package.json | 2 +- packages/api/CHANGELOG.md | 6 ++++++ packages/api/package.json | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) delete mode 100644 .changeset/organization-location.md diff --git a/.changeset/organization-location.md b/.changeset/organization-location.md deleted file mode 100644 index d98885b..0000000 --- a/.changeset/organization-location.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@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/happyview-modules/CHANGELOG.md b/happyview-modules/CHANGELOG.md index acd2952..eaf9969 100644 --- a/happyview-modules/CHANGELOG.md +++ b/happyview-modules/CHANGELOG.md @@ -1,5 +1,11 @@ # @hypercerts-org/happyview-modules +## 0.5.0 + +### Minor Changes + +- [#63](https://github.com/hypercerts-org/api/pull/63) [`102293a`](https://github.com/hypercerts-org/api/commit/102293a9dcf86026e38f71356951bada7bd7bf76) Thanks [@Kzoeps](https://github.com/Kzoeps)! - 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. + ## 0.4.0 ### Minor Changes diff --git a/happyview-modules/package.json b/happyview-modules/package.json index cce663c..6edcd1a 100644 --- a/happyview-modules/package.json +++ b/happyview-modules/package.json @@ -1,6 +1,6 @@ { "name": "@hypercerts-org/happyview-modules", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT OR Apache-2.0", "private": true, "type": "module", diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index ddbf0ba..5209b69 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,11 @@ # @hypercerts-org/api +## 0.3.0 + +### Minor Changes + +- [#63](https://github.com/hypercerts-org/api/pull/63) [`102293a`](https://github.com/hypercerts-org/api/commit/102293a9dcf86026e38f71356951bada7bd7bf76) Thanks [@Kzoeps](https://github.com/Kzoeps)! - 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. + ## 0.2.0 ### Minor Changes diff --git a/packages/api/package.json b/packages/api/package.json index 7cda7e2..8f598ae 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@hypercerts-org/api", - "version": "0.2.0", + "version": "0.3.0", "description": "TypeScript query definitions and types for the Hypercerts API, for use with @atproto/lex", "license": "MIT OR Apache-2.0", "repository": {