Skip to content

v3.21.0

Choose a tag to compare

@cldmv-bot cldmv-bot released this 28 Sep 04:04
· 2 commits to master since this release
v3.21.0
bfdfb97

release: v3.21.0 - named, module-owned principals for sync conditions

Slothlet v3.21.0 Changelog

Release Date: September 2026
Release Type: Minor
Branch: release/3.21.0


Overview

Version 3.21.0 adds principals to the permission system. A principal is a named, module-owned resolver that turns a caller identity into authorization facts, such as a user's per-project roles or an org's plan. Slothlet resolves it asynchronously, caches it per identity, and passes it read-only to the synchronous conditions of the rules that declare they need it (requires: ["roles"] → condition(ctx, { args, target, principals })). Per-resource gates that depend on facts only available asynchronously no longer need hand-rolled caching or context injection at every call site.

The release also makes api.slothlet.metadata.caller() / .self() reachable under defaultPolicy: "deny" without a user rule, and lets an instance start with no base directory and be built entirely through api.slothlet.api.add(). It fixes three bugs:

  • a lazy api.add() could resolve before its mount had finished materializing;
  • a module exporting name/length/… was shadowed by the wrapper;
  • the TypeScript temp-dir fallback left an empty directory behind in the system temp directory.

Everything is backward-compatible. The one visible behavior change is that a synchronous condition returning a thenable is now a non-match instead of failing open.


✨ Features

Principals — async authorization facts for sync conditions (#459)

A rule condition decides a single call synchronously, but real per-resource gates usually need facts about the caller that are only available asynchronously. A principal supplies them:

// Inside the roles module (e.g. its initialize routine)
self.slothlet.permissions.principal.register("roles", {
	key: (ctx) => ctx.user?.id, // identity key for this principal (sync)
	resolve: async (userId) => loadRoles(userId), // facts for that identity (may be async)
	maxAge: 30_000 // optional
});

// A rule declares the principals it needs; the condition receives them
{
	caller: "client.**",
	target: "project.files.list",
	effect: "allow",
	requires: ["roles"],
	condition: (ctx, { args, principals }) => principals.roles.projects[args[0]]?.includes("read") === true
}
  • Management surface: api.slothlet.permissions.principal.register / unregister / invalidate. It is host-only by default, through a built-in deny on slothlet.permissions.principal.**; the host grants it to the modules that should define principals. The first registrant owns a name, so another module can't replace its resolver (PRINCIPAL_NAME_OWNED / PRINCIPAL_NOT_OWNER). A module's resolver runs as that module.
  • Fail closed: a rule whose required principal is unregistered, dormant, has no identity for the call, or isn't current (and the call can't wait) doesn't match. The call falls through to other rules and defaultPolicy. DEBUG_PERMISSION_PRINCIPAL_UNAVAILABLE records which principal was unavailable and why.
  • Lazy, per-principal resolution with a sync fast path: calls whose principals are current stay synchronous. When a principal is stale, only that call waits, and only for the stale principal. It is enforced against gate inputs captured before the resolve, then runs. Sites that can't wait (construct, read gating, hooks, events) treat a stale principal as a non-match.
  • Caching: results are cached per identity, with an epoch and an optional maxAge. invalidate works per identity or for a whole principal, including after seal(). A resolve that an invalidation overtakes is discarded.
  • Read-only values: conditions see recursive read-only views (PRINCIPAL_READ_ONLY on write).
  • Lifecycle:
    • Removing the owning module removes its principals.
    • Reloading the owner makes them dormant (name kept, no resolver) until it registers them again.
    • A full reload replays host registrations and keeps module-owned names reserved.
    • seal() freezes register/unregister.
  • Safety fix: a synchronous condition that returns a thenable is now a non-match. Previously an allow rule with such a condition failed open.

Implemented in #469. See the new Principals section in docs/PERMISSIONS.md.

