You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/active/docs/perm/+page.svelte

1039 lines
32 KiB

<script lang="ts">
import CodeBlock from '../../_components/CodeBlock.svelte';
import Callout from '../../_components/Callout.svelte';
import AiAgentsBox from '../../_components/AiAgentsBox.svelte';
import ModuleHeader from '../../_components/ModuleHeader.svelte';
import PageNav from '../../_components/PageNav.svelte';
const publicSurface = `// Server authority
import {
createEnginePerms,
createPermHttpHandlers,
PERM_SQL_SCHEMA_MODEL,
type PermPolicyDbRow,
type PermRelationDbRow
} from '$svrs/perm';
// Shared policy language and active client
import {
createActivePerms,
definePermSchema,
definePolicies,
allow,
deny,
and,
or,
not,
attr,
actor,
resource,
ctx,
rel,
mask,
redact,
audit,
requireMfa,
createSqlCompiler
} from '$perm';
// UI helper is a component file, not a barrel export.
import Can from '$perm/Can.svelte';`;
const schemaExample = `const schema = definePermSchema({
actors: {
user: {
attributes: {
status: 'string',
role: 'string',
teamIds: 'string[]'
}
}
},
resources: {
post: {
actions: ['read', 'update', 'publish', 'delete'],
attributes: {
visibility: 'string',
status: 'string',
ownerId: 'string',
teamId: 'string'
}
}
},
relations: {
'post.owner': { from: 'post', to: 'user' },
'post.team.member': { from: 'post', to: 'user' },
'post.team.admin': { from: 'post', to: 'user' }
},
context: {
risk: { mfa: 'boolean' },
tenant: 'string'
}
});`;
const policiesExample = `const policies = definePolicies(schema, [
deny('post.*')
.id('post.deny.suspended')
.priority(1000)
.when(attr('actor.status').eq('suspended'))
.because('Suspended users cannot access posts', 'user_suspended'),
allow('post.read')
.id('post.read.public-or-member')
.when(or(
attr('post.visibility').eq('public'),
rel('post.team.member').is(actor())
))
.oblige(mask('internalNotes')),
allow('post.update')
.id('post.update.owner')
.when(and(
rel('post.owner').is(actor()),
attr('post.status').notEq('archived')
)),
allow('post.publish')
.id('post.publish.admin-with-mfa')
.when(and(
rel('post.team.admin').is(actor()),
attr('context.risk.mfa').eq(true)
))
.oblige(audit('post.publish', 'medium'))
]);`;
const databasePersistenceRows = [
{
item: 'PermSchema',
persistedAs: 'Code/config, or JSON if the app wants tenant-specific vocabularies.',
runtimeUse:
'Defines the authorization vocabulary: actor/resource types, attributes, actions and relations.'
},
{
item: 'PolicyIR[]',
persistedAs: 'Usually code. Can be stored as validated JSON rows for admin-managed policies.',
runtimeUse: 'Loaded into createEnginePerms({ policies }) before evaluation.'
},
{
item: 'Actor attributes',
persistedAs: 'User/session tables, auth profile, role tables or derived server context.',
runtimeUse: 'Passed on the actor object or resolved lazily through providers.attributes.'
},
{
item: 'Resource attributes',
persistedAs: 'Domain tables such as posts, projects, invoices or documents.',
runtimeUse: 'Loaded server-side before check/assert, or compiled into list predicates.'
},
{
item: 'Relations',
persistedAs: 'Join tables or resource-specific relation tables.',
runtimeUse:
'Resolved through providers.relations and optionally compiled through createSqlCompiler().'
},
{
item: 'Active snapshots',
persistedAs: 'Optional SSR/client cache only.',
runtimeUse: 'Improves UX. It is never the security source of truth.'
}
] as const;
const databaseModel = `// App-owned persistence. perm does not force an ORM or table layout.
type DbPermPolicy = {
id: string;
tenantId: string;
version: number;
enabled: boolean;
policy: PolicyIR; // JSON column with the builder output, not a function.
updatedAt: Date;
};
type DbPost = {
id: string;
tenantId: string;
ownerId: string;
teamId: string;
visibility: 'public' | 'team' | 'private';
status: 'draft' | 'published' | 'archived';
internalNotes: string;
};
type DbPostTeamMember = {
postId: string;
userId: string;
role: 'member' | 'admin';
};`;
const serverSqlModel = `// Server-side model exported by $svrs/perm.
import {
PERM_SQL_SCHEMA_MODEL,
type PermPolicyDbRow,
type PermRelationDbRow,
type PermDecisionAuditDbRow
} from '$svrs/perm';
PERM_SQL_SCHEMA_MODEL.tables.POLICIES; // permission_policies
PERM_SQL_SCHEMA_MODEL.tables.RELATIONS; // permission_relations
PERM_SQL_SCHEMA_MODEL.tables.DECISION_AUDIT; // permission_decision_audit
// Reference migration:
// src/svrs/perm/sql/postgres.sql`;
const policyPersistenceExample = `// Static apps can keep policies in TypeScript.
// Dynamic/tenant apps can load validated PolicyIR rows from the database.
const rows = await db.permissionPolicy.findMany({
where: { tenantId, enabled: true },
orderBy: [{ version: 'desc' }, { id: 'asc' }]
});
const policies = rows.map((row) => row.policy);
const Perms = createEnginePerms({
schema,
policies,
providers: createPermProviders(db),
compilers: [createSqlCompiler({ relation: compileRelationForSql })],
logger: App.logger
});`;
const databaseProviders = `function createPermProviders(db): PermProviders {
return {
relations: {
async hasRelation({ relation, resource, subject }) {
if (relation === 'post.owner') {
return resource.ownerId === subject.id;
}
if (relation === 'post.team.member' || relation === 'post.team.admin') {
const requiredRole = relation === 'post.team.admin' ? 'admin' : undefined;
const row = await db.postTeamMember.findFirst({
where: {
postId: resource.id,
userId: subject.id,
...(requiredRole ? { role: requiredRole } : {})
}
});
return row ? true : false;
}
return 'unknown';
}
},
attributes: {
async getAttribute({ root, path, context }) {
if (root === 'actor' && path === 'teamIds') {
const rows = await db.teamMember.findMany({
where: { userId: context.actor.id }
});
return rows.map((row) => row.teamId);
}
return undefined;
}
}
};
}`;
const sqlFilterExample = `const compileRelationForSql = ({ relation, resourceAlias, actor, param }) => {
if (relation === 'post.owner') {
return resourceAlias + '.owner_id = ' + param(actor.id);
}
if (relation === 'post.team.member') {
return [
'EXISTS (',
'SELECT 1 FROM post_team_members ptm',
'WHERE ptm.post_id = ' + resourceAlias + '.id',
'AND ptm.user_id = ' + param(actor.id),
')'
].join(' ');
}
if (relation === 'post.team.admin') {
return [
'EXISTS (',
'SELECT 1 FROM post_team_members ptm',
'WHERE ptm.post_id = ' + resourceAlias + '.id',
'AND ptm.user_id = ' + param(actor.id),
"AND ptm.role = 'admin'",
')'
].join(' ');
}
};
const plan = await Perms
.filter('post.read')
.for(actor)
.resource('post')
.context({ tenant: tenantId, risk: { mfa: true } })
.toPlan('sql');
if (plan.strategy === 'not_compilable') {
throw error(500, 'Perm filter cannot be compiled safely');
}
const predicate = plan.predicate as SqlCompileResult;
// queryPostsWhere is app/ORM-specific. It must bind params, not interpolate values.
const rows = await queryPostsWhere({
tenantId,
whereSql: predicate.sql,
params: predicate.params
});
if (plan.strategy === 'partial') {
return asyncFilter(rows, (post) =>
Perms.can({ actor, action: 'post.read', resource: post, context })
);
}
return rows;`;
const serverListFlow = `export const load = async (event) => {
const actor = await resolveActorFromServerSession(event);
const tenantId = event.locals.tenant;
const context = {
tenant: tenantId,
risk: { mfa: event.locals.auth.aal !== 'aal1' }
};
const plan = await Perms
.filter('post.read')
.for(actor)
.resource('post')
.context(context)
.toPlan('sql');
return {
posts: await queryAuthorizedPosts(plan, { actor, tenantId, context })
};
};`;
const engineExample = `const Perms = createEnginePerms({
schema,
policies,
providers: {
relations: {
hasRelation({ relation, resource, subject }) {
if (relation === 'post.owner') {
return resource.ownerId === subject.id;
}
if (relation === 'post.team.member') {
return Array.isArray(subject.teamIds)
&& subject.teamIds.includes(resource.teamId);
}
return 'unknown';
}
}
},
compilers: [createSqlCompiler()],
logger: App.logger
});`;
const serverCheck = `const actor = await resolveActorFromServerSession(event);
const post = await loadPost(event.params.id);
await Perms.assert({
actor,
action: 'post.update',
resource: post,
context: {
tenant: event.locals.tenant,
risk: { mfa: event.locals.auth.aal !== 'aal1' }
}
});
return savePost(post, await event.request.json());`;
const handlersExample = `const handlers = createPermHttpHandlers(Perms, async (request) => {
const session = await readServerSession(request);
return {
type: 'user',
id: session.user.id,
status: session.user.status,
role: session.user.role,
teamIds: session.user.teamIds
};
});
// SvelteKit endpoint convention is up to the app.
// handlers.handle(request) dispatches by suffix:
// /check, /batch, /what, /explain`;
const activeClient = `const App = createActiveApp({
services: {
session: defineActiveSession({ /* ... */ }),
perm: defineActivePerm({
endpoint: '/api/perm',
initialSnapshot: data.permissions,
cacheTtlMs: 30_000,
nonAllowCacheTtlMs: 2_000,
remoteFailureBackoffMs: 1_000,
scopeKey: () => App.session?.current?.user?.id ?? 'anonymous'
})
}
});
const decision = await App.perm.check({
action: 'post.update',
resource: post,
context: { risk: { mfa: true } }
});
if (decision.effect === 'allow') {
showEditButton = true;
}`;
const canExample = `<script lang="ts">
import Can from '$perm/Can.svelte';
import { setPermsContext } from '$perm';
setPermsContext(App.perm);
<\/script>
<Can action="post.update" resource={post} optimistic={false}>
<button>Edit post</button>
{#snippet fallback()}
<span>You cannot edit this post.</span>
{/snippet}
{#snippet loading()}
<span>Checking...</span>
{/snippet}
</Can>`;
const whatExplainFilter = `const toolbar = await Perms.what({
actor,
resource: post,
actions: ['post.read', 'post.update', 'post.delete']
});
const explanation = await Perms.explain({
actor,
action: 'post.publish',
resource: post,
context: { risk: { mfa: false } }
});
const canReadPost = await Perms
.filter('post.read')
.for(actor)
.resource('post')
.context({ tenant: 'acme' })
.toPredicate();
const queryPlan = await Perms
.filter('post.read')
.for(actor)
.resource('post')
.toPlan('sql');`;
const runtimeFlow = `// 1. Resolve trusted actor from the server session.
const actor = await resolveActorFromServerSession(event);
// 2. Load or receive the protected resource on the server.
const post = await loadPost(event.params.id);
// 3. Ask the authoritative engine.
const decision = await Perms.check({
actor,
action: 'post.update',
resource: post,
context: {
tenant: event.locals.tenant,
risk: { mfa: event.locals.auth.aal !== 'aal1' }
}
});
// 4. Fail closed unless the effect is allow.
if (decision.effect !== 'allow') {
throw error(403, 'Forbidden');
}
// 5. Enforce obligations returned by allow decisions.
for (const obligation of decision.obligations ?? []) {
enforceObligation(obligation);
}`;
const commonMistakes = [
{
name: 'Trusting the browser actor',
why: 'A client can send any actor/resource payload it wants.',
fix: 'Resolve actor from server session/auth context and load protected resources server-side.'
},
{
name: 'Using <Can /> as security',
why: '<Can /> only hides or shows UI. It does not protect data or mutations.',
fix: 'Use <Can /> for UX and EnginePerms.assert()/check() for every protected server operation.'
},
{
name: 'Treating indeterminate as allow',
why: 'Provider failures, unknown relations or missing data become privilege escalation.',
fix: 'Fail closed: allow is the only successful effect for protected paths.'
},
{
name: 'Caching decisions without actor scope',
why: 'One user can inherit another user decision after login/logout in the same tab.',
fix: 'Provide scopeKey and invalidate on auth/session/role/resource changes.'
},
{
name: 'Ignoring obligations',
why: 'A decision may allow access only if masking, audit or MFA obligations are enforced.',
fix: 'Inspect decision.obligations and apply them before returning data.'
},
{
name: 'Assuming schema.relations.table queries the DB',
why: 'Relation metadata is vocabulary/config. The runtime only calls your relation provider or SQL relation compiler.',
fix: 'Implement providers.relations.hasRelation() and createSqlCompiler({ relation }) for list queries.'
},
{
name: 'Persisting builders instead of IR',
why: 'Fluent policy builders are code. Databases should store the resulting PolicyIR JSON if policies are dynamic.',
fix: 'Validate and load PolicyIR[] before createEnginePerms(). Keep migrations/versioning app-owned.'
},
{
name: 'Ignoring partial SQL plans',
why: 'A partial plan means some policies could not be translated to SQL. Returning rows directly can overexpose data.',
fix: 'Either fail closed or post-filter returned rows with Perms.can()/assert().'
}
] as const;
const aiAgentRows = [
{
step: 'Server authority',
where: 'src/svrs/perm',
rule: 'Protected routes, actions, jobs and realtime joins must call EnginePerms. The active client is only UX.'
},
{
step: 'Shared language',
where: 'src/libs/perm',
rule: 'Keep policy vocabulary, builders, effects and decision types independent from HTTP and UI.'
},
{
step: 'Client reflector',
where: 'src/arts/perm and src/arts/perm/Can.svelte',
rule: 'Do not add security guarantees to ActivePerms or Can. They can cache and render, not authorize.'
},
{
step: 'Scope and cache',
where: 'scopeKey, snapshot(), hydrate(), invalidate()',
rule: 'Include actor/session scope and invalidate after login, logout, role changes and resource mutations.'
},
{
step: 'Diagnostics',
where: 'src/arts/perm/consts.ts, src/svrs/perm/consts.ts and $libs/logger.Logger',
rule: 'Use constants for decision diagnostics and the shared Logger contract for emitted logs.'
},
{
step: 'Tests',
where: 'src/libs/perm/test, src/svrs/perm/test, src/arts/perm/test and /test/perm',
rule: 'Policy changes need engine tests; remote/client changes need handler and active tests.'
}
] as const;
</script>
<svelte:head>
<title>Perms ($perm) — Active</title>
</svelte:head>
<article class="article">
<ModuleHeader
section="Identity & Security"
title="Perms"
alias="$perm"
summary="Server-authoritative authorization runtime: policies, rich decisions, remote active client, snapshots, query helpers and the Can UI helper."
factories={[
'createEnginePerms',
'createActivePerms',
'createPermHttpHandlers'
]}
dependsOn={['$libs/perm', '$svrs/perm', '$http', '$logger']}
layer="EnginePerms (server) / ActivePerms (client)"
/>
<Callout variant="warn" title="Security boundary">
<p>
The server decides. <code>ActivePerms</code> and <code>&lt;Can /&gt;</code>
are UX helpers only. Every protected read, write, channel join or command must call the server engine.
</p>
</Callout>
<h2>Overview</h2>
<p>
<code>perm</code> is the authorization artifact. It does not log users in, does not own sessions
and does not replace <code>auth</code>. It answers one question:
<code>actor + action + resource + context -&gt; decision</code>.
</p>
<p>
The module combines RBAC, ABAC and ReBAC instead of forcing the app into one model: roles are
actor attributes, request/session/risk values live in context, and ownership or membership is
resolved through relation providers.
</p>
<h2>Mental model</h2>
<p>
Authorization is a server decision with a client mirror. The policy language in
<code>$libs/perm</code> defines what can be said, <code>$svrs/perm</code> evaluates it against
trusted actor/resource/context data, and <code>$perm</code> only mirrors decisions for UI
responsiveness. If a route, action, WebSocket channel or job touches protected data, the server
engine must decide again even if the button was hidden by
<code>&lt;Can /&gt;</code>.
</p>
<h2>Public surface</h2>
<CodeBlock code={publicSurface} lang="ts" />
<table>
<thead>
<tr>
<th>Factory / helper</th>
<th>Layer</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr
><td><code>createEnginePerms(options)</code></td><td>server</td><td
>Authoritative runtime with <code>check</code>, <code>assert</code>, <code>explain</code>,
<code>what</code>, <code>who</code> and <code>filter</code>.</td
></tr
>
<tr
><td><code>createPermHttpHandlers(engine, resolveActor)</code></td><td>server</td><td
>HTTP bridge for the active client: <code>check</code>, <code>batch</code>,
<code>what</code>, <code>explain</code> and <code>handle</code>.</td
></tr
>
<tr
><td><code>createActivePerms(options)</code></td><td>client</td><td
>Remote reflector with cache, snapshots, loading/error state and <code>can()</code>.</td
></tr
>
<tr
><td><code>defineActivePerm(options)</code></td><td>client</td><td
>Service factory for the App schema. Registered as <code>services.perm</code>; the builder
injects <code>Http</code>, <code>Logger</code> and <code>Bus</code>; endpoint remains explicit.</td
></tr
>
<tr
><td><code>Can.svelte</code></td><td>client UI</td><td
>Conditional rendering helper backed by <code>Perms.can(...)</code>.</td
></tr
>
</tbody>
</table>
<h2>API reference</h2>
<table>
<thead>
<tr>
<th>Server member</th>
<th>Purpose</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr
><td><code>check(input)</code></td><td>Return a rich permission decision.</td><td
>Decision effect is <code>allow</code>, <code>deny</code>, <code>not_applicable</code> or
<code>indeterminate</code>.</td
></tr
>
<tr
><td><code>can(input)</code></td><td>Boolean shortcut over <code>check()</code>.</td><td
>True only for allow.</td
></tr
>
<tr
><td><code>assert(input)</code></td><td>Fail-closed enforcement.</td><td
>Throws when decision is not allow.</td
></tr
>
<tr
><td><code>explain(input)</code></td><td>Decision trace.</td><td
>Use for audit/debug panels and policy authoring.</td
></tr
>
<tr
><td><code>what(input)</code></td><td>Action matrix for one actor/resource.</td><td
>Useful for toolbars and menus.</td
></tr
>
<tr
><td><code>who(input)</code></td><td>Actor discovery query.</td><td
>Provider-backed where supported.</td
></tr
>
<tr
><td><code>filter(action)</code></td><td>Build a predicate or query plan.</td><td
>Can target SQL through <code>createSqlCompiler()</code>.</td
></tr
>
<tr
><td><code>dispose()</code></td><td>Close runtime.</td><td
>Post-dispose calls throw <code>PermDisposedError</code>.</td
></tr
>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Active member</th>
<th>Purpose</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr
><td><code>currentSnapshot</code></td><td>Current serializable decision cache.</td><td
>Can seed SSR or restore known checks.</td
></tr
>
<tr
><td><code>decisions</code></td><td>Cached decision map.</td><td
>Scoped by <code>scopeKey</code> to avoid actor leakage.</td
></tr
>
<tr
><td><code>size</code></td><td>Number of cached decisions.</td><td>Debug/UI surface.</td
></tr
>
<tr
><td><code>loading / lastError / disposed</code></td><td>ActiveEngine state.</td><td
>Shared active contract.</td
></tr
>
<tr
><td><code>check()</code>, <code>can()</code>, <code>batch()</code></td><td
>Remote permission checks.</td
><td>Calls server handlers and caches responses.</td></tr
>
<tr
><td><code>what()</code>, <code>explain()</code></td><td>Remote inspection helpers.</td><td
>Still uses server truth.</td
></tr
>
<tr
><td><code>hydrate(snapshot)</code></td><td>Seed active cache.</td><td>Usually SSR.</td></tr
>
<tr
><td><code>snapshot()</code></td><td>Serialize active cache.</td><td
>Safe to send to client when actor-scoped correctly.</td
></tr
>
<tr
><td><code>invalidate(scope?)</code></td><td>Clear matching decision cache.</td><td
>Call after login/logout/role/resource changes.</td
></tr
>
<tr
><td><code>subscribe(fn)</code>, <code>onChange(fn)</code></td><td
>Observe cache/state changes.</td
><td>Use for panels and integrations.</td></tr
>
<tr
><td><code>decisionKey(input)</code></td><td>Compute cache key.</td><td
>Includes scope key in the active client.</td
></tr
>
<tr
><td><code>clearError()</code>, <code>dispose()</code></td><td>Lifecycle.</td><td
>Dispose clears listeners and rejects new calls.</td
></tr
>
</tbody>
</table>
<h2>Vocabulary schema</h2>
<p>
The schema is not a database schema. It is the vocabulary that policies are allowed to speak:
actor types, resources, actions, attributes, relations and context.
</p>
<CodeBlock code={schemaExample} lang="ts" title="definePermSchema()" />
<h2>Policies</h2>
<p>
Policies are built as data. The fluent builder produces an IR that can be evaluated, explained,
serialized and partially compiled.
</p>
<CodeBlock code={policiesExample} lang="ts" title="definePolicies()" />
<h2>Database integration</h2>
<p>
<code>perm</code> deliberately does not own the database schema. The application owns users,
resources, relation tables and policy storage; <code>perm</code> owns the authorization language and
the evaluation contract. This keeps it compatible with SQL, Prisma, Drizzle, Kysely, document databases
or hand-written repositories.
</p>
<p>
The important split is: persist domain truth in your database, load trusted actor/resource data
on the server, and expose unresolved facts through providers or query compilers.
</p>
<table>
<thead>
<tr>
<th>Thing</th>
<th>Where it lives</th>
<th>How perm uses it</th>
</tr>
</thead>
<tbody>
{#each databasePersistenceRows as row (row.item)}
<tr>
<td><code>{row.item}</code></td>
<td>{row.persistedAs}</td>
<td>{row.runtimeUse}</td>
</tr>
{/each}
</tbody>
</table>
<CodeBlock code={databaseModel} lang="ts" title="Example app-owned tables" />
<CodeBlock code={serverSqlModel} lang="ts" title="Server SQL model contract" />
<p>
The repository includes a PostgreSQL reference migration at
<code>src/svrs/perm/sql/postgres.sql</code>. It defines
<code>permission_policies</code>, <code>permission_relations</code> and
<code>permission_decision_audit</code>. Treat it as the supported starting point for a real
database model, then adapt naming, migrations and ORM mappings to the application.
</p>
<h2>Persisting policies</h2>
<p>
The default and simplest pattern is to keep policies in TypeScript. If a product needs
admin-managed or tenant-specific rules, store the produced <code>PolicyIR</code> as JSON,
validate it when loading, version it, and then pass the resulting array to
<code>createEnginePerms()</code>. Do not persist fluent builder calls or executable
functions.
</p>
<CodeBlock code={policyPersistenceExample} lang="ts" />
<h2>DB-backed providers</h2>
<p>
Relations and lazy attributes are where the engine talks to persistence. A relation name such as <code
>post.team.member</code
>
is not automatically queried just because it appears in the schema; the provider is the adapter
between policy vocabulary and database truth. Return <code>'unknown'</code> when the provider cannot
answer safely.
</p>
<CodeBlock code={databaseProviders} lang="ts" />
<h2>Querying lists with SQL filters</h2>
<p>
For detail routes, load the resource and call <code>assert()</code>. For list routes, avoid
loading every row and filtering in memory. Use <code>filter(...).toPlan('sql')</code>
to compile the portion of the policy graph that can run in the database. If the plan is
<code>partial</code>, either fail closed or post-filter the returned rows with the engine.
</p>
<CodeBlock code={sqlFilterExample} lang="ts" title="SQL query plan with residual enforcement" />
<CodeBlock code={serverListFlow} lang="ts" title="Server list route shape" />
<h2>Decision model</h2>
<table>
<thead>
<tr>
<th>Effect</th>
<th>Meaning</th>
<th>Security posture</th>
</tr>
</thead>
<tbody>
<tr
><td><code>allow</code></td><td>A matching allow policy granted access.</td><td
>May carry <code>ttl</code>, obligations and advice.</td
></tr
>
<tr
><td><code>deny</code></td><td>A matching deny policy rejected access.</td><td
>Deny overrides allow.</td
></tr
>
<tr
><td><code>indeterminate</code></td><td
>The runtime or a provider could not decide safely.</td
><td>Treat as denied unless a route deliberately chooses otherwise.</td></tr
>
<tr
><td><code>not_applicable</code></td><td>No policy matched.</td><td
>Fail closed on protected endpoints.</td
></tr
>
</tbody>
</table>
<h2>Server engine</h2>
<p>
Server code supplies providers for data that is not already present on actor, resource or
context. Providers should return <code>'unknown'</code> instead of guessing when they cannot resolve
safely.
</p>
<CodeBlock code={engineExample} lang="ts" title="Authoritative runtime" />
<h2>Server enforcement</h2>
<p>
Use <code>assert()</code> or inspect <code>check()</code> before touching protected data. Do this
in route handlers, actions, command handlers, jobs and realtime servers.
</p>
<CodeBlock code={serverCheck} lang="ts" />
<h2>Runtime flow</h2>
<p>
A safe permission check starts from server-trusted identity, loads the protected resource on the
server, evaluates the decision, fails closed for anything other than
<code>allow</code>, and then enforces any obligations attached to the decision.
</p>
<CodeBlock code={runtimeFlow} lang="ts" title="Fail-closed server flow" />
<h2>HTTP bridge</h2>
<p>
The active client talks to the server through permission handlers. The built-in
<code>handle()</code> dispatches <code>POST</code> requests whose path ends in
<code>/check</code>, <code>/batch</code>, <code>/what</code> or <code>/explain</code>.
</p>
<CodeBlock code={handlersExample} lang="ts" />
<h2>Active client</h2>
<p>
The active client caches remote decisions for first-paint UX and repeated toolbar/list checks.
It is not trusted by the server.
</p>
<CodeBlock code={activeClient} lang="ts" />
<table>
<thead>
<tr>
<th>Option</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr
><td><code>endpoint</code></td><td
>Base endpoint used by the remote client. Required unless provided through App options.</td
></tr
>
<tr><td><code>initialSnapshot</code></td><td>SSR/first-paint cache seed.</td></tr>
<tr
><td><code>cacheTtlMs</code></td><td
>Default TTL for allow decisions without a server TTL.</td
></tr
>
<tr
><td><code>nonAllowCacheTtlMs</code></td><td
>Shorter TTL for deny and not-applicable decisions.</td
></tr
>
<tr
><td><code>remoteFailureBackoffMs</code></td><td
>Brief backoff for remote failures to avoid retry storms.</td
></tr
>
<tr
><td><code>scopeKey</code></td><td
>Actor/session key used to avoid leaking cached decisions between users in one tab.</td
></tr
>
</tbody>
</table>
<h2>Snapshots and cache</h2>
<p>
<code>Perms.snapshot()</code> returns the serializable client cache. Use
<code>hydrate(snapshot)</code> for SSR or to restore a known decision set, and call
<code>invalidate(scope?)</code> after login, logout, role changes, session refreshes or resource mutations.
</p>
<CodeBlock
lang="ts"
code={`interface PermSnapshot {
actor?: SubjectRef;
version?: string;
decisions?: Record<string, PermDecision>;
global?: Record<string, boolean | PermDecision>;
expiresAt?: string;
}`}
/>
<h2>Can component</h2>
<p>
<code>&lt;Can /&gt;</code> renders children only when <code>Perms.can(...)</code>
resolves to true. It reads the active client from Svelte context via
<code>setPermsContext()</code>.
</p>
<CodeBlock code={canExample} lang="svelte" />
<h2>what(), explain() and filter()</h2>
<p>
Use <code>what()</code> when a screen needs an action matrix for one resource, use
<code>explain()</code> for audit/debug panels, and use <code>filter()</code> to turn authorization
into a list predicate or query plan.
</p>
<CodeBlock code={whatExplainFilter} lang="ts" />
<h2>Obligations and advice</h2>
<p>
An <code>allow</code> can carry obligations such as <code>mask()</code>,
<code>redact()</code>, <code>audit()</code> or <code>requireMfa()</code>. The decision returns
them; the caller must enforce them.
</p>
<h2>Integration rules</h2>
<ul>
<li><code>auth</code> proves identity; <code>perm</code> decides access.</li>
<li>
<code>$session</code> is where the actor comes from; never trust an actor sent by the browser.
</li>
<li><code>http</code> is the active client's transport when composed through App.</li>
<li>
<code>$cache</code> and permission snapshots must be invalidated after actor or permission changes.
</li>
<li>
<code>$connection</code> servers should call the engine before channel joins or privileged messages.
</li>
<li><code>$logger</code> receives decision, deny and indeterminate diagnostics when injected.</li>
</ul>
<h2>Common mistakes</h2>
<table>
<thead>
<tr>
<th>Mistake</th>
<th>Why it hurts</th>
<th>Correct pattern</th>
</tr>
</thead>
<tbody>
{#each commonMistakes as mistake (mistake.name)}
<tr>
<td><code>{mistake.name}</code></td>
<td>{mistake.why}</td>
<td>{mistake.fix}</td>
</tr>
{/each}
</tbody>
</table>
<h2>Testing</h2>
<p>
Unit-test policy behavior directly against <code>createEnginePerms()</code>, then test
remote behavior through <code>createPermHttpHandlers()</code> and
<code>createActivePerms()</code>. The interactive page is
<a href="/test/perm">/test/perm</a>.
</p>
<AiAgentsBox
intro="Before editing $perm, separate policy language, server enforcement and client rendering. Never move authority to the browser."
rows={aiAgentRows}
/>
<PageNav />
</article>

Powered by TurnKey Linux.