Scopes

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 WhereInput you query with. It filters which rows are visible (read / update-target / delete-target) and what a write may produce: a create, update or accumulate whose 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 — include or exclude a 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 read is a 403, 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 inactive

To 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 required

Acting 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 customer

weave.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-closed
import { 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 anywhere

runAs 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.