Skip to content

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.

typescript
type TCrudOp = "query" | "pages" | "one" | "geo" | "insert" | "update" | "replace" | "remove";
type TCrudPermissions = Partial<Record<TCrudOp, string[]>>;
  • 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:

json
{
  "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": []
  }
}

Default emission per controller class ​

ClassEmitted keys
AsDbReadableControllerquery, pages, one; geo when geo-searchable
AsDbControllerinherits + insert, update, replace, remove
AsValueHelpControllerquery, pages, one
AsJsonValueHelpControllerquery, 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):

typescript
import { QUERY_CONTROLS, PAGES_CONTROLS, ONE_CONTROLS } from "@atscript/moost-db";
OpControls
queryfilter, insights, skip, limit, count, sort, nulls, select, search, index, fuzzy, vector, threshold, rank, with, actions, groupBy, rowOrder, from
pagesfilter, page, size, sort, nulls, select, search, index, fuzzy, vector, threshold, rank, with, actions
oneselect, with, actions
geofilter, 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):

typescript
import { DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS } from "@atscript/moost-db";
OpDB_CRUD_HANDLERS (AsDbReadableController / AsDbController)VALUE_HELP_CRUD_HANDLERS
queryqueryrunQuery
pagespagesrunPages
onegetOne, getOneCompositerunGetOne, runGetOneComposite
geogeo—
insertinsert—
updateupdate—
replacereplace—
removeremove, 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:

typescript
const isReadOnly =
  !("insert" in meta.crud) &&
  !("update" in meta.crud) &&
  !("replace" in meta.crud) &&
  !("remove" in meta.crud);

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 n reveals 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:

CheckRejection message
The relation is visible — hasField at its pathUnknown 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 inFiltering by related "ticket" rows is not permitted — add @db.rel.filterable to enable.
Its table is availableFiltering 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 rulesthe 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 onlyRelational predicates nest at most 3 levels deep ("ticket.team.tickets.issues"), At most 8 relational predicates per query
Not in $having400

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:

ShapeGate
A condition — sum(amount|status=paid):paidIts 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():

typescript
protected override nestedAggregates(): boolean {
  return currentScopes().allowsNestedAggregates // a 400 AGG_NESTED_NOT_SUPPORTED otherwise
}

/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); prepareRequest and 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. queryTargetScope and the query's validation run as a read (prepareRequest({ endpoint: "query" }) in a child of the action request): your prepareRequest sees 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 from queryTargetScope to refuse query targets otherwise.
  • Every read path runs prepareRequest. /query, /pages, /one, a query target and resolveQuery (since 0.1.149) all call it with endpoint: "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 or resolveQuery's read — put read authorization in prepareRequest. The calling command's own route guards protect the call.
  • Actions listed on a view. @DbActionsFrom actions are authorized by their source controller, evaluated as itself: its prepareRequest, allowedActions, row overlay and actionRowScope decide /meta, $actions and execution. The view's applyMetaOverlay never 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's FOR_EVENT instances.

Released under the MIT License.