slothlet.metadata.caller / metadata.self allowed by default (#468)

Under defaultPolicy: "deny", the only built-in allows on the slothlet.* surface were slothlet.lockCaller and slothlet.bind. A module calling self.slothlet.metadata.caller() or .self(), the documented way to authorize or scope by caller, was denied unless every host re-added the same rule. Both now have built-in allow rules. They reveal identity only and grant no data or control access. slothlet.metadata.get(path) stays gated, and a user rule of equal or higher specificity still overrides the built-ins. Implemented in #473.


🐛 Bug Fixes

An instance no longer requires a base directory (#471)

api.slothlet.api.add() was always meant to build a tree from scratch, but base was required, so an instance built purely at runtime had to point at a placeholder directory. With base omitted, or base: null, an instance in node or browser mode now composes an empty root with no WARN_DIRECTORY_EMPTY, and is built through api.add(): directory, file and in-memory { exports } forms, at a path or at the root. Permissions, removal, full reload() and scoped api.reload() work as usual; reload replays what api.add() built.

A base that is given but unusable still fails loudly:

  • base: "" throws INVALID_CONFIG_DIR_MISSING.
  • A path that doesn't exist still throws.
  • An existing but empty directory still warns.
  • In browser mode, a manifest listing entries without a base throws, because those entries can't be resolved without a base URL.

Browser mode requires a manifest only when a base is given. Fixed in #482.

Exports named name / length / … are reachable and read-gated (#475)

The wrapper answered name (from the api path), length (the impl's arity), toString, valueOf, toJSON and constructor itself, before looking at the module's members. A module exporting one of those names was therefore unreachable; every @cldmv/rummage extension exports name, and self.owner.name returned "owner". The read also bypassed permission enforcement.

When a module exports one of those names, the read now returns the export and is gated like any other member. Modules without such an export keep the wrapper's answers, function impls are unaffected, and then stays reserved. An unloaded lazy module has no members yet, so the wrapper keeps answering until the module loads; docs/MODULE-STRUCTURE.md documents reading such an export after load. Fixed in #478.

Lazy api.add() resolves with its mount fully built (#462)

Ownership registration walked a freshly added subtree through the wrappers' proxies. The proxies' get trap starts a fire-and-forget materialization on every lazy child it hands out, so a lazy-mode api.add() could resolve while the mount's top-level wrappers were still materializing. What it returned depended on timing. Registration now bypasses the proxy only for unmaterialized lazy children, which register their own descendants when they materialize. Ownership records match eager mode exactly, and lazy add() no longer loads the mount's nested folders early. Fixed in #470.

TypeScript temp-dir fallback no longer leaks directories (#465)

When a .ts/.mts file has no package root above it, the transform cache lives under a private mkdtemp root in the system temp directory. shutdown() removed the instance's cache directory inside it but never the root, so every process that hit the fallback left an empty slothlet-XXXXXX directory behind. The root is now PID-named (slothlet-<pid>-XXXXXX) and removed once no instance in the process still uses it. A process's first fallback use also sweeps this user's roots whose PID is dead. Symlinks and other users' entries are never touched. Fixed in #472.


🔧 CI & tooling

  • API structure debug checks run in CI — npm test now runs npm run debug, the eager/lazy composition parity checks, after the vitest and node-native suites. They had run only in the local precommit, which is why #462 went unnoticed from v3.16.0 to v3.20.0 (#470).
  • v4 workflows synced with the CLDMV/.github v4.29.0 templates, keeping slothlet's repo-specific settings. Added the missing dependabot-recreate, member-auto-merge, pr-notify and provenance workflows, and grouped Dependabot PRs (#480).
  • Test scratch kept out of the system temp directory — all test scratch now lives under tmp/test-fixtures/ and is swept once its owning process exits. A chokidar fixture that watched the whole system temp directory, stalling full runs for 20+ minutes, now watches its own folder (#464, #466).
  • Setup-hook timeouts raised to 60s in eight load-sensitive suites whose beforeEach exceeded the default 10s on a heavily loaded machine. Assertions are unchanged (#474, #476).
  • File headers added or repaired across 57 files with npm run fix:headers. Headers only (#463).
  • LICENSE restored to the verbatim Apache-2.0 text; section 6 was missing a phrase (#479).

📚 Documentation


🔧 Dependencies

No dependency updates.


Upgrade notes

This is a drop-in upgrade for v3.20.x.

  • Principals are opt-in: rules without requires and existing conditions behave as before.
  • If a synchronous condition returned a Promise and relied on an allow rule matching, it no longer matches. It previously failed open, so declare a principal for the async part instead.
  • A module that exports name, length, toString, valueOf, toJSON or constructor now gets its export (read-gated) instead of the wrapper's answer. Code that read api.x.name expecting the api path of such a module should read the path from metadata instead.
  • Configs that previously passed a placeholder base only to build through api.add() can drop it.

coverage

Metric Coverage
Statements 99.9%
Branches 99.8%
Functions 100.0%
Lines 99.9%

Avg: 99.9% · 2c158f4 · Node lts/*

👥 Contributors