Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions hypercerts-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,39 @@

This directory contains the tooling for building and testing a Hypercerts XRPC API on HappyView. It provides a reusable installer for HappyView admin assets (Lexicons and Lua scripts), pinned Hypercerts Lexicon dependencies, and test fixtures for checking record behavior. The installer lets API modules declare their assets together, install missing assets in dependency order, and refuse to overwrite assets that differ from what is already installed.

**Current status:** This branch contains the installer and offline test tooling, not an installable API. There is no production `manifest.json` or Lua handler bundle here yet. You can run the unit tests, but do not run `tooling/installer.js` against a HappyView target from this branch.
**Current status:** The root `manifest.json` includes the location API bundle. Build its Lua handlers and run the offline checks before installing it on an approved HappyView target.

## Get started

From `hypercerts-api/`:

```sh
pnpm install --frozen-lockfile
pnpm build:lua
pnpm check
```

`pnpm check` runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests. Run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. These checks do not require a running HappyView instance or database. The installable API bundle and its endpoint contract tests must be supplied separately before you can install or exercise API endpoints.
These checks run offline; they do not require a running HappyView instance or database. `build:lua` builds the standalone location handlers in `lua/endpoints/`. `pnpm check` runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests; run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. The bundle combines local API Lexicons and scripts with schemas from the pinned `@hypercerts-org/lexicon` package. HTTP contract tests require an installed bundle and a separately approved disposable target.

ESLint is installed with the package dependencies. It uses ESLint's recommended checks plus strict equality, no implicit coercion, no shadowed names, no reassigned parameters, no `var`, and `const` where possible. Console output is allowed only in CLI tooling. When a branch contains Lua scripts, Luacheck checks the generated handlers in `lua/endpoints/`; it rejects unknown globals and unused arguments, allowing only HappyView's `db`, `json`, `params`, `toarray`, and `handle`. It targets Lua 5.4 and skips line-length checks for long SQL expressions. Install Lua 5.4 and LuaRocks, then run `luarocks --lua-version=5.4 --local install luacheck 1.2.0` to enable that check locally. The lint runner also finds the default `~/.luarocks/bin` install if it is not on `PATH`. On this foundation-only branch, which has no Lua files, the Lua check reports that it is skipped. If Lua sources exist but generated handlers are missing, run `pnpm build:lua` first.
ESLint is installed with the package dependencies. It uses ESLint's recommended checks plus strict equality, no implicit coercion, no shadowed names, no reassigned parameters, no `var`, and `const` where possible. Console output is allowed only in CLI tooling. When a branch contains Lua scripts, Luacheck checks the generated handlers in `lua/endpoints/`; it rejects unknown globals and unused arguments, allowing only HappyView's `db`, `json`, `params`, `toarray`, and `handle`. It targets Lua 5.4 and skips line-length checks for long SQL expressions. Install Lua 5.4 and LuaRocks, then run `luarocks --lua-version=5.4 --local install luacheck 1.2.0` to enable that check locally. The lint runner also finds the default `~/.luarocks/bin` install if it is not on `PATH`. Branches without Lua files skip Luacheck. If Lua sources exist but generated handlers are missing, run `pnpm build:lua` first.

## Bootstrap a running HappyView instance

After those offline checks pass, run this from `hypercerts-api/` to install the bundle on an approved, running HappyView instance. Unlike the checks above, this command contacts the instance and uploads assets.

For an interactive run, start the installer and enter the requested URL and admin token when prompted. Token input is hidden. Any nonblank values already set in the environment are used, and the installer prompts only for missing values.

```sh
pnpm install:api
```

For a noninteractive run, provide both values in the environment:

```sh
HAPPYVIEW_BASE_URL='https://your-happyview.example' \
HAPPYVIEW_ADMIN_TOKEN='<scoped-admin-token>' \
pnpm install:api
```

## How an API bundle is installed

Expand Down Expand Up @@ -69,12 +88,16 @@ The root manifest lists module manifests once:

