Skip to content
These docs describe arocapi v5, which is in beta. Install it with pnpm add arocapi@beta.

Visibility

Visibility decides whether an entity exists for a request. A Hidden entity is left out of listings and search, including their totals and facets, and a direct request for it gets the same 404 as a missing entity. Visibility vs Access explains how visibility and access combine.

Option Required Type Default Purpose
entityVisibilityFilter yes EntityVisibilityFilter none Prisma condition matching the entities visible to a request.
searchVisibilityFilter yes SearchVisibilityFilter none Condition over the declared search fields matching the same entities.
promptLoginForHidden no boolean false Answer anonymous requests for Hidden entities with 401.

Both filters receive the request context and may be async. They must match the same entities, and any field they use must be in both the database and the search index; see why the two filters must agree.

Returns a Prisma EntityWhereInput. arocapi combines it with AND into every database query: /entities, /files (through each file’s entity), /entity/:id, /entity/:id/metadata, /file/:id and the reload of search hits.

Returns a SearchVisibilityWhere: a condition over id and the fields declared in your search backend, in one language every backend applies, so it carries over unchanged when you switch backends. POST /search calls it on every search and passes the result to the backend as the request’s visibility, and the backend applies it to the hits, the total and the facets. OpenSearchBackend translates it to a bool filter beside the query.

Type it as SearchVisibilityFilter<typeof fields>, with fields your backend’s declarations as const, so an undeclared field or a value of the wrong type fails to compile.

Each key is id or a declared field, including fields with no role, which are stored only for this filter. geo fields can’t be used. Several keys in one object must all match.

Condition Fields Matches when
a bare value, or { equals } every type but geo any value equals it
{ not } every type but geo no value equals it, including when there are none
{ in: [...] } every type but geo any value is in the list; an empty list matches nothing
{ notIn: [...] } every type but geo no value is in the list, including when there are none
{ lt, lte, gt, gte } date and number any value is within every bound given
null, or { equals: null } every type but geo the field has no value
{ not: null } every type but geo the field has a value

Operators in one condition must all match, so { gte: 5, not: 10 } is a value from 5 that isn’t 10. A text field compares its exact value, not its words. Date values are a Date or a YYYY, YYYY-MM, YYYY-MM-DD or RFC 3339 string, read like the dates in search documents: a partial date is its first UTC instant, and a date-time with no offset is UTC.

AND, OR and NOT combine filters, each taking an array of them. AND matches when every filter does, OR when any does (an empty OR matches nothing) and NOT when none does.

A field with several values matches when any of them does, and not and notIn match when none does. So draft: { not: true } keeps entities that have no draft value at all, where Prisma’s not on a nullable column leaves out its nulls. null means the field has no value, not an SQL null.

AllVisibleSearchFilter returns {}, which matches every entity.

The backend checks the filter on every search. An undeclared field, an operator that doesn’t fit the field’s type, such as lt on a keyword, or a bad value, such as a string for a number, fails the search with a 500 INTERNAL_ERROR, so a mistake never shows hidden entities. The log has the error, naming the field.

If the two filters disagree, search drops any hit the entity filter hides and logs a warning. The page can then be shorter than limit, and total still counts the dropped hits.

For a public repository, use the exported filters that make every entity visible, with AllPublicAccess:

import { AllPublicAccess, AllVisibleEntityFilter, AllVisibleSearchFilter, type Options } from 'arocapi';
export const guards = {
entityVisibilityFilter: AllVisibleEntityFilter,
searchVisibilityFilter: AllVisibleSearchFilter,
access: AllPublicAccess,
} satisfies Partial<Options>;

To hide whole collections, such as ones withdrawn from publication, build both filters from the same list. id is always indexed, and rootCollection needs declaring as a field with no role:

import type { EntityVisibilityFilter, SearchVisibilityFilter } from 'arocapi';
import type { FastifyRequest } from 'fastify';
// Your app's lookup of the top-level collections a request may not see, such as withdrawn ones
declare function hiddenCollections(request: FastifyRequest): Promise<string[]>;
// Your search backend's field declarations, with rootCollection stored for visibility only
const fields = {
name: { type: 'text' },
rootCollection: { type: 'keyword' },
} as const;
// Hides each collection and, through rootCollection, everything in it
export const entityVisibilityFilter: EntityVisibilityFilter = async ({ request }) => {
const hidden = await hiddenCollections(request);
return { id: { notIn: hidden }, OR: [{ rootCollection: null }, { rootCollection: { notIn: hidden } }] };
};
export const searchVisibilityFilter: SearchVisibilityFilter<typeof fields> = async ({ request }) => {
const hidden = await hiddenCollections(request);
return { id: { notIn: hidden }, rootCollection: { notIn: hidden } };
};

Visibility doesn’t pass down from collections. A member of a Hidden collection is only Hidden if your filters hide it, which is why both filters also check rootCollection. In Prisma, notIn leaves out rows where the column is null, so the entity filter keeps top-level collections with an explicit rootCollection: null. The search filter needs no such clause, as its notIn already matches entities with no rootCollection.

Hide only what shouldn’t be published. An entity a request lacks the licence for should stay Visible and be Denied, so its Record still says where to apply; see Licence checks against an external authority.

When true, an Anonymous request for a Hidden entity gets 401 UNAUTHORIZED instead of 404, in case logging in makes it visible. Authenticated requests still get 404, and missing entities always do. It applies to /entity/:id, /entity/:id/metadata and /file/:id. The trade-off is described under Hidden is 404, unless you prompt a login.

It requires isAuthenticated; registration throws promptLoginForHidden requires isAuthenticated without it. See Error responses for the full set of outcomes, and Logged-in only for using it to hide a whole repository from anonymous visitors.