A standalone, read-only TypeScript service. It reads the current PostgreSQL data owned by Hyperindex and serves ordered Hypercerts feeds over XRPC.
Hyperindex is the only supported owner of the database. The service provides a URI-only skeleton and a hydrated feed with generic entries and feed-specific views. It does not ingest or change indexed data, import Hyperindex code, call the Hyperindex GraphQL API, authenticate callers, fetch blob bytes, or provide an unchangeable event history.
GET / returns a small JSON description of the service and lists its public XRPC procedures. It does not query the database or report service readiness.
Both feed endpoints are unauthenticated POST procedures. They use the same { feedId, params?, limit?, cursor? } request wrapper, feed-scoped cursor contract, and stable public errors. params contains only algorithm-specific values; pagination is generic and top-level. The registered Hypercerts feed requires params.viewerDid to choose the viewer scope. The service does not check it against an authenticated caller:
POST /xrpc/org.hypercerts.feed.getFeedSkeleton
POST /xrpc/org.hypercerts.feed.getFeed
Content-Type: application/json
These are app-specific XRPC procedures. They are not Bluesky's app.bsky.feed.getFeedSkeleton query.
The procedure and JSON-body design is intentional. A Lexicon query cannot have an input body, and Lexicon's HTTP query-parameter params type cannot contain references, nested objects, or the open union needed for different feed algorithms to define different parameter shapes. Procedures can have both URL parameters and a body, but splitting feedId, limit, and cursor into the URL while putting algorithm-specific values in the body would make one feed request harder to construct and understand. Keeping the complete request in one JSON body conforms to the Lexicon specification and gives generated clients one coherent input object. It differs only from the style guide's usual limit and cursor convention for query endpoints. This transport shape is part of the public contract and should be settled before clients adopt it, because moving fields between the body and URL later would be a breaking change.
curl -sS http://localhost:3000/xrpc/org.hypercerts.feed.getFeed \
-H 'content-type: application/json' \
--data '{
"feedId": "org.hypercerts.feed.defs#hypercertsFeed",
"params": {
"$type": "org.hypercerts.feed.defs#hypercertsFeedParams",
"viewerDid": "did:plc:ar7c4by46qjdydhdevvrndac",
"trustedEvaluators": ["did:plc:ewvi7nxzyoun6zhxrhs64oiz"],
"organizationQuality": {
"allowed": ["high-quality", "standard"],
"includeUnrated": false
}
},
"limit": 20
}'Use the same body with getFeedSkeleton when another data system needs only ordered source AT-URIs. The public params union is open for future feed algorithms, including algorithms that accept no params. This service currently registers only org.hypercerts.feed.defs#hypercertsFeed and requires org.hypercerts.feed.defs#hypercertsFeedParams; missing params or a mismatched $type returns InvalidRequest, while an unknown feedId returns UnsupportedFeed.
{
"feed": [
{
"subject": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/org.hypercerts.claim.activity/3kpn"
}
],
"cursor": "eyJ2ZXJzaW9uIjoxLCJmZWVkSWQiOiJvcmcuaHlwZXJjZXJ0cy5mZWVkLmRlZnMjaHlwZXJjZXJ0c0ZlZWQiLCJ2YWx1ZSI6eyJ2YWx1ZSI6IjIwMjYtMDctMjFUMTA6MDA6MDAuMDAwMDAwWiIsInVyaSI6ImF0Oi8vZGlkOnBsYzpld3ZpN254enlvdW42emh4cmhzNjRvaXovb3JnLmh5cGVyY2VydHMuY2xhaW0uYWN0aXZpdHkvM2twbiJ9fQ"
}Each skeleton entry intentionally contains only the source record AT-URI. A downstream hydrator resolves the current indexed record version; the skeleton does not pin hydration to a CID or expose feed-specific classification metadata. Send a returned cursor back as the top-level request cursor, never inside algorithm-specific params.
{
"feed": [
{
"subject": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/org.hypercerts.context.evaluation/3kpn",
"view": {
"$type": "org.hypercerts.feed.defs#hypercertsFeedView",
"kind": "evaluation.create",
"actor": {
"did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"handle": "evaluator.example",
"displayName": "Evaluator"
},
"content": {
"$type": "org.hypercerts.feed.defs#evaluationView",
"summary": "Strong evidence",
"createdAt": "2026-07-21T10:00:00.000Z",
"target": {
"uri": "at://did:plc:ar7c4by46qjdydhdevvrndac/org.hypercerts.claim.activity/target",
"cid": "bafyreifxcn6ts5hr6oequ5w5jyrpwrdl6p5lq46jasnxmcw3h3sme6asru"
}
}
}
}
]
}Every hydrated entry has a generic source subject and an open view union. The current hypercertsFeedView variant owns the Hypercerts event kind, actor, and kind-specific content. Clients must tolerate unknown future feed-view and content variants.
Every hydrated feed entry comes from a valid source record. Each entry has a feed-specific view and kind-specific content.
The service uses an actor's Certified profile for their name and avatar when it can. Otherwise, it uses their Bluesky profile. It adds a valid handle separately. If there is no profile, it shows the actor by their handle or DID.
An evaluation, measurement, or update may point to another record by its exact URI and CID. These targets remain strong references even though the entry's source subject is URI-only. The service checks that each target reference is valid.
Clients must accept image and feed formats they do not know. Known images keep their original Hypercerts and AT Protocol formats.
The service removes invalid feed items and does not replace them. A page may therefore have fewer items than requested, or no items. The cursor still advances past every record the service checked.
The response never contains the original source JSON. It also does not say where actor details came from.
The service does not fetch, preview, or expand linked records. It only checks that the link contains a valid URI and CID.
The service does not contact a PDS or AppView. It does not download or proxy image files. It gets Bluesky profile data from the indexed database.
sequenceDiagram
actor Client
participant HTTP as HTTP App
participant XRPC as XRPC Handler
participant Service as FeedService / HydratedFeedService
participant Registry as Feed Registry
participant Feed as Registered SQL Feed
participant Identity as Identity Reader
participant DB as Hyperindex PostgreSQL
Client->>HTTP: POST feed procedure
HTTP->>XRPC: Bounded, validated JSON
XRPC->>Service: Endpoint input
Service->>Registry: Load metadata or source-aware page
Registry->>Feed: Dispatch feedId, optional params, and pagination
Feed->>DB: One normalized Hyperindex selection statement
DB-->>Feed: Scope and limit+1 metadata or exact sources
Feed-->>Registry: Selected page and feed-scoped cursor
Registry-->>Service: Selected page and cursor
alt Hydrated endpoint
Service->>Service: Validate sources and omit invalid rows
Service->>Identity: Requested actor DIDs when needed
Identity->>DB: One actor/profile batch
DB-->>Identity: Current identity contexts
Identity-->>Service: Complete DID map
Service-->>XRPC: Generic entries, feed-specific views, and selected-page cursor
else Skeleton endpoint
Service-->>XRPC: URI-only subjects and cursor
end
XRPC-->>HTTP: Procedure response
HTTP-->>Client: JSON response
- Malformed JSON, missing required params, an invalid nested
viewerDid, params that do not match the selected feed, or structurally invalid top-level pagination return HTTP 400 withInvalidRequest. Semantically invalid selected-feed parameters or pagination beyond that feed's supported range return HTTP 422 with the same generic error name and an actionable message. An unregisteredfeedIdreturnsUnsupportedFeed. These never become internal server errors. - The base scope always comes from the viewer's current
app.certified.graph.followrecords. The service ignores malformed follow subjects. trustedEvaluatorsadds the subjects of every current, active endorsement award from each evaluator.- An endorsement definition with no
allowedIssuersallows any issuer. When it is present, only its listed issuer DIDs qualify. An empty or malformed value allows no issuers. - The service removes the viewer. Hyperindex removes source records for identities that are explicitly deleted, deactivated, suspended, or taken down, so feed selection does not check actor status.
- The organization-quality rules run against the final combined author list before event selection. Only exact
app.certified.actor.organization/selfrecords count as organizations. - Trusted quality labelers come only from the service configuration. Quality labels are bare-DID, non-CID
external_labelrows. Callers cannot choose label sources. - Leaving out
kinds, or passing an empty list, includes every supported kind. Unknown kinds are rejected asInvalidRequest.
Supported event kinds:
cert.create
collection.create
project.created_with_cert
evaluation.create
measurement.create
hyperboard.create
update.create
endorsement.award
Project and activity records are combined before kind filtering and pagination. After a collection is returned as project.created_with_cert, its paired activity cannot appear on a later page.
The order is:
feed timestamp DESC, record URI DESC
Entries appear newest first using an internal feed timestamp. It uses the record's valid createdAt; otherwise, it uses the time when Hyperindex indexed the record. The timestamp is not exposed in either response. Pagination moves forward from the last source row chosen before hydration, so removing an invalid hydrated entry does not move the cursor.
Pages
- The service checks one extra item to see whether another page exists.
- The cursor marks the last item selected and is valid only for the
feedIdthat issued it. - Both endpoints use the same top-level pagination, ordering, and feed-scoped cursor rules.
- Invalid hydrated entries are removed, not replaced.
Database work
- A skeleton page uses one database query.
- A full feed page also loads actor and profile details when it has valid entries.
- The service never makes extra queries to fill gaps left by invalid entries.
Freshness
Feed entries and their source records are read together. Actor and profile details are loaded afterward, so they may be newer.
Each page is a separate database read. Changes made between requests may appear on later pages.
Record size
The service reads at most 51 source records per page. Their total size is not limited, so unusually large records may take longer and use more memory.
Source JSON is never returned.
For local development, copy .env.example to .env. For deployment, copy its values into the deployment's variable configuration. The process can load a local .env, but it never replaces variables already set in the environment.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DATABASE_URL |
yes | Dedicated read-only Postgres URL for the Hyperindex database | |
PORT |
no | 3000 |
HTTP listen port |
HOST |
no | 0.0.0.0 |
Public HTTP listen interface |
METRICS_HOST |
no | 0.0.0.0 |
Private metrics listen interface; used only when METRICS_PORT is set |
METRICS_PORT |
no | disabled | Enables the private GET /metrics listener on this port; must differ from PORT |
LOG_LEVEL |
no | info |
Pino log level |
DATABASE_MAX_CONNECTIONS |
no | 5 |
Maximum pool size, capped at 20; the pool keeps one connection warm |
DATABASE_IDLE_TIMEOUT_MS |
no | 60000 |
Time before idle connections above the one-connection minimum are closed |
DATABASE_CONNECTION_TIMEOUT_MS |
no | 2000 |
Pool acquisition timeout |
DATABASE_STATEMENT_TIMEOUT_MS |
no | 5000 |
PostgreSQL statement timeout |
REQUEST_TIMEOUT_MS |
no | 10000 |
Maximum time allowed to receive an HTTP request; not a handler or database deadline |
GRACEFUL_SHUTDOWN_MS |
no | 10000 |
Shutdown drain timeout |
TRUSTED_QUALITY_LABELER_DIDS |
no | empty | Comma-separated Orglabeler trust roots |
If there are no configured trusted labelers, known organizations count as unrated whenever the request includes an organization-quality policy.
Use a dedicated login that has only SELECT access. The service also sets default_transaction_read_only=on for every connection in the pool, but database grants are still the main security boundary.
This database arrangement will change when the Hypercerts API is introduced. At that point, Hypercerts Feed Service will use the same database as the Hypercerts API.
Each service replica has its own limited in-process pool. When planning database connections, allow for replica count × DATABASE_MAX_CONNECTIONS. Use an external pooler if many replicas share a PostgreSQL server with few available connections.
Example operator setup:
CREATE ROLE hypercerts_feed_reader LOGIN PASSWORD '<managed-secret>';
GRANT CONNECT ON DATABASE hyperindex TO hypercerts_feed_reader;
GRANT USAGE ON SCHEMA public TO hypercerts_feed_reader;
GRANT SELECT ON TABLE public.record, public.actor, public.external_label
TO hypercerts_feed_reader;
ALTER ROLE hypercerts_feed_reader SET default_transaction_read_only = on;The service has no migrations or feed tables of its own. /ready checks that the database can be reached, PostgreSQL 16 can validate timestamps, and the session is read-only. It does not check Hyperindex tables, migrations, label subscriptions, completed backfills, or ingestion freshness. See docs/database-contract.md for the runtime schema contract.
Before cutover, confirm that trusted quality-label subscriptions are healthy and current, the migration-010 timestamp backfill is complete, and the Hyperindex filters and backfill include app.certified.actor.profile, app.bsky.actor.profile, and org.hyperboards.board.
You need Node.js 22.13+ and PostgreSQL 16+.
npm install
npm run codegen
npm run check
npm run test:unit
npm run buildThe committed Lexicon JSON in lexicons/ defines the public wire contract and includes installed external dependencies. lexicons.json pins installed network Lexicons by AT-URI and CID. The generated TypeScript in src/lexicons/ is ignored. Do not edit or commit it. The codegen, check, test, and build commands regenerate it. Codegen stages only the standard org.hypercerts.defs#uri, #smallBlob, #smallImage, and #largeImage fragments from the pinned @hypercerts-org/lexicon package. This keeps the feed from committing duplicate definitions. AGENTS.md explains a small, focused workaround for the current @atproto/lex generator output that runs after codegen.
Check the committed network Lexicons against the manifest with:
npx --no-install lex install --ci --lexicons ./lexicons --manifest ./lexicons.jsonRun lex install --update only when you mean to update those pinned dependencies.
The main feed statement is src/feed/feed-query.sql. src/feed/query.ts registers its Hypercerts feed definition, while src/feed/registry.ts and src/feed/sql-feed.ts own dispatch and shared SQL-feed execution policy. During development, the watcher watches the SQL along with the TypeScript source. The build copies it next to dist/feed/query.js before checking that the production adapters load.
Integration tests need a PostgreSQL 16+ database that you explicitly choose and that is empty and safe to discard:
TEST_DATABASE_URL='postgresql://postgres:postgres@localhost:5432/hypercerts_feed_test' \
npm run test:integrationThe test suite creates the smallest needed contract tables and test rows. It does not drop or empty existing tables. Never point it at a shared, staging, or production database.
To test against Hyperindex's migrated schema, first have Hyperindex migrate a separate disposable database that otherwise contains no application rows:
TEST_DATABASE_URL='postgresql://postgres:postgres@localhost:5432/hyperindex_feed_test' \
npm run test:integration:hyperindexThis command needs psql. It checks the required Hyperindex migrations, the record, actor, and external-label columns, the generated record.rkey, and the external-label lookup index. It then runs the same behavior tests. It does not apply Hyperindex migrations.
Add a Changeset with npm run changeset when a pull request affects the service's behavior, runtime configuration, public contract, supported runtime versions, deployment, or operator procedures. Local development tools, tests, behavior-preserving internal refactors, documentation-only corrections, and repository or CI maintenance with no service or operator impact do not need one. CI allows omission but validates any included Changeset fragment before merge. GitHub creates approval-gated CI runs for the automated Release pull request. Maintainers approve those runs, wait for the full PostgreSQL-backed CI suite, and merge the Release pull request only after it passes. The release workflow then validates the exact merged commit and creates a Git tag and GitHub Release without publishing the private package to npm or deploying the service.
See docs/RELEASING.md for the contributor and maintainer workflow.
docker build -t hypercerts-feed-service .
docker run --rm -p 3000:3000 \
-e DATABASE_URL='postgresql://...' \
-e TRUSTED_QUALITY_LABELER_DIDS='did:plc:ar7c4by46qjdydhdevvrndac' \
hypercerts-feed-serviceDeploy the service beside Hyperindex and use private networking for the database.
To expose metrics, set a private metrics listener separate from the public application port:
METRICS_HOST=0.0.0.0
METRICS_PORT=3001Configure a compatible Prometheus collector to read http://<private-service-host>:3001/metrics. The GET /metrics response uses Prometheus-compatible exposition format. The listener has no application-level authentication, so do not attach a public domain or TCP proxy to the metrics port. Each replica exposes only its current in-memory registry; the collector owns aggregation, retention, and historical data. When METRICS_PORT is unset or empty, the metrics listener is disabled. /metrics remains unavailable on the public application port.
Set per-IP rate limits at the gateway. The first public policy allows 60 feed requests per minute for each client IP, with a burst of 20. When a client exceeds the limit, return HTTP 429 with Retry-After. Keep health and readiness private and outside this public limit. Adjust the limits using measured query response time and pool saturation.
The process limits request body size, HTTP request receive time, pool size, connection wait time, and SQL statement duration. REQUEST_TIMEOUT_MS is not a deadline for the whole handler or query. Feed procedures allow browser requests from every origin and support POST preflight requests. They do not allow credentialed CORS requests. CORS does not authenticate callers or replace gateway rate limiting. Do not add rate-limit state to this service because separate replicas would disagree.
GET /health: checks only whether the process is alive.GET /ready: checks current database support and read-only state; the runtime schema contract is documented separately.- Private metrics listener: when
METRICS_PORTis set,GET /metricsexposes this replica's metrics in Prometheus-compatible exposition format onMETRICS_HOST:METRICS_PORT; all other paths are rejected. SIGTERMandSIGINT: stop accepting requests, let current work finish, stop the optional metrics listener, and then close the database pool.
Stable public feed errors:
InvalidRequest
UnsupportedFeed
InvalidCursor
InternalError
Feed-specific parameter failures use InvalidRequest; their message identifies the invalid parameter and how to correct it.
Public errors never show SQL, database credentials, table contents, internal causes, or stack traces.
The service does not ingest data, authenticate callers, write records, manage migrations, cache across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, build target previews, recursively hydrate linked records, save preferences, discover the network, or provide an unchangeable event history. Results show Hyperindex's current, changeable data and its current freshness.