Each module manifest declares `{ "assets": [...] }`. Assets have an `id`, `kind` (`lexicon` or `script`), `config`, and optional `dependsOn` asset IDs. Lexicons point to a `packagePath` in `@hypercerts-org/lexicon` or a local `path`; scripts use a local `path`. Local paths are relative to the **module manifest that declares them**. Declare shared assets in one module and reference their IDs from dependent modules. The installer rejects duplicate IDs, missing dependencies, cycles, and invalid source files before making admin requests.

With a complete bundle and an explicitly approved HappyView target, the entry point is `node tooling/installer.js`. Set `HAPPYVIEW_BASE_URL` and `HAPPYVIEW_ADMIN_TOKEN` in the environment. The installer sends `Authorization: Bearer <token>`; session-cookie authentication is not supported. Remote targets must use HTTPS; HTTP is allowed only on `localhost`, `127.0.0.1`, or `::1`. URL credentials and HTTP redirects are rejected.
The root manifest's `validationLexicons` is the complete local schema closure used by Lexicon validation; it does not define what the installer uploads. Only assets listed in a module's `assets` array are deployed. The location module intentionally leaves package-backed support schemas in the validation closure without deploying them. Removing an asset from a manifest affects future installer runs only; the installer does not unregister assets already on the HappyView instance.

The bootstrap command above should target only an explicitly approved HappyView instance. It sends `Authorization: Bearer <token>`; session-cookie authentication is not supported.

The admin API key must have `lexicons:read` and `lexicons:create` for lexicon assets. If the bundle includes scripts, it also needs `scripts:read` and `scripts:manage`. If a manifest requests backfill for a new record lexicon, `backfill:create` is additionally needed to start that job; without it, the lexicon is still uploaded but no backfill starts. Do not use the installer until the bundle includes its production manifests, handlers, and domain-specific completeness tests.
The token must have `lexicons:read` and `lexicons:create` for lexicon assets. If the bundle includes scripts, it also needs `scripts:read` and `scripts:manage`. If a manifest requests backfill for a new record lexicon, `backfill:create` is additionally needed to start that job; without it, the lexicon is still uploaded but no backfill starts. Remote targets must use HTTPS; HTTP is allowed only on `localhost`, `127.0.0.1`, or `::1`. URL credentials and HTTP redirects are rejected. Resolve installed-asset conflicts manually. The location handlers and fixtures require PostgreSQL.

## Test fixtures (disposable databases only)

The fixture tools seed records directly into PostgreSQL, bypassing HappyView ingestion. Use them **only with a separately approved, disposable loopback test database**, never persistent data. The `seed:test` and `seed:bad-dates` scripts require `HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES`, a loopback `PGHOST`, a `PGDATABASE` name containing a `test` marker, and an absolute `PSQL_PATH` to a trusted `psql` executable. Standard `PGPORT` and `PGUSER` can select the test instance and user. The normal `seed:test` command seeds location, profile, and organization examples by default; `seed:bad-dates` seeds only location examples by default. Both tools also accept supplied record rows through `buildSeedInput` and `buildBadDateSeedInput` in `tooling/seed.js`.

Fixture CIDs are computed from stored record JSON with `@atcute/cbor` and `@atcute/cid`. If you seeded an older version of the blob-backed fixture, reseed the **disposable test database** before comparing CIDs: the earlier `jsonToLex` conversion produced a different CID.

