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
19 changes: 17 additions & 2 deletions .github/workflows/hypercerts-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ jobs:
with:
luaVersion: "5.4"

- name: Add Lua 5.4 executable alias
run: |
lua_bin="$(command -v lua)"
ln -s "$lua_bin" "${lua_bin%/*}/lua5.4"
lua5.4 -e 'assert(_VERSION == "Lua 5.4", "expected Lua 5.4")'

- uses: leafo/gh-actions-luarocks@35d062def313a7699a0d513e996bcf4352f05389 # v6.1.0

- name: Install Luacheck
Expand All @@ -45,5 +51,14 @@ jobs:
- name: Install Node dependencies
run: pnpm install --frozen-lockfile

- name: Lint and test
run: pnpm check
- name: Check generated Lua bundles
run: pnpm run check:generated

- name: Lint
run: pnpm run lint

- name: Typecheck
run: pnpm run typecheck

- name: Unit tests
run: pnpm run test:unit
7 changes: 6 additions & 1 deletion hypercerts-api/.luacheckrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
std = "lua54"
globals = { "db", "json", "params", "toarray", "handle" }
globals = { "handle" }
read_globals = { "db", "json", "params", "toarray" }
not_globals = {
"io", "debug", "package", "require", "dofile", "loadfile", "load", "collectgarbage",
"os.execute", "os.exit", "os.getenv", "os.remove", "os.rename", "os.setlocale", "os.tmpname",
}
unused_args = true
-- Generated SQL expressions can be long; keep Luacheck focused on code issues.
max_line_length = false
12 changes: 7 additions & 5 deletions hypercerts-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

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

**Current status:** The root `manifest.json` includes the location API bundle. Build its Lua handlers and run the offline checks before installing it on an approved HappyView target.
**Current status:** The root `manifest.json` includes the location and actor-follow query slices. Build their Lua handlers and run the offline checks before installing the bundle on an approved HappyView target.

The actor-follow slice exposes `app.certified.graph.getFollow`, `app.certified.graph.listActorFollowers`, and `app.certified.graph.listActorFollowing`, backed by the pinned `app.certified.graph.follow` record Lexicon. Both listing queries return a required `totalCount` for the complete deduplicated relationship set, independent of cursor and limit. Lookup and listings sort follows by `createdAt`; if it is missing or malformed, they use `indexed_at` (or the database insertion time when `indexed_at` is absent) so those follows remain visible and counted.

## Get started

Expand All @@ -14,9 +16,9 @@ pnpm build:lua
pnpm check
```

These checks run offline; they do not require a running HappyView instance or database. `build:lua` builds the standalone location 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.
These checks run offline; they do not require a running HappyView instance or database. `build:lua` builds the standalone location and actor-follow handlers in `lua/endpoints/`. `pnpm check` verifies the generated Lua bundles are current, runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests; run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. Run `pnpm check:generated` to check bundle freshness on its own; it does not modify files. If it fails, run `pnpm build:lua` and review the generated changes. The bundle combines local API Lexicons and scripts with schemas from the pinned `@hypercerts-org/lexicon` package. HTTP contract tests require an installed bundle and a separately approved disposable target.

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.
ESLint is installed with the package dependencies. It uses ESLint's recommended checks plus strict equality, no implicit coercion, no shadowed names, no reassigned parameters, no `var`, and `const` where possible. It also rejects direct `eval`, implied evaluation (such as string-based timers), and `Function` constructors. Console output is allowed only in CLI tooling, and unused ESLint disable directives fail lint. When a branch contains Lua scripts, Luacheck checks the generated handlers in `lua/endpoints/`; it rejects unknown globals, sandbox-removed Lua APIs, and unused arguments. It rejects `io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`, `collectgarbage`, and unsafe `os` functions; only `os.time`, `os.date`, `os.difftime`, and `os.clock` are allowed from `os`. Only the HappyView `handle` global is writable; `db`, `json`, `params`, and `toarray` are read-only. It targets Lua 5.4 and skips line-length checks for long SQL expressions. Install Lua 5.4 and LuaRocks, then run `luarocks --lua-version=5.4 --local install luacheck 1.2.0` to enable that check locally. The lint runner also finds the default `~/.luarocks/bin` install if it is not on `PATH`. Branches without Lua files skip Luacheck. If Lua sources exist but generated handlers are missing, run `pnpm build:lua` first.

## Bootstrap a running HappyView instance

Expand Down Expand Up @@ -92,11 +94,11 @@ The root manifest's `validationLexicons` is the complete local schema closure us

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

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

## 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`.
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, organization, and actor-follow examples by default; `seed:bad-dates` seeds only location examples and remains separate. `pnpm test:contracts` runs the location and actor-follow HTTP contract suites; `pnpm test:bad-dates` remains a separate opt-in suite. Both seed 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.

