The collections API
REST endpoints a collection opts into.
A collection can serve itself over HTTP. Add one option to the definition, and ohne ships REST endpoints for it. Reads take the URL query grammar, and writes run the same validation as the query builder.
import { defineCollection, field } from 'ohnejs';
export default defineCollection({
api: { read: 'public', create: true },
fields: {
title: field('text'),
views: field('integer'),
},
});GET /collections/posts?where={views:{atLeast:100}}&order=[-views] now returns the matching records as JSON, to anyone, because read is 'public'. Creating needs a signed-in user who has the collection.Posts.create capability, because an exposed operation is guarded unless you mark it public. A collection without api has no endpoints at all.
The routes
The URL segment is the collection's kebab-case name: Posts serves at /collections/posts, UserGroups at /collections/user-groups.
GET /collections/posts list records
POST /collections/posts/query the same list read, query in the body
POST /collections/posts create a record
GET /collections/posts/[uuid] read one record
PATCH /collections/posts/[uuid] update one record
DELETE /collections/posts/[uuid] delete one record
POST /collections/posts/[uuid]/duplicate copy one record into a new one
POST /collections/posts/verdicts ask which records a write may touch
GET /collections/posts/[uuid]/translations list the locales holding a translation
POST /collections/posts/[uuid]/translations/copy copy one locale's translation onto another
DELETE /collections/posts/[uuid]/translations delete one locale's translationThe translation routes exist only on collections with translatable fields. On any other collection they answer the same 404 as an unknown collection.
These are ordinary routes shipped by the ohnejs/base layer, so they work like every other route:
api.basePathprefixes them.- Your app overrides one by shipping the same route id.
disable: { routes: ['/collections/**'] }drops all of them.
Reading
The list endpoint accepts the whole URL query grammar: where, select, order, populate, limit/offset or page/perPage, and locale.
- With
pageorperPage, it answers a paginated object:records,total,page,perPage, andlastPage. Otherwise it answers a plain array of at mostmaxLimitrows (2000by default). Page withlimit/offset, or namepageto get thetotal. perPagedefaults to20when onlypageis named.
A query that is too long for a URL goes in a JSON body instead. POST /collections/posts/query takes the same top-level keys and runs the same read:
POST /collections/posts/query
{ "where": { "views": { "atLeast": 100 } }, "order": ["-views"], "limit": 20 }The by-UUID read returns one record. Only select, populate, and locale shape it. A filter or window param is a 400, because the UUID already selects the row. No matching record is a 404.
A record of a translatable collection carries _translations in every read and write answer: the locales where it has a translation. Under an access scope it lists only the locales where the scope allows the record, so a translation the scope hides never shows.
Writing
POST creates a record from a JSON body and answers 201 with the stored record. Defaults are filled and sanitizers have run, so it is exactly what a later read returns.
POST /collections/posts
{ "title": "Hello", "views": 0 }PATCHupdates one record and answers with its final state.DELETEanswers204.- A translatable collection writes one locale at a time:
?locale=deon the create or update writes that locale's values. POST /collections/posts/[uuid]/duplicatecopies a record and answers201with the copy. Its JSON body sets fields on the copy,{}for none. It needsreadandcreate, and runscreate'saccessonce per locale it writes.
Failures have the same shapes as in the rest of ohne:
- A validation failure is a
422with translated messages keyed by field path, exactly as writing records reports them. - A malformed query is a
400with a stablecodeandpath, as querying over HTTP describes. A malformed body is a plain400. - A delete blocked by a
restrictreference is a409. - A busy database is a
503withRetry-After.
A write-only field never comes back in a response unless the operation's access scope names it. If a query names one, or a write body names an immutable or writable: false field, the request is rejected as if the field did not exist.
Translations
The translation routes manage a record's locales as units, on a collection with translatable fields:
GET /collections/posts/[uuid]/translationsanswers{ locales }: the locales where the record has a translation, in the configured order. It is the same list as its_translations, so a read scope whoseselectleaves_translationsout answers404.POST /collections/posts/[uuid]/translations/copycopies a translation. A JSON body,{}or{ source }, names the locale to copy from,?locale=names the target, and it answers the target's new state.DELETE /collections/posts/[uuid]/translationsremoves all of one locale's values.?locale=dedrops the German translation, and the record and every other locale stay. It answers204, or404when the record had no translation there.
Each one runs under an operation's rules: the GET under read, the copy under update, and the DELETE under delete. The copy runs update's access twice: first with an empty input to reach the source record, then with the values it is about to write.
Exposure
An exposed operation is guarded by default: the request needs a signed-in user whose capabilities cover collection.<Name>.<operation>. api: true opens every operation, all of them guarded. An object opens operations one by one, and any operation it does not name stays closed:
import { defineCollection, field } from 'ohnejs';
export default defineCollection({
api: {
read: 'public',
create: true,
update: { middleware: ['audit'] },
},
fields: {
title: field('text'),
views: field('integer'),
},
});An operation is one of:
true- guarded.'public'- open to anyone.- An object of options: -
public: true- the same as'public', in the object form. -middleware- middleware to run after the guard, in order, after the global ones. A middleware that returns a value answers the request, and the operation never runs. -access- narrows the operation to the records and fields a request may reach.
public and middleware work together: { public: true, middleware: ['require-auth'] } skips the capability guard but still requires a signed-in user: any account, no role needed.
read covers every read endpoint. An unknown collection, an unexposed one, and a closed operation all answer the identical 404, so the API never reveals what exists.
A singleton opens read and update only. Its create, delete, and translation delete routes always answer that 404, and its list returns the one record.
Access
The guard decides whether a caller may run an operation at all. access decides which records and fields the operation reaches. It is a function on the operation. It runs once per request, after the guard and the middleware. Asking before a write runs it without the middleware. What it returns is applied to every query the operation runs:
import { defineCollection, field } from 'ohnejs';
import { useUser } from 'ohnejs/auth';
export default defineCollection({
api: {
read: 'public',
update: {
access: async () => {
const user = await useUser();
return user ? { where: { author: user.UUID } } : false;
},
},
},
fields: {
title: field('text'),
author: field('record', { collection: 'Users' }),
},
});Anyone can read posts. A signed-in user can update only the posts they wrote. The where is added to the update with AND, so a PATCH on someone else's post answers the same 404 as a missing record. A record outside the scope never answers 403. It looks exactly like a missing one, so the API never reveals what the caller cannot reach.
What access returns decides:
trueruns the operation without a scope, exactly as if the option were omitted.falserefuses it with that same404.- A scope object narrows it.
The scope
A scope object carries these keys:
where- a filter in the object form that the URLwheretakes, with the collection's fields as keys. It is added to every request with AND, so a request can filter further but can never escape it.select- the fields the request may reach. A read returns those fields, and a request's ownselectcan only choose among them. On an update the same list bounds the body: a field outside it answers422exactly as awritable: falsefield does, and the answered record carries only the scoped fields.limit- the maximum for a list read'slimit/offsetwindow. The request's ownlimitcan only lower it, and themaxLimitguard caps it. A paginated read takes its size fromperPageinstead, which themaxPerPageguard limits.locale- the locale a read uses when the request names none. It is only a default, so a request can still name another locale. On a collection with no translatable field, it does nothing.
A field outside select is refused in where, order, select, and populate exactly as a field that does not exist. That includes UUID, so name it when clients address rows.
A read scope applies to every read endpoint. An update or delete scope decides which rows the write may touch: a row outside where answers 404 as if it did not exist. A client can ask before it writes. A create has no rows to filter, so its where does nothing, and its select still narrows the answered record.
The filter checks the row as it is stored, so an update body can move a row out of the scope, for example when an author gives a post to someone else. Protect such a field through the scope's select, or lock it with writable: false. The shipped Users collection protects its roles with the grant rule.
When another collection's endpoint populates or probes this one, this collection's own read exposure, guard, scope, and middleware decide what comes back. If one of its middleware answers, this collection cannot be reached that way.
A link you do not populate is different. The UUID in a post's author is the post's own data, so it reads as stored even when you cannot read that user.
Writing a link is a read too. A record, records, or upload field in a body accepts a UUID only if you could read that record through its own collection's endpoint. Otherwise the write fails with 422 invalidReference, the same answer as for a UUID that does not exist, so a write never reveals what you cannot read. These links only need to exist:
- A value from the field's
default, since you did not send it. - A link the record already holds, sent again in the same field. The same
UUIDin another field is a new link. - A link to your own user.
A duplicate copies the source's links as new links, so each must be one you can read.
A where on translatable fields matches per locale, so it can allow a record in en and hide it in de. The endpoints then reduce the record's _translations to the allowed locales. They also refuse a request's filter on _translations with invalidField, because it would reveal the hidden ones. A where on plain fields gives the same answer in every locale, costs no extra read, and keeps the filter.
Who is asking
access receives a context naming the operation. The caller is not passed in: read it with useUser, requireUser, and userCan from ohnejs/auth, exactly as in a handler. This way a rule is ordinary code. In this example:
- the author, any listed editor, or the author's manager may edit,
- only the author may delete,
- only the author may give a post to someone else,
- a
posts.managecapability bypasses the editing rule.
import { defineCollection, field } from 'ohnejs';
import { requireUser, useUser, userCan } from 'ohnejs/auth';
export default defineCollection({
api: {
read: true,
create: {
access: async ({ input }) => {
const me = (await requireUser()).UUID;
return !('author' in input) || input.author === me;
},
},
update: {
access: async ({ input }) => {
const user = await requireUser();
if (userCan(user, 'posts.manage')) return true;
const me = user.UUID;
if ('author' in input) return { where: { author: me } };
return {
where: {
or: [
{ author: me },
{ editors: { has: { UUID: me } } },
{ author: { has: { manager: me } } },
],
},
};
},
},
delete: { access: async () => ({ where: { author: (await requireUser()).UUID } }) },
},
fields: {
title: field('text'),
author: field('record', {
collection: 'Users',
default: async () => (await useUser())?.UUID ?? null,
}),
editors: field('records', { collection: 'Users' }),
},
});manager is a record field to Users that your own Users collection declares. editors is a records field, so has matches a listed editor, and author reaches the author's own manager.
author defaults to the signed-in user, and the create rule lets a body name only the caller, so a create gets its owner. An update that names author is limited to the author's own rows, so when an editor tries to reassign a post, the answer is 404. Lock the field with writable: false instead when nobody may change it.
To name other users in editors, the writer needs a read on Users.
Give a bypass a name outside the collection. prefix. collection.Posts.* covers every name under it, including collection.Posts.manage, so a role meant for plain editing would have the bypass too.
The write input
A create or update carries input in the context: the JSON body as the request sent it, before validation. A rule can judge the write itself, not only the row it changes. The example above lets only the author reassign author, and with input.status === 'published' a rule can require a manager before a post is published. A read or delete carries no input. Its context names only the operation, so one function can serve every operation by branching on operation.
Asking before a write
An update or delete scope shows only when the write answers 404. A client that wants to know sooner, to hide an Edit button for example, names the records and asks:
POST /collections/posts/verdicts
{ "UUIDs": ["0198c3a2-7b1e-7d40-9f2a-5c1e8d3b6a10"] }It answers { update, delete }. Each holds the UUIDs that operation's scope allows, and update adds the scope's select when it has one. The answer is a label, not a promise: the write still checks for itself.
- The request runs under
read's rules and never names a record the read hides. A closed operation, a missing capability, and afalsefromaccessallow no records. - Without
UUIDsit counts instead: the body takes the list'swhere, and each operation answers atotal. localein the body is the locale the update writes.deletealways checks the default locale, as the record delete does.- A translatable collection also answers
deleteTranslation: the translation delete atlocale.
Asking runs access with an empty input and without the operation's middleware, so a question never counts against a write's rate limit. Read the caller as a handler does, never from something a middleware set. An access that throws an HTTP error, like requireUser without a user, allows no records.
The dashboard asks this for every collection whose update or delete has access, so an editor sees Edit and Delete only where the rule allows.
Your own routes
access applies only to the shipped endpoints. A query in your own route is trusted and has no scope. To apply the same policy to a route, open the query through queryScoped from ohnejs/auth. It runs the guard and access exactly as the collections API does, and returns a builder to query through:
- A closed operation or a
falsefromaccessis a404. - A missing user is a
401, and a missing capability a403. - A read carries the whole scope. An update or delete adds the scope's
wherewith AND, exactly as the shipped endpoints do. - A read or update answer lists
_translationsonly at the locales the scope'swhereallows.
The builder takes the same object grammar as the URL where:
import { defineHandler } from 'ohnejs';
import { queryScoped } from 'ohnejs/auth';
export default defineHandler(async () => {
const posts = await queryScoped('Posts', 'read');
return posts.where({ status: 'draft' }).findMany();
});A create or update takes the input you plan to write as its third argument, so the rule can judge it. The operation's own middleware do not run here, so your route needs its own. For a rule that must apply to every read in the process, shipped or not, use a query:filter hook.