After confirming the approved disposable PostgreSQL database is the one used by the installed HappyView instance, run `pnpm seed:test` and `pnpm test:contracts` with `HAPPYVIEW_BASE_URL` set to that instance. Optionally run `pnpm seed:bad-dates` and then `pnpm test:bad-dates` on the same target. These commands are not part of the offline unit suite; seeding bypasses ingestion and tests only the read path.
37 changes: 37 additions & 0 deletions hypercerts-api/lexicons/app.certified.location.getLocation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"lexicon": 1,
"id": "app.certified.location.getLocation",
"defs": {
"main": {
"type": "query",
"description": "Public lookup of one indexed location record by full AT-URI. Authentication is not required.",
"parameters": {
"type": "params",
"required": ["uri"],
"properties": {
"uri": { "type": "string", "format": "at-uri", "description": "Full record AT-URI using a DID authority." }
}
},
"output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } },
"errors": [{ "name": "RecordNotFound", "description": "No indexed location exists at this AT-URI." }]
},
"output": {
"type": "object",
"required": ["location"],
"properties": { "location": { "type": "ref", "ref": "#locationView" } }
},
"locationView": {
"type": "object",
"description": "Location record with full indexed metadata, original payload, and hydrated author.",
"required": ["uri", "cid", "indexedAt", "did", "author", "record"],
"properties": {
"uri": { "type": "string", "format": "at-uri" },
"cid": { "type": "string", "format": "cid" },
"indexedAt": { "type": "string", "format": "datetime" },
"did": { "type": "string", "format": "did" },
"author": { "type": "ref", "ref": "org.hypercerts.api.defs#actorView" },
"record": { "type": "ref", "ref": "app.certified.location" }
}
}
}
}
31 changes: 31 additions & 0 deletions hypercerts-api/lexicons/app.certified.location.listLocations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"lexicon": 1,
"id": "app.certified.location.listLocations",
"defs": {
"main": {
"type": "query",
"description": "Public listing of indexed location records. Authentication is not required. Array filters use repeated unbracketed keys, maximum 100 supplied values before deduplication; omission is unrestricted. The handler applies limit=25 and sortDirection=desc when omitted; limit is 1..100. Results sort by a valid zoned createdAt, otherwise indexedAt (or the stored row creation time when indexedAt is absent), then URI, both in the requested direction; the opaque direction-bound cursor captures that database timestamp at UTC microsecond precision, with no snapshot guarantee. Keep other parameters unchanged between pages. Unknown parameters and repeated scalar keys are rejected.",
"parameters": {
"type": "params",
"properties": {
"authors": { "type": "array", "maxLength": 100, "items": { "type": "string", "format": "did" } },
"uris": { "type": "array", "description": "Full record AT-URIs using DID authorities.", "maxLength": 100, "items": { "type": "string", "format": "at-uri" } },
"locationTypes": { "type": "array", "description": "Exact open-string match against locationType; empty strings are values, not sentinels.", "maxLength": 100, "items": { "type": "string", "maxLength": 20 } },
"limit": { "type": "integer", "description": "Page size; handler default is 25.", "minimum": 1, "maximum": 100 },
"cursor": { "type": "string", "description": "Opaque versioned keyset cursor bound to sortDirection." },
"sortDirection": { "type": "string", "description": "Sort direction; handler default is desc.", "enum": ["asc", "desc"] }
}
},
"output": { "encoding": "application/json", "schema": { "type": "ref", "ref": "#output" } },
"errors": [{ "name": "InvalidRequest", "description": "A query parameter is invalid, repeated where scalar, or unknown." }]
},
"output": {
"type": "object",
"required": ["locations"],
"properties": {
"locations": { "type": "array", "items": { "type": "ref", "ref": "app.certified.location.getLocation#locationView" } },
"cursor": { "type": "string"}
}
}
}
}
42 changes: 42 additions & 0 deletions hypercerts-api/lexicons/org.hypercerts.api.defs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"lexicon": 1,
"id": "org.hypercerts.api.defs",
"description": "Shared views for Hypercerts API read models.",
"defs": {
"profileView": {
"type": "object",
"description": "Profile record view preserving the full indexed record.",
"required": ["uri", "cid", "indexedAt", "did", "record"],
"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.actor.profile" }
}
},
"organizationView": {
"type": "object",
"description": "Raw organization sidecar record view preserving the full indexed record.",
"required": ["uri", "cid", "indexedAt", "did", "record"],
"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.actor.organization" }
}
},
"actorView": {
"type": "object",
"description": "Actor DID with nullable profile and raw organization sidecar views.",
"required": ["did", "profile", "organization"],
"nullable": ["profile", "organization"],
"properties": {
"did": { "type": "string", "format": "did" },
"profile": { "type": "ref", "ref": "#profileView" },
"organization": { "type": "ref", "ref": "#organizationView" }
}
}
}
}
104 changes: 104 additions & 0 deletions hypercerts-api/lua/endpoints/getLocation.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
local COLLECTION = "app.certified.location"
local PROFILE = "app.certified.actor.profile"
local ORGANIZATION = "app.certified.actor.organization"
local NULL = json.decode("null")

