Scopes
Download .mdScopes
A scope is a named access policy. Per entity it decides three things: which verbs are allowed, which rows are visible, and which fields are exposed. You write it by name; the server stores it by stable field-id (so it survives renames) and enforces it on every request.
// weave/scopes/storefront.ts
import { defineScope, scopeRule } from "@mauroandre/weave-sdk";
import product from "../entities/product.js";
import order from "../entities/order.js";
export default defineScope("storefront", [
scopeRule(product, {
verbs: ["read"],
where: { active: { eq: true } },
fields: { exclude: ["cost"] },
}),
scopeRule(order, {
verbs: ["read", "create"],
where: { customer: { id: { eq: { param: "customerId" } } } },
}),
]);Each rule is bound to its entity by reference with scopeRule(entity, …) — the same
entity object you defineEntity'd, exactly like reference(entity). No entity name is
ever a loose string, so a typo or a rename can't silently produce a broken (over-permissive)
policy.
- verbs — any of
read·create·update·delete. - where — the same
WhereInputyou query with. It filters which rows are visible (read / update-target / delete-target) and what a write may produce: acreate,updateoraccumulatewhose resulting row would fall outside the filter is rejected (403, and rolled back). So a scope can never be used to write into — or move a row into — another tenant. - fields —
includeorexcludea set of paths (dot-paths into owned/refs).
Reaching an entity through a reference
expand and select hydrate references, so a read of one entity can reach another.
Those hops answer to the reached entity's own rule:
- verbs — expanding an entity your scope can't
readis a403, exactly as if you had queried it directly. An entity with no rule at all is denied. - fields — the reached entity's projection applies to the expanded object.
- where — does not travel. The row filter is only applied to the entity you query. A reference is followed by foreign key, not re-filtered.
That last point is the one to design around. If a rule's where is what keeps a tenant
apart, put it on the entity you query; reaching that entity through someone else's
reference will not re-apply it:
scopeRule(user, { verbs: ["read"], where: { active: { eq: true } } });
await tenant.user.findMany(); // only active users
await tenant.post.findMany({}, { expand: { author: true } }); // author may be inactiveTo hide an entity from a scope entirely, give it no rule rather than read with a
narrow fields: a denied entity fails loudly if some future expand reaches it, while a
projected one quietly grants read to every row.
Parameters
A rule can depend on request-time values with { param: "name" } — perfect for
per-tenant or per-user filtering. The where is fully typed against the entity, and
literal and { param } values mix freely in the same tree:
scopeRule(order, {
verbs: ["read"],
where: { and: [
{ customer: { id: { eq: { param: "customerId" } } } }, // a request-time param
{ status: { ne: "draft" } }, // a literal
]},
});The param names are inferred from the rules — you never declare them. defineScope
carries them into its type, so weave.as requires the params object, typed: a missing
or misspelled param is a compile error, not a runtime surprise.
weave.as(storefront, { customerId: ctx.user.id }); // ✓
weave.as(storefront, {}); // ✗ — 'customerId' is requiredActing under a scope
The god-mode key is for trusted server-side use. To serve a request as a tenant, derive a scoped client:
import storefront from "./weave/scopes/storefront.js";
const tenant = weave.as(storefront, { customerId: ctx.user.id });
await tenant.product.findMany(); // only active products, no `cost` field
await tenant.order.create(o); // allowed; rows still constrained to this customerweave.as takes the scope object (defineScope(…)) — recommended — or its name as a
string. Passing the object keeps a single source of truth, just like importing an entity.
The verbs stay the same — findOne · findMany · paginate · create · … — you
just call them on the scoped client. The API doesn't change under a scope; the server
enforces the scope's verbs, rows and fields, and the client can't widen them. Push
scopes to the server with the CLI.
Scoped per request — `scopedWeave`
weave.as(scope, params) returns a scoped client you thread by hand. In a multi-tenant
server that means passing it through every service call — and forgetting once leaks god
access. The scoped client removes the threading: it resolves the active scope from an
AsyncLocalStorage context, so your services just use scopedWeave.* and get the right
scoped client automatically.
When your project has scopes, weave gen exports it for you — no config — alongside the
plain god client, sharing one connection:
// weave/index.ts (generated)
export const weave = createClient({ ... }); // god — boot, ETL, scripts
export const scopedWeave = createScopedClient(weave); // request-scoped, fail-closedimport { weave, scopedWeave } from "./weave/index.js";
// auth runs BEFORE a scope exists → the plain god client:
const user = await weave.session.findOne({ token });
// middleware — establish the scope for the whole request, from the user's role:
app.use((ctx, next) =>
scopedWeave.runAs(scopeFor(ctx.user.role), { companyId: ctx.user.companyId }, () => next()),
);
// any service / loader inside the request — zero plumbing, already scoped:
const orders = await scopedWeave.order.findMany();Fail-closed by construction. Outside any runAs, scopedWeave.* throws
(WeaveScopeError) — never god. Forgetting the middleware, or losing the async context,
denies the request; it never silently falls back to full access. That's the opposite of
the usual "default to admin" footgun — a missing scope fails loud, not open. (The plain
weave is always god; that's why boot and pre-scope auth use it, not scopedWeave.)
The ways to run:
scopedWeave.runAs(scope, params, fn) // scoped for fn (sync or async); params typed & required
scopedWeave.runAs(publicScope, fn) // a scope with no params → no params object needed
scopedWeave.runAsGod(fn) // explicit full access for fn (a trusted admin route, or
// a deliberate cross-tenant op inside a scoped request)
scopedWeave.god // the shared god client (=== weave) — reach it from anywhererunAs returns whatever fn returns, and the scope propagates across every await
inside it. Runs nest: a runAsGod inside a runAs shadows the scope for its callback,
and the outer scope restores when it returns.
createScopedClient lives in the main @mauroandre/weave-sdk (it uses Node's
AsyncLocalStorage, like the rest of the server-side SDK). The explicit
weave.as(scope, params) stays available for one-off scoped calls.
Dispatching by principal — `dispatcher`
runAs(scopeFor(role), …) still leaves an if role === … somewhere. Move that into a
dispatch table — a file you own (never touched by weave gen) that maps each scope to
when it applies and how to read its params from your authenticated principal:
// app/access.ts — your file, typed against your own principal
import { scopedWeave } from "./weave/index.js";
import { admin, consultant, master } from "./weave/scopes/index.js";
import type { UserToken } from "./auth.js";
export const runInScope = scopedWeave.dispatcher<UserToken>([
{ scope: admin, when: (p) => p.role === "admin", params: (p) => ({ companyId: p.companyId }) },
{ scope: consultant, when: (p) => p.role === "consultant", params: (p) => ({ userId: p.id }) },
{ scope: master, when: (p) => p.role === "master" },
]);
// middleware — no if-chain, ever again:
app.use((ctx, next) => runInScope(ctx.user, () => next()));dispatcher returns a callable (principal, fn) that runs fn under the first scope
whose when(principal) is true, with params(principal) extracted. First-match by order —
list the most specific first, a broader rule as a fallback (a department rule above the
plain admin wins for a department user, without touching the admin rule). No rule matches →
deny (WeaveScopeError) — the same fail-closed as being outside a runAs.
when / params are pure and synchronous over the principal in memory — no I/O; they run
on every request. Anything not on the token (a user's department ids, say) belongs on the
principal at authentication, not inside when.
The table lives in your code on purpose: when/params are client-side dispatch, not scope
rules — they're never pushed, so they must stay out of weave/scopes/* (which weave gen
overwrites from the server). defineScope stays defineScope(name, rules), fully regenerable.