Skip to content

v3.4.0

Choose a tag to compare

@cldmv-bot cldmv-bot released this 04 May 00:55
· 10 commits to master since this release
v3.4.0
4e125c8

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 is null. 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 condition skip the #resolvedCache write entirely. Evaluations with only unconditional rules continue to be cached as before.
  • Unconditional rules always apply — rules without a condition field are unaffected by context and always participate in evaluation regardless of what is or isn't in context.run().
  • Backward compatibility — existing rules without condition are unaffected. Adding condition: null or omitting condition is 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.add shorthand with { target, condition } object
  • api.add shorthand mixed entries (plain string + object in same array)
  • Audit payload conditionMatched: true when winning rule has a condition
  • Audit payload conditionMatched: false when 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