Expand Down
3 changes: 3 additions & 0 deletions hypercerts-api/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export default [
rules: {
...js.configs.recommended.rules,
eqeqeq: ["error", "always", { null: "ignore" }],
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error",
"no-console": "error",
"no-implicit-coercion": "error",
"no-param-reassign": ["error", { props: true }],
Expand Down
70 changes: 70 additions & 0 deletions hypercerts-api/lexicons/app.certified.graph.getFollow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"lexicon": 1,
"id": "app.certified.graph.getFollow",
"defs": {
"main": {
"type": "query",
"description": "Returns the earliest indexed follow record for one actor-to-actor relationship, or null when no matching relationship is indexed. Authentication is not required.",
"parameters": {
"type": "params",
"required": ["actor", "subject"],
"properties": {
"actor": {
"type": "string",
"format": "did",
"description": "DID of the actor who published the follow record. Required."
},
"subject": {
"type": "string",
"format": "did",
"description": "DID of the actor named as the follow record's subject. Required."
}
}
},
"output": {
"encoding": "application/json",
"schema": { "type": "ref", "ref": "#output" }
},
"errors": [
{ "name": "InvalidRequest", "description": "A required DID is invalid, repeated, or accompanied by an unknown parameter." }
]
},
"followRecordView": {
"type": "object",
"description": "Raw indexed follow record view, including the DID that published the relationship.",
"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.graph.follow" }
}
},
"actorFollowView": {
"type": "object",
"description": "The displayed actor and the raw follow record representing its relationship.",
"required": ["did", "profile", "organization", "follow"],
"nullable": ["profile", "organization"],
"properties": {
"did": { "type": "string", "format": "did" },
"profile": { "type": "ref", "ref": "org.hypercerts.api.defs#profileView" },
"organization": { "type": "ref", "ref": "org.hypercerts.api.defs#organizationView" },
"follow": { "type": "ref", "ref": "#followRecordView" }
}
},
"output": {
"type": "object",
"description": "The raw representative follow record, or explicit null when the relationship is not indexed.",
"required": ["follow"],
"nullable": ["follow"],
"properties": {
"follow": {
"type": "ref",
"ref": "#followRecordView",
"description": "The earliest-created indexed record for the actor and subject pair, with URI and publisher metadata."
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"lexicon": 1,
"id": "app.certified.graph.listActorFollowers",
"defs": {
"main": {
"type": "query",
"description": "Lists actors whose indexed follow records name the requested actor as subject. Authentication is not required. Duplicate relationships are collapsed before pagination.",
"parameters": {
"type": "params",
"required": ["actor"],
"properties": {
"actor": {
"type": "string",
"format": "did",
"description": "DID of the actor whose incoming follows are listed. Required."
},
"sortDirection": {
"type": "string",
"enum": ["asc", "desc"],
"description": "Sort by each representative follow's createdAt and URI in this direction; the default is desc."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum relationships returned; the handler default is 25."
},
"cursor": {
"type": "string",
"description": "Opaque cursor from the previous page, bound to sortDirection. Keep other query parameters unchanged between pages."
}
}
},
"output": {
"encoding": "application/json",
"schema": { "type": "ref", "ref": "#output" }
},
"errors": [
{ "name": "InvalidRequest", "description": "A required DID, sort direction, limit, cursor, or query parameter is invalid." }
]
},
"output": {
"type": "object",
"required": ["followers", "totalCount"],
"properties": {
"followers": {
"type": "array",
"items": { "type": "ref", "ref": "app.certified.graph.getFollow#actorFollowView" },
"description": "Incoming relationships, with each displayed DID identifying the actor who published the follow."
},
"totalCount": {
"type": "integer",
"minimum": 0,
"description": "Number of matching relationships after duplicate relationships are collapsed, before cursor and limit are applied."
},
"cursor": {
"type": "string",
"description": "Opaque cursor for the next page; omitted when there are no more relationships."
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"lexicon": 1,
"id": "app.certified.graph.listActorFollowing",
"defs": {
"main": {
"type": "query",
"description": "Lists actors named as subjects by the requested actor's indexed follow records. Authentication is not required. Duplicate relationships are collapsed before pagination.",
"parameters": {
"type": "params",
"required": ["actor"],
"properties": {
"actor": {
"type": "string",
"format": "did",
"description": "DID of the actor whose outgoing follows are listed. Required."
},
"sortDirection": {
"type": "string",
"enum": ["asc", "desc"],
"description": "Sort by each representative follow's createdAt and URI in this direction; the default is desc."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum relationships returned; the handler default is 25."
},
"cursor": {
"type": "string",
"description": "Opaque cursor from the previous page, bound to sortDirection. Keep other query parameters unchanged between pages."
}
}
},
"output": {
"encoding": "application/json",
"schema": { "type": "ref", "ref": "#output" }
},
"errors": [
{ "name": "InvalidRequest", "description": "A required DID, sort direction, limit, cursor, or query parameter is invalid." }
]
},
"output": {
"type": "object",
"required": ["following", "totalCount"],
"properties": {
"following": {
"type": "array",
"items": { "type": "ref", "ref": "app.certified.graph.getFollow#actorFollowView" },
"description": "Outgoing relationships, with each displayed DID identifying the actor named as the follow subject."
},
"totalCount": {
"type": "integer",
"minimum": 0,
"description": "Number of matching relationships after duplicate relationships are collapsed, before cursor and limit are applied."
},
"cursor": {
"type": "string",
"description": "Opaque cursor for the next page; omitted when there are no more relationships."
}
}
}
}
}
Loading
Loading