local function invalid(message)
error("InvalidRequest: " .. message, 0)
end

local function keys_only(values, allowed)
for key in pairs(values) do
if not allowed[key] then invalid("unknown query parameter") end
end
end

local function scalar(params, key)
local value = params[key]
if value == nil then return nil end
if type(value) ~= "string" and type(value) ~= "number" then
invalid(key .. " must occur once")
end
return tostring(value)
end

local function valid_did(value)
if #value > 2048 then return false end
local method, specific = value:match("^did:([a-z]+):(.+)$")
if not method or not specific or specific:sub(-1) == ":" or specific:sub(-1) == "%"
or value:find("[^%w%.:_%%%-]") then return false end
return true
end

local function valid_record_key(value)
return #value >= 1 and #value <= 512 and value ~= "." and value ~= ".."
and not value:find("[^%w_~%.:%-]")
end

local function valid_uri(value)
if value:find("[?#]") then return false end
local authority, collection, rkey = value:match("^at://([^/]+)/([^/]+)/([^/]+)$")
return authority ~= nil and valid_did(authority) and collection == COLLECTION and valid_record_key(rkey)
end

local function query(sql, values)
local ok, result = pcall(db.raw, sql, values)
if not ok then error("LocationQueryFailed: location lookup failed", 0) end
return result
end

local function row_view(row)
local record = json.decode(row.record)
return {
uri = row.uri, cid = row.cid, indexedAt = row.indexed_at, did = row.did,
record = record,
}
end

local function hydrate(views)
if #views == 0 then return end
local dids, seen = {}, {}
for _, view in ipairs(views) do
if not seen[view.did] then seen[view.did] = true; dids[#dids + 1] = view.did end
end
local profiles, organizations = {}, {}
local function load(collection, target)
if #dids == 0 then return end
local params, marks = {}, {}
params[1] = collection
for _, did in ipairs(dids) do params[#params + 1] = did; marks[#marks + 1] = "$" .. #params 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 rkey = 'self' AND did IN (" .. table.concat(marks, ",") .. ")", params)
for _, row in ipairs(rows) do target[row.did] = row end
end
load(PROFILE, profiles)
load(ORGANIZATION, organizations)
for _, view in ipairs(views) do
local profile, organization = profiles[view.did], organizations[view.did]
local author = { did = view.did, profile = NULL, organization = NULL }
if profile then author.profile = row_view(profile) end
if organization then author.organization = row_view(organization) end
view.author = author
end
end

local function query_location(uri)
if db.backend() ~= "postgres" then error("LocationQueryFailed: location API requires PostgreSQL", 0) 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 = $2 LIMIT 1", { COLLECTION, uri })
local views = {}
for _, row in ipairs(rows) do views[#views + 1] = row_view(row) end
hydrate(views)
return views
end

local function get_location()
keys_only(params, { uri = true })
local uri = scalar(params, "uri")
if not uri or not valid_uri(uri) then invalid("uri must be a full app.certified.location AT-URI with a DID authority") end
local views = query_location(uri)
if #views == 0 then error("RecordNotFound: location record is not indexed", 0) end
return { location = views[1] }
end

function handle()
return get_location()
end
Loading
Loading