Permissions
Every /meta response carries a crud field that advertises which built-in CRUD operations the controller exposes. UI clients read it to decide which edit / delete / insert affordances to render.
type TCrudOp = "query" | "pages" | "one" | "geo" | "insert" | "update" | "replace" | "remove";
type TCrudPermissions = Partial<Record<TCrudOp, string[]>>;2
- Key absent → operation is denied / not exposed.
- Key present → operation is allowed; the
string[]value is the accepted UniQuery control whitelist for read ops ([]for write ops, which take no controls — presence still signals "allowed").
A typical writable controller emits all seven keys:
{
"crud": {
"query": [
"filter",
"insights",
"skip",
"limit",
"count",
"sort",
"nulls",
"select",
"search",
"index",
"fuzzy",
"vector",
"threshold",
"rank",
"with",
"actions",
"groupBy",
"rowOrder"
],
"pages": [
"filter",
"page",
"size",
"sort",
"nulls",
"select",
"search",
"index",
"fuzzy",
"vector",
"threshold",
"rank",
"with",
"actions"
],
"one": ["select", "with", "actions"],
"insert": [],
"update": [],
"replace": [],
"remove": []
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Default emission per controller class
| Class | Emitted keys |
|---|---|
AsDbReadableController | query, pages, one; geo when geo-searchable |
AsDbController | inherits + insert, update, replace, remove |
AsValueHelpController | query, pages, one |
AsJsonValueHelpController | query, pages, one |
geo advertises the /geo endpoint. It is emitted only when /meta reports geoSearchable: true — the adapter supports geo search and the table declares a @db.index.geo index — and is absent otherwise (so AsDbController inherits it on the same condition). Value-help controllers never emit it.
The read-op control whitelists are static per handler. The query, pages and one lists are importable as constants (the geo list has none):
import { QUERY_CONTROLS, PAGES_CONTROLS, ONE_CONTROLS } from "@atscript/moost-db";| Op | Controls |
|---|---|
query | filter, insights, skip, limit, count, sort, nulls, select, search, index, fuzzy, vector, threshold, rank, with, actions, groupBy, rowOrder, from |
pages | filter, page, size, sort, nulls, select, search, index, fuzzy, vector, threshold, rank, with, actions |
one | select, with, actions |
geo | filter, insights, center, maxDistance, minDistance, index, select, skip, limit, page, size, with, actions |
actions is the URL-control name for $actions=true — when the caller asks the server to compute per-row action availability.
Handler methods per op
Since 0.1.143 the handler method(s) serving each op are exported, so a permission layer can authorize a crud entry exactly as its route is authorized (an op is allowed when any of its handlers is):
import { DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS } from "@atscript/moost-db";| Op | DB_CRUD_HANDLERS (AsDbReadableController / AsDbController) | VALUE_HELP_CRUD_HANDLERS |
|---|---|---|
query | query | runQuery |
pages | pages | runPages |
one | getOne, getOneComposite | runGetOne, runGetOneComposite |
geo | geo | — |
insert | insert | — |
update | update | — |
replace | replace | — |
remove | remove, removeComposite | — |
Both maps are frozen. A readable serves only the read ops.
Read-only check
readOnly was removed in favor of crud. Derive the boolean inline:
const isReadOnly =
!("insert" in meta.crud) &&
!("update" in meta.crud) &&
!("replace" in meta.crud) &&
!("remove" in meta.crud);2
3
4
5
Discoverability only — not a security boundary
The crud field tells the UI what to render. It does NOT stop a client from hitting the underlying route. Real per-principal enforcement is the job of the upcoming ARBAC package, which wires the same permission set into the dispatchers and keeps /meta in sync automatically.
Relational predicates
Since 0.1.147. A client relational predicate (ticket=$some(status=open)) filters the parent rows by their related rows — so the count of returned issues tells the client something about tickets. moost-db accepts one only under this rule:
A predicate on relation
nreveals nothing that$with=n(<same filter>)would not reveal under the same controller policy — same visibility, same field rules on the related table, same row overlay — and the relation opted in with@db.rel.filterable.
Every predicate in the URL filter and in $with sub-filters is checked before anything runs. Each failure is a 400 with the validation envelope:
| Check | Rejection message |
|---|---|
The relation is visible — hasField at its path | Unknown field "ticket" (a hidden relation answers like a nonexistent one) |
| It is a navigation property | "$some" / "$none" are only valid on a navigation relation — "title" is not one |
| It opted in | Filtering by related "ticket" rows is not permitted — add @db.rel.filterable to enable. |
| Its table is available | Filtering by related "ticket" rows is not possible — the related table is not available. |
Each operand field is visible at its full path (hasField("ticket.code")) and filterable under the related table's rules | the related table's own message, with the full path: Unknown field "ticket.secret", Filtering on field "ticket.code" is not permitted — field is @db.writeOnly., encrypted and JSON-storage rejections, … — add @db.column.filterable to enable. in 'manual' mode |
Nested predicates pass the same checks at their full path (ticket.team) | as above |
| One relation per level | "ticket.team" is a navigation path — nest the predicates, one relation per level: ticket=$some(team=$some(…)) |
At most 3 nested levels per chain, at most 8 predicates in the whole request ($with sub-filters included; $with hops do not count as levels) — the client's predicates only | Relational predicates nest at most 3 levels deep ("ticket.team.tickets.issues"), At most 8 relational predicates per query |
Not in $having | 400 |
Each rejection carries a request error code (since 0.1.175): UNKNOWN_FIELD for a hidden relation, NOT_A_RELATION for a field that is no navigation property, RELATION_NOT_FILTERABLE ({ relation }) for a relation that did not opt in or whose table is unavailable, the related table's field codes for an operand field, NAVIGATION_PATH ({ field, op: "filter" }) for a dotted navigation path, and INVALID_QUERY for the limits.
A @db.column.derived operand field whose source is hidden answers Unknown field, as at the root. An adapter without predicate support answers REL_FILTER_NOT_SUPPORTED as a 400.
Row overlay. Filtering on visible fields is not enough when the caller may see only some related rows — a ticket of another team must neither make $some true nor $none false. Override transformRelationFilter to AND the related table's row scope into each operand; $none then means "no visible related row matches", exactly what $with would show.
Identification. A predicate never identifies a row: /one?…, DELETE /?… and action ids accept identifications only.
Server-side filters are not gated. transformFilter, transformOne and actionRowScope may use predicates on any relation, without the opt-in or the overlay — they are the authorization rule. The same holds for row scopes a validateControls override conjoins into $with entry filters: the gate judges the client's $with tree as it was before validateControls ran, and the overlay rewrites only the client's predicates. Such an override must wrap the client's filter object (entry.filter = { $and: [scope, entry.filter] }), not copy or drop it — with transformRelationFilter overridden, a client $with predicate that is no longer found answers 500 rather than run without its overlay. Server predicates do not count against the client's 8-predicate budget; the core's own limits (4 levels, 16 predicates) leave room for them.
Permission layers. prepareRequest receives the parsed client filter as ctx.filter, so a policy can resolve the relations its predicates touch alongside ctx.controls.$with (check ctx.hasRelationFilters first — reading ctx.filter copies it). With @aooth/arbac-moost, a client predicate on relation R is allowed exactly when the request's $with relation policy allows R, and the related table's row scope is conjoined into the operand through transformRelationFilter — see the Aooth docs.
Aggregates
Since 0.1.168 grouped queries carry three more shapes; each is gated like the parts of a plain read it resembles:
| Shape | Gate |
|---|---|
A condition — sum(amount|status=paid):paid | Its fields are filter fields: hasField, @db.writeOnly, encryption and the table's filter policy apply exactly as to the request filter. A hidden field answers Unknown field, like a nonexistent one. |
A field list — countDistinct(a,b) | Each field is an aggregate field, checked on its own. |
A two-level aggregate — $from=( … ) | The inner level is gated like a standalone grouped query. A hidden field in either level answers Unknown field, never AGG_NESTED_NOT_NUMERIC, so the error does not reveal a hidden field's type. validateControls also runs for the inner level. An outer condition or order ($where, $sort, $rowOrder) on an inner $groupBy field meets that field's filter / sort policy, as the same condition or order would on the table. |
To turn two-level aggregates off — for every caller or per request — override nestedAggregates():
protected override nestedAggregates(): boolean {
return currentScopes().allowsNestedAggregates // a 400 AGG_NESTED_NOT_SUPPORTED otherwise
}2
3
/meta.nestedAggregates follows it when it is constant; a per-request answer should prune /meta in applyMetaOverlay too. Value-help endpoints, /pages and query targets have no aggregates: $groupBy, $from, conditions and field lists are a 400 there.
Relationship to actions[]
crud and actions[] are sibling fields on /meta with distinct dispatch paths:
crud[op]→ typed client methods (client.query(),client.insert(), …).actions[]→Client.action(name, pk?). POST-locked, single-PK-per-call.
See Actions for the actions wire shape.
Action authorization at a glance (since 0.1.147)
- Per-row action scope.
actionRowScope(action, ctx)receives the candidate rows, so a scope may be derived from them. On the action route it now runs after the request body is read (it needs the ids);prepareRequestand the row overlay still run before it. - Query targets. "Every row matching the query" resolves under the row overlay AND
queryTargetScope(action)— by default the read scope (transformFilter({})) — so a caller can target only rows they could list and may act on.queryTargetScopeand the query's validation run as a read (prepareRequest({ endpoint: "query" })in a child of the action request): yourprepareRequestsees a read there, and its per-request state (a read grant, field visibility) applies to the target without touching the action request's. Refuse the read (throw 403) and the caller can still act on ids, never on "all matching". Throw fromqueryTargetScopeto refuse query targets otherwise. - Every read path runs
prepareRequest./query,/pages,/one, a query target andresolveQuery(since 0.1.149) all call it withendpoint: "query"(or the endpoint's own name) before any read hook, so field visibility and row scopes built there apply to all of them. Route interceptors and guards on a route do not run for a query target's orresolveQuery's read — put read authorization inprepareRequest. The calling command's own route guards protect the call. - Actions listed on a view.
@DbActionsFromactions are authorized by their source controller, evaluated as itself: itsprepareRequest,allowedActions, row overlay andactionRowScopedecide/meta,$actionsand execution. The view'sapplyMetaOverlaynever sees them. A view's query target for such an action runs the source's route per batch inside the same request (the source's guards see each batch's own body), so the source re-checks every batch; source hooks share the request'sFOR_EVENTinstances.