v3.4.0
release: v3.4.0 (#84)
Slothlet v3.4.0 Changelog
Release Date: May 2026
Release Type: Minor
Branch: release/v3.4.0
Overview
Version 3.4.0 extends the Permission System with context-conditional rule evaluation — an optional condition field on permission rules that is evaluated against the per-request ALS context at enforcement time.
This enables per-request routing scenarios where the same caller/target pair should be allowed or denied based on runtime context values (service level, role, domain, etc.), not just which module made the call. A condition may be a plain object (every leaf key must match via strict equality, including nested objects), a function (called with the runtime context, truthy return = match), or an array of any mix of the above — where any single entry matching is sufficient (OR semantics).
Conditional rules are never cached — the permission manager detects hasConditionalRules and skips the #resolvedCache write for that evaluation. Rules without condition behave identically to before, and their cache behavior is unchanged.
No breaking changes. All v3.3.x configuration and API usage is fully compatible.
🚀 New Features
Context-Conditional Permission Rules
Add an optional condition field to any permission rule. It is evaluated against the current per-request context at the moment the permission check fires.
Plain-object condition
All keys in the condition object must match the runtime context by strict equality (===). Extra keys in the context are ignored. Nesting is supported — nested objects are recursed into, and every leaf must match.
const api = await slothlet({
dir: "./api",
permissions: {
defaultPolicy: "deny",
rules: [
// Only allow premium-service requests
{ caller: "callers.**", target: "payments.**", effect: "allow", condition: { service: "premium" } },
// Explicitly deny free-service requests
{ caller: "callers.**", target: "payments.**", effect: "deny", condition: { service: "free" } }
]
}
});
// Allow: context matches condition
await api.slothlet.context.run({ service: "premium" }, async () => {
await api.callers.paymentsCaller.callCharge(100); // ✅ allowed
});
// Deny: context matches the deny condition
await api.slothlet.context.run({ service: "free" }, async () => {
await api.callers.paymentsCaller.callCharge(100); // ❌ denied
});
// Deny: no context.run(), condition does not match
await api.callers.paymentsCaller.callCharge(100); // ❌ denied (condition not met)Function condition
The function is called with the current runtime context (or {} if no context is active). A truthy return value means the condition matches. Exceptions thrown by the function are caught and treated as non-match (not as an implicit allow or deny).
const api = await slothlet({
dir: "./api",
permissions: {
defaultPolicy: "deny",
rules: [
{
caller: "callers.**",
target: "admin.**",
effect: "allow",
condition: (ctx) => ctx.role === "admin"
}
]
}
});
await api.slothlet.context.run({ role: "admin" }, async () => {
await api.callers.adminCaller.manage(); // ✅ allowed
});
await api.slothlet.context.run({ role: "guest" }, async () => {
await api.callers.adminCaller.manage(); // ❌ denied (condition returned false)
});Array condition (OR semantics)
Pass an array of conditions. The rule matches when any one entry in the array matches — short-circuit evaluation stops at the first match.
// Allow if the request is either premium-service OR an admin role
{
caller: "callers.**",
target: "payments.**",
effect: "allow",
condition: [
{ service: "premium" },
(ctx) => ctx.role === "admin"
]
}Nested object condition
Objects are matched recursively — every leaf key in the pattern must be present with an identical value in the corresponding context sub-object:
// Matches: { user: { role: "admin", active: true, extra: "ignored" } }
// Non-match: { user: { role: "admin", active: false } }
{
condition: { user: { role: "admin", active: true } }
}Validation
Invalid condition values throw INVALID_PERMISSION_RULE with reason: "PERM_RULE_CONDITION_INVALID":
// Throws: rule.condition must be a plain object, a function,
// or an array where each entry is a plain object or function
api.slothlet.permissions.addRule({
caller: "**", target: "payments.**", effect: "allow", condition: "bad"
});
// Also throws: empty array is rejected
api.slothlet.permissions.addRule({ ..., condition: [] });
// Also throws (invalid example): array containing a primitive entry
api.slothlet.permissions.addRule({ ..., condition: [{ role: "admin" }, 42] });Key behaviors
- Condition non-match — when a conditional rule's condition does not match, it is treated as if that rule were absent. Other matching rules are still evaluated normally.
- No context active — if no
context.run()is active, the runtime context isnull. The condition receives{}(empty object). Object conditions with any required key will not match; function conditions receive{}. - Array = OR — for array conditions, any single matching entry is sufficient. Evaluation short-circuits at the first match.
- Deep object matching — object patterns are recursed into. Every leaf must match by strict
===. Extra keys in the context at any level are ignored. - Throws = non-match — if a function condition throws, it counts as non-match (including when the function is one entry in an array condition). The error is silently swallowed.
- Cache safety — evaluations where any candidate rule carries a
conditionskip the#resolvedCachewrite entirely. Evaluations with only unconditional rules continue to be cached as before. - Unconditional rules always apply — rules without a
conditionfield are unaffected by context and always participate in evaluation regardless of what is or isn't incontext.run(). - Backward compatibility — existing rules without
conditionare unaffected. Addingcondition: nullor omittingconditionis equivalent.
Rule serialization includes condition
api.slothlet.permissions.global.rulesForPath(targetPath) now includes condition in each serialized rule object (null when absent).
Audit event: conditionMatched field
permission:allowed and permission:denied lifecycle event payloads now include a conditionMatched boolean — true when the winning rule had a condition field, false otherwise.
api.slothlet.lifecycle.on("permission:allowed", (data) => {
console.log(data.conditionMatched); // true | false
});api.add shorthand: condition support
The permissions shorthand in api.slothlet.api.add() options now accepts entries as { target, condition } objects in addition to plain strings:
api.slothlet.api.add("./new-module.mjs", {
permissions: {
allow: [
"admin.**", // plain string (unchanged)
{ target: "payments.**", condition: { role: "billing" } } // object with condition
],
deny: ["untrusted.**"]
}
});📋 i18n
Added 1 new translation key across all 11 language files:
PERM_RULE_CONDITION_INVALID:"rule.condition must be a plain object, a function, or an array where each entry is a plain object or function"
🧪 Tests
Added 1 new test file: tests/vitests/suites/permissions/permissions-context-condition.test.vitest.mjs
Covers 25 scenarios across all 8 matrix configs (200 tests total):
- Object condition match → allow
- Object condition non-match → default deny
- Two rules with different conditions → per-request routing
- Function condition truthy → allow
- Function condition falsy → deny
- Function condition throws → non-match (not implicit allow)
- No
context.run()→ null runtime context → non-match - Invalid condition type (string) → throws
INVALID_PERMISSION_RULE - Conditional rules not cached (different context produces different result)
- Pure path rule still cached
- Backward compat (no condition → matches as before)
api.addshorthand with{ target, condition }objectapi.addshorthand mixed entries (plain string + object in same array)- Audit payload
conditionMatched: truewhen winning rule has a condition - Audit payload
conditionMatched: falsewhen winning rule has no condition - Array condition: first entry matches → allow
- Array condition: second entry matches (OR) → allow
- Array condition: no entry matches → default policy
- Array condition: function entry evaluated within array
- Nested object condition: all leaves match → allow
- Nested object condition: leaf mismatch at depth → non-match
- Nested object condition: missing nested key → non-match
- Array with non-object/non-function entry → throws
INVALID_PERMISSION_RULE - Empty array condition → throws
INVALID_PERMISSION_RULE - Rule without condition applies regardless of context
Full permissions regression suite: all tests pass.
📁 Modified Files
| File | Change |
|---|---|
src/lib/handlers/permission-manager.mjs |
#validateRule validates condition; addRule stores condition; new #conditionMatches() method; #evaluate() filters by condition and sets hasConditionalRules; checkAccess passes runtimeContext; #serializeRule includes condition |
src/lib/handlers/unified-wrapper.mjs |
applyTrap reads ctx?.context as runtimeContext and passes it to checkAccess |
src/lib/handlers/api-manager.mjs |
api.add permissions shorthand normalizes { target, condition } entries in allow/deny arrays |
src/lib/i18n/languages/*.json |
1 new key (PERM_RULE_CONDITION_INVALID) across all 11 language files |