Feature Areas - Hierarchical Authz Grouping
Group features under an area folder with _area.ts. The area declares a route prefix, admission lanes, and an authz vocabulary that only its own subtree may reference.
Group related features under one area folder. The area's _area.ts file declares a route
prefix, admission lanes, and an authorization vocabulary — relationships, roles, rules, tenant
anchors, scope kinds — that every feature inside the folder inherits by name. Features
outside the folder cannot reference any of it: the compiler fails the build.
Naming note: this concept is an area.
domainin Quickback always means a DNS custom domain (see custom domains).
Why
Cross-cutting authz for a concept like a project, an event, or a location used to live in the
root quickback.config.ts and was wired to its consumers by a path-prefix string across a flat
feature list. An area makes the grouping structural: the folder is the boundary, the
_area.ts is the single declaration site, and the compiler enforces that the vocabulary
never leaks outside the subtree.
Layout
quickback/features/
├── workspace/ ← AREA (has _area.ts)
│ ├── _area.ts ← defineArea({...})
│ ├── projects.ts ← the area folder may itself be a feature
│ ├── project_collaborators.ts
│ ├── tasks/ ← child feature
│ │ ├── tasks.ts
│ │ └── actions/addTask.ts
│ └── chat/ ← child feature
│ └── actions/postMessage.ts
└── billing/ ← flat feature (unchanged)Reserved sub-directories (actions/, handlers/, lib/, pages/) are never child features. A directory
without _area.ts is a leaf feature — its unreserved subdirectories are not loaded, and a
subdirectory containing defineTable(...) / defineAction(...) fails the compile (silent
route/table disappearance after a mis-move is the failure mode areas must never introduce).
Feature leaf names stay globally unique — they key src/features/<name>/ in the output, so
moving a feature into an area changes nothing about its generated identity.
_area.ts
// features/workspace/_area.ts
import { defineArea } from '@quickback/compiler';
export default defineArea({
// Namespace identity — drives the emitted routes file name and errors.
name: 'projectScope',
// Inherited route mount + admission gate. Optional: an area with no
// prefix contributes shared vocabulary only. A prefix REQUIRES at least
// one `via` lane (a mounted prefix with no gate would be ungated).
prefix: '/project/:projectId',
via: ['collaboratorOf', { roles: ['admin', 'owner'] }],
mintScope: true,
acceptScope: 'project',
// Area-owned vocabulary — the EXACT same shapes as the root authz blocks.
relationships: {
collaboratorOf: {
from: 'project_collaborators',
subject: { column: 'collaboratorUserId', equals: 'ctx.userId' },
resource: { column: 'projectId' },
where: { status: 'active' },
loads: 'projects',
exposeAs: 'project',
},
},
roles: {
projectStaff: { or: [{ via: 'collaboratorOf' }, { roles: ['admin', 'owner'] }] },
},
scopes: {
project: {
requestField: 'projectId',
roles: { collaborator: { via: 'collaboratorOf', subject: 'both' } },
},
},
// Named gates + tenant anchors work too — same shapes as authz.rules /
// authz.tenants, visible to this subtree only.
});A descendant feature references everything by bare name — no imports, no root declaration:
// features/workspace/tasks/actions/addTask.ts
export default defineAction({
path: './tasks/add', // relative → /project/:projectId/tasks/add
access: { roles: ['projectStaff'] }, // resolves against the area
// ...
});The visibility contract
An area's names are visible to its own subtree only (the area folder, its features, and
nested areas). The compiler checks every reference channel — access roles: arrays
(including named gates and both spellings of scoped roles: bare aliases and
scope:<kind>:<role>), action access and anchor:, firewall via: arms,
ctx.scope.<kind> accessors, { rule } and { tenant } firewall references, auth-view
tenant: includes, realtime.requiredRoles, table-level namespace.via lanes, and other
_area.ts nodes. Referencing an area name from outside is always a compile error:
Feature "billing" ... references role "projectStaff" declared by area "projectScope"
(features/workspace/_area.ts), but this site is not inside that area. ... move the
referencing code under the declaring area, or lift the declaration to root authz if it
is genuinely global.Two structural rules keep the fold sound:
- No overrides (v1). Re-declaring an inherited name — in any class, including scope kinds,
scoped-role aliases, and namespace names — is a compile error. Declare each name in exactly
one place. (
overrides:marker syntax is reserved for a future release.) - Declaration-site rule. Root
authzbodies (roles, rules, tenants, scopes, namespaces) may reference root-declared names only — a root definition is visible everywhere and would launder an area name out of its subtree. An area's bodies may reference root ∪ ancestors ∪ self.
One deliberate exception: root-config namespace-identity wiring — today exactly
realtime.wsTicket.namespace — may name an area's namespace. It attaches the area's complete
gate (its full lane set) and constructs no new grant. Such consumers are listed in the
manifest under the owning area.
Containment and the route: 'self' marker
The area's prefix is an audited surface:
- A feature outside the area may not declare an action path (or a table-level
namespaceprefix) under the area's prefix — compile error. - A project-level
authz.namespacesentry the area does not own — root-declared, or another area's — may not carve a prefix at or under the area's prefix. Namespace claiming is longest-prefix-wins, so such an entry would silently re-gate the area's descendant actions with its ownvialanes — compile error. (A descendant feature's table-level namespace inside the prefix is legal; the manifest attributes its claimed actions to that namespace, not to the area.) - A descendant action whose absolute path is outside the prefix opts out of the area's
admission gate and is gated solely by its own
access:. That must be explicit: mark the actionroute: 'self', or the compile fails (a typo'd prefix must never silently self-gate). The marker is equally rejected where it is dead — on in-prefix actions and in features outside any mounted area. Every self-gated action is listed in the manifest.
Relative paths (path: './…') resolve against the area prefix. Dot segments (. / ..) are
rejected outright — the resolved route must stay under the prefix.
Nested areas
Areas nest to arbitrary depth. A nested area may declare additional vocabulary freely. A
nested area may not mount its own prefix under an ancestor that already mounts one:
per-level nested admission is unlinked (a caller related to child instance S under parent A
would be admitted at /a/A/s/S even when S belongs to parent B), and Quickback refuses to
compile an unlinked gate. Mount one prefix per chain, or keep the nested area
vocabulary-only.
The capability floor: requires: ['feature-areas']
An area-tree project must declare the marker in quickback.config.ts:
export default defineConfig({
// ...
requires: ['feature-areas'],
});Any CLI — including one that predates areas — passthrough-executes the config, so the marker
always reaches the compiler. A current compiler that sees the marker but no areas payload
rejects the compile with an upgrade message instead of silently compiling a project whose
nested features an old CLI never loaded (which would otherwise generate destructive
migrations). In the other direction, a current CLI refuses output from a compiler that
predates areas.
The authz manifest
Area projects emit quickback/authz-manifest.json on every compile — the audit inventory the
flat config can no longer answer from one file: the area tree, every folded name and its
declaring file, per-area member features, areaGatedActions vs selfGatedActions, and any
root-config namespace consumers. Review it in PRs the way you review the security reports;
the generated AGENTS.md also gains a feature-areas section so coding agents place new
features correctly.
What belongs at root
Root authz remains the home for genuinely global vocabulary — names used across areas or by
flat features. Everything scoped to one concept belongs in that concept's _area.ts. When in
doubt: if only one folder's features reference it, it belongs in that folder's area.
feature() — the canonical form
One function call per feature file — declare the table and its security contract together. This is the way to write Quickback features.
Firewall - Data Isolation
Automatically isolate data by user, organization, or team with generated WHERE clauses. Prevent unauthorized data access at the database level.