Services
Routers expose service access for custom routes and tests.
Model Router Services
ModelRouter.getService(req) returns the internal model service.
ModelRouter.getPublicService(req) returns the public wrapper used by the built-in routes.
Common public methods:
_new()_list(filter, args?, options?)_read(id, args?, options?)_readFilter(filter, args?, options?)_create(data, args?, options?)_update(id, data, args?, options?)_upsert(data, args?, options?)_delete(id)_distinct(field, options?)_count(filter, access?)
These methods are what the built-in HTTP routes call internally.
Trusted per-call authorization
Internal find/findOne/findById, updateOne/updateById and upsert accept
options.authorization with independent rows, outputFields and related
dimensions. Model create, updateOne/updateById and both upsert branches also
accept independent inputFields authority. create rejects rows; new accepts output only and
continues to ignore select, including empty/exclusion selections. Standalone
exists/count/delete/distinct retain request-policy APIs. Dedicated subdocument
methods have their own parent/child/visibility policy below. Embedded output inside
integrated parent/target results inherits that model's output field paths; there is
no recursive subdocuments tree on model-root authorization.
All authorization input is trusted server-only. Use router.getService(req) or
req.macl.getService(...) after the owning runtime's middleware initializes the request.
There is no standalone Service export. Root and /advanced expose the policy types
and opaque consumer handles, while issuers, resolved execution and prepared queries
remain private. The installed package README contains a complete runnable Mongo/Express
example and the full breaking-release migration table.
import type { ModelRouter, OutputAuthorization } from '@web-ts-toolkit/access-router';
import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
type Order = { status?: string; internalNote?: string; tenantId: string };
declare const router: ModelRouter<Order>;
declare const req: ModelRequest;
declare const orderId: string;
declare const tenantId: string; // resolved by trusted server code
const service = router.getService(req);
const output = { outputFields: { mode: 'grant', paths: ['internalNote'] } } as const satisfies OutputAuthorization<Order>;
const result = await service.findOne(
{ _id: orderId },
{ select: ['status', 'internalNote'] },
{ skim: true, includePermissions: false, includeFieldPermissions: false,
authorization: { ...output, rows: { mode: 'scope', filter: { tenantId } } } },
);
if (result.success) {
const reuse = { documentAuthorization: result.context.documentAuthorization };
const picked = await service.pickAllowedFields(result.data, 'read', reuse);
await service.trimOutputFields(picked, 'read', reuse);
}
Business output is selected AND (request permission OR explicit grant). Grants are
additive: the stored selected note survives false/missing user rules, while denied
status is omitted. A granted-but-unselected note is omitted too. Missing values are
never fabricated or made required by a grant. Omitted select uses normal request fields
plus named grants; unrestricted uses the captured persisted/registered-virtual catalog.
Explicit exclusions and requireExplicitSelect still apply.
Grant paths use ordinary dotted response paths such as contacts.email, without
sub markers, signed tokens, indices or wildcards. Readonly path arrays are accepted.
Known model/virtual shapes are checked; unknown models use a runtime-validated string
fallback. A container grants its selected eligible subtree; a leaf grants only its
branch/minimal ancestors, not siblings. Schema select: false is eligible, while
identity/permission metadata and implementation fields retain separate rules.
Mutation-only input grants
Use MutationAuthorization<TModel, TVirtuals> for updates/upserts and
CreateAuthorization<TModel, TVirtuals> for creates. ServiceAuthorization remains
read-only. InputFieldAuthorization<TModel> / InputFieldPath<TModel> are exported
from root and /advanced; input paths never use the virtual generic.
import type { MutationAuthorization } from '@web-ts-toolkit/access-router';
import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
type Order = { internalNote?: string; contacts: { email: string; ssn: string }[] };
declare const req: ModelRequest;
declare const orderId: string;
const authorization = {
inputFields: { mode: 'grant', paths: ['internalNote', 'contacts.email'] },
} as const satisfies MutationAuthorization<Order>;
const result = await req.macl.getService<Order>('Order').updateById(orderId, {
internalNote: 'server decision', contacts: [{ email: 'corrected@example.test' }],
}, { select: ['internalNote'] }, { authorization });
// Persisted note; no response elevation. Contact 0 keeps its denied ssn sibling.
Missing/request input policy retains request admission; grant is additive and an
empty grant adds nothing. unrestricted admits eligible persisted roots. Neither
mode grants output, rows, query fields, route guards, related operations or $$sq.
Runtime schema validation rejects unknown/virtual/identity/control paths, indices,
brackets, sub markers and wildcards. Safe Mixed/Map paths work; referenced interiors
do not. Business payload fields named authorization/overrides remain ordinary data.
Leaf writes preserve denied/omitted siblings. Object/array container authority replaces the eligible subtree (including omitted children, null/empty values, array length and order). Descendant array grants patch existing object elements by index, not ID: trailing/omitted elements remain; excess/null/scalar/hole slots cannot alter structure. No absent array creation is allowed through descendants, including on create/upsert-create. Authorize the array/ancestor container, or rely on existing request container permission, for structural writes. These rules recurse into nested and Mixed arrays. Elevated embedded replacements omit supplied IDs; normal Mongoose ID generation applies unless request authority already permits IDs.
Validation, preparation, transforms, afterPersist/change hooks and Mongoose save
remain active. Whole-container/prepared values exclude virtuals and control metadata
even in JS containers with permissive schemas. Pre-write update document grants see
schema-hidden embedded input and remain private and separate from fresh
post-write output grants; input _permissions never establishes authority. Metadata
continues to describe the actor. Reads/new/output helpers/related read-count targets/
public/default options reject inputFields, including reusable mutation policies.
For migration, grant the specific input path and separately derive { outputFields }
for output helpers. See the installed README for the complete contract and executable
Mongo persistence example.
Mutation payloads obey requestComplexity through class/Map contents and sparse arrays,
and cycles reject before parsing/input hooks. Admission and prepared sanitization also
cap structural depth at 100 and nodes at 20,000. Untouched persisted virtual siblings
outside the write boundary remain unchanged and excluded from ordinary output.
Dedicated parent/child authorization
Internal listSub, readSub, createSub, updateSub, bulkUpdateSub and deleteSub
accept a final options object with authorization: { parent, child, visibility }.
The named field must be a top-level embedded Mongoose document array. Child fields
and row predicates are relative to its element (email); parent rows belong to the
owning model (tenantId). Literal names infer bounded child/virtual paths. The
installed README has the complete method/applicability table and runnable Mongo example.
import type { SubdocumentMutationAuthorization } from '@web-ts-toolkit/access-router';
import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
type Order = { tenantId: string; contacts: { email: string; ssn: string }[] };
declare const req: ModelRequest;
declare const orderId: string;
declare const childId: string;
const authorization = {
parent: { operation: { mode: 'grant' }, rows: { mode: 'scope', filter: { tenantId: 'server-tenant' } } },
child: { operation: { mode: 'grant' }, inputFields: { mode: 'grant', paths: ['email'] } },
visibility: {
parent: { operation: { mode: 'grant' }, rows: { mode: 'scope', filter: { tenantId: 'server-tenant' } } },
child: { operation: { mode: 'grant' }, outputFields: { mode: 'grant', paths: ['email'] } },
},
} as const satisfies SubdocumentMutationAuthorization<Order, Order['contacts'][number]>;
const result = await req.macl.getService<Order>('Order').updateSub(
orderId, 'contacts', childId, { email: 'corrected@example.test' }, { authorization },
);
Supplying a policy (even {}) opts into independent base-operation gates:
parent read for list/read, parent update for every mutation, and the corresponding
subs.<sub>.<operation> child gate. Missing dimensions use request authority;
operation grants authorize only their own gate. No-policy calls retain historical
route-owned initiating admission. PublicService overrides reject these capabilities,
including JS/transformed inputs, so basic/advanced route guards retain their contract.
Parent accepts only operation/rows. Read/list child accepts operation/rows/outputFields;
update/bulk child accepts operation/rows/inputFields. Create child has operation/inputFields
and rejects rows. Delete accepts operation/rows only and returns the removed ID. Neither
parent nor child accepts related: populated targets keep their own request authority.
Mutation visibility is independent: visibility.parent authorizes parent read,
visibility.child child read/rows/outputFields. Create additionally requires child
list/rows via visibility.list; update/bulk reject that dimension. Missing visibility
uses request-only read/list gates. A successful hidden write returns null/[] as below.
Output grants cannot write; input grants cannot grant response visibility.
Rows replace/skip only baseFilter. The configured overrideFilter still runs once; captured parent ID, owner scope and selected child IDs/array membership are ANDed afterward, including for in-place rewriting. False remains terminal. Scope predicates stay inert and schema-validated; policy arrays, paths and predicates obey existing request complexity bounds. Unsupported dimensions and invalid paths reject before writes.
Child writes reuse model input semantics for nested containers/arrays: descendants patch existing objects by index, container authority replaces only that child's eligible subtree, and outer membership changes only through create/delete. Update document grants use the original hydrated raw parent; output resolves a fresh parent read-stage result. Virtual access is list/read/create/update, output fields are list for list and read otherwise, and document-grant access is read. Mongoose validation/ save hooks remain active; dedicated calls do not invoke root mutation hook chains on a child. Virtual/control/identity input exclusion and original actor isolation remain. Dedicated Mixed/plain writes also retain their recursive virtual-name scrub; explicit input grants to those virtual aliases reject before effects.
Root and /advanced export method-specific Subdocument*Authorization policies and
internal Subdocument*Options. Public adapters should migrate to
PublicSubdocumentListOptions, PublicSubdocumentReadOptions,
PublicSubdocumentCreateOptions and empty PublicSubdocumentMutationOptions.
Call policy and document-result reuse
One immutable call policy governs fetch planning and output. Each document/scope/access
stage owns separate hook grants and memoized field decisions. Declare projected private
hook inputs (for example ownerId) with alwaysSelectFields; full document grants resolve
once even with skim or disabled/stripped/limited metadata. _view/_edit and hook metadata
still describe request permissions, not backend grants.
Successful single results expose context.documentAuthorization; lists/create expose one
contexts[index] per row. The non-enumerable opaque handle is outside data and preserves
owner-dependent status and a granted note after ownerId and _permissions are trimmed.
It accepts only unchanged original or package-created same-stage copies, not caller spreads,
matching IDs, another row/call, or pre-/post-write stage reuse. getDocumentAuthorization(child)
looks up an unchanged registered child in the original result graph.
Named pickAllowedFields/trimOutputFields and genAllowedFields(..., { purpose: 'output', ... })
accept either raw output-only authorization or the handle, never both. Raw policy is a
fresh evaluation; a recognized trimmed output keeps explicit/unconditional grants but cannot
rerun its hook or recover lost document-dependent decisions. A fresh unassociated input needs
the projected hook inputs. Helpers filter existing values, without selection, virtual getter
execution, restoration, row checks or write authority. Named genSelect/genQuerySelect return
fresh fetch projections with private inputs, not output grants. Omitted/undefined helper
options now use the same canonical request-mode output semantics as {}; hydrated picks
are isolated, and serialized permissions never grant fields. genAllowedFields defaults
to { purpose: 'output' } and reports existing paths, not an input/schema allowlist.
Supplied objects still require that purpose; nullish documents yield [] without hooks.
requireExplicitSelect restricts omitted/undefined selection; explicit null/empty
selection retains all-candidates compatibility, still subject to field authorization.
Next breaking release: positional skipChecks/subPaths and arbitrary baseFields
are removed now, with no adapter. JS booleans/arrays/extra arguments (including explicit
undefined plus an extra argument) reject with actionable BadRequest errors; installed
TypeScript calls reject too. Use named output grants/handles and scopePath: ['contacts'].
Request/input, distinct/sort and request-user _view/_edit keep their separate evaluation.
Model input keeps scope-aware virtual-name exclusion through Mixed/plain and prepared
containers; explicit alias grants reject, while untouched persisted siblings remain intact.
Obsolete buildVirtualPlan/buildSubVirtualPlan service builders are removed in favor of
the named fetch helpers; retained execution plans and system retention remain private.
genPopulate(access?, populate?, options?) takes request-only PopulatePlanningOptions
with ordinary response scopePath segments. Source fields, target operation/rows and
field checks remain request-only; private source fetch does not authorize population.
Use service populate plus authorization.related when backend target authority is needed.
DataCore/DataService field helpers instead accept DataFieldOptions (root and
/advanced), with only readonly scopePath and {} defaults. They retain the request-only
data evaluator, with no Mongoose catalogs, model document hooks or new DataService authority.
Allowed-fields is still a schema allowlist (even with null input); pick/trim retain allowed
existing values, with whole-container behavior and no automatic identity/metadata retention.
Data selection is the intersection of normalized requested tokens and request fields.
For compatibility, empty intersections—including omitted/null/empty, exclusion-only and
denied-only selections—mean no extra projection after request trimming in find/findOne.
Old data skipChecks never elevated fields; arbitrary baseFields elevation is removed
and unsupported. See the shipped README's named-helper migration table and data example.
Row and related target scope
| Row mode | Effective predicate |
|---|---|
| request (default) | transformed ordinary query AND request baseFilter |
| scope | transformed ordinary query AND trusted scope, replacing baseFilter |
| unrestricted | transformed ordinary query, without baseFilter |
Configured overrideFilter still runs once in every mode, including unrestricted;
its restrictions/false denial remain effective. Trusted scope and mandatory reference/join
constraints are ANDed afterward. Scope is inert (no client $$sq/$$date expansion); {}
means explicit all-row scope within those constraints. Upsert lookup scope does not stamp
values into newly created data. find.totalCount and integrated include counts reuse their
effective row scope through private prepared execution.
import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
declare const req: ModelRequest;
declare const orderId: string;
declare const tenantId: string;
const service = req.macl.getService<{ customer: string }>('Order');
const result = await service.findOne({ _id: orderId }, {
select: ['customer'], populate: [{ path: 'customer', select: ['name', 'billingEmail'] }],
}, { authorization: {
outputFields: { mode: 'grant', paths: ['customer'] },
related: { customer: {
model: 'Customer', operation: { mode: 'grant' },
rows: { mode: 'scope', filter: { tenantId } },
outputFields: { mode: 'grant', paths: ['billingEmail'] },
} },
} });
The parent grant permits the relationship only. Exact requested populate/include path and
registered target model must match; unused/ambiguous/mismatched entries reject. Child operation,
rows and output authority default independently to request mode, never parent elevation.
Reference IDs, populate match and include joins/filters still constrain all target row modes.
Nested policies belong under the immediate target's related; count entries accept only
model/operation/rows. For a subsequent child pick, use its graph handle with that target's
service. $$sq does not inherit these policies.
Virtual grants permit selected applicable getters and their private dependency fetches; missing dependencies, undefined or errors can still omit output. Output grants do not authorize writes, query/sort/distinct fields, database virtual filtering/sorting, rows or target operations implicitly. Validation, write admission and persistence hooks still apply. Trusted decorate/decorateAll/tasks run afterward and may construct/remove output; there is no automatic second authorization pass.
Route entry and service policy
Basic/advanced operationAccess guards authorize the generated endpoint before
service dispatch, with HTTP 401 on denial. They replace only its initiating guard.
Direct service calls are trusted application calls and retain their existing
checks; service methods do not universally enforce initiating operationAccess or
infer a transport variant from req.method.
Field selection, row/filter policy, validate/prepare/decorate hooks, related-model
checks, and root entry guards remain base-scoped. A permitted variant does not
grant fields or authorize a related target's advanced endpoint policy.
advancedUpsert does not additionally require advancedCreate/advancedUpdate;
its existing create/update branch policy still applies. Root entries keep base
operation names/checks, returning entry 401 inside their HTTP 200 envelope.
Important behavior
skimis deprecated and retained as a compatibility no-op. Authorization, document grants, virtuals and decoration run regardless of its value. Useleanfor hydration and the explicit metadata flags for serialization choices._read(id, args, { tryList: true })and_readFilter(filter, args, options)can fall back from an authorized read miss to list access, subject to baselistoperation access.list: false, advancedList: truecannot permit retry;list: true, basicList: falsecannot block it.tryListdefaults to true. Forbidden and BadRequest never retry. Denied route entry is distinct from an allowed read miss (ordinary 404 when retry is disabled)._update(..., { returningAll: false })trims the response to the updated fields_upsert(...)only supports the default_ididentifier
Denial versus a missing row
An ordinary false query, false identifier or rows: { mode: 'scope', filter: false }
is terminal Forbidden in every row mode, before parsing/identifier/child/persistence work
as applicable. A null/undefined scope predicate is BadRequest; omit row authority for
request-mode behavior. A transform that needs the parsed read/list predicate can deny
before primary persistence, but cannot undo subqueries already needed for parsing.
Breaking migration: the complete service overrides container (select/filter/populate/
idFilter/effectiveSelect/operation) is removed, including empty/null/undefined or mixed
new/old slots. It now returns actionable BadRequest before parsing/persistence. Use ordinary
select/query/populate plus explicit policy; custom IDs use findOne/updateOne and mutation
select is ordinary. Old object overrides.filter bypassed both filter hooks; new row modes
replace only base scope. Move replaceable user row restrictions into baseFilter when needed;
there is no transform-bypass option. See the package README for every before/after mapping.
Public _ wrappers, root/HTTP/OpenAPI/client options, defaults, populate and include/$$sq
control slots reject authorization and internal/removed controls, including after custom
validator transformations. Legitimate business properties named authorization/overrides
are still ordinary data. The configured overrideFilter hook/setter remains supported.
exists(filter, options?) applies read row policy by default (options.access
changes it). Allowed matches/misses yield successful true/false, or an { _id }
record/null with includeId: true. Explicit/base/override false returns a Forbidden
ErrorResult before adapter dispatch in both modes. This replaces the former
successful false/null denial result; check success before data.
Public reads no longer retry a terminal read denial through list policy. Public
_upsert with an _id now propagates denied update-policy existence as Forbidden
instead of Unauthorized; an allowed miss remains Unauthorized. Neither case creates
or updates a row. Generated direct routes use HTTP 403 and root batches use entry
403 within HTTP 200. Custom Express handlers own response serialization: returning
an ErrorResult or calling res.json(result) does not automatically set status 403.
Populate access selectors
Wire options.populateAccess and populate descriptor access accept only
'list' | 'read' (PopulateAccess from the existing /advanced entrypoint).
Related populate/include/subquery checks use target base operations, regardless
of the initiating basic/advanced endpoint.
The shared Core populate boundary rejects all fourteen reserved route-variant
names in effective option and per-descriptor access, including generic legacy
include/subquery options and trusted defaults, before target persistence. Other
trusted non-reserved custom access strings retain their runtime compatibility.
Invalid selectors return controlled BadRequest: direct HTTP 400 or affected root
entry 400/bad_request within HTTP 200. Legacy read/list includes now propagate
target BadRequest instead of silently dropping output, so nested invalid selectors
are visible. Existing denial/miss cardinality contracts remain.
Safe include output and exact counts
Legacy and correlated include path values are output-only. They cannot equal,
descend from, or contain the receiving model's documentPermissionField (default
_permissions). With auth.policy.permissions, auth, auth.policy and
auth.policy.permissions.canReadSecret reject; lineItemCount or the sibling
auth.policy.permissionsExtra do not overlap. Equivalent legacy bracket paths
follow the same rule. The entire supported include tree is preflighted with each
nested receiving model's setting before target persistence. BadRequest returns direct
HTTP 400 or root entry 400/bad_request within HTTP 200; malformed reads do not fall
back to list access. Ordinary output collisions remain supported.
Legacy op: 'count' uses one grouped aggregate per include execution for all parents,
counting distinct authorized target documents independently of listHardLimit and
include pagination. Duplicate local/foreign array keys or documents matching multiple
local keys do not inflate a parent's count; misses yield zero. The target schema casts
the authorized match (including tenant filters) and independent foreign operands
before aggregation, retaining original parent-key association for normalized ObjectId/String keys
and query setters. Invalid casts return controlled BadRequest rather than zero.
Terminal false count row policy retains legacy omitted output; the grouped service
returns Forbidden. Operation denials prevent target work.
Mongo returns numeric parent-group counts, never matching target-ID arrays. Internal
countByFieldValues(foreignField, values, filter = {}, access = 'list') now returns
Promise<SingleResult<Map<string, number>> | ErrorResult>, with readonly inputs and
zero for missing original String(value) aliases. Replace .get(key)?.size with
.get(key) ?? 0. Per-key counts can overlap; do not sum them for a multi-key union.
Pass count as the access for count row policy; this helper accepts no authorization
options. Parent-union execution remains private.
Both paths enforce fixed limits: 1,000 groups, 10,000 key occurrences, 64 KiB per BSON key/alias, 1 MiB cumulative BSON-key plus alias bytes, depth 8 / 20,000 key nodes, and 1,024 UTF-8 foreign-path bytes. Original and cast group operands must fit; excess returns BadRequest before aggregate dispatch. See the shipped README's “Numeric grouped-count service API (breaking migration)” for the complete contract.
Attachments are tracked separately from their original projected source. Source permission hooks/dependencies retain original values at collisions; only explicitly produced target results are restored after source filtering. A legacy read miss does not turn a denied or unselected source value into an attachment.
Casting does not execute countDocuments middleware/plugins or consume a persistence
permit; the aggregate is admitted and runs aggregate middleware. Arbitrary model
aggregate pipelines are not auto-cast. Express count authorization in router operation
and row policies. Correlated counts instead execute per parent through count semantics.
Nested updates
With only 'profile.public': { update: true }, send nested JSON
{ profile: { public: 'New display name' } }: protected/omitted siblings survive.
This holds for updateOne/updateById, existing-row upsert and single/bulk subdocuments.
Whole-parent grants permit replacement and remove omitted children; whole arrays
replace, while explicit indexed leaf grants patch only that path. Whole grants take
priority over overlapping leaves. Admitted null clears the field; an empty/null client
container supplies no descendant leaves. Literal dotted client keys are ignored by
selection, not interpreted as update operators. See Hooks for trusted
prepare/transform behavior and its omission compatibility change.
Correlated Includes
Read/list service paths (_list, _read, _readFilter, and the grouped
root entries that dispatch to them) accept correlated include entries
(mode: 'correlated') alongside legacy localField/foreignField joins.
Each correlated entry runs a target query per parent document using
{ "$parent": "<field>" } markers resolved against the immediate parent
snapshot:
{ "mode": "correlated", "model": "Org", "op": "read", "path": "org", "id": { "$parent": "orgId" } }
{ "mode": "correlated", "model": "Post", "op": "list", "path": "posts",
"filter": { "authorId": { "$parent": "_id" } },
"args": { "select": ["title"], "sort": { "createdAt": -1 }, "limit": 5 } }
{ "mode": "correlated", "model": "Post", "op": "count", "path": "postCount",
"filter": { "authorId": { "$parent": "_id" } } }
Execution notes:
- Reads attach the doc or
null, lists attach per-parent paginated arrays, counts attach numbers (count semantics, never a capped list count). Missing/nullreferences attachnull/[]/0with no target query. - Identifier reads use the target's configured identifier behavior with no read-to-list fallback; counts use explicit count access.
- Dispatch goes through the authorized target service under the active request/runtime context. Target denials fail the whole request with zero target persistence queries.
- Referenced parent fields are added to the parent DB select, then trimmed unless policy-allowed and selected. Sibling include output and decorate/task output never feed references.
- Bounds: each entry counts toward
maxIncludeCount; expanded filters are revalidated (maxNodes/maxDepth/maxInValues/maxLogicalClauses);maxCorrelatedQueries(default100) caps total inner executions andmaxCorrelatedDepth(default5) caps nesting. See Configuration. - Direct count parents carry no
include. Malformed correlated input is a controlledBadRequest, never a silent drop. See Validation.
Data Router Services
DataRouter.getService(req) returns a DataService.
Methods:
find(filter, args?, options?)findOne(filter, args?, options?)findById(id, args?, options?)
Result Shapes
Service methods return one of these shapes:
SingleResultListResultErrorResult
List results include count, and may include totalCount when count support is enabled.
Integrated internal model operations refine successful results to AuthorizedSingleResult
or AuthorizedListResult with opaque contexts. Public HTTP/root serializers omit context(s),
query diagnostics and private authority; they are not client transport fields.
Successful subdocument writes with hidden output
Create/single/bulk update response data requires post-save parent read operation/row
access. Full-array create output, including POST [], additionally intersects
subs.<sub>.list AND .read guards/row filters. Targeted single/bulk updates require
sub read only, not list access. All use read fields plus _id; visible counts and
stored array order are preserved. Bulk request order does not reorder the response;
service addFirst still governs insertion order, without adding an HTTP option.
These visibility checks default to base parent/subdocument read/list policy.
Trusted dedicated calls can explicitly configure authorization.visibility; no
mutation authority flows into it. Public routes always use request authority. Disabling
basicRead/basicList endpoints does not hide otherwise readable mutation output;
allowing an advanced read route does not override a denied base response guard.
A denied response guard, terminal false policy or no readable match hides output without failing the committed write:
| Operation | Service/root fields (all success: true) | Direct HTTP; root entry status |
|---|---|---|
| Create | kind: 'list', code: 'created', data: [], count: 0 | [], 201; 201 |
| Bulk update | kind: 'list', code: 'success', data: [], count: 0 | [], 200; 200 |
| Single update | kind: 'single', code: 'success', data: null | JSON null, 200; 200 |
Root keeps its HTTP 200 envelope. Do not retry successful empty responses. Actual write denials and validation/persistence failures retain errors. Visibility adds at most one parent query and resolves applicable row policies once per mutation.
Permissions Helper
AccessRouterPermissions is method-based:
permissions.has('isAdmin');
permissions.hasAny('posts.read', 'posts.write');
permissions.hasAll(['posts.read', 'posts.write']);
permissions.keys;